Compare commits
1 Commits
burn/274-1
...
burn/372-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412ee7329a |
36
.env.example
36
.env.example
@@ -257,42 +257,6 @@ BROWSER_INACTIVITY_TIMEOUT=120
|
||||
# TELEGRAM_WEBHOOK_PORT=8443
|
||||
# TELEGRAM_WEBHOOK_SECRET= # Recommended for production
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MATRIX INTEGRATION
|
||||
# =============================================================================
|
||||
# Matrix is the sovereign messaging protocol: federated, E2EE, no corporation.
|
||||
#
|
||||
# For fleet ops notifications (replacing Telegram), configure these variables.
|
||||
# See docs/matrix-setup.md for full setup guide.
|
||||
|
||||
# Required: Your homeserver URL
|
||||
# MATRIX_HOMESERVER=https://matrix-client.matrix.org # Public homeserver for testing
|
||||
# MATRIX_HOMESERVER=https://matrix.your.domain.com # Self-hosted (recommended)
|
||||
|
||||
# Required: Bot access token (get from Element: Settings -> Help & About -> Advanced)
|
||||
# MATRIX_ACCESS_TOKEN=***
|
||||
|
||||
# Required: Bot user ID
|
||||
# MATRIX_USER_ID=@hermes-bot:matrix.org
|
||||
|
||||
# Required: Device ID (from login response)
|
||||
# MATRIX_DEVICE_ID=HERMES_BOT
|
||||
|
||||
# Fleet ops channel: Where system notifications, cron results, and alerts go
|
||||
# Create a room in Element, invite the bot, then get the room ID from Room Settings
|
||||
# MATRIX_HOME_CHANNEL=!room-id:matrix.org
|
||||
# MATRIX_HOME_CHANNEL_NAME=Fleet Ops
|
||||
|
||||
# Crisis channel: For SOUL.md protocol and urgent alerts
|
||||
# MATRIX_CRISIS_CHANNEL=!crisis-room-id:matrix.org
|
||||
# MATRIX_CRISIS_CHANNEL_NAME=Crisis Room
|
||||
|
||||
# E2EE Support: Install matrix-nio with encryption support
|
||||
# pip install "matrix-nio[e2e]"
|
||||
# Requires libolm C library: brew install libolm (macOS) or apt install libolm-dev (Linux)
|
||||
|
||||
# =============================================================================
|
||||
# WhatsApp (built-in Baileys bridge — run `hermes whatsapp` to pair)
|
||||
# WHATSAPP_ENABLED=false
|
||||
# WHATSAPP_ALLOWED_USERS=15551234567
|
||||
|
||||
71
cron/jobs.py
71
cron/jobs.py
@@ -547,30 +547,20 @@ def resume_job(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
def trigger_job(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Schedule a job to run on the next scheduler tick.
|
||||
|
||||
Clears stale error state when re-triggering a previously-failed job
|
||||
so the stale failure doesn't persist until the next tick completes.
|
||||
"""
|
||||
"""Schedule a job to run on the next scheduler tick."""
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
return None
|
||||
|
||||
updates = {
|
||||
"enabled": True,
|
||||
"state": "scheduled",
|
||||
"paused_at": None,
|
||||
"paused_reason": None,
|
||||
"next_run_at": _hermes_now().isoformat(),
|
||||
}
|
||||
|
||||
# Clear stale error state when re-triggering
|
||||
if job.get("last_status") == "error":
|
||||
updates["last_status"] = "retrying"
|
||||
updates["last_error"] = None
|
||||
updates["error_cleared_at"] = _hermes_now().isoformat()
|
||||
|
||||
return update_job(job_id, updates)
|
||||
return update_job(
|
||||
job_id,
|
||||
{
|
||||
"enabled": True,
|
||||
"state": "scheduled",
|
||||
"paused_at": None,
|
||||
"paused_reason": None,
|
||||
"next_run_at": _hermes_now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def run_job_now(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -628,7 +618,6 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
|
||||
|
||||
Updates last_run_at, last_status, increments completed count,
|
||||
computes next_run_at, and auto-deletes if repeat limit reached.
|
||||
Tracks health timestamps for error/success history.
|
||||
"""
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
@@ -638,18 +627,6 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
|
||||
job["last_status"] = "ok" if success else "error"
|
||||
job["last_error"] = error if not success else None
|
||||
|
||||
# Track health timestamps
|
||||
if success:
|
||||
job["last_success_at"] = now
|
||||
# Clear stale error tracking on success
|
||||
if job.get("last_error_at"):
|
||||
job["error_resolved_at"] = now
|
||||
else:
|
||||
job["last_error_at"] = now
|
||||
# Clear resolved tracking on new error
|
||||
if job.get("error_resolved_at"):
|
||||
del job["error_resolved_at"]
|
||||
|
||||
# Increment completed count
|
||||
if job.get("repeat"):
|
||||
job["repeat"]["completed"] = job["repeat"].get("completed", 0) + 1
|
||||
@@ -679,32 +656,6 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
|
||||
save_jobs(jobs)
|
||||
|
||||
|
||||
|
||||
def clear_job_error(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Clear stale error state for a job.
|
||||
|
||||
Resets last_status to 'ok', last_error to None, and
|
||||
records when the error was cleared. Useful after auth
|
||||
recovery when the job itself is healthy but stale error
|
||||
state persists.
|
||||
|
||||
Returns:
|
||||
Updated job dict, or None if not found.
|
||||
"""
|
||||
jobs = load_jobs()
|
||||
for job in jobs:
|
||||
if job["id"] == job_id:
|
||||
job["last_status"] = "ok"
|
||||
job["last_error"] = None
|
||||
job["error_cleared_at"] = _hermes_now().isoformat()
|
||||
save_jobs(jobs)
|
||||
return job
|
||||
save_jobs(jobs)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def advance_next_run(job_id: str) -> bool:
|
||||
"""Preemptively advance next_run_at for a recurring job before execution.
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_time import now as _hermes_now
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -545,8 +544,78 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
return False, f"Script execution failed: {exc}"
|
||||
|
||||
|
||||
def _build_job_prompt(job: dict) -> str:
|
||||
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider mismatch detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROVIDER_ALIASES: dict[str, set[str]] = {
|
||||
"ollama": {"ollama", "local ollama", "localhost:11434"},
|
||||
"anthropic": {"anthropic", "claude", "sonnet", "opus", "haiku"},
|
||||
"nous": {"nous", "mimo", "nousresearch"},
|
||||
"openrouter": {"openrouter"},
|
||||
"kimi": {"kimi", "moonshot", "kimi-coding"},
|
||||
"zai": {"zai", "glm", "zhipu"},
|
||||
"openai": {"openai", "gpt", "codex"},
|
||||
"gemini": {"gemini", "google"},
|
||||
}
|
||||
|
||||
|
||||
def _classify_runtime(provider: str, model: str) -> str:
|
||||
"""Return 'local' | 'cloud' | 'unknown' for a provider/model pair."""
|
||||
p = (provider or "").strip().lower()
|
||||
m = (model or "").strip().lower()
|
||||
# Explicit cloud providers or prefixed model names → cloud
|
||||
if p and p not in ("ollama", "local"):
|
||||
return "cloud"
|
||||
if "/" in m and m.split("/")[0] in ("nous", "openrouter", "anthropic", "openai", "zai", "kimi", "gemini", "minimax"):
|
||||
return "cloud"
|
||||
# Ollama / local / empty provider with non-prefixed model → local
|
||||
if p in ("ollama", "local") or (not p and m):
|
||||
return "local"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _detect_provider_mismatch(prompt: str, active_provider: str) -> Optional[str]:
|
||||
"""Return the stale provider group referenced in *prompt*, or None."""
|
||||
if not active_provider or not prompt:
|
||||
return None
|
||||
prompt_lower = prompt.lower()
|
||||
active_lower = active_provider.lower().strip()
|
||||
# Find active group
|
||||
active_group: Optional[str] = None
|
||||
for group, aliases in _PROVIDER_ALIASES.items():
|
||||
if active_lower in aliases or active_lower.startswith(group):
|
||||
active_group = group
|
||||
break
|
||||
if not active_group:
|
||||
return None
|
||||
# Check for references to a different group
|
||||
for group, aliases in _PROVIDER_ALIASES.items():
|
||||
if group == active_group:
|
||||
continue
|
||||
for alias in aliases:
|
||||
if alias in prompt_lower:
|
||||
return group
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_job_prompt(
|
||||
job: dict,
|
||||
*,
|
||||
runtime_model: str = "",
|
||||
runtime_provider: str = "",
|
||||
) -> str:
|
||||
"""Build the effective prompt for a cron job.
|
||||
|
||||
Args:
|
||||
job: The cron job dict.
|
||||
runtime_model: Resolved model name (e.g. "xiaomi/mimo-v2-pro").
|
||||
runtime_provider: Resolved provider name (e.g. "nous", "openrouter").
|
||||
"""
|
||||
prompt = job.get("prompt", "")
|
||||
skills = job.get("skills")
|
||||
|
||||
@@ -578,6 +647,36 @@ def _build_job_prompt(job: dict) -> str:
|
||||
|
||||
# Always prepend cron execution guidance so the agent knows how
|
||||
# delivery works and can suppress delivery when appropriate.
|
||||
#
|
||||
# Runtime context injection — tells the agent what it can actually do.
|
||||
# Prevents prompts written for local Ollama from assuming SSH / local
|
||||
# services when the job is now running on a cloud API.
|
||||
_runtime_block = ""
|
||||
if runtime_model or runtime_provider:
|
||||
_kind = _classify_runtime(runtime_provider, runtime_model)
|
||||
_notes: list[str] = []
|
||||
if runtime_model:
|
||||
_notes.append(f"MODEL: {runtime_model}")
|
||||
if runtime_provider:
|
||||
_notes.append(f"PROVIDER: {runtime_provider}")
|
||||
if _kind == "local":
|
||||
_notes.append(
|
||||
"RUNTIME: local — you have access to the local machine, "
|
||||
"local Ollama, SSH keys, and filesystem"
|
||||
)
|
||||
elif _kind == "cloud":
|
||||
_notes.append(
|
||||
"RUNTIME: cloud API — you do NOT have local machine access. "
|
||||
"Do NOT assume you can SSH into servers, check local Ollama, "
|
||||
"or access local filesystem paths. Use terminal tools only "
|
||||
"for commands that work from this environment."
|
||||
)
|
||||
if _notes:
|
||||
_runtime_block = (
|
||||
"[SYSTEM: RUNTIME CONTEXT — "
|
||||
+ "; ".join(_notes)
|
||||
+ ". Adjust your approach based on these capabilities.]\\n\\n"
|
||||
)
|
||||
cron_hint = (
|
||||
"[SYSTEM: You are running as a scheduled cron job. "
|
||||
"DELIVERY: Your final response will be automatically delivered "
|
||||
@@ -597,7 +696,7 @@ def _build_job_prompt(job: dict) -> str:
|
||||
"\"[SCRIPT_FAILED]: forge.alexanderwhitestone.com timed out\" "
|
||||
"\"[SCRIPT_FAILED]: script exited with code 1\".]\\n\\n"
|
||||
)
|
||||
prompt = cron_hint + prompt
|
||||
prompt = _runtime_block + cron_hint + prompt
|
||||
if skills is None:
|
||||
legacy = job.get("skill")
|
||||
skills = [legacy] if legacy else []
|
||||
@@ -667,7 +766,36 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
|
||||
job_id = job["id"]
|
||||
job_name = job["name"]
|
||||
prompt = _build_job_prompt(job)
|
||||
|
||||
# ── Early model/provider resolution ───────────────────────────────────
|
||||
# We need the model name before building the prompt so the runtime
|
||||
# context block can be injected. Full provider resolution happens
|
||||
# later (smart routing, etc.) but the basic name is enough here.
|
||||
_early_model = job.get("model") or os.getenv("HERMES_MODEL") or ""
|
||||
_early_provider = os.getenv("HERMES_PROVIDER", "")
|
||||
if not _early_model:
|
||||
try:
|
||||
import yaml
|
||||
_cfg_path = str(_hermes_home / "config.yaml")
|
||||
if os.path.exists(_cfg_path):
|
||||
with open(_cfg_path) as _f:
|
||||
_cfg_early = yaml.safe_load(_f) or {}
|
||||
_mc = _cfg_early.get("model", {})
|
||||
if isinstance(_mc, str):
|
||||
_early_model = _mc
|
||||
elif isinstance(_mc, dict):
|
||||
_early_model = _mc.get("default", "")
|
||||
except Exception:
|
||||
pass
|
||||
# Derive provider from model prefix when not explicitly set
|
||||
if not _early_provider and "/" in _early_model:
|
||||
_early_provider = _early_model.split("/")[0]
|
||||
|
||||
prompt = _build_job_prompt(
|
||||
job,
|
||||
runtime_model=_early_model,
|
||||
runtime_provider=_early_provider,
|
||||
)
|
||||
origin = _resolve_origin(job)
|
||||
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
@@ -763,6 +891,20 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
message = format_runtime_provider_error(exc)
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
# ── Provider mismatch warning ─────────────────────────────────
|
||||
# If the job prompt references a provider different from the one
|
||||
# we actually resolved, warn so operators know which prompts are stale.
|
||||
_resolved_provider = runtime.get("provider", "") or ""
|
||||
_raw_prompt = job.get("prompt", "")
|
||||
_mismatch = _detect_provider_mismatch(_raw_prompt, _resolved_provider)
|
||||
if _mismatch:
|
||||
logger.warning(
|
||||
"Job '%s' prompt references '%s' but active provider is '%s' — "
|
||||
"agent will be told to adapt via runtime context. "
|
||||
"Consider updating this job's prompt.",
|
||||
job_name, _mismatch, _resolved_provider,
|
||||
)
|
||||
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
turn_route = resolve_turn_route(
|
||||
prompt,
|
||||
@@ -778,29 +920,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
},
|
||||
)
|
||||
|
||||
# Build disabled toolsets — always exclude cronjob/messaging/clarify
|
||||
# for cron sessions. When the runtime endpoint is cloud (not local),
|
||||
# also disable terminal so the agent does not attempt SSH or shell
|
||||
# commands that require local infrastructure (keys, filesystem).
|
||||
# Jobs that declare requires_local_infra=true also get terminal
|
||||
# disabled on cloud endpoints regardless of this check. #379
|
||||
_cron_disabled = ["cronjob", "messaging", "clarify"]
|
||||
_runtime_base_url = turn_route["runtime"].get("base_url", "")
|
||||
_is_cloud = not is_local_endpoint(_runtime_base_url)
|
||||
if _is_cloud:
|
||||
_cron_disabled.append("terminal")
|
||||
logger.info(
|
||||
"Job '%s': cloud provider detected (%s), disabling terminal toolset",
|
||||
job_name,
|
||||
turn_route["runtime"].get("provider", "unknown"),
|
||||
)
|
||||
if job.get("requires_local_infra") and _is_cloud:
|
||||
logger.warning(
|
||||
"Job '%s': requires_local_infra=true but running on cloud provider — "
|
||||
"terminal-dependent steps will fail gracefully",
|
||||
job_name,
|
||||
)
|
||||
|
||||
_agent_kwargs = _safe_agent_kwargs({
|
||||
"model": turn_route["model"],
|
||||
"api_key": turn_route["runtime"].get("api_key"),
|
||||
@@ -808,7 +927,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"provider": turn_route["runtime"].get("provider"),
|
||||
"api_mode": turn_route["runtime"].get("api_mode"),
|
||||
"acp_command": turn_route["runtime"].get("command"),
|
||||
"acp_args": list(turn_route["runtime"].get("args") or []),
|
||||
"acp_args": turn_route["runtime"].get("args"),
|
||||
"max_iterations": max_iterations,
|
||||
"reasoning_config": reasoning_config,
|
||||
"prefill_messages": prefill_messages,
|
||||
@@ -816,7 +935,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"providers_ignored": pr.get("ignore"),
|
||||
"providers_order": pr.get("order"),
|
||||
"provider_sort": pr.get("sort"),
|
||||
"disabled_toolsets": _cron_disabled,
|
||||
"disabled_toolsets": ["cronjob", "messaging", "clarify"],
|
||||
"tool_choice": "required",
|
||||
"quiet_mode": True,
|
||||
"skip_memory": True, # Cron system prompts would corrupt user representations
|
||||
|
||||
154
deploy-crons.py
154
deploy-crons.py
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
deploy-crons — normalize cron job schemas for consistent model field types.
|
||||
|
||||
This script ensures that the model field in jobs.json is always a dict when
|
||||
either model or provider is specified, preventing schema inconsistency.
|
||||
|
||||
Usage:
|
||||
python deploy-crons.py [--dry-run] [--jobs-file PATH]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def normalize_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Normalize a job dict to ensure consistent model field types.
|
||||
|
||||
Before normalization:
|
||||
- If model AND provider: model = raw string, provider = raw string (inconsistent)
|
||||
- If only model: model = raw string
|
||||
- If only provider: provider = raw string at top level
|
||||
|
||||
After normalization:
|
||||
- If model exists: model = {"model": "xxx"}
|
||||
- If provider exists: model = {"provider": "yyy"}
|
||||
- If both exist: model = {"model": "xxx", "provider": "yyy"}
|
||||
- If neither: model = None
|
||||
"""
|
||||
job = dict(job) # Create a copy to avoid modifying the original
|
||||
|
||||
model = job.get("model")
|
||||
provider = job.get("provider")
|
||||
|
||||
# Skip if already normalized (model is a dict)
|
||||
if isinstance(model, dict):
|
||||
return job
|
||||
|
||||
# Build normalized model dict
|
||||
model_dict = {}
|
||||
|
||||
if model is not None and isinstance(model, str):
|
||||
model_dict["model"] = model.strip()
|
||||
|
||||
if provider is not None and isinstance(provider, str):
|
||||
model_dict["provider"] = provider.strip()
|
||||
|
||||
# Set model field
|
||||
if model_dict:
|
||||
job["model"] = model_dict
|
||||
else:
|
||||
job["model"] = None
|
||||
|
||||
# Remove top-level provider field if it was moved into model dict
|
||||
if provider is not None and "provider" in model_dict:
|
||||
# Keep provider field for backward compatibility but mark it as deprecated
|
||||
# This allows existing code that reads job["provider"] to continue working
|
||||
pass
|
||||
|
||||
return job
|
||||
|
||||
|
||||
def normalize_jobs_file(jobs_file: Path, dry_run: bool = False) -> int:
|
||||
"""
|
||||
Normalize all jobs in a jobs.json file.
|
||||
|
||||
Returns the number of jobs that were modified.
|
||||
"""
|
||||
if not jobs_file.exists():
|
||||
print(f"Error: Jobs file not found: {jobs_file}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
with open(jobs_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in {jobs_file}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
jobs = data.get("jobs", [])
|
||||
if not jobs:
|
||||
print("No jobs found in file.")
|
||||
return 0
|
||||
|
||||
modified_count = 0
|
||||
for i, job in enumerate(jobs):
|
||||
original_model = job.get("model")
|
||||
original_provider = job.get("provider")
|
||||
|
||||
normalized_job = normalize_job(job)
|
||||
|
||||
# Check if anything changed
|
||||
if (normalized_job.get("model") != original_model or
|
||||
normalized_job.get("provider") != original_provider):
|
||||
jobs[i] = normalized_job
|
||||
modified_count += 1
|
||||
|
||||
job_id = job.get("id", "?")
|
||||
job_name = job.get("name", "(unnamed)")
|
||||
print(f"Normalized job {job_id} ({job_name}):")
|
||||
print(f" model: {original_model!r} -> {normalized_job.get('model')!r}")
|
||||
print(f" provider: {original_provider!r} -> {normalized_job.get('provider')!r}")
|
||||
|
||||
if modified_count == 0:
|
||||
print("All jobs already have consistent model field types.")
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
print(f"DRY RUN: Would normalize {modified_count} jobs.")
|
||||
return 0
|
||||
|
||||
# Write back to file
|
||||
data["jobs"] = jobs
|
||||
try:
|
||||
with open(jobs_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
print(f"Normalized {modified_count} jobs in {jobs_file}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error writing to {jobs_file}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Normalize cron job schemas for consistent model field types."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be changed without modifying the file."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jobs-file",
|
||||
type=Path,
|
||||
default=Path.home() / ".hermes" / "cron" / "jobs.json",
|
||||
help="Path to jobs.json file (default: ~/.hermes/cron/jobs.json)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.dry_run:
|
||||
print("DRY RUN MODE — no changes will be made.")
|
||||
print()
|
||||
|
||||
return normalize_jobs_file(args.jobs_file, args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,170 +0,0 @@
|
||||
# Honcho Memory Integration Evaluation (#322)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Status:** Integration already implemented and production-ready.
|
||||
**Recommendation:** KEEP — well-gated, zero overhead when disabled, supports self-hosted.
|
||||
|
||||
## Decision: Cloud vs Local
|
||||
|
||||
### The Question
|
||||
"Do we want a cloud-dependent memory layer, or keep everything local?"
|
||||
|
||||
### Answer: BOTH — User's Choice
|
||||
|
||||
Honcho supports both deployment modes:
|
||||
|
||||
| Mode | Configuration | Data Location | Use Case |
|
||||
|------|--------------|---------------|----------|
|
||||
| Cloud | `HONCHO_API_KEY` | Honcho servers | Quick start, no infrastructure |
|
||||
| Self-hosted | `HONCHO_BASE_URL=http://localhost:8000` | Your servers | Full sovereignty |
|
||||
| Disabled | No config | N/A | Pure local (holographic fact_store only) |
|
||||
|
||||
### Why Keep It
|
||||
|
||||
1. **Opt-in Architecture**
|
||||
- No Honcho config → zero overhead (cron guard, lazy init)
|
||||
- Memory provider system allows switching between providers
|
||||
- `hermes memory off` disables completely
|
||||
|
||||
2. **Zero Runtime Cost When Disabled**
|
||||
```python
|
||||
if not cfg.enabled or not (cfg.api_key or cfg.base_url):
|
||||
return "" # No HTTP calls, no overhead
|
||||
```
|
||||
|
||||
3. **Cross-Session User Modeling**
|
||||
- Holographic fact_store lacks persistent user modeling
|
||||
- Honcho provides: peer cards, dialectic Q&A, semantic search
|
||||
- Complements (not replaces) local memory
|
||||
|
||||
4. **Self-Hosted Option**
|
||||
- Set `HONCHO_BASE_URL=http://localhost:8000`
|
||||
- Run Honcho server locally via Docker
|
||||
- Full data sovereignty
|
||||
|
||||
5. **Production-Grade Implementation**
|
||||
- 3 components, ~700 lines of code
|
||||
- 7 tests passing
|
||||
- Async prefetch (zero-latency context injection)
|
||||
- Configurable recall modes (hybrid/context/tools)
|
||||
- Write frequency control (async/turn/session/N-turns)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components (Already Implemented)
|
||||
|
||||
```
|
||||
plugins/memory/honcho/
|
||||
├── client.py # Config resolution (API key, base_url, profiles)
|
||||
├── session.py # Session management, async prefetch, dialectic queries
|
||||
├── __init__.py # MemoryProvider interface, 4 tool schemas
|
||||
├── cli.py # CLI commands (setup, status, sessions, map, peer, mode)
|
||||
├── plugin.yaml # Plugin metadata
|
||||
└── README.md # Documentation
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **System Prompt**: Context injected on first turn (cached for prompt caching)
|
||||
2. **Tool Registry**: 4 tools available when `recall_mode != "context"`
|
||||
3. **Session End**: Messages flushed to Honcho
|
||||
4. **Cron Guard**: Fully inactive in cron context
|
||||
|
||||
### Tools Available
|
||||
|
||||
| Tool | Cost | Speed | Purpose |
|
||||
|------|------|-------|---------|
|
||||
| `honcho_profile` | Free | Fast | Quick factual snapshot (peer card) |
|
||||
| `honcho_search` | Free | Fast | Semantic search (raw excerpts) |
|
||||
| `honcho_context` | Paid | Slow | Dialectic Q&A (synthesized answers) |
|
||||
| `honcho_conclude` | Free | Fast | Save persistent facts about user |
|
||||
|
||||
## Configuration Guide
|
||||
|
||||
### Option 1: Cloud (Quick Start)
|
||||
```bash
|
||||
# Get API key from https://app.honcho.dev
|
||||
export HONCHO_API_KEY="your-api-key"
|
||||
hermes chat
|
||||
```
|
||||
|
||||
### Option 2: Self-Hosted (Full Sovereignty)
|
||||
```bash
|
||||
# Run Honcho server locally
|
||||
docker run -p 8000:8000 honcho/server
|
||||
|
||||
# Configure Hermes
|
||||
export HONCHO_BASE_URL="http://localhost:8000"
|
||||
hermes chat
|
||||
```
|
||||
|
||||
### Option 3: CLI Setup
|
||||
```bash
|
||||
hermes honcho setup
|
||||
```
|
||||
|
||||
### Option 4: Disabled (Pure Local)
|
||||
```bash
|
||||
# Don't set any Honcho config
|
||||
hermes memory off # If previously enabled
|
||||
hermes chat
|
||||
```
|
||||
|
||||
## Memory Modes
|
||||
|
||||
| Mode | Context Injection | Tools | Cost | Use Case |
|
||||
|------|------------------|-------|------|----------|
|
||||
| hybrid | Yes | Yes | Medium | Default — auto-inject + on-demand |
|
||||
| context | Yes | No | Low | Budget mode — auto-inject only |
|
||||
| tools | No | Yes | Variable | Full control — agent decides |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation | Status |
|
||||
|------|------------|--------|
|
||||
| Cloud dependency | Self-hosted option available | ✅ |
|
||||
| Cost from LLM calls | Recall mode "context" or "tools" reduces calls | ✅ |
|
||||
| Data privacy | Self-hosted keeps data on your servers | ✅ |
|
||||
| Performance overhead | Cron guard + lazy init + async prefetch | ✅ |
|
||||
| Vendor lock-in | MemoryProvider interface allows swapping | ✅ |
|
||||
|
||||
## Comparison with Alternatives
|
||||
|
||||
| Feature | Honcho | Holographic | Mem0 | Hindsight |
|
||||
|---------|--------|-------------|------|-----------|
|
||||
| Cross-session modeling | ✅ | ❌ | ✅ | ✅ |
|
||||
| Dialectic Q&A | ✅ | ❌ | ❌ | ❌ |
|
||||
| Self-hosted | ✅ | N/A | ❌ | ❌ |
|
||||
| Local-only option | ✅ | ✅ | ❌ | ✅ |
|
||||
| Cost | Free/Paid | Free | Paid | Free |
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Keep Honcho integration.** It provides unique cross-session user modeling capabilities that complement the local holographic fact_store. The integration is:
|
||||
|
||||
- Well-gated (opt-in, zero overhead when disabled)
|
||||
- Flexible (cloud or self-hosted)
|
||||
- Production-ready (7 tests passing, async prefetch, configurable)
|
||||
- Non-exclusive (works alongside other memory providers)
|
||||
|
||||
### To Enable
|
||||
|
||||
```bash
|
||||
# Cloud
|
||||
hermes honcho setup
|
||||
|
||||
# Self-hosted
|
||||
export HONCHO_BASE_URL="http://localhost:8000"
|
||||
hermes chat
|
||||
```
|
||||
|
||||
### To Disable
|
||||
|
||||
```bash
|
||||
hermes memory off
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Evaluated by SANDALPHON — Cron/Ops lane*
|
||||
@@ -214,98 +214,58 @@ matrix:
|
||||
- "!room1:matrix.org"
|
||||
```
|
||||
|
||||
|
||||
## Fleet Ops Channel (Phase 4)
|
||||
|
||||
The fleet ops channel replaces Telegram for system notifications, cron job results, and operational alerts.
|
||||
|
||||
### 1. Create Fleet Ops Room
|
||||
|
||||
1. Open Element (or any Matrix client)
|
||||
2. Create a new room named "Fleet Ops" or "Hermes Ops"
|
||||
3. Set room to **invite-only** (not public)
|
||||
4. Invite your bot: `@hermes-bot:your.domain.com`
|
||||
5. Get the room ID from Room Settings -> Advanced -> Internal Room ID
|
||||
- Format: `!randomstring:your.domain.com`
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Add to `~/.hermes/.env`:
|
||||
|
||||
```bash
|
||||
# Fleet ops channel
|
||||
MATRIX_HOME_CHANNEL=!your-room-id:matrix.org
|
||||
MATRIX_HOME_CHANNEL_NAME=Fleet Ops
|
||||
```
|
||||
|
||||
### 3. Test Fleet Ops
|
||||
|
||||
```bash
|
||||
# Test Matrix connection
|
||||
hermes matrix test
|
||||
|
||||
# Send a test message to fleet ops channel
|
||||
hermes matrix send "Fleet ops channel test"
|
||||
|
||||
# Check cron job delivery
|
||||
hermes cron list
|
||||
# Jobs with deliver=origin will now deliver to Matrix if configured
|
||||
```
|
||||
|
||||
### 4. Configure Cron Delivery
|
||||
|
||||
Cron jobs can deliver results to Matrix instead of Telegram:
|
||||
|
||||
```bash
|
||||
# Create a job that delivers to Matrix
|
||||
hermes cron create "Check system health" --schedule "every 1h" --deliver matrix:home
|
||||
|
||||
# Or deliver to a specific Matrix room
|
||||
hermes cron create "Backup status" --schedule "every 6h" --deliver matrix:!room-id:matrix.org
|
||||
```
|
||||
|
||||
### 5. Fleet Ops Notifications
|
||||
|
||||
The following notifications go to the fleet ops channel:
|
||||
|
||||
- **Cron job results**: Success/failure of scheduled tasks
|
||||
- **System alerts**: Memory, disk, GPU usage warnings
|
||||
- **Agent status**: Model changes, provider switches, errors
|
||||
- **Security events**: Auth failures, suspicious activity
|
||||
|
||||
### 6. Crisis Room (Optional)
|
||||
|
||||
For urgent alerts (SOUL.md protocol), create a separate crisis room:
|
||||
|
||||
```bash
|
||||
# Crisis channel for urgent alerts
|
||||
MATRIX_CRISIS_CHANNEL=!crisis-room-id:matrix.org
|
||||
MATRIX_CRISIS_CHANNEL_NAME=Crisis Room
|
||||
```
|
||||
|
||||
### 7. Migrate from Telegram
|
||||
|
||||
Once Matrix is working:
|
||||
|
||||
1. Update cron jobs to deliver to Matrix: `--deliver matrix:home`
|
||||
2. Test all critical notifications
|
||||
3. Disable Telegram delivery for migrated jobs
|
||||
4. Monitor both channels during transition
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot not receiving messages
|
||||
- Check bot has permission to read room
|
||||
- Verify E2EE is working (see E2EE section)
|
||||
- Check `MATRIX_HOME_CHANNEL` is set correctly
|
||||
### "Matrix: need MATRIX_ACCESS_TOKEN or MATRIX_USER_ID + MATRIX_PASSWORD"
|
||||
|
||||
### Messages not sending
|
||||
- Verify `MATRIX_ACCESS_TOKEN` is valid
|
||||
- Check homeserver is reachable
|
||||
- Look at gateway logs: `hermes gateway logs`
|
||||
Neither auth method is configured. Set `MATRIX_ACCESS_TOKEN` in `~/.hermes/.env`
|
||||
or provide `MATRIX_USER_ID` + `MATRIX_PASSWORD`.
|
||||
|
||||
### E2EE issues
|
||||
- Install: `pip install "matrix-nio[e2e]"`
|
||||
- Install libolm: `brew install libolm` (macOS) or `apt install libolm-dev` (Linux)
|
||||
- Restart gateway after installing
|
||||
### "Matrix: whoami failed"
|
||||
|
||||
The access token is invalid or expired. Generate a new one via the login API.
|
||||
|
||||
### "Matrix: E2EE dependencies are missing"
|
||||
|
||||
Install libolm and matrix-nio with E2EE support:
|
||||
|
||||
```bash
|
||||
brew install libolm # macOS
|
||||
pip install "matrix-nio[e2e]"
|
||||
```
|
||||
|
||||
### "Matrix: login failed"
|
||||
|
||||
- Check username and password.
|
||||
- Ensure the account exists on the target homeserver.
|
||||
- Some homeservers require admin approval for new registrations.
|
||||
|
||||
### Bot Not Responding in Rooms
|
||||
|
||||
1. Check `MATRIX_REQUIRE_MENTION` — if `true` (default), messages must
|
||||
@mention the bot.
|
||||
2. Check `MATRIX_ALLOWED_USERS` — if set, only listed users can interact.
|
||||
3. Check logs: `tail -f ~/.hermes/logs/gateway.log`
|
||||
|
||||
### E2EE Rooms Show "Unable to Decrypt"
|
||||
|
||||
1. Ensure `MATRIX_DEVICE_ID` is set to a stable value.
|
||||
2. Check that `~/.hermes/platforms/matrix/store/` has read/write permissions.
|
||||
3. Verify libolm is installed: `python -c "from nio.crypto import ENCRYPTION_ENABLED; print(ENCRYPTION_ENABLED)"`
|
||||
|
||||
### Slow Message Delivery
|
||||
|
||||
Matrix federation can add latency. For faster responses:
|
||||
- Use the same homeserver for the bot and users.
|
||||
- Set `MATRIX_HOME_ROOM` to a local room.
|
||||
- Check network connectivity between Hermes and the homeserver.
|
||||
|
||||
## Quick Start (Automated)
|
||||
|
||||
Run the interactive setup script:
|
||||
|
||||
```bash
|
||||
python scripts/setup_matrix.py
|
||||
```
|
||||
|
||||
This guides you through homeserver selection, authentication, and verification.
|
||||
|
||||
@@ -412,52 +412,6 @@ class GatewayConfig:
|
||||
return self.unauthorized_dm_behavior
|
||||
|
||||
|
||||
def _validate_fallback_providers() -> None:
|
||||
"""Validate fallback_providers from config.yaml at gateway startup.
|
||||
|
||||
Checks that each entry has 'provider' and 'model' fields and logs
|
||||
warnings for malformed entries. This catches broken fallback chains
|
||||
before they silently degrade into no-fallback mode.
|
||||
"""
|
||||
try:
|
||||
_home = get_hermes_home()
|
||||
_config_path = _home / "config.yaml"
|
||||
if not _config_path.exists():
|
||||
return
|
||||
import yaml
|
||||
with open(_config_path, encoding="utf-8") as _f:
|
||||
_cfg = yaml.safe_load(_f) or {}
|
||||
fbp = _cfg.get("fallback_providers")
|
||||
if not fbp:
|
||||
return
|
||||
if not isinstance(fbp, list):
|
||||
logger.warning(
|
||||
"fallback_providers should be a YAML list, got %s. "
|
||||
"Fallback chain will be disabled.",
|
||||
type(fbp).__name__,
|
||||
)
|
||||
return
|
||||
for i, entry in enumerate(fbp):
|
||||
if not isinstance(entry, dict):
|
||||
logger.warning(
|
||||
"fallback_providers[%d] is not a dict (got %s). Skipping entry.",
|
||||
i, type(entry).__name__,
|
||||
)
|
||||
continue
|
||||
if not entry.get("provider"):
|
||||
logger.warning(
|
||||
"fallback_providers[%d] missing 'provider' field. Skipping entry.",
|
||||
i,
|
||||
)
|
||||
if not entry.get("model"):
|
||||
logger.warning(
|
||||
"fallback_providers[%d] missing 'model' field. Skipping entry.",
|
||||
i,
|
||||
)
|
||||
except Exception:
|
||||
pass # Non-fatal; validation is advisory
|
||||
|
||||
|
||||
def load_gateway_config() -> GatewayConfig:
|
||||
"""
|
||||
Load gateway configuration from multiple sources.
|
||||
@@ -691,19 +645,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
platform.value, env_name,
|
||||
)
|
||||
|
||||
# Warn about API Server enabled without a key (unauthenticated endpoint)
|
||||
if Platform.API_SERVER in config.platforms:
|
||||
api_cfg = config.platforms[Platform.API_SERVER]
|
||||
if api_cfg.enabled and not api_cfg.extra.get("key"):
|
||||
logger.warning(
|
||||
"api_server is enabled but API_SERVER_KEY is not set. "
|
||||
"The API endpoint will run unauthenticated. "
|
||||
"Set API_SERVER_KEY in ~/.hermes/.env to secure it.",
|
||||
)
|
||||
|
||||
# Validate fallback_providers structure from config.yaml
|
||||
_validate_fallback_providers()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -1026,16 +1026,6 @@ class GatewayRunner:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or None
|
||||
if fb:
|
||||
# Treat empty dict / disabled fallback as "not configured"
|
||||
if isinstance(fb, dict):
|
||||
_enabled = fb.get("enabled")
|
||||
if _enabled is False or (
|
||||
isinstance(_enabled, str)
|
||||
and _enabled.strip().lower() in ("false", "0", "no", "off")
|
||||
):
|
||||
return None
|
||||
if not fb.get("provider") and not fb.get("model"):
|
||||
return None
|
||||
return fb
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1338,11 +1338,6 @@ _KNOWN_ROOT_KEYS = {
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "memory", "gateway",
|
||||
"session_reset", "browser", "checkpoints", "smart_model_routing",
|
||||
"voice", "stt", "tts", "human_delay", "security", "privacy",
|
||||
"cron", "logging", "approvals", "command_allowlist", "quick_commands",
|
||||
"personalities", "skills", "honcho", "timezone", "discord",
|
||||
"whatsapp", "prefill_messages_file", "file_read_max_chars",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
@@ -1426,7 +1421,6 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
|
||||
))
|
||||
|
||||
# ── fallback_model must be a top-level dict with provider + model ────
|
||||
# Blank or explicitly disabled fallback is intentional — skip validation.
|
||||
fb = config.get("fallback_model")
|
||||
if fb is not None:
|
||||
if not isinstance(fb, dict):
|
||||
@@ -1436,40 +1430,21 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
|
||||
"Change to:\n"
|
||||
" fallback_model:\n"
|
||||
" provider: openrouter\n"
|
||||
" model: anthropic/claude-sonnet-4\n"
|
||||
"Or disable with:\n"
|
||||
" fallback_model:\n"
|
||||
" enabled: false",
|
||||
" model: anthropic/claude-sonnet-4",
|
||||
))
|
||||
elif fb:
|
||||
# Skip warnings when fallback is explicitly disabled (enabled: false)
|
||||
_enabled = fb.get("enabled")
|
||||
if _enabled is False or (isinstance(_enabled, str) and _enabled.strip().lower() in ("false", "0", "no", "off")):
|
||||
pass # intentionally disabled — no warnings
|
||||
else:
|
||||
# Check if both fields are blank (intentional disable)
|
||||
provider = fb.get("provider")
|
||||
model = fb.get("model")
|
||||
provider_blank = not provider or (isinstance(provider, str) and not provider.strip())
|
||||
model_blank = not model or (isinstance(model, str) and not model.strip())
|
||||
|
||||
# Only warn if at least one field is set (user might be trying to configure)
|
||||
# If both are blank, treat as intentionally disabled
|
||||
if not provider_blank or not model_blank:
|
||||
if provider_blank:
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
"fallback_model is missing 'provider' field — fallback will be disabled",
|
||||
"Add: provider: openrouter (or another provider)\n"
|
||||
"Or disable with: enabled: false",
|
||||
))
|
||||
if model_blank:
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
"fallback_model is missing 'model' field — fallback will be disabled",
|
||||
"Add: model: anthropic/claude-sonnet-4 (or another model)\n"
|
||||
"Or disable with: enabled: false",
|
||||
))
|
||||
if not fb.get("provider"):
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
"fallback_model is missing 'provider' field — fallback will be disabled",
|
||||
"Add: provider: openrouter (or another provider)",
|
||||
))
|
||||
if not fb.get("model"):
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
"fallback_model is missing 'model' field — fallback will be disabled",
|
||||
"Add: model: anthropic/claude-sonnet-4 (or another model)",
|
||||
))
|
||||
|
||||
# ── Check for fallback_model accidentally nested inside custom_providers ──
|
||||
if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}):
|
||||
@@ -1503,72 +1478,6 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
|
||||
f"Move '{key}' under the appropriate section",
|
||||
))
|
||||
|
||||
# ── fallback_providers must be a list of dicts with provider + model ─
|
||||
fbp = config.get("fallback_providers")
|
||||
if fbp is not None:
|
||||
if not isinstance(fbp, list):
|
||||
issues.append(ConfigIssue(
|
||||
"error",
|
||||
f"fallback_providers should be a YAML list, got {type(fbp).__name__}",
|
||||
"Change to:\n"
|
||||
" fallback_providers:\n"
|
||||
" - provider: openrouter\n"
|
||||
" model: google/gemini-3-flash-preview",
|
||||
))
|
||||
elif fbp:
|
||||
for i, entry in enumerate(fbp):
|
||||
if not isinstance(entry, dict):
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
f"fallback_providers[{i}] is not a dict (got {type(entry).__name__})",
|
||||
"Each entry needs at minimum: provider, model",
|
||||
))
|
||||
continue
|
||||
if not entry.get("provider"):
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
f"fallback_providers[{i}] is missing 'provider' field — this fallback will be skipped",
|
||||
"Add: provider: openrouter (or another provider name)",
|
||||
))
|
||||
if not entry.get("model"):
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
f"fallback_providers[{i}] is missing 'model' field — this fallback will be skipped",
|
||||
"Add: model: google/gemini-3-flash-preview (or another model slug)",
|
||||
))
|
||||
|
||||
# ── session_reset validation ─────────────────────────────────────────
|
||||
session_reset = config.get("session_reset", {})
|
||||
if isinstance(session_reset, dict):
|
||||
idle_minutes = session_reset.get("idle_minutes")
|
||||
if idle_minutes is not None:
|
||||
if not isinstance(idle_minutes, (int, float)) or idle_minutes <= 0:
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
f"session_reset.idle_minutes={idle_minutes} is invalid (must be a positive number)",
|
||||
"Set to a positive integer, e.g. 1440 (24 hours). Using 0 causes immediate resets.",
|
||||
))
|
||||
at_hour = session_reset.get("at_hour")
|
||||
if at_hour is not None:
|
||||
if not isinstance(at_hour, (int, float)) or not (0 <= at_hour <= 23):
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
f"session_reset.at_hour={at_hour} is invalid (must be 0-23)",
|
||||
"Set to an hour between 0 and 23, e.g. 4 for 4am",
|
||||
))
|
||||
|
||||
# ── API Server key check ─────────────────────────────────────────────
|
||||
# If api_server is enabled via env, but no key is set, warn.
|
||||
# This catches the "API_SERVER_KEY not configured" error from gateway logs.
|
||||
api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes")
|
||||
api_server_key = os.getenv("API_SERVER_KEY", "").strip()
|
||||
if api_server_enabled and not api_server_key:
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
"API_SERVER is enabled but API_SERVER_KEY is not set — the API server will run unauthenticated",
|
||||
"Set API_SERVER_KEY in ~/.hermes/.env to secure the API endpoint",
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
@@ -93,39 +93,6 @@ def cron_list(show_all: bool = False):
|
||||
script = job.get("script")
|
||||
if script:
|
||||
print(f" Script: {script}")
|
||||
|
||||
# Show health status
|
||||
last_status = job.get("last_status")
|
||||
last_error = job.get("last_error")
|
||||
last_error_at = job.get("last_error_at")
|
||||
last_success_at = job.get("last_success_at")
|
||||
error_cleared_at = job.get("error_cleared_at")
|
||||
error_resolved_at = job.get("error_resolved_at")
|
||||
|
||||
if last_status == "error" and last_error:
|
||||
if error_cleared_at or error_resolved_at:
|
||||
# Error was cleared/resolved
|
||||
cleared_time = error_cleared_at or error_resolved_at
|
||||
print(color(f" Status: ok (error cleared)", Colors.GREEN))
|
||||
print(color(f" Last error: {last_error[:80]}...", Colors.DIM))
|
||||
print(color(f" Resolved: {cleared_time}", Colors.DIM))
|
||||
else:
|
||||
# Current error
|
||||
print(color(f" Status: ERROR", Colors.RED))
|
||||
print(color(f" Error: {last_error[:80]}...", Colors.RED))
|
||||
if last_error_at:
|
||||
print(color(f" Since: {last_error_at}", Colors.RED))
|
||||
elif last_status == "retrying":
|
||||
print(color(f" Status: retrying (error cleared)", Colors.YELLOW))
|
||||
elif last_status == "ok":
|
||||
if last_success_at:
|
||||
print(color(f" Status: ok (last success: {last_success_at})", Colors.GREEN))
|
||||
elif last_status:
|
||||
print(f" Status: {last_status}")
|
||||
|
||||
# Show success history if available
|
||||
if last_success_at and last_status != "error":
|
||||
print(f" Last ok: {last_success_at}")
|
||||
print()
|
||||
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
@@ -255,18 +222,7 @@ def cron_edit(args):
|
||||
|
||||
|
||||
def _job_action(action: str, job_id: str, success_verb: str, now: bool = False) -> int:
|
||||
if action == "clear_error":
|
||||
result = _cron_api(action="clear_error", job_id=job_id)
|
||||
if not result.get("success"):
|
||||
print(color(f"Failed to clear error: {result.get('error', 'unknown error')}", Colors.RED))
|
||||
return 1
|
||||
job = result.get("job", {})
|
||||
name = job.get("name", job_id)
|
||||
print(color(f"Cleared stale error state for job '{name}'", Colors.GREEN))
|
||||
if job.get("error_cleared_at"):
|
||||
print(f" Cleared at: {job['error_cleared_at']}")
|
||||
return 0
|
||||
if action == "run" and now:
|
||||
if action == "run" and now:
|
||||
# Synchronous execution — run job immediately and show result
|
||||
result = _cron_api(action="run_now", job_id=job_id)
|
||||
if not result.get("success"):
|
||||
@@ -336,13 +292,9 @@ def cron_command(args):
|
||||
now = getattr(args, 'now', False)
|
||||
return _job_action("run", args.job_id, "Triggered", now=now)
|
||||
|
||||
|
||||
if subcmd == "clear-error":
|
||||
return _job_action("clear_error", args.job_id, "Cleared")
|
||||
|
||||
if subcmd in {"remove", "rm", "delete"}:
|
||||
return _job_action("remove", args.job_id, "Removed")
|
||||
|
||||
print(f"Unknown cron command: {subcmd}")
|
||||
print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|clear-error|status|tick]")
|
||||
print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|tick]")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -662,153 +662,6 @@ def cmd_chat(args):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
def cmd_matrix(args):
|
||||
"""Handle Matrix integration commands."""
|
||||
from hermes_cli.colors import Colors, color
|
||||
import os
|
||||
|
||||
subcmd = getattr(args, 'matrix_command', None)
|
||||
|
||||
if subcmd is None or subcmd == "status":
|
||||
# Show Matrix configuration status
|
||||
print(color("\n=== Matrix Integration Status ===\n", Colors.CYAN))
|
||||
|
||||
homeserver = os.getenv("MATRIX_HOMESERVER", "")
|
||||
access_token = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
user_id = os.getenv("MATRIX_USER_ID", "")
|
||||
device_id = os.getenv("MATRIX_DEVICE_ID", "")
|
||||
home_channel = os.getenv("MATRIX_HOME_CHANNEL", "")
|
||||
crisis_channel = os.getenv("MATRIX_CRISIS_CHANNEL", "")
|
||||
|
||||
if not homeserver:
|
||||
print(color("✗ Matrix not configured", Colors.RED))
|
||||
print(" Set MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_USER_ID in ~/.hermes/.env")
|
||||
print(" See docs/matrix-setup.md for setup guide")
|
||||
return 1
|
||||
|
||||
print(color("✓ Homeserver:", Colors.GREEN), homeserver)
|
||||
print(color("✓ User ID:", Colors.GREEN) if user_id else color("✗ User ID: not set", Colors.RED))
|
||||
print(color("✓ Access Token:", Colors.GREEN) if access_token else color("✗ Access Token: not set", Colors.RED))
|
||||
print(color("✓ Device ID:", Colors.GREEN) if device_id else color("✗ Device ID: not set", Colors.YELLOW))
|
||||
|
||||
print()
|
||||
print(color("Channels:", Colors.CYAN))
|
||||
if home_channel:
|
||||
print(color(" ✓ Fleet Ops:", Colors.GREEN), home_channel)
|
||||
else:
|
||||
print(color(" ✗ Fleet Ops: not configured", Colors.YELLOW))
|
||||
print(" Set MATRIX_HOME_CHANNEL for system notifications")
|
||||
|
||||
if crisis_channel:
|
||||
print(color(" ✓ Crisis Room:", Colors.GREEN), crisis_channel)
|
||||
else:
|
||||
print(color(" ○ Crisis Room: not configured", Colors.DIM))
|
||||
|
||||
# Check E2EE support
|
||||
try:
|
||||
import nio
|
||||
print(color(" ✓ E2EE support: available", Colors.GREEN))
|
||||
except ImportError:
|
||||
print(color(" ✗ E2EE support: missing (pip install 'matrix-nio[e2e]')", Colors.YELLOW))
|
||||
|
||||
return 0
|
||||
|
||||
if subcmd == "test":
|
||||
# Test Matrix connection
|
||||
print(color("Testing Matrix connection...", Colors.CYAN))
|
||||
|
||||
homeserver = os.getenv("MATRIX_HOMESERVER", "")
|
||||
access_token = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
|
||||
if not homeserver or not access_token:
|
||||
print(color("✗ Matrix not configured", Colors.RED))
|
||||
return 1
|
||||
|
||||
try:
|
||||
import urllib.request, json, ssl
|
||||
ctx = ssl.create_default_context()
|
||||
|
||||
# Test homeserver reachability
|
||||
req = urllib.request.Request(f"{homeserver}/_matrix/client/versions")
|
||||
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
|
||||
versions = json.loads(resp.read())
|
||||
print(color("✓ Homeserver reachable", Colors.GREEN))
|
||||
|
||||
# Test authentication
|
||||
req = urllib.request.Request(
|
||||
f"{homeserver}/_matrix/client/v3/account/whoami",
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
|
||||
whoami = json.loads(resp.read())
|
||||
print(color(f"✓ Authenticated as: {whoami.get('user_id')}", Colors.GREEN))
|
||||
|
||||
# Test home channel if configured
|
||||
home_channel = os.getenv("MATRIX_HOME_CHANNEL", "")
|
||||
if home_channel:
|
||||
print(color(f"✓ Fleet Ops channel configured: {home_channel}", Colors.GREEN))
|
||||
|
||||
print(color("\n✓ Matrix integration working!", Colors.GREEN))
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(color(f"✗ Matrix test failed: {e}", Colors.RED))
|
||||
return 1
|
||||
|
||||
if subcmd == "send":
|
||||
# Send message to Matrix
|
||||
message = args.message
|
||||
channel = getattr(args, 'channel', None) or os.getenv("MATRIX_HOME_CHANNEL", "")
|
||||
|
||||
if not channel:
|
||||
print(color("✗ No channel specified and MATRIX_HOME_CHANNEL not set", Colors.RED))
|
||||
return 1
|
||||
|
||||
print(color(f"Sending to Matrix channel: {channel}", Colors.CYAN))
|
||||
|
||||
# Use the send_message_tool
|
||||
try:
|
||||
import asyncio
|
||||
from tools.send_message_tool import _send_matrix
|
||||
|
||||
homeserver = os.getenv("MATRIX_HOMESERVER", "")
|
||||
access_token = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
|
||||
if not homeserver or not access_token:
|
||||
print(color("✗ Matrix not configured", Colors.RED))
|
||||
return 1
|
||||
|
||||
result = asyncio.run(_send_matrix(access_token, {"homeserver": homeserver}, channel, message))
|
||||
|
||||
if result.get("success"):
|
||||
print(color("✓ Message sent", Colors.GREEN))
|
||||
return 0
|
||||
else:
|
||||
print(color(f"✗ Send failed: {result.get('error')}", Colors.RED))
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
print(color(f"✗ Error: {e}", Colors.RED))
|
||||
return 1
|
||||
|
||||
if subcmd == "setup":
|
||||
print(color("Matrix Setup Wizard", Colors.CYAN))
|
||||
print("\nTo set up Matrix integration:")
|
||||
print("1. See docs/matrix-setup.md for full guide")
|
||||
print("2. Set environment variables in ~/.hermes/.env:")
|
||||
print(" MATRIX_HOMESERVER=https://your-homeserver.com")
|
||||
print(" MATRIX_ACCESS_TOKEN=your-token")
|
||||
print(" MATRIX_USER_ID=@bot:your-homeserver.com")
|
||||
print(" MATRIX_HOME_CHANNEL=!room-id:your-homeserver.com")
|
||||
print("3. Run: hermes matrix test")
|
||||
print("4. Create cron jobs with --deliver matrix:home")
|
||||
return 0
|
||||
|
||||
print(color(f"Unknown matrix command: {subcmd}", Colors.RED))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_gateway(args):
|
||||
"""Gateway management commands."""
|
||||
from hermes_cli.gateway import gateway_command
|
||||
@@ -4419,33 +4272,6 @@ For more help on a command:
|
||||
)
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
|
||||
# =========================================================================
|
||||
# matrix command
|
||||
# =========================================================================
|
||||
matrix_parser = subparsers.add_parser(
|
||||
"matrix",
|
||||
help="Matrix sovereign messaging integration",
|
||||
description="Manage Matrix integration for fleet ops and notifications."
|
||||
)
|
||||
matrix_subparsers = matrix_parser.add_subparsers(dest="matrix_command")
|
||||
|
||||
# Matrix test command
|
||||
matrix_test = matrix_subparsers.add_parser("test", help="Test Matrix connection and configuration")
|
||||
|
||||
# Matrix send command
|
||||
matrix_send = matrix_subparsers.add_parser("send", help="Send a message to Matrix channel")
|
||||
matrix_send.add_argument("message", help="Message to send")
|
||||
matrix_send.add_argument("--channel", "-c", help="Channel to send to (default: home channel)")
|
||||
|
||||
# Matrix status command
|
||||
matrix_status = matrix_subparsers.add_parser("status", help="Show Matrix integration status")
|
||||
|
||||
# Matrix setup command
|
||||
matrix_setup = matrix_subparsers.add_parser("setup", help="Interactive Matrix setup wizard")
|
||||
|
||||
matrix_parser.set_defaults(func=cmd_matrix)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# model command
|
||||
# =========================================================================
|
||||
@@ -4750,9 +4576,6 @@ For more help on a command:
|
||||
cron_run.add_argument("job_id", help="Job ID to trigger")
|
||||
cron_run.add_argument("--now", action="store_true", help="Execute immediately and wait for result (clears stale errors)")
|
||||
|
||||
cron_clear_error = cron_subparsers.add_parser("clear-error", help="Clear stale error state for a job")
|
||||
cron_clear_error.add_argument("job_id", help="Job ID to clear error for")
|
||||
|
||||
cron_remove = cron_subparsers.add_parser("remove", aliases=["rm", "delete"], help="Remove a scheduled job")
|
||||
cron_remove.add_argument("job_id", help="Job ID to remove")
|
||||
|
||||
@@ -5182,7 +5005,7 @@ For more help on a command:
|
||||
# =========================================================================
|
||||
sessions_parser = subparsers.add_parser(
|
||||
"sessions",
|
||||
help="Manage session history (list, rename, export, prune, gc, delete)",
|
||||
help="Manage session history (list, rename, export, prune, delete)",
|
||||
description="View and manage the SQLite session store"
|
||||
)
|
||||
sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action")
|
||||
@@ -5205,14 +5028,6 @@ For more help on a command:
|
||||
sessions_prune.add_argument("--source", help="Only prune sessions from this source")
|
||||
sessions_prune.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
||||
|
||||
sessions_gc = sessions_subparsers.add_parser("gc", help="Garbage-collect empty/trivial sessions")
|
||||
sessions_gc.add_argument("--empty-hours", type=int, default=24, help="Delete empty (0-msg) sessions older than N hours (default: 24)")
|
||||
sessions_gc.add_argument("--trivial-days", type=int, default=7, help="Delete trivial (1-5 msg) sessions older than N days (default: 7)")
|
||||
sessions_gc.add_argument("--trivial-max", type=int, default=5, help="Max messages to consider trivial (default: 5)")
|
||||
sessions_gc.add_argument("--source", help="Only GC sessions from this source")
|
||||
sessions_gc.add_argument("--dry-run", action="store_true", help="Show what would be deleted without deleting")
|
||||
sessions_gc.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
||||
|
||||
sessions_stats = sessions_subparsers.add_parser("stats", help="Show session store statistics")
|
||||
|
||||
sessions_rename = sessions_subparsers.add_parser("rename", help="Set or change a session's title")
|
||||
@@ -5382,49 +5197,6 @@ For more help on a command:
|
||||
size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
||||
print(f"Database size: {size_mb:.1f} MB")
|
||||
|
||||
elif action == "gc":
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
if dry_run:
|
||||
counts = db.garbage_collect(
|
||||
empty_older_than_hours=args.empty_hours,
|
||||
trivial_max_messages=args.trivial_max,
|
||||
trivial_older_than_days=args.trivial_days,
|
||||
source=args.source,
|
||||
dry_run=True,
|
||||
)
|
||||
print(f"[dry-run] Would delete {counts['total']} session(s):")
|
||||
print(f" Empty (0 msgs, >{args.empty_hours}h old): {counts['empty']}")
|
||||
print(f" Trivial (<={args.trivial_max} msgs, >{args.trivial_days}d old): {counts['trivial']}")
|
||||
else:
|
||||
# Preview first
|
||||
preview = db.garbage_collect(
|
||||
empty_older_than_hours=args.empty_hours,
|
||||
trivial_max_messages=args.trivial_max,
|
||||
trivial_older_than_days=args.trivial_days,
|
||||
source=args.source,
|
||||
dry_run=True,
|
||||
)
|
||||
if preview["total"] == 0:
|
||||
print("Nothing to collect.")
|
||||
else:
|
||||
if not args.yes:
|
||||
if not _confirm_prompt(
|
||||
f"Delete {preview['total']} session(s) "
|
||||
f"({preview['empty']} empty, {preview['trivial']} trivial)? [y/N] "
|
||||
):
|
||||
print("Cancelled.")
|
||||
return
|
||||
counts = db.garbage_collect(
|
||||
empty_older_than_hours=args.empty_hours,
|
||||
trivial_max_messages=args.trivial_max,
|
||||
trivial_older_than_days=args.trivial_days,
|
||||
source=args.source,
|
||||
dry_run=False,
|
||||
)
|
||||
print(f"Collected {counts['total']} session(s):")
|
||||
print(f" Empty: {counts['empty']}")
|
||||
print(f" Trivial: {counts['trivial']}")
|
||||
|
||||
else:
|
||||
sessions_parser.print_help()
|
||||
|
||||
|
||||
179
hermes_state.py
179
hermes_state.py
@@ -32,7 +32,7 @@ T = TypeVar("T")
|
||||
|
||||
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
|
||||
SCHEMA_VERSION = 7
|
||||
SCHEMA_VERSION = 6
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
@@ -66,7 +66,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
profile TEXT,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
@@ -87,7 +86,6 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_profile ON sessions(profile);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
|
||||
@@ -332,19 +330,6 @@ class SessionDB:
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 6")
|
||||
if current_version < 7:
|
||||
# v7: add profile column to sessions for profile isolation (#323)
|
||||
try:
|
||||
cursor.execute('ALTER TABLE sessions ADD COLUMN "profile" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
try:
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_profile ON sessions(profile)"
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
cursor.execute("UPDATE schema_version SET version = 7")
|
||||
|
||||
# Unique title index — always ensure it exists (safe to run after migrations
|
||||
# since the title column is guaranteed to exist at this point)
|
||||
@@ -377,19 +362,13 @@ class SessionDB:
|
||||
system_prompt: str = None,
|
||||
user_id: str = None,
|
||||
parent_session_id: str = None,
|
||||
profile: str = None,
|
||||
) -> str:
|
||||
"""Create a new session record. Returns the session_id.
|
||||
|
||||
Args:
|
||||
profile: Profile name for session isolation. When set, sessions
|
||||
are tagged so queries can filter by profile. (#323)
|
||||
"""
|
||||
"""Create a new session record. Returns the session_id."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
|
||||
system_prompt, parent_session_id, profile, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
system_prompt, parent_session_id, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
source,
|
||||
@@ -398,7 +377,6 @@ class SessionDB:
|
||||
json.dumps(model_config) if model_config else None,
|
||||
system_prompt,
|
||||
parent_session_id,
|
||||
profile,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
@@ -527,23 +505,19 @@ class SessionDB:
|
||||
session_id: str,
|
||||
source: str = "unknown",
|
||||
model: str = None,
|
||||
profile: str = None,
|
||||
) -> None:
|
||||
"""Ensure a session row exists, creating it with minimal metadata if absent.
|
||||
|
||||
Used by _flush_messages_to_session_db to recover from a failed
|
||||
create_session() call (e.g. transient SQLite lock at agent startup).
|
||||
INSERT OR IGNORE is safe to call even when the row already exists.
|
||||
|
||||
Args:
|
||||
profile: Profile name for session isolation. (#323)
|
||||
"""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO sessions
|
||||
(id, source, model, profile, started_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(session_id, source, model, profile, time.time()),
|
||||
(id, source, model, started_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(session_id, source, model, time.time()),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
@@ -814,7 +788,6 @@ class SessionDB:
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_children: bool = False,
|
||||
profile: str = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
|
||||
@@ -826,10 +799,6 @@ class SessionDB:
|
||||
|
||||
By default, child sessions (subagent runs, compression continuations)
|
||||
are excluded. Pass ``include_children=True`` to include them.
|
||||
|
||||
Args:
|
||||
profile: Filter sessions to this profile name. Pass None to see all.
|
||||
(#323)
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
@@ -844,9 +813,6 @@ class SessionDB:
|
||||
placeholders = ",".join("?" for _ in exclude_sources)
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(exclude_sources)
|
||||
if profile:
|
||||
where_clauses.append("s.profile = ?")
|
||||
params.append(profile)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
query = f"""
|
||||
@@ -1192,52 +1158,34 @@ class SessionDB:
|
||||
source: str = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
profile: str = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions, optionally filtered by source and profile.
|
||||
|
||||
Args:
|
||||
profile: Filter sessions to this profile name. Pass None to see all.
|
||||
(#323)
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
if source:
|
||||
where_clauses.append("source = ?")
|
||||
params.append(source)
|
||||
if profile:
|
||||
where_clauses.append("profile = ?")
|
||||
params.append(profile)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
query = f"SELECT * FROM sessions {where_sql} ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
"""List sessions, optionally filtered by source."""
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(query, params)
|
||||
if source:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM sessions WHERE source = ? ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
(source, limit, offset),
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# =========================================================================
|
||||
# Utility
|
||||
# =========================================================================
|
||||
|
||||
def session_count(self, source: str = None, profile: str = None) -> int:
|
||||
"""Count sessions, optionally filtered by source and profile.
|
||||
|
||||
Args:
|
||||
profile: Filter to this profile name. Pass None to count all. (#323)
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
if source:
|
||||
where_clauses.append("source = ?")
|
||||
params.append(source)
|
||||
if profile:
|
||||
where_clauses.append("profile = ?")
|
||||
params.append(profile)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
def session_count(self, source: str = None) -> int:
|
||||
"""Count sessions, optionally filtered by source."""
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions {where_sql}", params)
|
||||
if source:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def message_count(self, session_id: str = None) -> int:
|
||||
@@ -1355,78 +1303,3 @@ class SessionDB:
|
||||
return len(session_ids)
|
||||
|
||||
return self._execute_write(_do)
|
||||
|
||||
def garbage_collect(
|
||||
self,
|
||||
empty_older_than_hours: int = 24,
|
||||
trivial_max_messages: int = 5,
|
||||
trivial_older_than_days: int = 7,
|
||||
source: str = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, int]:
|
||||
"""Delete empty and trivial sessions based on age.
|
||||
|
||||
Policy (matches #315):
|
||||
- Empty sessions (0 messages) older than ``empty_older_than_hours``
|
||||
- Trivial sessions (1..``trivial_max_messages`` msgs) older than
|
||||
``trivial_older_than_days``
|
||||
- Sessions with more than ``trivial_max_messages`` are kept indefinitely
|
||||
- Active (not ended) sessions are never deleted
|
||||
|
||||
Returns a dict with counts: ``empty``, ``trivial``, ``total``.
|
||||
"""
|
||||
now = time.time()
|
||||
empty_cutoff = now - (empty_older_than_hours * 3600)
|
||||
trivial_cutoff = now - (trivial_older_than_days * 86400)
|
||||
|
||||
def _do(conn):
|
||||
# --- Find empty sessions ---
|
||||
empty_q = (
|
||||
"SELECT id FROM sessions "
|
||||
"WHERE message_count = 0 AND started_at < ? AND ended_at IS NOT NULL"
|
||||
)
|
||||
params = [empty_cutoff]
|
||||
if source:
|
||||
empty_q += " AND source = ?"
|
||||
params.append(source)
|
||||
empty_ids = [r[0] for r in conn.execute(empty_q, params).fetchall()]
|
||||
|
||||
# --- Find trivial sessions ---
|
||||
trivial_q = (
|
||||
"SELECT id FROM sessions "
|
||||
"WHERE message_count BETWEEN 1 AND ? AND started_at < ? AND ended_at IS NOT NULL"
|
||||
)
|
||||
t_params = [trivial_max_messages, trivial_cutoff]
|
||||
if source:
|
||||
trivial_q += " AND source = ?"
|
||||
t_params.append(source)
|
||||
trivial_ids = [r[0] for r in conn.execute(trivial_q, t_params).fetchall()]
|
||||
|
||||
all_ids = set(empty_ids) | set(trivial_ids)
|
||||
|
||||
if dry_run:
|
||||
return {"empty": len(empty_ids), "trivial": len(trivial_ids),
|
||||
"total": len(all_ids)}
|
||||
|
||||
# --- Collect child sessions to delete first (FK constraint) ---
|
||||
child_ids = set()
|
||||
for sid in all_ids:
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM sessions WHERE parent_session_id = ?", (sid,)
|
||||
).fetchall():
|
||||
child_ids.add(r[0])
|
||||
|
||||
# Delete children
|
||||
for cid in child_ids:
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (cid,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (cid,))
|
||||
|
||||
# Delete targets
|
||||
for sid in all_ids:
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||
|
||||
return {"empty": len(empty_ids), "trivial": len(trivial_ids),
|
||||
"total": len(all_ids)}
|
||||
|
||||
return self._execute_write(_do)
|
||||
|
||||
@@ -136,83 +136,6 @@ class TestFallbackModelValidation:
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_blank_fallback_fields_no_issues(self):
|
||||
"""Blank fallback_model fields (both empty) should not trigger warnings."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {
|
||||
"provider": "",
|
||||
"model": "",
|
||||
},
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_blank_fallback_fields_with_whitespace_no_issues(self):
|
||||
"""Blank fallback_model fields with whitespace should not trigger warnings."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {
|
||||
"provider": " ",
|
||||
"model": " ",
|
||||
},
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_none_fallback_fields_no_issues(self):
|
||||
"""None fallback_model fields should not trigger warnings."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {
|
||||
"provider": None,
|
||||
"model": None,
|
||||
},
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_enabled_false_no_issues(self):
|
||||
"""enabled: false should suppress warnings."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {
|
||||
"enabled": False,
|
||||
},
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_enabled_false_string_no_issues(self):
|
||||
"""enabled: 'false' (string) should suppress warnings."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {
|
||||
"enabled": "false",
|
||||
},
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_partial_blank_fallback_warns(self):
|
||||
"""Partial blank fallback (only one field blank) should warn."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {
|
||||
"provider": "",
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
},
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 1
|
||||
assert "provider" in fb_issues[0].message
|
||||
|
||||
def test_valid_fallback_with_enabled_true(self):
|
||||
"""Valid fallback with enabled: true should not warn."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {
|
||||
"enabled": True,
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
},
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
|
||||
class TestMissingModelSection:
|
||||
"""Warn when custom_providers exists but model section is missing."""
|
||||
@@ -249,111 +172,3 @@ class TestConfigIssueDataclass:
|
||||
a = ConfigIssue("error", "msg", "hint")
|
||||
b = ConfigIssue("error", "msg", "hint")
|
||||
assert a == b
|
||||
|
||||
|
||||
class TestFallbackProvidersValidation:
|
||||
"""fallback_providers must be a list of dicts with provider + model."""
|
||||
|
||||
def test_non_list(self):
|
||||
"""fallback_providers as string should error."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_providers": "openrouter:google/gemini-3-flash-preview",
|
||||
})
|
||||
errors = [i for i in issues if i.severity == "error"]
|
||||
assert any("fallback_providers" in i.message and "list" in i.message for i in errors)
|
||||
|
||||
def test_dict_instead_of_list(self):
|
||||
"""fallback_providers as dict should error."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_providers": {"provider": "openrouter", "model": "test"},
|
||||
})
|
||||
errors = [i for i in issues if i.severity == "error"]
|
||||
assert any("fallback_providers" in i.message and "dict" in i.message for i in errors)
|
||||
|
||||
def test_entry_missing_provider(self):
|
||||
"""Entry without provider should warn."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_providers": [{"model": "google/gemini-3-flash-preview"}],
|
||||
})
|
||||
assert any("missing 'provider'" in i.message for i in issues)
|
||||
|
||||
def test_entry_missing_model(self):
|
||||
"""Entry without model should warn."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_providers": [{"provider": "openrouter"}],
|
||||
})
|
||||
assert any("missing 'model'" in i.message for i in issues)
|
||||
|
||||
def test_entry_not_dict(self):
|
||||
"""Non-dict entries should warn."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_providers": ["not-a-dict"],
|
||||
})
|
||||
assert any("not a dict" in i.message for i in issues)
|
||||
|
||||
def test_valid_entries(self):
|
||||
"""Valid fallback_providers should produce no fallback-related issues."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_providers": [
|
||||
{"provider": "openrouter", "model": "google/gemini-3-flash-preview"},
|
||||
{"provider": "gemini", "model": "gemini-2.5-flash"},
|
||||
],
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback_providers" in i.message]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_empty_list_no_issues(self):
|
||||
"""Empty list is valid (fallback disabled)."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_providers": [],
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback_providers" in i.message]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
|
||||
class TestSessionResetValidation:
|
||||
"""session_reset.idle_minutes must be positive."""
|
||||
|
||||
def test_zero_idle_minutes(self):
|
||||
"""idle_minutes=0 should warn."""
|
||||
issues = validate_config_structure({
|
||||
"session_reset": {"idle_minutes": 0},
|
||||
})
|
||||
assert any("idle_minutes=0" in i.message for i in issues)
|
||||
|
||||
def test_negative_idle_minutes(self):
|
||||
"""idle_minutes=-5 should warn."""
|
||||
issues = validate_config_structure({
|
||||
"session_reset": {"idle_minutes": -5},
|
||||
})
|
||||
assert any("idle_minutes=-5" in i.message for i in issues)
|
||||
|
||||
def test_string_idle_minutes(self):
|
||||
"""idle_minutes as string should warn."""
|
||||
issues = validate_config_structure({
|
||||
"session_reset": {"idle_minutes": "abc"},
|
||||
})
|
||||
assert any("idle_minutes=" in i.message for i in issues)
|
||||
|
||||
def test_valid_idle_minutes(self):
|
||||
"""Valid idle_minutes should not warn."""
|
||||
issues = validate_config_structure({
|
||||
"session_reset": {"idle_minutes": 1440},
|
||||
})
|
||||
idle_issues = [i for i in issues if "idle_minutes" in i.message]
|
||||
assert len(idle_issues) == 0
|
||||
|
||||
def test_invalid_at_hour(self):
|
||||
"""at_hour=25 should warn."""
|
||||
issues = validate_config_structure({
|
||||
"session_reset": {"at_hour": 25},
|
||||
})
|
||||
assert any("at_hour=25" in i.message for i in issues)
|
||||
|
||||
def test_valid_at_hour(self):
|
||||
"""Valid at_hour should not warn."""
|
||||
issues = validate_config_structure({
|
||||
"session_reset": {"at_hour": 4},
|
||||
})
|
||||
hour_issues = [i for i in issues if "at_hour" in i.message]
|
||||
assert len(hour_issues) == 0
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Tests for cron scheduler cloud-provider terminal disabling (#379).
|
||||
|
||||
When a cron job runs on a cloud inference endpoint (Nous, OpenRouter, etc.),
|
||||
the terminal toolset must be disabled because SSH keys don't exist on cloud
|
||||
servers. Only local endpoints (localhost, 127.0.0.1, RFC-1918) retain
|
||||
terminal access.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
|
||||
class TestIsLocalEndpoint:
|
||||
"""Verify is_local_endpoint correctly classifies endpoints."""
|
||||
|
||||
def test_localhost(self):
|
||||
assert is_local_endpoint("http://localhost:11434/v1") is True
|
||||
|
||||
def test_127_loopback(self):
|
||||
assert is_local_endpoint("http://127.0.0.1:8080/v1") is True
|
||||
|
||||
def test_0_0_0_0(self):
|
||||
assert is_local_endpoint("http://0.0.0.0:11434/v1") is True
|
||||
|
||||
def test_rfc1918_10(self):
|
||||
assert is_local_endpoint("http://10.0.0.5:8080/v1") is True
|
||||
|
||||
def test_rfc1918_192(self):
|
||||
assert is_local_endpoint("http://192.168.1.100:11434/v1") is True
|
||||
|
||||
def test_rfc1918_172(self):
|
||||
assert is_local_endpoint("http://172.16.0.1:8080/v1") is True
|
||||
|
||||
def test_cloud_openrouter(self):
|
||||
assert is_local_endpoint("https://openrouter.ai/api/v1") is False
|
||||
|
||||
def test_cloud_nous(self):
|
||||
assert is_local_endpoint("https://inference-api.nousresearch.com/v1") is False
|
||||
|
||||
def test_cloud_anthropic(self):
|
||||
assert is_local_endpoint("https://api.anthropic.com") is False
|
||||
|
||||
def test_empty_url(self):
|
||||
assert is_local_endpoint("") is False
|
||||
|
||||
def test_none_url(self):
|
||||
assert is_local_endpoint(None) is False
|
||||
|
||||
|
||||
class TestCronDisabledToolsetsLogic:
|
||||
"""Verify the disabled_toolsets logic matches scheduler expectations."""
|
||||
|
||||
def _build_disabled(self, base_url, job=None):
|
||||
"""Mirror the scheduler's disabled_toolsets logic."""
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
cron_disabled = ["cronjob", "messaging", "clarify"]
|
||||
if not is_local_endpoint(base_url):
|
||||
cron_disabled.append("terminal")
|
||||
return cron_disabled
|
||||
|
||||
def test_local_keeps_terminal(self):
|
||||
disabled = self._build_disabled("http://localhost:11434/v1")
|
||||
assert "terminal" not in disabled
|
||||
assert "cronjob" in disabled
|
||||
|
||||
def test_cloud_disables_terminal(self):
|
||||
disabled = self._build_disabled("https://openrouter.ai/api/v1")
|
||||
assert "terminal" in disabled
|
||||
assert "cronjob" in disabled
|
||||
|
||||
def test_empty_url_disables_terminal(self):
|
||||
disabled = self._build_disabled("")
|
||||
assert "terminal" in disabled
|
||||
129
tests/test_cron_provider_mismatch.py
Normal file
129
tests/test_cron_provider_mismatch.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for cron scheduler: provider mismatch detection, runtime classification,
|
||||
and capability-aware prompt building."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
def _import_scheduler():
|
||||
"""Import the scheduler module, bypassing __init__.py re-exports that may
|
||||
reference symbols not yet merged upstream."""
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"cron.scheduler", str(Path(__file__).resolve().parent.parent / "cron" / "scheduler.py"),
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
pass # some top-level imports may fail in CI; functions are still defined
|
||||
return mod
|
||||
|
||||
|
||||
_sched = _import_scheduler()
|
||||
_classify_runtime = _sched._classify_runtime
|
||||
_detect_provider_mismatch = _sched._detect_provider_mismatch
|
||||
_build_job_prompt = _sched._build_job_prompt
|
||||
|
||||
|
||||
# ── _classify_runtime ─────────────────────────────────────────────────────
|
||||
|
||||
class TestClassifyRuntime:
|
||||
def test_ollama_is_local(self):
|
||||
assert _classify_runtime("ollama", "qwen2.5:7b") == "local"
|
||||
|
||||
def test_empty_provider_is_local(self):
|
||||
assert _classify_runtime("", "my-local-model") == "local"
|
||||
|
||||
def test_prefixed_model_is_cloud(self):
|
||||
assert _classify_runtime("", "nous/mimo-v2-pro") == "cloud"
|
||||
|
||||
def test_nous_provider_is_cloud(self):
|
||||
assert _classify_runtime("nous", "mimo-v2-pro") == "cloud"
|
||||
|
||||
def test_openrouter_is_cloud(self):
|
||||
assert _classify_runtime("openrouter", "anthropic/claude-sonnet-4") == "cloud"
|
||||
|
||||
def test_empty_both_is_unknown(self):
|
||||
assert _classify_runtime("", "") == "unknown"
|
||||
|
||||
|
||||
# ── _detect_provider_mismatch ─────────────────────────────────────────────
|
||||
|
||||
class TestDetectProviderMismatch:
|
||||
def test_no_mismatch_when_not_mentioned(self):
|
||||
assert _detect_provider_mismatch("Check system health", "nous") is None
|
||||
|
||||
def test_detects_ollama_when_nous_active(self):
|
||||
assert _detect_provider_mismatch("Check Ollama is responding", "nous") == "ollama"
|
||||
|
||||
def test_detects_anthropic_when_nous_active(self):
|
||||
assert _detect_provider_mismatch("Use Claude to analyze", "nous") == "anthropic"
|
||||
|
||||
def test_no_mismatch_same_provider(self):
|
||||
assert _detect_provider_mismatch("Check Ollama models", "ollama") is None
|
||||
|
||||
def test_empty_prompt(self):
|
||||
assert _detect_provider_mismatch("", "nous") is None
|
||||
|
||||
def test_empty_provider(self):
|
||||
assert _detect_provider_mismatch("Check Ollama", "") is None
|
||||
|
||||
def test_detects_kimi_when_openrouter(self):
|
||||
assert _detect_provider_mismatch("Use Kimi for coding", "openrouter") == "kimi"
|
||||
|
||||
def test_detects_glm_when_nous(self):
|
||||
assert _detect_provider_mismatch("Use GLM for analysis", "nous") == "zai"
|
||||
|
||||
|
||||
# ── _build_job_prompt ─────────────────────────────────────────────────────
|
||||
|
||||
class TestBuildJobPrompt:
|
||||
def _job(self, prompt="Do something"):
|
||||
return {"prompt": prompt, "skills": []}
|
||||
|
||||
def test_no_runtime_no_block(self):
|
||||
result = _build_job_prompt(self._job())
|
||||
assert "Do something" in result
|
||||
assert "RUNTIME CONTEXT" not in result
|
||||
|
||||
def test_cloud_runtime_injected(self):
|
||||
result = _build_job_prompt(
|
||||
self._job(),
|
||||
runtime_model="xiaomi/mimo-v2-pro",
|
||||
runtime_provider="nous",
|
||||
)
|
||||
assert "MODEL: xiaomi/mimo-v2-pro" in result
|
||||
assert "PROVIDER: nous" in result
|
||||
assert "cloud API" in result
|
||||
assert "Do NOT assume you can SSH" in result
|
||||
|
||||
def test_local_runtime_injected(self):
|
||||
result = _build_job_prompt(
|
||||
self._job(),
|
||||
runtime_model="qwen2.5:7b",
|
||||
runtime_provider="ollama",
|
||||
)
|
||||
assert "RUNTIME: local" in result
|
||||
assert "SSH keys" in result
|
||||
|
||||
def test_empty_runtime_no_block(self):
|
||||
result = _build_job_prompt(self._job(), runtime_model="", runtime_provider="")
|
||||
assert "RUNTIME CONTEXT" not in result
|
||||
|
||||
def test_cron_hint_always_present(self):
|
||||
result = _build_job_prompt(self._job())
|
||||
assert "scheduled cron job" in result
|
||||
assert "[SYSTEM:" in result
|
||||
|
||||
def test_runtime_block_before_cron_hint(self):
|
||||
result = _build_job_prompt(
|
||||
self._job("Check Ollama"),
|
||||
runtime_model="mimo-v2-pro",
|
||||
runtime_provider="nous",
|
||||
)
|
||||
runtime_pos = result.index("RUNTIME CONTEXT")
|
||||
cron_pos = result.index("scheduled cron job")
|
||||
assert runtime_pos < cron_pos
|
||||
@@ -665,127 +665,6 @@ class TestPruneSessions:
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# =========================================================================
|
||||
# Garbage Collect
|
||||
# =========================================================================
|
||||
|
||||
class TestGarbageCollect:
|
||||
def test_gc_deletes_empty_old_sessions(self, db):
|
||||
"""Empty sessions (0 messages) older than 24h should be deleted."""
|
||||
db.create_session(session_id="empty_old", source="cli")
|
||||
db.end_session("empty_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, "empty_old"), # 48 hours ago
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
# Recent empty session should be kept
|
||||
db.create_session(session_id="empty_new", source="cli")
|
||||
db.end_session("empty_new", end_reason="done")
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["empty"] == 1
|
||||
assert result["trivial"] == 0
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("empty_old") is None
|
||||
assert db.get_session("empty_new") is not None
|
||||
|
||||
def test_gc_deletes_trivial_old_sessions(self, db):
|
||||
"""Sessions with 1-5 messages older than 7 days should be deleted."""
|
||||
db.create_session(session_id="trivial_old", source="cli")
|
||||
for i in range(3):
|
||||
db.append_message("trivial_old", role="user", content=f"msg {i}")
|
||||
db.end_session("trivial_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 10 * 86400, "trivial_old"), # 10 days ago
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["trivial"] == 1
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("trivial_old") is None
|
||||
|
||||
def test_gc_keeps_active_sessions(self, db):
|
||||
"""Active (not ended) sessions should never be deleted."""
|
||||
db.create_session(session_id="active_old", source="cli")
|
||||
# Backdate but don't end
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, "active_old"),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["total"] == 0
|
||||
assert db.get_session("active_old") is not None
|
||||
|
||||
def test_gc_keeps_substantial_sessions(self, db):
|
||||
"""Sessions with >5 messages should never be deleted."""
|
||||
db.create_session(session_id="big_old", source="cli")
|
||||
for i in range(10):
|
||||
db.append_message("big_old", role="user", content=f"msg {i}")
|
||||
db.end_session("big_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 365 * 86400, "big_old"), # 1 year ago
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["total"] == 0
|
||||
assert db.get_session("big_old") is not None
|
||||
|
||||
def test_gc_dry_run_does_not_delete(self, db):
|
||||
"""dry_run=True should return counts but not delete anything."""
|
||||
db.create_session(session_id="empty_old", source="cli")
|
||||
db.end_session("empty_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, "empty_old"),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect(dry_run=True)
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("empty_old") is not None # Still exists
|
||||
|
||||
def test_gc_with_source_filter(self, db):
|
||||
"""--source should only GC sessions from that source."""
|
||||
for sid, src in [("old_cli", "cli"), ("old_tg", "telegram")]:
|
||||
db.create_session(session_id=sid, source=src)
|
||||
db.end_session(sid, end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, sid),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect(source="cli")
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("old_cli") is None
|
||||
assert db.get_session("old_tg") is not None
|
||||
|
||||
def test_gc_handles_child_sessions(self, db):
|
||||
"""Child sessions should be deleted when parent is GC'd."""
|
||||
db.create_session(session_id="parent_old", source="cli")
|
||||
db.end_session("parent_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, "parent_old"),
|
||||
)
|
||||
# Create child session
|
||||
db.create_session(session_id="child", source="cli", parent_session_id="parent_old")
|
||||
db.end_session("child", end_reason="done")
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("parent_old") is None
|
||||
assert db.get_session("child") is None
|
||||
|
||||
# Schema and WAL mode
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@@ -201,17 +201,6 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"paused_at": job.get("paused_at"),
|
||||
"paused_reason": job.get("paused_reason"),
|
||||
}
|
||||
# Health timestamps
|
||||
if job.get("last_error_at"):
|
||||
result["last_error_at"] = job["last_error_at"]
|
||||
if job.get("last_success_at"):
|
||||
result["last_success_at"] = job["last_success_at"]
|
||||
if job.get("error_resolved_at"):
|
||||
result["error_resolved_at"] = job["error_resolved_at"]
|
||||
if job.get("error_cleared_at"):
|
||||
result["error_cleared_at"] = job["error_cleared_at"]
|
||||
|
||||
|
||||
if job.get("script"):
|
||||
result["script"] = job["script"]
|
||||
return result
|
||||
@@ -337,13 +326,6 @@ def cronjob(
|
||||
if result is None:
|
||||
return json.dumps({"success": False, "error": "Job not found"}, indent=2)
|
||||
return json.dumps(result, indent=2)
|
||||
if normalized == "clear_error":
|
||||
from cron.jobs import clear_job_error
|
||||
job = clear_job_error(job_id)
|
||||
if job is None:
|
||||
return json.dumps({"success": False, "error": "Job not found"}, indent=2)
|
||||
return json.dumps({"success": True, "job": _format_job(job)}, indent=2)
|
||||
|
||||
|
||||
if normalized == "update":
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
Reference in New Issue
Block a user