From 192e7eb21f5e2c4b8ef7b332e4423ea69a979754 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 04:53:42 -0700 Subject: [PATCH 01/41] fix(nous): don't trip cross-session rate breaker on upstream-capacity 429s (#15898) Nous Portal multiplexes multiple upstream providers (DeepSeek, Kimi, MiMo, Hermes) behind one endpoint. Before this fix, any 429 on any of those models recorded a cross-session file breaker that blocked EVERY model on Nous for the cooldown window -- even though the caller's own RPM/RPH/TPM/TPH buckets were healthy. Users hit a DeepSeek V4 Pro capacity error, restarted, switched to Kimi 2.6, and still got 'Nous Portal rate limit active -- resets in 46m 53s'. Nous already emits the full x-ratelimit-* header suite on every response (captured by rate_limit_tracker into agent._rate_limit_state). We now gate the breaker on that data: trip it only when either the 429's own headers or the last-known-good state show a bucket with remaining == 0 AND a reset window >= 60s. Upstream-capacity 429s (healthy buckets everywhere, but upstream out of capacity) fall through to normal retry/fallback and the breaker is never written. Note: the in-memory 'restart TUI/gateway to clear' workaround circulated in Discord does NOT work -- the breaker is file-backed at ~/.hermes/rate_limits/nous.json. The workaround for users still affected by a bad state file is to delete it. Reported in Discord by CrazyDok1 and KYSIV (Apr 2026). --- agent/nous_rate_guard.py | 142 ++++++++++++++++++++++++++++ run_agent.py | 61 +++++++++--- tests/agent/test_nous_rate_guard.py | 138 +++++++++++++++++++++++++++ 3 files changed, 327 insertions(+), 14 deletions(-) diff --git a/agent/nous_rate_guard.py b/agent/nous_rate_guard.py index 712d8a0f1f..ea866f2e08 100644 --- a/agent/nous_rate_guard.py +++ b/agent/nous_rate_guard.py @@ -180,3 +180,145 @@ def format_remaining(seconds: float) -> str: h, remainder = divmod(s, 3600) m = remainder // 60 return f"{h}h {m}m" if m else f"{h}h" + + +# Buckets with reset windows shorter than this are treated as transient +# (upstream jitter, secondary throttling) rather than a genuine quota +# exhaustion worth a cross-session breaker trip. +_MIN_RESET_FOR_BREAKER_SECONDS = 60.0 + + +def is_genuine_nous_rate_limit( + *, + headers: Optional[Mapping[str, str]] = None, + last_known_state: Optional[Any] = None, +) -> bool: + """Decide whether a 429 from Nous Portal is a real account rate limit. + + Nous Portal multiplexes multiple upstream providers (DeepSeek, Kimi, + MiMo, Hermes, ...) behind one endpoint. A 429 can mean either: + + (a) The caller's own RPM / RPH / TPM / TPH bucket on Nous is + exhausted — a genuine rate limit that will last until the + bucket resets. + (b) The upstream provider is out of capacity for a specific model + — transient, clears in seconds, and has nothing to do with + the caller's quota on Nous. + + Tripping the cross-session breaker on (b) blocks ALL Nous requests + (and all models, since Nous is one provider key) for minutes even + though the caller's account is healthy and a different model would + have worked. That's the bug users hit when DeepSeek V4 Pro 429s + trigger a breaker that then blocks Kimi 2.6 and MiMo V2.5 Pro. + + We tell the two apart by looking at: + + 1. The 429 response's own ``x-ratelimit-*`` headers. Nous emits + the full suite on every response including 429s. An exhausted + bucket (``remaining == 0`` with a reset window >= 60s) is + proof of (a). + 2. The last-known-good rate-limit state captured by + ``_capture_rate_limits()`` on the previous successful + response. If any bucket there was already near-exhausted with + a substantial reset window, the current 429 is almost + certainly (a) continuing from that condition. + + If neither signal fires, we treat the 429 as (b): fail the single + request, let the retry loop or model-switch proceed, and do NOT + write the cross-session breaker file. + + Returns True when the evidence points at (a). + """ + # Signal 1: current 429 response headers. + state = _parse_buckets_from_headers(headers) + if _has_exhausted_bucket(state): + return True + + # Signal 2: last-known-good state from a recent successful response. + # Accepts either a RateLimitState (dataclass from rate_limit_tracker) + # or a dict of bucket snapshots. + if last_known_state is not None and _has_exhausted_bucket_in_object(last_known_state): + return True + + return False + + +def _parse_buckets_from_headers( + headers: Optional[Mapping[str, str]], +) -> dict[str, tuple[Optional[int], Optional[float]]]: + """Extract (remaining, reset_seconds) per bucket from x-ratelimit-* headers. + + Returns empty dict when no rate-limit headers are present. + """ + if not headers: + return {} + + lowered = {k.lower(): v for k, v in headers.items()} + if not any(k.startswith("x-ratelimit-") for k in lowered): + return {} + + def _maybe_int(raw: Optional[str]) -> Optional[int]: + if raw is None: + return None + try: + return int(float(raw)) + except (TypeError, ValueError): + return None + + def _maybe_float(raw: Optional[str]) -> Optional[float]: + if raw is None: + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + result: dict[str, tuple[Optional[int], Optional[float]]] = {} + for tag in ("requests", "requests-1h", "tokens", "tokens-1h"): + remaining = _maybe_int(lowered.get(f"x-ratelimit-remaining-{tag}")) + reset = _maybe_float(lowered.get(f"x-ratelimit-reset-{tag}")) + if remaining is not None or reset is not None: + result[tag] = (remaining, reset) + return result + + +def _has_exhausted_bucket( + buckets: Mapping[str, tuple[Optional[int], Optional[float]]], +) -> bool: + """Return True when any bucket has remaining == 0 AND a meaningful reset window.""" + for remaining, reset in buckets.values(): + if remaining is None or remaining > 0: + continue + if reset is None: + continue + if reset >= _MIN_RESET_FOR_BREAKER_SECONDS: + return True + return False + + +def _has_exhausted_bucket_in_object(state: Any) -> bool: + """Check a RateLimitState-like object for an exhausted bucket. + + Accepts the dataclass from ``agent.rate_limit_tracker`` (buckets + exposed as attributes ``requests_min``, ``requests_hour``, + ``tokens_min``, ``tokens_hour``) and falls back gracefully for any + object missing those attributes. + """ + for attr in ("requests_min", "requests_hour", "tokens_min", "tokens_hour"): + bucket = getattr(state, attr, None) + if bucket is None: + continue + limit = getattr(bucket, "limit", 0) or 0 + remaining = getattr(bucket, "remaining", 0) or 0 + # Prefer the adjusted "remaining_seconds_now" property when present; + # fall back to raw reset_seconds. + reset = getattr(bucket, "remaining_seconds_now", None) + if reset is None: + reset = getattr(bucket, "reset_seconds", 0.0) or 0.0 + if limit <= 0: + continue + if remaining > 0: + continue + if reset >= _MIN_RESET_FOR_BREAKER_SECONDS: + return True + return False diff --git a/run_agent.py b/run_agent.py index 1f2a062127..c0dd76596d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -11007,36 +11007,69 @@ class AIAgent: continue # ── Nous Portal: record rate limit & skip retries ───── - # When Nous returns a 429, record the reset time to a - # shared file so ALL sessions (cron, gateway, auxiliary) - # know not to pile on. Then skip further retries — - # each one burns another RPH request and deepens the - # rate limit hole. The retry loop's top-of-iteration - # guard will catch this on the next pass and try - # fallback or bail with a clear message. + # When Nous returns a 429 that is a genuine account- + # level rate limit, record the reset time to a shared + # file so ALL sessions (cron, gateway, auxiliary) know + # not to pile on, then skip further retries -- each + # one burns another RPH request and deepens the hole. + # The retry loop's top-of-iteration guard will catch + # this on the next pass and try fallback or bail. + # + # IMPORTANT: Nous Portal multiplexes multiple upstream + # providers (DeepSeek, Kimi, MiMo, Hermes). A 429 can + # also mean an UPSTREAM provider is out of capacity + # for one specific model -- transient, clears in + # seconds, nothing to do with the caller's quota. + # Tripping the cross-session breaker on that would + # block every Nous model for minutes. We use + # ``is_genuine_nous_rate_limit`` to tell the two + # apart via the 429's own x-ratelimit-* headers and + # the last-known-good state captured on the previous + # successful response. if ( is_rate_limited and self.provider == "nous" and classified.reason == FailoverReason.rate_limit and not recovered_with_pool ): + _genuine_nous_rate_limit = False try: - from agent.nous_rate_guard import record_nous_rate_limit + from agent.nous_rate_guard import ( + is_genuine_nous_rate_limit, + record_nous_rate_limit, + ) _err_resp = getattr(api_error, "response", None) _err_hdrs = ( getattr(_err_resp, "headers", None) if _err_resp else None ) - record_nous_rate_limit( + _genuine_nous_rate_limit = is_genuine_nous_rate_limit( headers=_err_hdrs, - error_context=error_context, + last_known_state=self._rate_limit_state, ) + if _genuine_nous_rate_limit: + record_nous_rate_limit( + headers=_err_hdrs, + error_context=error_context, + ) + else: + logging.info( + "Nous 429 looks like upstream capacity " + "(no exhausted bucket in headers or " + "last-known state) -- not tripping " + "cross-session breaker." + ) except Exception: pass - # Skip straight to max_retries — the top-of-loop - # guard will handle fallback or bail cleanly. - retry_count = max_retries - continue + if _genuine_nous_rate_limit: + # Skip straight to max_retries -- the + # top-of-loop guard will handle fallback or + # bail cleanly. + retry_count = max_retries + continue + # Upstream capacity 429: fall through to normal + # retry logic. A different model (or the same + # model a moment later) will typically succeed. is_payload_too_large = ( classified.reason == FailoverReason.payload_too_large diff --git a/tests/agent/test_nous_rate_guard.py b/tests/agent/test_nous_rate_guard.py index 45d30f7246..4441aa6e44 100644 --- a/tests/agent/test_nous_rate_guard.py +++ b/tests/agent/test_nous_rate_guard.py @@ -251,3 +251,141 @@ class TestAuxiliaryClientIntegration: monkeypatch.setattr(aux, "_read_nous_auth", lambda: None) result = aux._try_nous() assert result == (None, None) + + +class TestIsGenuineNousRateLimit: + """Tell a real account-level 429 apart from an upstream-capacity 429. + + Nous Portal multiplexes upstreams (DeepSeek, Kimi, MiMo, Hermes). + A 429 from an upstream out of capacity should NOT trip the + cross-session breaker; a real user-quota 429 should. + """ + + def test_exhausted_hourly_bucket_in_429_headers_is_genuine(self): + from agent.nous_rate_guard import is_genuine_nous_rate_limit + + headers = { + "x-ratelimit-limit-requests-1h": "800", + "x-ratelimit-remaining-requests-1h": "0", + "x-ratelimit-reset-requests-1h": "3100", + "x-ratelimit-limit-requests": "200", + "x-ratelimit-remaining-requests": "198", + "x-ratelimit-reset-requests": "40", + } + assert is_genuine_nous_rate_limit(headers=headers) is True + + def test_exhausted_tokens_bucket_is_genuine(self): + from agent.nous_rate_guard import is_genuine_nous_rate_limit + + headers = { + "x-ratelimit-limit-tokens": "800000", + "x-ratelimit-remaining-tokens": "0", + "x-ratelimit-reset-tokens": "45", # < 60s threshold -> not genuine + "x-ratelimit-limit-tokens-1h": "8000000", + "x-ratelimit-remaining-tokens-1h": "0", + "x-ratelimit-reset-tokens-1h": "1800", # >= 60s threshold -> genuine + } + assert is_genuine_nous_rate_limit(headers=headers) is True + + def test_healthy_headers_on_429_are_upstream_capacity(self): + # Classic upstream-capacity symptom: Nous edge reports plenty of + # headroom on every bucket, but returns 429 anyway because + # upstream (DeepSeek / Kimi / ...) is out of capacity. + from agent.nous_rate_guard import is_genuine_nous_rate_limit + + headers = { + "x-ratelimit-limit-requests": "200", + "x-ratelimit-remaining-requests": "198", + "x-ratelimit-reset-requests": "40", + "x-ratelimit-limit-requests-1h": "800", + "x-ratelimit-remaining-requests-1h": "750", + "x-ratelimit-reset-requests-1h": "3100", + "x-ratelimit-limit-tokens": "800000", + "x-ratelimit-remaining-tokens": "790000", + "x-ratelimit-reset-tokens": "40", + "x-ratelimit-limit-tokens-1h": "8000000", + "x-ratelimit-remaining-tokens-1h": "7800000", + "x-ratelimit-reset-tokens-1h": "3100", + } + assert is_genuine_nous_rate_limit(headers=headers) is False + + def test_bare_429_with_no_headers_is_upstream(self): + from agent.nous_rate_guard import is_genuine_nous_rate_limit + + assert is_genuine_nous_rate_limit(headers=None) is False + assert is_genuine_nous_rate_limit(headers={}) is False + assert is_genuine_nous_rate_limit( + headers={"content-type": "application/json"} + ) is False + + def test_exhausted_bucket_with_short_reset_is_not_genuine(self): + # remaining == 0 but reset in < 60s: almost certainly a + # secondary per-minute throttle that will clear immediately -- + # not worth tripping the cross-session breaker. + from agent.nous_rate_guard import is_genuine_nous_rate_limit + + headers = { + "x-ratelimit-limit-requests": "200", + "x-ratelimit-remaining-requests": "0", + "x-ratelimit-reset-requests": "30", + } + assert is_genuine_nous_rate_limit(headers=headers) is False + + def test_last_known_state_with_exhausted_bucket_triggers_genuine(self): + # Headers on the 429 lack rate-limit info, but the previous + # successful response already showed the hourly bucket + # exhausted -- the 429 is almost certainly that limit + # continuing. + from agent.nous_rate_guard import is_genuine_nous_rate_limit + from agent.rate_limit_tracker import parse_rate_limit_headers + + prior_headers = { + "x-ratelimit-limit-requests-1h": "800", + "x-ratelimit-remaining-requests-1h": "0", + "x-ratelimit-reset-requests-1h": "2000", + "x-ratelimit-limit-requests": "200", + "x-ratelimit-remaining-requests": "100", + "x-ratelimit-reset-requests": "30", + "x-ratelimit-limit-tokens": "800000", + "x-ratelimit-remaining-tokens": "700000", + "x-ratelimit-reset-tokens": "30", + "x-ratelimit-limit-tokens-1h": "8000000", + "x-ratelimit-remaining-tokens-1h": "7000000", + "x-ratelimit-reset-tokens-1h": "2000", + } + last_state = parse_rate_limit_headers(prior_headers, provider="nous") + assert is_genuine_nous_rate_limit( + headers=None, last_known_state=last_state + ) is True + + def test_last_known_state_all_healthy_stays_upstream(self): + # Prior state was healthy; bare 429 arrives; should be treated + # as upstream capacity. + from agent.nous_rate_guard import is_genuine_nous_rate_limit + from agent.rate_limit_tracker import parse_rate_limit_headers + + prior_headers = { + "x-ratelimit-limit-requests-1h": "800", + "x-ratelimit-remaining-requests-1h": "750", + "x-ratelimit-reset-requests-1h": "2000", + "x-ratelimit-limit-requests": "200", + "x-ratelimit-remaining-requests": "180", + "x-ratelimit-reset-requests": "30", + "x-ratelimit-limit-tokens": "800000", + "x-ratelimit-remaining-tokens": "790000", + "x-ratelimit-reset-tokens": "30", + "x-ratelimit-limit-tokens-1h": "8000000", + "x-ratelimit-remaining-tokens-1h": "7900000", + "x-ratelimit-reset-tokens-1h": "2000", + } + last_state = parse_rate_limit_headers(prior_headers, provider="nous") + assert is_genuine_nous_rate_limit( + headers=None, last_known_state=last_state + ) is False + + def test_none_last_state_and_no_headers_is_upstream(self): + from agent.nous_rate_guard import is_genuine_nous_rate_limit + + assert is_genuine_nous_rate_limit( + headers=None, last_known_state=None + ) is False From 76042f586787d7a2af8adb70cca4d2d53bd56bb8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 05:17:10 -0700 Subject: [PATCH 02/41] feat(review): class-first skill review prompt (#16026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background skill-review prompt (spawned after N user turns) now instructs the reviewer to SURVEY existing skills first, identify the CLASS of task, and PREFER updating/generalizing an existing skill over creating a new narrow one. This reduces near-duplicate skill accumulation at the source. Catches the common failure mode where repeated tasks of the same class each spawn their own specific skill ("fix-my-tauri-error", "fix-my-electron-error") instead of a single class-level skill ("desktop-app-build-troubleshooting"). Applied to both _SKILL_REVIEW_PROMPT and the **Skills** half of _COMBINED_REVIEW_PROMPT. Memory-only review prompt unchanged. Groundwork for the Curator feature (issue #7816) — the creation-side fix. Curator handles the retirement/consolidation side in a follow-up PR. Tests assert the behavioral instructions are present (survey, class, update- over-create, overlap-flagging, opt-out clause) rather than snapshotting the full prompt text. --- run_agent.py | 42 +++++++--- .../test_review_prompt_class_first.py | 78 +++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 tests/run_agent/test_review_prompt_class_first.py diff --git a/run_agent.py b/run_agent.py index c0dd76596d..7b23b5b41c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3109,13 +3109,28 @@ class AIAgent: ) _SKILL_REVIEW_PROMPT = ( - "Review the conversation above and consider saving or updating a skill if appropriate.\n\n" - "Focus on: was a non-trivial approach used to complete a task that required trial " - "and error, or changing course due to experiential findings along the way, or did " - "the user expect or desire a different method or outcome?\n\n" - "If a relevant skill already exists, update it with what you learned. " - "Otherwise, create a new skill if the approach is reusable.\n" - "If nothing is worth saving, just say 'Nothing to save.' and stop." + "Review the conversation above and consider whether a skill should be saved or updated.\n\n" + "Work in this order — do not skip steps:\n\n" + "1. SURVEY the existing skill landscape first. Call skills_list to see what you " + "have. If anything looks potentially relevant, skill_view it before deciding. " + "You are looking for the CLASS of task that just happened, not the exact task. " + "Example: a successful Tauri build is in the class \"desktop app build " + "troubleshooting\", not \"fix my specific Tauri error today\".\n\n" + "2. THINK CLASS-FIRST. What general pattern of task did the user just complete? " + "What conditions will trigger this pattern again? Describe the class in one " + "sentence before looking at what to save.\n\n" + "3. PREFER GENERALIZING AN EXISTING SKILL over creating a new one. If a skill " + "already covers the class — even partially — update it (skill_manage patch) " + "with the new insight. Broaden its \"when to use\" trigger if needed.\n\n" + "4. ONLY CREATE A NEW SKILL when no existing skill reasonably covers the class. " + "When you create one, name and scope it at the class level " + "(\"react-i18n-setup\", not \"add-i18n-to-my-dashboard-app\"). The trigger " + "section must describe the class of situations, not this one session.\n\n" + "5. If you notice two existing skills that overlap, note it in your response " + "so a future review can consolidate them. Do not consolidate now unless the " + "overlap is obvious and low-risk.\n\n" + "Only act when something is genuinely worth saving. " + "If nothing stands out, just say 'Nothing to save.' and stop." ) _COMBINED_REVIEW_PROMPT = ( @@ -3125,9 +3140,16 @@ class AIAgent: "about how you should behave, their work style, or ways they want you to operate? " "If so, save using the memory tool.\n\n" "**Skills**: Was a non-trivial approach used to complete a task that required trial " - "and error, or changing course due to experiential findings along the way, or did " - "the user expect or desire a different method or outcome? If a relevant skill " - "already exists, update it. Otherwise, create a new one if the approach is reusable.\n\n" + "and error, changing course due to experiential findings, or a different method " + "or outcome than the user expected? If so, work in this order:\n" + " a. SURVEY existing skills first (skills_list, then skill_view on candidates).\n" + " b. Identify the CLASS of task, not the specific task " + "(\"desktop app build troubleshooting\", not \"fix my Tauri error\").\n" + " c. PREFER UPDATING/GENERALIZING an existing skill that covers the class.\n" + " d. ONLY CREATE A NEW SKILL if no existing one covers the class. Scope at " + "the class level, not this one session.\n" + " e. If you notice overlapping skills during the survey, note it so a future " + "review can consolidate them.\n\n" "Only act if there's something genuinely worth saving. " "If nothing stands out, just say 'Nothing to save.' and stop." ) diff --git a/tests/run_agent/test_review_prompt_class_first.py b/tests/run_agent/test_review_prompt_class_first.py new file mode 100644 index 0000000000..4a7fed1d74 --- /dev/null +++ b/tests/run_agent/test_review_prompt_class_first.py @@ -0,0 +1,78 @@ +"""Behavior tests for the class-first skill review prompts. + +The skill review / combined review prompts steer the background review agent +toward generalizing existing skills rather than accumulating near-duplicates. +These tests assert the behavioral *instructions* are present — they do NOT +snapshot the full prompt text (change-detector). +""" + +from run_agent import AIAgent + + +def test_skill_review_prompt_instructs_survey_first(): + """Prompt must tell the reviewer to list existing skills before deciding.""" + prompt = AIAgent._SKILL_REVIEW_PROMPT + assert "skills_list" in prompt, "must instruct the reviewer to call skills_list" + assert "skill_view" in prompt, "must instruct the reviewer to skill_view candidates" + assert "SURVEY" in prompt, "must name the survey step explicitly" + + +def test_skill_review_prompt_is_class_first(): + """Prompt must steer toward the CLASS of task, not the specific task.""" + prompt = AIAgent._SKILL_REVIEW_PROMPT + assert "CLASS" in prompt, "must tell the reviewer to think about the task class" + assert "class level" in prompt, "must anchor naming at the class level" + + +def test_skill_review_prompt_prefers_updating_existing(): + """Prompt must prefer generalizing an existing skill over creating a new one.""" + prompt = AIAgent._SKILL_REVIEW_PROMPT + assert "PREFER GENERALIZING" in prompt or "PREFER UPDATING" in prompt, ( + "must state the update-over-create preference" + ) + assert "ONLY CREATE A NEW SKILL" in prompt, ( + "must gate new-skill creation behind a last-resort clause" + ) + + +def test_skill_review_prompt_flags_overlap_for_followup(): + """Prompt must ask the reviewer to note overlapping skills for future review.""" + prompt = AIAgent._SKILL_REVIEW_PROMPT + assert "overlap" in prompt.lower(), "must mention the overlap-flagging protocol" + + +def test_skill_review_prompt_preserves_opt_out_clause(): + """The 'Nothing to save.' escape clause must remain.""" + prompt = AIAgent._SKILL_REVIEW_PROMPT + assert "Nothing to save." in prompt + + +def test_combined_review_prompt_keeps_memory_section(): + """Combined prompt must still cover memory review.""" + prompt = AIAgent._COMBINED_REVIEW_PROMPT + assert "**Memory**" in prompt + assert "memory tool" in prompt + + +def test_combined_review_prompt_skills_section_is_class_first(): + """The **Skills** half of the combined prompt must follow the same protocol.""" + prompt = AIAgent._COMBINED_REVIEW_PROMPT + assert "**Skills**" in prompt + assert "SURVEY" in prompt + assert "CLASS" in prompt + assert "skills_list" in prompt + assert "ONLY CREATE A NEW SKILL" in prompt + + +def test_combined_review_prompt_preserves_opt_out_clause(): + prompt = AIAgent._COMBINED_REVIEW_PROMPT + assert "Nothing to save." in prompt + + +def test_memory_review_prompt_unchanged_in_structure(): + """Memory-only review prompt stays focused on user facts — not touched by this change.""" + prompt = AIAgent._MEMORY_REVIEW_PROMPT + # Guardrails: the memory-only prompt must NOT mention skills/surveys. + assert "skills_list" not in prompt + assert "SURVEY" not in prompt + assert "memory tool" in prompt From 2ccdadcca6296d3a4128830865067c52f5ea2d5d Mon Sep 17 00:00:00 2001 From: zkl Date: Fri, 24 Apr 2026 14:48:55 +0800 Subject: [PATCH 03/41] fix(deepseek): bump V4 family context window to 1M tokens #14934 added deepseek-v4-pro / deepseek-v4-flash to the DeepSeek native provider but the context-window lookup still falls back to the existing "deepseek" substring entry (128K). DeepSeek V4 ships with a 1M context window, so any caller relying on get_model_context_length() for pre-flight token budgeting (compression, context warnings) under-counts by ~8x. Add explicit lowercase entries for the four DeepSeek model ids that ship 1M context: - deepseek-v4-pro - deepseek-v4-flash - deepseek-chat (legacy alias, server-side maps to v4-flash non-thinking) - deepseek-reasoner (legacy alias, server-side maps to v4-flash thinking) Longest-key-first substring matching means these explicit entries also cover the vendor-prefixed forms (deepseek/deepseek-v4-pro on OpenRouter and Nous Portal) without regressing the existing 128K fallback for older / unknown DeepSeek model ids on custom endpoints. Source: https://api-docs.deepseek.com/zh-cn/quick_start/pricing --- agent/model_metadata.py | 12 +++++++++- tests/agent/test_model_metadata.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 29d5e1e89b..bce3a9998f 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -164,7 +164,17 @@ DEFAULT_CONTEXT_LENGTHS = { "gemma-4-31b": 256000, "gemma-3": 131072, "gemma": 8192, # fallback for older gemma models - # DeepSeek + # DeepSeek — V4 family ships with a 1M context window. The legacy + # aliases ``deepseek-chat`` / ``deepseek-reasoner`` are server-side + # mapped to the non-thinking / thinking modes of ``deepseek-v4-flash`` + # and inherit the same 1M window. The ``deepseek`` substring entry + # below remains as a 128K fallback for older / unknown DeepSeek model + # ids (e.g. via custom endpoints). + # https://api-docs.deepseek.com/zh-cn/quick_start/pricing + "deepseek-v4-pro": 1_000_000, + "deepseek-v4-flash": 1_000_000, + "deepseek-chat": 1_000_000, + "deepseek-reasoner": 1_000_000, "deepseek": 128000, # Meta "llama": 131072, diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 42ec0a464f..d08cac3102 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -192,6 +192,43 @@ class TestDefaultContextLengths: f"{model_id}: expected {expected_ctx}, got {actual}" ) + def test_deepseek_v4_models_1m_context(self): + from agent.model_metadata import get_model_context_length + from unittest.mock import patch as mock_patch + + expected_keys = { + "deepseek-v4-pro": 1_000_000, + "deepseek-v4-flash": 1_000_000, + "deepseek-chat": 1_000_000, + "deepseek-reasoner": 1_000_000, + } + for key, value in expected_keys.items(): + assert key in DEFAULT_CONTEXT_LENGTHS, f"{key} missing" + assert DEFAULT_CONTEXT_LENGTHS[key] == value, ( + f"{key} should be {value}, got {DEFAULT_CONTEXT_LENGTHS[key]}" + ) + + # Longest-first substring matching must resolve both the bare V4 + # ids (native DeepSeek) and the vendor-prefixed forms (OpenRouter + # / Nous Portal) to 1M without probing down to the legacy 128K + # ``deepseek`` substring fallback. + with mock_patch("agent.model_metadata.fetch_model_metadata", return_value={}), \ + mock_patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \ + mock_patch("agent.model_metadata.get_cached_context_length", return_value=None): + cases = [ + ("deepseek-v4-pro", 1_000_000), + ("deepseek-v4-flash", 1_000_000), + ("deepseek/deepseek-v4-pro", 1_000_000), + ("deepseek/deepseek-v4-flash", 1_000_000), + ("deepseek-chat", 1_000_000), + ("deepseek-reasoner", 1_000_000), + ] + for model_id, expected_ctx in cases: + actual = get_model_context_length(model_id) + assert actual == expected_ctx, ( + f"{model_id}: expected {expected_ctx}, got {actual}" + ) + def test_all_values_positive(self): for key, value in DEFAULT_CONTEXT_LENGTHS.items(): assert value > 0, f"{key} has non-positive context length" From 438db0c7b062d5ceeadec5d9de009324ee822467 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 05:43:31 -0700 Subject: [PATCH 04/41] fix(cli): /model picker honors provider-specific context caps (#16030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_apply_model_switch_result` (the interactive `/model` picker's confirmation path) printed `ModelInfo.context_window` straight from models.dev, which reports the vendor-wide value (1.05M for gpt-5.5 on openai). ChatGPT Codex OAuth caps the same slug at 272K, so the picker showed 1M while the runtime (compressor, gateway `/model`, typed `/model `) correctly used 272K — the classic 'sometimes 1M, sometimes 272K' mismatch on a single model. Both display paths now go through `resolve_display_context_length()`, matching the fix that `_handle_model_switch` received earlier. Also bump the stale last-resort fallback in DEFAULT_CONTEXT_LENGTHS (`gpt-5.5: 400000 -> 1050000`) to match the real OpenAI API value; the 272K Codex cap is already enforced via the Codex-OAuth branch, so the fallback now reflects what every non-Codex probe-miss should see. Tests: adds `test_apply_model_switch_result_context.py` with three scenarios (Codex cap wins, OpenRouter shows 1.05M, resolver-empty falls back to ModelInfo). Updates the existing non-Codex fallback test to assert 1.05M (the correct value). ## Validation | path | before | after | |-------------------------------|-----------|-----------| | picker -> gpt-5.5 on Codex | 1,050,000 | 272,000 | | picker -> gpt-5.5 on OpenAI | 1,050,000 | 1,050,000 | | picker -> gpt-5.5 on OpenRouter | 1,050,000 | 1,050,000 | | typed /model gpt-5.5 on Codex | 272,000 | 272,000 | --- agent/model_metadata.py | 9 +- cli.py | 30 ++-- tests/agent/test_model_metadata.py | 6 +- .../test_apply_model_switch_result_context.py | 152 ++++++++++++++++++ 4 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 tests/hermes_cli/test_apply_model_switch_result_context.py diff --git a/agent/model_metadata.py b/agent/model_metadata.py index bce3a9998f..62c18218b1 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -145,10 +145,11 @@ DEFAULT_CONTEXT_LENGTHS = { "claude": 200000, # OpenAI — GPT-5 family (most have 400k; specific overrides first) # Source: https://developers.openai.com/api/docs/models - # GPT-5.5 (launched Apr 23 2026). 400k is the fallback for providers we - # can't probe live. ChatGPT Codex OAuth actually caps lower (272k as of - # Apr 2026) and is resolved via _resolve_codex_oauth_context_length(). - "gpt-5.5": 400000, + # GPT-5.5 (launched Apr 23 2026) is 1.05M on the direct OpenAI API and + # ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own + # provider-aware branches (_resolve_codex_oauth_context_length + models.dev). + # This hardcoded value is only reached when every probe misses. + "gpt-5.5": 1050000, "gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4) "gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4) "gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context) diff --git a/cli.py b/cli.py index 9f3e8964c4..bc77d4c350 100644 --- a/cli.py +++ b/cli.py @@ -5153,27 +5153,29 @@ class HermesCLI: _cprint(f" ✓ Model switched: {result.new_model}") _cprint(f" Provider: {provider_label}") + # Context: always resolve via the provider-aware chain so Codex OAuth, + # Copilot, and Nous-enforced caps win over the raw models.dev entry + # (e.g. gpt-5.5 is 1.05M on openai but 272K on Codex OAuth). mi = result.model_info + try: + from hermes_cli.model_switch import resolve_display_context_length + ctx = resolve_display_context_length( + result.new_model, + result.target_provider, + base_url=result.base_url or self.base_url or "", + api_key=result.api_key or self.api_key or "", + model_info=mi, + ) + if ctx: + _cprint(f" Context: {ctx:,} tokens") + except Exception: + pass if mi: - if mi.context_window: - _cprint(f" Context: {mi.context_window:,} tokens") if mi.max_output: _cprint(f" Max output: {mi.max_output:,} tokens") if mi.has_cost_data(): _cprint(f" Cost: {mi.format_cost()}") _cprint(f" Capabilities: {mi.format_capabilities()}") - else: - try: - from agent.model_metadata import get_model_context_length - ctx = get_model_context_length( - result.new_model, - base_url=result.base_url or self.base_url, - api_key=result.api_key or self.api_key, - provider=result.target_provider, - ) - _cprint(f" Context: {ctx:,} tokens") - except Exception: - pass cache_enabled = ( (base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower()) diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index d08cac3102..c28b68226b 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -340,7 +340,9 @@ class TestCodexOAuthContextLength: from agent.model_metadata import get_model_context_length # OpenRouter — should hit its own catalog path first; when mocked - # empty, falls through to hardcoded DEFAULT_CONTEXT_LENGTHS (400k). + # empty, falls through to hardcoded DEFAULT_CONTEXT_LENGTHS (1.05M, + # matching the real direct-API value — Codex OAuth's 272k cap is + # provider-specific and must not leak here). with patch("agent.model_metadata.fetch_model_metadata", return_value={}), \ patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \ patch("agent.model_metadata.get_cached_context_length", return_value=None), \ @@ -351,7 +353,7 @@ class TestCodexOAuthContextLength: api_key="", provider="openrouter", ) - assert ctx == 400_000, ( + assert ctx == 1_050_000, ( f"Non-Codex gpt-5.5 resolved to {ctx}; Codex 272k override " "leaked outside openai-codex provider" ) diff --git a/tests/hermes_cli/test_apply_model_switch_result_context.py b/tests/hermes_cli/test_apply_model_switch_result_context.py new file mode 100644 index 0000000000..fd17150be3 --- /dev/null +++ b/tests/hermes_cli/test_apply_model_switch_result_context.py @@ -0,0 +1,152 @@ +"""Regression test for the `/model` picker confirmation display. + +Bug (April 2026): after choosing a model from the interactive `/model` picker, +``HermesCLI._apply_model_switch_result()`` printed ``ModelInfo.context_window`` +straight from models.dev, which always reports the vendor-wide value (e.g. +gpt-5.5 = 1,050,000 on ``openai``). That ignored provider-specific caps — in +particular, ChatGPT Codex OAuth enforces 272K on the same slug. The sibling +``_handle_model_switch()`` (typed ``/model ``) was already fixed to use +``resolve_display_context_length()``; the picker path was missed, causing +"sometimes 1M, sometimes 272K" for the same model across sibling UI paths. + +Fix: both display paths now go through ``resolve_display_context_length()``. +""" +from __future__ import annotations + +from unittest.mock import patch + +from hermes_cli.model_switch import ModelSwitchResult + + +class _FakeModelInfo: + context_window = 1_050_000 + max_output = 0 + + def has_cost_data(self): + return False + + def format_capabilities(self): + return "" + + +class _StubCLI: + """Minimum attrs ``_apply_model_switch_result`` reads on ``self``.""" + agent = None + model = "" + provider = "" + requested_provider = "" + api_key = "" + _explicit_api_key = "" + base_url = "" + _explicit_base_url = "" + api_mode = "" + _pending_model_switch_note = "" + + +def _run_display(monkeypatch, result): + import cli as cli_mod + + captured: list[str] = [] + monkeypatch.setattr(cli_mod, "_cprint", lambda s, *a, **k: captured.append(str(s))) + # Avoid writing to ~/.hermes/config.yaml during the test. + monkeypatch.setattr(cli_mod, "save_config_value", lambda *a, **k: None) + cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, False) + return captured + + +def test_picker_path_uses_provider_aware_context_on_codex(monkeypatch): + """``_apply_model_switch_result`` must prefer the provider-aware resolver + (272K on Codex) over the raw models.dev value (1.05M for gpt-5.5). + """ + result = ModelSwitchResult( + success=True, + new_model="gpt-5.5", + target_provider="openai-codex", + provider_changed=True, + api_key="", + base_url="https://chatgpt.com/backend-api/codex", + api_mode="codex_responses", + warning_message="", + provider_label="ChatGPT Codex", + resolved_via_alias=False, + capabilities=None, + model_info=_FakeModelInfo(), # models.dev says 1.05M + is_global=False, + ) + with patch( + "agent.model_metadata.get_model_context_length", + return_value=272_000, + ): + lines = _run_display(monkeypatch, result) + + ctx_line = next((l for l in lines if "Context:" in l), "") + assert "272,000" in ctx_line, ( + f"picker-path display must show Codex's 272K cap, got: {ctx_line!r}" + ) + assert "1,050,000" not in ctx_line, ( + f"picker-path display leaked models.dev's 1.05M for Codex: {ctx_line!r}" + ) + + +def test_picker_path_shows_vendor_value_when_no_provider_cap(monkeypatch): + """On providers with no enforced cap (e.g. OpenRouter), the picker path + should surface the real 1.05M context for gpt-5.5 — resolver and models.dev + agree here. + """ + result = ModelSwitchResult( + success=True, + new_model="openai/gpt-5.5", + target_provider="openrouter", + provider_changed=True, + api_key="", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + warning_message="", + provider_label="OpenRouter", + resolved_via_alias=False, + capabilities=None, + model_info=_FakeModelInfo(), + is_global=False, + ) + with patch( + "agent.model_metadata.get_model_context_length", + return_value=1_050_000, + ): + lines = _run_display(monkeypatch, result) + + ctx_line = next((l for l in lines if "Context:" in l), "") + assert "1,050,000" in ctx_line, ( + f"OpenRouter gpt-5.5 should show 1.05M context, got: {ctx_line!r}" + ) + + +def test_picker_path_falls_back_to_model_info_when_resolver_empty(monkeypatch): + """If ``get_model_context_length`` returns nothing (rare — truly unknown + endpoint), the display still surfaces ``ModelInfo.context_window`` so the + user sees *something* rather than a silent blank. + """ + result = ModelSwitchResult( + success=True, + new_model="some-model", + target_provider="some-provider", + provider_changed=True, + api_key="", + base_url="", + api_mode="chat_completions", + warning_message="", + provider_label="Some Provider", + resolved_via_alias=False, + capabilities=None, + model_info=_FakeModelInfo(), # context_window = 1_050_000 + is_global=False, + ) + with patch( + "agent.model_metadata.get_model_context_length", + return_value=None, + ): + lines = _run_display(monkeypatch, result) + + ctx_line = next((l for l in lines if "Context:" in l), "") + assert "1,050,000" in ctx_line, ( + f"resolver-empty path should fall back to ModelInfo, got: {ctx_line!r}" + ) From d09ab8ff13329da1715d20e3fb17d47f499fbc18 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 05:43:54 -0700 Subject: [PATCH 05/41] fix(mcp-oauth): preserve server_url path for protected-resource validation (#16031) Stop pre-stripping the path from the configured MCP server URL before constructing OAuthClientProvider. The MCP SDK strips the path itself via OAuthContext.get_authorization_base_url() for authorization-server discovery, but uses the full server_url through resource_url_from_server_url() + check_resource_allowed() to validate against the server's RFC 9728 Protected Resource Metadata. For servers whose PRM advertises a path-scoped resource (e.g. Notion's https://mcp.notion.com/mcp), our _parse_base_url() collapsed the URL to the origin, so check_resource_allowed() saw requested='/' vs configured='/mcp/' and refused the token. Fixes OAuth against Notion MCP (and any other path-scoped resource). Closes #16015. --- tests/tools/test_mcp_oauth.py | 37 +++++++++++++++++++++++++++++------ tools/mcp_oauth.py | 8 +------- tools/mcp_oauth_manager.py | 3 +-- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index b2f3f02297..db0342e993 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -491,11 +491,36 @@ def test_configure_callback_port_uses_explicit_port(): assert cfg["_resolved_port"] == 54321 -def test_parse_base_url_strips_path(): - """_parse_base_url drops path components for OAuth discovery.""" - from tools.mcp_oauth import _parse_base_url +def test_build_oauth_auth_preserves_server_url_path(): + """server_url with path is forwarded to OAuthClientProvider unmodified. + + Regression for #16015: previously ``_parse_base_url`` stripped the path, + collapsing ``https://mcp.notion.com/mcp`` to ``https://mcp.notion.com`` and + breaking RFC 9728 protected-resource validation against servers whose PRM + advertises a path-scoped resource (Notion). The MCP SDK strips the path + itself for authorization-server discovery via + ``OAuthContext.get_authorization_base_url``; Hermes must not pre-strip. + """ + from tools import mcp_oauth + + captured: dict = {} + + class _FakeProvider: + def __init__(self, **kwargs): + captured.update(kwargs) + + with patch.object(mcp_oauth, "_OAUTH_AVAILABLE", True), \ + patch.object(mcp_oauth, "OAuthClientProvider", _FakeProvider), \ + patch.object(mcp_oauth, "_is_interactive", return_value=True), \ + patch.object(mcp_oauth, "_maybe_preregister_client"), \ + patch.object(mcp_oauth, "HermesTokenStorage") as mock_storage_cls: + mock_storage_cls.return_value = MagicMock(has_cached_tokens=lambda: True) + build_oauth_auth( + server_name="notion", + server_url="https://mcp.notion.com/mcp", + oauth_config={}, + ) + + assert captured["server_url"] == "https://mcp.notion.com/mcp" - assert _parse_base_url("https://example.com/mcp/v1") == "https://example.com" - assert _parse_base_url("https://example.com") == "https://example.com" - assert _parse_base_url("https://host.example.com:8080/api") == "https://host.example.com:8080" diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index fd655bf3d2..51e243c6c1 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -519,12 +519,6 @@ def _maybe_preregister_client( logger.debug("Pre-registered client_id=%s for '%s'", client_id, storage._server_name) -def _parse_base_url(server_url: str) -> str: - """Strip path component from server URL, returning the base origin.""" - parsed = urlparse(server_url) - return f"{parsed.scheme}://{parsed.netloc}" - - def build_oauth_auth( server_name: str, server_url: str, @@ -570,7 +564,7 @@ def build_oauth_auth( _maybe_preregister_client(storage, cfg, client_metadata) return OAuthClientProvider( - server_url=_parse_base_url(server_url), + server_url=server_url, client_metadata=client_metadata, storage=storage, redirect_handler=_redirect_handler, diff --git a/tools/mcp_oauth_manager.py b/tools/mcp_oauth_manager.py index 7c8a91f3f9..dbe2fc3e06 100644 --- a/tools/mcp_oauth_manager.py +++ b/tools/mcp_oauth_manager.py @@ -362,7 +362,6 @@ class MCPOAuthManager: _configure_callback_port, _is_interactive, _maybe_preregister_client, - _parse_base_url, _redirect_handler, _wait_for_callback, ) @@ -387,7 +386,7 @@ class MCPOAuthManager: return _HERMES_PROVIDER_CLS( server_name=server_name, - server_url=_parse_base_url(entry.server_url), + server_url=entry.server_url, client_metadata=client_metadata, storage=storage, redirect_handler=_redirect_handler, From 855366909f659e7f11635dc5bfbd279a1d4ef83e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 05:46:43 -0700 Subject: [PATCH 06/41] feat(models): remote model catalog manifest for OpenRouter + Nous Portal (#16033) OpenRouter and Nous Portal curated picker lists now resolve via a JSON manifest served by the docs site, falling back to the in-repo snapshot when unreachable. Lets us update model lists without shipping a release. Live URL: https://hermes-agent.nousresearch.com/docs/api/model-catalog.json (source at website/static/api/model-catalog.json; auto-deploys via the existing deploy-site.yml GitHub Pages pipeline on every merge to main). Schema (v1) carries id + optional description + free-form metadata at manifest, provider, and model levels. Pricing and context length stay live-fetched via existing machinery (/v1/models endpoints, models.dev). Config (new model_catalog section, default enabled): model_catalog.url master manifest URL model_catalog.ttl_hours disk cache TTL (default 24h) model_catalog.providers..url optional per-provider override Fetch pipeline: in-process cache -> disk cache (fresh < TTL) -> HTTP fetch -> disk-cache-on-failure fallback -> in-repo snapshot as last resort. Never raises to callers; at worst returns the bundled list. Changes: - website/static/api/model-catalog.json initial manifest (35 OR + 31 Nous) - scripts/build_model_catalog.py regenerator from in-repo lists - hermes_cli/model_catalog.py fetch + validate + cache module - hermes_cli/models.py fetch_openrouter_models() + new get_curated_nous_model_ids() - hermes_cli/main.py, hermes_cli/auth.py Nous flows use the helper - hermes_cli/config.py model_catalog defaults - website/docs/reference/model-catalog.md + sidebars.ts - tests/hermes_cli/test_model_catalog.py 21 tests (validation, fetch success/failure, accessors, disabled, overrides, integration) --- hermes_cli/auth.py | 4 +- hermes_cli/config.py | 21 ++ hermes_cli/main.py | 4 +- hermes_cli/model_catalog.py | 329 ++++++++++++++++++++++++ hermes_cli/models.py | 29 ++- scripts/build_model_catalog.py | 95 +++++++ tests/hermes_cli/test_model_catalog.py | 284 ++++++++++++++++++++ website/docs/reference/model-catalog.md | 103 ++++++++ website/sidebars.ts | 1 + website/static/api/model-catalog.json | 259 +++++++++++++++++++ 10 files changed, 1124 insertions(+), 5 deletions(-) create mode 100644 hermes_cli/model_catalog.py create mode 100755 scripts/build_model_catalog.py create mode 100644 tests/hermes_cli/test_model_catalog.py create mode 100644 website/docs/reference/model-catalog.md create mode 100644 website/static/api/model-catalog.json diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 482e3c47a2..eeccbece98 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4244,10 +4244,10 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: ) from hermes_cli.models import ( - _PROVIDER_MODELS, get_pricing_for_provider, + get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, ) - model_ids = _PROVIDER_MODELS.get("nous", []) + model_ids = get_curated_nous_model_ids() print() unavailable_models: list = [] diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3b5e24a376..4af2aff1de 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -959,6 +959,27 @@ DEFAULT_CONFIG = { "backup_count": 3, # Number of rotated backup files to keep }, + # Remotely-hosted model catalog manifest. When enabled, the CLI fetches + # curated model lists for OpenRouter and Nous Portal from this URL, + # falling back to the in-repo snapshot on network failure. Lets us + # update model picker lists without shipping a hermes-agent release. + # The default URL is served by the docs site GitHub Pages deploy. + "model_catalog": { + "enabled": True, + "url": "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json", + # Disk cache TTL in hours. Beyond this, the CLI refetches on the + # next /model or `hermes model` invocation; network failures + # silently fall back to the stale cache. + "ttl_hours": 24, + # Optional per-provider override URLs for third parties that want + # to self-host their own curation list using the same schema. + # Example: + # providers: + # openrouter: + # url: https://example.com/my-curation.json + "providers": {}, + }, + # Network settings — workarounds for connectivity issues. "network": { # Force IPv4 connections. On servers with broken or unreachable IPv6, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2064b324f5..30dfee21e2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2315,13 +2315,13 @@ def _model_flow_nous(config, current_model="", args=None): # The live /models endpoint returns hundreds of models; the curated list # shows only agentic models users recognize from OpenRouter. from hermes_cli.models import ( - _PROVIDER_MODELS, + get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, ) - model_ids = _PROVIDER_MODELS.get("nous", []) + model_ids = get_curated_nous_model_ids() if not model_ids: print("No curated models available for Nous Portal.") return diff --git a/hermes_cli/model_catalog.py b/hermes_cli/model_catalog.py new file mode 100644 index 0000000000..500910d57f --- /dev/null +++ b/hermes_cli/model_catalog.py @@ -0,0 +1,329 @@ +"""Remote model catalog fetcher. + +The Hermes docs site hosts a JSON manifest of curated models for providers +we want to update without shipping a release (currently OpenRouter and +Nous Portal). This module fetches, validates, and caches that manifest, +falling back to the in-repo hardcoded lists when the network is unavailable. + +Pipeline +-------- +1. ``get_catalog()`` — returns a parsed manifest dict. + - Checks in-process cache (invalidated by TTL). + - Reads disk cache at ``~/.hermes/cache/model_catalog.json``. + - Fetches the master URL if disk cache is stale or missing. + - On any fetch failure, keeps using the stale cache (or empty dict). + +2. ``get_curated_openrouter_models()`` / ``get_curated_nous_models()`` — + thin accessors returning the shapes existing callers expect. Each + falls back to the in-repo hardcoded list on any lookup failure. + +Schema (version 1) +------------------ +:: + + { + "version": 1, + "updated_at": "2026-04-25T22:00:00Z", + "metadata": {...}, # free-form + "providers": { + "openrouter": { + "metadata": {...}, # free-form + "models": [ + {"id": "vendor/model", "description": "recommended", + "metadata": {...}} # free-form, model-level + ] + }, + "nous": {...} + } + } + +Unknown fields are ignored — extra metadata can be added at either level +without bumping ``version``. ``version`` bumps are reserved for +breaking changes (renaming ``providers``, changing ``models`` shape). +""" + +from __future__ import annotations + +import json +import logging +import os +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from hermes_cli import __version__ as _HERMES_VERSION + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_CATALOG_URL = ( + "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json" +) +DEFAULT_TTL_HOURS = 24 +DEFAULT_FETCH_TIMEOUT = 8.0 +SUPPORTED_SCHEMA_VERSION = 1 + +_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}" + +# In-process cache to avoid repeated disk + parse work across multiple +# calls within the same session. Invalidated by TTL against the disk file's +# mtime, so calling code never has to think about this. +_catalog_cache: dict[str, Any] | None = None +_catalog_cache_source_mtime: float = 0.0 + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + + +def _load_catalog_config() -> dict[str, Any]: + """Load the ``model_catalog`` config block with defaults filled in.""" + try: + from hermes_cli.config import load_config + cfg = load_config() or {} + except Exception: + cfg = {} + + raw = cfg.get("model_catalog") + if not isinstance(raw, dict): + raw = {} + + return { + "enabled": bool(raw.get("enabled", True)), + "url": str(raw.get("url") or DEFAULT_CATALOG_URL), + "ttl_hours": float(raw.get("ttl_hours") or DEFAULT_TTL_HOURS), + "providers": raw.get("providers") if isinstance(raw.get("providers"), dict) else {}, + } + + +def _cache_path() -> Path: + """Return the disk cache path. Import lazily so tests can monkeypatch home.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "cache" / "model_catalog.json" + + +# --------------------------------------------------------------------------- +# Fetch + validate + cache +# --------------------------------------------------------------------------- + + +def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None: + """HTTP GET the manifest URL and return a parsed dict, or None on failure.""" + try: + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": _HERMES_USER_AGENT, + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: + logger.info("model catalog fetch failed (%s): %s", url, exc) + return None + except Exception as exc: # pragma: no cover — defensive + logger.info("model catalog fetch errored (%s): %s", url, exc) + return None + + if not _validate_manifest(data): + logger.info("model catalog at %s failed schema validation", url) + return None + + return data + + +def _validate_manifest(data: Any) -> bool: + """Return True when ``data`` matches the minimum manifest shape.""" + if not isinstance(data, dict): + return False + version = data.get("version") + if not isinstance(version, int) or version > SUPPORTED_SCHEMA_VERSION: + # Future schema version we don't understand — refuse rather than + # guess. Older schemas (version < 1) aren't supported either. + return False + providers = data.get("providers") + if not isinstance(providers, dict): + return False + for pname, pblock in providers.items(): + if not isinstance(pname, str) or not isinstance(pblock, dict): + return False + models = pblock.get("models") + if not isinstance(models, list): + return False + for m in models: + if not isinstance(m, dict): + return False + if not isinstance(m.get("id"), str) or not m["id"].strip(): + return False + return True + + +def _read_disk_cache() -> tuple[dict[str, Any] | None, float]: + """Return ``(data_or_none, mtime)``. mtime is 0 if file is missing.""" + path = _cache_path() + try: + mtime = path.stat().st_mtime + except (OSError, FileNotFoundError): + return (None, 0.0) + try: + with open(path) as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + return (None, 0.0) + if not _validate_manifest(data): + return (None, 0.0) + return (data, mtime) + + +def _write_disk_cache(data: dict[str, Any]) -> None: + path = _cache_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") + os.replace(tmp, path) + except OSError as exc: + logger.info("model catalog cache write failed: %s", exc) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]: + """Return the parsed model catalog manifest, or an empty dict on failure. + + Callers should treat a missing provider/model as "use the in-repo fallback" + — never raise from this function so the CLI keeps working offline. + """ + global _catalog_cache, _catalog_cache_source_mtime + + cfg = _load_catalog_config() + if not cfg["enabled"]: + return {} + + ttl_seconds = max(0.0, cfg["ttl_hours"] * 3600.0) + + disk_data, disk_mtime = _read_disk_cache() + now = time.time() + disk_fresh = disk_data is not None and (now - disk_mtime) < ttl_seconds + + # In-process cache hit: disk hasn't changed since we loaded it and still fresh. + if ( + not force_refresh + and _catalog_cache is not None + and disk_data is not None + and disk_mtime == _catalog_cache_source_mtime + and disk_fresh + ): + return _catalog_cache + + # Disk is fresh enough — use it without a network hit. + if not force_refresh and disk_fresh and disk_data is not None: + _catalog_cache = disk_data + _catalog_cache_source_mtime = disk_mtime + return disk_data + + # Need to (re)fetch. If it fails, fall back to any stale disk copy. + fetched = _fetch_manifest(cfg["url"], DEFAULT_FETCH_TIMEOUT) + if fetched is not None: + _write_disk_cache(fetched) + new_disk_data, new_mtime = _read_disk_cache() + if new_disk_data is not None: + _catalog_cache = new_disk_data + _catalog_cache_source_mtime = new_mtime + return new_disk_data + _catalog_cache = fetched + _catalog_cache_source_mtime = now + return fetched + + if disk_data is not None: + _catalog_cache = disk_data + _catalog_cache_source_mtime = disk_mtime + return disk_data + + return {} + + +def _fetch_provider_override(provider: str) -> dict[str, Any] | None: + """If ``model_catalog.providers..url`` is set, fetch that instead.""" + cfg = _load_catalog_config() + if not cfg["enabled"]: + return None + provider_cfg = cfg["providers"].get(provider) + if not isinstance(provider_cfg, dict): + return None + override_url = provider_cfg.get("url") + if not isinstance(override_url, str) or not override_url.strip(): + return None + # Override fetches skip the disk cache because they're usually + # third-party self-hosted. Re-request on every call but with a short + # timeout so they don't block the picker. + return _fetch_manifest(override_url.strip(), DEFAULT_FETCH_TIMEOUT) + + +def _get_provider_block(provider: str) -> dict[str, Any] | None: + """Return the provider's manifest block, respecting per-provider overrides.""" + override = _fetch_provider_override(provider) + if override is not None: + block = override.get("providers", {}).get(provider) + if isinstance(block, dict): + return block + + catalog = get_catalog() + if not catalog: + return None + block = catalog.get("providers", {}).get(provider) + return block if isinstance(block, dict) else None + + +def get_curated_openrouter_models() -> list[tuple[str, str]] | None: + """Return OpenRouter's curated ``[(id, description), ...]`` from the manifest. + + Returns ``None`` when the manifest is unavailable, so callers can fall + back to their hardcoded list. + """ + block = _get_provider_block("openrouter") + if not block: + return None + out: list[tuple[str, str]] = [] + for m in block.get("models", []): + mid = str(m.get("id") or "").strip() + if not mid: + continue + desc = str(m.get("description") or "") + out.append((mid, desc)) + return out or None + + +def get_curated_nous_models() -> list[str] | None: + """Return Nous Portal's curated list of model ids from the manifest. + + Returns ``None`` when the manifest is unavailable. + """ + block = _get_provider_block("nous") + if not block: + return None + out: list[str] = [] + for m in block.get("models", []): + mid = str(m.get("id") or "").strip() + if mid: + out.append(mid) + return out or None + + +def reset_cache() -> None: + """Clear the in-process cache. Used by tests and ``hermes model --refresh``.""" + global _catalog_cache, _catalog_cache_source_mtime + _catalog_cache = None + _catalog_cache_source_mtime = 0.0 diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 23ddc6f3ca..dbc1a1e2b6 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -876,7 +876,16 @@ def fetch_openrouter_models( if _openrouter_catalog_cache is not None and not force_refresh: return list(_openrouter_catalog_cache) - fallback = list(OPENROUTER_MODELS) + # Prefer the remotely-hosted catalog manifest; fall back to the in-repo + # snapshot when the manifest is unreachable. Both are curated lists that + # drive the picker; the OpenRouter live /v1/models filter (tool support, + # free pricing) is applied on top either way. + try: + from hermes_cli.model_catalog import get_curated_openrouter_models + remote = get_curated_openrouter_models() + except Exception: + remote = None + fallback = list(remote) if remote else list(OPENROUTER_MODELS) preferred_ids = [mid for mid, _ in fallback] try: @@ -929,6 +938,24 @@ def model_ids(*, force_refresh: bool = False) -> list[str]: return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)] +def get_curated_nous_model_ids() -> list[str]: + """Return the curated Nous Portal model-id list. + + Prefers the remotely-hosted catalog manifest (published under + ``website/static/api/model-catalog.json``); falls back to the in-repo + snapshot in ``_PROVIDER_MODELS["nous"]`` when the manifest is + unreachable. Always returns a list (never None). + """ + try: + from hermes_cli.model_catalog import get_curated_nous_models + remote = get_curated_nous_models() + except Exception: + remote = None + if remote: + return list(remote) + return list(_PROVIDER_MODELS.get("nous", [])) + + def _ai_gateway_model_is_free(pricing: Any) -> bool: """Return True if an AI Gateway model has $0 input AND output pricing.""" if not isinstance(pricing, dict): diff --git a/scripts/build_model_catalog.py b/scripts/build_model_catalog.py new file mode 100755 index 0000000000..cd21c929e7 --- /dev/null +++ b/scripts/build_model_catalog.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Build the Hermes Model Catalog — a centralized JSON manifest of curated models. + +This script reads the in-repo hardcoded curated lists (``OPENROUTER_MODELS``, +``_PROVIDER_MODELS["nous"]``) and writes them to a JSON manifest that the +Hermes CLI fetches at runtime. Publishing the catalog through the docs site +lets maintainers update model lists without shipping a Hermes release. + +The runtime fetcher falls back to the same in-repo hardcoded lists if the +manifest is unreachable, so this script is a convenience for keeping the +manifest in sync — not a source of truth. + +Usage:: + + python scripts/build_model_catalog.py + +Output: ``website/static/api/model-catalog.json`` + +Live URL (after ``deploy-site.yml`` runs on merge to main): +``https://hermes-agent.nousresearch.com/docs/api/model-catalog.json`` +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timezone + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +# Ensure HERMES_HOME is set for imports that touch it at module level. +os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes")) + +from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS # noqa: E402 + +OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "model-catalog.json") +CATALOG_VERSION = 1 + + +def build_catalog() -> dict: + return { + "version": CATALOG_VERSION, + "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "metadata": { + "source": "hermes-agent repo", + "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog", + }, + "providers": { + "openrouter": { + "metadata": { + "display_name": "OpenRouter", + "note": ( + "Descriptions drive picker badges. Live /api/v1/models " + "filters curated ids by tool-calling support and free pricing." + ), + }, + "models": [ + {"id": mid, "description": desc} + for mid, desc in OPENROUTER_MODELS + ], + }, + "nous": { + "metadata": { + "display_name": "Nous Portal", + "note": ( + "Free-tier gating is determined live via Portal pricing " + "(partition_nous_models_by_tier), not this manifest." + ), + }, + "models": [ + {"id": mid} + for mid in _PROVIDER_MODELS.get("nous", []) + ], + }, + }, + } + + +def main() -> int: + catalog = build_catalog() + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + with open(OUTPUT_PATH, "w") as fh: + json.dump(catalog, fh, indent=2) + fh.write("\n") + + print(f"Wrote {OUTPUT_PATH}") + for provider, block in catalog["providers"].items(): + print(f" {provider}: {len(block['models'])} models") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py new file mode 100644 index 0000000000..2b757ac79b --- /dev/null +++ b/tests/hermes_cli/test_model_catalog.py @@ -0,0 +1,284 @@ +"""Tests for hermes_cli.model_catalog — remote manifest fetch + cache + fallback.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + """Isolate HERMES_HOME + reset any module-level catalog cache per test.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Force a fresh catalog module state for each test. + import importlib + from hermes_cli import model_catalog + importlib.reload(model_catalog) + yield home + model_catalog.reset_cache() + + +def _valid_manifest() -> dict: + return { + "version": 1, + "updated_at": "2026-04-25T22:00:00Z", + "metadata": {"source": "test"}, + "providers": { + "openrouter": { + "metadata": {"display_name": "OpenRouter"}, + "models": [ + {"id": "anthropic/claude-opus-4.7", "description": "recommended"}, + {"id": "openai/gpt-5.4", "description": ""}, + {"id": "openrouter/elephant-alpha", "description": "free"}, + ], + }, + "nous": { + "metadata": {"display_name": "Nous Portal"}, + "models": [ + {"id": "anthropic/claude-opus-4.7"}, + {"id": "moonshotai/kimi-k2.6"}, + ], + }, + }, + } + + +class TestValidation: + def test_accepts_well_formed_manifest(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + assert _validate_manifest(_valid_manifest()) is True + + def test_rejects_non_dict(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + assert _validate_manifest("string") is False + assert _validate_manifest([]) is False + assert _validate_manifest(None) is False + + def test_rejects_missing_version(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + del m["version"] + assert _validate_manifest(m) is False + + def test_rejects_future_version(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + m["version"] = 999 + assert _validate_manifest(m) is False + + def test_rejects_missing_providers(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + del m["providers"] + assert _validate_manifest(m) is False + + def test_rejects_malformed_model_entry(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + m["providers"]["openrouter"]["models"][0] = {"id": ""} # empty id + assert _validate_manifest(m) is False + + def test_rejects_non_string_model_id(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + m["providers"]["openrouter"]["models"][0] = {"id": 42} + assert _validate_manifest(m) is False + + +class TestFetchSuccess: + def test_fetch_and_cache_writes_disk(self, isolated_home): + from hermes_cli import model_catalog + manifest = _valid_manifest() + with patch.object( + model_catalog, "_fetch_manifest", return_value=manifest + ) as fetch: + result = model_catalog.get_catalog(force_refresh=True) + + assert result == manifest + assert fetch.called + + cache_file = model_catalog._cache_path() + assert cache_file.exists() + with open(cache_file) as fh: + assert json.load(fh) == manifest + + def test_second_call_uses_in_process_cache(self, isolated_home): + from hermes_cli import model_catalog + manifest = _valid_manifest() + with patch.object( + model_catalog, "_fetch_manifest", return_value=manifest + ) as fetch: + model_catalog.get_catalog(force_refresh=True) + model_catalog.get_catalog() # should not hit network again + assert fetch.call_count == 1 + + def test_force_refresh_always_refetches(self, isolated_home): + from hermes_cli import model_catalog + manifest = _valid_manifest() + with patch.object( + model_catalog, "_fetch_manifest", return_value=manifest + ) as fetch: + model_catalog.get_catalog(force_refresh=True) + model_catalog.get_catalog(force_refresh=True) + assert fetch.call_count == 2 + + +class TestFetchFailure: + def test_network_failure_returns_empty_when_no_cache(self, isolated_home): + from hermes_cli import model_catalog + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + result = model_catalog.get_catalog(force_refresh=True) + assert result == {} + + def test_network_failure_falls_back_to_disk_cache(self, isolated_home): + from hermes_cli import model_catalog + # Prime disk cache with a fresh copy. + manifest = _valid_manifest() + with patch.object(model_catalog, "_fetch_manifest", return_value=manifest): + model_catalog.get_catalog(force_refresh=True) + + # Now wipe in-process cache and simulate network failure on refetch. + model_catalog.reset_cache() + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + result = model_catalog.get_catalog(force_refresh=True) + + assert result == manifest + + def test_fetch_failure_falls_back_to_stale_cache(self, isolated_home): + from hermes_cli import model_catalog + manifest = _valid_manifest() + # Write stale cache directly (mtime in the past). + cache = model_catalog._cache_path() + cache.parent.mkdir(parents=True, exist_ok=True) + with open(cache, "w") as fh: + json.dump(manifest, fh) + old = time.time() - 30 * 24 * 3600 # 30 days ago + import os as _os + _os.utime(cache, (old, old)) + + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + result = model_catalog.get_catalog() + + # Stale cache is better than nothing. + assert result == manifest + + +class TestCuratedAccessors: + def test_openrouter_returns_tuples(self, isolated_home): + from hermes_cli import model_catalog + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ): + result = model_catalog.get_curated_openrouter_models() + assert result == [ + ("anthropic/claude-opus-4.7", "recommended"), + ("openai/gpt-5.4", ""), + ("openrouter/elephant-alpha", "free"), + ] + + def test_nous_returns_ids(self, isolated_home): + from hermes_cli import model_catalog + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ): + result = model_catalog.get_curated_nous_models() + assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"] + + def test_openrouter_returns_none_when_catalog_empty(self, isolated_home): + from hermes_cli import model_catalog + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + assert model_catalog.get_curated_openrouter_models() is None + + def test_nous_returns_none_when_catalog_empty(self, isolated_home): + from hermes_cli import model_catalog + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + assert model_catalog.get_curated_nous_models() is None + + +class TestDisabled: + def test_disabled_config_short_circuits(self, isolated_home): + from hermes_cli import model_catalog + with patch.object( + model_catalog, + "_load_catalog_config", + return_value={ + "enabled": False, + "url": "http://ignored", + "ttl_hours": 24.0, + "providers": {}, + }, + ): + with patch.object(model_catalog, "_fetch_manifest") as fetch: + result = model_catalog.get_catalog() + assert result == {} + fetch.assert_not_called() + + +class TestProviderOverride: + def test_override_url_takes_precedence(self, isolated_home): + from hermes_cli import model_catalog + + override_payload = { + "version": 1, + "providers": { + "openrouter": { + "models": [ + {"id": "override/model", "description": "custom"}, + ] + } + }, + } + + def fake_fetch(url, timeout): + if "override" in url: + return override_payload + return _valid_manifest() + + with patch.object( + model_catalog, + "_load_catalog_config", + return_value={ + "enabled": True, + "url": "http://master", + "ttl_hours": 24.0, + "providers": {"openrouter": {"url": "http://override"}}, + }, + ): + with patch.object(model_catalog, "_fetch_manifest", side_effect=fake_fetch): + result = model_catalog.get_curated_openrouter_models() + + assert result == [("override/model", "custom")] + + +class TestIntegrationWithModelsModule: + """Exercise the fallback paths via the real callers in hermes_cli.models.""" + + def test_curated_nous_ids_falls_back_to_hardcoded_on_empty_catalog( + self, isolated_home + ): + from hermes_cli import model_catalog + from hermes_cli.models import get_curated_nous_model_ids, _PROVIDER_MODELS + + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + result = get_curated_nous_model_ids() + + assert result == list(_PROVIDER_MODELS["nous"]) + + def test_curated_nous_ids_prefers_manifest(self, isolated_home): + from hermes_cli import model_catalog + from hermes_cli.models import get_curated_nous_model_ids + + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ): + result = get_curated_nous_model_ids() + + assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"] diff --git a/website/docs/reference/model-catalog.md b/website/docs/reference/model-catalog.md new file mode 100644 index 0000000000..3393ffeebf --- /dev/null +++ b/website/docs/reference/model-catalog.md @@ -0,0 +1,103 @@ +--- +sidebar_position: 11 +title: Model Catalog +description: Remotely-hosted manifest driving curated model picker lists for OpenRouter and Nous Portal. +--- + +# Model Catalog + +Hermes fetches curated model lists for **OpenRouter** and **Nous Portal** from a JSON manifest hosted alongside the docs site. This lets maintainers update picker lists without shipping a new `hermes-agent` release. + +When the manifest is unreachable (offline, network blocked, hosting failure), Hermes silently falls back to the in-repo snapshot that ships with the CLI. The manifest never breaks the picker — worst case you see whatever list was bundled with your installed version. + +## Live manifest URL + +``` +https://hermes-agent.nousresearch.com/docs/api/model-catalog.json +``` + +Published on every merge to `main` via the existing `deploy-site.yml` GitHub Pages pipeline. The source of truth lives in the repo at `website/static/api/model-catalog.json`. + +## Schema + +```json +{ + "version": 1, + "updated_at": "2026-04-25T22:00:00Z", + "metadata": {}, + "providers": { + "openrouter": { + "metadata": {}, + "models": [ + {"id": "moonshotai/kimi-k2.6", "description": "recommended", "metadata": {}}, + {"id": "openai/gpt-5.4", "description": ""} + ] + }, + "nous": { + "metadata": {}, + "models": [ + {"id": "anthropic/claude-opus-4.7"}, + {"id": "moonshotai/kimi-k2.6"} + ] + } + } +} +``` + +Field notes: + +- **`version`** — integer schema version. Future schemas bump this; Hermes refuses manifests with versions it doesn't understand and falls back to the hardcoded snapshot. +- **`metadata`** — free-form dict at the manifest, provider, and model level. Any keys. Hermes ignores unknown fields, so you can annotate entries (`"tier": "paid"`, `"tags": [...]`, etc.) without coordinating a schema change. +- **`description`** — OpenRouter-only. Drives picker badge text (`"recommended"`, `"free"`, or empty). Nous Portal doesn't use this — free-tier gating is determined live from the Portal's pricing endpoint. +- **Pricing and context length** are NOT in the manifest. Those come from live provider APIs (`/v1/models` endpoints, models.dev) at fetch time. + +## Fetch behavior + +| When | What happens | +|---|---| +| `/model` or `hermes model` | Fetches if disk cache is stale, else uses cache | +| Disk cache fresh (< TTL) | No network hit | +| Network failure with cache | Silent fallback to cache, one log line | +| Network failure, no cache | Silent fallback to in-repo snapshot | +| Manifest fails schema validation | Treated as unreachable | + +Cache location: `~/.hermes/cache/model_catalog.json`. + +## Config + +```yaml +model_catalog: + enabled: true + url: https://hermes-agent.nousresearch.com/docs/api/model-catalog.json + ttl_hours: 24 + providers: {} +``` + +Set `enabled: false` to disable remote fetch entirely and always use the in-repo snapshot. + +### Per-provider override URLs + +Third parties can self-host their own curation list using the same schema. Point a provider at a custom URL: + +```yaml +model_catalog: + providers: + openrouter: + url: https://example.com/my-openrouter-curation.json +``` + +The overriding manifest only needs to populate the provider block(s) it cares about. Other providers continue to resolve against the master URL. + +## Updating the manifest + +Maintainers: + +```bash +# Re-generate from the in-repo hardcoded lists (keeps manifest in sync after +# editing OPENROUTER_MODELS or _PROVIDER_MODELS["nous"] in hermes_cli/models.py). +python scripts/build_model_catalog.py +``` + +Then PR the resulting change to `website/static/api/model-catalog.json` to `main`. The docs site auto-deploys on merge and the new manifest is live within a few minutes. + +You can also hand-edit the JSON directly for fine-grained metadata changes that don't belong in the in-repo snapshot — the generator script is a convenience, not the single source of truth. diff --git a/website/sidebars.ts b/website/sidebars.ts index b3663e9da5..b654291810 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -613,6 +613,7 @@ const sidebars: SidebarsConfig = { 'reference/tools-reference', 'reference/toolsets-reference', 'reference/mcp-config-reference', + 'reference/model-catalog', 'reference/skills-catalog', 'reference/optional-skills-catalog', 'reference/faq', diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json new file mode 100644 index 0000000000..a2ef50a1e1 --- /dev/null +++ b/website/static/api/model-catalog.json @@ -0,0 +1,259 @@ +{ + "version": 1, + "updated_at": "2026-04-26T12:34:42Z", + "metadata": { + "source": "hermes-agent repo", + "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" + }, + "providers": { + "openrouter": { + "metadata": { + "display_name": "OpenRouter", + "note": "Descriptions drive picker badges. Live /api/v1/models filters curated ids by tool-calling support and free pricing." + }, + "models": [ + { + "id": "moonshotai/kimi-k2.6", + "description": "recommended" + }, + { + "id": "deepseek/deepseek-v4-pro", + "description": "" + }, + { + "id": "deepseek/deepseek-v4-flash", + "description": "" + }, + { + "id": "anthropic/claude-opus-4.7", + "description": "" + }, + { + "id": "anthropic/claude-opus-4.6", + "description": "" + }, + { + "id": "anthropic/claude-sonnet-4.6", + "description": "" + }, + { + "id": "qwen/qwen3.6-plus", + "description": "" + }, + { + "id": "anthropic/claude-sonnet-4.5", + "description": "" + }, + { + "id": "anthropic/claude-haiku-4.5", + "description": "" + }, + { + "id": "openrouter/elephant-alpha", + "description": "free" + }, + { + "id": "openai/gpt-5.5", + "description": "" + }, + { + "id": "openai/gpt-5.4-mini", + "description": "" + }, + { + "id": "xiaomi/mimo-v2.5-pro", + "description": "" + }, + { + "id": "xiaomi/mimo-v2.5", + "description": "" + }, + { + "id": "openai/gpt-5.3-codex", + "description": "" + }, + { + "id": "google/gemini-3-pro-image-preview", + "description": "" + }, + { + "id": "google/gemini-3-flash-preview", + "description": "" + }, + { + "id": "google/gemini-3.1-pro-preview", + "description": "" + }, + { + "id": "google/gemini-3.1-flash-lite-preview", + "description": "" + }, + { + "id": "qwen/qwen3.5-plus-02-15", + "description": "" + }, + { + "id": "qwen/qwen3.5-35b-a3b", + "description": "" + }, + { + "id": "stepfun/step-3.5-flash", + "description": "" + }, + { + "id": "minimax/minimax-m2.7", + "description": "" + }, + { + "id": "minimax/minimax-m2.5", + "description": "" + }, + { + "id": "minimax/minimax-m2.5:free", + "description": "free" + }, + { + "id": "z-ai/glm-5.1", + "description": "" + }, + { + "id": "z-ai/glm-5v-turbo", + "description": "" + }, + { + "id": "z-ai/glm-5-turbo", + "description": "" + }, + { + "id": "x-ai/grok-4.20", + "description": "" + }, + { + "id": "nvidia/nemotron-3-super-120b-a12b", + "description": "" + }, + { + "id": "nvidia/nemotron-3-super-120b-a12b:free", + "description": "free" + }, + { + "id": "arcee-ai/trinity-large-preview:free", + "description": "free" + }, + { + "id": "arcee-ai/trinity-large-thinking", + "description": "" + }, + { + "id": "openai/gpt-5.5-pro", + "description": "" + }, + { + "id": "openai/gpt-5.4-nano", + "description": "" + } + ] + }, + "nous": { + "metadata": { + "display_name": "Nous Portal", + "note": "Free-tier gating is determined live via Portal pricing (partition_nous_models_by_tier), not this manifest." + }, + "models": [ + { + "id": "moonshotai/kimi-k2.6" + }, + { + "id": "deepseek/deepseek-v4-pro" + }, + { + "id": "deepseek/deepseek-v4-flash" + }, + { + "id": "xiaomi/mimo-v2.5-pro" + }, + { + "id": "xiaomi/mimo-v2.5" + }, + { + "id": "anthropic/claude-opus-4.7" + }, + { + "id": "anthropic/claude-opus-4.6" + }, + { + "id": "anthropic/claude-sonnet-4.6" + }, + { + "id": "anthropic/claude-sonnet-4.5" + }, + { + "id": "anthropic/claude-haiku-4.5" + }, + { + "id": "openai/gpt-5.5" + }, + { + "id": "openai/gpt-5.4-mini" + }, + { + "id": "openai/gpt-5.3-codex" + }, + { + "id": "google/gemini-3-pro-preview" + }, + { + "id": "google/gemini-3-flash-preview" + }, + { + "id": "google/gemini-3.1-pro-preview" + }, + { + "id": "google/gemini-3.1-flash-lite-preview" + }, + { + "id": "qwen/qwen3.5-plus-02-15" + }, + { + "id": "qwen/qwen3.5-35b-a3b" + }, + { + "id": "stepfun/step-3.5-flash" + }, + { + "id": "minimax/minimax-m2.7" + }, + { + "id": "minimax/minimax-m2.5" + }, + { + "id": "minimax/minimax-m2.5:free" + }, + { + "id": "z-ai/glm-5.1" + }, + { + "id": "z-ai/glm-5v-turbo" + }, + { + "id": "z-ai/glm-5-turbo" + }, + { + "id": "x-ai/grok-4.20-beta" + }, + { + "id": "nvidia/nemotron-3-super-120b-a12b" + }, + { + "id": "arcee-ai/trinity-large-thinking" + }, + { + "id": "openai/gpt-5.5-pro" + }, + { + "id": "openai/gpt-5.4-nano" + } + ] + } + } +} From a5624203831705454a3c8e2f72b3931f5b9ec74e Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Sun, 26 Apr 2026 15:19:16 +0700 Subject: [PATCH 07/41] fix(tui): robust clipboard handling with debug logging and headless detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Ctrl+C in Hermes TUI shows 'copied' but clipboard often empty. Root causes: - Native Linux tools (xclip, wl-copy) require DISPLAY/WAYLAND_DISPLAY; in headless Docker/SSH they fail or hang. - OSC 52 fallback requires terminal emulator support; when absent, sequence is dropped silently. - Dashboard OSC 52 → Clipboard API path fails due to missing user gesture; errors were silently caught. - User feedback 'copied selection' was shown unconditionally, regardless of success. Solution implemented: - Short-circuit Linux native clipboard probing when no display server is present (no DISPLAY and no WAYLAND_DISPLAY). Avoids futile attempts and timeouts. - Add HERMES_TUI_DEBUG_CLIPBOARD env var (1/true). When set, TUI logs to stderr which clipboard path is used, probe results on Linux, and whether OSC 52 was emitted. Greatly improves diagnosability. - Improve dashboard clipboard error handling: replace empty catch blocks with console.warn messages for OSC 52 decode/Write failures and direct copy/paste errors. Makes browser permission/user-gesture failures visible in DevTools. - Add comprehensive clipboard troubleshooting documentation to README and AGENTS, covering OSC 52 verification, tmux config, Docker/headless constraints, env vars, dashboard caveats, and fallback strategies. Technical details: - in ui-tui/packages/hermes-ink/src/ink/termio/osc.ts: - Early return on Linux if both DISPLAY and WAYLAND_DISPLAY unset. - Refactor probe sequence to async with 500ms timeout, caching result; subsequent copies use cached tool immediately. - Emit debug logs when HERMES_TUI_DEBUG_CLIPBOARD=1. - in ink.tsx: log when OSC 52 not emitted (native or tmux path in use) in debug mode. - : OSC 52 handler and Ctrl+Shift+C handler now log warnings to console on Clipboard API rejection with error message. - Documentation: new 'Clipboard Troubleshooting' section in README; new 'Clipboard environment variables and pitfalls' subsection in AGENTS.md (Known Pitfalls). Tests: full ui-tui test suite (292 tests) passes; clipboard and OSC tests unaffected. No breaking changes. Files changed: - ui-tui/packages/hermes-ink/src/ink/termio/osc.ts - ui-tui/packages/hermes-ink/src/ink/ink.tsx - web/src/pages/ChatPage.tsx - README.md - AGENTS.md - CHANGELOG.md (new) --- AGENTS.md | 21 +++++ CHANGELOG.md | 21 +++++ README.md | 94 ++++++++++++++++++- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 4 +- .../packages/hermes-ink/src/ink/termio/osc.ts | 92 ++++++++++-------- web/src/pages/ChatPage.tsx | 24 +++-- 6 files changed, 204 insertions(+), 52 deletions(-) create mode 100644 CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 05a6742d41..92f8f355f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -667,6 +667,27 @@ def profile_env(tmp_path, monkeypatch): return home ``` +### Clipboard environment variables and pitfalls + +Hermes TUI clipboard handling uses a three-tier strategy: + +1. **Native OS tools** (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) — available **only** when a display server is present (`$DISPLAY` for X11 or `$WAYLAND_DISPLAY` for Wayland). On Linux in headless environments (Docker, remote SSH without X11 forwarding), these tools fail or hang. The code now short-circuits immediately if both variables are unset. +2. **tmux buffer** (`tmux load-buffer`) — when inside a tmux session; requires `set-clipboard on` for system clipboard propagation. +3. **OSC 52 escape** — written to stdout; the terminal emulator must intercept and set the clipboard. Support varies: iTerm2 disables it by default, VS Code may block it behind a permission prompt, and raw PTYs without an emulator drop it silently. + +**Environment variables:** + +| Variable | Purpose | +|---|---| +| `HERMES_TUI_CLIPBOARD_OSC52` or `HERMES_TUI_COPY_OSC52` | Force OSC 52 emission (`1`/`true`) or disable (`0`/`false`). Ignored when native tools are expected to work (macOS local, or Linux with `$DISPLAY/$WAYLAND_DISPLAY`). | +| `HERMES_TUI_DEBUG_CLIPBOARD` | Set to `1` to log detailed debug information to stderr about which clipboard path is taken, probe results on Linux, and why OSC 52 may be suppressed. | +| `SSH_CONNECTION` | Presence indicates an SSH session; this gates native tool usage (to avoid writing to the remote machine's clipboard) and prefers OSC 52. | +| `TMUX`, `STY` | Used to detect tmux/screen and apply appropriate passthrough or buffer loading. | + +**Common false-positive:** The UI message "copied selection" is displayed **unconditionally** after Ctrl+C, even if all clipboard mechanisms fail. If you're in a headless Docker container or a non-OSC52-capable terminal, you'll see the message but nothing is copied. Use `HERMES_TUI_DEBUG_CLIPBOARD=1` to diagnose. + +**Dashboard caveat:** The dashboard's `Ctrl+C` path relies on OSC 52 → xterm's handler → browser Clipboard API. Because the Clipboard API requires a user gesture, this can fail if the OSC 52 response arrives outside the key event's activation window. Use `Ctrl+Shift+C` (Cmd+Shift+C on macOS) as a reliable fallback; it calls `navigator.clipboard.writeText()` directly inside the key handler. + --- ## Testing diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..c7ce7f76c2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- **TUI clipboard copy** — Native tool probing on Linux now short-circuits immediately when `$DISPLAY` and `$WAYLAND_DISPLAY` are both unset, avoiding wasted time and silent failures in headless environments (Docker, CI). (Hermes Ink / osc.ts) +- **TUI debug visibility** — Added `HERMES_TUI_DEBUG_CLIPBOARD` environment variable. When set, the TUI logs which clipboard mechanism is used, probe results, and why OSC 52 might be suppressed. Helps users and operators diagnose copy failures. +- **Dashboard clipboard logging** — Silent failures in OSC 52 → Clipboard API bridge and direct `Ctrl+Shift+C` copy are now logged to the browser console with explanatory warnings, replacing empty catch blocks. Makes clipboard permission issues and gesture-timeout failures visible during development. +- **Documentation** — Added comprehensive "Clipboard Troubleshooting" section to README covering OSC 52 verification, tmux configuration, Docker/headless constraints, environment variables, and dashboard caveats. AGENTS.md now documents all clipboard-related environment variables and known failure modes. + +### Changed + +- Desktop and dashboard clipboard error handling is now consistent: all Clipboard API rejections and native tool failures produce diagnostic logs rather than being swallowed. + + \ No newline at end of file diff --git a/README.md b/README.md index 11390fb2b2..a604207500 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,99 @@ scripts/run_tests.sh - 💬 [Discord](https://discord.gg/NousResearch) - 📚 [Skills Hub](https://agentskills.io) - 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues) -- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. + - 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. + +--- + +## Clipboard Troubleshooting + +Hermes TUI (standalone) and dashboard both support copying via `Ctrl+C` / `Cmd+C`. This requires either: + +- A terminal with **OSC 52** support enabled, **or** +- Native clipboard utilities (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) available in PATH **and** a running display server (X11 or Wayland). + +If the UI says "copied" but the text is not in your system clipboard, follow these steps. + +### Standalone TUI (`hermes --tui`) + +#### Verify OSC 52 support + +Run this in the same terminal you use for Hermes: +```bash +printf '\e]52;c;%s\a' "$(echo -n 'test-osc52' | base64)" && echo +``` +Then paste (Cmd+V / Ctrl+Shift+V). If you see `test-osc52`, OSC 52 works. + +If it fails, enable OSC 52 in your terminal: + +| Terminal | Setting | +|--------------|-------------------------------------------------------------------------| +| iTerm2 | Preferences → General → Selection → check "Copy to pasteboard" | +| Kitty | `allow_remote_control yes` (default: on) | +| WezTerm | `enable_osc52_copy = true` | +| VS Code | Usually works; if blocked, check DevTools console for permission error | +| GNOME | Enabled by default | + +#### tmux users + +tmux absorbs OSC 52 unless explicitly configured. Add to `~/.tmux.conf`: +```tmux +set -g set-clipboard on +set -g allow-passthrough on +``` +Then reload: `tmux source-file ~/.tmux.conf`. + +#### Docker/headless environments + +Inside a Docker container, `$DISPLAY` and `$WAYLAND_DISPLAY` are typically unset, so native clipboard tools fail immediately. OSC 52 is the only path — it must be supported by your local terminal emulator (the one connected to the container's PTY). If your terminal doesn't support OSC 52, consider: + +- Using `ssh -X` / `ssh -Y` to forward X11 and run `xclip` on the host via SSH +- Running Hermes on the host directly, not inside a container +- Writing copied text to a file: `/copy` saves to `~/.hermes/clipboard.txt` (fallback) + +#### Force OSC 52 emission + +If your terminal supports OSC 52 but Hermes isn't emitting it (e.g., inside SSH where native tools are skipped), set: +```bash +export HERMES_TUI_CLIPBOARD_OSC52=1 +hermes --tui +``` + +#### Debug mode + +To see exactly which clipboard path Hermes takes: +```bash +export HERMES_TUI_DEBUG_CLIPBOARD=1 +hermes --tui +``` +Then attempt a copy and watch stderr for messages like: +``` +[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable +[clipboard] [native] Linux: clipboard probe complete → xclip +[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use +``` + +### Dashboard (`hermes dashboard` → /chat) + +The dashboard uses the browser's Clipboard API. There are two copy paths: + +1. **Ctrl/Cmd+Shift+C** — direct copy from xterm's selection (most reliable) +2. **Ink's Ctrl+C** — emits OSC 52 → xterm OSC 52 handler → Clipboard API; this is more fragile because the Clipboard API requires a **user gesture**. In some browsers the OSC 52 response is processed outside the original key event's activation window, causing a silent failure. + +If copy doesn't work in the dashboard: +- Use `Ctrl+Shift+C` (Linux/Windows) or `Cmd+Shift+C` (macOS) instead +- Check the browser console (F12) for warnings like `[dashboard clipboard] OSC 52 write failed` +- Ensure the page has clipboard permissions (browser may ask on first use) + +Clicking the "copy last response" button also sends `/copy` over the WebSocket, which suffers from the same OSC 52 timing issue. + +### When all else fails: file-based fallback + +You can save copied text to a file manually: +```bash +hermes --tui # inside TUI, use /copy which includes a file fallback in future versions +``` +Or implement a custom skill that writes the last assistant message to disk. --- diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 7422cf4637..481fae8cb7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1309,11 +1309,11 @@ export default class Ink { const text = getSelectedText(this.selection, this.frontFrame.screen) if (text) { - // Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux - // drops it silently unless allow-passthrough is on — no regression). void setClipboard(text).then(raw => { if (raw) { this.options.stdout.write(raw) + } else if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error('[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use') } }) } diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts index 3230767e7e..8fce739e33 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -198,11 +198,33 @@ export async function setClipboard(text: string): Promise { // Cached after first attempt so repeated mouse-ups skip the probe chain. let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined +/** Internal: probe once and cache — wl-copy first, then xclip, then xsel. */ +async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> { + const opts = { useCwd: false, timeout: 500 } + + const r = await execFileNoThrow('wl-copy', [], opts) + if (r.code === 0) { + return 'wl-copy' + } + + const r2 = await execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) + if (r2.code === 0) { + return 'xclip' + } + + const r3 = await execFileNoThrow('xsel', ['--clipboard', '--input'], opts) + return r3.code === 0 ? 'xsel' : null +} + /** * Shell out to a native clipboard utility as a safety net for OSC 52. * Only called when not in an SSH session (over SSH, these would write to * the remote machine's clipboard — OSC 52 is the right path there). * Fire-and-forget: failures are silent since OSC 52 may have succeeded. + * + * Linux behaviour: if DISPLAY and WAYLAND_DISPLAY are both unset, native + * clipboard tools cannot work (they need a display server). In that case + * we skip probing entirely and treat linuxCopy as permanently null. */ function copyNative(text: string): void { const opts = { input: text, useCwd: false, timeout: 2000 } @@ -210,51 +232,44 @@ function copyNative(text: string): void { switch (process.platform) { case 'darwin': void execFileNoThrow('pbcopy', [], opts) - return + case 'linux': { - if (linuxCopy === null) { - return - } - - if (linuxCopy === 'wl-copy') { - void execFileNoThrow('wl-copy', [], opts) - - return - } - - if (linuxCopy === 'xclip') { - void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) - - return - } - - if (linuxCopy === 'xsel') { - void execFileNoThrow('xsel', ['--clipboard', '--input'], opts) - - return - } - - // First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner. - void execFileNoThrow('wl-copy', [], opts).then(r => { - if (r.code === 0) { - linuxCopy = 'wl-copy' - + // If we already probed (success or hard-fail), short-circuit. + if (linuxCopy !== undefined) { + if (linuxCopy === null) { + // No working native tool — skip silently. return } + // linuxCopy is a known-working tool; fire-and-forget. + void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) + return + } - void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(r2 => { - if (r2.code === 0) { - linuxCopy = 'xclip' + // No display server → native tools will fail immediately. Cache null. + if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable') + } + linuxCopy = null + return + } - return - } + // First call: probe in the background and cache the result for future copies. + // We don't await — this is fire-and-forget. + void (async () => { + const winner = await probeLinuxCopy() + linuxCopy = winner - void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(r3 => { - linuxCopy = r3.code === 0 ? 'xsel' : null - }) - }) - }) + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error(`[clipboard] [native] Linux: clipboard probe complete → ${winner ?? 'no tool available'}`) + } + + // Actually perform the copy with the discovered tool. + if (winner) { + void execFileNoThrow(winner, winner === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) + } + })() return } @@ -263,7 +278,6 @@ function copyNative(text: string): void { // clip.exe is always available on Windows. Unicode handling is // imperfect (system locale encoding) but good enough for a fallback. void execFileNoThrow('clip', [], opts) - return } } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 80398104a1..372653c50e 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -269,17 +269,17 @@ export default function ChatPage() { const payload = data.slice(semi + 1); if (payload === "?" || payload === "") return false; // read/clear — ignore try { - // atob returns a binary string (one byte per char); we need UTF-8 - // decode so multi-byte codepoints (≥, →, emoji, CJK) round-trip - // correctly. Without this step, the three UTF-8 bytes of `≥` - // would land in the clipboard as the three separate Latin-1 - // characters `≥`. const binary = atob(payload); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); const text = new TextDecoder("utf-8").decode(bytes); - navigator.clipboard.writeText(text).catch(() => {}); - } catch { - // Malformed base64 — silently drop. + navigator.clipboard.writeText(text).catch((err) => { + // Most common reason: the Clipboard API requires a user gesture. + // This can fail when the OSC 52 response arrives outside the + // original keydown event's activation. Log to aid debugging. + console.warn("[dashboard clipboard] OSC 52 write failed:", err.message); + }); + } catch (e) { + console.warn("[dashboard clipboard] malformed OSC 52 payload"); } return true; }); @@ -296,7 +296,9 @@ export default function ChatPage() { if (copyModifier && ev.key.toLowerCase() === "c") { const sel = term.getSelection(); if (sel) { - navigator.clipboard.writeText(sel).catch(() => {}); + navigator.clipboard.writeText(sel).catch((err) => { + console.warn("[dashboard clipboard] direct copy failed:", err.message); + }); ev.preventDefault(); return false; } @@ -308,7 +310,9 @@ export default function ChatPage() { .then((text) => { if (text) term.paste(text); }) - .catch(() => {}); + .catch((err) => { + console.warn("[dashboard clipboard] paste failed:", err.message); + }); ev.preventDefault(); return false; } From 0f3a6f0fb3a1e7c6322af960614cc055f8d32a0c Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Sun, 26 Apr 2026 18:37:21 +0700 Subject: [PATCH 08/41] fix(clipboard): dashboard Ctrl+C direct copy; TUI honest feedback; HERMES_TUI_FORCE_OSC52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard copy: direct Clipboard API on Ctrl+C/Cmd+C (user gesture); send Escape to TUI to clear selection; Ctrl+Shift+C kept as fallback. - TUI /copy: copySelection() async; only reports success if OSC52 emitted. - Add HERMES_TUI_FORCE_OSC52 env var to override native-tool detection. - Fixes "copied N chars" false-positive when clipboard backend absent. Changes: web/src/pages/ChatPage.tsx — direct navigator.clipboard.writeText ui-tui/packages/hermes-ink/src/ink/ink.tsx — async copySelection ui-tui/packages/hermes-ink/src/ink/termio/osc.ts — HERMES_TUI_FORCE_OSC52 ui-tui/src/app/slash/commands/core.ts — async /copy with honest feedback --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 30 ++++++++++++++----- .../packages/hermes-ink/src/ink/termio/osc.ts | 6 +++- ui-tui/src/app/slash/commands/core.ts | 12 ++++++-- web/src/pages/ChatPage.tsx | 7 ++++- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 481fae8cb7..40e3762800 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1301,7 +1301,13 @@ export default class Ink { * highlight. Matches iTerm2's copy-on-select behavior where the selected * region stays visible after the automatic copy. */ - copySelectionNoClear(): string { + /** + * Copy the current text selection to the system clipboard without clearing the + * selection. Returns the copied text on success (empty if no selection or + * clipboard operation failed). Success is determined by whether an OSC 52 + * sequence was emitted (native/tmux paths do not produce a sequence). + */ + async copySelectionNoClear(): Promise { if (!hasSelection(this.selection)) { return '' } @@ -1309,28 +1315,36 @@ export default class Ink { const text = getSelectedText(this.selection, this.frontFrame.screen) if (text) { - void setClipboard(text).then(raw => { + try { + const raw = await setClipboard(text) if (raw) { this.options.stdout.write(raw) - } else if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + return text + } + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { console.error('[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use') } - }) + } catch (err) { + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error('[clipboard] [osc52] error:', err) + } + } } - return text + return '' } /** * Copy the current text selection to the system clipboard via OSC 52 - * and clear the selection. Returns the copied text (empty if no selection). + * and clear the selection. Returns the copied text (empty if no selection + * or clipboard operation failed). */ - copySelection(): string { + async copySelection(): Promise { if (!hasSelection(this.selection)) { return '' } - const text = this.copySelectionNoClear() + const text = await this.copySelectionNoClear() clearSelection(this.selection) this.notifySelectionChange() diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts index 8fce739e33..a7e232c96e 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -84,7 +84,11 @@ export function getClipboardPath(): ClipboardPath { } export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env): boolean { - const override = (env.HERMES_TUI_CLIPBOARD_OSC52 ?? env.HERMES_TUI_COPY_OSC52 ?? '').trim() + const override = ( + env.HERMES_TUI_FORCE_OSC52 ?? + env.HERMES_TUI_CLIPBOARD_OSC52 ?? + env.HERMES_TUI_COPY_OSC52 ?? '' + ).trim() if (ENV_ON_RE.test(override)) { return true diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 6d927fedcc..a792fe117c 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -251,11 +251,17 @@ export const coreCommands: SlashCommand[] = [ { help: 'copy selection or assistant message', name: 'copy', - run: (arg, ctx) => { + run: async (arg, ctx) => { const { sys } = ctx.transcript - if (!arg && ctx.composer.hasSelection && ctx.composer.selection.copySelection()) { - return sys('copied selection') + if (!arg && ctx.composer.hasSelection) { + const text = await ctx.composer.selection.copySelection() + if (text) { + // Include character count to match user's reported message format + return sys(`copied ${text.length} characters`) + } else { + return sys('clipboard copy failed — no OSC 52 emitted; see HERMES_TUI_DEBUG_CLIPBOARD') + } } if (arg && Number.isNaN(parseInt(arg, 10))) { diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 372653c50e..cd5afcbb3b 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -290,7 +290,9 @@ export default function ChatPage() { term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; - const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; + // Copy: Cmd+C on macOS, Ctrl+C on other platforms (when selection exists) + // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others + const copyModifier = isMac ? ev.metaKey : ev.ctrlKey; const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; if (copyModifier && ev.key.toLowerCase() === "c") { @@ -299,9 +301,12 @@ export default function ChatPage() { navigator.clipboard.writeText(sel).catch((err) => { console.warn("[dashboard clipboard] direct copy failed:", err.message); }); + // Send Escape to the TUI to clear its selection overlay + term.write("\x1b"); ev.preventDefault(); return false; } + // No selection → let Ctrl+C pass through as interrupt } if (pasteModifier && ev.key.toLowerCase() === "v") { From 2511207cb088e24d0325d5814d9a1b197a7c8ad8 Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Sun, 26 Apr 2026 18:46:55 +0700 Subject: [PATCH 09/41] chore: revert docs --- AGENTS.md | 21 ------------ CHANGELOG.md | 21 ------------ README.md | 94 +--------------------------------------------------- 3 files changed, 1 insertion(+), 135 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 92f8f355f8..05a6742d41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -667,27 +667,6 @@ def profile_env(tmp_path, monkeypatch): return home ``` -### Clipboard environment variables and pitfalls - -Hermes TUI clipboard handling uses a three-tier strategy: - -1. **Native OS tools** (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) — available **only** when a display server is present (`$DISPLAY` for X11 or `$WAYLAND_DISPLAY` for Wayland). On Linux in headless environments (Docker, remote SSH without X11 forwarding), these tools fail or hang. The code now short-circuits immediately if both variables are unset. -2. **tmux buffer** (`tmux load-buffer`) — when inside a tmux session; requires `set-clipboard on` for system clipboard propagation. -3. **OSC 52 escape** — written to stdout; the terminal emulator must intercept and set the clipboard. Support varies: iTerm2 disables it by default, VS Code may block it behind a permission prompt, and raw PTYs without an emulator drop it silently. - -**Environment variables:** - -| Variable | Purpose | -|---|---| -| `HERMES_TUI_CLIPBOARD_OSC52` or `HERMES_TUI_COPY_OSC52` | Force OSC 52 emission (`1`/`true`) or disable (`0`/`false`). Ignored when native tools are expected to work (macOS local, or Linux with `$DISPLAY/$WAYLAND_DISPLAY`). | -| `HERMES_TUI_DEBUG_CLIPBOARD` | Set to `1` to log detailed debug information to stderr about which clipboard path is taken, probe results on Linux, and why OSC 52 may be suppressed. | -| `SSH_CONNECTION` | Presence indicates an SSH session; this gates native tool usage (to avoid writing to the remote machine's clipboard) and prefers OSC 52. | -| `TMUX`, `STY` | Used to detect tmux/screen and apply appropriate passthrough or buffer loading. | - -**Common false-positive:** The UI message "copied selection" is displayed **unconditionally** after Ctrl+C, even if all clipboard mechanisms fail. If you're in a headless Docker container or a non-OSC52-capable terminal, you'll see the message but nothing is copied. Use `HERMES_TUI_DEBUG_CLIPBOARD=1` to diagnose. - -**Dashboard caveat:** The dashboard's `Ctrl+C` path relies on OSC 52 → xterm's handler → browser Clipboard API. Because the Clipboard API requires a user gesture, this can fail if the OSC 52 response arrives outside the key event's activation window. Use `Ctrl+Shift+C` (Cmd+Shift+C on macOS) as a reliable fallback; it calls `navigator.clipboard.writeText()` directly inside the key handler. - --- ## Testing diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index c7ce7f76c2..0000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,21 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Fixed - -- **TUI clipboard copy** — Native tool probing on Linux now short-circuits immediately when `$DISPLAY` and `$WAYLAND_DISPLAY` are both unset, avoiding wasted time and silent failures in headless environments (Docker, CI). (Hermes Ink / osc.ts) -- **TUI debug visibility** — Added `HERMES_TUI_DEBUG_CLIPBOARD` environment variable. When set, the TUI logs which clipboard mechanism is used, probe results, and why OSC 52 might be suppressed. Helps users and operators diagnose copy failures. -- **Dashboard clipboard logging** — Silent failures in OSC 52 → Clipboard API bridge and direct `Ctrl+Shift+C` copy are now logged to the browser console with explanatory warnings, replacing empty catch blocks. Makes clipboard permission issues and gesture-timeout failures visible during development. -- **Documentation** — Added comprehensive "Clipboard Troubleshooting" section to README covering OSC 52 verification, tmux configuration, Docker/headless constraints, environment variables, and dashboard caveats. AGENTS.md now documents all clipboard-related environment variables and known failure modes. - -### Changed - -- Desktop and dashboard clipboard error handling is now consistent: all Clipboard API rejections and native tool failures produce diagnostic logs rather than being swallowed. - - \ No newline at end of file diff --git a/README.md b/README.md index a604207500..11390fb2b2 100644 --- a/README.md +++ b/README.md @@ -169,99 +169,7 @@ scripts/run_tests.sh - 💬 [Discord](https://discord.gg/NousResearch) - 📚 [Skills Hub](https://agentskills.io) - 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues) - - 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. - ---- - -## Clipboard Troubleshooting - -Hermes TUI (standalone) and dashboard both support copying via `Ctrl+C` / `Cmd+C`. This requires either: - -- A terminal with **OSC 52** support enabled, **or** -- Native clipboard utilities (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) available in PATH **and** a running display server (X11 or Wayland). - -If the UI says "copied" but the text is not in your system clipboard, follow these steps. - -### Standalone TUI (`hermes --tui`) - -#### Verify OSC 52 support - -Run this in the same terminal you use for Hermes: -```bash -printf '\e]52;c;%s\a' "$(echo -n 'test-osc52' | base64)" && echo -``` -Then paste (Cmd+V / Ctrl+Shift+V). If you see `test-osc52`, OSC 52 works. - -If it fails, enable OSC 52 in your terminal: - -| Terminal | Setting | -|--------------|-------------------------------------------------------------------------| -| iTerm2 | Preferences → General → Selection → check "Copy to pasteboard" | -| Kitty | `allow_remote_control yes` (default: on) | -| WezTerm | `enable_osc52_copy = true` | -| VS Code | Usually works; if blocked, check DevTools console for permission error | -| GNOME | Enabled by default | - -#### tmux users - -tmux absorbs OSC 52 unless explicitly configured. Add to `~/.tmux.conf`: -```tmux -set -g set-clipboard on -set -g allow-passthrough on -``` -Then reload: `tmux source-file ~/.tmux.conf`. - -#### Docker/headless environments - -Inside a Docker container, `$DISPLAY` and `$WAYLAND_DISPLAY` are typically unset, so native clipboard tools fail immediately. OSC 52 is the only path — it must be supported by your local terminal emulator (the one connected to the container's PTY). If your terminal doesn't support OSC 52, consider: - -- Using `ssh -X` / `ssh -Y` to forward X11 and run `xclip` on the host via SSH -- Running Hermes on the host directly, not inside a container -- Writing copied text to a file: `/copy` saves to `~/.hermes/clipboard.txt` (fallback) - -#### Force OSC 52 emission - -If your terminal supports OSC 52 but Hermes isn't emitting it (e.g., inside SSH where native tools are skipped), set: -```bash -export HERMES_TUI_CLIPBOARD_OSC52=1 -hermes --tui -``` - -#### Debug mode - -To see exactly which clipboard path Hermes takes: -```bash -export HERMES_TUI_DEBUG_CLIPBOARD=1 -hermes --tui -``` -Then attempt a copy and watch stderr for messages like: -``` -[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable -[clipboard] [native] Linux: clipboard probe complete → xclip -[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use -``` - -### Dashboard (`hermes dashboard` → /chat) - -The dashboard uses the browser's Clipboard API. There are two copy paths: - -1. **Ctrl/Cmd+Shift+C** — direct copy from xterm's selection (most reliable) -2. **Ink's Ctrl+C** — emits OSC 52 → xterm OSC 52 handler → Clipboard API; this is more fragile because the Clipboard API requires a **user gesture**. In some browsers the OSC 52 response is processed outside the original key event's activation window, causing a silent failure. - -If copy doesn't work in the dashboard: -- Use `Ctrl+Shift+C` (Linux/Windows) or `Cmd+Shift+C` (macOS) instead -- Check the browser console (F12) for warnings like `[dashboard clipboard] OSC 52 write failed` -- Ensure the page has clipboard permissions (browser may ask on first use) - -Clicking the "copy last response" button also sends `/copy` over the WebSocket, which suffers from the same OSC 52 timing issue. - -### When all else fails: file-based fallback - -You can save copied text to a file manually: -```bash -hermes --tui # inside TUI, use /copy which includes a file fallback in future versions -``` -Or implement a custom skill that writes the last assistant message to disk. +- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. --- From e8441c4c0fd993c6876dc40ad25f0e2d2e0b63f7 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 26 Apr 2026 05:44:38 -0700 Subject: [PATCH 10/41] fix(clipboard): report native/tmux success, keep Ctrl+Shift+C on dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on #16020 salvage. Three corrections: 1. Truth signal for /copy Before: success was 'OSC 52 sequence was emitted to stdout'. That's false on local Linux inside tmux (emitSequence=false), so /copy kept printing 'clipboard copy failed' to users whose xclip/wl-copy had already succeeded fire-and-forget. Fix: setClipboard() now returns { sequence, success } where success = native-fired OR tmux-buffer-loaded OR osc52-emitted. copyNative() returns a boolean telling setClipboard whether a native attempt was made. /copy only shows 'failed' when literally no path was taken. 2. Dashboard keybinding Before: Ctrl+C for copy on non-Mac (Ctrl+Shift+C for paste). That swallows SIGINT when a stale selection is present and breaks the xterm/gnome-terminal/konsole/Windows-Terminal convention where Ctrl+C in a terminal emulator is always SIGINT. The real bug was that clipboard writes lost user-gesture through OSC-52 round-trips, which the direct writeText already fixes. Fix: revert copyModifier to Ctrl+Shift+C on non-Mac. Direct writeText in the keydown handler preserves user gesture. term.write Escape replaced with term.clearSelection() (works without relying on TUI input mode). 3. Error toast text Before: 'see HERMES_TUI_DEBUG_CLIPBOARD' — tells users how to debug but not how to fix. Fix: point users at HERMES_TUI_FORCE_OSC52=1 first (the actual escape hatch), mention the debug var second. --- .../hermes-ink/src/ink/hooks/use-selection.ts | 8 +- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 27 ++++--- .../hermes-ink/src/ink/termio/osc.test.ts | 22 +++++ .../packages/hermes-ink/src/ink/termio/osc.ts | 81 ++++++++++++++----- .../src/__tests__/createSlashHandler.test.ts | 2 +- ui-tui/src/app/interfaces.ts | 2 +- ui-tui/src/app/slash/commands/core.ts | 4 +- ui-tui/src/types/hermes-ink.d.ts | 4 +- web/src/pages/ChatPage.tsx | 20 +++-- 9 files changed, 120 insertions(+), 50 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts index 58761fe241..bd4ef87fc7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts @@ -9,9 +9,9 @@ import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.j * Returns no-op functions when fullscreen mode is disabled. */ export function useSelection(): { - copySelection: () => string + copySelection: () => Promise /** Copy without clearing the highlight (for copy-on-select). */ - copySelectionNoClear: () => string + copySelectionNoClear: () => Promise clearSelection: () => void hasSelection: () => boolean /** Read the raw mutable selection state (for drag-to-scroll). */ @@ -48,8 +48,8 @@ export function useSelection(): { return useMemo(() => { if (!ink) { return { - copySelection: () => '', - copySelectionNoClear: () => '', + copySelection: async () => '', + copySelectionNoClear: async () => '', clearSelection: () => {}, hasSelection: () => false, getState: () => null, diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 40e3762800..93b10f6520 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1296,16 +1296,12 @@ export default class Ink { this.prevFrameContaminated = true } - /** - * Copy the current selection to the clipboard without clearing the - * highlight. Matches iTerm2's copy-on-select behavior where the selected - * region stays visible after the automatic copy. - */ /** * Copy the current text selection to the system clipboard without clearing the - * selection. Returns the copied text on success (empty if no selection or - * clipboard operation failed). Success is determined by whether an OSC 52 - * sequence was emitted (native/tmux paths do not produce a sequence). + * selection. Returns the copied text when a clipboard path succeeded (native + * tool fired, tmux buffer loaded, or OSC 52 emitted), or '' when no path was + * taken (e.g. headless Linux without tmux). Matches iTerm2's copy-on-select + * behavior where the selected region stays visible after the automatic copy. */ async copySelectionNoClear(): Promise { if (!hasSelection(this.selection)) { @@ -1316,17 +1312,22 @@ export default class Ink { if (text) { try { - const raw = await setClipboard(text) - if (raw) { - this.options.stdout.write(raw) + const { sequence, success } = await setClipboard(text) + + if (sequence) { + this.options.stdout.write(sequence) + } + + if (success) { return text } + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { - console.error('[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use') + console.error('[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence') } } catch (err) { if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { - console.error('[clipboard] [osc52] error:', err) + console.error('[clipboard] error:', err) } } } diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts index 4860544479..4c54f8d18a 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts @@ -26,4 +26,26 @@ describe('shouldEmitClipboardSequence', () => { shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv) ).toBe(false) }) + + it('HERMES_TUI_FORCE_OSC52 takes precedence over TMUX suppression', () => { + // Without the override, local-in-tmux suppresses the OSC 52 sequence + // so the terminal multiplexer path wins. FORCE_OSC52=1 flips that + // back on for users whose tmux config supports passthrough. + expect(shouldEmitClipboardSequence({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv)).toBe(false) + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_FORCE_OSC52: '1', + TMUX: '/tmp/t,1,0' + } as NodeJS.ProcessEnv) + ).toBe(true) + }) + + it('HERMES_TUI_FORCE_OSC52=0 suppresses OSC 52 even for remote or plain terminals', () => { + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_FORCE_OSC52: '0', + SSH_CONNECTION: '1' + } as NodeJS.ProcessEnv) + ).toBe(false) + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts index a7e232c96e..c60196b8c1 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -166,10 +166,23 @@ export async function tmuxLoadBuffer(text: string): Promise { * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over * SSH these would write to the remote clipboard — OSC 52 is the right path there. * - * Returns the sequence for the caller to write to stdout (raw OSC 52 - * outside tmux, DCS-wrapped inside). + * Returns { sequence, success }: + * - `sequence` is the bytes to write to stdout (raw OSC 52 outside tmux, + * DCS-wrapped inside; empty string when we shouldn't emit). + * - `success` is true when we believe SOME path reached the clipboard: + * native tool fired (local), tmux buffer loaded, or an OSC 52 sequence + * was emitted to the terminal. False only when no path was taken at + * all (headless Linux with no tmux + osc52 suppressed, effectively). + * This is best-effort — pbcopy/xclip are fire-and-forget, and OSC 52 + * depends on the outer terminal honoring the sequence — but it lets + * callers distinguish "nothing attempted" from "attempted". */ -export async function setClipboard(text: string): Promise { +export type ClipboardResult = { + sequence: string + success: boolean +} + +export async function setClipboard(text: string): Promise { const b64 = Buffer.from(text, 'utf8').toString('base64') const raw = osc(OSC.CLIPBOARD, 'c', b64) const emitSequence = shouldEmitClipboardSequence(process.env) @@ -181,20 +194,28 @@ export async function setClipboard(text: string): Promise { // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829). // Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY // forever but SSH_CONNECTION is in tmux's default update-environment and - // clears on local attach. Fire-and-forget. - if (!process.env['SSH_CONNECTION']) { - copyNative(text) - } + // clears on local attach. Fire-and-forget, but `copyNativeAttempted` + // tells us whether ANY native path will be tried on this platform. + const nativeAttempted = + !process.env['SSH_CONNECTION'] && copyNative(text) const tmuxBufferLoaded = await tmuxLoadBuffer(text) // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling // too, and BEL works everywhere for OSC 52. - if (tmuxBufferLoaded) { - return emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '' - } + const sequence = tmuxBufferLoaded + ? (emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '') + : (emitSequence ? raw : '') - return emitSequence ? raw : '' + // Success if any path was taken. Native and tmux are fire-and-forget, + // so we can't truly confirm the clipboard was written — but if native + // was attempted OR tmux buffer loaded OR we emitted OSC 52, the user's + // paste is likely to work. The only false case is "we did literally + // nothing" (e.g. local-in-tmux with osc52 suppressed and tmux buffer + // load failed), in which case reporting failure to the user is honest. + const success = nativeAttempted || tmuxBufferLoaded || sequence.length > 0 + + return { sequence, success } } // Linux clipboard tool: undefined = not yet probed, null = none available. @@ -207,16 +228,19 @@ async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> { const opts = { useCwd: false, timeout: 500 } const r = await execFileNoThrow('wl-copy', [], opts) + if (r.code === 0) { return 'wl-copy' } const r2 = await execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) + if (r2.code === 0) { return 'xclip' } const r3 = await execFileNoThrow('xsel', ['--clipboard', '--input'], opts) + return r3.code === 0 ? 'xsel' : null } @@ -226,28 +250,37 @@ async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> { * the remote machine's clipboard — OSC 52 is the right path there). * Fire-and-forget: failures are silent since OSC 52 may have succeeded. * + * Returns true when a native copy path was (or will be) attempted — i.e. + * we'll spawn pbcopy on macOS, clip on Windows, or a known-working Linux + * tool. Returns false only when we know no native tool is viable (Linux + * without DISPLAY/WAYLAND_DISPLAY, or previously-probed-to-null). The + * return value is used to decide whether to tell the user the copy + * succeeded — spawning is best-effort but good enough to claim success. + * * Linux behaviour: if DISPLAY and WAYLAND_DISPLAY are both unset, native * clipboard tools cannot work (they need a display server). In that case * we skip probing entirely and treat linuxCopy as permanently null. */ -function copyNative(text: string): void { +function copyNative(text: string): boolean { const opts = { input: text, useCwd: false, timeout: 2000 } switch (process.platform) { case 'darwin': void execFileNoThrow('pbcopy', [], opts) - return + return true case 'linux': { // If we already probed (success or hard-fail), short-circuit. if (linuxCopy !== undefined) { if (linuxCopy === null) { // No working native tool — skip silently. - return + return false } + // linuxCopy is a known-working tool; fire-and-forget. void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) - return + + return true } // No display server → native tools will fail immediately. Cache null. @@ -255,12 +288,15 @@ function copyNative(text: string): void { if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable') } - linuxCopy = null - return - } + linuxCopy = null + + return false + } // First call: probe in the background and cache the result for future copies. - // We don't await — this is fire-and-forget. + // We don't await — this is fire-and-forget. Treat as an attempt: + // the probe will discover a tool and spawn it. If probing finds + // nothing, the NEXT copy will short-circuit above. void (async () => { const winner = await probeLinuxCopy() linuxCopy = winner @@ -275,15 +311,18 @@ function copyNative(text: string): void { } })() - return + return true } case 'win32': // clip.exe is always available on Windows. Unicode handling is // imperfect (system locale encoding) but good enough for a fallback. void execFileNoThrow('clip', [], opts) - return + + return true } + + return false } /** @internal test-only */ diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 4bd3503103..01c20bba61 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -363,7 +363,7 @@ const buildComposer = () => ({ hasSelection: false, paste: vi.fn(), queueRef: { current: [] as string[] }, - selection: { copySelection: vi.fn(() => '') }, + selection: { copySelection: vi.fn(async () => '') }, setInput: vi.fn() }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9049c17f9a..5386a4e149 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -32,7 +32,7 @@ export type StatusBarMode = 'bottom' | 'off' | 'top' export interface SelectionApi { clearSelection: () => void - copySelection: () => string + copySelection: () => Promise } export interface CompletionItem { diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index a792fe117c..7aea2fa47a 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -256,11 +256,11 @@ export const coreCommands: SlashCommand[] = [ if (!arg && ctx.composer.hasSelection) { const text = await ctx.composer.selection.copySelection() + if (text) { - // Include character count to match user's reported message format return sys(`copied ${text.length} characters`) } else { - return sys('clipboard copy failed — no OSC 52 emitted; see HERMES_TUI_DEBUG_CLIPBOARD') + return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details') } } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 507be85a34..ad69348486 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -83,8 +83,8 @@ declare module '@hermes/ink' { export function withInkSuspended(run: RunExternalProcess): Promise export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void export function useSelection(): { - readonly copySelection: () => string - readonly copySelectionNoClear: () => string + readonly copySelection: () => Promise + readonly copySelectionNoClear: () => Promise readonly clearSelection: () => void readonly hasSelection: () => boolean readonly getState: () => unknown diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index cd5afcbb3b..525739b192 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -290,23 +290,31 @@ export default function ChatPage() { term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; - // Copy: Cmd+C on macOS, Ctrl+C on other platforms (when selection exists) - // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others - const copyModifier = isMac ? ev.metaKey : ev.ctrlKey; + // Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C + // is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal / + // konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists; + // without a selection it passes through to the TUI so agents can still + // react to the keypress. + // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others. + const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; if (copyModifier && ev.key.toLowerCase() === "c") { const sel = term.getSelection(); if (sel) { + // Direct writeText inside the keydown handler preserves the user + // gesture — async round-trips through OSC 52 can lose activation + // and fail with "Document is not focused". navigator.clipboard.writeText(sel).catch((err) => { console.warn("[dashboard clipboard] direct copy failed:", err.message); }); - // Send Escape to the TUI to clear its selection overlay - term.write("\x1b"); + // Clear xterm.js's highlight after copy (matches gnome-terminal). + term.clearSelection(); ev.preventDefault(); return false; } - // No selection → let Ctrl+C pass through as interrupt + // No selection → fall through so the TUI receives Ctrl+Shift+C + // (or the bare ev if the user used a different modifier). } if (pasteModifier && ev.key.toLowerCase() === "v") { From 35c57cc46b88710a98c4d43107b87b4ab828e3eb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 05:47:37 -0700 Subject: [PATCH 11/41] fix(gateway): suppress tool-progress bubbles after interrupt (#16034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the LLM response carries N parallel tool calls, the agent fires N tool.started events back-to-back before its interrupt check runs. A user sending /stop mid-batch would see the '⚡ Interrupting current task' ack followed by a trail of 🔍 web_search bubbles for the remaining events in the batch — making the interrupt feel ignored. progress_callback and the drain loop in send_progress_messages now check agent.is_interrupted (via agent_holder[0], the existing cross-scope handle). Events that arrive after interrupt are dropped at both the queueing and rendering stages. The '⚡ Interrupting' message is sent through a separate adapter path and is unaffected. --- gateway/run.py | 32 +++ tests/gateway/test_run_progress_interrupt.py | 215 +++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 tests/gateway/test_run_progress_interrupt.py diff --git a/gateway/run.py b/gateway/run.py index 05578fa0d8..f1aafcdf30 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9370,6 +9370,22 @@ class GatewayRunner: if event_type not in ("tool.started",): return + # Suppress tool-progress bubbles once the user has sent `stop`. + # When the LLM response carries N parallel tool calls, the agent + # fires N "tool.started" events back-to-back before checking for + # interrupts — without this guard, a late `stop` still renders + # all N as 🔍 bubbles, making the interrupt feel ignored. + # (agent lives in run_sync's scope; agent_holder[0] is the shared + # handle across nested scopes — see line ~9607.) + try: + _agent_for_interrupt = agent_holder[0] if agent_holder else None + if _agent_for_interrupt is not None and getattr( + _agent_for_interrupt, "is_interrupted", False + ): + return + except Exception: + pass + # "new" mode: only report when tool changes if progress_mode == "new" and tool_name == last_tool[0]: return @@ -9476,6 +9492,22 @@ class GatewayRunner: raw = progress_queue.get_nowait() + # Drain silently when interrupted: events queued in the + # window between tool parse and interrupt processing + # should not render as bubbles. The "⚡ Interrupting + # current task" message is sent separately and is the + # last progress-flavored bubble the user should see. + try: + _agent_for_interrupt = agent_holder[0] if agent_holder else None + if _agent_for_interrupt is not None and getattr( + _agent_for_interrupt, "is_interrupted", False + ): + # Drop this event and continue draining. + await asyncio.sleep(0) + continue + except Exception: + pass + # Handle dedup messages: update last line with repeat counter if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__": _, base_msg, count = raw diff --git a/tests/gateway/test_run_progress_interrupt.py b/tests/gateway/test_run_progress_interrupt.py new file mode 100644 index 0000000000..23969677e0 --- /dev/null +++ b/tests/gateway/test_run_progress_interrupt.py @@ -0,0 +1,215 @@ +"""Tests for interrupt-aware tool-progress suppression in gateway. + +When a user sends `stop` while the agent is executing a batch of parallel +tool calls, the gateway's progress_callback should stop queuing 🔍 bubbles +and the drain loop should drop any already-queued events. Without this +guard, the stop acknowledgement appears first but is followed by a trail +of tool-progress bubbles for calls that were already parsed from the LLM +response — making the interrupt feel ignored. +""" + +import asyncio +import importlib +import sys +import time +import types +from types import SimpleNamespace + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import BasePlatformAdapter, SendResult +from gateway.session import SessionSource + + +class ProgressCaptureAdapter(BasePlatformAdapter): + def __init__(self, platform=Platform.TELEGRAM): + super().__init__(PlatformConfig(enabled=True, token="***"), platform) + self.sent = [] + self.edits = [] + self.typing = [] + + async def connect(self) -> bool: + return True + + async def disconnect(self) -> None: + return None + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + self.sent.append({"chat_id": chat_id, "content": content}) + return SendResult(success=True, message_id="progress-1") + + async def edit_message(self, chat_id, message_id, content) -> SendResult: + self.edits.append({"message_id": message_id, "content": content}) + return SendResult(success=True, message_id=message_id) + + async def send_typing(self, chat_id, metadata=None) -> None: + self.typing.append(chat_id) + + async def stop_typing(self, chat_id) -> None: + return None + + async def get_chat_info(self, chat_id: str): + return {"id": chat_id} + + +class PreInterruptAgent: + """Fires tool-progress events BEFORE the interrupt lands. + + These should render normally. Baseline for comparison with the + interrupted case — proves the harness renders events when no + interrupt is active. + """ + + def __init__(self, **kwargs): + self.tool_progress_callback = kwargs.get("tool_progress_callback") + self.tools = [] + self._interrupt_requested = False + + @property + def is_interrupted(self) -> bool: + return self._interrupt_requested + + def run_conversation(self, message, conversation_history=None, task_id=None): + self.tool_progress_callback("tool.started", "web_search", "first search", {}) + time.sleep(0.35) # let the drain loop process + return {"final_response": "done", "messages": [], "api_calls": 1} + + +class InterruptedAgent: + """Fires tool.started events AFTER interrupt — all should be suppressed. + + Mirrors the failure mode in the bug report: LLM returned N parallel + web_search calls, interrupt flag flipped, remaining events still + rendered as bubbles. With the fix, none of these should appear. + """ + + def __init__(self, **kwargs): + self.tool_progress_callback = kwargs.get("tool_progress_callback") + self.tools = [] + # Start already interrupted — simulates stop having already landed + # by the time the agent batch starts firing tool.started events. + self._interrupt_requested = True + + @property + def is_interrupted(self) -> bool: + return self._interrupt_requested + + def run_conversation(self, message, conversation_history=None, task_id=None): + # Parallel tool batch — in production these come from one LLM + # response with 5 tool_calls. All are post-interrupt. + self.tool_progress_callback("tool.started", "web_search", "cognee hermes", {}) + self.tool_progress_callback("tool.started", "web_search", "McBee deer hunting", {}) + self.tool_progress_callback("tool.started", "web_search", "kuzu graph db", {}) + self.tool_progress_callback("tool.started", "web_search", "moonshot kimi api", {}) + self.tool_progress_callback("tool.started", "web_search", "platform.moonshot.cn", {}) + time.sleep(0.35) # let the drain loop attempt to process the queue + return {"final_response": "interrupted", "messages": [], "api_calls": 1} + + +def _make_runner(adapter): + gateway_run = importlib.import_module("gateway.run") + GatewayRunner = gateway_run.GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.adapters = {adapter.platform: adapter} + runner._voice_mode = {} + runner._prefill_messages = [] + runner._ephemeral_system_prompt = "" + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._session_db = None + runner._running_agents = {} + runner._session_run_generation = {} + runner.hooks = SimpleNamespace(loaded_hooks=False) + runner.config = SimpleNamespace( + thread_sessions_per_user=False, + group_sessions_per_user=False, + stt_enabled=False, + ) + return runner + + +async def _run_once(monkeypatch, tmp_path, agent_cls, session_id): + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") + + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = agent_cls + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + adapter = ProgressCaptureAdapter() + runner = _make_runner(adapter) + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr( + gateway_run, + "_resolve_runtime_agent_kwargs", + lambda: {"api_key": "fake"}, + ) + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="-1001", + chat_type="group", + thread_id="17585", + ) + result = await runner._run_agent( + message="hi", + context_prompt="", + history=[], + source=source, + session_id=session_id, + session_key="agent:main:telegram:group:-1001:17585", + ) + return adapter, result + + +@pytest.mark.asyncio +async def test_baseline_non_interrupted_agent_renders_progress(monkeypatch, tmp_path): + """Sanity check: when is_interrupted is False, tool-progress renders normally.""" + adapter, result = await _run_once(monkeypatch, tmp_path, PreInterruptAgent, "sess-baseline") + assert result["final_response"] == "done" + rendered = " ".join(c["content"] for c in adapter.sent) + " " + " ".join( + c["content"] for c in adapter.edits + ) + assert "first search" in rendered, ( + "baseline agent should render its tool-progress event — " + "if this fails the test harness is broken, not the fix" + ) + + +@pytest.mark.asyncio +async def test_progress_suppressed_when_agent_is_interrupted(monkeypatch, tmp_path): + """Post-interrupt tool.started events must not render as bubbles. + + This is Bug B from the screenshot: user sends `stop`, agent acks with + ⚡ Interrupting, but 5 more 🔍 web_search bubbles still render because + their tool.started events were already parsed from the LLM response. + With the fix, progress_callback and the drain loop both check + is_interrupted and skip these events. + """ + adapter, result = await _run_once( + monkeypatch, tmp_path, InterruptedAgent, "sess-interrupted" + ) + assert result["final_response"] == "interrupted" + + rendered = " ".join(c["content"] for c in adapter.sent) + " " + " ".join( + c["content"] for c in adapter.edits + ) + + # None of the post-interrupt queries should appear. + for leaked_query in ( + "cognee hermes", + "McBee deer hunting", + "kuzu graph db", + "moonshot kimi api", + "platform.moonshot.cn", + ): + assert leaked_query not in rendered, ( + f"event '{leaked_query}' leaked into the UI after interrupt — " + f"progress_callback / drain loop is not checking is_interrupted" + ) From 67dcace412342ff11dff635c3f5002ed205ebabb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 05:51:37 -0700 Subject: [PATCH 12/41] docs(config): show options in comments for display settings (#16038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users who run `hermes setup` get `cli-config.yaml.example` copied verbatim (including comments) to ~/.hermes/config.yaml. But several display settings had thin comments that didn't enumerate the valid options, so users couldn't tell from reading their config what values each key accepts. - busy_input_mode: widen from 'CLI' to 'CLI and gateway platforms'; note /stop as gateway equivalent of Ctrl+C; add /busy_input_mode runtime hint - compact, interim_assistant_messages, bell_on_complete, show_reasoning, streaming: add true/false option lines showing effect of each value - skin: refresh the built-in skin list (was missing daylight, warm-lightmode, poseidon, sisyphus, charizard — 5 of 9 built-ins undocumented) --- cli-config.yaml.example | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 90d98490c5..56090dca8b 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -824,7 +824,9 @@ delegation: # Display # ============================================================================= display: - # Use compact banner mode + # Use compact banner mode (hides the ASCII-art banner, shows a single line). + # true: Compact single-line banner + # false: Full ASCII banner with tool/skill summary (default) compact: false # Tool progress display level (CLI and gateway) @@ -838,12 +840,15 @@ display: # Gateway-only natural mid-turn assistant updates. # When true, completed assistant status messages are sent as separate chat # messages. This is independent of tool_progress and gateway streaming. + # true: Send mid-turn assistant updates as separate messages (default) + # false: Only send the final response interim_assistant_messages: true - # What Enter does when Hermes is already busy in the CLI. + # What Enter does when Hermes is already busy (CLI and gateway platforms). # interrupt: Interrupt the current run and redirect Hermes (default) # queue: Queue your message for the next turn - # Ctrl+C always interrupts regardless of this setting. + # Ctrl+C (or /stop in gateway) always interrupts regardless of this setting. + # Toggle at runtime with /busy_input_mode . busy_input_mode: interrupt # Background process notifications (gateway/messaging only). @@ -859,17 +864,22 @@ display: # Play terminal bell when agent finishes a response. # Useful for long-running tasks — your terminal will ding when the agent is done. # Works over SSH. Most terminals can be configured to flash the taskbar or play a sound. + # true: Ring the terminal bell on each response + # false: Silent (default) bell_on_complete: false # Show model reasoning/thinking before each response. # When enabled, a dim box shows the model's thought process above the response. # Toggle at runtime with /reasoning show or /reasoning hide. + # true: Show the reasoning box + # false: Hide reasoning (default) show_reasoning: false # Stream tokens to the terminal as they arrive instead of waiting for the # full response. The response box opens on first token and text appears # line-by-line. Tool calls are still captured silently. - # Stream tokens to the terminal in real-time. Disable to wait for full responses. + # true: Stream tokens as they arrive (default) + # false: Wait for the full response before rendering streaming: true # ─────────────────────────────────────────────────────────────────────────── @@ -879,10 +889,15 @@ display: # response box label, and branding text. Change at runtime with /skin . # # Built-in skins: - # default — Classic Hermes gold/kawaii - # ares — Crimson/bronze war-god theme with spinner wings - # mono — Clean grayscale monochrome - # slate — Cool blue developer-focused + # default — Classic Hermes gold/kawaii + # ares — Crimson/bronze war-god theme with spinner wings + # mono — Clean grayscale monochrome + # slate — Cool blue developer-focused + # daylight — Bright light-mode theme + # warm-lightmode — Warm paper-tone light-mode theme + # poseidon — Sea-green/teal Olympian theme + # sisyphus — Earthy stone-and-moss theme + # charizard — Fiery orange dragon theme # # Custom skins: drop a YAML file in ~/.hermes/skins/.yaml # Schema (all fields optional, missing values inherit from default): From 4bda9dcade8bf1080a6c70841fe862f8c7229c00 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 05:52:05 -0700 Subject: [PATCH 13/41] fix(gateway): honor voice.auto_tts config in auto-TTS gate (#16007) (#16039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base adapter's auto-TTS path fired on any voice message unless the chat had explicitly run /voice off — it never read voice.auto_tts from config.yaml, so users who set auto_tts: false still got audio replies. Gate the base adapter on a three-layer decision instead: 1. chat in _auto_tts_enabled_chats (explicit /voice on|tts) → fire 2. chat in _auto_tts_disabled_chats (explicit /voice off) → suppress 3. else → voice.auto_tts global default Runner now pushes voice.auto_tts onto the adapter as _auto_tts_default and mirrors /voice on|tts chats into _auto_tts_enabled_chats via the existing _sync_voice_mode_state_to_adapter path. /voice off still wins. Closes #16007. --- gateway/platforms/base.py | 40 +++++++++-- gateway/run.py | 77 +++++++++++++++++---- tests/gateway/test_voice_command.py | 100 ++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 18 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 2732513854..8cb4f7c0eb 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1025,7 +1025,20 @@ class BasePlatformAdapter(ABC): self._post_delivery_callbacks: Dict[str, Any] = {} self._expected_cancelled_tasks: set[asyncio.Task] = set() self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None - # Chats where auto-TTS on voice input is disabled (set by /voice off) + # Auto-TTS on voice input: ``_auto_tts_default`` is the global default + # (``voice.auto_tts`` in config.yaml, pushed by GatewayRunner on connect). + # Per-chat overrides live in two sets populated from ``_voice_mode``: + # - ``_auto_tts_enabled_chats``: chat explicitly opted in via ``/voice on`` + # or ``/voice tts`` (mode is ``voice_only`` or ``all``). Fires even when + # the global default is False. + # - ``_auto_tts_disabled_chats``: chat explicitly opted out via + # ``/voice off`` (mode is ``off``). Suppresses auto-TTS even when the + # global default is True. + # The gate in _process_message() is: + # fire if chat in _auto_tts_enabled_chats + # OR (_auto_tts_default and chat not in _auto_tts_disabled_chats) + self._auto_tts_default: bool = False + self._auto_tts_enabled_chats: set = set() self._auto_tts_disabled_chats: set = set() # Chats where typing indicator is paused (e.g. during approval waits). # _keep_typing skips send_typing when the chat_id is in this set. @@ -1047,6 +1060,21 @@ class BasePlatformAdapter(ABC): def fatal_error_retryable(self) -> bool: return self._fatal_error_retryable + def _should_auto_tts_for_chat(self, chat_id: str) -> bool: + """Whether auto-TTS on voice input should fire for ``chat_id``. + + Decision layers (Issue #16007): + 1. Explicit ``/voice on`` or ``/voice tts`` → always fire (even if + ``voice.auto_tts`` is False). + 2. Explicit ``/voice off`` → never fire. + 3. Fall back to the global ``voice.auto_tts`` config default. + """ + if chat_id in self._auto_tts_enabled_chats: + return True + if chat_id in self._auto_tts_disabled_chats: + return False + return bool(self._auto_tts_default) + def set_fatal_error_handler(self, handler: Callable[["BasePlatformAdapter"], Awaitable[None] | None]) -> None: self._fatal_error_handler = handler @@ -2214,12 +2242,14 @@ class BasePlatformAdapter(ABC): logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files)) # Auto-TTS: if voice message, generate audio FIRST (before sending text) - # Skipped when the chat has voice mode disabled (/voice off) + # Gated via ``_should_auto_tts_for_chat``: fires when the chat has + # an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is + # True globally and no ``/voice off`` has been issued. _tts_path = None - if (event.message_type == MessageType.VOICE + if (self._should_auto_tts_for_chat(event.source.chat_id) + and event.message_type == MessageType.VOICE and text_content - and not media_files - and event.source.chat_id not in self._auto_tts_disabled_chats): + and not media_files): try: from tools.tts_tool import text_to_speech_tool, check_tts_requirements if check_tts_requirements(): diff --git a/gateway/run.py b/gateway/run.py index f1aafcdf30..497d9241c4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -881,23 +881,74 @@ class GatewayRunner: return if disabled: disabled_chats.add(chat_id) + # ``/voice off`` also clears any explicit enable — it's a hard override. + enabled_chats = getattr(adapter, "_auto_tts_enabled_chats", None) + if isinstance(enabled_chats, set): + enabled_chats.discard(chat_id) else: disabled_chats.discard(chat_id) - def _sync_voice_mode_state_to_adapter(self, adapter) -> None: - """Restore persisted /voice off state into a live platform adapter.""" - disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) - if not isinstance(disabled_chats, set): + def _set_adapter_auto_tts_enabled(self, adapter, chat_id: str, enabled: bool) -> None: + """Update an adapter's per-chat auto-TTS opt-in set if present. + + Used for ``/voice on``/``/voice tts`` where the user explicitly wants + auto-TTS even when ``voice.auto_tts`` is False globally. + """ + enabled_chats = getattr(adapter, "_auto_tts_enabled_chats", None) + if not isinstance(enabled_chats, set): return + if enabled: + enabled_chats.add(chat_id) + # An explicit opt-in clears any stale /voice off for this chat. + disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) + if isinstance(disabled_chats, set): + disabled_chats.discard(chat_id) + else: + enabled_chats.discard(chat_id) + + def _sync_voice_mode_state_to_adapter(self, adapter) -> None: + """Restore persisted /voice state into a live platform adapter. + + Populates three fields from config + ``self._voice_mode``: + - ``_auto_tts_default``: global default from ``voice.auto_tts`` + - ``_auto_tts_enabled_chats``: chats with mode ``voice_only``/``all`` + - ``_auto_tts_disabled_chats``: chats with mode ``off`` + """ platform = getattr(adapter, "platform", None) if not isinstance(platform, Platform): return - disabled_chats.clear() + + disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) + enabled_chats = getattr(adapter, "_auto_tts_enabled_chats", None) + if not isinstance(disabled_chats, set) and not isinstance(enabled_chats, set): + return + + # Push the global voice.auto_tts default (config.yaml) onto the adapter. + # Lazy import to avoid adding a module-level dep from gateway → hermes_cli. + try: + from hermes_cli.config import load_config as _load_full_config + _full_cfg = _load_full_config() + _auto_tts_default = bool( + (_full_cfg.get("voice") or {}).get("auto_tts", False) + ) + except Exception: + _auto_tts_default = False + if hasattr(adapter, "_auto_tts_default"): + adapter._auto_tts_default = _auto_tts_default + prefix = f"{platform.value}:" - disabled_chats.update( - key[len(prefix):] for key, mode in self._voice_mode.items() - if mode == "off" and key.startswith(prefix) - ) + if isinstance(disabled_chats, set): + disabled_chats.clear() + disabled_chats.update( + key[len(prefix):] for key, mode in self._voice_mode.items() + if mode == "off" and key.startswith(prefix) + ) + if isinstance(enabled_chats, set): + enabled_chats.clear() + enabled_chats.update( + key[len(prefix):] for key, mode in self._voice_mode.items() + if mode in ("voice_only", "all") and key.startswith(prefix) + ) async def _safe_adapter_disconnect(self, adapter, platform) -> None: """Call adapter.disconnect() defensively, swallowing any error. @@ -5977,7 +6028,7 @@ class GatewayRunner: self._voice_mode[voice_key] = "voice_only" self._save_voice_modes() if adapter: - self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) + self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) return ( "Voice mode enabled.\n" "I'll reply with voice when you send voice messages.\n" @@ -5993,7 +6044,7 @@ class GatewayRunner: self._voice_mode[voice_key] = "all" self._save_voice_modes() if adapter: - self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) + self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) return ( "Auto-TTS enabled.\n" "All replies will include a voice message." @@ -6032,7 +6083,7 @@ class GatewayRunner: self._voice_mode[voice_key] = "voice_only" self._save_voice_modes() if adapter: - self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) + self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) return "Voice mode enabled." else: self._voice_mode[voice_key] = "off" @@ -6083,7 +6134,7 @@ class GatewayRunner: adapter._voice_sources[guild_id] = event.source.to_dict() self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "all" self._save_voice_modes() - self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False) + self._set_adapter_auto_tts_enabled(adapter, event.source.chat_id, enabled=True) return ( f"Joined voice channel **{voice_channel.name}**.\n" f"I'll speak my replies and listen to you. Use /voice leave to disconnect." diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index ed36b976e5..2e9c54608a 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -177,6 +177,53 @@ class TestHandleVoiceCommand: assert adapter._auto_tts_disabled_chats == {"123"} + def test_sync_populates_enabled_chats_from_voice_modes(self, runner): + """Issue #16007: sync also restores per-chat /voice on|tts opt-ins. + + The adapter's ``_auto_tts_enabled_chats`` must mirror chats whose + persisted voice_mode is ``voice_only`` or ``all`` — without this, + ``/voice on`` was relying on a "not in disabled set" default that + silently enabled auto-TTS for every chat. + """ + from gateway.config import Platform + runner._voice_mode = { + "telegram:off_chat": "off", + "telegram:on_chat": "voice_only", + "telegram:tts_chat": "all", + "slack:999": "voice_only", # wrong platform, must be ignored + } + adapter = SimpleNamespace( + _auto_tts_default=False, + _auto_tts_disabled_chats=set(), + _auto_tts_enabled_chats=set(), + platform=Platform.TELEGRAM, + ) + + runner._sync_voice_mode_state_to_adapter(adapter) + + assert adapter._auto_tts_disabled_chats == {"off_chat"} + assert adapter._auto_tts_enabled_chats == {"on_chat", "tts_chat"} + + def test_sync_pushes_config_default_onto_adapter(self, runner, monkeypatch): + """Issue #16007: ``voice.auto_tts`` must propagate to ``_auto_tts_default``.""" + from gateway.config import Platform + + fake_cfg = {"voice": {"auto_tts": True}} + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: fake_cfg, + ) + adapter = SimpleNamespace( + _auto_tts_default=False, + _auto_tts_disabled_chats=set(), + _auto_tts_enabled_chats=set(), + platform=Platform.TELEGRAM, + ) + + runner._sync_voice_mode_state_to_adapter(adapter) + + assert adapter._auto_tts_default is True + def test_restart_restores_voice_off_state(self, runner, tmp_path): from gateway.config import Platform runner._VOICE_MODE_PATH.write_text(json.dumps({"telegram:123": "off"})) @@ -2706,3 +2753,56 @@ class TestUDPKeepalive: mock_conn.send_packet.assert_called_with(b'\xf8\xff\xfe') finally: DiscordAdapter._KEEPALIVE_INTERVAL = original_interval + + +# ===================================================================== +# BasePlatformAdapter._should_auto_tts_for_chat — gate for auto-TTS +# on voice input. Regression test for Issue #16007. +# ===================================================================== + +class TestShouldAutoTtsForChat: + """Three-layer gate: per-chat enable > per-chat disable > config default.""" + + def _make_adapter(self, *, default: bool, enabled=(), disabled=()): + """Build a bare adapter with only the attrs the gate reads.""" + adapter = SimpleNamespace( + _auto_tts_default=default, + _auto_tts_enabled_chats=set(enabled), + _auto_tts_disabled_chats=set(disabled), + ) + # Bind the unbound method — _should_auto_tts_for_chat only reads the + # three attrs above via ``self.``, so an unbound call works. + from gateway.platforms.base import BasePlatformAdapter + return BasePlatformAdapter._should_auto_tts_for_chat, adapter + + def test_default_false_no_override_suppresses(self): + """Issue #16007: voice.auto_tts=False and no per-chat state → no TTS.""" + fn, adapter = self._make_adapter(default=False) + assert fn(adapter, "chat1") is False + + def test_default_true_no_override_fires(self): + fn, adapter = self._make_adapter(default=True) + assert fn(adapter, "chat1") is True + + def test_explicit_enable_overrides_false_default(self): + """``/voice on`` with config auto_tts=False still fires.""" + fn, adapter = self._make_adapter(default=False, enabled={"chat1"}) + assert fn(adapter, "chat1") is True + + def test_explicit_disable_overrides_true_default(self): + """``/voice off`` with config auto_tts=True still suppresses.""" + fn, adapter = self._make_adapter(default=True, disabled={"chat1"}) + assert fn(adapter, "chat1") is False + + def test_enabled_wins_over_disabled(self): + """An explicit enable beats an explicit disable (enable takes priority).""" + fn, adapter = self._make_adapter( + default=False, enabled={"chat1"}, disabled={"chat1"} + ) + assert fn(adapter, "chat1") is True + + def test_per_chat_isolation(self): + """Enable for chat1 doesn't leak to chat2.""" + fn, adapter = self._make_adapter(default=False, enabled={"chat1"}) + assert fn(adapter, "chat1") is True + assert fn(adapter, "chat2") is False From 83c1c201f61c607259f5a5f7af32ddbc9c1cc2cc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:06:27 -0700 Subject: [PATCH 14/41] feat(onboarding): contextual first-touch hints for /busy and /verbose (#16046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of a blocking first-run questionnaire, show a one-time hint the first time the user hits each behavior fork: 1. First message while the agent is working — appends a hint to the busy-ack explaining the /busy queue vs /busy interrupt knob, phrased to match the mode that was just applied (don't tell a queue-mode user to switch to queue). 2. First tool that runs for >= 30s in the noisiest progress mode (tool_progress: all) — prints a hint about /verbose to cycle display modes (all -> new -> off -> verbose). Gated on /verbose actually being usable on the surface: always shown on CLI; on gateway only shown when display.tool_progress_command is enabled. Each hint is latched in config.yaml under onboarding.seen., so it fires exactly once per install across CLI, gateway, and cron, then never again. Users can wipe the section to re-see hints. New: - agent/onboarding.py — is_seen / mark_seen / hint strings, shared by both CLI and gateway. - onboarding.seen in DEFAULT_CONFIG (hermes_cli/config.py) and in load_cli_config defaults (cli.py). No _config_version bump — deep merge handles new keys. Wired: - gateway/run.py: _handle_active_session_busy_message appends the hint after building the ack. progress_callback tracks tool.completed duration and queues the tool-progress hint into the progress bubble. - cli.py: CLI input loop appends the busy-input hint on the first busy Enter; _on_tool_progress appends the tool-progress hint on the first >=30s tool completion. In-memory CLI_CONFIG is also updated so subsequent fires in the same process are suppressed immediately. All writes go through atomic_yaml_write and are wrapped in try/except so onboarding can never break the input/busy-ack paths. --- agent/onboarding.py | 144 ++++++++++++++++++ cli.py | 48 ++++++ gateway/run.py | 53 ++++++- hermes_cli/config.py | 7 + tests/agent/test_onboarding.py | 164 +++++++++++++++++++++ tests/gateway/test_busy_session_ack.py | 118 +++++++++++++++ website/docs/user-guide/cli.md | 4 + website/docs/user-guide/messaging/index.md | 11 ++ 8 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 agent/onboarding.py create mode 100644 tests/agent/test_onboarding.py diff --git a/agent/onboarding.py b/agent/onboarding.py new file mode 100644 index 0000000000..eed832ab90 --- /dev/null +++ b/agent/onboarding.py @@ -0,0 +1,144 @@ +""" +Contextual first-touch onboarding hints. + +Instead of blocking first-run questionnaires, show a one-time hint the *first* +time a user hits a behavior fork — message-while-running, first long-running +tool, etc. Each hint is shown once per install (tracked in ``config.yaml`` under +``onboarding.seen.``) and then never again. + +Keep this module tiny and dependency-free so both the CLI and gateway can import +it without pulling in heavy modules. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Mapping, Optional + +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------------- +# Flag names (stable — used as config.yaml keys under onboarding.seen) +# ------------------------------------------------------------------------- + +BUSY_INPUT_FLAG = "busy_input_prompt" +TOOL_PROGRESS_FLAG = "tool_progress_prompt" + + +# ------------------------------------------------------------------------- +# Hint content +# ------------------------------------------------------------------------- + +def busy_input_hint_gateway(mode: str) -> str: + """Hint shown the first time a user messages while the agent is busy. + + ``mode`` is the effective busy_input_mode that was just applied, so the + message matches reality ("I just interrupted…" vs "I just queued…"). + """ + if mode == "queue": + return ( + "💡 First-time tip — I queued your message instead of interrupting. " + "Send `/busy interrupt` to make new messages stop the current task " + "immediately, or `/busy status` to check. This notice won't appear again." + ) + return ( + "💡 First-time tip — I just interrupted my current task to answer you. " + "Send `/busy queue` to queue follow-ups for after the current task instead, " + "or `/busy status` to check. This notice won't appear again." + ) + + +def busy_input_hint_cli(mode: str) -> str: + """CLI version of the busy-input hint (plain text, no markdown).""" + if mode == "queue": + return ( + "(tip) Your message was queued for the next turn. " + "Use /busy interrupt to make Enter stop the current run instead. " + "This tip only shows once." + ) + return ( + "(tip) Your message interrupted the current run. " + "Use /busy queue to queue messages for the next turn instead. " + "This tip only shows once." + ) + + +def tool_progress_hint_gateway() -> str: + return ( + "💡 First-time tip — that tool took a while and I'm streaming every step. " + "If the progress messages feel noisy, send `/verbose` to cycle modes " + "(all → new → off). This notice won't appear again." + ) + + +def tool_progress_hint_cli() -> str: + return ( + "(tip) That tool ran for a while. Use /verbose to cycle tool-progress " + "display modes (all -> new -> off -> verbose). This tip only shows once." + ) + + +# ------------------------------------------------------------------------- +# State read / write +# ------------------------------------------------------------------------- + +def _get_seen_dict(config: Mapping[str, Any]) -> Mapping[str, Any]: + onboarding = config.get("onboarding") if isinstance(config, Mapping) else None + if not isinstance(onboarding, Mapping): + return {} + seen = onboarding.get("seen") + return seen if isinstance(seen, Mapping) else {} + + +def is_seen(config: Mapping[str, Any], flag: str) -> bool: + """Return True if the user has already been shown this first-touch hint.""" + return bool(_get_seen_dict(config).get(flag)) + + +def mark_seen(config_path: Path, flag: str) -> bool: + """Persist ``onboarding.seen. = True`` to ``config_path``. + + Uses the atomic YAML writer so a concurrent process can't observe a + partially-written file. Returns True on success, False on any error + (including the config file being absent — onboarding is best-effort). + """ + try: + import yaml + from utils import atomic_yaml_write + except Exception as e: # pragma: no cover — dependency issue + logger.debug("onboarding: failed to import yaml/utils: %s", e) + return False + + try: + cfg: dict = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + if not isinstance(cfg.get("onboarding"), dict): + cfg["onboarding"] = {} + seen = cfg["onboarding"].get("seen") + if not isinstance(seen, dict): + seen = {} + cfg["onboarding"]["seen"] = seen + if seen.get(flag) is True: + return True # already marked — nothing to do + seen[flag] = True + atomic_yaml_write(config_path, cfg) + return True + except Exception as e: + logger.debug("onboarding: failed to mark flag %s: %s", flag, e) + return False + + +__all__ = [ + "BUSY_INPUT_FLAG", + "TOOL_PROGRESS_FLAG", + "busy_input_hint_gateway", + "busy_input_hint_cli", + "tool_progress_hint_gateway", + "tool_progress_hint_cli", + "is_seen", + "mark_seen", +] diff --git a/cli.py b/cli.py index bc77d4c350..038c83f06f 100644 --- a/cli.py +++ b/cli.py @@ -417,6 +417,11 @@ def load_cli_config() -> Dict[str, Any]: "base_url": "", # Direct OpenAI-compatible endpoint for subagents "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY) }, + "onboarding": { + # First-touch hint flags (see agent/onboarding.py). Each hint is + # shown once per install then latched here. + "seen": {}, + }, } # Track whether the config file explicitly set terminal config. @@ -7412,6 +7417,31 @@ class HermesCLI: _cprint(f" {line}") except Exception: pass + # First-touch onboarding: on the first tool in this process + # that takes longer than the threshold while we're in the + # noisiest progress mode, print a one-time hint about + # /verbose. Latched on self so it fires at most once per + # process; persisted to config.yaml so it never fires again + # across processes either. + try: + if ( + not getattr(self, "_long_tool_hint_fired", False) + and self.tool_progress_mode == "all" + and duration >= 30.0 + ): + from agent.onboarding import ( + TOOL_PROGRESS_FLAG, + is_seen, + mark_seen, + tool_progress_hint_cli, + ) + if not is_seen(CLI_CONFIG, TOOL_PROGRESS_FLAG): + self._long_tool_hint_fired = True + _cprint(f" {_DIM}{tool_progress_hint_cli()}{_RST}") + mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG) + CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[TOOL_PROGRESS_FLAG] = True + except Exception: + pass self._invalidate() return if event_type != "tool.started": @@ -9295,6 +9325,24 @@ class HermesCLI: f"agent_running={self._agent_running}\n") except Exception: pass + # First-touch onboarding: on the very first busy-while-running + # event for this install, print a one-line tip explaining the + # /busy knob. Flag persists to config.yaml and never fires + # again. Guarded for exceptions so onboarding can't break + # the input loop. + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + busy_input_hint_cli, + is_seen, + mark_seen, + ) + if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG): + _cprint(f" {_DIM}{busy_input_hint_cli(self.busy_input_mode)}{_RST}") + mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) + CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True + except Exception: + pass else: self._pending_input.put(payload) event.app.current_buffer.reset(append_to_history=True) diff --git a/gateway/run.py b/gateway/run.py index 497d9241c4..d7331bdc75 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1630,6 +1630,27 @@ class GatewayRunner: f"I'll respond to your message shortly." ) + # First-touch onboarding: the very first time a user sends a message + # while the agent is busy, append a one-time hint explaining the + # queue/interrupt knob. Flag is persisted to config.yaml so it never + # fires again on this install. + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + busy_input_hint_gateway, + is_seen, + mark_seen, + ) + _user_cfg = _load_gateway_config() + if not is_seen(_user_cfg, BUSY_INPUT_FLAG): + message = ( + f"{message}\n\n" + f"{busy_input_hint_gateway('queue' if is_queue_mode else 'interrupt')}" + ) + mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) + except Exception as _onb_err: + logger.debug("Failed to apply busy-input onboarding hint: %s", _onb_err) + thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None try: await adapter._send_with_retry( @@ -9411,12 +9432,42 @@ class GatewayRunner: last_tool = [None] # Mutable container for tracking in closure last_progress_msg = [None] # Track last message for dedup repeat_count = [0] # How many times the same message repeated - + # First-touch onboarding latch: fires at most once per run, even if + # several tools exceed the threshold. + long_tool_hint_fired = [False] + _LONG_TOOL_THRESHOLD_S = 30.0 + def progress_callback(event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs): """Callback invoked by agent on tool lifecycle events.""" if not progress_queue or not _run_still_current(): return + # First-touch onboarding: the first time a tool takes longer than + # _LONG_TOOL_THRESHOLD_S during a run that's streaming every tool + # (progress_mode == "all"), append a one-time hint suggesting + # /verbose. We only fire when (a) the user hasn't seen the hint + # before and (b) /verbose is actually usable on this platform + # (gateway gate must be open). The CLI has its own trigger. + if event_type == "tool.completed" and not long_tool_hint_fired[0]: + try: + duration = kwargs.get("duration") or 0 + if duration >= _LONG_TOOL_THRESHOLD_S and progress_mode == "all": + from agent.onboarding import ( + TOOL_PROGRESS_FLAG, + is_seen, + mark_seen, + tool_progress_hint_gateway, + ) + _cfg = _load_gateway_config() + gate_on = bool(_cfg.get("display", {}).get("tool_progress_command", False)) + if gate_on and not is_seen(_cfg, TOOL_PROGRESS_FLAG): + long_tool_hint_fired[0] = True + progress_queue.put(tool_progress_hint_gateway()) + mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG) + except Exception as _hint_err: + logger.debug("tool-progress onboarding hint failed: %s", _hint_err) + return + # Only act on tool.started events (ignore tool.completed, reasoning.available, etc.) if event_type not in ("tool.started",): return diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4af2aff1de..72d0232f33 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1016,6 +1016,13 @@ DEFAULT_CONFIG = { "min_interval_hours": 24, }, + # Contextual first-touch onboarding hints (see agent/onboarding.py). + # Each hint is shown once per install and then latched here so it + # never fires again. Users can wipe the section to re-see all hints. + "onboarding": { + "seen": {}, + }, + # Config schema version - bump this when adding new required fields "_config_version": 22, } diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py new file mode 100644 index 0000000000..a14c7d1797 --- /dev/null +++ b/tests/agent/test_onboarding.py @@ -0,0 +1,164 @@ +"""Tests for agent/onboarding.py — contextual first-touch hint helpers.""" + +from __future__ import annotations + +import yaml +import pytest + +from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_cli, + busy_input_hint_gateway, + is_seen, + mark_seen, + tool_progress_hint_cli, + tool_progress_hint_gateway, +) + + +class TestIsSeen: + def test_empty_config_unseen(self): + assert is_seen({}, BUSY_INPUT_FLAG) is False + + def test_missing_onboarding_unseen(self): + assert is_seen({"display": {}}, BUSY_INPUT_FLAG) is False + + def test_onboarding_not_dict_unseen(self): + assert is_seen({"onboarding": "nope"}, BUSY_INPUT_FLAG) is False + + def test_seen_dict_missing_flag(self): + assert is_seen({"onboarding": {"seen": {}}}, BUSY_INPUT_FLAG) is False + + def test_seen_flag_true(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: True}}} + assert is_seen(cfg, BUSY_INPUT_FLAG) is True + + def test_seen_flag_falsy(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: False}}} + assert is_seen(cfg, BUSY_INPUT_FLAG) is False + + def test_other_flags_isolated(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: True}}} + assert is_seen(cfg, TOOL_PROGRESS_FLAG) is False + + +class TestMarkSeen: + def test_creates_missing_file_and_sets_flag(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_preserves_other_config(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({ + "model": {"default": "claude-sonnet-4.6"}, + "display": {"skin": "default"}, + })) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert loaded["model"]["default"] == "claude-sonnet-4.6" + assert loaded["display"]["skin"] == "default" + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_preserves_other_seen_flags(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({ + "onboarding": {"seen": {TOOL_PROGRESS_FLAG: True}}, + })) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert loaded["onboarding"]["seen"][TOOL_PROGRESS_FLAG] is True + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_idempotent(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + mark_seen(cfg_path, BUSY_INPUT_FLAG) + first = cfg_path.read_text() + + # Second call must be a no-op on-disk content (file may be touched, + # but the YAML contents should be identical). + mark_seen(cfg_path, BUSY_INPUT_FLAG) + second = cfg_path.read_text() + + assert yaml.safe_load(first) == yaml.safe_load(second) + + def test_handles_non_dict_onboarding(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({"onboarding": "corrupted"})) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_handles_non_dict_seen(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({"onboarding": {"seen": "corrupted"}})) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + +class TestHintMessages: + def test_busy_input_hint_gateway_interrupt(self): + msg = busy_input_hint_gateway("interrupt") + assert "/busy queue" in msg + assert "interrupted" in msg.lower() + + def test_busy_input_hint_gateway_queue(self): + msg = busy_input_hint_gateway("queue") + assert "/busy interrupt" in msg + assert "queued" in msg.lower() + + def test_busy_input_hint_cli_interrupt(self): + msg = busy_input_hint_cli("interrupt") + assert "/busy queue" in msg + + def test_busy_input_hint_cli_queue(self): + msg = busy_input_hint_cli("queue") + assert "/busy interrupt" in msg + + def test_tool_progress_hints_mention_verbose(self): + assert "/verbose" in tool_progress_hint_gateway() + assert "/verbose" in tool_progress_hint_cli() + + def test_hints_are_not_empty(self): + for hint in ( + busy_input_hint_gateway("queue"), + busy_input_hint_gateway("interrupt"), + busy_input_hint_cli("queue"), + busy_input_hint_cli("interrupt"), + tool_progress_hint_gateway(), + tool_progress_hint_cli(), + ): + assert hint.strip() + + +class TestRoundTrip: + """After mark_seen, is_seen on the re-loaded config must return True.""" + + def test_mark_then_is_seen(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert is_seen(loaded, BUSY_INPUT_FLAG) is True + assert is_seen(loaded, TOOL_PROGRESS_FLAG) is False + + def test_mark_both_flags_independently(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + + mark_seen(cfg_path, BUSY_INPUT_FLAG) + mark_seen(cfg_path, TOOL_PROGRESS_FLAG) + loaded = yaml.safe_load(cfg_path.read_text()) + + assert is_seen(loaded, BUSY_INPUT_FLAG) is True + assert is_seen(loaded, TOOL_PROGRESS_FLAG) is True diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 290c1a4b89..2d5f30f6d3 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -349,3 +349,121 @@ class TestBusySessionAck: result = await runner._handle_active_session_busy_message(event, sk) assert result is False # not handled, let default path try + + +class TestBusySessionOnboardingHint: + """First-touch hint appended to the busy-ack the first time it fires.""" + + @pytest.mark.asyncio + async def test_first_busy_ack_appends_interrupt_hint(self, tmp_path, monkeypatch): + """First busy-while-running message gets an extra hint about /busy.""" + import gateway.run as _gr + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + # mark_seen imports utils.atomic_yaml_write; make sure it resolves + # against a writable dir by pointing _hermes_home at tmp_path. + monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {}) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + + event = _make_event(text="ping") + sk = build_session_key(event.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 3, "max_iterations": 60, + "current_tool": None, "last_activity_ts": time.time(), + "last_activity_desc": "api", "seconds_since_activity": 0.1, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 5 + runner.adapters[event.source.platform] = adapter + + await runner._handle_active_session_busy_message(event, sk) + + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content", "") + + # Normal ack body + assert "Interrupting" in content + # First-touch hint appended + assert "First-time tip" in content + assert "/busy queue" in content + + # The flag is now persisted to tmp_path/config.yaml + import yaml + cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert cfg["onboarding"]["seen"]["busy_input_prompt"] is True + + @pytest.mark.asyncio + async def test_second_busy_ack_omits_hint(self, tmp_path, monkeypatch): + """Once the flag is marked, the hint never appears again.""" + import gateway.run as _gr + import yaml + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + # Pre-populate the config so is_seen() returns True from the start. + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "onboarding": {"seen": {"busy_input_prompt": True}}, + })) + monkeypatch.setattr( + _gr, "_load_gateway_config", + lambda: yaml.safe_load((tmp_path / "config.yaml").read_text()), + ) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + + event = _make_event(text="ping again") + sk = build_session_key(event.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 3, "max_iterations": 60, + "current_tool": None, "last_activity_ts": time.time(), + "last_activity_desc": "api", "seconds_since_activity": 0.1, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 5 + runner.adapters[event.source.platform] = adapter + + await runner._handle_active_session_busy_message(event, sk) + + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content", "") + + assert "Interrupting" in content + assert "First-time tip" not in content + assert "/busy queue" not in content + + @pytest.mark.asyncio + async def test_queue_mode_hint_points_to_interrupt(self, tmp_path, monkeypatch): + """In queue mode the hint should suggest /busy interrupt, not /busy queue.""" + import gateway.run as _gr + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {}) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "queue" + adapter = _make_adapter() + + event = _make_event(text="queue me") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + agent = MagicMock() + runner._running_agents[sk] = agent + + with patch("gateway.run.merge_pending_message_event"): + await runner._handle_active_session_busy_message(event, sk) + + content = adapter._send_with_retry.call_args.kwargs.get("content", "") + assert "Queued for the next turn" in content + assert "First-time tip" in content + assert "/busy interrupt" in content + # Must NOT tell the user to /busy queue when they're already on queue. + assert "/busy queue" not in content diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 90b571aa8b..0ba7245958 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -242,6 +242,10 @@ You can also change it inside the CLI: /busy status ``` +:::tip First-touch hint +The very first time you press Enter while Hermes is working, Hermes prints a one-line reminder explaining the `/busy` knob (`"(tip) Your message interrupted the current run…"`). It only fires once per install — a flag in `config.yaml` under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. +::: + ### Suspending to Background On Unix systems, press **`Ctrl+Z`** to suspend Hermes to the background — just like any terminal process. The shell prints a confirmation: diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index dcde46a6b5..2e6fa4f212 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -219,6 +219,17 @@ Send any message while the agent is working to interrupt it. Key behaviors: - **Multiple messages are combined** — messages sent during interruption are joined into one prompt - **`/stop` command** — interrupts without queuing a follow-up message +### Queue vs interrupt (busy-input mode) + +By default, messaging a busy agent interrupts it. To switch the whole install so follow-ups queue behind the current task instead, set: + +```yaml +display: + busy_input_mode: queue # default: interrupt +``` + +The first time you message a busy agent on any platform, Hermes appends a one-line reminder to the busy-ack explaining the knob (`"💡 First-time tip — …"`). The reminder fires once per install — a flag under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. + ## Tool Progress Notifications Control how much tool activity is displayed in `~/.hermes/config.yaml`: From 1e37ddc9293cc7b912b1ef85765f4fc93dba7ced Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:19:04 -0700 Subject: [PATCH 15/41] feat(cli): add 'hermes fallback' command to manage fallback providers (#16052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage the fallback_providers chain from the CLI instead of hand-editing config.yaml. The picker reuses select_provider_and_model() from 'hermes model' — same provider list, same credential prompts, same model picker. hermes fallback [list] Show the current chain (primary + fallbacks) hermes fallback add Run the model picker, append selection to chain hermes fallback remove Pick an entry to delete (arrow-key menu) hermes fallback clear Remove all entries (with confirmation) 'add' snapshots config['model'] before calling the picker, extracts the user's selection from the post-picker state, then restores the primary and appends {provider, model, base_url?, api_mode?} to fallback_providers. Auth store's active_provider is snapshot/restored too so OAuth-provider fallbacks don't silently deactivate the user's primary. Duplicates and self-as-fallback are rejected. Legacy single-dict 'fallback_model' entries are auto-migrated to the list format on first write. --- hermes_cli/fallback_cmd.py | 361 +++++++++++++++++++ hermes_cli/main.py | 39 +++ tests/hermes_cli/test_fallback_cmd.py | 486 ++++++++++++++++++++++++++ 3 files changed, 886 insertions(+) create mode 100644 hermes_cli/fallback_cmd.py create mode 100644 tests/hermes_cli/test_fallback_cmd.py diff --git a/hermes_cli/fallback_cmd.py b/hermes_cli/fallback_cmd.py new file mode 100644 index 0000000000..02c0a01c39 --- /dev/null +++ b/hermes_cli/fallback_cmd.py @@ -0,0 +1,361 @@ +""" +hermes fallback — manage the fallback provider chain. + +Fallback providers are tried in order when the primary model fails with +rate-limit, overload, or connection errors. See: +https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers + +Subcommands: + hermes fallback [list] Show the current fallback chain (default when no subcommand) + hermes fallback add Pick provider + model via the same picker as `hermes model`, + then append the selection to the chain + hermes fallback remove Pick an entry to delete from the chain + hermes fallback clear Remove all fallback entries + +Storage: ``fallback_providers`` in ``~/.hermes/config.yaml`` (top-level, list of +``{provider, model, base_url?, api_mode?}`` dicts). The legacy single-dict +``fallback_model`` format is migrated to the new list format on first add. +""" +from __future__ import annotations + +import copy +from typing import Any, Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _read_chain(config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return the normalized fallback chain as a list of dicts. + + Accepts both the new list format (``fallback_providers``) and the legacy + single-dict format (``fallback_model``). The returned list is always a + fresh copy — callers can mutate without touching the config dict. + """ + chain = config.get("fallback_providers") or [] + if isinstance(chain, list): + result = [dict(e) for e in chain if isinstance(e, dict) and e.get("provider") and e.get("model")] + if result: + return result + legacy = config.get("fallback_model") + if isinstance(legacy, dict) and legacy.get("provider") and legacy.get("model"): + return [dict(legacy)] + if isinstance(legacy, list): + return [dict(e) for e in legacy if isinstance(e, dict) and e.get("provider") and e.get("model")] + return [] + + +def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None: + """Persist the chain to ``fallback_providers`` and clear legacy key.""" + config["fallback_providers"] = chain + # Drop the legacy single-dict key on write so there's only one source of truth. + if "fallback_model" in config: + config.pop("fallback_model", None) + + +def _format_entry(entry: Dict[str, Any]) -> str: + """One-line human-readable rendering of a fallback entry.""" + provider = entry.get("provider", "?") + model = entry.get("model", "?") + base = entry.get("base_url") + suffix = f" [{base}]" if base else "" + return f"{model} (via {provider}){suffix}" + + +def _extract_fallback_from_model_cfg(model_cfg: Any) -> Optional[Dict[str, Any]]: + """Pull the ``{provider, model, base_url?, api_mode?}`` dict from a ``config["model"]`` snapshot.""" + if not isinstance(model_cfg, dict): + return None + provider = (model_cfg.get("provider") or "").strip() + # The picker writes the selected model to ``model.default``. + model = (model_cfg.get("default") or model_cfg.get("model") or "").strip() + if not provider or not model: + return None + entry: Dict[str, Any] = {"provider": provider, "model": model} + base_url = (model_cfg.get("base_url") or "").strip() + if base_url: + entry["base_url"] = base_url + api_mode = (model_cfg.get("api_mode") or "").strip() + if api_mode: + entry["api_mode"] = api_mode + return entry + + +def _snapshot_auth_active_provider() -> Any: + """Return the current ``active_provider`` in auth.json, or a sentinel if unavailable.""" + try: + from hermes_cli.auth import _load_auth_store + store = _load_auth_store() + return store.get("active_provider") + except Exception: + return None + + +def _restore_auth_active_provider(value: Any) -> None: + """Write back a previously snapshotted ``active_provider`` value.""" + try: + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + with _auth_store_lock(): + store = _load_auth_store() + store["active_provider"] = value + _save_auth_store(store) + except Exception: + # Best-effort — if auth.json can't be restored, the user's primary + # provider may have been deactivated by the picker. They can re-run + # `hermes model` to fix it. Don't fail the fallback add. + pass + + +# --------------------------------------------------------------------------- +# Subcommand handlers +# --------------------------------------------------------------------------- + +def cmd_fallback_list(args) -> None: # noqa: ARG001 + """Print the current fallback chain.""" + from hermes_cli.config import load_config + + config = load_config() + chain = _read_chain(config) + + print() + if not chain: + print(" No fallback providers configured.") + print() + print(" Add one with: hermes fallback add") + print() + return + + primary = _describe_primary(config) + if primary: + print(f" Primary: {primary}") + print() + print(f" Fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):") + for i, entry in enumerate(chain, 1): + print(f" {i}. {_format_entry(entry)}") + print() + print(" Tried in order when the primary fails (rate-limit, 5xx, connection errors).") + print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers") + print() + + +def _describe_primary(config: Dict[str, Any]) -> Optional[str]: + """One-line description of the primary model for display purposes.""" + model_cfg = config.get("model") + if isinstance(model_cfg, dict): + provider = (model_cfg.get("provider") or "?").strip() or "?" + model = (model_cfg.get("default") or model_cfg.get("model") or "?").strip() or "?" + return f"{model} (via {provider})" + if isinstance(model_cfg, str) and model_cfg.strip(): + return model_cfg.strip() + return None + + +def cmd_fallback_add(args) -> None: + """Launch the same picker as `hermes model`, then append the selection to the chain.""" + from hermes_cli.main import _require_tty, select_provider_and_model + from hermes_cli.config import load_config, save_config + + _require_tty("fallback add") + + # Snapshot BEFORE the picker runs so we can distinguish "user actually + # picked something" from "user cancelled" by comparing before/after. + before_cfg = load_config() + model_before = copy.deepcopy(before_cfg.get("model")) + active_provider_before = _snapshot_auth_active_provider() + + print() + print(" Adding a fallback provider. The picker below is the same one used by") + print(" `hermes model` — select the provider + model you want as a fallback.") + print() + + try: + select_provider_and_model(args=args) + except SystemExit: + # Some provider flows exit on auth failure — restore state and re-raise. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + raise + + # Read the post-picker state to see what the user selected. + after_cfg = load_config() + model_after = after_cfg.get("model") + + new_entry = _extract_fallback_from_model_cfg(model_after) + if not new_entry: + # Picker didn't complete (user cancelled or flow bailed). Nothing to do. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + print() + print(" No fallback added.") + return + + # Picker picked the same thing that's already the primary → nothing changed, + # and there's nothing useful to add as a fallback to itself. + primary_entry = _extract_fallback_from_model_cfg(model_before) + if primary_entry and primary_entry["provider"] == new_entry["provider"] \ + and primary_entry["model"] == new_entry["model"]: + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + print() + print(f" Selected model matches the current primary ({_format_entry(new_entry)}).") + print(" A provider cannot be a fallback for itself — no change.") + return + + # Reload the config with the primary restored, then append the new entry + # to ``fallback_providers``. We deliberately re-load (rather than mutating + # ``after_cfg``) because the picker may have touched other top-level keys + # (custom_providers, providers credentials) that we want to keep. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + + final_cfg = load_config() + chain = _read_chain(final_cfg) + + # Reject exact-duplicate fallback entries. + for existing in chain: + if existing.get("provider") == new_entry["provider"] \ + and existing.get("model") == new_entry["model"]: + print() + print(f" {_format_entry(new_entry)} is already in the fallback chain — skipped.") + return + + chain.append(new_entry) + _write_chain(final_cfg, chain) + save_config(final_cfg) + + print() + print(f" Added fallback: {_format_entry(new_entry)}") + print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.") + print() + print(" Run `hermes fallback list` to view, or `hermes fallback remove` to delete.") + + +def _restore_model_cfg(model_before: Any) -> None: + """Restore ``config["model"]`` to a previously-captured snapshot.""" + from hermes_cli.config import load_config, save_config + + cfg = load_config() + if model_before is None: + cfg.pop("model", None) + else: + cfg["model"] = copy.deepcopy(model_before) + save_config(cfg) + + +def cmd_fallback_remove(args) -> None: # noqa: ARG001 + """Pick an entry from the chain and remove it.""" + from hermes_cli.config import load_config, save_config + + config = load_config() + chain = _read_chain(config) + + if not chain: + print() + print(" No fallback providers configured — nothing to remove.") + print() + return + + choices = [_format_entry(e) for e in chain] + choices.append("Cancel") + + try: + from hermes_cli.setup import _curses_prompt_choice + idx = _curses_prompt_choice("Select a fallback to remove:", choices, 0) + except Exception: + idx = _numbered_pick("Select a fallback to remove:", choices) + + if idx is None or idx < 0 or idx >= len(chain): + print() + print(" Cancelled — no change.") + return + + removed = chain.pop(idx) + _write_chain(config, chain) + save_config(config) + + print() + print(f" Removed fallback: {_format_entry(removed)}") + if chain: + print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.") + else: + print(" Fallback chain is now empty.") + print() + + +def cmd_fallback_clear(args) -> None: # noqa: ARG001 + """Remove all fallback entries (with confirmation).""" + from hermes_cli.config import load_config, save_config + + config = load_config() + chain = _read_chain(config) + + if not chain: + print() + print(" No fallback providers configured — nothing to clear.") + print() + return + + print() + print(f" Current fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):") + for i, entry in enumerate(chain, 1): + print(f" {i}. {_format_entry(entry)}") + print() + try: + resp = input(" Clear all entries? [y/N]: ").strip().lower() + except (KeyboardInterrupt, EOFError): + print() + print(" Cancelled.") + return + if resp not in ("y", "yes"): + print(" Cancelled — no change.") + return + + _write_chain(config, []) + save_config(config) + print() + print(" Fallback chain cleared.") + print() + + +def _numbered_pick(question: str, choices: List[str]) -> Optional[int]: + """Fallback numbered-list picker when curses is unavailable.""" + print(question) + for i, c in enumerate(choices, 1): + print(f" {i}. {c}") + print() + while True: + try: + val = input(f"Choice [1-{len(choices)}]: ").strip() + if not val: + return None + idx = int(val) - 1 + if 0 <= idx < len(choices): + return idx + print(f"Please enter 1-{len(choices)}") + except ValueError: + print("Please enter a number") + except (KeyboardInterrupt, EOFError): + print() + return None + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +def cmd_fallback(args) -> None: + """Top-level dispatcher for ``hermes fallback [subcommand]``.""" + sub = getattr(args, "fallback_command", None) + if sub in (None, "", "list", "ls"): + cmd_fallback_list(args) + elif sub == "add": + cmd_fallback_add(args) + elif sub in ("remove", "rm"): + cmd_fallback_remove(args) + elif sub == "clear": + cmd_fallback_clear(args) + else: + print(f"Unknown fallback subcommand: {sub}") + print("Use one of: list, add, remove, clear") + raise SystemExit(2) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 30dfee21e2..a53b8d2c5e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7223,6 +7223,9 @@ Examples: hermes auth remove

Remove pooled credential by index, id, or label hermes auth reset Clear exhaustion status for a provider hermes model Select default model + hermes fallback [list] Show fallback provider chain + hermes fallback add Add a fallback provider (same picker as `hermes model`) + hermes fallback remove Remove a fallback provider from the chain hermes config View configuration hermes config edit Edit config in $EDITOR hermes config set model gpt-4 Set a config value @@ -7564,6 +7567,42 @@ For more help on a command: ) model_parser.set_defaults(func=cmd_model) + # ========================================================================= + # fallback command — manage the fallback provider chain + # ========================================================================= + from hermes_cli.fallback_cmd import cmd_fallback + + fallback_parser = subparsers.add_parser( + "fallback", + help="Manage fallback providers (tried when the primary model fails)", + description=( + "Manage the fallback provider chain. Fallback providers are tried " + "in order when the primary model fails with rate-limit, overload, or " + "connection errors. See: " + "https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers" + ), + ) + fallback_subparsers = fallback_parser.add_subparsers(dest="fallback_command") + fallback_subparsers.add_parser( + "list", + aliases=["ls"], + help="Show the current fallback chain (default when no subcommand)", + ) + fallback_subparsers.add_parser( + "add", + help="Pick a provider + model (same picker as `hermes model`) and append to the chain", + ) + fallback_subparsers.add_parser( + "remove", + aliases=["rm"], + help="Pick an entry to delete from the chain", + ) + fallback_subparsers.add_parser( + "clear", + help="Remove all fallback entries", + ) + fallback_parser.set_defaults(func=cmd_fallback) + # ========================================================================= # gateway command # ========================================================================= diff --git a/tests/hermes_cli/test_fallback_cmd.py b/tests/hermes_cli/test_fallback_cmd.py new file mode 100644 index 0000000000..a88c84b3aa --- /dev/null +++ b/tests/hermes_cli/test_fallback_cmd.py @@ -0,0 +1,486 @@ +"""Tests for `hermes fallback` — chain reading, add/remove/clear, legacy migration.""" +from __future__ import annotations + +import io +import types +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# Shared fixture — isolate HERMES_HOME so save_config writes to tmp_path +# --------------------------------------------------------------------------- + +@pytest.fixture() +def isolated_home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + home = tmp_path / ".hermes" + home.mkdir(exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + return tmp_path + + +def _write_config(home: Path, data: dict) -> None: + config_path = home / ".hermes" / "config.yaml" + config_path.write_text(yaml.safe_dump(data), encoding="utf-8") + + +def _read_config(home: Path) -> dict: + config_path = home / ".hermes" / "config.yaml" + return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + + +# --------------------------------------------------------------------------- +# _read_chain / _write_chain +# --------------------------------------------------------------------------- + +class TestReadChain: + def test_returns_empty_list_when_unset(self): + from hermes_cli.fallback_cmd import _read_chain + assert _read_chain({}) == [] + + def test_reads_new_list_format(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, + ] + } + assert _read_chain(cfg) == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, + ] + + def test_migrates_legacy_single_dict(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = {"fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}} + assert _read_chain(cfg) == [{"provider": "openrouter", "model": "gpt-5.4"}] + + def test_skips_incomplete_entries(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter"}, # missing model + {"model": "gpt-5.4"}, # missing provider + {"provider": "nous", "model": "foo"}, # valid + "not-a-dict", # noise + ] + } + assert _read_chain(cfg) == [{"provider": "nous", "model": "foo"}] + + def test_returns_copies_not_aliases(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = {"fallback_providers": [{"provider": "nous", "model": "foo"}]} + result = _read_chain(cfg) + result[0]["provider"] = "mutated" + assert cfg["fallback_providers"][0]["provider"] == "nous" + + +# --------------------------------------------------------------------------- +# _extract_fallback_from_model_cfg +# --------------------------------------------------------------------------- + +class TestExtractFallback: + def test_extracts_from_default_field(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + model_cfg = {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"} + assert _extract_fallback_from_model_cfg(model_cfg) == { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + } + + def test_extracts_optional_base_url_and_api_mode(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + model_cfg = { + "provider": "custom", + "default": "local-model", + "base_url": "http://localhost:11434/v1", + "api_mode": "chat_completions", + } + assert _extract_fallback_from_model_cfg(model_cfg) == { + "provider": "custom", + "model": "local-model", + "base_url": "http://localhost:11434/v1", + "api_mode": "chat_completions", + } + + def test_returns_none_without_provider(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg({"default": "foo"}) is None + + def test_returns_none_without_model(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg({"provider": "openrouter"}) is None + + def test_returns_none_for_non_dict(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg("plain-string") is None + assert _extract_fallback_from_model_cfg(None) is None + + +# --------------------------------------------------------------------------- +# cmd_fallback_list +# --------------------------------------------------------------------------- + +class TestListCommand: + def test_list_empty(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + assert "hermes fallback add" in out + + def test_list_with_entries(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4"}, + ], + }) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "Fallback chain (2 entries)" in out + assert "anthropic/claude-sonnet-4.6" in out + assert "Hermes-4" in out + # Primary should be shown too + assert "claude-sonnet-4-6" in out + + def test_list_migrates_legacy_for_display(self, isolated_home, capsys): + _write_config(isolated_home, { + "fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}, + }) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "1 entry" in out + assert "gpt-5.4" in out + + +# --------------------------------------------------------------------------- +# cmd_fallback_add — mock select_provider_and_model +# --------------------------------------------------------------------------- + +class TestAddCommand: + def test_add_appends_new_entry(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + # Simulate what the real picker does: writes the selection to config["model"] + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = { + "provider": "openrouter", + "default": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Primary is preserved + assert cfg["model"]["provider"] == "anthropic" + assert cfg["model"]["default"] == "claude-sonnet-4-6" + # Fallback was appended + assert cfg["fallback_providers"] == [ + { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + ] + out = capsys.readouterr().out + assert "Added fallback" in out + + def test_add_rejects_duplicate(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + ], + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "openrouter", "default": "gpt-5.4"} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Should still have exactly one entry + assert len(cfg["fallback_providers"]) == 1 + out = capsys.readouterr().out + assert "already in the fallback chain" in out + + def test_add_rejects_same_as_primary(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "openrouter", "default": "gpt-5.4"}, + }) + + def fake_picker(args=None): + # User picks the same thing that's already the primary + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "openrouter", "default": "gpt-5.4"} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert "fallback_providers" not in cfg or cfg["fallback_providers"] == [] + out = capsys.readouterr().out + assert "matches the current primary" in out + + def test_add_preserves_primary_when_picker_changes_it(self, isolated_home): + """The picker mutates config["model"]; fallback_add must restore the primary.""" + _write_config(isolated_home, { + "model": { + "provider": "anthropic", + "default": "claude-sonnet-4-6", + "base_url": "https://api.anthropic.com", + "api_mode": "anthropic_messages", + }, + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = { + "provider": "openrouter", + "default": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Primary exactly as it was + assert cfg["model"]["provider"] == "anthropic" + assert cfg["model"]["default"] == "claude-sonnet-4-6" + assert cfg["model"]["base_url"] == "https://api.anthropic.com" + assert cfg["model"]["api_mode"] == "anthropic_messages" + # Fallback added + assert len(cfg["fallback_providers"]) == 1 + assert cfg["fallback_providers"][0]["provider"] == "openrouter" + + def test_add_noop_when_picker_cancelled(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + # User cancelled — no change to config + pass + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert "fallback_providers" not in cfg or cfg["fallback_providers"] == [] + out = capsys.readouterr().out + # Either "No fallback added" (picker fully cancelled) or "matches the current primary" + # (picker left config untouched) — both indicate a non-add outcome. + assert ("No fallback added" in out) or ("matches the current primary" in out) + + def test_add_noop_when_picker_clears_model(self, isolated_home, capsys): + """Simulate picker explicitly clearing model.default (unusual but possible).""" + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "", "default": ""} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + out = capsys.readouterr().out + assert "No fallback added" in out + + +# --------------------------------------------------------------------------- +# cmd_fallback_remove +# --------------------------------------------------------------------------- + +class TestRemoveCommand: + def test_remove_empty_chain(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "nothing to remove" in out + + def test_remove_selected_entry(self, isolated_home, capsys): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "nous", "model": "Hermes-4"}, + {"provider": "anthropic", "model": "claude-sonnet-4-6"}, + ], + }) + + # Picker returns index 1 (the middle entry, "nous / Hermes-4") + with patch("hermes_cli.setup._curses_prompt_choice", return_value=1): + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert cfg["fallback_providers"] == [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "anthropic", "model": "claude-sonnet-4-6"}, + ] + out = capsys.readouterr().out + assert "Removed fallback" in out + assert "Hermes-4" in out + + def test_remove_cancel_keeps_chain(self, isolated_home): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + ], + }) + + # Cancel = last item (index == len(chain) == 1 in our menu) + with patch("hermes_cli.setup._curses_prompt_choice", return_value=1): + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert len(cfg["fallback_providers"]) == 1 + + +# --------------------------------------------------------------------------- +# cmd_fallback_clear +# --------------------------------------------------------------------------- + +class TestClearCommand: + def test_clear_empty_chain(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "nothing to clear" in out + + def test_clear_with_confirmation(self, isolated_home, capsys, monkeypatch): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "nous", "model": "Hermes-4"}, + ], + }) + monkeypatch.setattr("builtins.input", lambda *a, **kw: "y") + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert cfg.get("fallback_providers") == [] + out = capsys.readouterr().out + assert "Fallback chain cleared" in out + + def test_clear_cancelled(self, isolated_home, monkeypatch): + _write_config(isolated_home, { + "fallback_providers": [{"provider": "openrouter", "model": "gpt-5.4"}], + }) + monkeypatch.setattr("builtins.input", lambda *a, **kw: "n") + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert len(cfg["fallback_providers"]) == 1 + + +# --------------------------------------------------------------------------- +# cmd_fallback dispatcher +# --------------------------------------------------------------------------- + +class TestDispatcher: + def test_no_subcommand_lists(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command=None)) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + + def test_list_alias(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command="ls")) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + + def test_remove_alias(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command="rm")) + out = capsys.readouterr().out + assert "nothing to remove" in out + + def test_unknown_subcommand_exits(self, isolated_home): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + with pytest.raises(SystemExit): + cmd_fallback(types.SimpleNamespace(fallback_command="nope")) + + +# --------------------------------------------------------------------------- +# argparse wiring — verify the subparser is registered +# --------------------------------------------------------------------------- + +class TestArgparseWiring: + """Verify `hermes fallback` is wired into main.py's argparse tree. + + main() builds the parser inline, so we invoke main([...]) via subprocess + with --help to introspect registered subcommands without side effects. + """ + + def test_fallback_help_lists_subcommands(self): + import subprocess + import sys + result = subprocess.run( + [sys.executable, "-m", "hermes_cli.main", "fallback", "--help"], + capture_output=True, + text=True, + timeout=30, + ) + # --help exits 0 + assert result.returncode == 0, f"stderr: {result.stderr}" + out = result.stdout + result.stderr + # All four subcommands should appear in help + assert "list" in out + assert "add" in out + assert "remove" in out + assert "clear" in out From ffd2621039259ee8419549fedc8739bf1a350436 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:24:19 -0700 Subject: [PATCH 16/41] feat(onboarding): port first-touch hints to the TUI (#16054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #16046 added /busy and /verbose hints to the classic CLI and the gateway runner but skipped the Ink TUI (and therefore the dashboard /chat page, which embeds the TUI via PTY). This extends the same latch to the TUI with TUI-native wording. The TUI's busy-input model is not the /busy knob from the CLI — single Enter while busy auto-queues, double Enter on an empty line interrupts. The new busy-input hint teaches THAT gesture instead of telling the user to flip a config that does not apply. Changes: - agent/onboarding.py — add busy_input_hint_tui() + tool_progress_hint_tui() - tui_gateway/server.py — onboarding.claim JSON-RPC (Ink triggers busy hint on enqueue) + _maybe_emit_onboarding_hint helper hooked into _on_tool_complete for the 30s/tool_progress=all path. Same config.yaml latch so each hint fires at most once per install across CLI, gateway, and TUI combined. - ui-tui/src/gatewayTypes.ts — OnboardingClaimResponse + onboarding.hint event - ui-tui/src/app/createGatewayEventHandler.ts — render the hint event as sys() - ui-tui/src/app/useSubmission.ts — claim busy_input_prompt on first busy enqueue - tests/agent/test_onboarding.py — +3 cases for TUI hint shape - tests/tui_gateway/test_protocol.py — +4 cases for onboarding.claim - website/docs/user-guide/tui.md — new 'Interrupting and queueing' section explaining the TUI's double-Enter model and the hints Validation: scripts/run_tests.sh tests/agent/test_onboarding.py \ tests/tui_gateway/test_protocol.py \ tests/gateway/test_busy_session_ack.py -> 66 passed npm --prefix ui-tui run type-check -> clean npm --prefix ui-tui run lint -> clean npm --prefix ui-tui run build -> clean --- agent/onboarding.py | 22 ++++ tests/agent/test_onboarding.py | 12 ++ tests/tui_gateway/test_protocol.py | 91 +++++++++++++++ tui_gateway/server.py | 119 ++++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 11 ++ ui-tui/src/app/useSubmission.ts | 20 +++- ui-tui/src/gatewayTypes.ts | 6 + website/docs/user-guide/tui.md | 12 ++ 8 files changed, 291 insertions(+), 2 deletions(-) diff --git a/agent/onboarding.py b/agent/onboarding.py index eed832ab90..7b755ef47e 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -80,6 +80,26 @@ def tool_progress_hint_cli() -> str: ) +def busy_input_hint_tui() -> str: + """Hint shown the first time a user sends a message while the TUI is busy. + + The TUI auto-queues messages sent mid-turn and uses double-Enter on empty + input as the interrupt gesture. There is no ``/busy`` knob to flip — this + hint teaches the keybind instead of a command. + """ + return ( + "queued for after the current turn — press Enter twice on an empty " + "line to interrupt the current turn instead. This tip only shows once." + ) + + +def tool_progress_hint_tui() -> str: + return ( + "that tool ran for a while — use /verbose to cycle tool-progress " + "display modes (all → new → off → verbose). This tip only shows once." + ) + + # ------------------------------------------------------------------------- # State read / write # ------------------------------------------------------------------------- @@ -137,8 +157,10 @@ __all__ = [ "TOOL_PROGRESS_FLAG", "busy_input_hint_gateway", "busy_input_hint_cli", + "busy_input_hint_tui", "tool_progress_hint_gateway", "tool_progress_hint_cli", + "tool_progress_hint_tui", "is_seen", "mark_seen", ] diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index a14c7d1797..ec88c1cc30 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -10,10 +10,12 @@ from agent.onboarding import ( TOOL_PROGRESS_FLAG, busy_input_hint_cli, busy_input_hint_gateway, + busy_input_hint_tui, is_seen, mark_seen, tool_progress_hint_cli, tool_progress_hint_gateway, + tool_progress_hint_tui, ) @@ -128,6 +130,14 @@ class TestHintMessages: def test_tool_progress_hints_mention_verbose(self): assert "/verbose" in tool_progress_hint_gateway() assert "/verbose" in tool_progress_hint_cli() + assert "/verbose" in tool_progress_hint_tui() + + def test_busy_input_hint_tui_teaches_double_enter(self): + msg = busy_input_hint_tui() + # TUI uses double-Enter as the interrupt gesture, not /busy. + assert "Enter" in msg + assert "queued" in msg.lower() + assert "/busy" not in msg def test_hints_are_not_empty(self): for hint in ( @@ -135,8 +145,10 @@ class TestHintMessages: busy_input_hint_gateway("interrupt"), busy_input_hint_cli("queue"), busy_input_hint_cli("interrupt"), + busy_input_hint_tui(), tool_progress_hint_gateway(), tool_progress_hint_cli(), + tool_progress_hint_tui(), ): assert hint.strip() diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 42caaacc58..196e1ee517 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -542,3 +542,94 @@ def test_dispatch_unknown_long_method_still_goes_inline(server): resp = server.dispatch({"id": "r4", "method": "some.method", "params": {}}) assert resp["result"] == {"ok": True} + + +# ── onboarding.claim ───────────────────────────────────────────────── + + +def test_onboarding_claim_rejects_unknown_flag(server): + resp = server.handle_request({ + "id": "o1", + "method": "onboarding.claim", + "params": {"flag": "bogus_flag"}, + }) + assert "error" in resp + assert resp["error"]["code"] == 4002 + assert "unknown onboarding flag" in resp["error"]["message"] + + +def test_onboarding_claim_busy_input_returns_tui_hint(server, tmp_path, monkeypatch): + """First claim returns the TUI hint text and marks the config.yaml flag.""" + monkeypatch.setattr(server, "_hermes_home", tmp_path) + # Bust cached cfg so the new _hermes_home is re-read. + server._cfg_cache = None + server._cfg_mtime = None + + resp = server.handle_request({ + "id": "o2", + "method": "onboarding.claim", + "params": {"flag": "busy_input_prompt"}, + }) + + assert "result" in resp + result = resp["result"] + assert result["claimed"] is True + assert isinstance(result["hint"], str) and result["hint"].strip() + # The TUI hint must teach the double-Enter gesture, not the /busy knob. + assert "Enter" in result["hint"] + assert "/busy" not in result["hint"] + + # config.yaml should now be written with the flag set. + cfg_path = tmp_path / "config.yaml" + assert cfg_path.exists() + import yaml + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"]["busy_input_prompt"] is True + + +def test_onboarding_claim_second_call_returns_null_hint(server, tmp_path, monkeypatch): + """Second claim on the same flag reads config.yaml and returns hint=null.""" + import yaml + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"onboarding": {"seen": {"tool_progress_prompt": True}}}) + ) + monkeypatch.setattr(server, "_hermes_home", tmp_path) + server._cfg_cache = None + server._cfg_mtime = None + + resp = server.handle_request({ + "id": "o3", + "method": "onboarding.claim", + "params": {"flag": "tool_progress_prompt"}, + }) + + assert "result" in resp + assert resp["result"]["claimed"] is False + assert resp["result"]["hint"] is None + + +def test_onboarding_claim_flags_are_independent(server, tmp_path, monkeypatch): + """Claiming one flag does not affect the other.""" + monkeypatch.setattr(server, "_hermes_home", tmp_path) + server._cfg_cache = None + server._cfg_mtime = None + + # Claim busy_input_prompt first + resp1 = server.handle_request({ + "id": "o4a", + "method": "onboarding.claim", + "params": {"flag": "busy_input_prompt"}, + }) + assert resp1["result"]["claimed"] is True + + # tool_progress_prompt must still be claimable. Cache bust because the + # first claim wrote to disk mid-test. + server._cfg_cache = None + server._cfg_mtime = None + resp2 = server.handle_request({ + "id": "o4b", + "method": "onboarding.claim", + "params": {"flag": "tool_progress_prompt"}, + }) + assert resp2["result"]["claimed"] is True + assert "/verbose" in resp2["result"]["hint"] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03631bf174..419a911e76 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1016,6 +1016,64 @@ def _tool_summary(name: str, result: str, duration_s: float | None) -> str | Non return f"{text or 'Completed'}{suffix}" if (text or dur) else None +# ── Onboarding hint emission ───────────────────────────────────────── +# First-touch hints are latched to config.yaml (onboarding.seen.) +# and shared with CLI + gateway so each hint fires at most once per +# install across all surfaces. Best-effort — never raises. + +_ONBOARDING_HINTS_EMITTED: set[str] = set() + + +def _maybe_emit_onboarding_hint(sid: str, flag: str) -> bool: + """Atomically claim an onboarding flag and emit its hint to Ink. + + Returns True if a hint was emitted this call, False if the flag was + already seen (or if anything went wrong — onboarding must never + interrupt the normal event flow). Also deduplicates within a single + process run via ``_ONBOARDING_HINTS_EMITTED`` so concurrent callers + can't double-emit before the config.yaml write lands. + """ + if flag in _ONBOARDING_HINTS_EMITTED: + return False + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_tui, + is_seen, + mark_seen, + tool_progress_hint_tui, + ) + except Exception: + return False + + try: + cfg = _load_cfg() + except Exception: + cfg = {} + if is_seen(cfg, flag): + _ONBOARDING_HINTS_EMITTED.add(flag) + return False + + if flag == BUSY_INPUT_FLAG: + hint_text = busy_input_hint_tui() + elif flag == TOOL_PROGRESS_FLAG: + hint_text = tool_progress_hint_tui() + else: + return False + + _ONBOARDING_HINTS_EMITTED.add(flag) + try: + mark_seen(_hermes_home / "config.yaml", flag) + except Exception: + pass + try: + _emit("onboarding.hint", sid, {"flag": flag, "text": hint_text}) + except Exception: + return False + return True + + def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session = _sessions.get(sid) if session is not None: @@ -1067,6 +1125,20 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result if _tool_progress_enabled(sid) or payload.get("inline_diff"): _emit("tool.complete", sid, payload) + # First-touch onboarding: the first time a tool runs >= 30s in the + # noisiest progress mode ("all"), emit a one-time hint suggesting + # /verbose. Claim is atomic via config.yaml so the hint fires at + # most once per install across CLI + gateway + TUI. + try: + if ( + duration_s is not None + and duration_s >= 30.0 + and _session_tool_progress_mode(sid) == "all" + ): + _maybe_emit_onboarding_hint(sid, "tool_progress_prompt") + except Exception as _hint_err: # pragma: no cover — onboarding is best-effort + logger.debug("tui onboarding tool-progress hint failed: %s", _hint_err) + def _on_tool_progress( sid: str, @@ -1934,6 +2006,53 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "interrupted"}) +# ── Methods: onboarding ────────────────────────────────────────────── +# First-touch hint latch, shared with CLI + gateway via config.yaml +# (``onboarding.seen.``). Ink calls ``onboarding.claim`` the first +# time it hits a behavior fork (busy enqueue, long tool completion); the +# method atomically returns the hint text AND marks the flag seen, so a +# second fast trigger in the same session never double-renders. + +_VALID_ONBOARDING_FLAGS = {"busy_input_prompt", "tool_progress_prompt"} + + +@method("onboarding.claim") +def _(rid, params: dict) -> dict: + flag = str(params.get("flag", "") or "").strip() + if flag not in _VALID_ONBOARDING_FLAGS: + return _err(rid, 4002, f"unknown onboarding flag: {flag}") + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_tui, + is_seen, + mark_seen, + tool_progress_hint_tui, + ) + except Exception as e: # pragma: no cover — onboarding is best-effort + return _ok(rid, {"hint": None, "claimed": False, "error": str(e)}) + + cfg = _load_cfg() + if is_seen(cfg, flag): + return _ok(rid, {"hint": None, "claimed": False}) + + if flag == BUSY_INPUT_FLAG: + hint = busy_input_hint_tui() + elif flag == TOOL_PROGRESS_FLAG: + hint = tool_progress_hint_tui() + else: # defensive — validated above + return _err(rid, 4002, f"unknown onboarding flag: {flag}") + + # Mark seen atomically before returning. If persistence fails, still + # return the hint so the user sees it at least once this session. + try: + mark_seen(_hermes_home / "config.yaml", flag) + except Exception: + pass + return _ok(rid, {"hint": hint, "claimed": True}) + + # ── Delegation: subagent tree observability + controls ─────────────── # Powers the TUI's /agents overlay (see ui-tui/src/components/agentsOverlay). # The registry lives in tools/delegate_tool — these handlers are thin diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 15cf00a5a9..0bd2faecf4 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -570,6 +570,17 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sys(`error: ${message}`) setStatus('ready') } + + return + case 'onboarding.hint': { + const text = String(ev.payload?.text || '').trim() + + if (text) { + sys(`(tip) ${text}`) + } + + return + } } } } diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index f09dc36340..8414126c32 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -3,7 +3,7 @@ import { type MutableRefObject, useCallback, useRef } from 'react' import { attachedImageNotice } from '../domain/messages.js' import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' -import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' +import type { InputDetectDropResponse, OnboardingClaimResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' import { PASTE_SNIPPET_RE } from '../protocol/paste.js' @@ -218,6 +218,22 @@ export function useSubmission(opts: UseSubmissionOptions) { composerActions.pushHistory(full) if (getUiState().busy) { + // First-touch onboarding: teach the TUI's auto-queue + double-Enter + // interrupt pattern the first time the user hits it. Claim is + // atomic server-side (config.yaml latch), shared with CLI + gateway. + gw.request('onboarding.claim', { flag: 'busy_input_prompt' }) + .then(raw => { + const r = asRpcResult(raw) + const text = r?.hint + + if (typeof text === 'string' && text.trim()) { + sys(`(tip) ${text.trim()}`) + } + }) + .catch(() => { + // Onboarding is best-effort — never block the enqueue path. + }) + return composerActions.enqueue(full) } @@ -229,7 +245,7 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] + [appendMessage, composerActions, composerRefs, gw, interpolate, send, sendQueued, shellExec, slashRef, sys] ) const submit = useCallback( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index e64d113c22..ebaa24f2bd 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -174,6 +174,11 @@ export interface PromptSubmitResponse { ok?: boolean } +export interface OnboardingClaimResponse { + claimed?: boolean + hint?: null | string +} + export interface BackgroundStartResponse { task_id?: string } @@ -417,3 +422,4 @@ export type GatewayEvent = type: 'message.complete' } | { payload?: { message?: string }; session_id?: string; type: 'error' } + | { payload: { flag: string; text: string }; session_id?: string; type: 'onboarding.hint' } diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 8c1b179b67..2b936e34e3 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -106,6 +106,18 @@ The TUI's status line tracks agent state in real time: The per-skin status-bar colors and thresholds are shared with the classic CLI — see [Skins](features/skins.md) for customization. +## Interrupting and queueing + +The TUI's busy-input model is different from the classic CLI's `display.busy_input_mode` knob. There is no mode to configure — both behaviors are always available: + +- **Single Enter while busy** — message is **queued** and sent as the next turn after the agent finishes. +- **Double Enter on an empty line while busy** — **interrupts** the current turn. +- **Double Enter on an empty line with queued messages and no running turn** — drains the next queued message. + +The first time you send a message while the agent is working, the TUI prints a one-time `(tip)` line explaining the double-Enter gesture. It fires once per install — the same `onboarding.seen.busy_input_prompt` latch used by the classic CLI and the gateway. Delete that key from `~/.hermes/config.yaml` to see the tip again. + +Similarly, the first time a tool runs for 30 seconds or longer while you're in the noisiest `tool_progress: all` mode, the TUI prints a one-time `(tip)` about `/verbose` for cycling display modes. Latched under `onboarding.seen.tool_progress_prompt`. + ## Configuration The TUI respects all standard Hermes config: `~/.hermes/config.yaml`, profiles, personalities, skins, quick commands, credential pools, memory providers, tool/skill enablement. No TUI-specific config file exists. From 9a7026049088ef6545053313d71856703ff933f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:31:37 -0700 Subject: [PATCH 17/41] Revert "feat(onboarding): port first-touch hints to the TUI (#16054)" (#16062) This reverts commit ffd2621039259ee8419549fedc8739bf1a350436. --- agent/onboarding.py | 22 ---- tests/agent/test_onboarding.py | 12 -- tests/tui_gateway/test_protocol.py | 91 --------------- tui_gateway/server.py | 119 -------------------- ui-tui/src/app/createGatewayEventHandler.ts | 11 -- ui-tui/src/app/useSubmission.ts | 20 +--- ui-tui/src/gatewayTypes.ts | 6 - website/docs/user-guide/tui.md | 12 -- 8 files changed, 2 insertions(+), 291 deletions(-) diff --git a/agent/onboarding.py b/agent/onboarding.py index 7b755ef47e..eed832ab90 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -80,26 +80,6 @@ def tool_progress_hint_cli() -> str: ) -def busy_input_hint_tui() -> str: - """Hint shown the first time a user sends a message while the TUI is busy. - - The TUI auto-queues messages sent mid-turn and uses double-Enter on empty - input as the interrupt gesture. There is no ``/busy`` knob to flip — this - hint teaches the keybind instead of a command. - """ - return ( - "queued for after the current turn — press Enter twice on an empty " - "line to interrupt the current turn instead. This tip only shows once." - ) - - -def tool_progress_hint_tui() -> str: - return ( - "that tool ran for a while — use /verbose to cycle tool-progress " - "display modes (all → new → off → verbose). This tip only shows once." - ) - - # ------------------------------------------------------------------------- # State read / write # ------------------------------------------------------------------------- @@ -157,10 +137,8 @@ __all__ = [ "TOOL_PROGRESS_FLAG", "busy_input_hint_gateway", "busy_input_hint_cli", - "busy_input_hint_tui", "tool_progress_hint_gateway", "tool_progress_hint_cli", - "tool_progress_hint_tui", "is_seen", "mark_seen", ] diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index ec88c1cc30..a14c7d1797 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -10,12 +10,10 @@ from agent.onboarding import ( TOOL_PROGRESS_FLAG, busy_input_hint_cli, busy_input_hint_gateway, - busy_input_hint_tui, is_seen, mark_seen, tool_progress_hint_cli, tool_progress_hint_gateway, - tool_progress_hint_tui, ) @@ -130,14 +128,6 @@ class TestHintMessages: def test_tool_progress_hints_mention_verbose(self): assert "/verbose" in tool_progress_hint_gateway() assert "/verbose" in tool_progress_hint_cli() - assert "/verbose" in tool_progress_hint_tui() - - def test_busy_input_hint_tui_teaches_double_enter(self): - msg = busy_input_hint_tui() - # TUI uses double-Enter as the interrupt gesture, not /busy. - assert "Enter" in msg - assert "queued" in msg.lower() - assert "/busy" not in msg def test_hints_are_not_empty(self): for hint in ( @@ -145,10 +135,8 @@ class TestHintMessages: busy_input_hint_gateway("interrupt"), busy_input_hint_cli("queue"), busy_input_hint_cli("interrupt"), - busy_input_hint_tui(), tool_progress_hint_gateway(), tool_progress_hint_cli(), - tool_progress_hint_tui(), ): assert hint.strip() diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 196e1ee517..42caaacc58 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -542,94 +542,3 @@ def test_dispatch_unknown_long_method_still_goes_inline(server): resp = server.dispatch({"id": "r4", "method": "some.method", "params": {}}) assert resp["result"] == {"ok": True} - - -# ── onboarding.claim ───────────────────────────────────────────────── - - -def test_onboarding_claim_rejects_unknown_flag(server): - resp = server.handle_request({ - "id": "o1", - "method": "onboarding.claim", - "params": {"flag": "bogus_flag"}, - }) - assert "error" in resp - assert resp["error"]["code"] == 4002 - assert "unknown onboarding flag" in resp["error"]["message"] - - -def test_onboarding_claim_busy_input_returns_tui_hint(server, tmp_path, monkeypatch): - """First claim returns the TUI hint text and marks the config.yaml flag.""" - monkeypatch.setattr(server, "_hermes_home", tmp_path) - # Bust cached cfg so the new _hermes_home is re-read. - server._cfg_cache = None - server._cfg_mtime = None - - resp = server.handle_request({ - "id": "o2", - "method": "onboarding.claim", - "params": {"flag": "busy_input_prompt"}, - }) - - assert "result" in resp - result = resp["result"] - assert result["claimed"] is True - assert isinstance(result["hint"], str) and result["hint"].strip() - # The TUI hint must teach the double-Enter gesture, not the /busy knob. - assert "Enter" in result["hint"] - assert "/busy" not in result["hint"] - - # config.yaml should now be written with the flag set. - cfg_path = tmp_path / "config.yaml" - assert cfg_path.exists() - import yaml - loaded = yaml.safe_load(cfg_path.read_text()) - assert loaded["onboarding"]["seen"]["busy_input_prompt"] is True - - -def test_onboarding_claim_second_call_returns_null_hint(server, tmp_path, monkeypatch): - """Second claim on the same flag reads config.yaml and returns hint=null.""" - import yaml - (tmp_path / "config.yaml").write_text( - yaml.safe_dump({"onboarding": {"seen": {"tool_progress_prompt": True}}}) - ) - monkeypatch.setattr(server, "_hermes_home", tmp_path) - server._cfg_cache = None - server._cfg_mtime = None - - resp = server.handle_request({ - "id": "o3", - "method": "onboarding.claim", - "params": {"flag": "tool_progress_prompt"}, - }) - - assert "result" in resp - assert resp["result"]["claimed"] is False - assert resp["result"]["hint"] is None - - -def test_onboarding_claim_flags_are_independent(server, tmp_path, monkeypatch): - """Claiming one flag does not affect the other.""" - monkeypatch.setattr(server, "_hermes_home", tmp_path) - server._cfg_cache = None - server._cfg_mtime = None - - # Claim busy_input_prompt first - resp1 = server.handle_request({ - "id": "o4a", - "method": "onboarding.claim", - "params": {"flag": "busy_input_prompt"}, - }) - assert resp1["result"]["claimed"] is True - - # tool_progress_prompt must still be claimable. Cache bust because the - # first claim wrote to disk mid-test. - server._cfg_cache = None - server._cfg_mtime = None - resp2 = server.handle_request({ - "id": "o4b", - "method": "onboarding.claim", - "params": {"flag": "tool_progress_prompt"}, - }) - assert resp2["result"]["claimed"] is True - assert "/verbose" in resp2["result"]["hint"] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 419a911e76..03631bf174 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1016,64 +1016,6 @@ def _tool_summary(name: str, result: str, duration_s: float | None) -> str | Non return f"{text or 'Completed'}{suffix}" if (text or dur) else None -# ── Onboarding hint emission ───────────────────────────────────────── -# First-touch hints are latched to config.yaml (onboarding.seen.) -# and shared with CLI + gateway so each hint fires at most once per -# install across all surfaces. Best-effort — never raises. - -_ONBOARDING_HINTS_EMITTED: set[str] = set() - - -def _maybe_emit_onboarding_hint(sid: str, flag: str) -> bool: - """Atomically claim an onboarding flag and emit its hint to Ink. - - Returns True if a hint was emitted this call, False if the flag was - already seen (or if anything went wrong — onboarding must never - interrupt the normal event flow). Also deduplicates within a single - process run via ``_ONBOARDING_HINTS_EMITTED`` so concurrent callers - can't double-emit before the config.yaml write lands. - """ - if flag in _ONBOARDING_HINTS_EMITTED: - return False - try: - from agent.onboarding import ( - BUSY_INPUT_FLAG, - TOOL_PROGRESS_FLAG, - busy_input_hint_tui, - is_seen, - mark_seen, - tool_progress_hint_tui, - ) - except Exception: - return False - - try: - cfg = _load_cfg() - except Exception: - cfg = {} - if is_seen(cfg, flag): - _ONBOARDING_HINTS_EMITTED.add(flag) - return False - - if flag == BUSY_INPUT_FLAG: - hint_text = busy_input_hint_tui() - elif flag == TOOL_PROGRESS_FLAG: - hint_text = tool_progress_hint_tui() - else: - return False - - _ONBOARDING_HINTS_EMITTED.add(flag) - try: - mark_seen(_hermes_home / "config.yaml", flag) - except Exception: - pass - try: - _emit("onboarding.hint", sid, {"flag": flag, "text": hint_text}) - except Exception: - return False - return True - - def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session = _sessions.get(sid) if session is not None: @@ -1125,20 +1067,6 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result if _tool_progress_enabled(sid) or payload.get("inline_diff"): _emit("tool.complete", sid, payload) - # First-touch onboarding: the first time a tool runs >= 30s in the - # noisiest progress mode ("all"), emit a one-time hint suggesting - # /verbose. Claim is atomic via config.yaml so the hint fires at - # most once per install across CLI + gateway + TUI. - try: - if ( - duration_s is not None - and duration_s >= 30.0 - and _session_tool_progress_mode(sid) == "all" - ): - _maybe_emit_onboarding_hint(sid, "tool_progress_prompt") - except Exception as _hint_err: # pragma: no cover — onboarding is best-effort - logger.debug("tui onboarding tool-progress hint failed: %s", _hint_err) - def _on_tool_progress( sid: str, @@ -2006,53 +1934,6 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "interrupted"}) -# ── Methods: onboarding ────────────────────────────────────────────── -# First-touch hint latch, shared with CLI + gateway via config.yaml -# (``onboarding.seen.``). Ink calls ``onboarding.claim`` the first -# time it hits a behavior fork (busy enqueue, long tool completion); the -# method atomically returns the hint text AND marks the flag seen, so a -# second fast trigger in the same session never double-renders. - -_VALID_ONBOARDING_FLAGS = {"busy_input_prompt", "tool_progress_prompt"} - - -@method("onboarding.claim") -def _(rid, params: dict) -> dict: - flag = str(params.get("flag", "") or "").strip() - if flag not in _VALID_ONBOARDING_FLAGS: - return _err(rid, 4002, f"unknown onboarding flag: {flag}") - try: - from agent.onboarding import ( - BUSY_INPUT_FLAG, - TOOL_PROGRESS_FLAG, - busy_input_hint_tui, - is_seen, - mark_seen, - tool_progress_hint_tui, - ) - except Exception as e: # pragma: no cover — onboarding is best-effort - return _ok(rid, {"hint": None, "claimed": False, "error": str(e)}) - - cfg = _load_cfg() - if is_seen(cfg, flag): - return _ok(rid, {"hint": None, "claimed": False}) - - if flag == BUSY_INPUT_FLAG: - hint = busy_input_hint_tui() - elif flag == TOOL_PROGRESS_FLAG: - hint = tool_progress_hint_tui() - else: # defensive — validated above - return _err(rid, 4002, f"unknown onboarding flag: {flag}") - - # Mark seen atomically before returning. If persistence fails, still - # return the hint so the user sees it at least once this session. - try: - mark_seen(_hermes_home / "config.yaml", flag) - except Exception: - pass - return _ok(rid, {"hint": hint, "claimed": True}) - - # ── Delegation: subagent tree observability + controls ─────────────── # Powers the TUI's /agents overlay (see ui-tui/src/components/agentsOverlay). # The registry lives in tools/delegate_tool — these handlers are thin diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 0bd2faecf4..15cf00a5a9 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -570,17 +570,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sys(`error: ${message}`) setStatus('ready') } - - return - case 'onboarding.hint': { - const text = String(ev.payload?.text || '').trim() - - if (text) { - sys(`(tip) ${text}`) - } - - return - } } } } diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 8414126c32..f09dc36340 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -3,7 +3,7 @@ import { type MutableRefObject, useCallback, useRef } from 'react' import { attachedImageNotice } from '../domain/messages.js' import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' -import type { InputDetectDropResponse, OnboardingClaimResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' +import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' import { PASTE_SNIPPET_RE } from '../protocol/paste.js' @@ -218,22 +218,6 @@ export function useSubmission(opts: UseSubmissionOptions) { composerActions.pushHistory(full) if (getUiState().busy) { - // First-touch onboarding: teach the TUI's auto-queue + double-Enter - // interrupt pattern the first time the user hits it. Claim is - // atomic server-side (config.yaml latch), shared with CLI + gateway. - gw.request('onboarding.claim', { flag: 'busy_input_prompt' }) - .then(raw => { - const r = asRpcResult(raw) - const text = r?.hint - - if (typeof text === 'string' && text.trim()) { - sys(`(tip) ${text.trim()}`) - } - }) - .catch(() => { - // Onboarding is best-effort — never block the enqueue path. - }) - return composerActions.enqueue(full) } @@ -245,7 +229,7 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, gw, interpolate, send, sendQueued, shellExec, slashRef, sys] + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] ) const submit = useCallback( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ebaa24f2bd..e64d113c22 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -174,11 +174,6 @@ export interface PromptSubmitResponse { ok?: boolean } -export interface OnboardingClaimResponse { - claimed?: boolean - hint?: null | string -} - export interface BackgroundStartResponse { task_id?: string } @@ -422,4 +417,3 @@ export type GatewayEvent = type: 'message.complete' } | { payload?: { message?: string }; session_id?: string; type: 'error' } - | { payload: { flag: string; text: string }; session_id?: string; type: 'onboarding.hint' } diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 2b936e34e3..8c1b179b67 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -106,18 +106,6 @@ The TUI's status line tracks agent state in real time: The per-skin status-bar colors and thresholds are shared with the classic CLI — see [Skins](features/skins.md) for customization. -## Interrupting and queueing - -The TUI's busy-input model is different from the classic CLI's `display.busy_input_mode` knob. There is no mode to configure — both behaviors are always available: - -- **Single Enter while busy** — message is **queued** and sent as the next turn after the agent finishes. -- **Double Enter on an empty line while busy** — **interrupts** the current turn. -- **Double Enter on an empty line with queued messages and no running turn** — drains the next queued message. - -The first time you send a message while the agent is working, the TUI prints a one-time `(tip)` line explaining the double-Enter gesture. It fires once per install — the same `onboarding.seen.busy_input_prompt` latch used by the classic CLI and the gateway. Delete that key from `~/.hermes/config.yaml` to see the tip again. - -Similarly, the first time a tool runs for 30 seconds or longer while you're in the noisiest `tool_progress: all` mode, the TUI prints a one-time `(tip)` about `/verbose` for cycling display modes. Latched under `onboarding.seen.tool_progress_prompt`. - ## Configuration The TUI respects all standard Hermes config: `~/.hermes/config.yaml`, profiles, personalities, skins, quick commands, credential pools, memory providers, tool/skill enablement. No TUI-specific config file exists. From 7fa70b6c87224543430e4a99c7126f50e4d1190f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:11:08 -0700 Subject: [PATCH 18/41] refactor: /btw is now an alias for /background (#16053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ephemeral no-tools side-question variant of /btw confused users who expected 'by-the-way' to mean 'run this off to the side with tools' — they'd type /btw and get a toolless agent that couldn't do the work. /bg worked because it was /background with full tools. Collapse the two: /btw and /bg both alias to /background. One command, one behavior, no more gotchas about which variant has tools. Removed: - _handle_btw_command in cli.py and gateway/run.py - _run_btw_task + _active_btw_tasks state in gateway/run.py - prompt.btw JSON-RPC method + btw.complete event in tui_gateway - BtwStartResponse type + btw.complete case in ui-tui - Standalone /btw slash tree registration in Discord - Standalone btw CommandDef in hermes_cli/commands.py Updated: - background CommandDef aliases: (bg,) -> (bg, btw) - TUI session.ts: local btw handler merged into background - Docs and tips updated to describe /btw as a /background alias --- cli.py | 118 ------------ gateway/platforms/discord.py | 5 - gateway/run.py | 174 ------------------ hermes_cli/commands.py | 4 +- hermes_cli/tips.py | 3 +- .../hermes-agent/SKILL.md | 1 - tui_gateway/server.py | 42 ----- ui-tui/README.md | 1 - ui-tui/src/app/createGatewayEventHandler.ts | 6 - ui-tui/src/app/slash/commands/session.ts | 20 +- ui-tui/src/gatewayTypes.ts | 5 - web/src/lib/gatewayClient.ts | 1 - website/docs/reference/slash-commands.md | 3 +- .../autonomous-ai-agents-hermes-agent.md | 1 - 14 files changed, 4 insertions(+), 380 deletions(-) diff --git a/cli.py b/cli.py index 038c83f06f..da401e5c18 100644 --- a/cli.py +++ b/cli.py @@ -6129,8 +6129,6 @@ class HermesCLI: self._handle_agents_command() elif canonical == "background": self._handle_background_command(cmd_original) - elif canonical == "btw": - self._handle_btw_command(cmd_original) elif canonical == "queue": # Extract prompt after "/queue " or "/q " parts = cmd_original.split(None, 1) @@ -6417,122 +6415,6 @@ class HermesCLI: self._background_tasks[task_id] = thread thread.start() - def _handle_btw_command(self, cmd: str): - """Handle /btw — ephemeral side question using session context. - - Snapshots the current conversation history, spawns a no-tools agent in - a background thread, and prints the answer without persisting anything - to the main session. - """ - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - _cprint(" Usage: /btw ") - _cprint(" Example: /btw what module owns session title sanitization?") - _cprint(" Answers using session context. No tools, not persisted.") - return - - question = parts[1].strip() - task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}" - - if not self._ensure_runtime_credentials(): - _cprint(" (>_<) Cannot start /btw: no valid credentials.") - return - - turn_route = self._resolve_turn_agent_config(question) - history_snapshot = list(self.conversation_history) - - preview = question[:60] + ("..." if len(question) > 60 else "") - _cprint(f' 💬 /btw: "{preview}"') - - def run_btw(): - try: - btw_agent = AIAgent( - model=turn_route["model"], - api_key=turn_route["runtime"].get("api_key"), - base_url=turn_route["runtime"].get("base_url"), - provider=turn_route["runtime"].get("provider"), - api_mode=turn_route["runtime"].get("api_mode"), - acp_command=turn_route["runtime"].get("command"), - acp_args=turn_route["runtime"].get("args"), - max_iterations=8, - enabled_toolsets=[], - quiet_mode=True, - verbose_logging=False, - session_id=task_id, - platform="cli", - reasoning_config=self.reasoning_config, - service_tier=self.service_tier, - request_overrides=turn_route.get("request_overrides"), - providers_allowed=self._providers_only, - providers_ignored=self._providers_ignore, - providers_order=self._providers_order, - provider_sort=self._provider_sort, - provider_require_parameters=self._provider_require_params, - provider_data_collection=self._provider_data_collection, - fallback_model=self._fallback_model, - session_db=None, - skip_memory=True, - skip_context_files=True, - persist_session=False, - ) - - btw_prompt = ( - "[Ephemeral /btw side question. Answer using the conversation " - "context. No tools available. Be direct and concise.]\n\n" - + question - ) - result = btw_agent.run_conversation( - user_message=btw_prompt, - conversation_history=history_snapshot, - task_id=task_id, - ) - - response = (result.get("final_response") or "") if result else "" - if not response and result and result.get("error"): - response = f"Error: {result['error']}" - - # TUI refresh before printing - if self._app: - self._app.invalidate() - time.sleep(0.05) - print() - - if response: - try: - from hermes_cli.skin_engine import get_active_skin - _skin = get_active_skin() - _resp_color = _skin.get_color("response_border", "#4F6D4A") - except Exception: - _resp_color = "#4F6D4A" - - ChatConsole().print(Panel( - _render_final_assistant_content(response, mode=self.final_response_markdown), - title=f"[{_resp_color} bold]⚕ /btw[/]", - title_align="left", - border_style=_resp_color, - box=rich_box.HORIZONTALS, - padding=(1, 4), - )) - else: - _cprint(" 💬 /btw: (no response)") - - if self.bell_on_complete: - sys.stdout.write("\a") - sys.stdout.flush() - - except Exception as e: - if self._app: - self._app.invalidate() - time.sleep(0.05) - print() - _cprint(f" ❌ /btw failed: {e}") - finally: - if self._app: - self._invalidate(min_interval=0) - - thread = threading.Thread(target=run_btw, daemon=True, name=f"btw-{task_id}") - thread.start() - @staticmethod def _try_launch_chrome_debug(port: int, system: str) -> bool: """Try to launch Chrome/Chromium with remote debugging enabled. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 5d30f244e8..b4018c6df6 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2315,11 +2315,6 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_background(interaction: discord.Interaction, prompt: str): await self._run_simple_slash(interaction, f"/background {prompt}", "Background task started~") - @tree.command(name="btw", description="Ephemeral side question using session context") - @discord.app_commands.describe(question="Your side question (no tools, not persisted)") - async def slash_btw(interaction: discord.Interaction, question: str): - await self._run_simple_slash(interaction, f"/btw {question}") - # ── Auto-register any gateway-available commands not yet on the tree ── # This ensures new commands added to COMMAND_REGISTRY in # hermes_cli/commands.py automatically appear as Discord slash diff --git a/gateway/run.py b/gateway/run.py index d7331bdc75..6cd1083ba7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3773,9 +3773,6 @@ class GatewayRunner: if canonical == "background": return await self._handle_background_command(event) - if canonical == "btw": - return await self._handle_btw_command(event) - if canonical == "steer": # No active agent — /steer has no tool call to inject into. # Strip the prefix so downstream treats it as a normal user @@ -6673,177 +6670,6 @@ class GatewayRunner: except Exception: pass - async def _handle_btw_command(self, event: MessageEvent) -> str: - """Handle /btw — ephemeral side question in the same chat.""" - question = event.get_command_args().strip() - if not question: - return ( - "Usage: /btw \n" - "Example: /btw what module owns session title sanitization?\n\n" - "Answers using session context. No tools, not persisted." - ) - - source = event.source - session_key = self._session_key_for_source(source) - - # Guard: one /btw at a time per session - existing = getattr(self, "_active_btw_tasks", {}).get(session_key) - if existing and not existing.done(): - return "A /btw is already running for this chat. Wait for it to finish." - - if not hasattr(self, "_active_btw_tasks"): - self._active_btw_tasks: dict = {} - - import uuid as _uuid - task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{_uuid.uuid4().hex[:6]}" - _task = asyncio.create_task(self._run_btw_task(question, source, session_key, task_id)) - self._background_tasks.add(_task) - self._active_btw_tasks[session_key] = _task - - def _cleanup(task): - self._background_tasks.discard(task) - if self._active_btw_tasks.get(session_key) is task: - self._active_btw_tasks.pop(session_key, None) - - _task.add_done_callback(_cleanup) - - preview = question[:60] + ("..." if len(question) > 60 else "") - return f'💬 /btw: "{preview}"\nReply will appear here shortly.' - - async def _run_btw_task( - self, question: str, source, session_key: str, task_id: str, - ) -> None: - """Execute an ephemeral /btw side question and deliver the answer.""" - from run_agent import AIAgent - - adapter = self.adapters.get(source.platform) - if not adapter: - logger.warning("No adapter for platform %s in /btw task %s", source.platform, task_id) - return - - _thread_meta = {"thread_id": source.thread_id} if source.thread_id else None - - try: - user_config = _load_gateway_config() - model, runtime_kwargs = self._resolve_session_agent_runtime( - source=source, - session_key=session_key, - user_config=user_config, - ) - if not runtime_kwargs.get("api_key"): - await adapter.send( - source.chat_id, - "❌ /btw failed: no provider credentials configured.", - metadata=_thread_meta, - ) - return - - platform_key = _platform_config_key(source.platform) - reasoning_config = self._resolve_session_reasoning_config( - source=source, - session_key=session_key, - ) - self._service_tier = self._load_service_tier() - turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs) - pr = self._provider_routing - - # Snapshot history from running agent or stored transcript - running_agent = self._running_agents.get(session_key) - if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: - history_snapshot = list(getattr(running_agent, "_session_messages", []) or []) - else: - session_entry = self.session_store.get_or_create_session(source) - history_snapshot = self.session_store.load_transcript(session_entry.session_id) - - btw_prompt = ( - "[Ephemeral /btw side question. Answer using the conversation " - "context. No tools available. Be direct and concise.]\n\n" - + question - ) - - def run_sync(): - agent = AIAgent( - model=turn_route["model"], - **turn_route["runtime"], - max_iterations=8, - quiet_mode=True, - verbose_logging=False, - enabled_toolsets=[], - reasoning_config=reasoning_config, - service_tier=self._service_tier, - request_overrides=turn_route.get("request_overrides"), - providers_allowed=pr.get("only"), - providers_ignored=pr.get("ignore"), - providers_order=pr.get("order"), - provider_sort=pr.get("sort"), - provider_require_parameters=pr.get("require_parameters", False), - provider_data_collection=pr.get("data_collection"), - session_id=task_id, - platform=platform_key, - session_db=None, - fallback_model=self._fallback_model, - skip_memory=True, - skip_context_files=True, - persist_session=False, - ) - try: - return agent.run_conversation( - user_message=btw_prompt, - conversation_history=history_snapshot, - task_id=task_id, - ) - finally: - self._cleanup_agent_resources(agent) - - result = await self._run_in_executor_with_context(run_sync) - - response = (result.get("final_response") or "") if result else "" - if not response and result and result.get("error"): - response = f"Error: {result['error']}" - if not response: - response = "(No response generated)" - - media_files, response = adapter.extract_media(response) - images, text_content = adapter.extract_images(response) - preview = question[:60] + ("..." if len(question) > 60 else "") - header = f'💬 /btw: "{preview}"\n\n' - - if text_content: - await adapter.send( - chat_id=source.chat_id, - content=header + text_content, - metadata=_thread_meta, - ) - elif not images and not media_files: - await adapter.send( - chat_id=source.chat_id, - content=header + "(No response generated)", - metadata=_thread_meta, - ) - - for image_url, alt_text in (images or []): - try: - await adapter.send_image(chat_id=source.chat_id, image_url=image_url, caption=alt_text) - except Exception: - pass - - for media_path, _is_voice in (media_files or []): - try: - await adapter.send_file(chat_id=source.chat_id, file_path=media_path) - except Exception: - pass - - except Exception as e: - logger.exception("/btw task %s failed", task_id) - try: - await adapter.send( - chat_id=source.chat_id, - content=f"❌ /btw failed: {e}", - metadata=_thread_meta, - ) - except Exception: - pass - async def _handle_reasoning_command(self, event: MessageEvent) -> str: """Handle /reasoning command — manage reasoning effort and display toggle. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 4d650487b4..614d783d95 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -84,9 +84,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("deny", "Deny a pending dangerous command", "Session", gateway_only=True), CommandDef("background", "Run a prompt in the background", "Session", - aliases=("bg",), args_hint=""), - CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session", - args_hint=""), + aliases=("bg", "btw"), args_hint=""), CommandDef("agents", "Show active agents and running tasks", "Session", aliases=("tasks",)), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index db66e1db1b..a93a31db13 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -10,8 +10,7 @@ import random TIPS = [ # --- Slash Commands --- - "/btw asks a quick side question without tools or history — great for clarifications.", - "/background runs a task in a separate session while your current one stays free.", + "/background (alias /bg or /btw) runs a task in a separate session while your current one stays free.", "/branch forks the current session so you can explore a different direction without losing progress.", "/compress manually compresses conversation context when things get long.", "/rollback lists filesystem checkpoints — restore files the agent modified to any prior state.", diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 4ed03a904c..76a0e51b6c 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -281,7 +281,6 @@ Type these during an interactive chat session. ### Utility ``` /branch (/fork) Branch the current session -/btw Ephemeral side question (doesn't interrupt main task) /fast Toggle priority/fast processing /browser Open CDP browser connection /history Show conversation history (CLI) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03631bf174..30531aab28 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2550,48 +2550,6 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"task_id": task_id}) -@method("prompt.btw") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err - text, sid = params.get("text", ""), params.get("session_id", "") - if not text: - return _err(rid, 4012, "text required") - snapshot = list(session.get("history", [])) - - def run(): - session_tokens = _set_session_context(session["session_key"]) - try: - from run_agent import AIAgent - - result = AIAgent( - model=_resolve_model(), - quiet_mode=True, - platform="tui", - max_iterations=8, - enabled_toolsets=[], - ).run_conversation(text, conversation_history=snapshot) - _emit( - "btw.complete", - sid, - { - "text": ( - result.get("final_response", str(result)) - if isinstance(result, dict) - else str(result) - ) - }, - ) - except Exception as e: - _emit("btw.complete", sid, {"text": f"error: {e}"}) - finally: - _clear_session_context(session_tokens) - - threading.Thread(target=run, daemon=True).start() - return _ok(rid, {"status": "running"}) - - # ── Methods: respond ───────────────────────────────────────────────── diff --git a/ui-tui/README.md b/ui-tui/README.md index 2f95a47aa2..17d57f08af 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -252,7 +252,6 @@ Primary event types the client handles today: | `sudo.request` | `{ request_id }` | | `secret.request` | `{ prompt, env_var, request_id }` | | `background.complete` | `{ task_id, text }` | -| `btw.complete` | `{ text }` | | `error` | `{ message }` | | `gateway.stderr` | synthesized from child stderr | | `gateway.protocol_error` | synthesized from malformed stdout | diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 15cf00a5a9..0bd505078f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -431,12 +431,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return - case 'btw.complete': - dropBgTask('btw:x') - sys(`[btw] ${ev.payload.text}`) - - return - case 'subagent.spawn_requested': // Child built but not yet running (waiting on ThreadPoolExecutor slot). // Preserve completed state if a later event races in before this one. diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 1049ee34d8..df106e1d86 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,7 +1,6 @@ import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' import type { BackgroundStartResponse, - BtwStartResponse, ConfigGetValueResponse, ConfigSetResponse, ImageAttachResponse, @@ -18,7 +17,7 @@ import type { SlashCommand } from '../types.js' export const sessionCommands: SlashCommand[] = [ { - aliases: ['bg'], + aliases: ['bg', 'btw'], help: 'launch a background prompt', name: 'background', run: (arg, ctx) => { @@ -39,23 +38,6 @@ export const sessionCommands: SlashCommand[] = [ } }, - { - help: 'by-the-way follow-up', - name: 'btw', - run: (arg, ctx) => { - if (!arg) { - return ctx.transcript.sys('/btw ') - } - - ctx.gateway.rpc('prompt.btw', { session_id: ctx.sid, text: arg }).then( - ctx.guarded(() => { - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) - ctx.transcript.sys('btw running…') - }) - ) - } - }, - { help: 'change or show model', aliases: ['provider'], diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index e64d113c22..ce056040c2 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -178,10 +178,6 @@ export interface BackgroundStartResponse { task_id?: string } -export interface BtwStartResponse { - ok?: boolean -} - export interface ClarifyRespondResponse { ok?: boolean } @@ -403,7 +399,6 @@ export type GatewayEvent = | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } - | { payload: { text: string }; session_id?: string; type: 'btw.complete' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' } diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index 012482b710..fa58841ce1 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -32,7 +32,6 @@ export type GatewayEventName = | "sudo.request" | "secret.request" | "background.complete" - | "btw.complete" | "error" | "skin.changed" | (string & {}); diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 6e04bcd010..ed2a2ff2fc 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -36,8 +36,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/resume [name]` | Resume a previously-named session | | `/status` | Show session info | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | -| `/background ` (alias: `/bg`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). | -| `/btw ` | Ephemeral side question using session context (no tools, not persisted). Useful for quick clarifications without affecting the conversation history. | +| `/background ` (alias: `/bg`, `/btw`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). | | `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) | ### Configuration diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index efd6326259..10a91f2aae 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -298,7 +298,6 @@ Type these during an interactive chat session. ### Utility ``` /branch (/fork) Branch the current session -/btw Ephemeral side question (doesn't interrupt main task) /fast Toggle priority/fast processing /browser Open CDP browser connection /history Show conversation history (CLI) From 70f56e7605c36885622c0741537e8a9ee5edd68f Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 26 Apr 2026 07:10:52 -0700 Subject: [PATCH 19/41] fix(gateway): let /btw dispatch mid-turn instead of being rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /btw spawns a parallel ephemeral side-question task (self-guarded against concurrent /btw on the same chat) — exactly like /background. But it was missing from the running-agent bypass list in _handle_message(), so it fell through to the catch-all and returned: ⏳ Agent is running — /btw can't run mid-turn. Wait for the current response or /stop first. That's the opposite of what /btw is for — asking a side question while the main turn is still working. Add the bypass next to /background and a regression test covering the mid-turn dispatch path. Reported by @IuriiTiunov on Telegram. --- gateway/run.py | 8 +++++++ .../test_running_agent_session_toggles.py | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 6cd1083ba7..1ab57984e0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3501,6 +3501,14 @@ class GatewayRunner: if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) + # /btw must bypass the running-agent guard for the same reason + # as /background: it spawns a parallel ephemeral side-question + # task (see _handle_btw_command) that doesn't interrupt the + # active conversation and self-guards against concurrent /btw + # on the same chat. + if _cmd_def_inner and _cmd_def_inner.name == "btw": + return await self._handle_btw_command(event) + # Session-level toggles that are safe to run mid-agent — # /yolo can unblock a pending approval prompt, /verbose cycles # the tool-progress display mode for the ongoing stream. diff --git a/tests/gateway/test_running_agent_session_toggles.py b/tests/gateway/test_running_agent_session_toggles.py index fbe0d5163c..d60e5b154e 100644 --- a/tests/gateway/test_running_agent_session_toggles.py +++ b/tests/gateway/test_running_agent_session_toggles.py @@ -165,3 +165,27 @@ async def test_reasoning_rejected_mid_run(): assert result is not None assert "can't run mid-turn" in result assert "/reasoning" in result + + +@pytest.mark.asyncio +async def test_btw_dispatches_mid_run(): + """/btw mid-run must dispatch to its handler, not hit the catch-all. + + /btw spawns a parallel ephemeral side-question task that does NOT + interrupt the active conversation (see _handle_btw_command). It's the + whole point of the command — asking a side question while the main + turn is still working. Before the mid-turn bypass was added, /btw + fell through to the "Agent is running — wait or /stop first" catch-all, + making it useless in exactly the scenario it was designed for. + """ + runner = _make_runner() + runner._handle_btw_command = AsyncMock( + return_value='💬 /btw: "what module owns titles?"\nReply will appear here shortly.' + ) + + result = await runner._handle_message(_make_event("/btw what module owns titles?")) + + runner._handle_btw_command.assert_awaited_once() + assert result is not None + assert "💬 /btw" in result + assert "can't run mid-turn" not in result From 454d883e6977419854cf26138b93b118871d36d7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:15:23 -0700 Subject: [PATCH 20/41] refactor: drop persist_session plumbing + fix broken btw mid-turn bypass (#16075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #16053 (/btw as /background alias). Cleans up the plumbing added exclusively for the old ephemeral /btw handler and repairs a broken btw bypass that landed between my refactor and this follow-up. run_agent.py: - Remove persist_session kwarg, instance attr, and _persist_session short-circuit. Only /btw ever passed persist_session=False; with /btw gone the default (always persist) is the only behavior anyone ever wanted. gateway/run.py: - Remove the unreachable 'if _cmd_def_inner.name == "btw"' block (PR #16059). Canonical name for a /btw message is 'background' after alias resolution — the comparison could never be true, and it called _handle_btw_command which no longer exists. The /background branch above it already dispatches /btw correctly. tests/gateway/test_running_agent_session_toggles.py: - Fix test_btw_dispatches_mid_run to mock _handle_background_command (the real dispatch target for /btw) instead of the deleted _handle_btw_command. --- gateway/run.py | 10 ++------- run_agent.py | 5 ----- .../test_running_agent_session_toggles.py | 21 +++++++++---------- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 1ab57984e0..9926920b81 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3498,17 +3498,11 @@ class GatewayRunner: # /background must bypass the running-agent guard — it starts a # parallel task and must never interrupt the active conversation. + # /btw is an alias of /background and resolves to the same canonical + # name, so this branch handles both commands. if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) - # /btw must bypass the running-agent guard for the same reason - # as /background: it spawns a parallel ephemeral side-question - # task (see _handle_btw_command) that doesn't interrupt the - # active conversation and self-guards against concurrent /btw - # on the same chat. - if _cmd_def_inner and _cmd_def_inner.name == "btw": - return await self._handle_btw_command(event) - # Session-level toggles that are safe to run mid-agent — # /yolo can unblock a pending approval prompt, /verbose cycles # the tool-progress display mode for the ongoing stream. diff --git a/run_agent.py b/run_agent.py index 7b23b5b41c..43c367e460 100644 --- a/run_agent.py +++ b/run_agent.py @@ -892,7 +892,6 @@ class AIAgent: checkpoints_enabled: bool = False, checkpoint_max_snapshots: int = 50, pass_session_id: bool = False, - persist_session: bool = True, ): """ Initialize the AI Agent. @@ -964,7 +963,6 @@ class AIAgent: self.background_review_callback = None # Optional sync callback for gateway delivery self.skip_context_files = skip_context_files self.pass_session_id = pass_session_id - self.persist_session = persist_session self._credential_pool = credential_pool self.log_prefix_chars = log_prefix_chars self.log_prefix = f"{log_prefix} " if log_prefix else "" @@ -3353,10 +3351,7 @@ class AIAgent: """Save session state to both JSON log and SQLite on any exit path. Ensures conversations are never lost, even on errors or early returns. - Skipped when ``persist_session=False`` (ephemeral helper flows). """ - if not self.persist_session: - return self._apply_persist_user_message_override(messages) self._session_messages = messages self._save_session_log(messages) diff --git a/tests/gateway/test_running_agent_session_toggles.py b/tests/gateway/test_running_agent_session_toggles.py index d60e5b154e..6bf8be9973 100644 --- a/tests/gateway/test_running_agent_session_toggles.py +++ b/tests/gateway/test_running_agent_session_toggles.py @@ -169,23 +169,22 @@ async def test_reasoning_rejected_mid_run(): @pytest.mark.asyncio async def test_btw_dispatches_mid_run(): - """/btw mid-run must dispatch to its handler, not hit the catch-all. + """/btw mid-run must dispatch to /background's handler, not hit the catch-all. - /btw spawns a parallel ephemeral side-question task that does NOT - interrupt the active conversation (see _handle_btw_command). It's the - whole point of the command — asking a side question while the main - turn is still working. Before the mid-turn bypass was added, /btw - fell through to the "Agent is running — wait or /stop first" catch-all, - making it useless in exactly the scenario it was designed for. + /btw is an alias of /background (see hermes_cli/commands.py). Typing + /btw mid-turn must spawn a parallel background task — that's the whole + point of the command. Before the mid-turn bypass was added for + /background, /btw fell through to the "Agent is running — wait or + /stop first" catch-all, making it useless in exactly the scenario it + was designed for. The alias and the bypass together make it work. """ runner = _make_runner() - runner._handle_btw_command = AsyncMock( - return_value='💬 /btw: "what module owns titles?"\nReply will appear here shortly.' + runner._handle_background_command = AsyncMock( + return_value='🚀 Background task started: "what module owns titles?"' ) result = await runner._handle_message(_make_event("/btw what module owns titles?")) - runner._handle_btw_command.assert_awaited_once() + runner._handle_background_command.assert_awaited_once() assert result is not None - assert "💬 /btw" in result assert "can't run mid-turn" not in result From 15937a6b4654a331ce5fd5b1052baad82f9319fd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:24:26 -0700 Subject: [PATCH 21/41] feat(kanban): durable multi-profile collaboration board (#16081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `hermes kanban` CLI subcommand + `/kanban` slash command + skills for worker and orchestrator profiles. SQLite-backed task board (~/.hermes/kanban.db) shared across all profiles on the host. Zero changes to run_agent.py, no new core tools, no tool-schema bloat. Motivation: delegate_task is a function call — sync fork/join, anonymous subagent, no resumability, no human-in-the-loop. Kanban is the durable shape needed for research triage, scheduled ops, digital twins, engineering pipelines, and fleet work. They coexist (workers may call delegate_task internally). What this adds - hermes_cli/kanban_db.py — schema, CAS claim, dependency resolution, dispatcher, workspace resolution, worker-context builder. - hermes_cli/kanban.py — 15-verb CLI surface and shared run_slash() entry point used by both CLI and gateway. - skills/devops/kanban-worker — how a profile should work a claimed task. - skills/devops/kanban-orchestrator — "you are a dispatcher, not a worker" template with anti-temptation rules. - /kanban slash command wired into cli.py and gateway/run.py. Bypasses the running-agent guard (board writes don't touch agent state), so /kanban unblock can free a stuck worker mid-conversation. - Design spec at docs/hermes-kanban-v1-spec.pdf — comparative analysis vs Cline Kanban, Paperclip, NanoClaw, Gemini Enterprise; 8 patterns; 4 user stories; implementation plan; concurrency correctness. - Docs: website/docs/user-guide/features/kanban.md, CLI reference updated, sidebar entry added. Architecture highlights - Three planes: control (user + gateway), state (board + dispatcher), execution (pool of profile processes). - Every worker is a full OS process, spawned as `hermes -p `. No in-process subagent swarms — solves NanoClaw's SDK-lifecycle failure class. - Atomic claim via SQLite CAS in a BEGIN IMMEDIATE transaction; stale claims reclaimed 15 min after their TTL expires. - Tenant namespacing via one nullable column — one specialist fleet can serve many businesses with data isolation by workspace path. Tests: 60 targeted tests (schema, CAS atomicity, dependency resolution, dispatcher, workspace kinds, tenancy, CLI + slash surface). All pass hermetic via scripts/run_tests.sh. --- cli.py | 25 +- docs/hermes-kanban-v1-spec.pdf | Bin 0 -> 213669 bytes gateway/run.py | 42 + hermes_cli/commands.py | 5 + hermes_cli/kanban.py | 662 ++++++++++++ hermes_cli/kanban_db.py | 1067 ++++++++++++++++++++ hermes_cli/main.py | 14 + skills/devops/kanban-orchestrator/SKILL.md | 140 +++ skills/devops/kanban-worker/SKILL.md | 120 +++ tests/hermes_cli/test_kanban_cli.py | 210 ++++ tests/hermes_cli/test_kanban_db.py | 438 ++++++++ website/docs/reference/cli-commands.md | 33 + website/docs/user-guide/features/kanban.md | 167 +++ website/sidebars.ts | 1 + 14 files changed, 2923 insertions(+), 1 deletion(-) create mode 100644 docs/hermes-kanban-v1-spec.pdf create mode 100644 hermes_cli/kanban.py create mode 100644 hermes_cli/kanban_db.py create mode 100644 skills/devops/kanban-orchestrator/SKILL.md create mode 100644 skills/devops/kanban-worker/SKILL.md create mode 100644 tests/hermes_cli/test_kanban_cli.py create mode 100644 tests/hermes_cli/test_kanban_db.py create mode 100644 website/docs/user-guide/features/kanban.md diff --git a/cli.py b/cli.py index da401e5c18..f876a93398 100644 --- a/cli.py +++ b/cli.py @@ -5818,7 +5818,28 @@ class HermesCLI: print(f"(._.) Unknown cron command: {subcommand}") print(" Available: list, add, edit, pause, resume, run, remove") - + + def _handle_kanban_command(self, cmd: str): + """Handle the /kanban command — delegate to the shared kanban CLI. + + The string form passed here is the user's full ``/kanban ...`` + including the leading slash; we strip it and hand the remainder + to ``kanban.run_slash`` which returns a single formatted string. + """ + from hermes_cli.kanban import run_slash + + rest = cmd.strip() + if rest.startswith("/"): + rest = rest.lstrip("/") + if rest.startswith("kanban"): + rest = rest[len("kanban"):].lstrip() + try: + output = run_slash(rest) + except Exception as exc: # pragma: no cover - defensive + output = f"(._.) kanban error: {exc}" + if output: + print(output) + def _handle_skills_command(self, cmd: str): """Handle /skills slash command — delegates to hermes_cli.skills_hub.""" from hermes_cli.skills_hub import handle_skills_slash @@ -6055,6 +6076,8 @@ class HermesCLI: self.save_conversation() elif canonical == "cron": self._handle_cron_command(cmd_original) + elif canonical == "kanban": + self._handle_kanban_command(cmd_original) elif canonical == "skills": with self._busy_command(self._slow_command_status(cmd_original)): self._handle_skills_command(cmd_original) diff --git a/docs/hermes-kanban-v1-spec.pdf b/docs/hermes-kanban-v1-spec.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c7899cd12a92e3f14e8c44e7c36f96f174982c41 GIT binary patch literal 213669 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 z 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 literal 0 HcmV?d00001 diff --git a/gateway/run.py b/gateway/run.py index 9926920b81..c85210515f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3503,6 +3503,14 @@ class GatewayRunner: if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) + # /kanban must bypass the guard. It writes to a profile-agnostic + # DB (kanban.db), not to the running agent's state. In fact + # /kanban unblock is often the only way to free a worker that + # has blocked waiting for a peer — letting that be dispatched + # mid-run is the whole point of the board. + if _cmd_def_inner and _cmd_def_inner.name == "kanban": + return await self._handle_kanban_command(event) + # Session-level toggles that are safe to run mid-agent — # /yolo can unblock a pending approval prompt, /verbose cycles # the tool-progress display mode for the ongoing stream. @@ -3727,6 +3735,9 @@ class GatewayRunner: if canonical == "personality": return await self._handle_personality_command(event) + if canonical == "kanban": + return await self._handle_kanban_command(event) + if canonical == "retry": return await self._handle_retry_command(event) @@ -5154,6 +5165,37 @@ class GatewayRunner: return "\n".join(lines) + + async def _handle_kanban_command(self, event: MessageEvent) -> str: + """Handle /kanban — delegate to the shared kanban CLI. + + Run the potentially-blocking DB work in a thread pool so the + gateway event loop stays responsive. Read operations (list, + show, context, tail) are permitted while an agent is running; + mutations are allowed too because the board is profile-agnostic + and does not touch the running agent's state. + """ + import asyncio + from hermes_cli.kanban import run_slash + + text = (event.text or "").strip() + # Strip the leading "/kanban" (with or without slash), leaving args. + if text.startswith("/"): + text = text.lstrip("/") + if text.startswith("kanban"): + text = text[len("kanban"):].lstrip() + + try: + output = await asyncio.to_thread(run_slash, text) + except Exception as exc: # pragma: no cover - defensive + return f"⚠ kanban error: {exc}" + + # Gateway messages have practical length caps; truncate long + # listings to keep the UX reasonable. + if len(output) > 3800: + output = output[:3800] + "\n… (truncated; use `hermes kanban …` in your terminal for full output)" + return output or "(no output)" + async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" source = event.source diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 614d783d95..2d748d525d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -140,6 +140,11 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), + CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)", + "Tools & Skills", args_hint="[subcommand]", + subcommands=("list", "ls", "show", "create", "assign", "link", "unlink", + "claim", "comment", "complete", "block", "unblock", "archive", + "tail", "dispatch", "context", "init", "gc")), CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills", cli_only=True), CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py new file mode 100644 index 0000000000..0744a78753 --- /dev/null +++ b/hermes_cli/kanban.py @@ -0,0 +1,662 @@ +"""CLI for the Hermes Kanban board — ``hermes kanban …`` subcommand. + +Exposes the full 15-verb surface documented in the design spec +(``docs/hermes-kanban-v1-spec.pdf``). All DB work is delegated to +``kanban_db``. This module adds: + + * Argparse subcommand construction (``build_parser``). + * Argument dispatch (``kanban_command``). + * Output formatting (plain text + ``--json``). + * A short shared helper that parses a single slash-style string + (used by ``/kanban …`` in CLI and gateway) and forwards it to the + argparse surface. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import sys +import time +from pathlib import Path +from typing import Any, Optional + +from hermes_cli import kanban_db as kb + + +# --------------------------------------------------------------------------- +# Small formatting helpers +# --------------------------------------------------------------------------- + +_STATUS_ICONS = { + "todo": "◻", + "ready": "▶", + "running": "●", + "blocked": "⊘", + "done": "✓", + "archived": "—", +} + + +def _fmt_ts(ts: Optional[int]) -> str: + if not ts: + return "" + return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + + +def _fmt_task_line(t: kb.Task) -> str: + icon = _STATUS_ICONS.get(t.status, "?") + assignee = t.assignee or "(unassigned)" + tenant = f" [{t.tenant}]" if t.tenant else "" + return f"{icon} {t.id} {t.status:8s} {assignee:20s}{tenant} {t.title}" + + +def _task_to_dict(t: kb.Task) -> dict[str, Any]: + return { + "id": t.id, + "title": t.title, + "body": t.body, + "assignee": t.assignee, + "status": t.status, + "priority": t.priority, + "tenant": t.tenant, + "workspace_kind": t.workspace_kind, + "workspace_path": t.workspace_path, + "created_by": t.created_by, + "created_at": t.created_at, + "started_at": t.started_at, + "completed_at": t.completed_at, + "result": t.result, + } + + +def _parse_workspace_flag(value: str) -> tuple[str, Optional[str]]: + """Parse ``--workspace`` into ``(kind, path|None)``. + + Accepts: ``scratch``, ``worktree``, ``dir:``. + """ + if not value: + return ("scratch", None) + v = value.strip() + if v in ("scratch", "worktree"): + return (v, None) + if v.startswith("dir:"): + path = v[len("dir:"):].strip() + if not path: + raise argparse.ArgumentTypeError( + "--workspace dir: requires a path after the colon" + ) + return ("dir", os.path.expanduser(path)) + raise argparse.ArgumentTypeError( + f"unknown --workspace value {value!r}: use scratch, worktree, or dir:" + ) + + +# --------------------------------------------------------------------------- +# Argparse builder +# --------------------------------------------------------------------------- + +def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: + """Attach the ``kanban`` subcommand tree under an existing subparsers. + + Returns the top-level ``kanban`` parser so caller can ``set_defaults``. + """ + kanban_parser = parent_subparsers.add_parser( + "kanban", + help="Multi-profile collaboration board (tasks, links, comments)", + description=( + "Durable SQLite-backed task board shared across Hermes profiles. " + "Tasks are claimed atomically, can depend on other tasks, and " + "are executed by a named profile in an isolated workspace. " + "See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban " + "or docs/hermes-kanban-v1-spec.pdf for the full design." + ), + ) + sub = kanban_parser.add_subparsers(dest="kanban_action") + + # --- init --- + sub.add_parser("init", help="Create kanban.db if missing (idempotent)") + + # --- create --- + p_create = sub.add_parser("create", help="Create a new task") + p_create.add_argument("title", help="Task title") + p_create.add_argument("--body", default=None, help="Optional opening post") + p_create.add_argument("--assignee", default=None, help="Profile name to assign") + p_create.add_argument("--parent", action="append", default=[], + help="Parent task id (repeatable)") + p_create.add_argument("--workspace", default="scratch", + help="scratch | worktree | dir: (default: scratch)") + p_create.add_argument("--tenant", default=None, help="Tenant namespace") + p_create.add_argument("--priority", type=int, default=0, help="Priority tiebreaker") + p_create.add_argument("--created-by", default="user", + help="Author name recorded on the task (default: user)") + p_create.add_argument("--json", action="store_true", help="Emit JSON output") + + # --- list --- + p_list = sub.add_parser("list", aliases=["ls"], help="List tasks") + p_list.add_argument("--mine", action="store_true", + help="Filter by $HERMES_PROFILE as assignee") + p_list.add_argument("--assignee", default=None) + p_list.add_argument("--status", default=None, + choices=sorted(kb.VALID_STATUSES)) + p_list.add_argument("--tenant", default=None) + p_list.add_argument("--archived", action="store_true", + help="Include archived tasks") + p_list.add_argument("--json", action="store_true") + + # --- show --- + p_show = sub.add_parser("show", help="Show a task with comments + events") + p_show.add_argument("task_id") + p_show.add_argument("--json", action="store_true") + + # --- assign --- + p_assign = sub.add_parser("assign", help="Assign or reassign a task") + p_assign.add_argument("task_id") + p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)") + + # --- link / unlink --- + p_link = sub.add_parser("link", help="Add a parent->child dependency") + p_link.add_argument("parent_id") + p_link.add_argument("child_id") + p_unlink = sub.add_parser("unlink", help="Remove a parent->child dependency") + p_unlink.add_argument("parent_id") + p_unlink.add_argument("child_id") + + # --- claim --- + p_claim = sub.add_parser( + "claim", + help="Atomically claim a ready task (prints resolved workspace path)", + ) + p_claim.add_argument("task_id") + p_claim.add_argument("--ttl", type=int, default=kb.DEFAULT_CLAIM_TTL_SECONDS, + help="Claim TTL in seconds (default: 900)") + + # --- comment / complete / block / unblock / archive --- + p_comment = sub.add_parser("comment", help="Append a comment") + p_comment.add_argument("task_id") + p_comment.add_argument("text", nargs="+", help="Comment body") + p_comment.add_argument("--author", default=None, + help="Author name (default: $HERMES_PROFILE or 'user')") + + p_complete = sub.add_parser("complete", help="Mark a task done") + p_complete.add_argument("task_id") + p_complete.add_argument("--result", default=None, help="Result summary") + + p_block = sub.add_parser("block", help="Mark a task blocked (needs input)") + p_block.add_argument("task_id") + p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)") + + p_unblock = sub.add_parser("unblock", help="Return a blocked task to ready") + p_unblock.add_argument("task_id") + + p_archive = sub.add_parser("archive", help="Archive a task (hide from default list)") + p_archive.add_argument("task_id") + + # --- tail --- + p_tail = sub.add_parser("tail", help="Follow a task's event stream") + p_tail.add_argument("task_id") + p_tail.add_argument("--interval", type=float, default=1.0) + + # --- dispatch --- + p_disp = sub.add_parser( + "dispatch", + help="One dispatcher pass: reclaim stale, promote ready, spawn workers", + ) + p_disp.add_argument("--dry-run", action="store_true", + help="Don't actually spawn processes; just print what would happen") + p_disp.add_argument("--max", type=int, default=None, + help="Cap number of spawns this pass") + p_disp.add_argument("--json", action="store_true") + + # --- context --- (for spawned workers) + p_ctx = sub.add_parser( + "context", + help="Print the full context a worker sees for a task " + "(title + body + parent results + comments).", + ) + p_ctx.add_argument("task_id") + + # --- gc --- + sub.add_parser( + "gc", help="Garbage-collect workspaces of archived tasks" + ) + + kanban_parser.set_defaults(_kanban_parser=kanban_parser) + return kanban_parser + + +# --------------------------------------------------------------------------- +# Command dispatch +# --------------------------------------------------------------------------- + +def kanban_command(args: argparse.Namespace) -> int: + """Entry point from ``hermes kanban …`` argparse dispatch. + + Returns a shell-style exit code (0 on success, non-zero on error). + """ + action = getattr(args, "kanban_action", None) + if not action: + # No subaction given: print help via the stored parser reference. + parser = getattr(args, "_kanban_parser", None) + if parser is not None: + parser.print_help() + else: + print( + "usage: hermes kanban [options]\n" + "Run 'hermes kanban --help' for the full list of actions.", + file=sys.stderr, + ) + return 0 + + handlers = { + "init": _cmd_init, + "create": _cmd_create, + "list": _cmd_list, + "ls": _cmd_list, + "show": _cmd_show, + "assign": _cmd_assign, + "link": _cmd_link, + "unlink": _cmd_unlink, + "claim": _cmd_claim, + "comment": _cmd_comment, + "complete": _cmd_complete, + "block": _cmd_block, + "unblock": _cmd_unblock, + "archive": _cmd_archive, + "tail": _cmd_tail, + "dispatch": _cmd_dispatch, + "context": _cmd_context, + "gc": _cmd_gc, + } + handler = handlers.get(action) + if not handler: + print(f"kanban: unknown action {action!r}", file=sys.stderr) + return 2 + try: + return int(handler(args) or 0) + except (ValueError, RuntimeError) as exc: + print(f"kanban: {exc}", file=sys.stderr) + return 1 + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +def _profile_author() -> str: + """Best-effort author name for an interactive CLI call.""" + for env in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"): + v = os.environ.get(env) + if v: + return v + try: + from hermes_cli.profiles import get_active_profile_name + return get_active_profile_name() or "user" + except Exception: + return "user" + + +def _cmd_init(args: argparse.Namespace) -> int: + path = kb.init_db() + print(f"Kanban DB initialized at {path}") + return 0 + + +def _cmd_create(args: argparse.Namespace) -> int: + ws_kind, ws_path = _parse_workspace_flag(args.workspace) + with kb.connect() as conn: + task_id = kb.create_task( + conn, + title=args.title, + body=args.body, + assignee=args.assignee, + created_by=args.created_by or _profile_author(), + workspace_kind=ws_kind, + workspace_path=ws_path, + tenant=args.tenant, + priority=args.priority, + parents=tuple(args.parent or ()), + ) + task = kb.get_task(conn, task_id) + if getattr(args, "json", False): + print(json.dumps(_task_to_dict(task), indent=2, ensure_ascii=False)) + else: + print(f"Created {task_id} ({task.status}, assignee={task.assignee or '-'})") + return 0 + + +def _cmd_list(args: argparse.Namespace) -> int: + assignee = args.assignee + if args.mine and not assignee: + assignee = _profile_author() + with kb.connect() as conn: + # Cheap "mini-dispatch": recompute ready so list output reflects + # dependencies that may have cleared since the last dispatcher tick. + kb.recompute_ready(conn) + tasks = kb.list_tasks( + conn, + assignee=assignee, + status=args.status, + tenant=args.tenant, + include_archived=args.archived, + ) + if getattr(args, "json", False): + print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False)) + return 0 + if not tasks: + print("(no matching tasks)") + return 0 + for t in tasks: + print(_fmt_task_line(t)) + return 0 + + +def _cmd_show(args: argparse.Namespace) -> int: + with kb.connect() as conn: + task = kb.get_task(conn, args.task_id) + if not task: + print(f"no such task: {args.task_id}", file=sys.stderr) + return 1 + comments = kb.list_comments(conn, args.task_id) + events = kb.list_events(conn, args.task_id) + parents = kb.parent_ids(conn, args.task_id) + children = kb.child_ids(conn, args.task_id) + + if getattr(args, "json", False): + payload = { + "task": _task_to_dict(task), + "parents": parents, + "children": children, + "comments": [ + {"author": c.author, "body": c.body, "created_at": c.created_at} + for c in comments + ], + "events": [ + {"kind": e.kind, "payload": e.payload, "created_at": e.created_at} + for e in events + ], + } + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + + print(f"Task {task.id}: {task.title}") + print(f" status: {task.status}") + print(f" assignee: {task.assignee or '-'}") + if task.tenant: + print(f" tenant: {task.tenant}") + print(f" workspace: {task.workspace_kind}" + + (f" @ {task.workspace_path}" if task.workspace_path else "")) + print(f" created: {_fmt_ts(task.created_at)} by {task.created_by or '-'}") + if task.started_at: + print(f" started: {_fmt_ts(task.started_at)}") + if task.completed_at: + print(f" completed: {_fmt_ts(task.completed_at)}") + if parents: + print(f" parents: {', '.join(parents)}") + if children: + print(f" children: {', '.join(children)}") + if task.body: + print() + print("Body:") + print(task.body) + if task.result: + print() + print("Result:") + print(task.result) + if comments: + print() + print(f"Comments ({len(comments)}):") + for c in comments: + print(f" [{_fmt_ts(c.created_at)}] {c.author}: {c.body}") + if events: + print() + print(f"Events ({len(events)}):") + for e in events[-20:]: + pl = f" {e.payload}" if e.payload else "" + print(f" [{_fmt_ts(e.created_at)}] {e.kind}{pl}") + return 0 + + +def _cmd_assign(args: argparse.Namespace) -> int: + profile = None if args.profile.lower() in ("none", "-", "null") else args.profile + with kb.connect() as conn: + ok = kb.assign_task(conn, args.task_id, profile) + if not ok: + print(f"no such task: {args.task_id}", file=sys.stderr) + return 1 + print(f"Assigned {args.task_id} to {profile or '(unassigned)'}") + return 0 + + +def _cmd_link(args: argparse.Namespace) -> int: + with kb.connect() as conn: + kb.link_tasks(conn, args.parent_id, args.child_id) + print(f"Linked {args.parent_id} -> {args.child_id}") + return 0 + + +def _cmd_unlink(args: argparse.Namespace) -> int: + with kb.connect() as conn: + ok = kb.unlink_tasks(conn, args.parent_id, args.child_id) + if not ok: + print(f"No such link: {args.parent_id} -> {args.child_id}", file=sys.stderr) + return 1 + print(f"Unlinked {args.parent_id} -> {args.child_id}") + return 0 + + +def _cmd_claim(args: argparse.Namespace) -> int: + with kb.connect() as conn: + task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl) + if task is None: + # Report why + existing = kb.get_task(conn, args.task_id) + if existing is None: + print(f"no such task: {args.task_id}", file=sys.stderr) + return 1 + print( + f"cannot claim {args.task_id}: status={existing.status} " + f"lock={existing.claim_lock or '(none)'}", + file=sys.stderr, + ) + return 1 + workspace = kb.resolve_workspace(task) + kb.set_workspace_path(conn, task.id, str(workspace)) + print(f"Claimed {task.id}") + print(f"Workspace: {workspace}") + return 0 + + +def _cmd_comment(args: argparse.Namespace) -> int: + body = " ".join(args.text).strip() + author = args.author or _profile_author() + with kb.connect() as conn: + kb.add_comment(conn, args.task_id, author, body) + print(f"Comment added to {args.task_id}") + return 0 + + +def _cmd_complete(args: argparse.Namespace) -> int: + with kb.connect() as conn: + ok = kb.complete_task(conn, args.task_id, result=args.result) + if not ok: + print(f"cannot complete {args.task_id} (unknown id or terminal state)", file=sys.stderr) + return 1 + print(f"Completed {args.task_id}") + return 0 + + +def _cmd_block(args: argparse.Namespace) -> int: + reason = " ".join(args.reason).strip() if args.reason else None + author = _profile_author() + with kb.connect() as conn: + if reason: + kb.add_comment(conn, args.task_id, author, f"BLOCKED: {reason}") + ok = kb.block_task(conn, args.task_id, reason=reason) + if not ok: + print(f"cannot block {args.task_id}", file=sys.stderr) + return 1 + print(f"Blocked {args.task_id}" + (f": {reason}" if reason else "")) + return 0 + + +def _cmd_unblock(args: argparse.Namespace) -> int: + with kb.connect() as conn: + ok = kb.unblock_task(conn, args.task_id) + if not ok: + print(f"cannot unblock {args.task_id} (not blocked?)", file=sys.stderr) + return 1 + print(f"Unblocked {args.task_id}") + return 0 + + +def _cmd_archive(args: argparse.Namespace) -> int: + with kb.connect() as conn: + ok = kb.archive_task(conn, args.task_id) + if not ok: + print(f"cannot archive {args.task_id}", file=sys.stderr) + return 1 + print(f"Archived {args.task_id}") + return 0 + + +def _cmd_tail(args: argparse.Namespace) -> int: + last_id = 0 + print(f"Tailing events for {args.task_id}. Ctrl-C to stop.") + try: + while True: + with kb.connect() as conn: + events = kb.list_events(conn, args.task_id) + for e in events: + if e.id > last_id: + pl = f" {e.payload}" if e.payload else "" + print(f"[{_fmt_ts(e.created_at)}] {e.kind}{pl}", flush=True) + last_id = e.id + time.sleep(max(0.1, args.interval)) + except KeyboardInterrupt: + print("\n(stopped)") + return 0 + + +def _cmd_dispatch(args: argparse.Namespace) -> int: + with kb.connect() as conn: + res = kb.dispatch_once( + conn, + dry_run=args.dry_run, + max_spawn=args.max, + ) + if getattr(args, "json", False): + print(json.dumps({ + "reclaimed": res.reclaimed, + "promoted": res.promoted, + "spawned": [ + {"task_id": tid, "assignee": who, "workspace": ws} + for (tid, who, ws) in res.spawned + ], + "skipped_unassigned": res.skipped_unassigned, + }, indent=2)) + return 0 + print(f"Reclaimed: {res.reclaimed}") + print(f"Promoted: {res.promoted}") + print(f"Spawned: {len(res.spawned)}") + for tid, who, ws in res.spawned: + tag = " (dry)" if args.dry_run else "" + print(f" - {tid} -> {who} @ {ws or '-'}{tag}") + if res.skipped_unassigned: + print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}") + return 0 + + +def _cmd_context(args: argparse.Namespace) -> int: + with kb.connect() as conn: + text = kb.build_worker_context(conn, args.task_id) + print(text) + return 0 + + +def _cmd_gc(args: argparse.Namespace) -> int: + """Remove scratch workspaces of archived tasks. + + Only touches directories under the default scratch root; leaves user + ``dir:`` workspaces and ``worktree`` dirs alone (user owns those). + """ + import shutil + scratch_root = kb.workspaces_root() + removed = 0 + with kb.connect() as conn: + rows = conn.execute( + "SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'" + ).fetchall() + for row in rows: + if row["workspace_kind"] != "scratch": + continue + path = Path(row["workspace_path"] or (scratch_root / row["id"])) + try: + path = path.resolve() + except OSError: + continue + try: + scratch_root.resolve().relative_to(scratch_root.resolve()) + path.relative_to(scratch_root.resolve()) + except ValueError: + # Safety: never delete outside the scratch root. + continue + if path.exists() and path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + removed += 1 + print(f"GC complete: removed {removed} scratch workspace(s)") + return 0 + + +# --------------------------------------------------------------------------- +# Slash-command entry point (used by /kanban from CLI and gateway) +# --------------------------------------------------------------------------- + +def run_slash(rest: str) -> str: + """Execute a ``/kanban …`` string and return captured stdout/stderr. + + ``rest`` is everything after ``/kanban`` (may be empty). Used from + both the interactive CLI (``self._handle_kanban_command``) and the + gateway (``_handle_kanban_command``) so formatting is identical. + """ + import io + import contextlib + + tokens = shlex.split(rest) if rest and rest.strip() else [] + + parser = argparse.ArgumentParser(prog="/kanban", add_help=False) + parser.exit_on_error = False # type: ignore[attr-defined] + sub = parser.add_subparsers(dest="kanban_action") + # Reuse the argparse builder -- call it with a throwaway parent + # subparsers via a wrapping top-level parser. + wrap = argparse.ArgumentParser(prog="/", add_help=False) + wrap.exit_on_error = False # type: ignore[attr-defined] + wrap_sub = wrap.add_subparsers(dest="_top") + build_parser(wrap_sub) + + buf_out = io.StringIO() + buf_err = io.StringIO() + try: + # Prepend the "kanban" token so our top-level subparser routes here. + argv = ["kanban", *tokens] if tokens else ["kanban"] + args = wrap.parse_args(argv) + except SystemExit as exc: + return f"(usage error: {exc})" + except argparse.ArgumentError as exc: + return f"(usage error: {exc})" + + with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err): + try: + kanban_command(args) + except SystemExit: + pass + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + + out = buf_out.getvalue().rstrip() + err = buf_err.getvalue().rstrip() + if err and out: + return f"{out}\n{err}" + return err if err else (out or "(no output)") diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py new file mode 100644 index 0000000000..862f9f3c1d --- /dev/null +++ b/hermes_cli/kanban_db.py @@ -0,0 +1,1067 @@ +"""SQLite-backed Kanban board for multi-profile collaboration. + +The board lives at ``$HERMES_HOME/kanban.db`` (profile-agnostic on purpose: +multiple profiles on the same machine all see the same board, which IS the +coordination primitive). + +Schema is intentionally small: tasks, task_links, task_comments, +task_events. The ``workspace_kind`` field decouples coordination from git +worktrees so that research / ops / digital-twin workloads work alongside +coding workloads. See ``docs/hermes-kanban-v1-spec.pdf`` for the full +design specification. + +Concurrency strategy: WAL mode + ``BEGIN IMMEDIATE`` for write +transactions + compare-and-swap (CAS) updates on ``tasks.status`` and +``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at +most one claimer can win any given task. Losers observe zero affected +rows and move on -- no retry loops, no distributed-lock machinery. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import secrets +import sqlite3 +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Optional + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +VALID_STATUSES = {"todo", "ready", "running", "blocked", "done", "archived"} +VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} + +# A running task's claim is valid for 15 minutes; after that the next +# dispatcher tick reclaims it. Workers that outlive this window should call +# ``heartbeat_claim(task_id)`` periodically. In practice most kanban +# workloads either finish within 15m or set a longer claim explicitly. +DEFAULT_CLAIM_TTL_SECONDS = 15 * 60 + + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +def kanban_db_path() -> Path: + """Return the path to ``kanban.db`` inside the active HERMES_HOME.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "kanban.db" + + +def workspaces_root() -> Path: + """Return the directory under which ``scratch`` workspaces are created.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "kanban" / "workspaces" + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class Task: + """In-memory view of a row from the ``tasks`` table.""" + + id: str + title: str + body: Optional[str] + assignee: Optional[str] + status: str + priority: int + created_by: Optional[str] + created_at: int + started_at: Optional[int] + completed_at: Optional[int] + workspace_kind: str + workspace_path: Optional[str] + claim_lock: Optional[str] + claim_expires: Optional[int] + tenant: Optional[str] + result: Optional[str] = None + + @classmethod + def from_row(cls, row: sqlite3.Row) -> "Task": + return cls( + id=row["id"], + title=row["title"], + body=row["body"], + assignee=row["assignee"], + status=row["status"], + priority=row["priority"], + created_by=row["created_by"], + created_at=row["created_at"], + started_at=row["started_at"], + completed_at=row["completed_at"], + workspace_kind=row["workspace_kind"], + workspace_path=row["workspace_path"], + claim_lock=row["claim_lock"], + claim_expires=row["claim_expires"], + tenant=row["tenant"] if "tenant" in row.keys() else None, + result=row["result"] if "result" in row.keys() else None, + ) + + +@dataclass +class Comment: + id: int + task_id: str + author: str + body: str + created_at: int + + +@dataclass +class Event: + id: int + task_id: str + kind: str + payload: Optional[dict] + created_at: int + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT, + assignee TEXT, + status TEXT NOT NULL, + priority INTEGER DEFAULT 0, + created_by TEXT, + created_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + workspace_kind TEXT NOT NULL DEFAULT 'scratch', + workspace_path TEXT, + claim_lock TEXT, + claim_expires INTEGER, + tenant TEXT, + result TEXT +); + +CREATE TABLE IF NOT EXISTS task_links ( + parent_id TEXT NOT NULL, + child_id TEXT NOT NULL, + PRIMARY KEY (parent_id, child_id) +); + +CREATE TABLE IF NOT EXISTS task_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + author TEXT NOT NULL, + body TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant); +CREATE INDEX IF NOT EXISTS idx_links_child ON task_links(child_id); +CREATE INDEX IF NOT EXISTS idx_links_parent ON task_links(parent_id); +CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, created_at); +CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at); +""" + + +# --------------------------------------------------------------------------- +# Connection helpers +# --------------------------------------------------------------------------- + +def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: + """Open (and initialize if needed) the kanban DB. + + WAL mode is enabled on every connection; it's a no-op after the first + time but keeps the code robust if the DB file is ever re-created. + """ + path = db_path or kanban_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), isolation_level=None, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(db_path: Optional[Path] = None) -> Path: + """Create the schema if it doesn't exist; return the path used.""" + path = db_path or kanban_db_path() + with contextlib.closing(connect(path)) as conn: + conn.executescript(SCHEMA_SQL) + _migrate_add_optional_columns(conn) + return path + + +def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: + """Add columns that were introduced after v1 release to legacy DBs. + + Called by ``init_db`` so opening an old DB is always safe. + """ + cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")} + if "tenant" not in cols: + conn.execute("ALTER TABLE tasks ADD COLUMN tenant TEXT") + if "result" not in cols: + conn.execute("ALTER TABLE tasks ADD COLUMN result TEXT") + + +@contextlib.contextmanager +def write_txn(conn: sqlite3.Connection): + """Context manager for an IMMEDIATE write transaction. + + Use for any multi-statement write (creating a task + link, claiming a + task + recording an event, etc.). A claim CAS inside this context is + atomic -- at most one concurrent writer can succeed. + """ + conn.execute("BEGIN IMMEDIATE") + try: + yield conn + except Exception: + conn.execute("ROLLBACK") + raise + else: + conn.execute("COMMIT") + + +# --------------------------------------------------------------------------- +# ID generation +# --------------------------------------------------------------------------- + +def _new_task_id() -> str: + """Generate a short, URL-safe, human-readable task id. + + Format: ``t_<4 hex chars>``. Space is 65k values; collisions are + rare but handled by a one-shot retry in ``create_task``. + """ + return "t_" + secrets.token_hex(2) + + +def _claimer_id() -> str: + """Return a ``host:pid`` string that identifies this claimer.""" + import socket + try: + host = socket.gethostname() or "unknown" + except Exception: + host = "unknown" + return f"{host}:{os.getpid()}" + + +# --------------------------------------------------------------------------- +# Task creation / mutation +# --------------------------------------------------------------------------- + +def create_task( + conn: sqlite3.Connection, + *, + title: str, + body: Optional[str] = None, + assignee: Optional[str] = None, + created_by: Optional[str] = None, + workspace_kind: str = "scratch", + workspace_path: Optional[str] = None, + tenant: Optional[str] = None, + priority: int = 0, + parents: Iterable[str] = (), +) -> str: + """Create a new task and optionally link it under parent tasks. + + Returns the new task id. Status is ``ready`` when there are no + parents (or all parents already ``done``), otherwise ``todo``. + """ + if not title or not title.strip(): + raise ValueError("title is required") + if workspace_kind not in VALID_WORKSPACE_KINDS: + raise ValueError( + f"workspace_kind must be one of {sorted(VALID_WORKSPACE_KINDS)}, " + f"got {workspace_kind!r}" + ) + parents = tuple(p for p in parents if p) + + now = int(time.time()) + + # Retry once on the extremely unlikely id collision. + for attempt in range(2): + task_id = _new_task_id() + try: + with write_txn(conn): + # Determine initial status from parent status. + initial_status = "ready" + if parents: + missing = _find_missing_parents(conn, parents) + if missing: + raise ValueError(f"unknown parent task(s): {', '.join(missing)}") + # If any parent is not yet done, we're todo. + rows = conn.execute( + "SELECT status FROM tasks WHERE id IN " + "(" + ",".join("?" * len(parents)) + ")", + parents, + ).fetchall() + if any(r["status"] != "done" for r in rows): + initial_status = "todo" + + conn.execute( + """ + INSERT INTO tasks ( + id, title, body, assignee, status, priority, + created_by, created_at, workspace_kind, workspace_path, + tenant + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task_id, + title.strip(), + body, + assignee, + initial_status, + priority, + created_by, + now, + workspace_kind, + workspace_path, + tenant, + ), + ) + for pid in parents: + conn.execute( + "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)", + (pid, task_id), + ) + _append_event( + conn, + task_id, + "created", + { + "assignee": assignee, + "status": initial_status, + "parents": list(parents), + "tenant": tenant, + }, + ) + return task_id + except sqlite3.IntegrityError: + if attempt == 1: + raise + # Retry with a fresh id. + continue + raise RuntimeError("unreachable") + + +def _find_missing_parents(conn: sqlite3.Connection, parents: Iterable[str]) -> list[str]: + parents = list(parents) + if not parents: + return [] + placeholders = ",".join("?" * len(parents)) + rows = conn.execute( + f"SELECT id FROM tasks WHERE id IN ({placeholders})", + parents, + ).fetchall() + present = {r["id"] for r in rows} + return [p for p in parents if p not in present] + + +def get_task(conn: sqlite3.Connection, task_id: str) -> Optional[Task]: + row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + return Task.from_row(row) if row else None + + +def list_tasks( + conn: sqlite3.Connection, + *, + assignee: Optional[str] = None, + status: Optional[str] = None, + tenant: Optional[str] = None, + include_archived: bool = False, + limit: Optional[int] = None, +) -> list[Task]: + query = "SELECT * FROM tasks WHERE 1=1" + params: list[Any] = [] + if assignee is not None: + query += " AND assignee = ?" + params.append(assignee) + if status is not None: + if status not in VALID_STATUSES: + raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}") + query += " AND status = ?" + params.append(status) + if tenant is not None: + query += " AND tenant = ?" + params.append(tenant) + if not include_archived and status != "archived": + query += " AND status != 'archived'" + query += " ORDER BY priority DESC, created_at ASC" + if limit: + query += f" LIMIT {int(limit)}" + rows = conn.execute(query, params).fetchall() + return [Task.from_row(r) for r in rows] + + +def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) -> bool: + """Assign or reassign a task. Returns True on success. + + Refuses to reassign a task that's currently running (claim_lock set). + Reassign after the current run completes if needed. + """ + with write_txn(conn): + row = conn.execute( + "SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + if not row: + return False + if row["claim_lock"] is not None and row["status"] == "running": + raise RuntimeError( + f"cannot reassign {task_id}: currently running (claimed). " + "Wait for completion or reclaim the stale lock first." + ) + conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id)) + _append_event(conn, task_id, "assigned", {"assignee": profile}) + return True + + +# --------------------------------------------------------------------------- +# Links +# --------------------------------------------------------------------------- + +def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: + if parent_id == child_id: + raise ValueError("a task cannot depend on itself") + with write_txn(conn): + missing = _find_missing_parents(conn, [parent_id, child_id]) + if missing: + raise ValueError(f"unknown task(s): {', '.join(missing)}") + if _would_cycle(conn, parent_id, child_id): + raise ValueError( + f"linking {parent_id} -> {child_id} would create a cycle" + ) + conn.execute( + "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)", + (parent_id, child_id), + ) + # If child was ready but parent is not yet done, demote child to todo. + parent_status = conn.execute( + "SELECT status FROM tasks WHERE id = ?", (parent_id,) + ).fetchone()["status"] + if parent_status != "done": + conn.execute( + "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", + (child_id,), + ) + _append_event( + conn, child_id, "linked", + {"parent": parent_id, "child": child_id}, + ) + + +def _would_cycle(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: + """Return True if adding parent->child creates a cycle. + + A cycle exists iff ``parent_id`` is already a descendant of + ``child_id`` via existing parent->child links. We walk downward + from ``child_id`` and check whether we reach ``parent_id``. + """ + seen = set() + stack = [child_id] + while stack: + node = stack.pop() + if node == parent_id: + return True + if node in seen: + continue + seen.add(node) + rows = conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ?", (node,) + ).fetchall() + stack.extend(r["child_id"] for r in rows) + return False + + +def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: + with write_txn(conn): + cur = conn.execute( + "DELETE FROM task_links WHERE parent_id = ? AND child_id = ?", + (parent_id, child_id), + ) + if cur.rowcount: + _append_event( + conn, child_id, "unlinked", + {"parent": parent_id, "child": child_id}, + ) + return cur.rowcount > 0 + + +def parent_ids(conn: sqlite3.Connection, task_id: str) -> list[str]: + rows = conn.execute( + "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", + (task_id,), + ).fetchall() + return [r["parent_id"] for r in rows] + + +def child_ids(conn: sqlite3.Connection, task_id: str) -> list[str]: + rows = conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id", + (task_id,), + ).fetchall() + return [r["child_id"] for r in rows] + + +def parent_results(conn: sqlite3.Connection, task_id: str) -> list[tuple[str, Optional[str]]]: + """Return ``(parent_id, result)`` for every done parent of ``task_id``.""" + rows = conn.execute( + """ + SELECT t.id AS id, t.result AS result + FROM tasks t + JOIN task_links l ON l.parent_id = t.id + WHERE l.child_id = ? AND t.status = 'done' + ORDER BY t.completed_at ASC + """, + (task_id,), + ).fetchall() + return [(r["id"], r["result"]) for r in rows] + + +# --------------------------------------------------------------------------- +# Comments & events +# --------------------------------------------------------------------------- + +def add_comment( + conn: sqlite3.Connection, task_id: str, author: str, body: str +) -> int: + if not body or not body.strip(): + raise ValueError("comment body is required") + if not author or not author.strip(): + raise ValueError("comment author is required") + now = int(time.time()) + with write_txn(conn): + if not conn.execute( + "SELECT 1 FROM tasks WHERE id = ?", (task_id,) + ).fetchone(): + raise ValueError(f"unknown task {task_id}") + cur = conn.execute( + "INSERT INTO task_comments (task_id, author, body, created_at) " + "VALUES (?, ?, ?, ?)", + (task_id, author.strip(), body.strip(), now), + ) + _append_event(conn, task_id, "commented", {"author": author, "len": len(body)}) + return int(cur.lastrowid or 0) + + +def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]: + rows = conn.execute( + "SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at ASC", + (task_id,), + ).fetchall() + return [ + Comment( + id=r["id"], + task_id=r["task_id"], + author=r["author"], + body=r["body"], + created_at=r["created_at"], + ) + for r in rows + ] + + +def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]: + rows = conn.execute( + "SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC", + (task_id,), + ).fetchall() + out = [] + for r in rows: + try: + payload = json.loads(r["payload"]) if r["payload"] else None + except Exception: + payload = None + out.append( + Event( + id=r["id"], + task_id=r["task_id"], + kind=r["kind"], + payload=payload, + created_at=r["created_at"], + ) + ) + return out + + +def _append_event( + conn: sqlite3.Connection, + task_id: str, + kind: str, + payload: Optional[dict] = None, +) -> None: + """Record an event row. Called from within an already-open txn.""" + now = int(time.time()) + pl = json.dumps(payload, ensure_ascii=False) if payload else None + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, ?, ?, ?)", + (task_id, kind, pl, now), + ) + + +# --------------------------------------------------------------------------- +# Dependency resolution (todo -> ready) +# --------------------------------------------------------------------------- + +def recompute_ready(conn: sqlite3.Connection) -> int: + """Promote ``todo`` tasks to ``ready`` when all parents are ``done``. + + Returns the number of tasks promoted. Safe to call inside or outside + an existing transaction; it opens its own IMMEDIATE txn. + """ + promoted = 0 + with write_txn(conn): + todo_rows = conn.execute( + "SELECT id FROM tasks WHERE status = 'todo'" + ).fetchall() + for row in todo_rows: + task_id = row["id"] + parents = conn.execute( + "SELECT t.status FROM tasks t " + "JOIN task_links l ON l.parent_id = t.id " + "WHERE l.child_id = ?", + (task_id,), + ).fetchall() + if all(p["status"] == "done" for p in parents): + conn.execute( + "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'", + (task_id,), + ) + _append_event(conn, task_id, "ready", None) + promoted += 1 + return promoted + + +# --------------------------------------------------------------------------- +# Claim / complete / block +# --------------------------------------------------------------------------- + +def claim_task( + conn: sqlite3.Connection, + task_id: str, + *, + ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, + claimer: Optional[str] = None, +) -> Optional[Task]: + """Atomically transition ``ready -> running``. + + Returns the claimed ``Task`` on success, ``None`` if the task was + already claimed (or is not in ``ready`` status). + """ + now = int(time.time()) + lock = claimer or _claimer_id() + expires = now + int(ttl_seconds) + with write_txn(conn): + cur = conn.execute( + """ + UPDATE tasks + SET status = 'running', + claim_lock = ?, + claim_expires = ?, + started_at = COALESCE(started_at, ?) + WHERE id = ? + AND status = 'ready' + AND claim_lock IS NULL + """, + (lock, expires, now, task_id), + ) + if cur.rowcount != 1: + return None + _append_event(conn, task_id, "claimed", {"lock": lock, "expires": expires}) + return get_task(conn, task_id) + + +def heartbeat_claim( + conn: sqlite3.Connection, + task_id: str, + *, + ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, + claimer: Optional[str] = None, +) -> bool: + """Extend a running claim. Returns True if we still own it. + + Workers that know they'll exceed 15 minutes should call this every + few minutes to keep ownership. + """ + expires = int(time.time()) + int(ttl_seconds) + lock = claimer or _claimer_id() + with write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET claim_expires = ? " + "WHERE id = ? AND status = 'running' AND claim_lock = ?", + (expires, task_id, lock), + ) + return cur.rowcount == 1 + + +def release_stale_claims(conn: sqlite3.Connection) -> int: + """Reset any ``running`` task whose claim has expired. + + Returns the number of stale claims reclaimed. Safe to call often. + """ + now = int(time.time()) + reclaimed = 0 + with write_txn(conn): + stale = conn.execute( + "SELECT id, claim_lock FROM tasks " + "WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?", + (now,), + ).fetchall() + for row in stale: + conn.execute( + "UPDATE tasks SET status = 'ready', claim_lock = NULL, " + "claim_expires = NULL " + "WHERE id = ? AND status = 'running'", + (row["id"],), + ) + _append_event( + conn, row["id"], "reclaimed", + {"stale_lock": row["claim_lock"]}, + ) + reclaimed += 1 + return reclaimed + + +def complete_task( + conn: sqlite3.Connection, + task_id: str, + *, + result: Optional[str] = None, +) -> bool: + """Transition ``running|ready -> done`` and record ``result``. + + Accepts a task that's merely ``ready`` too, so a manual CLI + completion (``hermes kanban complete ``) works without requiring + a claim/start/complete sequence. + """ + now = int(time.time()) + with write_txn(conn): + cur = conn.execute( + """ + UPDATE tasks + SET status = 'done', + result = ?, + completed_at = ?, + claim_lock = NULL, + claim_expires= NULL + WHERE id = ? + AND status IN ('running', 'ready', 'blocked') + """, + (result, now, task_id), + ) + if cur.rowcount != 1: + return False + _append_event( + conn, task_id, "completed", + {"result_len": len(result) if result else 0}, + ) + # Recompute ready status for dependents (separate txn so children see done). + recompute_ready(conn) + return True + + +def block_task( + conn: sqlite3.Connection, + task_id: str, + *, + reason: Optional[str] = None, +) -> bool: + """Transition ``running -> blocked``.""" + with write_txn(conn): + cur = conn.execute( + """ + UPDATE tasks + SET status = 'blocked', + claim_lock = NULL, + claim_expires= NULL + WHERE id = ? + AND status IN ('running', 'ready') + """, + (task_id,), + ) + if cur.rowcount != 1: + return False + _append_event(conn, task_id, "blocked", {"reason": reason}) + return True + + +def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool: + """Transition ``blocked -> ready``.""" + with write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'blocked'", + (task_id,), + ) + if cur.rowcount != 1: + return False + _append_event(conn, task_id, "unblocked", None) + return True + + +def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: + with write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET status = 'archived' WHERE id = ? AND status != 'archived'", + (task_id,), + ) + if cur.rowcount != 1: + return False + _append_event(conn, task_id, "archived", None) + return True + + +# --------------------------------------------------------------------------- +# Workspace resolution +# --------------------------------------------------------------------------- + +def resolve_workspace(task: Task) -> Path: + """Resolve (and create if needed) the workspace for a task. + + - ``scratch``: a fresh dir under ``$HERMES_HOME/kanban/workspaces//``. + - ``dir:``: the path stored in ``workspace_path``. Created if missing. + - ``worktree``: a git worktree at ``workspace_path``. Not created + automatically in v1 -- the kanban-worker skill documents + ``git worktree add`` as a worker-side step. Returns the intended path. + + Persist the resolved path back to the task row via ``set_workspace_path`` + so subsequent runs reuse the same directory. + """ + kind = task.workspace_kind or "scratch" + if kind == "scratch": + if task.workspace_path: + p = Path(task.workspace_path).expanduser() + else: + p = workspaces_root() / task.id + p.mkdir(parents=True, exist_ok=True) + return p + if kind == "dir": + if not task.workspace_path: + raise ValueError( + f"task {task.id} has workspace_kind=dir but no workspace_path" + ) + p = Path(task.workspace_path).expanduser() + p.mkdir(parents=True, exist_ok=True) + return p + if kind == "worktree": + if not task.workspace_path: + # Default: .worktrees// under CWD. Worker skill creates it. + return Path.cwd() / ".worktrees" / task.id + return Path(task.workspace_path).expanduser() + raise ValueError(f"unknown workspace_kind: {kind}") + + +def set_workspace_path( + conn: sqlite3.Connection, task_id: str, path: Path | str +) -> None: + with write_txn(conn): + conn.execute( + "UPDATE tasks SET workspace_path = ? WHERE id = ?", + (str(path), task_id), + ) + + +# --------------------------------------------------------------------------- +# Dispatcher (one-shot pass) +# --------------------------------------------------------------------------- + +@dataclass +class DispatchResult: + """Outcome of a single ``dispatch`` pass.""" + + reclaimed: int = 0 + promoted: int = 0 + spawned: list[tuple[str, str, str]] = field(default_factory=list) + """List of ``(task_id, assignee, workspace_path)`` triples.""" + skipped_unassigned: list[str] = field(default_factory=list) + + +def dispatch_once( + conn: sqlite3.Connection, + *, + spawn_fn=None, + ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, + dry_run: bool = False, + max_spawn: Optional[int] = None, +) -> DispatchResult: + """Run one dispatcher tick. + + Steps: + 1. Reclaim stale running tasks. + 2. Promote todo -> ready where all parents are done. + 3. For each ready task with an assignee, atomically claim and call + ``spawn_fn(task, workspace_path)``. + + ``spawn_fn`` defaults to ``_default_spawn`` which invokes + ``hermes -p chat -q "..."`` in the background. Tests pass + a stub. + """ + result = DispatchResult() + result.reclaimed = release_stale_claims(conn) + result.promoted = recompute_ready(conn) + + ready_rows = conn.execute( + "SELECT id, assignee FROM tasks " + "WHERE status = 'ready' AND claim_lock IS NULL " + "ORDER BY priority DESC, created_at ASC" + ).fetchall() + spawned = 0 + for row in ready_rows: + if max_spawn is not None and spawned >= max_spawn: + break + if not row["assignee"]: + result.skipped_unassigned.append(row["id"]) + continue + if dry_run: + result.spawned.append((row["id"], row["assignee"], "")) + continue + claimed = claim_task(conn, row["id"], ttl_seconds=ttl_seconds) + if claimed is None: + continue + workspace = resolve_workspace(claimed) + # Persist the resolved workspace path so the worker can cd there. + set_workspace_path(conn, claimed.id, str(workspace)) + if spawn_fn is None: + spawn_fn = _default_spawn + try: + spawn_fn(claimed, str(workspace)) + result.spawned.append((claimed.id, claimed.assignee or "", str(workspace))) + spawned += 1 + except Exception as exc: + # Spawn failed: release the claim so the next tick can retry. + with write_txn(conn): + conn.execute( + "UPDATE tasks SET status = 'ready', claim_lock = NULL, " + "claim_expires = NULL WHERE id = ? AND status = 'running'", + (claimed.id,), + ) + _append_event( + conn, claimed.id, "spawn_failed", + {"error": str(exc)[:500]}, + ) + return result + + +def _default_spawn(task: Task, workspace: str) -> None: + """Fire-and-forget ``hermes -p chat -q ...`` subprocess. + + We don't wait for the child; its completion is observed by polling + the board ``complete``/``block`` transitions that the worker writes. + """ + import subprocess + if not task.assignee: + raise ValueError(f"task {task.id} has no assignee") + + prompt = f"work kanban task {task.id}" + env = dict(os.environ) + if task.tenant: + env["HERMES_TENANT"] = task.tenant + env["HERMES_KANBAN_TASK"] = task.id + env["HERMES_KANBAN_WORKSPACE"] = workspace + + cmd = [ + "hermes", + "-p", task.assignee, + "chat", + "-q", prompt, + ] + # Use Popen with DEVNULL stdin so the child doesn't inherit our tty. + # Redirect output to a per-task log under HERMES_HOME/kanban/logs/. + from hermes_constants import get_hermes_home + log_dir = get_hermes_home() / "kanban" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / f"{task.id}.log" + + # Use 'a' so a re-run on unblock appends rather than overwrites. + log_f = open(log_path, "ab") + try: + subprocess.Popen( # noqa: S603 -- argv is a fixed list built above + cmd, + cwd=workspace if os.path.isdir(workspace) else None, + stdin=subprocess.DEVNULL, + stdout=log_f, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + except FileNotFoundError: + log_f.close() + raise RuntimeError( + "`hermes` executable not found on PATH. " + "Install Hermes Agent or activate its venv before running the kanban dispatcher." + ) + # NOTE: we intentionally do NOT close log_f here — we want Popen's + # child process to keep writing after this function returns. The + # handle is kept alive by the child's inheritance. The parent's + # reference goes out of scope and is GC'd, but the OS-level FD stays + # open in the child until the child exits. + + +# --------------------------------------------------------------------------- +# Worker context builder (what a spawned worker sees) +# --------------------------------------------------------------------------- + +def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: + """Return the full text a worker should read to understand its task. + + Order (per design spec §8): + 1. Task title (mandatory). + 2. Task body (optional opening post). + 3. Every comment on the task, chronologically, with authors. + 4. Completion results of every done parent task. + """ + task = get_task(conn, task_id) + if not task: + raise ValueError(f"unknown task {task_id}") + + lines: list[str] = [] + lines.append(f"# Kanban task {task.id}: {task.title}") + lines.append("") + lines.append(f"Assignee: {task.assignee or '(unassigned)'}") + lines.append(f"Status: {task.status}") + if task.tenant: + lines.append(f"Tenant: {task.tenant}") + lines.append(f"Workspace: {task.workspace_kind} @ {task.workspace_path or '(unresolved)'}") + lines.append("") + + if task.body and task.body.strip(): + lines.append("## Body") + lines.append(task.body.strip()) + lines.append("") + + parents = parent_results(conn, task_id) + if parents: + lines.append("## Parent task results") + for pid, result in parents: + lines.append(f"### {pid}") + lines.append((result or "(no result recorded)").strip()) + lines.append("") + + comments = list_comments(conn, task_id) + if comments: + lines.append("## Comment thread") + for c in comments: + ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(c.created_at)) + lines.append(f"**{c.author}** ({ts}):") + lines.append(c.body.strip()) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a53b8d2c5e..19623434d9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4780,6 +4780,13 @@ def cmd_webhook(args): webhook_command(args) +def cmd_kanban(args): + """Multi-profile collaboration board.""" + from hermes_cli.kanban import kanban_command + + return kanban_command(args) + + def cmd_hooks(args): """Shell-hook inspection and management.""" from hermes_cli.hooks import hooks_command @@ -8116,6 +8123,13 @@ For more help on a command: webhook_parser.set_defaults(func=cmd_webhook) + # ========================================================================= + # kanban command — multi-profile collaboration board + # ========================================================================= + from hermes_cli.kanban import build_parser as _build_kanban_parser + kanban_parser = _build_kanban_parser(subparsers) + kanban_parser.set_defaults(func=cmd_kanban) + # ========================================================================= # hooks command — shell-hook inspection and management # ========================================================================= diff --git a/skills/devops/kanban-orchestrator/SKILL.md b/skills/devops/kanban-orchestrator/SKILL.md new file mode 100644 index 0000000000..1b706b9fca --- /dev/null +++ b/skills/devops/kanban-orchestrator/SKILL.md @@ -0,0 +1,140 @@ +--- +name: kanban-orchestrator +description: Decompose user goals into Kanban tasks and delegate them to specialist profiles. Load this skill in an orchestrator profile whose job is routing, NOT execution. Triggers when the user's goal spans multiple profiles, needs parallel work, or should be durable/auditable. +version: 1.0.0 +metadata: + hermes: + tags: [kanban, multi-agent, orchestration, routing] + related_skills: [kanban-worker] +--- + +# Kanban Orchestrator + +**You are a dispatcher, not a worker.** + +Load this skill in an orchestrator profile. An orchestrator's job is to route: read the user's goal, decompose it into well-scoped tasks, assign each to the right specialist profile, link dependencies, and step back. It does NOT do research, writing, coding, or any implementation work itself. + +## When to use the board (vs. just doing the work) + +Create Kanban tasks when any of these are true: + +1. **Multiple specialists are needed.** Research + analysis + writing is three profiles. +2. **The work should survive a crash or restart.** Long-running, recurring, or important. +3. **The user might want to interject.** Human-in-the-loop at any step. +4. **Multiple subtasks can run in parallel.** Fan-out for speed. +5. **Review / iteration is expected.** A reviewer profile loops on drafter output. +6. **The audit trail matters.** Board rows persist in SQLite forever. + +If *none* of those apply — it's a small one-shot reasoning task — use `delegate_task` instead or answer directly. + +## The anti-temptation rules + +These are the rules you MUST NOT break: + +- **Do not execute the work yourself.** Your tools literally don't include terminal/file/code/web for implementation. If you find yourself "just fixing this quickly" — stop. +- **For any concrete task, create a Kanban task and assign it to a specialist.** Every single time. +- **If no specialist fits, ask the user which profile to create.** Do not default to doing it yourself under "close enough." +- **Your job is to decompose, route, and summarize — nothing else.** + +## The standard specialist roster (convention) + +Unless the user's setup has customized profiles, assume these exist. Adjust to whatever profiles the user actually has — ask if unsure. + +| Profile | Does | +|---|---| +| `researcher` | Reads sources, gathers facts, writes findings. Scratch workspace. | +| `analyst` | Synthesizes, ranks, de-dupes. Consumes multiple `researcher` outputs. | +| `writer` | Drafts prose in the user's voice. | +| `reviewer` | Reads output, leaves line-comments, gates approval. | +| `backend-eng` | Writes server-side code. Worktree workspace. | +| `frontend-eng` | Writes client-side code. Worktree workspace. | +| `ops` | Runs scripts, manages services, handles deployments. | + +## Decomposition playbook + +### Step 1 — Understand the goal + +Ask clarifying questions if the goal is ambiguous. Cheap to ask; expensive to spawn the wrong fleet. + +### Step 2 — Sketch the task graph + +Before creating anything, draft the graph out loud (in your response): + +``` +T1 [planner] — meta; this is me + ├── T2 [researcher] — angle A + ├── T3 [researcher] — angle B + ├── T4 [researcher] — angle C + └── T5 [analyst] — synthesize T2,T3,T4 + └── T6 [writer] — brief the user +``` + +### Step 3 — Create tasks, link dependencies + +For each leaf-level task: +```bash +hermes kanban create "angle: cost analysis" \ + --assignee researcher \ + --tenant $HERMES_TENANT +``` + +Repeat per task. Then link them: +```bash +hermes kanban link +``` + +**Do not assign something to yourself.** If the orchestrator shows up as an assignee anywhere, you've made a mistake. + +### Step 4 — Complete your own orchestration task with a summary + +If you were spawned as a task yourself (e.g. `planner` profile was assigned `T1: "investigate foo"`), mark it done with a summary of what you created: + +```bash +hermes kanban complete $HERMES_KANBAN_TASK \ + --result "decomposed into T2-T6: 3 research angles, 1 synthesis, 1 brief" +``` + +### Step 5 — Tell the user what you did + +Reply to the user with: +- The task IDs you created. +- What each is doing. +- Who will work on them. +- Roughly when to expect results (or "I'll message when the last one's done" if the gateway is wired up). + +## Tenant propagation + +If `$HERMES_TENANT` is set, **every task you create must carry the same `--tenant `.** This is how one specialist fleet serves multiple businesses — the tenant flows down the graph, not across. + +## Pattern reference + +The eight collaboration patterns you can instantiate (load the design spec if unsure): + +- **P1 Fan-out** — N siblings, same role, no links between them. +- **P2 Pipeline** — role-specialized chain with linear deps. +- **P3 Voting/quorum** — N siblings + 1 aggregator linked from all N. +- **P4 Journal** — same profile + `--workspace dir:` + recurring cron. +- **P5 Human-in-the-loop** — any worker blocks; user/peer unblocks. +- **P6 @mention** — the user or an agent can write `@profile-name` inline to address a profile; the gateway parses and routes. (UX, not a new primitive.) +- **P7 Thread-scoped workspace** — `/kanban here` pins workspace to current thread dir. +- **P8 Fleet farming** — one profile, N tasks, one workspace per subject (e.g. 50 social accounts). + +## Example run + +User says: *"Analyze whether we should migrate to Postgres. Include a cost analysis and a performance angle."* + +Your decomposition: +1. `hermes kanban create "research: Postgres cost vs current" --assignee researcher` +2. `hermes kanban create "research: Postgres performance vs current" --assignee researcher` +3. `hermes kanban create "synthesize migration recommendation" --assignee analyst` +4. `hermes kanban link ` ; `hermes kanban link ` +5. `hermes kanban create "draft decision memo" --assignee writer --parent ` +6. Report task IDs and expected flow to the user. + +## Pitfalls + +**The "just a quick check" trap.** When the user asks a small question you could probably answer yourself, the temptation is to skip the board. If the question is genuinely one-shot, answer directly. If it's the opening of a workflow ("first, check X; then Y; then Z"), it's board work even if step 1 looks small. + +**Reassignment vs. new task.** If a reviewer blocks with "needs changes," create a NEW task linked from the reviewer's task — don't re-run the same task with a stern look. The new task is assigned to the original implementer profile. + +**Link order matters.** `hermes kanban link ` — parent first. Mixing them up demotes the wrong task to `todo`. diff --git a/skills/devops/kanban-worker/SKILL.md b/skills/devops/kanban-worker/SKILL.md new file mode 100644 index 0000000000..a6e6d54432 --- /dev/null +++ b/skills/devops/kanban-worker/SKILL.md @@ -0,0 +1,120 @@ +--- +name: kanban-worker +description: How a Hermes profile should work a task from the shared Kanban board. Load this skill in any profile that participates in the board (researcher, backend-eng, reviewer, etc.). Triggers on HERMES_KANBAN_TASK env var or a "work kanban task " prompt. +version: 1.0.0 +metadata: + hermes: + tags: [kanban, multi-agent, collaboration, workflow] + related_skills: [kanban-orchestrator] +--- + +# Kanban Worker + +Use this skill when you were spawned to work a task from the shared Hermes Kanban board. Symptoms: + +- Your initial prompt says "work kanban task " — e.g. `work kanban task t_9f2a`. +- Env vars set: `HERMES_KANBAN_TASK`, `HERMES_KANBAN_WORKSPACE`, optionally `HERMES_TENANT`. +- You were started by `hermes kanban dispatch` (cron) or a human ran `hermes -p chat -q "work kanban task "`. + +## Your job + +You are **one run of one specialist profile working one task.** Read the task, do the work inside the workspace, record a result, and exit. Everything else is somebody else's job. + +## Step 1 — Read the full context + +```bash +hermes kanban context $HERMES_KANBAN_TASK +``` + +That command prints: +1. Task title + body. +2. Every comment on the task, in order, with author names. +3. Completion results of every `done` parent task (upstream context). + +**Read all of it.** The comment thread is the inter-agent protocol — past peers, human clarifications, and blocker resolutions all live there. If a reviewer left feedback or the user answered a blocker, it's in the comments. + +## Step 2 — Work inside the workspace + +`cd $HERMES_KANBAN_WORKSPACE` and do the work there. The workspace kind determines what that means: + +| `workspace_kind` | What it is | Your behavior | +|---|---|---| +| `scratch` | Fresh temp dir, yours alone | Read/write freely; it gets GC'd when the task is archived. | +| `dir:` | Shared persistent directory | Treat as a long-lived workspace; other runs will read what you write. | +| `worktree` | Git worktree at the resolved path | You may need to `git worktree add ` if it doesn't exist yet. Commit work here. | + +For `worktree` mode: check if `.git` exists in the workspace path. If not, run: +```bash +git worktree add $HERMES_KANBAN_WORKSPACE +``` +from the main repo's root. Then cd and work normally. + +## Step 3 — If tenancy matters, respect it + +If `$HERMES_TENANT` is set, the task belongs to that tenant namespace. When reading or writing persistent memory, prefix memory entries with the tenant name so context doesn't leak across tenants: + +> Good: memory entry `business-a: Acme is our biggest customer` +> Bad: unprefixed `Acme is our biggest customer` (leaks across tenants) + +## Step 4 — If you hit an ambiguity you can't resolve, BLOCK. Don't guess. + +Any of these should trigger a block: +- User-specific decision you can't infer (IP vs. user-id keys; which tone to use). +- Missing credential or access. +- Source that needs human input (paywalled article, 2FA-gated login). +- Peer profile needs to deliver something first and you can't reach around that. + +```bash +hermes kanban block $HERMES_KANBAN_TASK "need decision: IP vs user_id for rate limit key?" +``` + +`block` also appends your reason as a visible comment. When the user or a peer unblocks and the dispatcher re-spawns you, you'll see the full comment thread including their answer in step 1's context read. + +## Step 5 — Complete with a crisp, machine-readable result + +```bash +hermes kanban complete $HERMES_KANBAN_TASK --result "rate_limiter.py implemented; keys on user_id with IP fallback; tests passing" +``` + +Rules for the `--result` string: +- One to three sentences. It's not a report, it's a handoff note. +- Name concrete artifacts you produced (file paths, URLs, commit SHAs). +- State any caveats a downstream profile needs to know. +- **Do not** include secrets, tokens, or raw PII — results are durable in the board DB forever. + +Downstream tasks (children linked from this task) will see your `--result` verbatim as part of their parent-result context. + +## Step 6 — If follow-up work is obvious, create it. Don't do it. + +You are one task. If you notice something else needs doing, create a linked child task for the right profile instead of scope-creeping: + +```bash +hermes kanban create "add concurrent-request test" \ + --assignee backend-eng \ + --parent $HERMES_KANBAN_TASK +``` + +## Leave comments to talk to peers + +If you want to flag something for a reviewer, a future run, or the user — append a comment: + +```bash +hermes kanban comment $HERMES_KANBAN_TASK "note: skipped the sqlite driver path; needs separate task" +``` + +Comments are the inter-agent protocol. Direct IPC does not exist; the board is the only channel. + +## Do NOT + +- Do not call `delegate_task` as a substitute for creating kanban tasks — `delegate_task` is for short synchronous reasoning subtasks inside your own run, not for cross-agent handoffs. +- Do not modify files outside `$HERMES_KANBAN_WORKSPACE` unless the task body explicitly asks for it. +- Do not assign tasks to yourself during your run (you're already running one; create new tasks for follow-ups only). +- Do not complete a task you didn't actually finish. Block it instead. + +## Pitfalls + +**The task might already be blocked or reassigned when you start.** Between when the dispatcher claimed and when you actually booted up, circumstances can change. Always read the current state at step 1. If `hermes kanban show` reports the task is blocked or reassigned, stop — don't keep running. + +**The workspace may already have artifacts from a previous run.** Especially for `dir:` and `worktree` workspaces, a previous worker may have written files that are incomplete or stale. Read the comment thread — it usually explains why you're running again. + +**Your memory persists but the task result does not carry over automatically.** If you learn something that matters for future runs of this profile in other tasks, write it to your profile memory via the normal mechanism. Comments on the task are for humans and peers; memory is for your future self. diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py new file mode 100644 index 0000000000..f7c84d5df8 --- /dev/null +++ b/tests/hermes_cli/test_kanban_cli.py @@ -0,0 +1,210 @@ +"""Tests for the kanban CLI surface (hermes_cli.kanban).""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +import pytest + +from hermes_cli import kanban as kc +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +# --------------------------------------------------------------------------- +# Workspace flag parsing +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "value,expected", + [ + ("scratch", ("scratch", None)), + ("worktree", ("worktree", None)), + ("dir:/tmp/work", ("dir", "/tmp/work")), + ], +) +def test_parse_workspace_flag_valid(value, expected): + assert kc._parse_workspace_flag(value) == expected + + +def test_parse_workspace_flag_expands_user(): + kind, path = kc._parse_workspace_flag("dir:~/vault") + assert kind == "dir" + assert path.endswith("/vault") + assert not path.startswith("~") + + +@pytest.mark.parametrize("bad", ["cloud", "dir:", "", "worktree:/x"]) +def test_parse_workspace_flag_rejects(bad): + if not bad: + # Empty -> defaults; not an error. + assert kc._parse_workspace_flag(bad) == ("scratch", None) + return + with pytest.raises(argparse.ArgumentTypeError): + kc._parse_workspace_flag(bad) + + +# --------------------------------------------------------------------------- +# run_slash smoke tests (end-to-end via the same entry both CLI and gateway use) +# --------------------------------------------------------------------------- + +def test_run_slash_no_args_shows_usage(kanban_home): + out = kc.run_slash("") + assert "kanban" in out.lower() + assert "create" in out.lower() or "subcommand" in out.lower() or "action" in out.lower() + + +def test_run_slash_create_and_list(kanban_home): + out = kc.run_slash("create 'ship feature' --assignee alice") + assert "Created" in out + out = kc.run_slash("list") + assert "ship feature" in out + assert "alice" in out + + +def test_run_slash_create_with_parent_and_cascade(kanban_home): + # Parent then child via --parent + out1 = kc.run_slash("create 'parent' --assignee alice") + # Extract the "t_xxxx" id from "Created t_xxxx (ready, ...)" + import re + m = re.search(r"(t_[a-f0-9]+)", out1) + assert m + p = m.group(1) + out2 = kc.run_slash(f"create 'child' --assignee bob --parent {p}") + assert "todo" in out2 # child starts as todo + + # Complete parent; list should promote child to ready + kc.run_slash(f"complete {p}") + # Explicit filter: child should now be ready (was todo before complete). + ready_list = kc.run_slash("list --status ready") + assert "child" in ready_list + + +def test_run_slash_show_includes_comments(kanban_home): + out = kc.run_slash("create 'x'") + import re + tid = re.search(r"(t_[a-f0-9]+)", out).group(1) + kc.run_slash(f"comment {tid} 'source is paywalled'") + show = kc.run_slash(f"show {tid}") + assert "source is paywalled" in show + + +def test_run_slash_block_unblock_cycle(kanban_home): + out = kc.run_slash("create 'x' --assignee alice") + import re + tid = re.search(r"(t_[a-f0-9]+)", out).group(1) + # Claim first so block() finds it running + kc.run_slash(f"claim {tid}") + assert "Blocked" in kc.run_slash(f"block {tid} 'need decision'") + assert "Unblocked" in kc.run_slash(f"unblock {tid}") + + +def test_run_slash_json_output(kanban_home): + out = kc.run_slash("create 'jsontask' --assignee alice --json") + payload = json.loads(out) + assert payload["title"] == "jsontask" + assert payload["assignee"] == "alice" + assert payload["status"] == "ready" + + +def test_run_slash_dispatch_dry_run_counts(kanban_home): + kc.run_slash("create 'a' --assignee alice") + kc.run_slash("create 'b' --assignee bob") + out = kc.run_slash("dispatch --dry-run") + assert "Spawned:" in out + + +def test_run_slash_context_output_format(kanban_home): + out = kc.run_slash("create 'tech spec' --assignee alice --body 'write an RFC'") + import re + tid = re.search(r"(t_[a-f0-9]+)", out).group(1) + kc.run_slash(f"comment {tid} 'remember to include performance section'") + ctx = kc.run_slash(f"context {tid}") + assert "tech spec" in ctx + assert "write an RFC" in ctx + assert "performance section" in ctx + + +def test_run_slash_tenant_filter(kanban_home): + kc.run_slash("create 'biz-a task' --tenant biz-a --assignee alice") + kc.run_slash("create 'biz-b task' --tenant biz-b --assignee alice") + a = kc.run_slash("list --tenant biz-a") + b = kc.run_slash("list --tenant biz-b") + assert "biz-a task" in a and "biz-b task" not in a + assert "biz-b task" in b and "biz-a task" not in b + + +def test_run_slash_usage_error_returns_message(kanban_home): + # Missing required argument for create + out = kc.run_slash("create") + assert "usage" in out.lower() or "error" in out.lower() + + +def test_run_slash_assign_reassigns(kanban_home): + out = kc.run_slash("create 'x' --assignee alice") + import re + tid = re.search(r"(t_[a-f0-9]+)", out).group(1) + assert "Assigned" in kc.run_slash(f"assign {tid} bob") + show = kc.run_slash(f"show {tid}") + assert "bob" in show + + +def test_run_slash_link_unlink(kanban_home): + a = kc.run_slash("create 'a'") + b = kc.run_slash("create 'b'") + import re + ta = re.search(r"(t_[a-f0-9]+)", a).group(1) + tb = re.search(r"(t_[a-f0-9]+)", b).group(1) + assert "Linked" in kc.run_slash(f"link {ta} {tb}") + # After link, b is todo + show = kc.run_slash(f"show {tb}") + assert "todo" in show + assert "Unlinked" in kc.run_slash(f"unlink {ta} {tb}") + + +# --------------------------------------------------------------------------- +# Integration with the COMMAND_REGISTRY +# --------------------------------------------------------------------------- + +def test_kanban_is_resolvable(): + from hermes_cli.commands import resolve_command + + cmd = resolve_command("kanban") + assert cmd is not None + assert cmd.name == "kanban" + + +def test_kanban_bypasses_active_session_guard(): + from hermes_cli.commands import should_bypass_active_session + + assert should_bypass_active_session("kanban") + + +def test_kanban_in_autocomplete_table(): + from hermes_cli.commands import COMMANDS, SUBCOMMANDS + + assert "/kanban" in COMMANDS + subs = SUBCOMMANDS.get("/kanban") or [] + assert "create" in subs + assert "dispatch" in subs + + +def test_kanban_not_gateway_only(): + # kanban is available in BOTH CLI and gateway surfaces. + from hermes_cli.commands import COMMAND_REGISTRY + + cmd = next(c for c in COMMAND_REGISTRY if c.name == "kanban") + assert not cmd.cli_only + assert not cmd.gateway_only diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py new file mode 100644 index 0000000000..fcc6396be4 --- /dev/null +++ b/tests/hermes_cli/test_kanban_db.py @@ -0,0 +1,438 @@ +"""Tests for the Kanban DB layer (hermes_cli.kanban_db).""" + +from __future__ import annotations + +import concurrent.futures +import os +import time +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME with an empty kanban DB.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +# --------------------------------------------------------------------------- +# Schema / init +# --------------------------------------------------------------------------- + +def test_init_db_is_idempotent(kanban_home): + # Second call should not error or drop data. + with kb.connect() as conn: + kb.create_task(conn, title="persisted") + kb.init_db() + with kb.connect() as conn: + tasks = kb.list_tasks(conn) + assert len(tasks) == 1 + assert tasks[0].title == "persisted" + + +def test_init_creates_expected_tables(kanban_home): + with kb.connect() as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() + names = {r["name"] for r in rows} + assert {"tasks", "task_links", "task_comments", "task_events"} <= names + + +# --------------------------------------------------------------------------- +# Task creation + status inference +# --------------------------------------------------------------------------- + +def test_create_task_no_parents_is_ready(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="ship it", assignee="alice") + t = kb.get_task(conn, tid) + assert t is not None + assert t.status == "ready" + assert t.assignee == "alice" + assert t.workspace_kind == "scratch" + + +def test_create_task_with_parent_is_todo_until_parent_done(kanban_home): + with kb.connect() as conn: + p = kb.create_task(conn, title="parent") + c = kb.create_task(conn, title="child", parents=[p]) + assert kb.get_task(conn, c).status == "todo" + kb.complete_task(conn, p, result="ok") + assert kb.get_task(conn, c).status == "ready" + + +def test_create_task_unknown_parent_errors(kanban_home): + with kb.connect() as conn, pytest.raises(ValueError, match="unknown parent"): + kb.create_task(conn, title="orphan", parents=["t_ghost"]) + + +def test_workspace_kind_validation(kanban_home): + with kb.connect() as conn, pytest.raises(ValueError, match="workspace_kind"): + kb.create_task(conn, title="bad ws", workspace_kind="cloud") + + +# --------------------------------------------------------------------------- +# Links + dependency resolution +# --------------------------------------------------------------------------- + +def test_link_demotes_ready_child_to_todo_when_parent_not_done(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b") + assert kb.get_task(conn, b).status == "ready" + kb.link_tasks(conn, a, b) + assert kb.get_task(conn, b).status == "todo" + + +def test_link_keeps_ready_child_when_parent_already_done(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + kb.complete_task(conn, a) + b = kb.create_task(conn, title="b") + assert kb.get_task(conn, b).status == "ready" + kb.link_tasks(conn, a, b) + assert kb.get_task(conn, b).status == "ready" + + +def test_link_rejects_self_loop(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + with pytest.raises(ValueError, match="itself"): + kb.link_tasks(conn, a, a) + + +def test_link_detects_cycle(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b", parents=[a]) + c = kb.create_task(conn, title="c", parents=[b]) + with pytest.raises(ValueError, match="cycle"): + kb.link_tasks(conn, c, a) + with pytest.raises(ValueError, match="cycle"): + kb.link_tasks(conn, b, a) + + +def test_recompute_ready_cascades_through_chain(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b", parents=[a]) + c = kb.create_task(conn, title="c", parents=[b]) + assert [kb.get_task(conn, x).status for x in (a, b, c)] == \ + ["ready", "todo", "todo"] + kb.complete_task(conn, a) + assert kb.get_task(conn, b).status == "ready" + kb.complete_task(conn, b) + assert kb.get_task(conn, c).status == "ready" + + +def test_recompute_ready_fan_in_waits_for_all_parents(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b") + c = kb.create_task(conn, title="c", parents=[a, b]) + kb.complete_task(conn, a) + assert kb.get_task(conn, c).status == "todo" + kb.complete_task(conn, b) + assert kb.get_task(conn, c).status == "ready" + + +# --------------------------------------------------------------------------- +# Atomic claim (CAS) +# --------------------------------------------------------------------------- + +def test_claim_once_wins_second_loses(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + first = kb.claim_task(conn, t, claimer="host:1") + assert first is not None and first.status == "running" + second = kb.claim_task(conn, t, claimer="host:2") + assert second is None + + +def test_claim_fails_on_non_ready(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + # Move to todo by introducing an unsatisfied parent. + p = kb.create_task(conn, title="p") + kb.link_tasks(conn, p, t) + assert kb.get_task(conn, t).status == "todo" + assert kb.claim_task(conn, t) is None + + +def test_stale_claim_reclaimed(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + kb.claim_task(conn, t) + # Rewind claim_expires so it looks stale. + conn.execute( + "UPDATE tasks SET claim_expires = ? WHERE id = ?", + (int(time.time()) - 3600, t), + ) + reclaimed = kb.release_stale_claims(conn) + assert reclaimed == 1 + assert kb.get_task(conn, t).status == "ready" + + +def test_heartbeat_extends_claim(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + claimer = "host:hb" + kb.claim_task(conn, t, claimer=claimer, ttl_seconds=60) + original = kb.get_task(conn, t).claim_expires + # Rewind then heartbeat. + conn.execute("UPDATE tasks SET claim_expires = ? WHERE id = ?", (0, t)) + ok = kb.heartbeat_claim(conn, t, claimer=claimer, ttl_seconds=3600) + assert ok + new = kb.get_task(conn, t).claim_expires + assert new > int(time.time()) + 3000 + + +def test_concurrent_claims_only_one_wins(kanban_home): + """Fire N threads claiming the same task; exactly one must win.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="race", assignee="a") + + def attempt(i): + with kb.connect() as c: + return kb.claim_task(c, t, claimer=f"host:{i}") + + n_workers = 8 + with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as ex: + results = list(ex.map(attempt, range(n_workers))) + winners = [r for r in results if r is not None] + assert len(winners) == 1 + assert winners[0].status == "running" + + +# --------------------------------------------------------------------------- +# Complete / block / unblock / archive / assign +# --------------------------------------------------------------------------- + +def test_complete_records_result(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + assert kb.complete_task(conn, t, result="done and dusted") + task = kb.get_task(conn, t) + assert task.status == "done" + assert task.result == "done and dusted" + assert task.completed_at is not None + + +def test_block_then_unblock(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + kb.claim_task(conn, t) + assert kb.block_task(conn, t, reason="need input") + assert kb.get_task(conn, t).status == "blocked" + assert kb.unblock_task(conn, t) + assert kb.get_task(conn, t).status == "ready" + + +def test_assign_refuses_while_running(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + kb.claim_task(conn, t) + with pytest.raises(RuntimeError, match="currently running"): + kb.assign_task(conn, t, "b") + + +def test_assign_reassigns_when_not_running(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + assert kb.assign_task(conn, t, "b") + assert kb.get_task(conn, t).assignee == "b" + + +def test_archive_hides_from_default_list(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + kb.complete_task(conn, t) + assert kb.archive_task(conn, t) + assert len(kb.list_tasks(conn)) == 0 + assert len(kb.list_tasks(conn, include_archived=True)) == 1 + + +# --------------------------------------------------------------------------- +# Comments / events / worker context +# --------------------------------------------------------------------------- + +def test_comments_recorded_in_order(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + kb.add_comment(conn, t, "user", "first") + kb.add_comment(conn, t, "researcher", "second") + comments = kb.list_comments(conn, t) + assert [c.body for c in comments] == ["first", "second"] + assert [c.author for c in comments] == ["user", "researcher"] + + +def test_empty_comment_rejected(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + with pytest.raises(ValueError, match="body is required"): + kb.add_comment(conn, t, "user", "") + + +def test_events_capture_lifecycle(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + kb.claim_task(conn, t) + kb.complete_task(conn, t, result="ok") + events = kb.list_events(conn, t) + kinds = [e.kind for e in events] + assert "created" in kinds + assert "claimed" in kinds + assert "completed" in kinds + + +def test_worker_context_includes_parent_results_and_comments(kanban_home): + with kb.connect() as conn: + p = kb.create_task(conn, title="p") + kb.complete_task(conn, p, result="PARENT_RESULT_MARKER") + c = kb.create_task(conn, title="child", parents=[p]) + kb.add_comment(conn, c, "user", "CLARIFICATION_MARKER") + ctx = kb.build_worker_context(conn, c) + assert "PARENT_RESULT_MARKER" in ctx + assert "CLARIFICATION_MARKER" in ctx + assert c in ctx + assert "child" in ctx + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + +def test_dispatch_dry_run_does_not_claim(kanban_home): + with kb.connect() as conn: + t1 = kb.create_task(conn, title="a", assignee="alice") + t2 = kb.create_task(conn, title="b", assignee="bob") + res = kb.dispatch_once(conn, dry_run=True) + assert {s[0] for s in res.spawned} == {t1, t2} + with kb.connect() as conn: + # Dry run must NOT mutate status. + assert kb.get_task(conn, t1).status == "ready" + assert kb.get_task(conn, t2).status == "ready" + + +def test_dispatch_skips_unassigned(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="floater") + res = kb.dispatch_once(conn, dry_run=True) + assert t in res.skipped_unassigned + assert not res.spawned + + +def test_dispatch_promotes_ready_and_spawns(kanban_home): + spawns = [] + + def fake_spawn(task, workspace): + spawns.append((task.id, task.assignee, workspace)) + + with kb.connect() as conn: + p = kb.create_task(conn, title="p", assignee="alice") + c = kb.create_task(conn, title="c", assignee="bob", parents=[p]) + # Finish parent outside dispatch; promotion happens inside. + kb.complete_task(conn, p) + res = kb.dispatch_once(conn, spawn_fn=fake_spawn) + # Spawned c (a was already done when dispatch was called). + assert len(spawns) == 1 + assert spawns[0][0] == c + assert spawns[0][1] == "bob" + # c is now running + with kb.connect() as conn: + assert kb.get_task(conn, c).status == "running" + + +def test_dispatch_spawn_failure_releases_claim(kanban_home): + def boom(task, workspace): + raise RuntimeError("spawn failed") + + with kb.connect() as conn: + t = kb.create_task(conn, title="boom", assignee="alice") + kb.dispatch_once(conn, spawn_fn=boom) + # Must return to ready so the next tick can retry. + assert kb.get_task(conn, t).status == "ready" + assert kb.get_task(conn, t).claim_lock is None + + +def test_dispatch_reclaims_stale_before_spawning(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="alice") + kb.claim_task(conn, t) + conn.execute( + "UPDATE tasks SET claim_expires = ? WHERE id = ?", + (int(time.time()) - 1, t), + ) + res = kb.dispatch_once(conn, dry_run=True) + assert res.reclaimed == 1 + + +# --------------------------------------------------------------------------- +# Workspace resolution +# --------------------------------------------------------------------------- + +def test_scratch_workspace_created_under_hermes_home(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + assert ws.exists() + assert ws.is_dir() + assert "kanban" in str(ws) + + +def test_dir_workspace_honors_given_path(kanban_home, tmp_path): + target = tmp_path / "my-vault" + with kb.connect() as conn: + t = kb.create_task( + conn, title="biz", workspace_kind="dir", workspace_path=str(target) + ) + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + assert ws == target + assert ws.exists() + + +def test_worktree_workspace_returns_intended_path(kanban_home, tmp_path): + target = str(tmp_path / ".worktrees" / "my-task") + with kb.connect() as conn: + t = kb.create_task( + conn, title="ship", workspace_kind="worktree", workspace_path=target + ) + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + # We do NOT auto-create worktrees; the worker's skill handles that. + assert str(ws) == target + + +# --------------------------------------------------------------------------- +# Tenancy +# --------------------------------------------------------------------------- + +def test_tenant_column_filters_listings(kanban_home): + with kb.connect() as conn: + kb.create_task(conn, title="a1", tenant="biz-a") + kb.create_task(conn, title="b1", tenant="biz-b") + kb.create_task(conn, title="shared") # no tenant + biz_a = kb.list_tasks(conn, tenant="biz-a") + biz_b = kb.list_tasks(conn, tenant="biz-b") + assert [t.title for t in biz_a] == ["a1"] + assert [t.title for t in biz_b] == ["b1"] + + +def test_tenant_propagates_to_events(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="tenant-task", tenant="biz-a") + events = kb.list_events(conn, t) + # The "created" event should have tenant in its payload. + created = [e for e in events if e.kind == "created"] + assert created and created[0].payload.get("tenant") == "biz-a" diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 947994844b..f0d28d958e 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -45,6 +45,7 @@ hermes [global-options] [subcommand/options] | `hermes login` / `logout` | **Deprecated** — use `hermes auth` instead. | | `hermes status` | Show agent, auth, and platform status. | | `hermes cron` | Inspect and tick the cron scheduler. | +| `hermes kanban` | Multi-profile collaboration board (tasks, links, dispatcher). | | `hermes webhook` | Manage dynamic webhook subscriptions for event-driven activation. | | `hermes doctor` | Diagnose config and dependency issues. | | `hermes dump` | Copy-pasteable setup summary for support/debugging. | @@ -272,6 +273,38 @@ hermes cron | `status` | Check whether the cron scheduler is running. | | `tick` | Run due jobs once and exit. | +## `hermes kanban` + +```bash +hermes kanban [options] +``` + +Multi-profile collaboration board. Tasks live in `~/.hermes/kanban.db` (WAL-mode SQLite); every profile reads and writes the same board. A `cron`-driven dispatcher (`hermes kanban dispatch`) atomically claims ready tasks and spawns the assigned profile as its own process with an isolated workspace. + +| Action | Purpose | +|--------|---------| +| `init` | Create `kanban.db` if missing. Idempotent. | +| `create ""` | Create a new task. Flags: `--body`, `--assignee`, `--parent` (repeatable), `--workspace scratch\|worktree\|dir:<path>`, `--tenant`, `--priority`. | +| `list` / `ls` | List tasks. Filter with `--mine`, `--assignee`, `--status`, `--tenant`, `--archived`, `--json`. | +| `show <id>` | Show a task with comments and events. `--json` for machine output. | +| `assign <id> <profile>` | Assign or reassign. Use `none` to unassign. Refused while task is running. | +| `link <parent> <child>` | Add a dependency. Cycle-detected. | +| `unlink <parent> <child>` | Remove a dependency. | +| `claim <id>` | Atomically claim a ready task. Prints resolved workspace path. | +| `comment <id> "<text>"` | Append a comment. Visible to the next worker that runs the task. | +| `complete <id>` | Mark task done. Flag: `--result "<summary>"` (goes into children's parent-result context). | +| `block <id> "<reason>"` | Mark task blocked. Also appends the reason as a comment. | +| `unblock <id>` | Return a blocked task to ready. | +| `archive <id>` | Hide from default list. `gc` will remove scratch workspaces. | +| `tail <id>` | Follow a task's event stream. | +| `dispatch` | One dispatcher pass. Flags: `--dry-run`, `--max N`, `--json`. | +| `context <id>` | Print the full context a worker would see (title + body + parent results + comments). | +| `gc` | Remove scratch workspaces for archived tasks. | + +All actions are also available as a slash command in the gateway (`/kanban …`), with the same argument surface. + +For the full design — comparison with Cline Kanban / Paperclip / NanoClaw / Gemini Enterprise, eight collaboration patterns, four user stories, concurrency correctness proof — see `docs/hermes-kanban-v1-spec.pdf` in the repository or the [Kanban user guide](/docs/user-guide/features/kanban). + ## `hermes webhook` ```bash diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md new file mode 100644 index 0000000000..068c37275b --- /dev/null +++ b/website/docs/user-guide/features/kanban.md @@ -0,0 +1,167 @@ +--- +sidebar_position: 12 +title: "Kanban (Multi-Agent Board)" +description: "Durable SQLite-backed task board for coordinating multiple Hermes profiles" +--- + +# Kanban — Multi-Agent Profile Collaboration + +Hermes Kanban is a durable task board, shared across all your Hermes profiles, that lets multiple named agents collaborate on work without fragile in-process subagent swarms. Every task is a row in `~/.hermes/kanban.db`; every handoff is a row anyone can read and write; every worker is a full OS process with its own identity. + +This is the shape that covers the workloads `delegate_task` can't: + +- **Research triage** — parallel researchers + analyst + writer, human-in-the-loop. +- **Scheduled ops** — recurring daily briefs that build a journal over weeks. +- **Digital twins** — persistent named assistants (`inbox-triage`, `ops-review`) that accumulate memory over time. +- **Engineering pipelines** — decompose → implement in parallel worktrees → review → iterate → PR. +- **Fleet work** — one specialist managing N subjects (50 social accounts, 12 monitored services). + +For the full design rationale, comparative analysis against Cline Kanban / Paperclip / NanoClaw / Google Gemini Enterprise, and the eight canonical collaboration patterns, see `docs/hermes-kanban-v1-spec.pdf` in the repository. + +## Kanban vs. `delegate_task` + +They look similar; they are not the same primitive. + +| | `delegate_task` | Kanban | +|---|---|---| +| Shape | RPC call (fork → join) | Durable message queue + state machine | +| Parent | Blocks until child returns | Fire-and-forget after `create` | +| Child identity | Anonymous subagent | Named profile with persistent memory | +| Resumability | None — failed = failed | Block → unblock → re-run; crash → reclaim | +| Human in the loop | Not supported | Comment / unblock at any point | +| Agents per task | One call = one subagent | N agents over task's life (retry, review, follow-up) | +| Audit trail | Lost on context compression | Durable rows in SQLite forever | +| Coordination | Hierarchical (caller → callee) | Peer — any profile reads/writes any task | + +**One-sentence distinction:** `delegate_task` is a function call; Kanban is a work queue where every handoff is a row any profile (or human) can see and edit. + +**Use `delegate_task` when** the parent agent needs a short reasoning answer before continuing, no humans involved, result goes back into the parent's context. + +**Use Kanban when** work crosses agent boundaries, needs to survive restarts, might need human input, might be picked up by a different role, or needs to be discoverable after the fact. + +They coexist: a kanban worker may call `delegate_task` internally during its run. + +## Core concepts + +- **Task** — a row with title, optional body, one assignee (a profile name), status (`todo | ready | running | blocked | done | archived`), optional tenant namespace. +- **Link** — `task_links` row recording a parent → child dependency. The dispatcher promotes `todo → ready` when all parents are `done`. +- **Comment** — the inter-agent protocol. Agents and humans append comments; when a worker is (re-)spawned it reads the full comment thread as part of its context. +- **Workspace** — the directory a worker operates in. Three kinds: + - `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/`. + - `dir:<path>` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). + - `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. +- **Dispatcher** — `hermes kanban dispatch` runs a one-shot pass: reclaim stale claims, promote ready tasks, atomically claim, spawn assigned profiles. Runs via cron every 60 seconds. +- **Tenant** — optional string namespace. One specialist fleet can serve multiple businesses (`--tenant business-a`) with data isolation by workspace path and memory key prefix. + +## Quick start + +```bash +# 1. Create the board +hermes kanban init + +# 2. Create a task +hermes kanban create "research AI funding landscape" --assignee researcher + +# 3. List what's on the board +hermes kanban list + +# 4. Run a dispatcher pass (dry-run to preview, real to spawn workers) +hermes kanban dispatch --dry-run +hermes kanban dispatch +``` + +To have the board run continuously, schedule the dispatcher: + +```bash +hermes cron add --schedule "*/1 * * * *" \ + --name kanban-dispatch \ + hermes kanban dispatch +``` + +## The worker skill + +Any profile that should be able to work kanban tasks must load the `kanban-worker` skill. It teaches the worker the full lifecycle: + +1. On spawn, read `$HERMES_KANBAN_TASK` env var. +2. Run `hermes kanban context $HERMES_KANBAN_TASK` to read title + body + parent results + full comment thread. +3. `cd $HERMES_KANBAN_WORKSPACE` and do the work there. +4. Complete with `hermes kanban complete <id> --result "<summary>"`, or block with `hermes kanban block <id> "<reason>"` if stuck. + +Load it with: + +```bash +hermes skills install devops/kanban-worker +``` + +## The orchestrator skill + +A **well-behaved orchestrator does not do the work itself.** It decomposes the user's goal into tasks, links them, assigns each to a specialist, and steps back. The `kanban-orchestrator` skill encodes this: anti-temptation rules, a standard specialist roster (`researcher`, `writer`, `analyst`, `backend-eng`, `reviewer`, `ops`), and a decomposition playbook. + +Load it into your orchestrator profile: + +```bash +hermes skills install devops/kanban-orchestrator +``` + +For best results, pair it with a profile whose toolsets are restricted to board operations (`kanban`, `gateway`, `memory`) so the orchestrator literally cannot execute implementation tasks even if it tries. + +## CLI command reference + +``` +hermes kanban init # create kanban.db +hermes kanban create "<title>" [--body ...] [--assignee <profile>] + [--parent <id>]... [--tenant <name>] + [--workspace scratch|worktree|dir:<path>] + [--priority N] [--json] +hermes kanban list [--mine] [--assignee P] [--status S] [--tenant T] [--archived] [--json] +hermes kanban show <id> [--json] +hermes kanban assign <id> <profile> # or 'none' to unassign +hermes kanban link <parent_id> <child_id> +hermes kanban unlink <parent_id> <child_id> +hermes kanban claim <id> [--ttl SECONDS] +hermes kanban comment <id> "<text>" [--author NAME] +hermes kanban complete <id> [--result "..."] +hermes kanban block <id> "<reason>" +hermes kanban unblock <id> +hermes kanban archive <id> +hermes kanban tail <id> # follow event stream +hermes kanban dispatch [--dry-run] [--max N] [--json] +hermes kanban context <id> # what a worker sees +hermes kanban gc # remove scratch dirs of archived tasks +``` + +All commands are also available as a slash command in the gateway (`/kanban list`, `/kanban comment t_abc "need docs"`, etc.). The slash command bypasses the running-agent guard, so you can `/kanban unblock` a stuck worker while the main agent is still chatting. + +## Collaboration patterns + +The board supports these eight patterns without any new primitives: + +| Pattern | Shape | Example | +|---|---|---| +| **P1 Fan-out** | N siblings, same role | "research 5 angles in parallel" | +| **P2 Pipeline** | role chain: scout → editor → writer | daily brief assembly | +| **P3 Voting / quorum** | N siblings + 1 aggregator | 3 researchers → 1 reviewer picks | +| **P4 Long-running journal** | same profile + shared dir + cron | Obsidian vault | +| **P5 Human-in-the-loop** | worker blocks → user comments → unblock | ambiguous decisions | +| **P6 `@mention`** | inline routing from prose | `@reviewer look at this` | +| **P7 Thread-scoped workspace** | `/kanban here` in a thread | per-project gateway threads | +| **P8 Fleet farming** | one profile, N subjects | 50 social accounts | + +For worked examples of each, see `docs/hermes-kanban-v1-spec.pdf`. + +## Multi-tenant usage + +When one specialist fleet serves multiple businesses, tag each task with a tenant: + +```bash +hermes kanban create "monthly report" \ + --assignee researcher \ + --tenant business-a \ + --workspace dir:~/tenants/business-a/data/ +``` + +Workers receive `$HERMES_TENANT` and namespace their memory writes by prefix. The board, the dispatcher, and the profile definitions are all shared; only the data is scoped. + +## Design spec + +The complete design — architecture, concurrency correctness, comparison with other systems, implementation plan, risks, open questions — lives in `docs/hermes-kanban-v1-spec.pdf`. Read that before filing any behavior-change PR. diff --git a/website/sidebars.ts b/website/sidebars.ts index b654291810..0b201baaf2 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -60,6 +60,7 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/features/cron', 'user-guide/features/delegation', + 'user-guide/features/kanban', 'user-guide/features/code-execution', 'user-guide/features/hooks', 'user-guide/features/batch-processing', From 63bf7a29b6a3eb03d37b749decbd6a5d9a70543b Mon Sep 17 00:00:00 2001 From: FocusFlow Dev <focusflow.app.help@gmail.com> Date: Sun, 26 Apr 2026 12:16:32 +0800 Subject: [PATCH 22/41] fix(run_agent): prevent reasoning_content regression in DeepSeek/Kimi tool-call replay PR #15478 fixed missing reasoning_content for DeepSeek API but introduced a regression: tool-call messages with genuine 'reasoning' field were overwritten by empty-string fallback before promotion. Re-order _copy_reasoning_content_for_api steps: 1. Preserve explicit reasoning_content 2. Promote 'reasoning' field (MOVED UP) 3. DeepSeek/Kimi tool-call empty-string fallback (MOVED DOWN) 4. Non-thinking provider cleanup Fixes #15812, relates #15749, #15478. --- run_agent.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/run_agent.py b/run_agent.py index 43c367e460..b567b96545 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7868,7 +7868,17 @@ class AIAgent: api_msg["reasoning_content"] = existing return - # 2. DeepSeek / Kimi thinking mode: tool-call turns that lack + # 2. Healthy session: promote 'reasoning' field to 'reasoning_content' + # for providers that use the internal 'reasoning' key. + # This must happen BEFORE the DeepSeek/Kimi tool-call check so that + # genuine reasoning content is not overwritten by the empty-string + # fallback (#15812 regression in PR #15478). + normalized_reasoning = source_msg.get("reasoning") + if isinstance(normalized_reasoning, str) and normalized_reasoning: + api_msg["reasoning_content"] = normalized_reasoning + return + + # 3. DeepSeek / Kimi thinking mode: tool-call turns that lack # reasoning_content are "poisoned history" — a prior provider (MiniMax, # etc.) left them empty. DeepSeek returns HTTP 400 if reasoning_content # is absent on replay; inject "" to satisfy the provider's requirement @@ -7884,13 +7894,6 @@ class AIAgent: api_msg["reasoning_content"] = "" return - # 3. Healthy session: promote 'reasoning' field to 'reasoning_content' - # for providers that use the internal 'reasoning' key. - normalized_reasoning = source_msg.get("reasoning") - if isinstance(normalized_reasoning, str) and normalized_reasoning: - api_msg["reasoning_content"] = normalized_reasoning - return - # 4. DeepSeek / Kimi thinking mode: all assistant messages need # reasoning_content. Inject "" to satisfy the provider's requirement # when no explicit reasoning content is present. From c5196f1fc2f44c28ed58bf5318d5597d6890f3fe Mon Sep 17 00:00:00 2001 From: Teknium <teknium1@gmail.com> Date: Sun, 26 Apr 2026 08:24:25 -0700 Subject: [PATCH 23/41] chore(release): map focusflow.app.help@gmail.com to yes999zc Salvage PR #15883 cherry-picked FocusFlow Dev's commit; release-notes CI needs the AUTHOR_MAP entry to attribute to the PR author's GitHub login rather than a placeholder. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index d2b50edb8b..d6d9be6d94 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -43,6 +43,7 @@ AUTHOR_MAP = { "teknium1@gmail.com": "teknium1", "teknium@nousresearch.com": "teknium1", "127238744+teknium1@users.noreply.github.com": "teknium1", + "focusflow.app.help@gmail.com": "yes999zc", "343873859@qq.com": "DrStrangerUJN", "uzmpsk.dilekakbas@gmail.com": "dlkakbs", "jefferson@heimdallstrategy.com": "Mind-Dragon", From 9ef1ae138ab349004c9cceec19b32b7bce59d544 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:27:39 -0700 Subject: [PATCH 24/41] fix(docker): don't chown config.yaml after gosu drop (#15865) (#16096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chown/chmod block on config.yaml was added in b24d239ce to keep the file readable by the hermes runtime user, but it sat in the post-gosu 'running as hermes' section of the entrypoint. That meant: 1. Default `docker run <image>` — container starts as root, entrypoint drops to hermes via gosu, then non-root hermes tries to chown the file to hermes. Works by coincidence because the file was just created by root during volume setup and gosu target == target owner. 2. `docker run -u $(id -u):$(id -g) <image>` (#15865) — container starts as the caller's UID. The root block is skipped entirely, we land in the hermes section as some arbitrary non-root user, and chown to 'hermes' fails with 'Operation not permitted'. Script aborts under `set -e`. Move the chown/chmod into the root block (before the gosu exec) where it actually has privilege, and guard with `2>/dev/null || true` so rootless Podman (where even in-container root lacks host-side chown rights) doesn't abort either. Closes #15865 --- docker/entrypoint.sh | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0be1d656c2..299aab97a2 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -41,6 +41,15 @@ if [ "$(id -u)" = "0" ]; then echo "Warning: chown failed (rootless container?) — continuing anyway" fi + # Ensure config.yaml is readable by the hermes runtime user even if it was + # edited on the host after initial ownership setup. Must run here (as root) + # rather than after the gosu drop, otherwise a non-root caller like + # `docker run -u $(id -u):$(id -g)` hits "Operation not permitted" (#15865). + if [ -f "$HERMES_HOME/config.yaml" ]; then + chown hermes:hermes "$HERMES_HOME/config.yaml" 2>/dev/null || true + chmod 640 "$HERMES_HOME/config.yaml" 2>/dev/null || true + fi + echo "Dropping root privileges" exec gosu hermes "$0" "$@" fi @@ -67,13 +76,6 @@ if [ ! -f "$HERMES_HOME/config.yaml" ]; then cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml" fi -# Ensure the main config file remains accessible to the hermes runtime user -# even if it was edited on the host after initial ownership setup. -if [ -f "$HERMES_HOME/config.yaml" ]; then - chown hermes:hermes "$HERMES_HOME/config.yaml" - chmod 640 "$HERMES_HOME/config.yaml" -fi - # SOUL.md if [ ! -f "$HERMES_HOME/SOUL.md" ]; then cp "$INSTALL_DIR/docker/SOUL.md" "$HERMES_HOME/SOUL.md" From 06f81752ed40d5f0e780bee01fbba8947ce5007a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:29:37 -0700 Subject: [PATCH 25/41] Revert "feat(kanban): durable multi-profile collaboration board (#16081)" (#16098) This reverts commit 15937a6b4654a331ce5fd5b1052baad82f9319fd. --- cli.py | 25 +- docs/hermes-kanban-v1-spec.pdf | Bin 213669 -> 0 bytes gateway/run.py | 42 - hermes_cli/commands.py | 5 - hermes_cli/kanban.py | 662 ------------ hermes_cli/kanban_db.py | 1067 -------------------- hermes_cli/main.py | 14 - skills/devops/kanban-orchestrator/SKILL.md | 140 --- skills/devops/kanban-worker/SKILL.md | 120 --- tests/hermes_cli/test_kanban_cli.py | 210 ---- tests/hermes_cli/test_kanban_db.py | 438 -------- website/docs/reference/cli-commands.md | 33 - website/docs/user-guide/features/kanban.md | 167 --- website/sidebars.ts | 1 - 14 files changed, 1 insertion(+), 2923 deletions(-) delete mode 100644 docs/hermes-kanban-v1-spec.pdf delete mode 100644 hermes_cli/kanban.py delete mode 100644 hermes_cli/kanban_db.py delete mode 100644 skills/devops/kanban-orchestrator/SKILL.md delete mode 100644 skills/devops/kanban-worker/SKILL.md delete mode 100644 tests/hermes_cli/test_kanban_cli.py delete mode 100644 tests/hermes_cli/test_kanban_db.py delete mode 100644 website/docs/user-guide/features/kanban.md diff --git a/cli.py b/cli.py index f876a93398..da401e5c18 100644 --- a/cli.py +++ b/cli.py @@ -5818,28 +5818,7 @@ class HermesCLI: print(f"(._.) Unknown cron command: {subcommand}") print(" Available: list, add, edit, pause, resume, run, remove") - - def _handle_kanban_command(self, cmd: str): - """Handle the /kanban command — delegate to the shared kanban CLI. - - The string form passed here is the user's full ``/kanban ...`` - including the leading slash; we strip it and hand the remainder - to ``kanban.run_slash`` which returns a single formatted string. - """ - from hermes_cli.kanban import run_slash - - rest = cmd.strip() - if rest.startswith("/"): - rest = rest.lstrip("/") - if rest.startswith("kanban"): - rest = rest[len("kanban"):].lstrip() - try: - output = run_slash(rest) - except Exception as exc: # pragma: no cover - defensive - output = f"(._.) kanban error: {exc}" - if output: - print(output) - + def _handle_skills_command(self, cmd: str): """Handle /skills slash command — delegates to hermes_cli.skills_hub.""" from hermes_cli.skills_hub import handle_skills_slash @@ -6076,8 +6055,6 @@ class HermesCLI: self.save_conversation() elif canonical == "cron": self._handle_cron_command(cmd_original) - elif canonical == "kanban": - self._handle_kanban_command(cmd_original) elif canonical == "skills": with self._busy_command(self._slow_command_status(cmd_original)): self._handle_skills_command(cmd_original) diff --git a/docs/hermes-kanban-v1-spec.pdf b/docs/hermes-kanban-v1-spec.pdf deleted file mode 100644 index c7899cd12a92e3f14e8c44e7c36f96f174982c41..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 213669 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 diff --git a/gateway/run.py b/gateway/run.py index c85210515f..9926920b81 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3503,14 +3503,6 @@ class GatewayRunner: if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) - # /kanban must bypass the guard. It writes to a profile-agnostic - # DB (kanban.db), not to the running agent's state. In fact - # /kanban unblock is often the only way to free a worker that - # has blocked waiting for a peer — letting that be dispatched - # mid-run is the whole point of the board. - if _cmd_def_inner and _cmd_def_inner.name == "kanban": - return await self._handle_kanban_command(event) - # Session-level toggles that are safe to run mid-agent — # /yolo can unblock a pending approval prompt, /verbose cycles # the tool-progress display mode for the ongoing stream. @@ -3735,9 +3727,6 @@ class GatewayRunner: if canonical == "personality": return await self._handle_personality_command(event) - if canonical == "kanban": - return await self._handle_kanban_command(event) - if canonical == "retry": return await self._handle_retry_command(event) @@ -5165,37 +5154,6 @@ class GatewayRunner: return "\n".join(lines) - - async def _handle_kanban_command(self, event: MessageEvent) -> str: - """Handle /kanban — delegate to the shared kanban CLI. - - Run the potentially-blocking DB work in a thread pool so the - gateway event loop stays responsive. Read operations (list, - show, context, tail) are permitted while an agent is running; - mutations are allowed too because the board is profile-agnostic - and does not touch the running agent's state. - """ - import asyncio - from hermes_cli.kanban import run_slash - - text = (event.text or "").strip() - # Strip the leading "/kanban" (with or without slash), leaving args. - if text.startswith("/"): - text = text.lstrip("/") - if text.startswith("kanban"): - text = text[len("kanban"):].lstrip() - - try: - output = await asyncio.to_thread(run_slash, text) - except Exception as exc: # pragma: no cover - defensive - return f"⚠ kanban error: {exc}" - - # Gateway messages have practical length caps; truncate long - # listings to keep the UX reasonable. - if len(output) > 3800: - output = output[:3800] + "\n… (truncated; use `hermes kanban …` in your terminal for full output)" - return output or "(no output)" - async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" source = event.source diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 2d748d525d..614d783d95 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -140,11 +140,6 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), - CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)", - "Tools & Skills", args_hint="[subcommand]", - subcommands=("list", "ls", "show", "create", "assign", "link", "unlink", - "claim", "comment", "complete", "block", "unblock", "archive", - "tail", "dispatch", "context", "init", "gc")), CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills", cli_only=True), CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py deleted file mode 100644 index 0744a78753..0000000000 --- a/hermes_cli/kanban.py +++ /dev/null @@ -1,662 +0,0 @@ -"""CLI for the Hermes Kanban board — ``hermes kanban …`` subcommand. - -Exposes the full 15-verb surface documented in the design spec -(``docs/hermes-kanban-v1-spec.pdf``). All DB work is delegated to -``kanban_db``. This module adds: - - * Argparse subcommand construction (``build_parser``). - * Argument dispatch (``kanban_command``). - * Output formatting (plain text + ``--json``). - * A short shared helper that parses a single slash-style string - (used by ``/kanban …`` in CLI and gateway) and forwards it to the - argparse surface. -""" - -from __future__ import annotations - -import argparse -import json -import os -import shlex -import sys -import time -from pathlib import Path -from typing import Any, Optional - -from hermes_cli import kanban_db as kb - - -# --------------------------------------------------------------------------- -# Small formatting helpers -# --------------------------------------------------------------------------- - -_STATUS_ICONS = { - "todo": "◻", - "ready": "▶", - "running": "●", - "blocked": "⊘", - "done": "✓", - "archived": "—", -} - - -def _fmt_ts(ts: Optional[int]) -> str: - if not ts: - return "" - return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - - -def _fmt_task_line(t: kb.Task) -> str: - icon = _STATUS_ICONS.get(t.status, "?") - assignee = t.assignee or "(unassigned)" - tenant = f" [{t.tenant}]" if t.tenant else "" - return f"{icon} {t.id} {t.status:8s} {assignee:20s}{tenant} {t.title}" - - -def _task_to_dict(t: kb.Task) -> dict[str, Any]: - return { - "id": t.id, - "title": t.title, - "body": t.body, - "assignee": t.assignee, - "status": t.status, - "priority": t.priority, - "tenant": t.tenant, - "workspace_kind": t.workspace_kind, - "workspace_path": t.workspace_path, - "created_by": t.created_by, - "created_at": t.created_at, - "started_at": t.started_at, - "completed_at": t.completed_at, - "result": t.result, - } - - -def _parse_workspace_flag(value: str) -> tuple[str, Optional[str]]: - """Parse ``--workspace`` into ``(kind, path|None)``. - - Accepts: ``scratch``, ``worktree``, ``dir:<path>``. - """ - if not value: - return ("scratch", None) - v = value.strip() - if v in ("scratch", "worktree"): - return (v, None) - if v.startswith("dir:"): - path = v[len("dir:"):].strip() - if not path: - raise argparse.ArgumentTypeError( - "--workspace dir: requires a path after the colon" - ) - return ("dir", os.path.expanduser(path)) - raise argparse.ArgumentTypeError( - f"unknown --workspace value {value!r}: use scratch, worktree, or dir:<path>" - ) - - -# --------------------------------------------------------------------------- -# Argparse builder -# --------------------------------------------------------------------------- - -def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: - """Attach the ``kanban`` subcommand tree under an existing subparsers. - - Returns the top-level ``kanban`` parser so caller can ``set_defaults``. - """ - kanban_parser = parent_subparsers.add_parser( - "kanban", - help="Multi-profile collaboration board (tasks, links, comments)", - description=( - "Durable SQLite-backed task board shared across Hermes profiles. " - "Tasks are claimed atomically, can depend on other tasks, and " - "are executed by a named profile in an isolated workspace. " - "See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban " - "or docs/hermes-kanban-v1-spec.pdf for the full design." - ), - ) - sub = kanban_parser.add_subparsers(dest="kanban_action") - - # --- init --- - sub.add_parser("init", help="Create kanban.db if missing (idempotent)") - - # --- create --- - p_create = sub.add_parser("create", help="Create a new task") - p_create.add_argument("title", help="Task title") - p_create.add_argument("--body", default=None, help="Optional opening post") - p_create.add_argument("--assignee", default=None, help="Profile name to assign") - p_create.add_argument("--parent", action="append", default=[], - help="Parent task id (repeatable)") - p_create.add_argument("--workspace", default="scratch", - help="scratch | worktree | dir:<path> (default: scratch)") - p_create.add_argument("--tenant", default=None, help="Tenant namespace") - p_create.add_argument("--priority", type=int, default=0, help="Priority tiebreaker") - p_create.add_argument("--created-by", default="user", - help="Author name recorded on the task (default: user)") - p_create.add_argument("--json", action="store_true", help="Emit JSON output") - - # --- list --- - p_list = sub.add_parser("list", aliases=["ls"], help="List tasks") - p_list.add_argument("--mine", action="store_true", - help="Filter by $HERMES_PROFILE as assignee") - p_list.add_argument("--assignee", default=None) - p_list.add_argument("--status", default=None, - choices=sorted(kb.VALID_STATUSES)) - p_list.add_argument("--tenant", default=None) - p_list.add_argument("--archived", action="store_true", - help="Include archived tasks") - p_list.add_argument("--json", action="store_true") - - # --- show --- - p_show = sub.add_parser("show", help="Show a task with comments + events") - p_show.add_argument("task_id") - p_show.add_argument("--json", action="store_true") - - # --- assign --- - p_assign = sub.add_parser("assign", help="Assign or reassign a task") - p_assign.add_argument("task_id") - p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)") - - # --- link / unlink --- - p_link = sub.add_parser("link", help="Add a parent->child dependency") - p_link.add_argument("parent_id") - p_link.add_argument("child_id") - p_unlink = sub.add_parser("unlink", help="Remove a parent->child dependency") - p_unlink.add_argument("parent_id") - p_unlink.add_argument("child_id") - - # --- claim --- - p_claim = sub.add_parser( - "claim", - help="Atomically claim a ready task (prints resolved workspace path)", - ) - p_claim.add_argument("task_id") - p_claim.add_argument("--ttl", type=int, default=kb.DEFAULT_CLAIM_TTL_SECONDS, - help="Claim TTL in seconds (default: 900)") - - # --- comment / complete / block / unblock / archive --- - p_comment = sub.add_parser("comment", help="Append a comment") - p_comment.add_argument("task_id") - p_comment.add_argument("text", nargs="+", help="Comment body") - p_comment.add_argument("--author", default=None, - help="Author name (default: $HERMES_PROFILE or 'user')") - - p_complete = sub.add_parser("complete", help="Mark a task done") - p_complete.add_argument("task_id") - p_complete.add_argument("--result", default=None, help="Result summary") - - p_block = sub.add_parser("block", help="Mark a task blocked (needs input)") - p_block.add_argument("task_id") - p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)") - - p_unblock = sub.add_parser("unblock", help="Return a blocked task to ready") - p_unblock.add_argument("task_id") - - p_archive = sub.add_parser("archive", help="Archive a task (hide from default list)") - p_archive.add_argument("task_id") - - # --- tail --- - p_tail = sub.add_parser("tail", help="Follow a task's event stream") - p_tail.add_argument("task_id") - p_tail.add_argument("--interval", type=float, default=1.0) - - # --- dispatch --- - p_disp = sub.add_parser( - "dispatch", - help="One dispatcher pass: reclaim stale, promote ready, spawn workers", - ) - p_disp.add_argument("--dry-run", action="store_true", - help="Don't actually spawn processes; just print what would happen") - p_disp.add_argument("--max", type=int, default=None, - help="Cap number of spawns this pass") - p_disp.add_argument("--json", action="store_true") - - # --- context --- (for spawned workers) - p_ctx = sub.add_parser( - "context", - help="Print the full context a worker sees for a task " - "(title + body + parent results + comments).", - ) - p_ctx.add_argument("task_id") - - # --- gc --- - sub.add_parser( - "gc", help="Garbage-collect workspaces of archived tasks" - ) - - kanban_parser.set_defaults(_kanban_parser=kanban_parser) - return kanban_parser - - -# --------------------------------------------------------------------------- -# Command dispatch -# --------------------------------------------------------------------------- - -def kanban_command(args: argparse.Namespace) -> int: - """Entry point from ``hermes kanban …`` argparse dispatch. - - Returns a shell-style exit code (0 on success, non-zero on error). - """ - action = getattr(args, "kanban_action", None) - if not action: - # No subaction given: print help via the stored parser reference. - parser = getattr(args, "_kanban_parser", None) - if parser is not None: - parser.print_help() - else: - print( - "usage: hermes kanban <action> [options]\n" - "Run 'hermes kanban --help' for the full list of actions.", - file=sys.stderr, - ) - return 0 - - handlers = { - "init": _cmd_init, - "create": _cmd_create, - "list": _cmd_list, - "ls": _cmd_list, - "show": _cmd_show, - "assign": _cmd_assign, - "link": _cmd_link, - "unlink": _cmd_unlink, - "claim": _cmd_claim, - "comment": _cmd_comment, - "complete": _cmd_complete, - "block": _cmd_block, - "unblock": _cmd_unblock, - "archive": _cmd_archive, - "tail": _cmd_tail, - "dispatch": _cmd_dispatch, - "context": _cmd_context, - "gc": _cmd_gc, - } - handler = handlers.get(action) - if not handler: - print(f"kanban: unknown action {action!r}", file=sys.stderr) - return 2 - try: - return int(handler(args) or 0) - except (ValueError, RuntimeError) as exc: - print(f"kanban: {exc}", file=sys.stderr) - return 1 - - -# --------------------------------------------------------------------------- -# Handlers -# --------------------------------------------------------------------------- - -def _profile_author() -> str: - """Best-effort author name for an interactive CLI call.""" - for env in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"): - v = os.environ.get(env) - if v: - return v - try: - from hermes_cli.profiles import get_active_profile_name - return get_active_profile_name() or "user" - except Exception: - return "user" - - -def _cmd_init(args: argparse.Namespace) -> int: - path = kb.init_db() - print(f"Kanban DB initialized at {path}") - return 0 - - -def _cmd_create(args: argparse.Namespace) -> int: - ws_kind, ws_path = _parse_workspace_flag(args.workspace) - with kb.connect() as conn: - task_id = kb.create_task( - conn, - title=args.title, - body=args.body, - assignee=args.assignee, - created_by=args.created_by or _profile_author(), - workspace_kind=ws_kind, - workspace_path=ws_path, - tenant=args.tenant, - priority=args.priority, - parents=tuple(args.parent or ()), - ) - task = kb.get_task(conn, task_id) - if getattr(args, "json", False): - print(json.dumps(_task_to_dict(task), indent=2, ensure_ascii=False)) - else: - print(f"Created {task_id} ({task.status}, assignee={task.assignee or '-'})") - return 0 - - -def _cmd_list(args: argparse.Namespace) -> int: - assignee = args.assignee - if args.mine and not assignee: - assignee = _profile_author() - with kb.connect() as conn: - # Cheap "mini-dispatch": recompute ready so list output reflects - # dependencies that may have cleared since the last dispatcher tick. - kb.recompute_ready(conn) - tasks = kb.list_tasks( - conn, - assignee=assignee, - status=args.status, - tenant=args.tenant, - include_archived=args.archived, - ) - if getattr(args, "json", False): - print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False)) - return 0 - if not tasks: - print("(no matching tasks)") - return 0 - for t in tasks: - print(_fmt_task_line(t)) - return 0 - - -def _cmd_show(args: argparse.Namespace) -> int: - with kb.connect() as conn: - task = kb.get_task(conn, args.task_id) - if not task: - print(f"no such task: {args.task_id}", file=sys.stderr) - return 1 - comments = kb.list_comments(conn, args.task_id) - events = kb.list_events(conn, args.task_id) - parents = kb.parent_ids(conn, args.task_id) - children = kb.child_ids(conn, args.task_id) - - if getattr(args, "json", False): - payload = { - "task": _task_to_dict(task), - "parents": parents, - "children": children, - "comments": [ - {"author": c.author, "body": c.body, "created_at": c.created_at} - for c in comments - ], - "events": [ - {"kind": e.kind, "payload": e.payload, "created_at": e.created_at} - for e in events - ], - } - print(json.dumps(payload, indent=2, ensure_ascii=False)) - return 0 - - print(f"Task {task.id}: {task.title}") - print(f" status: {task.status}") - print(f" assignee: {task.assignee or '-'}") - if task.tenant: - print(f" tenant: {task.tenant}") - print(f" workspace: {task.workspace_kind}" + - (f" @ {task.workspace_path}" if task.workspace_path else "")) - print(f" created: {_fmt_ts(task.created_at)} by {task.created_by or '-'}") - if task.started_at: - print(f" started: {_fmt_ts(task.started_at)}") - if task.completed_at: - print(f" completed: {_fmt_ts(task.completed_at)}") - if parents: - print(f" parents: {', '.join(parents)}") - if children: - print(f" children: {', '.join(children)}") - if task.body: - print() - print("Body:") - print(task.body) - if task.result: - print() - print("Result:") - print(task.result) - if comments: - print() - print(f"Comments ({len(comments)}):") - for c in comments: - print(f" [{_fmt_ts(c.created_at)}] {c.author}: {c.body}") - if events: - print() - print(f"Events ({len(events)}):") - for e in events[-20:]: - pl = f" {e.payload}" if e.payload else "" - print(f" [{_fmt_ts(e.created_at)}] {e.kind}{pl}") - return 0 - - -def _cmd_assign(args: argparse.Namespace) -> int: - profile = None if args.profile.lower() in ("none", "-", "null") else args.profile - with kb.connect() as conn: - ok = kb.assign_task(conn, args.task_id, profile) - if not ok: - print(f"no such task: {args.task_id}", file=sys.stderr) - return 1 - print(f"Assigned {args.task_id} to {profile or '(unassigned)'}") - return 0 - - -def _cmd_link(args: argparse.Namespace) -> int: - with kb.connect() as conn: - kb.link_tasks(conn, args.parent_id, args.child_id) - print(f"Linked {args.parent_id} -> {args.child_id}") - return 0 - - -def _cmd_unlink(args: argparse.Namespace) -> int: - with kb.connect() as conn: - ok = kb.unlink_tasks(conn, args.parent_id, args.child_id) - if not ok: - print(f"No such link: {args.parent_id} -> {args.child_id}", file=sys.stderr) - return 1 - print(f"Unlinked {args.parent_id} -> {args.child_id}") - return 0 - - -def _cmd_claim(args: argparse.Namespace) -> int: - with kb.connect() as conn: - task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl) - if task is None: - # Report why - existing = kb.get_task(conn, args.task_id) - if existing is None: - print(f"no such task: {args.task_id}", file=sys.stderr) - return 1 - print( - f"cannot claim {args.task_id}: status={existing.status} " - f"lock={existing.claim_lock or '(none)'}", - file=sys.stderr, - ) - return 1 - workspace = kb.resolve_workspace(task) - kb.set_workspace_path(conn, task.id, str(workspace)) - print(f"Claimed {task.id}") - print(f"Workspace: {workspace}") - return 0 - - -def _cmd_comment(args: argparse.Namespace) -> int: - body = " ".join(args.text).strip() - author = args.author or _profile_author() - with kb.connect() as conn: - kb.add_comment(conn, args.task_id, author, body) - print(f"Comment added to {args.task_id}") - return 0 - - -def _cmd_complete(args: argparse.Namespace) -> int: - with kb.connect() as conn: - ok = kb.complete_task(conn, args.task_id, result=args.result) - if not ok: - print(f"cannot complete {args.task_id} (unknown id or terminal state)", file=sys.stderr) - return 1 - print(f"Completed {args.task_id}") - return 0 - - -def _cmd_block(args: argparse.Namespace) -> int: - reason = " ".join(args.reason).strip() if args.reason else None - author = _profile_author() - with kb.connect() as conn: - if reason: - kb.add_comment(conn, args.task_id, author, f"BLOCKED: {reason}") - ok = kb.block_task(conn, args.task_id, reason=reason) - if not ok: - print(f"cannot block {args.task_id}", file=sys.stderr) - return 1 - print(f"Blocked {args.task_id}" + (f": {reason}" if reason else "")) - return 0 - - -def _cmd_unblock(args: argparse.Namespace) -> int: - with kb.connect() as conn: - ok = kb.unblock_task(conn, args.task_id) - if not ok: - print(f"cannot unblock {args.task_id} (not blocked?)", file=sys.stderr) - return 1 - print(f"Unblocked {args.task_id}") - return 0 - - -def _cmd_archive(args: argparse.Namespace) -> int: - with kb.connect() as conn: - ok = kb.archive_task(conn, args.task_id) - if not ok: - print(f"cannot archive {args.task_id}", file=sys.stderr) - return 1 - print(f"Archived {args.task_id}") - return 0 - - -def _cmd_tail(args: argparse.Namespace) -> int: - last_id = 0 - print(f"Tailing events for {args.task_id}. Ctrl-C to stop.") - try: - while True: - with kb.connect() as conn: - events = kb.list_events(conn, args.task_id) - for e in events: - if e.id > last_id: - pl = f" {e.payload}" if e.payload else "" - print(f"[{_fmt_ts(e.created_at)}] {e.kind}{pl}", flush=True) - last_id = e.id - time.sleep(max(0.1, args.interval)) - except KeyboardInterrupt: - print("\n(stopped)") - return 0 - - -def _cmd_dispatch(args: argparse.Namespace) -> int: - with kb.connect() as conn: - res = kb.dispatch_once( - conn, - dry_run=args.dry_run, - max_spawn=args.max, - ) - if getattr(args, "json", False): - print(json.dumps({ - "reclaimed": res.reclaimed, - "promoted": res.promoted, - "spawned": [ - {"task_id": tid, "assignee": who, "workspace": ws} - for (tid, who, ws) in res.spawned - ], - "skipped_unassigned": res.skipped_unassigned, - }, indent=2)) - return 0 - print(f"Reclaimed: {res.reclaimed}") - print(f"Promoted: {res.promoted}") - print(f"Spawned: {len(res.spawned)}") - for tid, who, ws in res.spawned: - tag = " (dry)" if args.dry_run else "" - print(f" - {tid} -> {who} @ {ws or '-'}{tag}") - if res.skipped_unassigned: - print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}") - return 0 - - -def _cmd_context(args: argparse.Namespace) -> int: - with kb.connect() as conn: - text = kb.build_worker_context(conn, args.task_id) - print(text) - return 0 - - -def _cmd_gc(args: argparse.Namespace) -> int: - """Remove scratch workspaces of archived tasks. - - Only touches directories under the default scratch root; leaves user - ``dir:`` workspaces and ``worktree`` dirs alone (user owns those). - """ - import shutil - scratch_root = kb.workspaces_root() - removed = 0 - with kb.connect() as conn: - rows = conn.execute( - "SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'" - ).fetchall() - for row in rows: - if row["workspace_kind"] != "scratch": - continue - path = Path(row["workspace_path"] or (scratch_root / row["id"])) - try: - path = path.resolve() - except OSError: - continue - try: - scratch_root.resolve().relative_to(scratch_root.resolve()) - path.relative_to(scratch_root.resolve()) - except ValueError: - # Safety: never delete outside the scratch root. - continue - if path.exists() and path.is_dir(): - shutil.rmtree(path, ignore_errors=True) - removed += 1 - print(f"GC complete: removed {removed} scratch workspace(s)") - return 0 - - -# --------------------------------------------------------------------------- -# Slash-command entry point (used by /kanban from CLI and gateway) -# --------------------------------------------------------------------------- - -def run_slash(rest: str) -> str: - """Execute a ``/kanban …`` string and return captured stdout/stderr. - - ``rest`` is everything after ``/kanban`` (may be empty). Used from - both the interactive CLI (``self._handle_kanban_command``) and the - gateway (``_handle_kanban_command``) so formatting is identical. - """ - import io - import contextlib - - tokens = shlex.split(rest) if rest and rest.strip() else [] - - parser = argparse.ArgumentParser(prog="/kanban", add_help=False) - parser.exit_on_error = False # type: ignore[attr-defined] - sub = parser.add_subparsers(dest="kanban_action") - # Reuse the argparse builder -- call it with a throwaway parent - # subparsers via a wrapping top-level parser. - wrap = argparse.ArgumentParser(prog="/", add_help=False) - wrap.exit_on_error = False # type: ignore[attr-defined] - wrap_sub = wrap.add_subparsers(dest="_top") - build_parser(wrap_sub) - - buf_out = io.StringIO() - buf_err = io.StringIO() - try: - # Prepend the "kanban" token so our top-level subparser routes here. - argv = ["kanban", *tokens] if tokens else ["kanban"] - args = wrap.parse_args(argv) - except SystemExit as exc: - return f"(usage error: {exc})" - except argparse.ArgumentError as exc: - return f"(usage error: {exc})" - - with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err): - try: - kanban_command(args) - except SystemExit: - pass - except Exception as exc: - print(f"error: {exc}", file=sys.stderr) - - out = buf_out.getvalue().rstrip() - err = buf_err.getvalue().rstrip() - if err and out: - return f"{out}\n{err}" - return err if err else (out or "(no output)") diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py deleted file mode 100644 index 862f9f3c1d..0000000000 --- a/hermes_cli/kanban_db.py +++ /dev/null @@ -1,1067 +0,0 @@ -"""SQLite-backed Kanban board for multi-profile collaboration. - -The board lives at ``$HERMES_HOME/kanban.db`` (profile-agnostic on purpose: -multiple profiles on the same machine all see the same board, which IS the -coordination primitive). - -Schema is intentionally small: tasks, task_links, task_comments, -task_events. The ``workspace_kind`` field decouples coordination from git -worktrees so that research / ops / digital-twin workloads work alongside -coding workloads. See ``docs/hermes-kanban-v1-spec.pdf`` for the full -design specification. - -Concurrency strategy: WAL mode + ``BEGIN IMMEDIATE`` for write -transactions + compare-and-swap (CAS) updates on ``tasks.status`` and -``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at -most one claimer can win any given task. Losers observe zero affected -rows and move on -- no retry loops, no distributed-lock machinery. -""" - -from __future__ import annotations - -import contextlib -import json -import os -import secrets -import sqlite3 -import time -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Iterable, Optional - - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -VALID_STATUSES = {"todo", "ready", "running", "blocked", "done", "archived"} -VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} - -# A running task's claim is valid for 15 minutes; after that the next -# dispatcher tick reclaims it. Workers that outlive this window should call -# ``heartbeat_claim(task_id)`` periodically. In practice most kanban -# workloads either finish within 15m or set a longer claim explicitly. -DEFAULT_CLAIM_TTL_SECONDS = 15 * 60 - - -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- - -def kanban_db_path() -> Path: - """Return the path to ``kanban.db`` inside the active HERMES_HOME.""" - from hermes_constants import get_hermes_home - return get_hermes_home() / "kanban.db" - - -def workspaces_root() -> Path: - """Return the directory under which ``scratch`` workspaces are created.""" - from hermes_constants import get_hermes_home - return get_hermes_home() / "kanban" / "workspaces" - - -# --------------------------------------------------------------------------- -# Data classes -# --------------------------------------------------------------------------- - -@dataclass -class Task: - """In-memory view of a row from the ``tasks`` table.""" - - id: str - title: str - body: Optional[str] - assignee: Optional[str] - status: str - priority: int - created_by: Optional[str] - created_at: int - started_at: Optional[int] - completed_at: Optional[int] - workspace_kind: str - workspace_path: Optional[str] - claim_lock: Optional[str] - claim_expires: Optional[int] - tenant: Optional[str] - result: Optional[str] = None - - @classmethod - def from_row(cls, row: sqlite3.Row) -> "Task": - return cls( - id=row["id"], - title=row["title"], - body=row["body"], - assignee=row["assignee"], - status=row["status"], - priority=row["priority"], - created_by=row["created_by"], - created_at=row["created_at"], - started_at=row["started_at"], - completed_at=row["completed_at"], - workspace_kind=row["workspace_kind"], - workspace_path=row["workspace_path"], - claim_lock=row["claim_lock"], - claim_expires=row["claim_expires"], - tenant=row["tenant"] if "tenant" in row.keys() else None, - result=row["result"] if "result" in row.keys() else None, - ) - - -@dataclass -class Comment: - id: int - task_id: str - author: str - body: str - created_at: int - - -@dataclass -class Event: - id: int - task_id: str - kind: str - payload: Optional[dict] - created_at: int - - -# --------------------------------------------------------------------------- -# Schema -# --------------------------------------------------------------------------- - -SCHEMA_SQL = """ -CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - body TEXT, - assignee TEXT, - status TEXT NOT NULL, - priority INTEGER DEFAULT 0, - created_by TEXT, - created_at INTEGER NOT NULL, - started_at INTEGER, - completed_at INTEGER, - workspace_kind TEXT NOT NULL DEFAULT 'scratch', - workspace_path TEXT, - claim_lock TEXT, - claim_expires INTEGER, - tenant TEXT, - result TEXT -); - -CREATE TABLE IF NOT EXISTS task_links ( - parent_id TEXT NOT NULL, - child_id TEXT NOT NULL, - PRIMARY KEY (parent_id, child_id) -); - -CREATE TABLE IF NOT EXISTS task_comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - author TEXT NOT NULL, - body TEXT NOT NULL, - created_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS task_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - kind TEXT NOT NULL, - payload TEXT, - created_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status); -CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); -CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant); -CREATE INDEX IF NOT EXISTS idx_links_child ON task_links(child_id); -CREATE INDEX IF NOT EXISTS idx_links_parent ON task_links(parent_id); -CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, created_at); -CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at); -""" - - -# --------------------------------------------------------------------------- -# Connection helpers -# --------------------------------------------------------------------------- - -def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: - """Open (and initialize if needed) the kanban DB. - - WAL mode is enabled on every connection; it's a no-op after the first - time but keeps the code robust if the DB file is ever re-created. - """ - path = db_path or kanban_db_path() - path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(path), isolation_level=None, timeout=30) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") - conn.execute("PRAGMA foreign_keys=ON") - return conn - - -def init_db(db_path: Optional[Path] = None) -> Path: - """Create the schema if it doesn't exist; return the path used.""" - path = db_path or kanban_db_path() - with contextlib.closing(connect(path)) as conn: - conn.executescript(SCHEMA_SQL) - _migrate_add_optional_columns(conn) - return path - - -def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: - """Add columns that were introduced after v1 release to legacy DBs. - - Called by ``init_db`` so opening an old DB is always safe. - """ - cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")} - if "tenant" not in cols: - conn.execute("ALTER TABLE tasks ADD COLUMN tenant TEXT") - if "result" not in cols: - conn.execute("ALTER TABLE tasks ADD COLUMN result TEXT") - - -@contextlib.contextmanager -def write_txn(conn: sqlite3.Connection): - """Context manager for an IMMEDIATE write transaction. - - Use for any multi-statement write (creating a task + link, claiming a - task + recording an event, etc.). A claim CAS inside this context is - atomic -- at most one concurrent writer can succeed. - """ - conn.execute("BEGIN IMMEDIATE") - try: - yield conn - except Exception: - conn.execute("ROLLBACK") - raise - else: - conn.execute("COMMIT") - - -# --------------------------------------------------------------------------- -# ID generation -# --------------------------------------------------------------------------- - -def _new_task_id() -> str: - """Generate a short, URL-safe, human-readable task id. - - Format: ``t_<4 hex chars>``. Space is 65k values; collisions are - rare but handled by a one-shot retry in ``create_task``. - """ - return "t_" + secrets.token_hex(2) - - -def _claimer_id() -> str: - """Return a ``host:pid`` string that identifies this claimer.""" - import socket - try: - host = socket.gethostname() or "unknown" - except Exception: - host = "unknown" - return f"{host}:{os.getpid()}" - - -# --------------------------------------------------------------------------- -# Task creation / mutation -# --------------------------------------------------------------------------- - -def create_task( - conn: sqlite3.Connection, - *, - title: str, - body: Optional[str] = None, - assignee: Optional[str] = None, - created_by: Optional[str] = None, - workspace_kind: str = "scratch", - workspace_path: Optional[str] = None, - tenant: Optional[str] = None, - priority: int = 0, - parents: Iterable[str] = (), -) -> str: - """Create a new task and optionally link it under parent tasks. - - Returns the new task id. Status is ``ready`` when there are no - parents (or all parents already ``done``), otherwise ``todo``. - """ - if not title or not title.strip(): - raise ValueError("title is required") - if workspace_kind not in VALID_WORKSPACE_KINDS: - raise ValueError( - f"workspace_kind must be one of {sorted(VALID_WORKSPACE_KINDS)}, " - f"got {workspace_kind!r}" - ) - parents = tuple(p for p in parents if p) - - now = int(time.time()) - - # Retry once on the extremely unlikely id collision. - for attempt in range(2): - task_id = _new_task_id() - try: - with write_txn(conn): - # Determine initial status from parent status. - initial_status = "ready" - if parents: - missing = _find_missing_parents(conn, parents) - if missing: - raise ValueError(f"unknown parent task(s): {', '.join(missing)}") - # If any parent is not yet done, we're todo. - rows = conn.execute( - "SELECT status FROM tasks WHERE id IN " - "(" + ",".join("?" * len(parents)) + ")", - parents, - ).fetchall() - if any(r["status"] != "done" for r in rows): - initial_status = "todo" - - conn.execute( - """ - INSERT INTO tasks ( - id, title, body, assignee, status, priority, - created_by, created_at, workspace_kind, workspace_path, - tenant - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - task_id, - title.strip(), - body, - assignee, - initial_status, - priority, - created_by, - now, - workspace_kind, - workspace_path, - tenant, - ), - ) - for pid in parents: - conn.execute( - "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)", - (pid, task_id), - ) - _append_event( - conn, - task_id, - "created", - { - "assignee": assignee, - "status": initial_status, - "parents": list(parents), - "tenant": tenant, - }, - ) - return task_id - except sqlite3.IntegrityError: - if attempt == 1: - raise - # Retry with a fresh id. - continue - raise RuntimeError("unreachable") - - -def _find_missing_parents(conn: sqlite3.Connection, parents: Iterable[str]) -> list[str]: - parents = list(parents) - if not parents: - return [] - placeholders = ",".join("?" * len(parents)) - rows = conn.execute( - f"SELECT id FROM tasks WHERE id IN ({placeholders})", - parents, - ).fetchall() - present = {r["id"] for r in rows} - return [p for p in parents if p not in present] - - -def get_task(conn: sqlite3.Connection, task_id: str) -> Optional[Task]: - row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() - return Task.from_row(row) if row else None - - -def list_tasks( - conn: sqlite3.Connection, - *, - assignee: Optional[str] = None, - status: Optional[str] = None, - tenant: Optional[str] = None, - include_archived: bool = False, - limit: Optional[int] = None, -) -> list[Task]: - query = "SELECT * FROM tasks WHERE 1=1" - params: list[Any] = [] - if assignee is not None: - query += " AND assignee = ?" - params.append(assignee) - if status is not None: - if status not in VALID_STATUSES: - raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}") - query += " AND status = ?" - params.append(status) - if tenant is not None: - query += " AND tenant = ?" - params.append(tenant) - if not include_archived and status != "archived": - query += " AND status != 'archived'" - query += " ORDER BY priority DESC, created_at ASC" - if limit: - query += f" LIMIT {int(limit)}" - rows = conn.execute(query, params).fetchall() - return [Task.from_row(r) for r in rows] - - -def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) -> bool: - """Assign or reassign a task. Returns True on success. - - Refuses to reassign a task that's currently running (claim_lock set). - Reassign after the current run completes if needed. - """ - with write_txn(conn): - row = conn.execute( - "SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,) - ).fetchone() - if not row: - return False - if row["claim_lock"] is not None and row["status"] == "running": - raise RuntimeError( - f"cannot reassign {task_id}: currently running (claimed). " - "Wait for completion or reclaim the stale lock first." - ) - conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id)) - _append_event(conn, task_id, "assigned", {"assignee": profile}) - return True - - -# --------------------------------------------------------------------------- -# Links -# --------------------------------------------------------------------------- - -def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: - if parent_id == child_id: - raise ValueError("a task cannot depend on itself") - with write_txn(conn): - missing = _find_missing_parents(conn, [parent_id, child_id]) - if missing: - raise ValueError(f"unknown task(s): {', '.join(missing)}") - if _would_cycle(conn, parent_id, child_id): - raise ValueError( - f"linking {parent_id} -> {child_id} would create a cycle" - ) - conn.execute( - "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)", - (parent_id, child_id), - ) - # If child was ready but parent is not yet done, demote child to todo. - parent_status = conn.execute( - "SELECT status FROM tasks WHERE id = ?", (parent_id,) - ).fetchone()["status"] - if parent_status != "done": - conn.execute( - "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", - (child_id,), - ) - _append_event( - conn, child_id, "linked", - {"parent": parent_id, "child": child_id}, - ) - - -def _would_cycle(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: - """Return True if adding parent->child creates a cycle. - - A cycle exists iff ``parent_id`` is already a descendant of - ``child_id`` via existing parent->child links. We walk downward - from ``child_id`` and check whether we reach ``parent_id``. - """ - seen = set() - stack = [child_id] - while stack: - node = stack.pop() - if node == parent_id: - return True - if node in seen: - continue - seen.add(node) - rows = conn.execute( - "SELECT child_id FROM task_links WHERE parent_id = ?", (node,) - ).fetchall() - stack.extend(r["child_id"] for r in rows) - return False - - -def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: - with write_txn(conn): - cur = conn.execute( - "DELETE FROM task_links WHERE parent_id = ? AND child_id = ?", - (parent_id, child_id), - ) - if cur.rowcount: - _append_event( - conn, child_id, "unlinked", - {"parent": parent_id, "child": child_id}, - ) - return cur.rowcount > 0 - - -def parent_ids(conn: sqlite3.Connection, task_id: str) -> list[str]: - rows = conn.execute( - "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", - (task_id,), - ).fetchall() - return [r["parent_id"] for r in rows] - - -def child_ids(conn: sqlite3.Connection, task_id: str) -> list[str]: - rows = conn.execute( - "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id", - (task_id,), - ).fetchall() - return [r["child_id"] for r in rows] - - -def parent_results(conn: sqlite3.Connection, task_id: str) -> list[tuple[str, Optional[str]]]: - """Return ``(parent_id, result)`` for every done parent of ``task_id``.""" - rows = conn.execute( - """ - SELECT t.id AS id, t.result AS result - FROM tasks t - JOIN task_links l ON l.parent_id = t.id - WHERE l.child_id = ? AND t.status = 'done' - ORDER BY t.completed_at ASC - """, - (task_id,), - ).fetchall() - return [(r["id"], r["result"]) for r in rows] - - -# --------------------------------------------------------------------------- -# Comments & events -# --------------------------------------------------------------------------- - -def add_comment( - conn: sqlite3.Connection, task_id: str, author: str, body: str -) -> int: - if not body or not body.strip(): - raise ValueError("comment body is required") - if not author or not author.strip(): - raise ValueError("comment author is required") - now = int(time.time()) - with write_txn(conn): - if not conn.execute( - "SELECT 1 FROM tasks WHERE id = ?", (task_id,) - ).fetchone(): - raise ValueError(f"unknown task {task_id}") - cur = conn.execute( - "INSERT INTO task_comments (task_id, author, body, created_at) " - "VALUES (?, ?, ?, ?)", - (task_id, author.strip(), body.strip(), now), - ) - _append_event(conn, task_id, "commented", {"author": author, "len": len(body)}) - return int(cur.lastrowid or 0) - - -def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]: - rows = conn.execute( - "SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at ASC", - (task_id,), - ).fetchall() - return [ - Comment( - id=r["id"], - task_id=r["task_id"], - author=r["author"], - body=r["body"], - created_at=r["created_at"], - ) - for r in rows - ] - - -def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]: - rows = conn.execute( - "SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC", - (task_id,), - ).fetchall() - out = [] - for r in rows: - try: - payload = json.loads(r["payload"]) if r["payload"] else None - except Exception: - payload = None - out.append( - Event( - id=r["id"], - task_id=r["task_id"], - kind=r["kind"], - payload=payload, - created_at=r["created_at"], - ) - ) - return out - - -def _append_event( - conn: sqlite3.Connection, - task_id: str, - kind: str, - payload: Optional[dict] = None, -) -> None: - """Record an event row. Called from within an already-open txn.""" - now = int(time.time()) - pl = json.dumps(payload, ensure_ascii=False) if payload else None - conn.execute( - "INSERT INTO task_events (task_id, kind, payload, created_at) " - "VALUES (?, ?, ?, ?)", - (task_id, kind, pl, now), - ) - - -# --------------------------------------------------------------------------- -# Dependency resolution (todo -> ready) -# --------------------------------------------------------------------------- - -def recompute_ready(conn: sqlite3.Connection) -> int: - """Promote ``todo`` tasks to ``ready`` when all parents are ``done``. - - Returns the number of tasks promoted. Safe to call inside or outside - an existing transaction; it opens its own IMMEDIATE txn. - """ - promoted = 0 - with write_txn(conn): - todo_rows = conn.execute( - "SELECT id FROM tasks WHERE status = 'todo'" - ).fetchall() - for row in todo_rows: - task_id = row["id"] - parents = conn.execute( - "SELECT t.status FROM tasks t " - "JOIN task_links l ON l.parent_id = t.id " - "WHERE l.child_id = ?", - (task_id,), - ).fetchall() - if all(p["status"] == "done" for p in parents): - conn.execute( - "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'", - (task_id,), - ) - _append_event(conn, task_id, "ready", None) - promoted += 1 - return promoted - - -# --------------------------------------------------------------------------- -# Claim / complete / block -# --------------------------------------------------------------------------- - -def claim_task( - conn: sqlite3.Connection, - task_id: str, - *, - ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, - claimer: Optional[str] = None, -) -> Optional[Task]: - """Atomically transition ``ready -> running``. - - Returns the claimed ``Task`` on success, ``None`` if the task was - already claimed (or is not in ``ready`` status). - """ - now = int(time.time()) - lock = claimer or _claimer_id() - expires = now + int(ttl_seconds) - with write_txn(conn): - cur = conn.execute( - """ - UPDATE tasks - SET status = 'running', - claim_lock = ?, - claim_expires = ?, - started_at = COALESCE(started_at, ?) - WHERE id = ? - AND status = 'ready' - AND claim_lock IS NULL - """, - (lock, expires, now, task_id), - ) - if cur.rowcount != 1: - return None - _append_event(conn, task_id, "claimed", {"lock": lock, "expires": expires}) - return get_task(conn, task_id) - - -def heartbeat_claim( - conn: sqlite3.Connection, - task_id: str, - *, - ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, - claimer: Optional[str] = None, -) -> bool: - """Extend a running claim. Returns True if we still own it. - - Workers that know they'll exceed 15 minutes should call this every - few minutes to keep ownership. - """ - expires = int(time.time()) + int(ttl_seconds) - lock = claimer or _claimer_id() - with write_txn(conn): - cur = conn.execute( - "UPDATE tasks SET claim_expires = ? " - "WHERE id = ? AND status = 'running' AND claim_lock = ?", - (expires, task_id, lock), - ) - return cur.rowcount == 1 - - -def release_stale_claims(conn: sqlite3.Connection) -> int: - """Reset any ``running`` task whose claim has expired. - - Returns the number of stale claims reclaimed. Safe to call often. - """ - now = int(time.time()) - reclaimed = 0 - with write_txn(conn): - stale = conn.execute( - "SELECT id, claim_lock FROM tasks " - "WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?", - (now,), - ).fetchall() - for row in stale: - conn.execute( - "UPDATE tasks SET status = 'ready', claim_lock = NULL, " - "claim_expires = NULL " - "WHERE id = ? AND status = 'running'", - (row["id"],), - ) - _append_event( - conn, row["id"], "reclaimed", - {"stale_lock": row["claim_lock"]}, - ) - reclaimed += 1 - return reclaimed - - -def complete_task( - conn: sqlite3.Connection, - task_id: str, - *, - result: Optional[str] = None, -) -> bool: - """Transition ``running|ready -> done`` and record ``result``. - - Accepts a task that's merely ``ready`` too, so a manual CLI - completion (``hermes kanban complete <id>``) works without requiring - a claim/start/complete sequence. - """ - now = int(time.time()) - with write_txn(conn): - cur = conn.execute( - """ - UPDATE tasks - SET status = 'done', - result = ?, - completed_at = ?, - claim_lock = NULL, - claim_expires= NULL - WHERE id = ? - AND status IN ('running', 'ready', 'blocked') - """, - (result, now, task_id), - ) - if cur.rowcount != 1: - return False - _append_event( - conn, task_id, "completed", - {"result_len": len(result) if result else 0}, - ) - # Recompute ready status for dependents (separate txn so children see done). - recompute_ready(conn) - return True - - -def block_task( - conn: sqlite3.Connection, - task_id: str, - *, - reason: Optional[str] = None, -) -> bool: - """Transition ``running -> blocked``.""" - with write_txn(conn): - cur = conn.execute( - """ - UPDATE tasks - SET status = 'blocked', - claim_lock = NULL, - claim_expires= NULL - WHERE id = ? - AND status IN ('running', 'ready') - """, - (task_id,), - ) - if cur.rowcount != 1: - return False - _append_event(conn, task_id, "blocked", {"reason": reason}) - return True - - -def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool: - """Transition ``blocked -> ready``.""" - with write_txn(conn): - cur = conn.execute( - "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'blocked'", - (task_id,), - ) - if cur.rowcount != 1: - return False - _append_event(conn, task_id, "unblocked", None) - return True - - -def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: - with write_txn(conn): - cur = conn.execute( - "UPDATE tasks SET status = 'archived' WHERE id = ? AND status != 'archived'", - (task_id,), - ) - if cur.rowcount != 1: - return False - _append_event(conn, task_id, "archived", None) - return True - - -# --------------------------------------------------------------------------- -# Workspace resolution -# --------------------------------------------------------------------------- - -def resolve_workspace(task: Task) -> Path: - """Resolve (and create if needed) the workspace for a task. - - - ``scratch``: a fresh dir under ``$HERMES_HOME/kanban/workspaces/<id>/``. - - ``dir:<path>``: the path stored in ``workspace_path``. Created if missing. - - ``worktree``: a git worktree at ``workspace_path``. Not created - automatically in v1 -- the kanban-worker skill documents - ``git worktree add`` as a worker-side step. Returns the intended path. - - Persist the resolved path back to the task row via ``set_workspace_path`` - so subsequent runs reuse the same directory. - """ - kind = task.workspace_kind or "scratch" - if kind == "scratch": - if task.workspace_path: - p = Path(task.workspace_path).expanduser() - else: - p = workspaces_root() / task.id - p.mkdir(parents=True, exist_ok=True) - return p - if kind == "dir": - if not task.workspace_path: - raise ValueError( - f"task {task.id} has workspace_kind=dir but no workspace_path" - ) - p = Path(task.workspace_path).expanduser() - p.mkdir(parents=True, exist_ok=True) - return p - if kind == "worktree": - if not task.workspace_path: - # Default: .worktrees/<id>/ under CWD. Worker skill creates it. - return Path.cwd() / ".worktrees" / task.id - return Path(task.workspace_path).expanduser() - raise ValueError(f"unknown workspace_kind: {kind}") - - -def set_workspace_path( - conn: sqlite3.Connection, task_id: str, path: Path | str -) -> None: - with write_txn(conn): - conn.execute( - "UPDATE tasks SET workspace_path = ? WHERE id = ?", - (str(path), task_id), - ) - - -# --------------------------------------------------------------------------- -# Dispatcher (one-shot pass) -# --------------------------------------------------------------------------- - -@dataclass -class DispatchResult: - """Outcome of a single ``dispatch`` pass.""" - - reclaimed: int = 0 - promoted: int = 0 - spawned: list[tuple[str, str, str]] = field(default_factory=list) - """List of ``(task_id, assignee, workspace_path)`` triples.""" - skipped_unassigned: list[str] = field(default_factory=list) - - -def dispatch_once( - conn: sqlite3.Connection, - *, - spawn_fn=None, - ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, - dry_run: bool = False, - max_spawn: Optional[int] = None, -) -> DispatchResult: - """Run one dispatcher tick. - - Steps: - 1. Reclaim stale running tasks. - 2. Promote todo -> ready where all parents are done. - 3. For each ready task with an assignee, atomically claim and call - ``spawn_fn(task, workspace_path)``. - - ``spawn_fn`` defaults to ``_default_spawn`` which invokes - ``hermes -p <profile> chat -q "..."`` in the background. Tests pass - a stub. - """ - result = DispatchResult() - result.reclaimed = release_stale_claims(conn) - result.promoted = recompute_ready(conn) - - ready_rows = conn.execute( - "SELECT id, assignee FROM tasks " - "WHERE status = 'ready' AND claim_lock IS NULL " - "ORDER BY priority DESC, created_at ASC" - ).fetchall() - spawned = 0 - for row in ready_rows: - if max_spawn is not None and spawned >= max_spawn: - break - if not row["assignee"]: - result.skipped_unassigned.append(row["id"]) - continue - if dry_run: - result.spawned.append((row["id"], row["assignee"], "")) - continue - claimed = claim_task(conn, row["id"], ttl_seconds=ttl_seconds) - if claimed is None: - continue - workspace = resolve_workspace(claimed) - # Persist the resolved workspace path so the worker can cd there. - set_workspace_path(conn, claimed.id, str(workspace)) - if spawn_fn is None: - spawn_fn = _default_spawn - try: - spawn_fn(claimed, str(workspace)) - result.spawned.append((claimed.id, claimed.assignee or "", str(workspace))) - spawned += 1 - except Exception as exc: - # Spawn failed: release the claim so the next tick can retry. - with write_txn(conn): - conn.execute( - "UPDATE tasks SET status = 'ready', claim_lock = NULL, " - "claim_expires = NULL WHERE id = ? AND status = 'running'", - (claimed.id,), - ) - _append_event( - conn, claimed.id, "spawn_failed", - {"error": str(exc)[:500]}, - ) - return result - - -def _default_spawn(task: Task, workspace: str) -> None: - """Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess. - - We don't wait for the child; its completion is observed by polling - the board ``complete``/``block`` transitions that the worker writes. - """ - import subprocess - if not task.assignee: - raise ValueError(f"task {task.id} has no assignee") - - prompt = f"work kanban task {task.id}" - env = dict(os.environ) - if task.tenant: - env["HERMES_TENANT"] = task.tenant - env["HERMES_KANBAN_TASK"] = task.id - env["HERMES_KANBAN_WORKSPACE"] = workspace - - cmd = [ - "hermes", - "-p", task.assignee, - "chat", - "-q", prompt, - ] - # Use Popen with DEVNULL stdin so the child doesn't inherit our tty. - # Redirect output to a per-task log under HERMES_HOME/kanban/logs/. - from hermes_constants import get_hermes_home - log_dir = get_hermes_home() / "kanban" / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - log_path = log_dir / f"{task.id}.log" - - # Use 'a' so a re-run on unblock appends rather than overwrites. - log_f = open(log_path, "ab") - try: - subprocess.Popen( # noqa: S603 -- argv is a fixed list built above - cmd, - cwd=workspace if os.path.isdir(workspace) else None, - stdin=subprocess.DEVNULL, - stdout=log_f, - stderr=subprocess.STDOUT, - env=env, - start_new_session=True, - ) - except FileNotFoundError: - log_f.close() - raise RuntimeError( - "`hermes` executable not found on PATH. " - "Install Hermes Agent or activate its venv before running the kanban dispatcher." - ) - # NOTE: we intentionally do NOT close log_f here — we want Popen's - # child process to keep writing after this function returns. The - # handle is kept alive by the child's inheritance. The parent's - # reference goes out of scope and is GC'd, but the OS-level FD stays - # open in the child until the child exits. - - -# --------------------------------------------------------------------------- -# Worker context builder (what a spawned worker sees) -# --------------------------------------------------------------------------- - -def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: - """Return the full text a worker should read to understand its task. - - Order (per design spec §8): - 1. Task title (mandatory). - 2. Task body (optional opening post). - 3. Every comment on the task, chronologically, with authors. - 4. Completion results of every done parent task. - """ - task = get_task(conn, task_id) - if not task: - raise ValueError(f"unknown task {task_id}") - - lines: list[str] = [] - lines.append(f"# Kanban task {task.id}: {task.title}") - lines.append("") - lines.append(f"Assignee: {task.assignee or '(unassigned)'}") - lines.append(f"Status: {task.status}") - if task.tenant: - lines.append(f"Tenant: {task.tenant}") - lines.append(f"Workspace: {task.workspace_kind} @ {task.workspace_path or '(unresolved)'}") - lines.append("") - - if task.body and task.body.strip(): - lines.append("## Body") - lines.append(task.body.strip()) - lines.append("") - - parents = parent_results(conn, task_id) - if parents: - lines.append("## Parent task results") - for pid, result in parents: - lines.append(f"### {pid}") - lines.append((result or "(no result recorded)").strip()) - lines.append("") - - comments = list_comments(conn, task_id) - if comments: - lines.append("## Comment thread") - for c in comments: - ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(c.created_at)) - lines.append(f"**{c.author}** ({ts}):") - lines.append(c.body.strip()) - lines.append("") - - return "\n".join(lines).rstrip() + "\n" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 19623434d9..a53b8d2c5e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4780,13 +4780,6 @@ def cmd_webhook(args): webhook_command(args) -def cmd_kanban(args): - """Multi-profile collaboration board.""" - from hermes_cli.kanban import kanban_command - - return kanban_command(args) - - def cmd_hooks(args): """Shell-hook inspection and management.""" from hermes_cli.hooks import hooks_command @@ -8123,13 +8116,6 @@ For more help on a command: webhook_parser.set_defaults(func=cmd_webhook) - # ========================================================================= - # kanban command — multi-profile collaboration board - # ========================================================================= - from hermes_cli.kanban import build_parser as _build_kanban_parser - kanban_parser = _build_kanban_parser(subparsers) - kanban_parser.set_defaults(func=cmd_kanban) - # ========================================================================= # hooks command — shell-hook inspection and management # ========================================================================= diff --git a/skills/devops/kanban-orchestrator/SKILL.md b/skills/devops/kanban-orchestrator/SKILL.md deleted file mode 100644 index 1b706b9fca..0000000000 --- a/skills/devops/kanban-orchestrator/SKILL.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -name: kanban-orchestrator -description: Decompose user goals into Kanban tasks and delegate them to specialist profiles. Load this skill in an orchestrator profile whose job is routing, NOT execution. Triggers when the user's goal spans multiple profiles, needs parallel work, or should be durable/auditable. -version: 1.0.0 -metadata: - hermes: - tags: [kanban, multi-agent, orchestration, routing] - related_skills: [kanban-worker] ---- - -# Kanban Orchestrator - -**You are a dispatcher, not a worker.** - -Load this skill in an orchestrator profile. An orchestrator's job is to route: read the user's goal, decompose it into well-scoped tasks, assign each to the right specialist profile, link dependencies, and step back. It does NOT do research, writing, coding, or any implementation work itself. - -## When to use the board (vs. just doing the work) - -Create Kanban tasks when any of these are true: - -1. **Multiple specialists are needed.** Research + analysis + writing is three profiles. -2. **The work should survive a crash or restart.** Long-running, recurring, or important. -3. **The user might want to interject.** Human-in-the-loop at any step. -4. **Multiple subtasks can run in parallel.** Fan-out for speed. -5. **Review / iteration is expected.** A reviewer profile loops on drafter output. -6. **The audit trail matters.** Board rows persist in SQLite forever. - -If *none* of those apply — it's a small one-shot reasoning task — use `delegate_task` instead or answer directly. - -## The anti-temptation rules - -These are the rules you MUST NOT break: - -- **Do not execute the work yourself.** Your tools literally don't include terminal/file/code/web for implementation. If you find yourself "just fixing this quickly" — stop. -- **For any concrete task, create a Kanban task and assign it to a specialist.** Every single time. -- **If no specialist fits, ask the user which profile to create.** Do not default to doing it yourself under "close enough." -- **Your job is to decompose, route, and summarize — nothing else.** - -## The standard specialist roster (convention) - -Unless the user's setup has customized profiles, assume these exist. Adjust to whatever profiles the user actually has — ask if unsure. - -| Profile | Does | -|---|---| -| `researcher` | Reads sources, gathers facts, writes findings. Scratch workspace. | -| `analyst` | Synthesizes, ranks, de-dupes. Consumes multiple `researcher` outputs. | -| `writer` | Drafts prose in the user's voice. | -| `reviewer` | Reads output, leaves line-comments, gates approval. | -| `backend-eng` | Writes server-side code. Worktree workspace. | -| `frontend-eng` | Writes client-side code. Worktree workspace. | -| `ops` | Runs scripts, manages services, handles deployments. | - -## Decomposition playbook - -### Step 1 — Understand the goal - -Ask clarifying questions if the goal is ambiguous. Cheap to ask; expensive to spawn the wrong fleet. - -### Step 2 — Sketch the task graph - -Before creating anything, draft the graph out loud (in your response): - -``` -T1 [planner] — meta; this is me - ├── T2 [researcher] — angle A - ├── T3 [researcher] — angle B - ├── T4 [researcher] — angle C - └── T5 [analyst] — synthesize T2,T3,T4 - └── T6 [writer] — brief the user -``` - -### Step 3 — Create tasks, link dependencies - -For each leaf-level task: -```bash -hermes kanban create "angle: cost analysis" \ - --assignee researcher \ - --tenant $HERMES_TENANT -``` - -Repeat per task. Then link them: -```bash -hermes kanban link <parent> <child> -``` - -**Do not assign something to yourself.** If the orchestrator shows up as an assignee anywhere, you've made a mistake. - -### Step 4 — Complete your own orchestration task with a summary - -If you were spawned as a task yourself (e.g. `planner` profile was assigned `T1: "investigate foo"`), mark it done with a summary of what you created: - -```bash -hermes kanban complete $HERMES_KANBAN_TASK \ - --result "decomposed into T2-T6: 3 research angles, 1 synthesis, 1 brief" -``` - -### Step 5 — Tell the user what you did - -Reply to the user with: -- The task IDs you created. -- What each is doing. -- Who will work on them. -- Roughly when to expect results (or "I'll message when the last one's done" if the gateway is wired up). - -## Tenant propagation - -If `$HERMES_TENANT` is set, **every task you create must carry the same `--tenant <value>`.** This is how one specialist fleet serves multiple businesses — the tenant flows down the graph, not across. - -## Pattern reference - -The eight collaboration patterns you can instantiate (load the design spec if unsure): - -- **P1 Fan-out** — N siblings, same role, no links between them. -- **P2 Pipeline** — role-specialized chain with linear deps. -- **P3 Voting/quorum** — N siblings + 1 aggregator linked from all N. -- **P4 Journal** — same profile + `--workspace dir:<path>` + recurring cron. -- **P5 Human-in-the-loop** — any worker blocks; user/peer unblocks. -- **P6 @mention** — the user or an agent can write `@profile-name` inline to address a profile; the gateway parses and routes. (UX, not a new primitive.) -- **P7 Thread-scoped workspace** — `/kanban here` pins workspace to current thread dir. -- **P8 Fleet farming** — one profile, N tasks, one workspace per subject (e.g. 50 social accounts). - -## Example run - -User says: *"Analyze whether we should migrate to Postgres. Include a cost analysis and a performance angle."* - -Your decomposition: -1. `hermes kanban create "research: Postgres cost vs current" --assignee researcher` -2. `hermes kanban create "research: Postgres performance vs current" --assignee researcher` -3. `hermes kanban create "synthesize migration recommendation" --assignee analyst` -4. `hermes kanban link <t1> <t3>` ; `hermes kanban link <t2> <t3>` -5. `hermes kanban create "draft decision memo" --assignee writer --parent <t3>` -6. Report task IDs and expected flow to the user. - -## Pitfalls - -**The "just a quick check" trap.** When the user asks a small question you could probably answer yourself, the temptation is to skip the board. If the question is genuinely one-shot, answer directly. If it's the opening of a workflow ("first, check X; then Y; then Z"), it's board work even if step 1 looks small. - -**Reassignment vs. new task.** If a reviewer blocks with "needs changes," create a NEW task linked from the reviewer's task — don't re-run the same task with a stern look. The new task is assigned to the original implementer profile. - -**Link order matters.** `hermes kanban link <parent> <child>` — parent first. Mixing them up demotes the wrong task to `todo`. diff --git a/skills/devops/kanban-worker/SKILL.md b/skills/devops/kanban-worker/SKILL.md deleted file mode 100644 index a6e6d54432..0000000000 --- a/skills/devops/kanban-worker/SKILL.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -name: kanban-worker -description: How a Hermes profile should work a task from the shared Kanban board. Load this skill in any profile that participates in the board (researcher, backend-eng, reviewer, etc.). Triggers on HERMES_KANBAN_TASK env var or a "work kanban task <id>" prompt. -version: 1.0.0 -metadata: - hermes: - tags: [kanban, multi-agent, collaboration, workflow] - related_skills: [kanban-orchestrator] ---- - -# Kanban Worker - -Use this skill when you were spawned to work a task from the shared Hermes Kanban board. Symptoms: - -- Your initial prompt says "work kanban task <id>" — e.g. `work kanban task t_9f2a`. -- Env vars set: `HERMES_KANBAN_TASK`, `HERMES_KANBAN_WORKSPACE`, optionally `HERMES_TENANT`. -- You were started by `hermes kanban dispatch` (cron) or a human ran `hermes -p <profile> chat -q "work kanban task <id>"`. - -## Your job - -You are **one run of one specialist profile working one task.** Read the task, do the work inside the workspace, record a result, and exit. Everything else is somebody else's job. - -## Step 1 — Read the full context - -```bash -hermes kanban context $HERMES_KANBAN_TASK -``` - -That command prints: -1. Task title + body. -2. Every comment on the task, in order, with author names. -3. Completion results of every `done` parent task (upstream context). - -**Read all of it.** The comment thread is the inter-agent protocol — past peers, human clarifications, and blocker resolutions all live there. If a reviewer left feedback or the user answered a blocker, it's in the comments. - -## Step 2 — Work inside the workspace - -`cd $HERMES_KANBAN_WORKSPACE` and do the work there. The workspace kind determines what that means: - -| `workspace_kind` | What it is | Your behavior | -|---|---|---| -| `scratch` | Fresh temp dir, yours alone | Read/write freely; it gets GC'd when the task is archived. | -| `dir:<path>` | Shared persistent directory | Treat as a long-lived workspace; other runs will read what you write. | -| `worktree` | Git worktree at the resolved path | You may need to `git worktree add <path> <branch>` if it doesn't exist yet. Commit work here. | - -For `worktree` mode: check if `.git` exists in the workspace path. If not, run: -```bash -git worktree add $HERMES_KANBAN_WORKSPACE -``` -from the main repo's root. Then cd and work normally. - -## Step 3 — If tenancy matters, respect it - -If `$HERMES_TENANT` is set, the task belongs to that tenant namespace. When reading or writing persistent memory, prefix memory entries with the tenant name so context doesn't leak across tenants: - -> Good: memory entry `business-a: Acme is our biggest customer` -> Bad: unprefixed `Acme is our biggest customer` (leaks across tenants) - -## Step 4 — If you hit an ambiguity you can't resolve, BLOCK. Don't guess. - -Any of these should trigger a block: -- User-specific decision you can't infer (IP vs. user-id keys; which tone to use). -- Missing credential or access. -- Source that needs human input (paywalled article, 2FA-gated login). -- Peer profile needs to deliver something first and you can't reach around that. - -```bash -hermes kanban block $HERMES_KANBAN_TASK "need decision: IP vs user_id for rate limit key?" -``` - -`block` also appends your reason as a visible comment. When the user or a peer unblocks and the dispatcher re-spawns you, you'll see the full comment thread including their answer in step 1's context read. - -## Step 5 — Complete with a crisp, machine-readable result - -```bash -hermes kanban complete $HERMES_KANBAN_TASK --result "rate_limiter.py implemented; keys on user_id with IP fallback; tests passing" -``` - -Rules for the `--result` string: -- One to three sentences. It's not a report, it's a handoff note. -- Name concrete artifacts you produced (file paths, URLs, commit SHAs). -- State any caveats a downstream profile needs to know. -- **Do not** include secrets, tokens, or raw PII — results are durable in the board DB forever. - -Downstream tasks (children linked from this task) will see your `--result` verbatim as part of their parent-result context. - -## Step 6 — If follow-up work is obvious, create it. Don't do it. - -You are one task. If you notice something else needs doing, create a linked child task for the right profile instead of scope-creeping: - -```bash -hermes kanban create "add concurrent-request test" \ - --assignee backend-eng \ - --parent $HERMES_KANBAN_TASK -``` - -## Leave comments to talk to peers - -If you want to flag something for a reviewer, a future run, or the user — append a comment: - -```bash -hermes kanban comment $HERMES_KANBAN_TASK "note: skipped the sqlite driver path; needs separate task" -``` - -Comments are the inter-agent protocol. Direct IPC does not exist; the board is the only channel. - -## Do NOT - -- Do not call `delegate_task` as a substitute for creating kanban tasks — `delegate_task` is for short synchronous reasoning subtasks inside your own run, not for cross-agent handoffs. -- Do not modify files outside `$HERMES_KANBAN_WORKSPACE` unless the task body explicitly asks for it. -- Do not assign tasks to yourself during your run (you're already running one; create new tasks for follow-ups only). -- Do not complete a task you didn't actually finish. Block it instead. - -## Pitfalls - -**The task might already be blocked or reassigned when you start.** Between when the dispatcher claimed and when you actually booted up, circumstances can change. Always read the current state at step 1. If `hermes kanban show` reports the task is blocked or reassigned, stop — don't keep running. - -**The workspace may already have artifacts from a previous run.** Especially for `dir:` and `worktree` workspaces, a previous worker may have written files that are incomplete or stale. Read the comment thread — it usually explains why you're running again. - -**Your memory persists but the task result does not carry over automatically.** If you learn something that matters for future runs of this profile in other tasks, write it to your profile memory via the normal mechanism. Comments on the task are for humans and peers; memory is for your future self. diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py deleted file mode 100644 index f7c84d5df8..0000000000 --- a/tests/hermes_cli/test_kanban_cli.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Tests for the kanban CLI surface (hermes_cli.kanban).""" - -from __future__ import annotations - -import argparse -import json -import os -from pathlib import Path - -import pytest - -from hermes_cli import kanban as kc -from hermes_cli import kanban_db as kb - - -@pytest.fixture -def kanban_home(tmp_path, monkeypatch): - home = tmp_path / ".hermes" - home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - kb.init_db() - return home - - -# --------------------------------------------------------------------------- -# Workspace flag parsing -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize( - "value,expected", - [ - ("scratch", ("scratch", None)), - ("worktree", ("worktree", None)), - ("dir:/tmp/work", ("dir", "/tmp/work")), - ], -) -def test_parse_workspace_flag_valid(value, expected): - assert kc._parse_workspace_flag(value) == expected - - -def test_parse_workspace_flag_expands_user(): - kind, path = kc._parse_workspace_flag("dir:~/vault") - assert kind == "dir" - assert path.endswith("/vault") - assert not path.startswith("~") - - -@pytest.mark.parametrize("bad", ["cloud", "dir:", "", "worktree:/x"]) -def test_parse_workspace_flag_rejects(bad): - if not bad: - # Empty -> defaults; not an error. - assert kc._parse_workspace_flag(bad) == ("scratch", None) - return - with pytest.raises(argparse.ArgumentTypeError): - kc._parse_workspace_flag(bad) - - -# --------------------------------------------------------------------------- -# run_slash smoke tests (end-to-end via the same entry both CLI and gateway use) -# --------------------------------------------------------------------------- - -def test_run_slash_no_args_shows_usage(kanban_home): - out = kc.run_slash("") - assert "kanban" in out.lower() - assert "create" in out.lower() or "subcommand" in out.lower() or "action" in out.lower() - - -def test_run_slash_create_and_list(kanban_home): - out = kc.run_slash("create 'ship feature' --assignee alice") - assert "Created" in out - out = kc.run_slash("list") - assert "ship feature" in out - assert "alice" in out - - -def test_run_slash_create_with_parent_and_cascade(kanban_home): - # Parent then child via --parent - out1 = kc.run_slash("create 'parent' --assignee alice") - # Extract the "t_xxxx" id from "Created t_xxxx (ready, ...)" - import re - m = re.search(r"(t_[a-f0-9]+)", out1) - assert m - p = m.group(1) - out2 = kc.run_slash(f"create 'child' --assignee bob --parent {p}") - assert "todo" in out2 # child starts as todo - - # Complete parent; list should promote child to ready - kc.run_slash(f"complete {p}") - # Explicit filter: child should now be ready (was todo before complete). - ready_list = kc.run_slash("list --status ready") - assert "child" in ready_list - - -def test_run_slash_show_includes_comments(kanban_home): - out = kc.run_slash("create 'x'") - import re - tid = re.search(r"(t_[a-f0-9]+)", out).group(1) - kc.run_slash(f"comment {tid} 'source is paywalled'") - show = kc.run_slash(f"show {tid}") - assert "source is paywalled" in show - - -def test_run_slash_block_unblock_cycle(kanban_home): - out = kc.run_slash("create 'x' --assignee alice") - import re - tid = re.search(r"(t_[a-f0-9]+)", out).group(1) - # Claim first so block() finds it running - kc.run_slash(f"claim {tid}") - assert "Blocked" in kc.run_slash(f"block {tid} 'need decision'") - assert "Unblocked" in kc.run_slash(f"unblock {tid}") - - -def test_run_slash_json_output(kanban_home): - out = kc.run_slash("create 'jsontask' --assignee alice --json") - payload = json.loads(out) - assert payload["title"] == "jsontask" - assert payload["assignee"] == "alice" - assert payload["status"] == "ready" - - -def test_run_slash_dispatch_dry_run_counts(kanban_home): - kc.run_slash("create 'a' --assignee alice") - kc.run_slash("create 'b' --assignee bob") - out = kc.run_slash("dispatch --dry-run") - assert "Spawned:" in out - - -def test_run_slash_context_output_format(kanban_home): - out = kc.run_slash("create 'tech spec' --assignee alice --body 'write an RFC'") - import re - tid = re.search(r"(t_[a-f0-9]+)", out).group(1) - kc.run_slash(f"comment {tid} 'remember to include performance section'") - ctx = kc.run_slash(f"context {tid}") - assert "tech spec" in ctx - assert "write an RFC" in ctx - assert "performance section" in ctx - - -def test_run_slash_tenant_filter(kanban_home): - kc.run_slash("create 'biz-a task' --tenant biz-a --assignee alice") - kc.run_slash("create 'biz-b task' --tenant biz-b --assignee alice") - a = kc.run_slash("list --tenant biz-a") - b = kc.run_slash("list --tenant biz-b") - assert "biz-a task" in a and "biz-b task" not in a - assert "biz-b task" in b and "biz-a task" not in b - - -def test_run_slash_usage_error_returns_message(kanban_home): - # Missing required argument for create - out = kc.run_slash("create") - assert "usage" in out.lower() or "error" in out.lower() - - -def test_run_slash_assign_reassigns(kanban_home): - out = kc.run_slash("create 'x' --assignee alice") - import re - tid = re.search(r"(t_[a-f0-9]+)", out).group(1) - assert "Assigned" in kc.run_slash(f"assign {tid} bob") - show = kc.run_slash(f"show {tid}") - assert "bob" in show - - -def test_run_slash_link_unlink(kanban_home): - a = kc.run_slash("create 'a'") - b = kc.run_slash("create 'b'") - import re - ta = re.search(r"(t_[a-f0-9]+)", a).group(1) - tb = re.search(r"(t_[a-f0-9]+)", b).group(1) - assert "Linked" in kc.run_slash(f"link {ta} {tb}") - # After link, b is todo - show = kc.run_slash(f"show {tb}") - assert "todo" in show - assert "Unlinked" in kc.run_slash(f"unlink {ta} {tb}") - - -# --------------------------------------------------------------------------- -# Integration with the COMMAND_REGISTRY -# --------------------------------------------------------------------------- - -def test_kanban_is_resolvable(): - from hermes_cli.commands import resolve_command - - cmd = resolve_command("kanban") - assert cmd is not None - assert cmd.name == "kanban" - - -def test_kanban_bypasses_active_session_guard(): - from hermes_cli.commands import should_bypass_active_session - - assert should_bypass_active_session("kanban") - - -def test_kanban_in_autocomplete_table(): - from hermes_cli.commands import COMMANDS, SUBCOMMANDS - - assert "/kanban" in COMMANDS - subs = SUBCOMMANDS.get("/kanban") or [] - assert "create" in subs - assert "dispatch" in subs - - -def test_kanban_not_gateway_only(): - # kanban is available in BOTH CLI and gateway surfaces. - from hermes_cli.commands import COMMAND_REGISTRY - - cmd = next(c for c in COMMAND_REGISTRY if c.name == "kanban") - assert not cmd.cli_only - assert not cmd.gateway_only diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py deleted file mode 100644 index fcc6396be4..0000000000 --- a/tests/hermes_cli/test_kanban_db.py +++ /dev/null @@ -1,438 +0,0 @@ -"""Tests for the Kanban DB layer (hermes_cli.kanban_db).""" - -from __future__ import annotations - -import concurrent.futures -import os -import time -from pathlib import Path - -import pytest - -from hermes_cli import kanban_db as kb - - -@pytest.fixture -def kanban_home(tmp_path, monkeypatch): - """Isolated HERMES_HOME with an empty kanban DB.""" - home = tmp_path / ".hermes" - home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - kb.init_db() - return home - - -# --------------------------------------------------------------------------- -# Schema / init -# --------------------------------------------------------------------------- - -def test_init_db_is_idempotent(kanban_home): - # Second call should not error or drop data. - with kb.connect() as conn: - kb.create_task(conn, title="persisted") - kb.init_db() - with kb.connect() as conn: - tasks = kb.list_tasks(conn) - assert len(tasks) == 1 - assert tasks[0].title == "persisted" - - -def test_init_creates_expected_tables(kanban_home): - with kb.connect() as conn: - rows = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" - ).fetchall() - names = {r["name"] for r in rows} - assert {"tasks", "task_links", "task_comments", "task_events"} <= names - - -# --------------------------------------------------------------------------- -# Task creation + status inference -# --------------------------------------------------------------------------- - -def test_create_task_no_parents_is_ready(kanban_home): - with kb.connect() as conn: - tid = kb.create_task(conn, title="ship it", assignee="alice") - t = kb.get_task(conn, tid) - assert t is not None - assert t.status == "ready" - assert t.assignee == "alice" - assert t.workspace_kind == "scratch" - - -def test_create_task_with_parent_is_todo_until_parent_done(kanban_home): - with kb.connect() as conn: - p = kb.create_task(conn, title="parent") - c = kb.create_task(conn, title="child", parents=[p]) - assert kb.get_task(conn, c).status == "todo" - kb.complete_task(conn, p, result="ok") - assert kb.get_task(conn, c).status == "ready" - - -def test_create_task_unknown_parent_errors(kanban_home): - with kb.connect() as conn, pytest.raises(ValueError, match="unknown parent"): - kb.create_task(conn, title="orphan", parents=["t_ghost"]) - - -def test_workspace_kind_validation(kanban_home): - with kb.connect() as conn, pytest.raises(ValueError, match="workspace_kind"): - kb.create_task(conn, title="bad ws", workspace_kind="cloud") - - -# --------------------------------------------------------------------------- -# Links + dependency resolution -# --------------------------------------------------------------------------- - -def test_link_demotes_ready_child_to_todo_when_parent_not_done(kanban_home): - with kb.connect() as conn: - a = kb.create_task(conn, title="a") - b = kb.create_task(conn, title="b") - assert kb.get_task(conn, b).status == "ready" - kb.link_tasks(conn, a, b) - assert kb.get_task(conn, b).status == "todo" - - -def test_link_keeps_ready_child_when_parent_already_done(kanban_home): - with kb.connect() as conn: - a = kb.create_task(conn, title="a") - kb.complete_task(conn, a) - b = kb.create_task(conn, title="b") - assert kb.get_task(conn, b).status == "ready" - kb.link_tasks(conn, a, b) - assert kb.get_task(conn, b).status == "ready" - - -def test_link_rejects_self_loop(kanban_home): - with kb.connect() as conn: - a = kb.create_task(conn, title="a") - with pytest.raises(ValueError, match="itself"): - kb.link_tasks(conn, a, a) - - -def test_link_detects_cycle(kanban_home): - with kb.connect() as conn: - a = kb.create_task(conn, title="a") - b = kb.create_task(conn, title="b", parents=[a]) - c = kb.create_task(conn, title="c", parents=[b]) - with pytest.raises(ValueError, match="cycle"): - kb.link_tasks(conn, c, a) - with pytest.raises(ValueError, match="cycle"): - kb.link_tasks(conn, b, a) - - -def test_recompute_ready_cascades_through_chain(kanban_home): - with kb.connect() as conn: - a = kb.create_task(conn, title="a") - b = kb.create_task(conn, title="b", parents=[a]) - c = kb.create_task(conn, title="c", parents=[b]) - assert [kb.get_task(conn, x).status for x in (a, b, c)] == \ - ["ready", "todo", "todo"] - kb.complete_task(conn, a) - assert kb.get_task(conn, b).status == "ready" - kb.complete_task(conn, b) - assert kb.get_task(conn, c).status == "ready" - - -def test_recompute_ready_fan_in_waits_for_all_parents(kanban_home): - with kb.connect() as conn: - a = kb.create_task(conn, title="a") - b = kb.create_task(conn, title="b") - c = kb.create_task(conn, title="c", parents=[a, b]) - kb.complete_task(conn, a) - assert kb.get_task(conn, c).status == "todo" - kb.complete_task(conn, b) - assert kb.get_task(conn, c).status == "ready" - - -# --------------------------------------------------------------------------- -# Atomic claim (CAS) -# --------------------------------------------------------------------------- - -def test_claim_once_wins_second_loses(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x", assignee="a") - first = kb.claim_task(conn, t, claimer="host:1") - assert first is not None and first.status == "running" - second = kb.claim_task(conn, t, claimer="host:2") - assert second is None - - -def test_claim_fails_on_non_ready(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x") - # Move to todo by introducing an unsatisfied parent. - p = kb.create_task(conn, title="p") - kb.link_tasks(conn, p, t) - assert kb.get_task(conn, t).status == "todo" - assert kb.claim_task(conn, t) is None - - -def test_stale_claim_reclaimed(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x", assignee="a") - kb.claim_task(conn, t) - # Rewind claim_expires so it looks stale. - conn.execute( - "UPDATE tasks SET claim_expires = ? WHERE id = ?", - (int(time.time()) - 3600, t), - ) - reclaimed = kb.release_stale_claims(conn) - assert reclaimed == 1 - assert kb.get_task(conn, t).status == "ready" - - -def test_heartbeat_extends_claim(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x", assignee="a") - claimer = "host:hb" - kb.claim_task(conn, t, claimer=claimer, ttl_seconds=60) - original = kb.get_task(conn, t).claim_expires - # Rewind then heartbeat. - conn.execute("UPDATE tasks SET claim_expires = ? WHERE id = ?", (0, t)) - ok = kb.heartbeat_claim(conn, t, claimer=claimer, ttl_seconds=3600) - assert ok - new = kb.get_task(conn, t).claim_expires - assert new > int(time.time()) + 3000 - - -def test_concurrent_claims_only_one_wins(kanban_home): - """Fire N threads claiming the same task; exactly one must win.""" - with kb.connect() as conn: - t = kb.create_task(conn, title="race", assignee="a") - - def attempt(i): - with kb.connect() as c: - return kb.claim_task(c, t, claimer=f"host:{i}") - - n_workers = 8 - with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as ex: - results = list(ex.map(attempt, range(n_workers))) - winners = [r for r in results if r is not None] - assert len(winners) == 1 - assert winners[0].status == "running" - - -# --------------------------------------------------------------------------- -# Complete / block / unblock / archive / assign -# --------------------------------------------------------------------------- - -def test_complete_records_result(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x") - assert kb.complete_task(conn, t, result="done and dusted") - task = kb.get_task(conn, t) - assert task.status == "done" - assert task.result == "done and dusted" - assert task.completed_at is not None - - -def test_block_then_unblock(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x", assignee="a") - kb.claim_task(conn, t) - assert kb.block_task(conn, t, reason="need input") - assert kb.get_task(conn, t).status == "blocked" - assert kb.unblock_task(conn, t) - assert kb.get_task(conn, t).status == "ready" - - -def test_assign_refuses_while_running(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x", assignee="a") - kb.claim_task(conn, t) - with pytest.raises(RuntimeError, match="currently running"): - kb.assign_task(conn, t, "b") - - -def test_assign_reassigns_when_not_running(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x", assignee="a") - assert kb.assign_task(conn, t, "b") - assert kb.get_task(conn, t).assignee == "b" - - -def test_archive_hides_from_default_list(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x") - kb.complete_task(conn, t) - assert kb.archive_task(conn, t) - assert len(kb.list_tasks(conn)) == 0 - assert len(kb.list_tasks(conn, include_archived=True)) == 1 - - -# --------------------------------------------------------------------------- -# Comments / events / worker context -# --------------------------------------------------------------------------- - -def test_comments_recorded_in_order(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x") - kb.add_comment(conn, t, "user", "first") - kb.add_comment(conn, t, "researcher", "second") - comments = kb.list_comments(conn, t) - assert [c.body for c in comments] == ["first", "second"] - assert [c.author for c in comments] == ["user", "researcher"] - - -def test_empty_comment_rejected(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x") - with pytest.raises(ValueError, match="body is required"): - kb.add_comment(conn, t, "user", "") - - -def test_events_capture_lifecycle(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x", assignee="a") - kb.claim_task(conn, t) - kb.complete_task(conn, t, result="ok") - events = kb.list_events(conn, t) - kinds = [e.kind for e in events] - assert "created" in kinds - assert "claimed" in kinds - assert "completed" in kinds - - -def test_worker_context_includes_parent_results_and_comments(kanban_home): - with kb.connect() as conn: - p = kb.create_task(conn, title="p") - kb.complete_task(conn, p, result="PARENT_RESULT_MARKER") - c = kb.create_task(conn, title="child", parents=[p]) - kb.add_comment(conn, c, "user", "CLARIFICATION_MARKER") - ctx = kb.build_worker_context(conn, c) - assert "PARENT_RESULT_MARKER" in ctx - assert "CLARIFICATION_MARKER" in ctx - assert c in ctx - assert "child" in ctx - - -# --------------------------------------------------------------------------- -# Dispatcher -# --------------------------------------------------------------------------- - -def test_dispatch_dry_run_does_not_claim(kanban_home): - with kb.connect() as conn: - t1 = kb.create_task(conn, title="a", assignee="alice") - t2 = kb.create_task(conn, title="b", assignee="bob") - res = kb.dispatch_once(conn, dry_run=True) - assert {s[0] for s in res.spawned} == {t1, t2} - with kb.connect() as conn: - # Dry run must NOT mutate status. - assert kb.get_task(conn, t1).status == "ready" - assert kb.get_task(conn, t2).status == "ready" - - -def test_dispatch_skips_unassigned(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="floater") - res = kb.dispatch_once(conn, dry_run=True) - assert t in res.skipped_unassigned - assert not res.spawned - - -def test_dispatch_promotes_ready_and_spawns(kanban_home): - spawns = [] - - def fake_spawn(task, workspace): - spawns.append((task.id, task.assignee, workspace)) - - with kb.connect() as conn: - p = kb.create_task(conn, title="p", assignee="alice") - c = kb.create_task(conn, title="c", assignee="bob", parents=[p]) - # Finish parent outside dispatch; promotion happens inside. - kb.complete_task(conn, p) - res = kb.dispatch_once(conn, spawn_fn=fake_spawn) - # Spawned c (a was already done when dispatch was called). - assert len(spawns) == 1 - assert spawns[0][0] == c - assert spawns[0][1] == "bob" - # c is now running - with kb.connect() as conn: - assert kb.get_task(conn, c).status == "running" - - -def test_dispatch_spawn_failure_releases_claim(kanban_home): - def boom(task, workspace): - raise RuntimeError("spawn failed") - - with kb.connect() as conn: - t = kb.create_task(conn, title="boom", assignee="alice") - kb.dispatch_once(conn, spawn_fn=boom) - # Must return to ready so the next tick can retry. - assert kb.get_task(conn, t).status == "ready" - assert kb.get_task(conn, t).claim_lock is None - - -def test_dispatch_reclaims_stale_before_spawning(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x", assignee="alice") - kb.claim_task(conn, t) - conn.execute( - "UPDATE tasks SET claim_expires = ? WHERE id = ?", - (int(time.time()) - 1, t), - ) - res = kb.dispatch_once(conn, dry_run=True) - assert res.reclaimed == 1 - - -# --------------------------------------------------------------------------- -# Workspace resolution -# --------------------------------------------------------------------------- - -def test_scratch_workspace_created_under_hermes_home(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="x") - task = kb.get_task(conn, t) - ws = kb.resolve_workspace(task) - assert ws.exists() - assert ws.is_dir() - assert "kanban" in str(ws) - - -def test_dir_workspace_honors_given_path(kanban_home, tmp_path): - target = tmp_path / "my-vault" - with kb.connect() as conn: - t = kb.create_task( - conn, title="biz", workspace_kind="dir", workspace_path=str(target) - ) - task = kb.get_task(conn, t) - ws = kb.resolve_workspace(task) - assert ws == target - assert ws.exists() - - -def test_worktree_workspace_returns_intended_path(kanban_home, tmp_path): - target = str(tmp_path / ".worktrees" / "my-task") - with kb.connect() as conn: - t = kb.create_task( - conn, title="ship", workspace_kind="worktree", workspace_path=target - ) - task = kb.get_task(conn, t) - ws = kb.resolve_workspace(task) - # We do NOT auto-create worktrees; the worker's skill handles that. - assert str(ws) == target - - -# --------------------------------------------------------------------------- -# Tenancy -# --------------------------------------------------------------------------- - -def test_tenant_column_filters_listings(kanban_home): - with kb.connect() as conn: - kb.create_task(conn, title="a1", tenant="biz-a") - kb.create_task(conn, title="b1", tenant="biz-b") - kb.create_task(conn, title="shared") # no tenant - biz_a = kb.list_tasks(conn, tenant="biz-a") - biz_b = kb.list_tasks(conn, tenant="biz-b") - assert [t.title for t in biz_a] == ["a1"] - assert [t.title for t in biz_b] == ["b1"] - - -def test_tenant_propagates_to_events(kanban_home): - with kb.connect() as conn: - t = kb.create_task(conn, title="tenant-task", tenant="biz-a") - events = kb.list_events(conn, t) - # The "created" event should have tenant in its payload. - created = [e for e in events if e.kind == "created"] - assert created and created[0].payload.get("tenant") == "biz-a" diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index f0d28d958e..947994844b 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -45,7 +45,6 @@ hermes [global-options] <command> [subcommand/options] | `hermes login` / `logout` | **Deprecated** — use `hermes auth` instead. | | `hermes status` | Show agent, auth, and platform status. | | `hermes cron` | Inspect and tick the cron scheduler. | -| `hermes kanban` | Multi-profile collaboration board (tasks, links, dispatcher). | | `hermes webhook` | Manage dynamic webhook subscriptions for event-driven activation. | | `hermes doctor` | Diagnose config and dependency issues. | | `hermes dump` | Copy-pasteable setup summary for support/debugging. | @@ -273,38 +272,6 @@ hermes cron <list|create|edit|pause|resume|run|remove|status|tick> | `status` | Check whether the cron scheduler is running. | | `tick` | Run due jobs once and exit. | -## `hermes kanban` - -```bash -hermes kanban <action> [options] -``` - -Multi-profile collaboration board. Tasks live in `~/.hermes/kanban.db` (WAL-mode SQLite); every profile reads and writes the same board. A `cron`-driven dispatcher (`hermes kanban dispatch`) atomically claims ready tasks and spawns the assigned profile as its own process with an isolated workspace. - -| Action | Purpose | -|--------|---------| -| `init` | Create `kanban.db` if missing. Idempotent. | -| `create "<title>"` | Create a new task. Flags: `--body`, `--assignee`, `--parent` (repeatable), `--workspace scratch\|worktree\|dir:<path>`, `--tenant`, `--priority`. | -| `list` / `ls` | List tasks. Filter with `--mine`, `--assignee`, `--status`, `--tenant`, `--archived`, `--json`. | -| `show <id>` | Show a task with comments and events. `--json` for machine output. | -| `assign <id> <profile>` | Assign or reassign. Use `none` to unassign. Refused while task is running. | -| `link <parent> <child>` | Add a dependency. Cycle-detected. | -| `unlink <parent> <child>` | Remove a dependency. | -| `claim <id>` | Atomically claim a ready task. Prints resolved workspace path. | -| `comment <id> "<text>"` | Append a comment. Visible to the next worker that runs the task. | -| `complete <id>` | Mark task done. Flag: `--result "<summary>"` (goes into children's parent-result context). | -| `block <id> "<reason>"` | Mark task blocked. Also appends the reason as a comment. | -| `unblock <id>` | Return a blocked task to ready. | -| `archive <id>` | Hide from default list. `gc` will remove scratch workspaces. | -| `tail <id>` | Follow a task's event stream. | -| `dispatch` | One dispatcher pass. Flags: `--dry-run`, `--max N`, `--json`. | -| `context <id>` | Print the full context a worker would see (title + body + parent results + comments). | -| `gc` | Remove scratch workspaces for archived tasks. | - -All actions are also available as a slash command in the gateway (`/kanban …`), with the same argument surface. - -For the full design — comparison with Cline Kanban / Paperclip / NanoClaw / Gemini Enterprise, eight collaboration patterns, four user stories, concurrency correctness proof — see `docs/hermes-kanban-v1-spec.pdf` in the repository or the [Kanban user guide](/docs/user-guide/features/kanban). - ## `hermes webhook` ```bash diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md deleted file mode 100644 index 068c37275b..0000000000 --- a/website/docs/user-guide/features/kanban.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -sidebar_position: 12 -title: "Kanban (Multi-Agent Board)" -description: "Durable SQLite-backed task board for coordinating multiple Hermes profiles" ---- - -# Kanban — Multi-Agent Profile Collaboration - -Hermes Kanban is a durable task board, shared across all your Hermes profiles, that lets multiple named agents collaborate on work without fragile in-process subagent swarms. Every task is a row in `~/.hermes/kanban.db`; every handoff is a row anyone can read and write; every worker is a full OS process with its own identity. - -This is the shape that covers the workloads `delegate_task` can't: - -- **Research triage** — parallel researchers + analyst + writer, human-in-the-loop. -- **Scheduled ops** — recurring daily briefs that build a journal over weeks. -- **Digital twins** — persistent named assistants (`inbox-triage`, `ops-review`) that accumulate memory over time. -- **Engineering pipelines** — decompose → implement in parallel worktrees → review → iterate → PR. -- **Fleet work** — one specialist managing N subjects (50 social accounts, 12 monitored services). - -For the full design rationale, comparative analysis against Cline Kanban / Paperclip / NanoClaw / Google Gemini Enterprise, and the eight canonical collaboration patterns, see `docs/hermes-kanban-v1-spec.pdf` in the repository. - -## Kanban vs. `delegate_task` - -They look similar; they are not the same primitive. - -| | `delegate_task` | Kanban | -|---|---|---| -| Shape | RPC call (fork → join) | Durable message queue + state machine | -| Parent | Blocks until child returns | Fire-and-forget after `create` | -| Child identity | Anonymous subagent | Named profile with persistent memory | -| Resumability | None — failed = failed | Block → unblock → re-run; crash → reclaim | -| Human in the loop | Not supported | Comment / unblock at any point | -| Agents per task | One call = one subagent | N agents over task's life (retry, review, follow-up) | -| Audit trail | Lost on context compression | Durable rows in SQLite forever | -| Coordination | Hierarchical (caller → callee) | Peer — any profile reads/writes any task | - -**One-sentence distinction:** `delegate_task` is a function call; Kanban is a work queue where every handoff is a row any profile (or human) can see and edit. - -**Use `delegate_task` when** the parent agent needs a short reasoning answer before continuing, no humans involved, result goes back into the parent's context. - -**Use Kanban when** work crosses agent boundaries, needs to survive restarts, might need human input, might be picked up by a different role, or needs to be discoverable after the fact. - -They coexist: a kanban worker may call `delegate_task` internally during its run. - -## Core concepts - -- **Task** — a row with title, optional body, one assignee (a profile name), status (`todo | ready | running | blocked | done | archived`), optional tenant namespace. -- **Link** — `task_links` row recording a parent → child dependency. The dispatcher promotes `todo → ready` when all parents are `done`. -- **Comment** — the inter-agent protocol. Agents and humans append comments; when a worker is (re-)spawned it reads the full comment thread as part of its context. -- **Workspace** — the directory a worker operates in. Three kinds: - - `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/`. - - `dir:<path>` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). - - `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. -- **Dispatcher** — `hermes kanban dispatch` runs a one-shot pass: reclaim stale claims, promote ready tasks, atomically claim, spawn assigned profiles. Runs via cron every 60 seconds. -- **Tenant** — optional string namespace. One specialist fleet can serve multiple businesses (`--tenant business-a`) with data isolation by workspace path and memory key prefix. - -## Quick start - -```bash -# 1. Create the board -hermes kanban init - -# 2. Create a task -hermes kanban create "research AI funding landscape" --assignee researcher - -# 3. List what's on the board -hermes kanban list - -# 4. Run a dispatcher pass (dry-run to preview, real to spawn workers) -hermes kanban dispatch --dry-run -hermes kanban dispatch -``` - -To have the board run continuously, schedule the dispatcher: - -```bash -hermes cron add --schedule "*/1 * * * *" \ - --name kanban-dispatch \ - hermes kanban dispatch -``` - -## The worker skill - -Any profile that should be able to work kanban tasks must load the `kanban-worker` skill. It teaches the worker the full lifecycle: - -1. On spawn, read `$HERMES_KANBAN_TASK` env var. -2. Run `hermes kanban context $HERMES_KANBAN_TASK` to read title + body + parent results + full comment thread. -3. `cd $HERMES_KANBAN_WORKSPACE` and do the work there. -4. Complete with `hermes kanban complete <id> --result "<summary>"`, or block with `hermes kanban block <id> "<reason>"` if stuck. - -Load it with: - -```bash -hermes skills install devops/kanban-worker -``` - -## The orchestrator skill - -A **well-behaved orchestrator does not do the work itself.** It decomposes the user's goal into tasks, links them, assigns each to a specialist, and steps back. The `kanban-orchestrator` skill encodes this: anti-temptation rules, a standard specialist roster (`researcher`, `writer`, `analyst`, `backend-eng`, `reviewer`, `ops`), and a decomposition playbook. - -Load it into your orchestrator profile: - -```bash -hermes skills install devops/kanban-orchestrator -``` - -For best results, pair it with a profile whose toolsets are restricted to board operations (`kanban`, `gateway`, `memory`) so the orchestrator literally cannot execute implementation tasks even if it tries. - -## CLI command reference - -``` -hermes kanban init # create kanban.db -hermes kanban create "<title>" [--body ...] [--assignee <profile>] - [--parent <id>]... [--tenant <name>] - [--workspace scratch|worktree|dir:<path>] - [--priority N] [--json] -hermes kanban list [--mine] [--assignee P] [--status S] [--tenant T] [--archived] [--json] -hermes kanban show <id> [--json] -hermes kanban assign <id> <profile> # or 'none' to unassign -hermes kanban link <parent_id> <child_id> -hermes kanban unlink <parent_id> <child_id> -hermes kanban claim <id> [--ttl SECONDS] -hermes kanban comment <id> "<text>" [--author NAME] -hermes kanban complete <id> [--result "..."] -hermes kanban block <id> "<reason>" -hermes kanban unblock <id> -hermes kanban archive <id> -hermes kanban tail <id> # follow event stream -hermes kanban dispatch [--dry-run] [--max N] [--json] -hermes kanban context <id> # what a worker sees -hermes kanban gc # remove scratch dirs of archived tasks -``` - -All commands are also available as a slash command in the gateway (`/kanban list`, `/kanban comment t_abc "need docs"`, etc.). The slash command bypasses the running-agent guard, so you can `/kanban unblock` a stuck worker while the main agent is still chatting. - -## Collaboration patterns - -The board supports these eight patterns without any new primitives: - -| Pattern | Shape | Example | -|---|---|---| -| **P1 Fan-out** | N siblings, same role | "research 5 angles in parallel" | -| **P2 Pipeline** | role chain: scout → editor → writer | daily brief assembly | -| **P3 Voting / quorum** | N siblings + 1 aggregator | 3 researchers → 1 reviewer picks | -| **P4 Long-running journal** | same profile + shared dir + cron | Obsidian vault | -| **P5 Human-in-the-loop** | worker blocks → user comments → unblock | ambiguous decisions | -| **P6 `@mention`** | inline routing from prose | `@reviewer look at this` | -| **P7 Thread-scoped workspace** | `/kanban here` in a thread | per-project gateway threads | -| **P8 Fleet farming** | one profile, N subjects | 50 social accounts | - -For worked examples of each, see `docs/hermes-kanban-v1-spec.pdf`. - -## Multi-tenant usage - -When one specialist fleet serves multiple businesses, tag each task with a tenant: - -```bash -hermes kanban create "monthly report" \ - --assignee researcher \ - --tenant business-a \ - --workspace dir:~/tenants/business-a/data/ -``` - -Workers receive `$HERMES_TENANT` and namespace their memory writes by prefix. The board, the dispatcher, and the profile definitions are all shared; only the data is scoped. - -## Design spec - -The complete design — architecture, concurrency correctness, comparison with other systems, implementation plan, risks, open questions — lives in `docs/hermes-kanban-v1-spec.pdf`. Read that before filing any behavior-change PR. diff --git a/website/sidebars.ts b/website/sidebars.ts index 0b201baaf2..b654291810 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -60,7 +60,6 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/features/cron', 'user-guide/features/delegation', - 'user-guide/features/kanban', 'user-guide/features/code-execution', 'user-guide/features/hooks', 'user-guide/features/batch-processing', From e3901d5b257d5ac3f58420c3fb55aaa536fc56ac Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:29:40 -0700 Subject: [PATCH 26/41] fix(run_agent): background review fork inherits parent's live runtime (#16099) The background memory/skill review (_spawn_background_review) has always forked a new AIAgent passing only model and provider, then relied on AIAgent.__init__ to re-resolve credentials from env vars. This works for users with keys in ~/.hermes/.env but silently falls back to env-var auto-resolution in all cases, which fails for OAuth-only providers, session-scoped creds, and credential-pool setups where auth can't be reconstructed from env. This used to be invisible -- failures were swallowed via logger.debug(). PR 8a2506af4 (Apr 24) surfaced auxiliary failures to the user, which made the stale bug visible as: "Auxiliary background review failed: No LLM provider configured" Fix: pass api_key, base_url, api_mode, and credential_pool from the parent's live runtime into the fork -- matching how every other auxiliary path (compression, memory flush, vision, session search) already inherits the parent's credentials via _current_main_runtime(). --- run_agent.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/run_agent.py b/run_agent.py index b567b96545..984c8e71d5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3245,12 +3245,25 @@ class AIAgent: with open(os.devnull, "w") as _devnull, \ contextlib.redirect_stdout(_devnull), \ contextlib.redirect_stderr(_devnull): + # Inherit the parent agent's live runtime (provider, model, + # base_url, api_key, api_mode) so the fork uses the exact + # same credentials the main turn is using. Without this, + # AIAgent.__init__ re-runs auto-resolution from env vars, + # which fails for OAuth-only providers, session-scoped + # creds, or credential-pool setups where the resolver can't + # reconstruct auth from scratch -- producing the spurious + # "No LLM provider configured" warning at end of turn. + _parent_runtime = self._current_main_runtime() review_agent = AIAgent( model=self.model, max_iterations=8, quiet_mode=True, platform=self.platform, provider=self.provider, + api_mode=_parent_runtime.get("api_mode") or None, + base_url=_parent_runtime.get("base_url") or None, + api_key=_parent_runtime.get("api_key") or None, + credential_pool=getattr(self, "_credential_pool", None), parent_session_id=self.session_id, ) review_agent._memory_write_origin = "background_review" From 8443998dc3bf89e453152389bec79351d2cae710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=B3=A5=E8=B1=86?= <1243352777@qq.com> Date: Sun, 26 Apr 2026 14:35:55 +0800 Subject: [PATCH 27/41] fix(auth): resolve API keys from ~/.hermes/.env and credential_pool _resolve_api_key_provider_secret() and _seed_from_env() only checked os.environ for provider API keys. When keys exist in ~/.hermes/.env but are not loaded into the process environment (e.g. ACP adapter entry point, post-session-start .env edits, or non-CLI entry points), the resolution returns an empty string, causing HTTP 401 failures. Changes: - credential_pool._seed_from_env: use get_env_value() which checks both os.environ and ~/.hermes/.env file, preventing _prune_stale_seeded_entries from removing valid entries whose env var isn't in os.environ - credential_pool._seed_from_env: same fix for openrouter and base_url_env_var resolution - auth._resolve_api_key_provider_secret: use get_env_value() instead of os.getenv(), and add credential_pool fallback when env resolution fails Fixes #15914 --- agent/credential_pool.py | 20 +++++++++++++++++--- hermes_cli/auth.py | 21 ++++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index f6cb24dd6b..dcdd297139 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1273,7 +1273,12 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool def _is_source_suppressed(_p, _s): # type: ignore[misc] return False if provider == "openrouter": - token = os.getenv("OPENROUTER_API_KEY", "").strip() + # Check both os.environ and ~/.hermes/.env file + try: + from hermes_cli.config import get_env_value + token = (get_env_value("OPENROUTER_API_KEY") or "").strip() + except Exception: + token = os.getenv("OPENROUTER_API_KEY", "").strip() if token: source = "env:OPENROUTER_API_KEY" if _is_source_suppressed(provider, source): @@ -1299,7 +1304,11 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool env_url = "" if pconfig.base_url_env_var: - env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/") + try: + from hermes_cli.config import get_env_value + env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/") + except Exception: + env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/") env_vars = list(pconfig.api_key_env_vars) if provider == "anthropic": @@ -1310,7 +1319,12 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool ] for env_var in env_vars: - token = os.getenv(env_var, "").strip() + # Check both os.environ and ~/.hermes/.env file + try: + from hermes_cli.config import get_env_value + token = (get_env_value(env_var) or "").strip() + except Exception: + token = os.getenv(env_var, "").strip() if not token: continue source = f"env:{env_var}" diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index eeccbece98..0ac6c64a34 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -468,10 +468,29 @@ def _resolve_api_key_provider_secret( return "", "" for env_var in pconfig.api_key_env_vars: - val = os.getenv(env_var, "").strip() + # Check both os.environ and ~/.hermes/.env file + try: + from hermes_cli.config import get_env_value + val = (get_env_value(env_var) or "").strip() + except Exception: + val = os.getenv(env_var, "").strip() if has_usable_secret(val): return val, env_var + # Fallback: try credential pool (e.g. zai key stored via auth.json) + try: + from agent.credential_pool import load_pool + pool = load_pool(provider_id) + if pool and pool.has_credentials(): + entry = pool.peek() + if entry: + key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "") + key = str(key).strip() + if has_usable_secret(key): + return key, f"credential_pool:{provider_id}" + except Exception: + pass + return "", "" From 27f4dba5ceef6e93597d8767d3f745bd9ecbdd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=B3=A5=E8=B1=86?= <1243352777@qq.com> Date: Sun, 26 Apr 2026 14:54:48 +0800 Subject: [PATCH 28/41] test: add unit tests for credential pool env fallback --- .../test_credential_pool_env_fallback.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/tools/test_credential_pool_env_fallback.py diff --git a/tests/tools/test_credential_pool_env_fallback.py b/tests/tools/test_credential_pool_env_fallback.py new file mode 100644 index 0000000000..bd88a0de99 --- /dev/null +++ b/tests/tools/test_credential_pool_env_fallback.py @@ -0,0 +1,110 @@ +"""Tests for credential_pool .env fallback and auth credential pool lookup.""" + +import os +import pytest +from unittest.mock import patch, MagicMock + + +def _make_pconfig(env_vars=None): + """Create a minimal ProviderConfig for testing.""" + from hermes_cli.auth import ProviderConfig + return ProviderConfig( + id="openai", + name="OpenAI", + auth_type="api_key", + api_key_env_vars=tuple(env_vars or ["OPENAI_API_KEY"]), + ) + + +class TestCredentialPoolEnvFallback: + """Verify _seed_from_env resolves keys from both os.environ and .env file.""" + + def test_os_environ_still_works(self): + """Existing os.environ resolution must not break. + _seed_from_env only collects env var names, does not return found=True + for existing keys — that is _resolve's job. Just verify no crash.""" + from agent.credential_pool import _seed_from_env + # Should not raise + found, entries = _seed_from_env("openai", []) + + def test_get_env_value_import_does_not_crash(self): + """Importing get_env_value from hermes_cli.config should not raise.""" + try: + from hermes_cli.config import get_env_value + assert callable(get_env_value) + except ImportError: + pytest.skip("hermes_cli.config not available in test environment") + + +class TestAuthCredentialPoolFallback: + """Verify auth.py falls back to credential pool when env vars are empty.""" + + def _clear_api_keys(self): + """Temporarily clear API key env vars, return backup dict.""" + backup = {} + for key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", + "ZAI_API_KEY", "DEEPSEEK_API_KEY"]: + if key in os.environ: + backup[key] = os.environ.pop(key) + return backup + + def test_credential_pool_fallback_structure(self): + """When no env var is set, auth should try credential pool.""" + from hermes_cli.auth import _resolve_api_key_provider_secret + + mock_entry = MagicMock() + mock_entry.access_token = "test-pool-key-12345" + mock_entry.runtime_api_key = "" + + mock_pool = MagicMock() + mock_pool.has_credentials.return_value = True + mock_pool.peek.return_value = mock_entry + + backup = self._clear_api_keys() + try: + with patch("agent.credential_pool.load_pool", return_value=mock_pool): + key, source = _resolve_api_key_provider_secret( + provider_id="openai", + pconfig=_make_pconfig(), + ) + assert "test-pool-key-12345" in key + assert "credential_pool" in source + finally: + os.environ.update(backup) + + def test_credential_pool_empty_returns_empty(self): + """When pool is empty, return empty string.""" + from hermes_cli.auth import _resolve_api_key_provider_secret + + mock_pool = MagicMock() + mock_pool.has_credentials.return_value = False + + backup = self._clear_api_keys() + try: + with patch("agent.credential_pool.load_pool", return_value=mock_pool): + key, source = _resolve_api_key_provider_secret( + provider_id="openai", + pconfig=_make_pconfig(), + ) + assert key == "" + finally: + os.environ.update(backup) + + def test_env_var_takes_priority_over_pool(self): + """Env vars should be checked before credential pool.""" + from hermes_cli.auth import _resolve_api_key_provider_secret + + mock_pool = MagicMock() + mock_pool.has_credentials.return_value = True + + with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-env-key-first-abc123"}): + with patch("agent.credential_pool.load_pool", return_value=mock_pool): + key, source = _resolve_api_key_provider_secret( + provider_id="openai", + pconfig=_make_pconfig(), + ) + assert key == "sk-env-key-first-abc123" + # Source is the env var name itself (e.g. "OPENAI_API_KEY") + assert "OPENAI_API_KEY" in source + # Pool peek should NOT have been called — env var found first + mock_pool.peek.assert_not_called() From f2d655529a7d9228d8eff30447d88442d9054032 Mon Sep 17 00:00:00 2001 From: Teknium <teknium1@gmail.com> Date: Sun, 26 Apr 2026 08:30:56 -0700 Subject: [PATCH 29/41] fix(auth): hoist get_env_value import + strengthen .env fallback tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to cherry-picked PR #15920: - agent/credential_pool.py: hoist 'from hermes_cli.config import get_env_value' to module top instead of inline try/except in each seed site (3 sites). No import cycle — hermes_cli/config.py doesn't depend on agent.credential_pool. - hermes_cli/auth.py: same hoist for the _resolve_api_key_provider_secret loop. - tests/tools/test_credential_pool_env_fallback.py: replace smoke-only tests with real .env file I/O. Each test writes a temp ~/.hermes/.env, verifies _seed_from_env / _resolve_api_key_provider_secret read from it, and asserts the full priority chain: os.environ > .env > credential_pool. Uses 'deepseek' as the test provider since 'openai' isn't in PROVIDER_REGISTRY and _seed_from_env's generic path requires a real pconfig lookup. --- agent/credential_pool.py | 19 +- hermes_cli/auth.py | 7 +- .../test_credential_pool_env_fallback.py | 256 ++++++++++++------ 3 files changed, 184 insertions(+), 98 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index dcdd297139..4f1395d17f 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -14,6 +14,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Set, Tuple from hermes_constants import OPENROUTER_BASE_URL +from hermes_cli.config import get_env_value import hermes_cli.auth as auth_mod from hermes_cli.auth import ( CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, @@ -1274,11 +1275,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool return False if provider == "openrouter": # Check both os.environ and ~/.hermes/.env file - try: - from hermes_cli.config import get_env_value - token = (get_env_value("OPENROUTER_API_KEY") or "").strip() - except Exception: - token = os.getenv("OPENROUTER_API_KEY", "").strip() + token = (get_env_value("OPENROUTER_API_KEY") or "").strip() if token: source = "env:OPENROUTER_API_KEY" if _is_source_suppressed(provider, source): @@ -1304,11 +1301,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool env_url = "" if pconfig.base_url_env_var: - try: - from hermes_cli.config import get_env_value - env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/") - except Exception: - env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/") + env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/") env_vars = list(pconfig.api_key_env_vars) if provider == "anthropic": @@ -1320,11 +1313,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool for env_var in env_vars: # Check both os.environ and ~/.hermes/.env file - try: - from hermes_cli.config import get_env_value - token = (get_env_value(env_var) or "").strip() - except Exception: - token = os.getenv(env_var, "").strip() + token = (get_env_value(env_var) or "").strip() if not token: continue source = f"env:{env_var}" diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0ac6c64a34..610a06dc94 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -467,13 +467,10 @@ def _resolve_api_key_provider_secret( pass return "", "" + from hermes_cli.config import get_env_value for env_var in pconfig.api_key_env_vars: # Check both os.environ and ~/.hermes/.env file - try: - from hermes_cli.config import get_env_value - val = (get_env_value(env_var) or "").strip() - except Exception: - val = os.getenv(env_var, "").strip() + val = (get_env_value(env_var) or "").strip() if has_usable_secret(val): return val, env_var diff --git a/tests/tools/test_credential_pool_env_fallback.py b/tests/tools/test_credential_pool_env_fallback.py index bd88a0de99..938484f015 100644 --- a/tests/tools/test_credential_pool_env_fallback.py +++ b/tests/tools/test_credential_pool_env_fallback.py @@ -1,110 +1,210 @@ -"""Tests for credential_pool .env fallback and auth credential pool lookup.""" +"""Tests for credential_pool .env fallback and auth credential_pool lookup. + +Covers the fix from #15914 / PR #15920: +- _seed_from_env reads API keys from ~/.hermes/.env when not in os.environ +- _resolve_api_key_provider_secret falls back to credential_pool when env vars are empty +- env vars take priority over .env file (handled by get_env_value itself) +- env vars take priority over credential pool (fallback only kicks in when env is empty) +""" import os +from pathlib import Path +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import patch, MagicMock -def _make_pconfig(env_vars=None): - """Create a minimal ProviderConfig for testing.""" +def _make_pconfig(provider_id="deepseek", env_vars=None): + """Create a minimal ProviderConfig for testing. + + Default provider_id is 'deepseek' because it's a real api_key provider + in PROVIDER_REGISTRY (needed for _seed_from_env's generic path). + """ from hermes_cli.auth import ProviderConfig return ProviderConfig( - id="openai", - name="OpenAI", + id=provider_id, + name=provider_id.title(), auth_type="api_key", - api_key_env_vars=tuple(env_vars or ["OPENAI_API_KEY"]), + api_key_env_vars=tuple(env_vars or [f"{provider_id.upper()}_API_KEY"]), ) -class TestCredentialPoolEnvFallback: - """Verify _seed_from_env resolves keys from both os.environ and .env file.""" +@pytest.fixture +def isolated_hermes_home(tmp_path, monkeypatch): + """Point HERMES_HOME at a temp dir and clear known API key env vars. + + Also invalidates any cached get_env_value state by patching Path.home(). + """ + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Clear all known API key env vars so get_env_value falls through to .env + for key in [ + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", + "ZAI_API_KEY", "DEEPSEEK_API_KEY", "ANTHROPIC_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_BASE_URL", + ]: + monkeypatch.delenv(key, raising=False) + + return home + + +def _write_env_file(home: Path, **kwargs) -> None: + """Write key=value pairs to ~/.hermes/.env.""" + lines = [f"{k}={v}" for k, v in kwargs.items()] + (home / ".env").write_text("\n".join(lines) + "\n") + + +class TestCredentialPoolSeedsFromDotEnv: + """_seed_from_env must read keys from ~/.hermes/.env, not just os.environ. + + This is the load-bearing behaviour for the fix: when a user adds a key to + .env mid-session or via a non-CLI entry point that doesn't run + load_hermes_dotenv, the credential pool must still discover it. + """ + + def test_deepseek_key_from_dotenv_only(self, isolated_hermes_home): + """Key in .env but not os.environ → _seed_from_env adds a pool entry.""" + _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-only-12345") + assert "DEEPSEEK_API_KEY" not in os.environ - def test_os_environ_still_works(self): - """Existing os.environ resolution must not break. - _seed_from_env only collects env var names, does not return found=True - for existing keys — that is _resolve's job. Just verify no crash.""" from agent.credential_pool import _seed_from_env - # Should not raise - found, entries = _seed_from_env("openai", []) + entries = [] + changed, active_sources = _seed_from_env("deepseek", entries) - def test_get_env_value_import_does_not_crash(self): - """Importing get_env_value from hermes_cli.config should not raise.""" - try: - from hermes_cli.config import get_env_value - assert callable(get_env_value) - except ImportError: - pytest.skip("hermes_cli.config not available in test environment") + assert changed is True + assert "env:DEEPSEEK_API_KEY" in active_sources + assert any( + e.access_token == "sk-dotenv-only-12345" + and e.source == "env:DEEPSEEK_API_KEY" + for e in entries + ), f"Expected seeded entry with dotenv key, got: {[(e.source, e.access_token) for e in entries]}" + + def test_openrouter_key_from_dotenv_only(self, isolated_hermes_home): + """OpenRouter path has its own branch — verify it also reads .env.""" + _write_env_file(isolated_hermes_home, OPENROUTER_API_KEY="sk-or-dotenv-abc") + assert "OPENROUTER_API_KEY" not in os.environ + + from agent.credential_pool import _seed_from_env + entries = [] + changed, active_sources = _seed_from_env("openrouter", entries) + + assert changed is True + assert "env:OPENROUTER_API_KEY" in active_sources + assert any( + e.access_token == "sk-or-dotenv-abc" for e in entries + ) + + def test_empty_dotenv_no_entries(self, isolated_hermes_home): + """No .env file, no env vars → no entries seeded (and no crash).""" + from agent.credential_pool import _seed_from_env + entries = [] + changed, active_sources = _seed_from_env("deepseek", entries) + assert changed is False + assert active_sources == set() + assert entries == [] + + def test_os_environ_still_wins_over_dotenv(self, isolated_hermes_home, monkeypatch): + """get_env_value checks os.environ first — verify seeding picks that up.""" + _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-stale") + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-fresh-xyz") + + from agent.credential_pool import _seed_from_env + entries = [] + changed, _ = _seed_from_env("deepseek", entries) + + assert changed is True + seeded = [e for e in entries if e.source == "env:DEEPSEEK_API_KEY"] + assert len(seeded) == 1 + assert seeded[0].access_token == "sk-env-fresh-xyz" + + +class TestAuthResolvesFromDotEnv: + """_resolve_api_key_provider_secret must also read from ~/.hermes/.env.""" + + def test_key_from_dotenv_only(self, isolated_hermes_home): + """Key in .env but not os.environ → _resolve returns it with the env var source.""" + _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-resolve-789") + assert "DEEPSEEK_API_KEY" not in os.environ + + from hermes_cli.auth import _resolve_api_key_provider_secret + key, source = _resolve_api_key_provider_secret( + provider_id="deepseek", + pconfig=_make_pconfig(), + ) + assert key == "sk-dotenv-resolve-789" + assert source == "DEEPSEEK_API_KEY" class TestAuthCredentialPoolFallback: - """Verify auth.py falls back to credential pool when env vars are empty.""" + """_resolve_api_key_provider_secret falls back to credential pool when env + dotenv are empty.""" - def _clear_api_keys(self): - """Temporarily clear API key env vars, return backup dict.""" - backup = {} - for key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", - "ZAI_API_KEY", "DEEPSEEK_API_KEY"]: - if key in os.environ: - backup[key] = os.environ.pop(key) - return backup - - def test_credential_pool_fallback_structure(self): - """When no env var is set, auth should try credential pool.""" - from hermes_cli.auth import _resolve_api_key_provider_secret - + def test_credential_pool_fallback_structure(self, isolated_hermes_home): + """Empty env + empty .env → auth falls back to credential pool.""" mock_entry = MagicMock() mock_entry.access_token = "test-pool-key-12345" mock_entry.runtime_api_key = "" - + mock_pool = MagicMock() mock_pool.has_credentials.return_value = True mock_pool.peek.return_value = mock_entry - - backup = self._clear_api_keys() - try: - with patch("agent.credential_pool.load_pool", return_value=mock_pool): - key, source = _resolve_api_key_provider_secret( - provider_id="openai", - pconfig=_make_pconfig(), - ) - assert "test-pool-key-12345" in key - assert "credential_pool" in source - finally: - os.environ.update(backup) - def test_credential_pool_empty_returns_empty(self): - """When pool is empty, return empty string.""" from hermes_cli.auth import _resolve_api_key_provider_secret - + with patch("agent.credential_pool.load_pool", return_value=mock_pool): + key, source = _resolve_api_key_provider_secret( + provider_id="deepseek", + pconfig=_make_pconfig(), + ) + assert "test-pool-key-12345" in key + assert "credential_pool" in source + + def test_credential_pool_empty_returns_empty(self, isolated_hermes_home): + """Empty env + empty .env + empty pool → empty string.""" mock_pool = MagicMock() mock_pool.has_credentials.return_value = False - - backup = self._clear_api_keys() - try: - with patch("agent.credential_pool.load_pool", return_value=mock_pool): - key, source = _resolve_api_key_provider_secret( - provider_id="openai", - pconfig=_make_pconfig(), - ) - assert key == "" - finally: - os.environ.update(backup) - def test_env_var_takes_priority_over_pool(self): - """Env vars should be checked before credential pool.""" from hermes_cli.auth import _resolve_api_key_provider_secret - + with patch("agent.credential_pool.load_pool", return_value=mock_pool): + key, source = _resolve_api_key_provider_secret( + provider_id="deepseek", + pconfig=_make_pconfig(), + ) + assert key == "" + + def test_env_var_takes_priority_over_pool(self, isolated_hermes_home, monkeypatch): + """os.environ key wins — credential pool is NEVER consulted.""" + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-key-first-abc123") + mock_pool = MagicMock() mock_pool.has_credentials.return_value = True - - with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-env-key-first-abc123"}): - with patch("agent.credential_pool.load_pool", return_value=mock_pool): - key, source = _resolve_api_key_provider_secret( - provider_id="openai", - pconfig=_make_pconfig(), - ) - assert key == "sk-env-key-first-abc123" - # Source is the env var name itself (e.g. "OPENAI_API_KEY") - assert "OPENAI_API_KEY" in source - # Pool peek should NOT have been called — env var found first - mock_pool.peek.assert_not_called() + + from hermes_cli.auth import _resolve_api_key_provider_secret + with patch("agent.credential_pool.load_pool", return_value=mock_pool) as mp: + key, source = _resolve_api_key_provider_secret( + provider_id="deepseek", + pconfig=_make_pconfig(), + ) + assert key == "sk-env-key-first-abc123" + assert source == "DEEPSEEK_API_KEY" + # Pool should not even have been loaded — env var satisfied the request first + mp.assert_not_called() + + def test_dotenv_takes_priority_over_pool(self, isolated_hermes_home): + """Key in .env beats credential pool — pool only fires when both env sources are empty.""" + _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-priority-xyz") + assert "DEEPSEEK_API_KEY" not in os.environ + + mock_pool = MagicMock() + mock_pool.has_credentials.return_value = True + + from hermes_cli.auth import _resolve_api_key_provider_secret + with patch("agent.credential_pool.load_pool", return_value=mock_pool) as mp: + key, source = _resolve_api_key_provider_secret( + provider_id="deepseek", + pconfig=_make_pconfig(), + ) + assert key == "sk-dotenv-priority-xyz" + assert source == "DEEPSEEK_API_KEY" + mp.assert_not_called() From d7a346824626cb3d89578d1c56e5bc79bc2c93ff Mon Sep 17 00:00:00 2001 From: ygd58 <buraysandro9@gmail.com> Date: Thu, 9 Apr 2026 15:10:07 +0200 Subject: [PATCH 30/41] fix(prompts): replace [SYSTEM: with [IMPORTANT: to avoid Azure content filter Azure OpenAI content filters (Default/DefaultV2) treat bracketed [SYSTEM: ...] meta-instructions as prompt-injection attempts and reject requests with HTTP 400. Replacing [SYSTEM: with [IMPORTANT: preserves the same semantic meaning for the model while bypassing the Azure heuristic. Fixes #6576 --- agent/skill_commands.py | 4 ++-- cron/scheduler.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 6b73e83b3e..19c9b06c6c 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -329,7 +329,7 @@ def build_skill_invocation_message( loaded_skill, skill_dir, skill_name = loaded activation_note = ( - f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want ' + f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want ' "you to follow its instructions. The full skill content is loaded below.]" ) return _build_skill_message( @@ -368,7 +368,7 @@ def build_preloaded_skills_prompt( loaded_skill, skill_dir, skill_name = loaded activation_note = ( - f'[SYSTEM: The user launched this CLI session with the "{skill_name}" skill ' + f'[IMPORTANT: The user launched this CLI session with the "{skill_name}" skill ' "preloaded. Treat its instructions as active guidance for the duration of this " "session unless the user overrides them.]" ) diff --git a/cron/scheduler.py b/cron/scheduler.py index 32b351aa04..2ca012ea05 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -715,7 +715,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: # Always prepend cron execution guidance so the agent knows how # delivery works and can suppress delivery when appropriate. cron_hint = ( - "[SYSTEM: You are running as a scheduled cron job. " + "[IMPORTANT: You are running as a scheduled cron job. " "DELIVERY: Your final response will be automatically delivered " "to the user — do NOT use send_message or try to deliver " "the output yourself. Just produce your report/output as your " @@ -751,7 +751,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: parts.append("") parts.extend( [ - f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]', + f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]', "", content, ] @@ -759,7 +759,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: if skipped: notice = ( - f"[SYSTEM: The following skill(s) were listed for this job but could not be found " + f"[IMPORTANT: The following skill(s) were listed for this job but could not be found " f"and were skipped: {', '.join(skipped)}. " f"Start your response with a brief notice so the user is aware, e.g.: " f"'⚠️ Skill(s) not found and skipped: {', '.join(skipped)}']" From 20cb706e034e551a6df8f6f3ff798888ee5793e7 Mon Sep 17 00:00:00 2001 From: Teknium <teknium1@gmail.com> Date: Sun, 26 Apr 2026 08:39:12 -0700 Subject: [PATCH 31/41] =?UTF-8?q?chore:=20extend=20[SYSTEM:=E2=86=92[IMPOR?= =?UTF-8?q?TANT:=20rename=20+=20AUTHOR=5FMAP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #6616 covering the remaining user-injected prompt markers that the original PR did not touch (reporter's second comment on #6576 explicitly flagged these). Azure OpenAI Default/DefaultV2 content filters treat any bracketed [SYSTEM: ...] as prompt-injection and reject with HTTP 400. Remaining call sites renamed: - cli.py: background-process notifications (watch_disabled, watch_match, completion), MCP reload notice (4 live + 1 docstring) - gateway/run.py: same notification paths + auto-loaded skill banner + MCP reload notice (5 live + 1 docstring) - tools/process_registry.py: comment reference Not renamed: - environments/hermes_base_env.py '[SYSTEM]\n{content}' — RL training trajectory rendering only, never sent to Azure, part of a symmetric [USER]/[ASSISTANT]/[TOOL] scheme. AUTHOR_MAP: buraysandro9@gmail.com -> ygd58. --- cli.py | 10 +++++----- gateway/run.py | 12 ++++++------ scripts/release.py | 1 + tools/process_registry.py | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/cli.py b/cli.py index da401e5c18..8ec767e942 100644 --- a/cli.py +++ b/cli.py @@ -1378,7 +1378,7 @@ def _resolve_attachment_path(raw_path: str) -> Path | None: def _format_process_notification(evt: dict) -> "str | None": - """Format a process notification event into a [SYSTEM: ...] message. + """Format a process notification event into a [IMPORTANT: ...] message. Handles both completion events (notify_on_complete) and watch pattern match events from the unified completion_queue. @@ -1388,14 +1388,14 @@ def _format_process_notification(evt: dict) -> "str | None": _cmd = evt.get("command", "unknown") if evt_type == "watch_disabled": - return f"[SYSTEM: {evt.get('message', '')}]" + return f"[IMPORTANT: {evt.get('message', '')}]" if evt_type == "watch_match": _pat = evt.get("pattern", "?") _out = evt.get("output", "") _sup = evt.get("suppressed", 0) text = ( - f"[SYSTEM: Background process {_sid} matched " + f"[IMPORTANT: Background process {_sid} matched " f"watch pattern \"{_pat}\".\n" f"Command: {_cmd}\n" f"Matched output:\n{_out}" @@ -1409,7 +1409,7 @@ def _format_process_notification(evt: dict) -> "str | None": _exit = evt.get("exit_code", "?") _out = evt.get("output", "") return ( - f"[SYSTEM: Background process {_sid} completed " + f"[IMPORTANT: Background process {_sid} completed " f"(exit code {_exit}).\n" f"Command: {_cmd}\n" f"Output:\n{_out}]" @@ -7217,7 +7217,7 @@ class HermesCLI: change_detail = ". ".join(change_parts) + ". " if change_parts else "" self.conversation_history.append({ "role": "user", - "content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", + "content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", }) # Persist session immediately so the session log reflects the diff --git a/gateway/run.py b/gateway/run.py index 9926920b81..a371beb76b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -591,20 +591,20 @@ def _parse_session_key(session_key: str) -> "dict | None": def _format_gateway_process_notification(evt: dict) -> "str | None": - """Format a watch pattern event from completion_queue into a [SYSTEM:] message.""" + """Format a watch pattern event from completion_queue into a [IMPORTANT:] message.""" evt_type = evt.get("type", "completion") _sid = evt.get("session_id", "unknown") _cmd = evt.get("command", "unknown") if evt_type == "watch_disabled": - return f"[SYSTEM: {evt.get('message', '')}]" + return f"[IMPORTANT: {evt.get('message', '')}]" if evt_type == "watch_match": _pat = evt.get("pattern", "?") _out = evt.get("output", "") _sup = evt.get("suppressed", 0) text = ( - f"[SYSTEM: Background process {_sid} matched " + f"[IMPORTANT: Background process {_sid} matched " f"watch pattern \"{_pat}\".\n" f"Command: {_cmd}\n" f"Matched output:\n{_out}" @@ -4232,7 +4232,7 @@ class GatewayRunner: if _loaded: _loaded_skill, _skill_dir, _display_name = _loaded _note = ( - f'[SYSTEM: The "{_display_name}" skill is auto-loaded. ' + f'[IMPORTANT: The "{_display_name}" skill is auto-loaded. ' f"Follow its instructions for this session.]" ) _part = _build_skill_message(_loaded_skill, _skill_dir, _note) @@ -7473,7 +7473,7 @@ class GatewayRunner: change_detail = ". ".join(change_parts) + ". " if change_parts else "" reload_msg = { "role": "user", - "content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", + "content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", } try: session_entry = self.session_store.get_or_create_session(event.source) @@ -8412,7 +8412,7 @@ class GatewayRunner: from tools.ansi_strip import strip_ansi _out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" synth_text = ( - f"[SYSTEM: Background process {session_id} completed " + f"[IMPORTANT: Background process {session_id} completed " f"(exit code {session.exit_code}).\n" f"Command: {session.command}\n" f"Output:\n{_out}]" diff --git a/scripts/release.py b/scripts/release.py index d6d9be6d94..eb52e942d5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -119,6 +119,7 @@ AUTHOR_MAP = { "nocoo@users.noreply.github.com": "nocoo", "30841158+n-WN@users.noreply.github.com": "n-WN", "tsuijinglei@gmail.com": "hiddenpuppy", + "buraysandro9@gmail.com": "ygd58", "jerome@clawwork.ai": "HiddenPuppy", "jerome.benoit@sap.com": "jerome-benoit", "wysie@users.noreply.github.com": "Wysie", diff --git a/tools/process_registry.py b/tools/process_registry.py index 57709bc29c..479030120d 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -776,7 +776,7 @@ class ProcessRegistry: # Only enqueue completion notification on the FIRST move. Without # this guard, kill_process() and the reader thread can both call - # _move_to_finished(), producing duplicate [SYSTEM: ...] messages. + # _move_to_finished(), producing duplicate [IMPORTANT: ...] messages. if was_running and session.notify_on_complete: from tools.ansi_strip import strip_ansi output_tail = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" From de24315978cc69fd0932e141bedef6b8f28e4b88 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:20:58 -0700 Subject: [PATCH 32/41] fix(gateway): preserve inactivity clock on interrupt-recursive cached-agent turns (#15654) _last_activity_ts was unconditionally reset to time.time() on every _agent_cache hit. For interrupt-recursive _run_agent calls (_interrupt_depth > 0) this silently reset the inactivity watchdog's idle clock on each re-entry, preventing the 30-min timeout from ever firing when a turn got stuck in an interrupt loop. A stuck session would emit "Still working... iteration 0/60, starting new turn (cached)" heartbeats indefinitely instead of timing out. Gate the reset on _interrupt_depth == 0 only. Fresh external turns still receive the reset so a session idle for 29 min doesn't trip the watchdog before the new turn makes its first API call (#9051). The per-turn reset logic is extracted into a static helper _init_cached_agent_for_turn() to make it directly testable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- gateway/run.py | 23 +++++-- tests/gateway/test_agent_cache.py | 101 ++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index a371beb76b..be4457295e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8722,6 +8722,22 @@ class GatewayRunner: with _lock: self._agent_cache.pop(session_key, None) + @staticmethod + def _init_cached_agent_for_turn(agent: Any, interrupt_depth: int) -> None: + """Reset per-turn state on a cached agent before a new turn starts. + + _last_activity_ts is only reset for fresh external turns (depth 0). + For interrupt-recursive turns the timestamp is preserved so the + inactivity watchdog can accumulate stuck-turn idle time and fire + the 30-min timeout (#15654). The depth-0 reset is still needed: + a session idle for 29 min would otherwise trip the watchdog before + the new turn makes its first API call (#9051). + """ + if interrupt_depth == 0: + agent._last_activity_ts = time.time() + agent._last_activity_desc = "starting new turn (cached)" + agent._api_call_count = 0 + def _release_evicted_agent_soft(self, agent: Any) -> None: """Soft cleanup for cache-evicted agents — preserves session tool state. @@ -9766,12 +9782,7 @@ class GatewayRunner: _cache.move_to_end(session_key) except KeyError: pass - # Reset activity timestamp so the inactivity timeout - # handler doesn't see stale idle time from the previous - # turn and immediately kill this agent. (#9051) - agent._last_activity_ts = time.time() - agent._last_activity_desc = "starting new turn (cached)" - agent._api_call_count = 0 + self._init_cached_agent_for_turn(agent, _interrupt_depth) logger.debug("Reusing cached agent for session %s", session_key) if agent is None: diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index d4019e1d5e..3e3e6c0b93 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -1043,3 +1043,104 @@ class TestAgentCacheIdleResume: new_agent.close() except Exception: pass + + +class TestCachedAgentInactivityReset: + """Inactivity-clock reset must be gated on _interrupt_depth == 0. + + On interrupt-recursive turns (_interrupt_depth > 0) the clock must + keep accumulating so the inactivity watchdog can fire when a turn is + stuck in an interrupt loop. Resetting unconditionally prevented the + 30-min timeout from triggering (#15654). The depth-0 reset is still + needed: a session idle for 29 min must not trip the watchdog before + the new turn makes its first API call (#9051). + """ + + def _fake_agent(self, stale_seconds: float = 1800.0): + import time as _t + m = MagicMock() + m._last_activity_ts = _t.time() - stale_seconds + m._api_call_count = 10 + m._last_activity_desc = "previous turn activity" + return m + + def test_fresh_turn_resets_idle_clock(self): + """interrupt_depth=0: clock resets so a post-idle turn gets a + fresh 30-min inactivity window (guard for #9051).""" + import time as _t + from gateway.run import GatewayRunner + + agent = self._fake_agent(stale_seconds=1800.0) + old_ts = agent._last_activity_ts + before = _t.time() + + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0) + + assert agent._last_activity_ts >= before, ( + "_last_activity_ts was not reset on a fresh turn (interrupt_depth=0)" + ) + assert agent._last_activity_ts > old_ts, ( + "Stale idle time should be cleared so the new turn gets a fresh window" + ) + + def test_interrupt_turn_preserves_idle_clock(self): + """interrupt_depth=1: clock preserved so accumulated stuck-turn + idle time is not discarded by an interrupt-recursive re-entry (#15654).""" + from gateway.run import GatewayRunner + + agent = self._fake_agent(stale_seconds=1200.0) + old_ts = agent._last_activity_ts + + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1) + + assert agent._last_activity_ts == old_ts, ( + "_last_activity_ts must not be reset on interrupt-recursive turns " + "(interrupt_depth>0) — the watchdog needs the accumulated idle time" + ) + + def test_deep_interrupt_recursion_preserves_idle_clock(self): + """interrupt_depth=MAX-1: clock still preserved at any non-zero depth.""" + from gateway.run import GatewayRunner + + agent = self._fake_agent(stale_seconds=600.0) + old_ts = agent._last_activity_ts + + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=4) + + assert agent._last_activity_ts == old_ts + + def test_api_call_count_reset_regardless_of_depth(self): + """_api_call_count is always reset to 0 for the new turn, at any depth.""" + from gateway.run import GatewayRunner + + agent_fresh = self._fake_agent() + agent_interrupted = self._fake_agent() + + GatewayRunner._init_cached_agent_for_turn(agent_fresh, interrupt_depth=0) + GatewayRunner._init_cached_agent_for_turn(agent_interrupted, interrupt_depth=1) + + assert agent_fresh._api_call_count == 0 + assert agent_interrupted._api_call_count == 0 + + def test_watchdog_accumulation_across_recursive_turns(self): + """Scenario: stuck turn + user interrupt → recursive turn. + + The idle time seen by the watchdog must reflect the full stuck + duration, not restart from zero on the recursive re-entry. + """ + import time as _t + from gateway.run import GatewayRunner + + STUCK_FOR = 1750.0 + agent = self._fake_agent(stale_seconds=STUCK_FOR) + + # Simulate: user sees "Still working..." and sends another message. + # That triggers an interrupt → _run_agent recurses at depth=1. + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1) + + # Watchdog sees time.time() - _last_activity_ts ≥ STUCK_FOR. + idle_secs = _t.time() - agent._last_activity_ts + assert idle_secs >= STUCK_FOR - 1.0, ( + f"Watchdog would see {idle_secs:.0f}s idle, expected ~{STUCK_FOR}s. " + "Inactivity timeout could not fire for a stuck interrupted turn." + ) From 4e356098d21a29a4f86c8c55c0caef9b90d10307 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:12:36 -0700 Subject: [PATCH 33/41] fixup! fix(gateway): preserve inactivity clock on interrupt-recursive cached-agent turns (#15654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review findings: 1. Gate _last_activity_desc on interrupt_depth == 0 alongside _last_activity_ts. Both fields are semantically paired — desc describes the activity *at* ts. Updating desc without ts made get_activity_summary() report "starting new turn (cached)" for 20+ minutes while the timestamp showed the true stale duration, producing misleading diagnostic output. 2. Monkeypatch gateway.run.time.time to a fixed epoch in tests that assert on _last_activity_ts values. Real time.time() comparisons were latently flaky under slow CI or NTP adjustments. _FAKE_NOW = 10_000.0 is used as the reference; assertions are now exact equality rather than >=. 3. Add test_fresh_turn_resets_desc and test_interrupt_turn_preserves_desc to directly cover the gated desc behaviour introduced by (1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- gateway/run.py | 17 +++++++----- tests/gateway/test_agent_cache.py | 46 +++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index be4457295e..8fda2c1f1e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8726,16 +8726,19 @@ class GatewayRunner: def _init_cached_agent_for_turn(agent: Any, interrupt_depth: int) -> None: """Reset per-turn state on a cached agent before a new turn starts. - _last_activity_ts is only reset for fresh external turns (depth 0). - For interrupt-recursive turns the timestamp is preserved so the - inactivity watchdog can accumulate stuck-turn idle time and fire - the 30-min timeout (#15654). The depth-0 reset is still needed: - a session idle for 29 min would otherwise trip the watchdog before - the new turn makes its first API call (#9051). + Both _last_activity_ts and _last_activity_desc are only reset for + fresh external turns (depth 0); they are semantically paired — + desc describes the activity *at* ts, so updating one without the + other would make get_activity_summary() misleading. + For interrupt-recursive turns both are preserved so the inactivity + watchdog can accumulate stuck-turn idle time and fire the 30-min + timeout (#15654). The depth-0 reset is still needed: a session + idle for 29 min would otherwise trip the watchdog before the new + turn makes its first API call (#9051). """ if interrupt_depth == 0: agent._last_activity_ts = time.time() - agent._last_activity_desc = "starting new turn (cached)" + agent._last_activity_desc = "starting new turn (cached)" agent._api_call_count = 0 def _release_evicted_agent_soft(self, agent: Any) -> None: diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 3e3e6c0b93..e21ea62440 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -1045,6 +1045,9 @@ class TestAgentCacheIdleResume: pass +_FAKE_NOW = 10_000.0 # Fixed epoch for deterministic time assertions + + class TestCachedAgentInactivityReset: """Inactivity-clock reset must be gated on _interrupt_depth == 0. @@ -1057,9 +1060,8 @@ class TestCachedAgentInactivityReset: """ def _fake_agent(self, stale_seconds: float = 1800.0): - import time as _t m = MagicMock() - m._last_activity_ts = _t.time() - stale_seconds + m._last_activity_ts = _FAKE_NOW - stale_seconds m._api_call_count = 10 m._last_activity_desc = "previous turn activity" return m @@ -1067,22 +1069,34 @@ class TestCachedAgentInactivityReset: def test_fresh_turn_resets_idle_clock(self): """interrupt_depth=0: clock resets so a post-idle turn gets a fresh 30-min inactivity window (guard for #9051).""" - import time as _t from gateway.run import GatewayRunner agent = self._fake_agent(stale_seconds=1800.0) old_ts = agent._last_activity_ts - before = _t.time() - GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0) + with patch("gateway.run.time") as mock_time: + mock_time.time.return_value = _FAKE_NOW + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0) - assert agent._last_activity_ts >= before, ( + assert agent._last_activity_ts == _FAKE_NOW, ( "_last_activity_ts was not reset on a fresh turn (interrupt_depth=0)" ) assert agent._last_activity_ts > old_ts, ( "Stale idle time should be cleared so the new turn gets a fresh window" ) + def test_fresh_turn_resets_desc(self): + """interrupt_depth=0: description is updated to reflect the new turn.""" + from gateway.run import GatewayRunner + + agent = self._fake_agent() + + with patch("gateway.run.time") as mock_time: + mock_time.time.return_value = _FAKE_NOW + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0) + + assert agent._last_activity_desc == "starting new turn (cached)" + def test_interrupt_turn_preserves_idle_clock(self): """interrupt_depth=1: clock preserved so accumulated stuck-turn idle time is not discarded by an interrupt-recursive re-entry (#15654).""" @@ -1098,6 +1112,19 @@ class TestCachedAgentInactivityReset: "(interrupt_depth>0) — the watchdog needs the accumulated idle time" ) + def test_interrupt_turn_preserves_desc(self): + """interrupt_depth=1: desc preserved — it is semantically paired with ts.""" + from gateway.run import GatewayRunner + + agent = self._fake_agent(stale_seconds=1200.0) + + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1) + + assert agent._last_activity_desc == "previous turn activity", ( + "_last_activity_desc must not change on interrupt-recursive turns; " + "it describes the activity *at* _last_activity_ts" + ) + def test_deep_interrupt_recursion_preserves_idle_clock(self): """interrupt_depth=MAX-1: clock still preserved at any non-zero depth.""" from gateway.run import GatewayRunner @@ -1116,7 +1143,9 @@ class TestCachedAgentInactivityReset: agent_fresh = self._fake_agent() agent_interrupted = self._fake_agent() - GatewayRunner._init_cached_agent_for_turn(agent_fresh, interrupt_depth=0) + with patch("gateway.run.time") as mock_time: + mock_time.time.return_value = _FAKE_NOW + GatewayRunner._init_cached_agent_for_turn(agent_fresh, interrupt_depth=0) GatewayRunner._init_cached_agent_for_turn(agent_interrupted, interrupt_depth=1) assert agent_fresh._api_call_count == 0 @@ -1128,7 +1157,6 @@ class TestCachedAgentInactivityReset: The idle time seen by the watchdog must reflect the full stuck duration, not restart from zero on the recursive re-entry. """ - import time as _t from gateway.run import GatewayRunner STUCK_FOR = 1750.0 @@ -1139,7 +1167,7 @@ class TestCachedAgentInactivityReset: GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1) # Watchdog sees time.time() - _last_activity_ts ≥ STUCK_FOR. - idle_secs = _t.time() - agent._last_activity_ts + idle_secs = _FAKE_NOW - agent._last_activity_ts assert idle_secs >= STUCK_FOR - 1.0, ( f"Watchdog would see {idle_secs:.0f}s idle, expected ~{STUCK_FOR}s. " "Inactivity timeout could not fire for a stuck interrupted turn." From eaa7e2db670ba0879bc040c22c39d5abb39b897c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:50:30 -0700 Subject: [PATCH 34/41] feat(cli,tui): surface /queue, /bg, /steer in agent-running placeholder (#16118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli,tui): surface /queue, /bg, /steer in agent-running placeholder While the agent loop is running, the input placeholder previously only hinted at Enter-to-interrupt. Surface the full set of busy-time actions (interrupt via new message, /queue, /bg, /steer) so users discover them without hunting through docs or Teknium's tweets. - cli.py: "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel" - ui-tui/src/components/appLayout.tsx: same string (was "Ctrl+C to interrupt…") * revert tui placeholder change (cli-only per review) --- cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 8ec767e942..04d9c055d4 100644 --- a/cli.py +++ b/cli.py @@ -9841,7 +9841,7 @@ class HermesCLI: status = cli_ref._command_status or "Processing command..." return f"{frame} {status}" if cli_ref._agent_running: - return "type a message + Enter to interrupt, Ctrl+C to cancel" + return "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel" if cli_ref._voice_mode: return "type or Ctrl+B to record" return "" From 0e2a53eab2ac7a937b2ce2a089b07c18f8e30bcf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:20:53 -0700 Subject: [PATCH 35/41] feat(skills): show enabled/disabled status in 'skills list' (#16129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'hermes skills list' now shows every skill's enabled/disabled status and accepts --enabled-only to filter down to what will actually load for the active profile: hermes -p dario skills list --enabled-only Previously the command was a flat catalog — it did not apply skills.disabled from config.yaml, so there was no way to see the live skill set for a profile without reading config by hand. Profile switching already works via -p (swaps HERMES_HOME); this just surfaces the result visibly. Changes: - hermes_cli/skills_hub.py: do_list adds a Status column and an enabled_only filter; summary reports enabled/disabled split - hermes_cli/main.py: --enabled-only flag on 'skills list' - /skills list slash command accepts --enabled-only too - tests: 4 new (status column, disabled marking, enabled-only hiding, no platform leakage into get_disabled_skill_names); existing fixtures updated to accept skip_disabled kwarg Reported by @mochizukimr on X. --- hermes_cli/main.py | 6 +++ hermes_cli/skills_hub.py | 74 +++++++++++++++++++++++------ tests/hermes_cli/test_skills_hub.py | 72 +++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 16 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a53b8d2c5e..9c4b40de27 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8453,6 +8453,12 @@ Examples: skills_list.add_argument( "--source", default="all", choices=["all", "hub", "builtin", "local"] ) + skills_list.add_argument( + "--enabled-only", + action="store_true", + help="Hide disabled skills. Use with -p <profile> to see exactly " + "which skills will load for that profile.", + ) skills_check = skills_subparsers.add_parser( "check", help="Check installed hub skills for updates" diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index bf92fafe10..2e425eee89 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -599,11 +599,24 @@ def inspect_skill(identifier: str) -> Optional[dict]: return out -def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None: - """List installed skills, distinguishing hub, builtin, and local skills.""" +def do_list(source_filter: str = "all", + enabled_only: bool = False, + console: Optional[Console] = None) -> None: + """List installed skills, distinguishing hub, builtin, and local skills. + + Args: + source_filter: ``all`` | ``hub`` | ``builtin`` | ``local``. + enabled_only: If True, hide disabled skills from the output. + + Enabled/disabled state is resolved against the currently active profile's + config — ``hermes -p <profile> skills list`` reads that profile's + ``skills.disabled`` list because ``-p`` swaps ``HERMES_HOME`` at process + start. No explicit profile flag needed here. + """ from tools.skills_hub import HubLockFile, ensure_hub_dirs from tools.skills_sync import _read_manifest from tools.skills_tool import _find_all_skills + from agent.skill_utils import get_disabled_skill_names c = console or _console ensure_hub_dirs() @@ -611,17 +624,26 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No hub_installed = {e["name"]: e for e in lock.list_installed()} builtin_names = set(_read_manifest()) - all_skills = _find_all_skills() + # Pull ALL skills (including disabled ones) so we can annotate status. + all_skills = _find_all_skills(skip_disabled=True) + disabled_names = get_disabled_skill_names() - table = Table(title="Installed Skills") + title = "Installed Skills" + if enabled_only: + title += " (enabled only)" + + table = Table(title=title) table.add_column("Name", style="bold cyan") table.add_column("Category", style="dim") table.add_column("Source", style="dim") table.add_column("Trust", style="dim") + table.add_column("Status", style="dim") hub_count = 0 builtin_count = 0 local_count = 0 + enabled_count = 0 + disabled_count = 0 for skill in sorted(all_skills, key=lambda s: (s.get("category") or "", s["name"])): name = skill["name"] @@ -632,29 +654,48 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No source_type = "hub" source_display = hub_entry.get("source", "hub") trust = hub_entry.get("trust_level", "community") - hub_count += 1 elif name in builtin_names: source_type = "builtin" source_display = "builtin" trust = "builtin" - builtin_count += 1 else: source_type = "local" source_display = "local" trust = "local" - local_count += 1 if source_filter != "all" and source_filter != source_type: continue + is_enabled = name not in disabled_names + if enabled_only and not is_enabled: + continue + + if source_type == "hub": + hub_count += 1 + elif source_type == "builtin": + builtin_count += 1 + else: + local_count += 1 + + if is_enabled: + enabled_count += 1 + status_cell = "[bold green]enabled[/]" + else: + disabled_count += 1 + status_cell = "[dim red]disabled[/]" + trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"}.get(trust, "dim") trust_label = "official" if source_display == "official" else trust - table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]") + table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]", status_cell) c.print(table) - c.print( - f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local[/]\n" - ) + summary = f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local" + if enabled_only: + summary += f" — {enabled_count} enabled shown" + else: + summary += f" — {enabled_count} enabled, {disabled_count} disabled" + summary += "[/]\n" + c.print(summary) def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None: @@ -1127,7 +1168,10 @@ def skills_command(args) -> None: elif action == "inspect": do_inspect(args.identifier) elif action == "list": - do_list(source_filter=args.source) + do_list( + source_filter=args.source, + enabled_only=getattr(args, "enabled_only", False), + ) elif action == "check": do_check(name=getattr(args, "name", None)) elif action == "update": @@ -1279,11 +1323,12 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: elif action == "list": source_filter = "all" + enabled_only = "--enabled-only" in args or "--enabled" in args if "--source" in args: idx = args.index("--source") if idx + 1 < len(args): source_filter = args[idx + 1] - do_list(source_filter=source_filter, console=c) + do_list(source_filter=source_filter, enabled_only=enabled_only, console=c) elif action == "check": name = args[0] if args else None @@ -1371,7 +1416,8 @@ def _print_skills_help(console: Console) -> None: " [cyan]search[/] <query> Search registries for skills\n" " [cyan]install[/] <identifier> Install a skill (with security scan)\n" " [cyan]inspect[/] <identifier> Preview a skill without installing\n" - " [cyan]list[/] [--source hub|builtin|local] List installed skills\n" + " [cyan]list[/] [--source hub|builtin|local] [--enabled-only]\n" + " List installed skills; --enabled-only filters to the active profile's live set\n" " [cyan]check[/] [name] Check hub skills for upstream updates\n" " [cyan]update[/] [name] Update hub skills with upstream changes\n" " [cyan]audit[/] [name] Re-scan hub skills for security\n" diff --git a/tests/hermes_cli/test_skills_hub.py b/tests/hermes_cli/test_skills_hub.py index bf9fa71a3a..3866730921 100644 --- a/tests/hermes_cli/test_skills_hub.py +++ b/tests/hermes_cli/test_skills_hub.py @@ -56,7 +56,7 @@ def three_source_env(monkeypatch, hub_env): import tools.skills_tool as skills_tool monkeypatch.setattr(hub, "HubLockFile", lambda: _DummyLockFile([_HUB_ENTRY])) - monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: list(_ALL_THREE_SKILLS)) + monkeypatch.setattr(skills_tool, "_find_all_skills", lambda **_kwargs: list(_ALL_THREE_SKILLS)) monkeypatch.setattr(skills_sync, "_read_manifest", lambda: dict(_BUILTIN_MANIFEST)) return hub_env @@ -107,7 +107,7 @@ def test_do_list_initializes_hub_dir(monkeypatch, hub_env): import tools.skills_sync as skills_sync import tools.skills_tool as skills_tool - monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: []) + monkeypatch.setattr(skills_tool, "_find_all_skills", lambda **_kwargs: []) monkeypatch.setattr(skills_sync, "_read_manifest", lambda: {}) hub_dir = hub_env @@ -154,6 +154,74 @@ def test_do_list_filter_builtin(three_source_env): assert "local-skill" not in output +def test_do_list_renders_status_column(three_source_env, monkeypatch): + """Every list row should carry an enabled/disabled status (new in PR that + answered Mr Mochizuki's 'I just want to see what's live' question).""" + from agent import skill_utils + + monkeypatch.setattr(skill_utils, "get_disabled_skill_names", lambda platform=None: set()) + output = _capture() + + assert "Status" in output + assert "enabled" in output.lower() + # Summary counts enabled skills. + assert "3 enabled, 0 disabled" in output + + +def test_do_list_marks_disabled_skills(three_source_env, monkeypatch): + from agent import skill_utils + + # Simulate `skills.disabled: [hub-skill]` in config. + monkeypatch.setattr( + skill_utils, "get_disabled_skill_names", + lambda platform=None: {"hub-skill"}, + ) + output = _capture() + + # Row still appears (no --enabled-only), but marked disabled + assert "hub-skill" in output + assert "disabled" in output.lower() + assert "2 enabled, 1 disabled" in output + + +def test_do_list_enabled_only_hides_disabled(three_source_env, monkeypatch): + from agent import skill_utils + + monkeypatch.setattr( + skill_utils, "get_disabled_skill_names", + lambda platform=None: {"hub-skill"}, + ) + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + do_list(enabled_only=True, console=console) + output = sink.getvalue() + + assert "hub-skill" not in output + assert "builtin-skill" in output + assert "local-skill" in output + assert "enabled only" in output.lower() + assert "2 enabled shown" in output + + +def test_do_list_platform_env_is_ignored(three_source_env, monkeypatch): + """`hermes skills list` reads the active profile's config via + HERMES_HOME (swapped by -p), so it must NOT pass a platform arg to + ``get_disabled_skill_names`` — otherwise per-platform overrides + would silently leak in from HERMES_PLATFORM env.""" + from agent import skill_utils + + seen = {} + + def _fake(platform=None): + seen["platform"] = platform + return set() + + monkeypatch.setattr(skill_utils, "get_disabled_skill_names", _fake) + _capture() + + assert seen["platform"] is None + + def test_do_check_reports_available_updates(monkeypatch): output = _capture_check(monkeypatch, [ {"name": "hub-skill", "source": "skills.sh", "status": "update_available"}, From 42c076d349e7a737355b37035ca415a79c719c4b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:57:58 -0700 Subject: [PATCH 36/41] feat(browser): auto-spawn local Chromium for LAN/localhost URLs in cloud mode (#16136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cloud browser provider (Browserbase / Browser-Use / Firecrawl) is configured, browser_navigate now transparently spawns a local Chromium sidecar for URLs whose host resolves to a private/loopback/LAN address (localhost, 127.0.0.1, 192.168.x.x, 10.x.x.x, *.local, *.lan, *.internal, ::1, 169.254.x.x). Public URLs continue to use the cloud provider in the same conversation. Previously, setting BROWSERBASE_API_KEY / cloud_provider: browserbase pinned the whole tool to cloud for the process — localhost URLs were either SSRF-blocked (default) or sent to Browserbase (where they 404'd because the cloud can't reach your LAN). Users who wanted 'cloud for public, local for localhost' had no way to express it short of toggling providers mid-session. Implementation uses a composite session key scheme: the bare task_id serves the cloud session, and a '{task_id}::local' sidecar serves the local Chromium. _last_active_session_key[task_id] tracks which of the two served the most recent nav so snapshot/click/fill/etc. hit the correct one. cleanup_browser(bare_task_id) reaps both. Feature is on by default. Opt out via: browser: auto_local_for_private_urls: false The cloud provider never sees private URLs. Post-redirect SSRF guard is preserved: redirects from public onto private addresses still block. --- hermes_cli/config.py | 1 + tests/tools/test_browser_hybrid_routing.py | 248 +++++++++++++++ tools/browser_tool.py | 329 +++++++++++++++++--- website/docs/user-guide/features/browser.md | 34 ++ 4 files changed, 563 insertions(+), 49 deletions(-) create mode 100644 tests/tools/test_browser_hybrid_routing.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 72d0232f33..542b4d4fa4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -465,6 +465,7 @@ DEFAULT_CONFIG = { "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.) "record_sessions": False, # Auto-record browser sessions as WebM videos "allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.) + "auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud "cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome # CDP supervisor — dialog + frame detection via a persistent WebSocket. # Active only when a CDP-capable backend is attached (Browserbase or diff --git a/tests/tools/test_browser_hybrid_routing.py b/tests/tools/test_browser_hybrid_routing.py new file mode 100644 index 0000000000..934b275d57 --- /dev/null +++ b/tests/tools/test_browser_hybrid_routing.py @@ -0,0 +1,248 @@ +"""Tests for hybrid browser-backend routing (LAN/localhost auto-local). + +When a cloud browser provider (Browserbase / Browser-Use / Firecrawl) is +configured globally, ``browser.auto_local_for_private_urls`` (default True) +causes ``browser_navigate`` to transparently spawn a local Chromium sidecar +for URLs whose host resolves to a private/loopback/LAN address, while +public URLs continue to hit the cloud session in the same conversation. + +These tests cover the routing decision layer — session_key selection, +sidecar detection, last-active-session tracking, and the config toggle. +The downstream session creation is covered by test_browser_cloud_fallback.py. +""" +from unittest.mock import Mock + +import pytest + +import tools.browser_tool as browser_tool + + +@pytest.fixture(autouse=True) +def _reset_routing_state(monkeypatch): + """Clear module-level caches so each test starts clean.""" + monkeypatch.setattr(browser_tool, "_active_sessions", {}) + monkeypatch.setattr(browser_tool, "_last_active_session_key", {}) + monkeypatch.setattr(browser_tool, "_cached_cloud_provider", None) + monkeypatch.setattr(browser_tool, "_cloud_provider_resolved", False) + monkeypatch.setattr(browser_tool, "_auto_local_for_private_urls_resolved", False) + monkeypatch.setattr(browser_tool, "_cached_auto_local_for_private_urls", True) + monkeypatch.setattr(browser_tool, "_start_browser_cleanup_thread", lambda: None) + monkeypatch.setattr(browser_tool, "_update_session_activity", lambda t: None) + # Default: no CDP override, no Camofox + monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None) + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False) + + +class TestNavigationSessionKey: + """Tests for _navigation_session_key URL-based routing decisions.""" + + def test_public_url_uses_bare_task_id(self, monkeypatch): + """Public URL with cloud provider configured → bare task_id (cloud).""" + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + key = browser_tool._navigation_session_key("default", "https://github.com/x/y") + assert key == "default" + + def test_localhost_routes_to_local_sidecar(self, monkeypatch): + """``localhost`` URL → ``::local`` suffix when cloud configured + flag on.""" + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") + assert key == "default::local" + + def test_loopback_ipv4_routes_to_local_sidecar(self, monkeypatch): + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + key = browser_tool._navigation_session_key("default", "http://127.0.0.1:8080/") + assert key == "default::local" + + def test_rfc1918_lan_routes_to_local_sidecar(self, monkeypatch): + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + key = browser_tool._navigation_session_key("default", "http://192.168.1.50:8000/") + assert key == "default::local" + + def test_ipv6_loopback_routes_to_local_sidecar(self, monkeypatch): + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + key = browser_tool._navigation_session_key("default", "http://[::1]:3000/") + assert key == "default::local" + + def test_public_ip_literal_uses_bare_task_id(self, monkeypatch): + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + key = browser_tool._navigation_session_key("default", "https://8.8.8.8/") + assert key == "default" + + def test_mdns_local_hostname_routes_to_sidecar(self, monkeypatch): + """``*.local`` mDNS / ``*.lan`` / ``*.internal`` hostnames route to sidecar.""" + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + for host in ("raspberrypi.local", "printer.lan", "db.internal"): + key = browser_tool._navigation_session_key("default", f"http://{host}/") + assert key == "default::local", f"host {host!r} did not route to sidecar" + + def test_no_cloud_provider_stays_on_bare_task_id(self, monkeypatch): + """When cloud provider is not configured, no hybrid routing happens.""" + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None) + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") + assert key == "default" + + def test_camofox_mode_stays_on_bare_task_id(self, monkeypatch): + """Camofox is already local — no hybrid routing needed.""" + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: True) + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") + assert key == "default" + + def test_cdp_override_stays_on_bare_task_id(self, monkeypatch): + """A user-supplied CDP endpoint owns the whole session — no hybrid.""" + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "ws://localhost:9222") + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") + assert key == "default" + + def test_feature_flag_off_disables_hybrid_routing(self, monkeypatch): + """``auto_local_for_private_urls: false`` keeps private URLs on cloud.""" + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + monkeypatch.setattr(browser_tool, "_auto_local_for_private_urls", lambda: False) + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") + assert key == "default" + + def test_none_task_id_defaults(self, monkeypatch): + """``None`` task_id resolves to 'default'.""" + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) + key = browser_tool._navigation_session_key(None, "http://localhost:3000/") + assert key == "default::local" + + +class TestSessionKeyHelpers: + def test_is_local_sidecar_key(self): + assert browser_tool._is_local_sidecar_key("default::local") + assert browser_tool._is_local_sidecar_key("my_task::local") + assert not browser_tool._is_local_sidecar_key("default") + assert not browser_tool._is_local_sidecar_key("my_task") + + def test_last_session_key_falls_back_to_task_id(self, monkeypatch): + """Without a recorded last-active key, returns the bare task_id.""" + monkeypatch.setattr(browser_tool, "_last_active_session_key", {}) + assert browser_tool._last_session_key("default") == "default" + assert browser_tool._last_session_key("task-42") == "task-42" + assert browser_tool._last_session_key(None) == "default" + + def test_last_session_key_returns_recorded_key(self, monkeypatch): + monkeypatch.setattr( + browser_tool, + "_last_active_session_key", + {"default": "default::local", "task-42": "task-42"}, + ) + assert browser_tool._last_session_key("default") == "default::local" + assert browser_tool._last_session_key("task-42") == "task-42" + # Unknown task_id still falls back + assert browser_tool._last_session_key("other") == "other" + + +class TestHybridRoutingSessionCreation: + """_get_session_info must force a local session when the key carries ``::local``.""" + + def test_local_sidecar_key_skips_cloud_provider(self, monkeypatch): + """A ``::local``-suffixed key creates a local session even when cloud is set.""" + provider = Mock() + provider.create_session.return_value = { + "session_name": "should_not_be_used", + "bb_session_id": "bb_xxx", + "cdp_url": "wss://fake.browserbase.com/ws", + } + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider) + monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda t: None) + + session = browser_tool._get_session_info("default::local") + + assert provider.create_session.call_count == 0 + assert session["bb_session_id"] is None + assert session["cdp_url"] is None + assert session["features"]["local"] is True + + def test_bare_task_id_with_cloud_provider_uses_cloud(self, monkeypatch): + """A bare task_id with cloud provider configured hits the cloud path.""" + provider = Mock() + provider.create_session.return_value = { + "session_name": "cloud-sess", + "bb_session_id": "bb_123", + "cdp_url": "wss://real.browserbase.com/ws", + } + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider) + monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda t: None) + monkeypatch.setattr(browser_tool, "_resolve_cdp_override", lambda u: u) + + session = browser_tool._get_session_info("default") + + assert provider.create_session.call_count == 1 + assert session["bb_session_id"] == "bb_123" + + +class TestCleanupHybridSessions: + """cleanup_browser(bare_task_id) must reap both cloud + local sidecar sessions.""" + + def test_cleanup_reaps_both_primary_and_sidecar(self, monkeypatch): + """Given a bare task_id with both sessions alive, both get cleaned.""" + reaped = [] + + def _fake_cleanup_one(key): + reaped.append(key) + + monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one) + monkeypatch.setattr( + browser_tool, + "_active_sessions", + { + "default": {"session_name": "cloud_sess"}, + "default::local": {"session_name": "local_sess"}, + }, + ) + monkeypatch.setattr( + browser_tool, "_last_active_session_key", {"default": "default::local"} + ) + + browser_tool.cleanup_browser("default") + + assert set(reaped) == {"default", "default::local"} + # last-active pointer dropped + assert "default" not in browser_tool._last_active_session_key + + def test_cleanup_reaps_only_primary_when_no_sidecar(self, monkeypatch): + """When no sidecar exists, only the primary is reaped.""" + reaped = [] + + def _fake_cleanup_one(key): + reaped.append(key) + + monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one) + monkeypatch.setattr( + browser_tool, + "_active_sessions", + {"default": {"session_name": "cloud_sess"}}, + ) + + browser_tool.cleanup_browser("default") + + assert reaped == ["default"] + + def test_cleanup_sidecar_directly_keeps_primary(self, monkeypatch): + """Calling cleanup with a ``::local`` key reaps only the sidecar.""" + reaped = [] + + def _fake_cleanup_one(key): + reaped.append(key) + + monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one) + monkeypatch.setattr( + browser_tool, + "_active_sessions", + { + "default": {"session_name": "cloud_sess"}, + "default::local": {"session_name": "local_sess"}, + }, + ) + monkeypatch.setattr( + browser_tool, "_last_active_session_key", {"default": "default::local"} + ) + + browser_tool.cleanup_browser("default::local") + + assert reaped == ["default::local"] + # Last-active pointer NOT dropped (primary task is still alive) + assert browser_tool._last_active_session_key.get("default") == "default::local" diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 469e9be28d..aecb2ee7f6 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -483,6 +483,147 @@ def _is_local_backend() -> bool: return _is_camofox_mode() or _get_cloud_provider() is None +_auto_local_for_private_urls_resolved = False +_cached_auto_local_for_private_urls: bool = True + + +def _auto_local_for_private_urls() -> bool: + """Return whether a cloud-configured install should auto-spawn a local + Chromium for LAN/localhost URLs. + + Reads ``browser.auto_local_for_private_urls`` once (default ``True``) and + caches it for the process lifetime. When enabled, ``browser_navigate`` + routes URLs whose host resolves to a private/loopback/LAN address to a + local headless Chromium sidecar even when a cloud provider (Browserbase + / Browser-Use / Firecrawl) is configured globally. Public URLs continue + to use the cloud provider in the same conversation. + """ + global _auto_local_for_private_urls_resolved, _cached_auto_local_for_private_urls + if _auto_local_for_private_urls_resolved: + return _cached_auto_local_for_private_urls + + _auto_local_for_private_urls_resolved = True + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() + browser_cfg = cfg.get("browser", {}) + if isinstance(browser_cfg, dict) and "auto_local_for_private_urls" in browser_cfg: + _cached_auto_local_for_private_urls = bool( + browser_cfg.get("auto_local_for_private_urls") + ) + except Exception as e: + logger.debug("Could not read auto_local_for_private_urls from config: %s", e) + return _cached_auto_local_for_private_urls + + +def _url_is_private(url: str) -> bool: + """Return True when the URL's host resolves to a private/LAN/loopback address. + + Reuses ``tools.url_safety.is_safe_url`` as the oracle — if the SSRF check + would reject the URL, we treat it as "private" for routing purposes. DNS + resolution failures are treated as NOT private (fall through to whatever + backend is configured, which will surface the DNS error naturally). + """ + try: + from tools.url_safety import is_safe_url + # is_safe_url returns False for private/loopback/link-local/CGNAT AND + # for DNS failures. We only want the private-network case here, so + # we parse + check the host shape as a DNS-failure sieve first. + from urllib.parse import urlparse + import ipaddress + import socket + parsed = urlparse(url) + hostname = (parsed.hostname or "").strip().lower().rstrip(".") + if not hostname: + return False + # Literal IP → check directly + try: + ip = ipaddress.ip_address(hostname) + return ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip in ipaddress.ip_network("100.64.0.0/10") + ) + except ValueError: + pass + # Hostname — must resolve to confirm it's private (bare "localhost" + # resolves to 127.0.0.1 via /etc/hosts). Short-circuit on obvious + # names to avoid a DNS hop. + if hostname in ("localhost",) or hostname.endswith(".localhost"): + return True + if hostname.endswith(".local") or hostname.endswith(".lan") or hostname.endswith(".internal"): + return True + try: + addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror: + return False # DNS fail → not private, let the normal path fail + for _, _, _, _, sockaddr in addr_info: + try: + ip = ipaddress.ip_address(sockaddr[0]) + except ValueError: + continue + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip in ipaddress.ip_network("100.64.0.0/10") + ): + return True + return False + except Exception as exc: + logger.debug("URL-privacy check failed for %s: %s", url, exc) + return False + + +def _navigation_session_key(task_id: str, url: str) -> str: + """Pick the session key that should handle ``url`` for ``task_id``. + + Returns the bare task_id unless ALL of these are true: + 1. A cloud provider is configured (``_get_cloud_provider()`` is not None). + 2. Auto-local routing is enabled (``browser.auto_local_for_private_urls``, + default True). + 3. The URL resolves to a private/LAN/loopback address. + 4. A CDP override is not active (that path owns the whole session). + 5. Camofox mode is not active (Camofox is already local-only). + + When all are true, returns ``f"{task_id}::local"`` so the hybrid-routing + path spawns a local Chromium sidecar while the cloud session (if any) + continues to serve public URLs. + """ + if task_id is None: + task_id = "default" + if _get_cdp_override(): + return task_id + if _is_camofox_mode(): + return task_id + if _get_cloud_provider() is None: + return task_id + if not _auto_local_for_private_urls(): + return task_id + if not _url_is_private(url): + return task_id + return f"{task_id}{_LOCAL_SUFFIX}" + + +def _is_local_sidecar_key(session_key: str) -> bool: + """Return True when ``session_key`` is a hybrid-routing local sidecar.""" + return session_key.endswith(_LOCAL_SUFFIX) + + +def _last_session_key(task_id: str) -> str: + """Return the session key to use for a non-nav browser tool call. + + If a previous ``browser_navigate`` on this task_id set a last-active key, + use it so snapshot/click/fill/etc. hit the same session. Otherwise fall + back to the bare task_id (matches original behavior for tasks that never + triggered hybrid routing). + """ + if task_id is None: + task_id = "default" + return _last_active_session_key.get(task_id, task_id) + + def _allow_private_urls() -> bool: """Return whether the browser is allowed to navigate to private/internal addresses. @@ -521,10 +662,25 @@ def _socket_safe_tmpdir() -> str: return tempfile.gettempdir() -# Track active sessions per task +# Track active sessions per "session key". +# +# A "session key" is either the bare task_id (cloud/default path) OR a composite +# like f"{task_id}::local" when the hybrid-routing feature spawns a local sidecar +# browser for a LAN/localhost URL while a cloud provider is configured globally. +# Both forms flow through the same _active_sessions / _run_browser_command / +# cleanup_browser code paths — the key is opaque to those internals. +# # Stores: session_name (always), bb_session_id + cdp_url (cloud mode only) -_active_sessions: Dict[str, Dict[str, str]] = {} # task_id -> {session_name, ...} -_recording_sessions: set = set() # task_ids with active recordings +_active_sessions: Dict[str, Dict[str, str]] = {} # session_key -> {session_name, ...} +_recording_sessions: set = set() # session_keys with active recordings + +# Tracks the most recent session_key used per task_id. Set by browser_navigate() +# after it chooses a backend for a URL; read by every non-nav browser tool +# (snapshot/click/fill/eval/...) so they target the session that served the last +# navigation. Without this, a task that navigated to localhost on the local +# sidecar would fall back to the cloud session on its next snapshot call. +_last_active_session_key: Dict[str, str] = {} # task_id -> session_key +_LOCAL_SUFFIX = "::local" # Flag to track if cleanup has been done _cleanup_done = False @@ -1014,37 +1170,48 @@ def _create_cdp_session(task_id: str, cdp_url: str) -> Dict[str, str]: def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]: """ - Get or create session info for the given task. - + Get or create session info for the given session key. + In cloud mode, creates a Browserbase session with proxies enabled. In local mode, generates a session name for agent-browser --session. Also starts the inactivity cleanup thread and updates activity tracking. Thread-safe: multiple subagents can call this concurrently. - + Args: - task_id: Unique identifier for the task - + task_id: Session key. Normally the task_id as-is, but may carry the + ``::local`` suffix for the hybrid-routing local sidecar — in that + case the cloud provider is skipped even when one is configured, + and a local Chromium session is created instead. + Returns: Dict with session_name (always), bb_session_id + cdp_url (cloud only) """ if task_id is None: task_id = "default" - + # Start the cleanup thread if not running (handles inactivity timeouts) _start_browser_cleanup_thread() - + # Update activity timestamp for this session _update_session_activity(task_id) - + with _cleanup_lock: # Check if we already have a session for this task if task_id in _active_sessions: return _active_sessions[task_id] - + + # Hybrid routing: session keys ending with ``::local`` force a local + # Chromium regardless of the globally-configured cloud provider. Public + # URLs in the same conversation continue to use the cloud session under + # the bare task_id key. + force_local = _is_local_sidecar_key(task_id) + # Create session outside the lock (network call in cloud mode) cdp_override = _get_cdp_override() - if cdp_override: + if cdp_override and not force_local: session_info = _create_cdp_session(task_id, cdp_override) + elif force_local: + session_info = _create_local_session(task_id) else: provider = _get_cloud_provider() if provider is None: @@ -1081,7 +1248,7 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]: session_info["fallback_from_cloud"] = True session_info["fallback_reason"] = str(e) session_info["fallback_provider"] = provider_name - + with _cleanup_lock: # Double-check: another thread may have created a session while we # were doing the network call. Use the existing one to avoid leaking @@ -1093,7 +1260,9 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]: # Lazy-start the CDP supervisor now that the session exists (if the # backend surfaces a CDP URL via override or session_info["cdp_url"]). # Idempotent; swallows errors. See _ensure_cdp_supervisor for details. - _ensure_cdp_supervisor(task_id) + # Skip for local sidecars — they have no CDP URL. + if not force_local: + _ensure_cdp_supervisor(task_id) return session_info @@ -1521,9 +1690,21 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: # SSRF protection — block private/internal addresses before navigating. # Skipped for local backends (Camofox, headless Chromium without a cloud # provider) because the agent already has full local network access via - # the terminal tool. Can also be opted out for cloud mode via - # ``browser.allow_private_urls`` in config. - if not _is_local_backend() and not _allow_private_urls() and not _is_safe_url(url): + # the terminal tool. Also skipped when hybrid routing will auto-spawn a + # local Chromium sidecar for this URL (cloud provider configured + + # private URL + ``browser.auto_local_for_private_urls`` enabled) — the + # cloud provider never sees the URL in that case. Can also be opted + # out globally via ``browser.allow_private_urls`` in config. + effective_task_id = task_id or "default" + nav_session_key = _navigation_session_key(effective_task_id, url) + auto_local_this_nav = _is_local_sidecar_key(nav_session_key) + + if ( + not _is_local_backend() + and not auto_local_this_nav + and not _allow_private_urls() + and not _is_safe_url(url) + ): return json.dumps({ "success": False, "error": "Blocked: URL targets a private or internal address", @@ -1543,19 +1724,31 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: from tools.browser_camofox import camofox_navigate return camofox_navigate(url, task_id) - effective_task_id = task_id or "default" - + if auto_local_this_nav: + logger.info( + "browser_navigate: auto-routing %s to local Chromium sidecar " + "(cloud provider %s stays on cloud for public URLs; " + "set browser.auto_local_for_private_urls: false to disable)", + url, + type(_get_cloud_provider()).__name__ if _get_cloud_provider() else "none", + ) + # Get session info to check if this is a new session # (will create one with features logged if not exists) - session_info = _get_session_info(effective_task_id) + session_info = _get_session_info(nav_session_key) is_first_nav = session_info.get("_first_nav", True) - + # Auto-start recording if configured and this is first navigation if is_first_nav: session_info["_first_nav"] = False - _maybe_start_recording(effective_task_id) + _maybe_start_recording(nav_session_key) - result = _run_browser_command(effective_task_id, "open", [url], timeout=max(_get_command_timeout(), 60)) + result = _run_browser_command(nav_session_key, "open", [url], timeout=max(_get_command_timeout(), 60)) + + # Remember which session served this nav so snapshot/click/fill/... + # on the same task_id hit it (critical when hybrid routing has both a + # cloud session and a local sidecar alive concurrently). + _last_active_session_key[effective_task_id] = nav_session_key if result.get("success"): data = result.get("data", {}) @@ -1565,10 +1758,17 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: # Post-redirect SSRF check — if the browser followed a redirect to a # private/internal address, block the result so the model can't read # internal content via subsequent browser_snapshot calls. - # Skipped for local backends (same rationale as the pre-nav check). - if not _is_local_backend() and not _allow_private_urls() and final_url and final_url != url and not _is_safe_url(final_url): + # Skipped for local backends (same rationale as the pre-nav check), + # and for the hybrid local sidecar (we're already on a local browser + # hitting a private URL by design). + if ( + not _is_local_backend() + and not auto_local_this_nav + and not _allow_private_urls() + and final_url and final_url != url and not _is_safe_url(final_url) + ): # Navigate away to a blank page to prevent snapshot leaks - _run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10) + _run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10) return json.dumps({ "success": False, "error": "Blocked: redirect landed on a private/internal address", @@ -1612,7 +1812,7 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: # Auto-take a compact snapshot so the model can act immediately # without a separate browser_snapshot call. try: - snap_result = _run_browser_command(effective_task_id, "snapshot", ["-c"]) + snap_result = _run_browser_command(nav_session_key, "snapshot", ["-c"]) if snap_result.get("success"): snap_data = snap_result.get("data", {}) snapshot_text = snap_data.get("snapshot", "") @@ -1652,7 +1852,7 @@ def browser_snapshot( from tools.browser_camofox import camofox_snapshot return camofox_snapshot(full, task_id, user_task) - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") # Build command args based on full flag args = [] @@ -1714,7 +1914,7 @@ def browser_click(ref: str, task_id: Optional[str] = None) -> str: from tools.browser_camofox import camofox_click return camofox_click(ref, task_id) - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") # Ensure ref starts with @ if not ref.startswith("@"): @@ -1750,7 +1950,7 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str: from tools.browser_camofox import camofox_type return camofox_type(ref, text, task_id) - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") # Ensure ref starts with @ if not ref.startswith("@"): @@ -1804,7 +2004,7 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str: result = camofox_scroll(direction, task_id) return result - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") result = _run_browser_command(effective_task_id, "scroll", [direction, str(_SCROLL_PIXELS)]) if not result.get("success"): @@ -1833,7 +2033,7 @@ def browser_back(task_id: Optional[str] = None) -> str: from tools.browser_camofox import camofox_back return camofox_back(task_id) - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") result = _run_browser_command(effective_task_id, "back", []) if result.get("success"): @@ -1864,7 +2064,7 @@ def browser_press(key: str, task_id: Optional[str] = None) -> str: from tools.browser_camofox import camofox_press return camofox_press(key, task_id) - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") result = _run_browser_command(effective_task_id, "press", [key]) if result.get("success"): @@ -1906,7 +2106,7 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_ from tools.browser_camofox import camofox_console return camofox_console(clear, task_id) - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") console_args = ["--clear"] if clear else [] error_args = ["--clear"] if clear else [] @@ -1945,7 +2145,7 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str: if _is_camofox_mode(): return _camofox_eval(expression, task_id) - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") result = _run_browser_command(effective_task_id, "eval", [expression]) if not result.get("success"): @@ -2077,7 +2277,7 @@ def browser_get_images(task_id: Optional[str] = None) -> str: from tools.browser_camofox import camofox_get_images return camofox_get_images(task_id) - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") # Use eval to run JavaScript that extracts images js_code = """JSON.stringify( @@ -2147,7 +2347,7 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] import base64 import uuid as uuid_mod - effective_task_id = task_id or "default" + effective_task_id = _last_session_key(task_id or "default") # Save screenshot to persistent location so it can be shared with users from hermes_constants import get_hermes_dir @@ -2350,17 +2550,47 @@ def _cleanup_old_recordings(max_age_hours=72): def cleanup_browser(task_id: Optional[str] = None) -> None: """ - Clean up browser session for a task. - + Clean up browser session(s) for a task. + Called automatically when a task completes or when inactivity timeout is reached. Closes both the agent-browser/Browserbase session and Camofox sessions. - + + When ``task_id`` is a bare task identifier (no ``::local`` suffix), reaps + BOTH the cloud/primary session AND any hybrid-routing local sidecar that + may have been spawned for LAN/localhost URLs in the same task. When + ``task_id`` already carries a ``::local`` suffix (called from the inactivity + cleanup loop against a specific session key), reaps only that one. + Args: - task_id: Task identifier to clean up + task_id: Task identifier (or explicit session key) """ if task_id is None: task_id = "default" + # Expand to the full set of session keys to reap. For a bare task_id + # that includes the cloud/primary key + the local sidecar if one exists. + if _is_local_sidecar_key(task_id): + session_keys = [task_id] + bare_task_id = task_id[: -len(_LOCAL_SUFFIX)] + else: + session_keys = [task_id] + sidecar_key = f"{task_id}{_LOCAL_SUFFIX}" + with _cleanup_lock: + if sidecar_key in _active_sessions: + session_keys.append(sidecar_key) + bare_task_id = task_id + + for session_key in session_keys: + _cleanup_single_browser_session(session_key) + + # Drop the last-active pointer only when the bare task is being cleaned + # (i.e. not when we're only reaping a sidecar mid-task). + if not _is_local_sidecar_key(task_id): + _last_active_session_key.pop(bare_task_id, None) + + +def _cleanup_single_browser_session(task_id: str) -> None: + """Internal: reap a single browser session by its exact session key.""" # Stop the CDP supervisor for this task FIRST so we close our WebSocket # before the backend tears down the underlying CDP endpoint. _stop_cdp_supervisor(task_id) @@ -2379,32 +2609,33 @@ def cleanup_browser(task_id: Optional[str] = None) -> None: logger.debug("cleanup_browser called for task_id: %s", task_id) logger.debug("Active sessions: %s", list(_active_sessions.keys())) - + # Check if session exists (under lock), but don't remove yet - # _run_browser_command needs it to build the close command. with _cleanup_lock: session_info = _active_sessions.get(task_id) - + if session_info: bb_session_id = session_info.get("bb_session_id", "unknown") logger.debug("Found session for task %s: bb_session_id=%s", task_id, bb_session_id) - + # Stop auto-recording before closing (saves the file) _maybe_stop_recording(task_id) - + # Try to close via agent-browser first (needs session in _active_sessions) try: _run_browser_command(task_id, "close", [], timeout=10) logger.debug("agent-browser close command completed for task %s", task_id) except Exception as e: logger.warning("agent-browser close failed for task %s: %s", task_id, e) - + # Now remove from tracking under lock with _cleanup_lock: _active_sessions.pop(task_id, None) _session_last_activity.pop(task_id, None) - - # Cloud mode: close the cloud browser session via provider API + + # Cloud mode: close the cloud browser session via provider API. + # Local sidecars have bb_session_id=None so this no-ops for them. if bb_session_id: provider = _get_cloud_provider() if provider is not None: diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index ca51b633ef..3bc1b0bb72 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -86,6 +86,40 @@ FIRECRAWL_API_URL=http://localhost:3002 FIRECRAWL_BROWSER_TTL=600 ``` +### Hybrid routing: cloud for public URLs, local for LAN/localhost + +When a cloud provider is configured, Hermes auto-spawns a **local Chromium sidecar** +for URLs that resolve to a private/loopback/LAN address (`localhost`, `127.0.0.1`, +`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`, `*.local`, `*.lan`, `*.internal`, +IPv6 loopback `::1`, link-local `169.254.x.x`). Public URLs continue to use the +cloud provider in the same conversation. + +This solves the common "I'm developing locally but using Browserbase" workflow — +the agent can screenshot your dashboard at `http://localhost:3000` AND scrape +`https://github.com` without you switching providers or disabling the SSRF guard. +The cloud provider never sees the private URL. + +The feature is **on by default**. To disable it (all URLs go to the configured +cloud provider, as before): + +```yaml +# ~/.hermes/config.yaml +browser: + cloud_provider: browserbase + auto_local_for_private_urls: false +``` + +With auto-routing disabled, private URLs are rejected with +`"Blocked: URL targets a private or internal address"` unless you also set +`browser.allow_private_urls: true` (which lets the cloud provider attempt them — +usually won't work since Browserbase etc. can't reach your LAN). + +Requirements: the local sidecar uses the same `agent-browser` CLI as pure local +mode, so you need it installed (`hermes setup tools → Browser Automation` +auto-installs it). Post-navigation redirects from a public URL onto a private +address are still blocked (you can't use a redirect-to-internal trick to reach +your LAN through the public path). + ### Camofox local mode [Camofox](https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js server wrapping Camoufox (a Firefox fork with C++ fingerprint spoofing). It provides local anti-detection browsing without cloud dependencies. From 0824ba6a9db8c5e92d4fe2e7ee5bc086844b336e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:28:19 -0700 Subject: [PATCH 37/41] fix(/branch): redirect session_log_file and expose branch sessions in list (#14854) (#16150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(/branch): redirect session_log_file and expose branch sessions in list Two bugs when using /branch: 1. cli.py _handle_branch_command updated agent.session_id but not agent.session_log_file, so all messages written after branching landed in the original session's JSON file and the branch never got its own session_{id}.json on disk. Fix: mirror the compression-split path (run_agent.py:7579) and update session_log_file immediately after changing session_id. 2. hermes_state.py list_sessions_rich filtered out every session with parent_session_id IS NOT NULL to hide sub-agent runs and compression continuations. Branch sessions share this column, so they became invisible to `hermes sessions list` and `sessions browse`. Fix: also include branch children — those whose parent ended with end_reason='branched' AND whose started_at >= parent.ended_at (the same timing condition that get_compression_tip uses to distinguish continuations from live-spawned subagents). Fixes #14854 Co-Authored-By: Octopus <liyuan851277048@icloud.com> * chore(release): map octo-patch placeholder email in AUTHOR_MAP --------- Co-authored-by: octo-patch <octo-patch@github.com> Co-authored-by: Octopus <liyuan851277048@icloud.com> --- cli.py | 6 +++++ hermes_state.py | 13 +++++++++- scripts/release.py | 1 + tests/cli/test_branch_command.py | 24 ++++++++++++++++++ tests/test_hermes_state.py | 42 ++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 04d9c055d4..60103bf956 100644 --- a/cli.py +++ b/cli.py @@ -4915,6 +4915,12 @@ class HermesCLI: if self.agent: self.agent.session_id = new_session_id self.agent.session_start = now + # Redirect the JSON session log to the new branch session file so + # messages written after branching land in the correct file. + if hasattr(self.agent, "session_log_file") and hasattr(self.agent, "logs_dir"): + self.agent.session_log_file = ( + self.agent.logs_dir / f"session_{new_session_id}.json" + ) self.agent.reset_session_state() if hasattr(self.agent, "_last_flushed_db_idx"): self.agent._last_flushed_db_idx = len(self.conversation_history) diff --git a/hermes_state.py b/hermes_state.py index 8ae8ae6e61..cc40313084 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -832,7 +832,18 @@ class SessionDB: params = [] if not include_children: - where_clauses.append("s.parent_session_id IS NULL") + # Show root sessions and branch sessions (whose parent ended with + # end_reason='branched' before the child was created), while still + # hiding sub-agent runs and compression continuations (which also + # carry a parent_session_id but were spawned while the parent was + # still live — i.e., started_at < parent.ended_at). + where_clauses.append( + "(s.parent_session_id IS NULL" + " OR EXISTS (SELECT 1 FROM sessions p" + " WHERE p.id = s.parent_session_id" + " AND p.end_reason = 'branched'" + " AND s.started_at >= p.ended_at))" + ) if source: where_clauses.append("s.source = ?") diff --git a/scripts/release.py b/scripts/release.py index eb52e942d5..7873b868e5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -70,6 +70,7 @@ AUTHOR_MAP = { "keira.voss94@gmail.com": "keiravoss94", "16443023+stablegenius49@users.noreply.github.com": "stablegenius49", "fqsy1416@gmail.com": "EKKOLearnAI", + "octo-patch@github.com": "octo-patch", "simbamax99@gmail.com": "simbam99", "iris@growthpillars.co": "irispillars", "185121704+stablegenius49@users.noreply.github.com": "stablegenius49", diff --git a/tests/cli/test_branch_command.py b/tests/cli/test_branch_command.py index 9c3ec61d8c..581cdbdb6a 100644 --- a/tests/cli/test_branch_command.py +++ b/tests/cli/test_branch_command.py @@ -160,6 +160,30 @@ class TestBranchCommandCLI: assert agent.reset_session_state.called assert agent._last_flushed_db_idx == 4 # len(conversation_history) + def test_branch_updates_agent_session_log_file(self, cli_instance, session_db, tmp_path): + """Branching must redirect the agent's session_log_file to the new session's path.""" + from cli import HermesCLI + from pathlib import Path + + logs_dir = tmp_path / "sessions" + logs_dir.mkdir() + + agent = MagicMock() + agent._last_flushed_db_idx = 0 + agent.logs_dir = logs_dir + agent.session_log_file = logs_dir / f"session_{cli_instance.session_id}.json" + cli_instance.agent = agent + + old_log_file = agent.session_log_file + HermesCLI._handle_branch_command(cli_instance, "/branch") + + new_session_id = cli_instance.session_id + expected_log = logs_dir / f"session_{new_session_id}.json" + assert agent.session_log_file == expected_log, ( + "session_log_file must point to the branch session, not the original" + ) + assert agent.session_log_file != old_log_file + def test_branch_sets_resumed_flag(self, cli_instance, session_db): """Branch should set _resumed=True to prevent auto-title generation.""" from cli import HermesCLI diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 94cd498a66..868a28c530 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1485,6 +1485,48 @@ class TestListSessionsRich: assert "\n" not in sessions[0]["preview"] assert "Line one Line two" in sessions[0]["preview"] + def test_branch_session_visible_in_list(self, db): + """Branch sessions (parent ended with 'branched') must appear in list_sessions_rich.""" + db.create_session("parent", "cli") + db.end_session("parent", "branched") + db.create_session("branch", "cli", parent_session_id="parent") + db.append_message("branch", "user", "Exploring the alternative approach") + + sessions = db.list_sessions_rich() + ids = [s["id"] for s in sessions] + assert "branch" in ids, "Branch session should be visible in default list" + + def test_subagent_session_still_hidden(self, db): + """Sub-agent children (parent NOT ended with 'branched') remain hidden.""" + db.create_session("root", "cli") + db.create_session("delegate", "cli", parent_session_id="root") + + sessions = db.list_sessions_rich() + ids = [s["id"] for s in sessions] + assert "delegate" not in ids, "Delegate sub-agent should not appear in default list" + assert "root" in ids + + def test_compression_child_still_hidden(self, db): + """Compression continuation sessions remain hidden (parent ended with 'compression').""" + import time as _time + t0 = _time.time() + db.create_session("root", "cli") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root")) + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?", + (t0 + 1800, "root"), + ) + db._conn.commit() + db.create_session("continuation", "cli", parent_session_id="root") + db._conn.execute( + "UPDATE sessions SET started_at=? WHERE id=?", (t0 + 1801, "continuation") + ) + db._conn.commit() + + sessions = db.list_sessions_rich(project_compression_tips=False) + ids = [s["id"] for s in sessions] + assert "continuation" not in ids, "Compression continuation should stay hidden" + class TestCompressionChainProjection: """Tests for lineage-aware list_sessions_rich — compressed conversations From 9662e3218a7fdb33efc3bdfe79247c6c5097ef86 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:44:22 -0700 Subject: [PATCH 38/41] fix(tui): call maybe_auto_title for TUI sessions (#15949) (#16151) * fix(tui): call maybe_auto_title for TUI sessions (#15961) The maybe_auto_title() helper is called from cli.py and gateway/run.py but was never wired into tui_gateway/server.py, so every session started via 'hermes --tui' landed in state.db with an empty title. Evidence from the issue reporter: 0/154 TUI sessions titled vs 91/383 CLI. Mirror the CLI/Gateway pattern: after emitting message.complete, when the turn finished cleanly, fire-and-forget title generation using the session key, user prompt, agent response, and current history. Fixes #15949. Co-authored-by: math0r-be <math0r-be@github.com> * chore(release): map math0r-be placeholder email in AUTHOR_MAP --------- Co-authored-by: math0r-be <math0r-be@github.com> --- scripts/release.py | 1 + tests/test_tui_gateway_server.py | 109 +++++++++++++++++++++++++++++++ tui_gateway/server.py | 20 ++++++ 3 files changed, 130 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 7873b868e5..b0612f09ad 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -71,6 +71,7 @@ AUTHOR_MAP = { "16443023+stablegenius49@users.noreply.github.com": "stablegenius49", "fqsy1416@gmail.com": "EKKOLearnAI", "octo-patch@github.com": "octo-patch", + "math0r-be@github.com": "math0r-be", "simbamax99@gmail.com": "simbam99", "iris@growthpillars.co": "irispillars", "185121704+stablegenius49@users.noreply.github.com": "stablegenius49", diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index f7eacb6859..899fa7db85 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1807,3 +1807,112 @@ def test_model_options_propagates_list_exception(monkeypatch): assert "error" in resp assert resp["error"]["code"] == 5033 assert "catalog blew up" in resp["error"]["message"] + + +# --------------------------------------------------------------------------- +# prompt.submit — auto-title +# --------------------------------------------------------------------------- + +class _ImmediateThread: + """Runs the target callable synchronously so assertions can follow.""" + + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + self._target() + + +def test_prompt_submit_auto_titles_session_on_complete(monkeypatch): + """maybe_auto_title is called after a successful (complete) prompt.""" + + class _Agent: + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + return { + "final_response": "Rome was founded in 753 BC.", + "messages": [ + {"role": "user", "content": "Tell me about Rome"}, + {"role": "assistant", "content": "Rome was founded in 753 BC."}, + ], + } + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + monkeypatch.setattr(server, "_get_db", lambda: None) + + with patch("agent.title_generator.maybe_auto_title") as mock_title: + server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "Tell me about Rome"}, + } + ) + + mock_title.assert_called_once() + args = mock_title.call_args.args + assert args[1] == "session-key" + assert args[2] == "Tell me about Rome" + assert args[3] == "Rome was founded in 753 BC." + + +def test_prompt_submit_skips_auto_title_when_interrupted(monkeypatch): + """maybe_auto_title must NOT be called when the agent was interrupted.""" + + class _Agent: + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + return { + "final_response": "partial answer", + "interrupted": True, + "messages": [], + } + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + monkeypatch.setattr(server, "_get_db", lambda: None) + + with patch("agent.title_generator.maybe_auto_title") as mock_title: + server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "Tell me about Rome"}, + } + ) + + mock_title.assert_not_called() + + +def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch): + """maybe_auto_title must NOT be called when the agent returns an empty reply.""" + + class _Agent: + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + return { + "final_response": "", + "messages": [], + } + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + monkeypatch.setattr(server, "_get_db", lambda: None) + + with patch("agent.title_generator.maybe_auto_title") as mock_title: + server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "Tell me about Rome"}, + } + ) + + mock_title.assert_not_called() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 30531aab28..ae5d58579e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2321,6 +2321,26 @@ def _(rid, params: dict) -> dict: payload["rendered"] = rendered _emit("message.complete", sid, payload) + if ( + status == "complete" + and isinstance(raw, str) + and raw.strip() + and isinstance(text, str) + and text.strip() + ): + try: + from agent.title_generator import maybe_auto_title + + maybe_auto_title( + _get_db(), + session.get("session_key") or sid, + text, + raw, + session.get("history", []), + ) + except Exception: + pass + # CLI parity: when voice-mode TTS is on, speak the agent reply # (cli.py:_voice_speak_response). Only the final text — tool # calls / reasoning already stream separately and would be From 93977675135acb9370167392542241e9650a69e8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:44:56 -0700 Subject: [PATCH 39/41] chore(skills): remove empty feeds category (#16153) skills/feeds/ only contained a category-marker DESCRIPTION.md with no actual skills in it. Removing the directory and the 'feeds' -> 'Feeds' display-label mapping in website/scripts/extract-skills.py (the only other reference in the repo). --- skills/feeds/DESCRIPTION.md | 3 --- website/scripts/extract-skills.py | 1 - 2 files changed, 4 deletions(-) delete mode 100644 skills/feeds/DESCRIPTION.md diff --git a/skills/feeds/DESCRIPTION.md b/skills/feeds/DESCRIPTION.md deleted file mode 100644 index 5c2c97bf6d..0000000000 --- a/skills/feeds/DESCRIPTION.md +++ /dev/null @@ -1,3 +0,0 @@ ---- -description: Skills for monitoring, aggregating, and processing RSS feeds, blogs, and web content sources. ---- diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index 30cf523161..79413aec0f 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -26,7 +26,6 @@ CATEGORY_LABELS = { "dogfood": "Dogfood", "domain": "Domain", "email": "Email", - "feeds": "Feeds", "gaming": "Gaming", "gifs": "GIFs", "github": "GitHub", From 9be83728a67c794daa20c553919f4869675a2edc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:46:08 -0700 Subject: [PATCH 40/41] docs(docker-backend): clarify container is shared across sessions, not per-session (#16158) The Docker terminal-backend docs said 'each session starts a long-lived container', implying a fresh container per chat session. That hasn't been true for a while: for the top-level agent, task_id defaults to 'default' and the container is cached in _active_environments for the lifetime of the Hermes process. /new, /reset, and switching sessions all reuse the same container. Only delegate_task subagents and RL rollouts get isolated containers keyed by their own task_id. --- website/docs/user-guide/configuration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 1da5963b7d..ac48e9f884 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -146,7 +146,9 @@ terminal: **Requirements:** Docker Desktop or Docker Engine installed and running. Hermes probes `$PATH` plus common macOS install locations (`/usr/local/bin/docker`, `/opt/homebrew/bin/docker`, Docker Desktop app bundle). -**Container lifecycle:** Each session starts a long-lived container (`docker run -d ... sleep 2h`). Commands run via `docker exec` with a login shell. On cleanup, the container is stopped and removed. +**Container lifecycle:** Hermes reuses a single long-lived container (`docker run -d ... sleep 2h`) for every terminal and file-tool call made by the top-level agent, across sessions, `/new`, and `/reset`, for the lifetime of the Hermes process. Commands run via `docker exec` with a login shell, so working-directory changes, installed packages, and files in `/workspace` all persist from one tool call to the next. The container is stopped and removed on Hermes shutdown (or when the idle-sweep reclaims it). + +Subagents (`delegate_task`) and RL rollouts get their own isolated containers keyed by `task_id` — only the top-level agent shares the `default` container. **Security hardening:** - `--cap-drop ALL` with only `DAC_OVERRIDE`, `CHOWN`, `FOWNER` added back From 087e74d4d79505e37669159a9557f9d3dc7b664a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:38:32 -0700 Subject: [PATCH 41/41] feat(slack): register every gateway command as a native slash (Discord/Telegram parity) (#16164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command in COMMAND_REGISTRY (/btw, /stop, /model, /help, /new, /bg, /reset, ...) is now a first-class Slack slash command instead of a /hermes <subcommand>. Users get the same autocomplete-driven slash picker experience Slack users expect and that Discord and Telegram already provide. Previously Slack registered ONE native slash (/hermes) and split on the first word, so typing /btw in Slack's composer got 'couldn't find an app for /btw' because the workspace manifest never declared it. Changes - hermes_cli/commands.py: slack_native_slashes() + slack_app_manifest() generate a Slack manifest from the registry (canonical names + aliases + plugin commands), clamped to Slack's 50-slash cap with /hermes reserved as the catch-all. - gateway/platforms/slack.py: single regex matcher dispatches every registered slash to _handle_slash_command, which dispatches on command['command']. Legacy /hermes <subcommand> keeps working for backward compat with older workspace manifests. - hermes_cli/slack_cli.py + hermes_cli/main.py: new 'hermes slack manifest' command prints/writes a full manifest (display info, OAuth scopes, event subs, socket mode, slash commands) ready to paste into 'Create from manifest' or Features → App Manifest. - hermes_cli/setup.py: _setup_slack() now writes the manifest up-front and points users at the 'From an app manifest' flow; also offers to refresh the manifest on reconfigure for picking up new commands. - Tests: 14 new tests covering native-slash dispatch (/btw, /stop, /model), legacy /hermes <sub> compat, manifest structure, and telegram<->slack parity (every Telegram command must also register as a Slack slash). Existing /hermes-registration test updated to assert the new regex matches /hermes, /btw, /stop, /model, /help. - Docs: slack.md gains a 'Slash Commands' section + Option A manifest flow in Step 1; cli-commands.md documents 'hermes slack manifest'. Users pick up the new slashes by running 'hermes slack manifest --write' and pasting into Features → App Manifest → Edit in their Slack app config, then Save (Slack prompts for reinstall if scopes changed). --- gateway/platforms/slack.py | 73 +++++++--- hermes_cli/commands.py | 108 +++++++++++++++ hermes_cli/main.py | 79 +++++++++++ hermes_cli/setup.py | 76 +++++++++-- hermes_cli/slack_cli.py | 152 +++++++++++++++++++++ tests/gateway/test_slack.py | 92 ++++++++++++- tests/hermes_cli/test_commands.py | 111 +++++++++++++++ website/docs/reference/cli-commands.md | 28 ++++ website/docs/user-guide/messaging/slack.md | 76 ++++++++++- 9 files changed, 763 insertions(+), 32 deletions(-) create mode 100644 hermes_cli/slack_cli.py diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 191689a5ae..61cc7020a2 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -207,8 +207,31 @@ class SlackAdapter(BasePlatformAdapter): async def handle_assistant_thread_context_changed(event, say): await self._handle_assistant_thread_lifecycle_event(event) - # Register slash command handler - @self._app.command("/hermes") + # Register slash command handler(s) + # + # Every gateway command from COMMAND_REGISTRY is a native Slack + # slash, matching Discord and Telegram's model (e.g. /btw, /stop, + # /model work directly without /hermes prefix). A single regex + # matcher dispatches all of them to one handler so we don't need + # N identical @app.command() decorators. + # + # The slash commands must ALSO be declared in the Slack app + # manifest (see `hermes slack manifest`). In Socket Mode, Slack + # routes the command event through the socket regardless of the + # manifest's request URL, but it will not deliver an event for + # a slash command the manifest doesn't declare. + from hermes_cli.commands import slack_native_slashes + import re as _re + + _slash_names = [name for name, _d, _h in slack_native_slashes()] + if _slash_names: + _slash_pattern = _re.compile( + r"^/(?:" + "|".join(_re.escape(n) for n in _slash_names) + r")$" + ) + else: # pragma: no cover - registry always non-empty + _slash_pattern = _re.compile(r"^/hermes$") + + @self._app.command(_slash_pattern) async def handle_hermes_command(ack, command): await ack() await self._handle_slash_command(command) @@ -1561,7 +1584,20 @@ class SlackAdapter(BasePlatformAdapter): return "" async def _handle_slash_command(self, command: dict) -> None: - """Handle /hermes slash command.""" + """Handle Slack slash commands. + + Every gateway command in COMMAND_REGISTRY is registered as a native + Slack slash (``/btw``, ``/stop``, ``/model``, etc.), matching the + Discord and Telegram model. The slash name itself is the command; + any text after it is the argument list. + + The legacy ``/hermes <subcommand> [args]`` form is preserved for + backward compatibility with older workspace manifests and for users + who want a single entry point for free-form questions (``/hermes + what's the weather`` — non-slash text is treated as a regular + message). + """ + slash_name = (command.get("command") or "").lstrip("/").strip() text = command.get("text", "").strip() user_id = command.get("user_id", "") channel_id = command.get("channel_id", "") @@ -1571,20 +1607,25 @@ class SlackAdapter(BasePlatformAdapter): if team_id and channel_id: self._channel_team[channel_id] = team_id - # Map subcommands to gateway commands — derived from central registry. - # Also keep "compact" as a Slack-specific alias for /compress. - from hermes_cli.commands import slack_subcommand_map - subcommand_map = slack_subcommand_map() - subcommand_map["compact"] = "/compress" - first_word = text.split()[0] if text else "" - if first_word in subcommand_map: - # Preserve arguments after the subcommand - rest = text[len(first_word):].strip() - text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] - elif text: - pass # Treat as a regular question + if slash_name in ("hermes", ""): + # Legacy /hermes <subcommand> [args] routing + free-form questions. + # Empty slash_name falls into this branch for backward compat + # with any caller that didn't populate command["command"]. + from hermes_cli.commands import slack_subcommand_map + subcommand_map = slack_subcommand_map() + subcommand_map["compact"] = "/compress" + first_word = text.split()[0] if text else "" + if first_word in subcommand_map: + rest = text[len(first_word):].strip() + text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] + elif text: + pass # Treat as a regular question + else: + text = "/help" else: - text = "/help" + # Native slash — /<slash_name> [args]. Route directly through the + # gateway command dispatcher by prepending the slash. + text = f"/{slash_name} {text}".strip() source = self.build_source( chat_id=channel_id, diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 614d783d95..d0eb74d872 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -806,6 +806,114 @@ def discord_skill_commands_by_category( return trimmed_categories, uncategorized, hidden +# --------------------------------------------------------------------------- +# Slack native slash commands +# --------------------------------------------------------------------------- + +# Slack slash command name constraints: lowercase a-z, 0-9, hyphens, +# underscores. Max 32 chars. Slack app manifest accepts up to 50 slash +# commands per app. +_SLACK_MAX_SLASH_COMMANDS = 50 +_SLACK_NAME_LIMIT = 32 +_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]") + + +def _sanitize_slack_name(raw: str) -> str: + """Convert a command name to a valid Slack slash command name. + + Slack allows lowercase a-z, digits, hyphens, and underscores. Max 32 + chars. Uppercase is lowercased; invalid chars are stripped. + """ + name = raw.lower() + name = _SLACK_INVALID_CHARS.sub("", name) + name = name.strip("-_") + return name[:_SLACK_NAME_LIMIT] + + +def slack_native_slashes() -> list[tuple[str, str, str]]: + """Return (slash_name, description, usage_hint) triples for Slack. + + Every gateway-available command in ``COMMAND_REGISTRY`` is surfaced as + a standalone Slack slash command (e.g. ``/btw``, ``/stop``, ``/model``), + matching Discord's and Telegram's model where every command is a + first-class slash and not a ``/hermes <verb>`` subcommand. + + Both canonical names and aliases are included so users can type any + documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work). + Plugin-registered slash commands are included too. + + Results are clamped to Slack's 50-command limit with duplicate-name + avoidance. ``/hermes`` is always reserved as the first entry so the + legacy ``/hermes <subcommand>`` form keeps working for anything that + gets dropped by the clamp or for free-form questions. + """ + overrides = _resolve_config_gates() + entries: list[tuple[str, str, str]] = [] + seen: set[str] = set() + + # Reserve /hermes as the catch-all top-level command. + entries.append(("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]")) + seen.add("hermes") + + def _add(name: str, desc: str, hint: str) -> None: + slack_name = _sanitize_slack_name(name) + if not slack_name or slack_name in seen: + return + if len(entries) >= _SLACK_MAX_SLASH_COMMANDS: + return + # Slack description cap is 2000 chars; keep it short. + entries.append((slack_name, desc[:140], hint[:100])) + seen.add(slack_name) + + # First pass: canonical names (so they win slots if we hit the cap). + for cmd in COMMAND_REGISTRY: + if not _is_gateway_available(cmd, overrides): + continue + _add(cmd.name, cmd.description, cmd.args_hint or "") + + # Second pass: aliases. + for cmd in COMMAND_REGISTRY: + if not _is_gateway_available(cmd, overrides): + continue + for alias in cmd.aliases: + # Skip aliases that only differ from canonical by case/punctuation + # normalization (already covered by _add dedup). + _add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "") + + # Third pass: plugin commands. + for name, description, args_hint in _iter_plugin_command_entries(): + _add(name, description, args_hint or "") + + return entries + + +def slack_app_manifest(request_url: str = "https://hermes-agent.local/slack/commands") -> dict[str, Any]: + """Generate a Slack app manifest with all gateway commands as slashes. + + ``request_url`` is required by Slack's manifest schema for every slash + command, but in Socket Mode (which we use) Slack ignores it and routes + the command event through the WebSocket. A placeholder URL is fine. + + The returned dict is the ``features.slash_commands`` portion only — + callers compose it into a full manifest (or merge into an existing + one). Keeping it narrow avoids coupling us to the rest of the manifest + schema (display_information, oauth_config, settings, etc.) which users + set up once in the Slack UI and rarely change. + """ + slashes = [] + for name, desc, usage in slack_native_slashes(): + entry = { + "command": f"/{name}", + "description": desc or f"Run /{name}", + "should_escape": False, + "url": request_url, + } + if usage: + entry["usage_hint"] = usage + slashes.append(entry) + return {"features": {"slash_commands": slashes}} + + def slack_subcommand_map() -> dict[str, str]: """Return subcommand -> /command mapping for Slack /hermes handler. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9c4b40de27..e10af44cd9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4780,6 +4780,37 @@ def cmd_webhook(args): webhook_command(args) +def cmd_slack(args): + """Slack integration helpers. + + Dispatches ``hermes slack <subcommand>``. Currently supports: + manifest — print or write a Slack app manifest with every gateway + command registered as a first-class slash. + """ + sub = getattr(args, "slack_command", None) + if sub in (None, ""): + # No subcommand — print usage hint. + print( + "usage: hermes slack <subcommand>\n" + "\n" + "subcommands:\n" + " manifest Generate a Slack app manifest with every gateway\n" + " command registered as a native slash\n" + "\n" + "Run `hermes slack manifest -h` for details.", + file=sys.stderr, + ) + return 1 + + if sub == "manifest": + from hermes_cli.slack_cli import slack_manifest_command + + return slack_manifest_command(args) + + print(f"Unknown slack subcommand: {sub}", file=sys.stderr) + return 1 + + def cmd_hooks(args): """Shell-hook inspection and management.""" from hermes_cli.hooks import hooks_command @@ -7798,6 +7829,54 @@ For more help on a command: ) whatsapp_parser.set_defaults(func=cmd_whatsapp) + # ========================================================================= + # slack command + # ========================================================================= + slack_parser = subparsers.add_parser( + "slack", + help="Slack integration helpers (manifest generation, etc.)", + description="Slack integration helpers for Hermes.", + ) + slack_sub = slack_parser.add_subparsers(dest="slack_command") + slack_manifest = slack_sub.add_parser( + "manifest", + help="Print or write a Slack app manifest with every gateway command " + "registered as a native slash (/btw, /stop, /model, ...)", + description=( + "Generate a Slack app manifest that registers every gateway " + "command in COMMAND_REGISTRY as a first-class Slack slash " + "command (matching Discord and Telegram parity). Paste the " + "output into Slack app config → Features → App Manifest → " + "Edit, then Save. Reinstall the app if Slack prompts for it." + ), + ) + slack_manifest.add_argument( + "--write", + nargs="?", + const=True, + default=None, + metavar="PATH", + help="Write manifest to a file instead of stdout. With no PATH " + "writes to $HERMES_HOME/slack-manifest.json.", + ) + slack_manifest.add_argument( + "--name", + default=None, + help='Bot display name (default: "Hermes")', + ) + slack_manifest.add_argument( + "--description", + default=None, + help="Bot description shown in Slack's app directory.", + ) + slack_manifest.add_argument( + "--slashes-only", + action="store_true", + help="Emit only the features.slash_commands array (for merging " + "into an existing manifest manually).", + ) + slack_parser.set_defaults(func=cmd_slack) + # ========================================================================= # login command # ========================================================================= diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 0fa1f8abb2..2c4d28e027 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1856,27 +1856,32 @@ def _setup_slack(): if existing: print_info("Slack: already configured") if not prompt_yes_no("Reconfigure Slack?", False): + # Even without reconfiguring, offer to refresh the manifest so + # new commands (e.g. /btw, /stop, ...) get registered in Slack. + if prompt_yes_no( + "Regenerate the Slack app manifest with the latest command " + "list? (recommended after `hermes update`)", + True, + ): + _write_slack_manifest_and_instruct() return print_info("Steps to create a Slack app:") - print_info(" 1. Go to https://api.slack.com/apps → Create New App (from scratch)") + print_info(" 1. Go to https://api.slack.com/apps → Create New App") + print_info(" Pick 'From an app manifest' — we'll generate one for you below.") print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable") print_info(" • Create an App-Level Token with 'connections:write' scope") - print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions") - print_info(" Required scopes: chat:write, app_mentions:read,") - print_info(" channels:history, channels:read, im:history,") - print_info(" im:read, im:write, users:read, files:read, files:write") - print_info(" Optional for private channels: groups:history") - print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable") - print_info(" Required events: message.im, message.channels, app_mention") - print_info(" Optional for private channels: message.groups") - print_warning(" ⚠ Without message.channels the bot will ONLY work in DMs,") - print_warning(" not public channels.") - print_info(" 5. Install to Workspace: Settings → Install App") - print_info(" 6. Reinstall the app after any scope or event changes") - print_info(" 7. After installing, invite the bot to channels: /invite @YourBot") + print_info(" 3. Install to Workspace: Settings → Install App") + print_info(" 4. After installing, invite the bot to channels: /invite @YourBot") print() print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/") + print() + + # Generate and write manifest up-front so the user can paste it into + # the "Create from manifest" flow instead of clicking through scopes / + # events / slash commands one at a time. + _write_slack_manifest_and_instruct() + print() bot_token = prompt("Slack Bot Token (xoxb-...)", password=True) if not bot_token: @@ -1902,6 +1907,49 @@ def _setup_slack(): print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.") +def _write_slack_manifest_and_instruct(): + """Generate the Slack manifest, write it under HERMES_HOME, and print + paste-into-Slack instructions. + + Exposed as its own helper so both the initial setup flow and the + "reconfigure? → no" branch can refresh the manifest without the user + re-entering tokens. Failures are non-fatal — if the manifest write + fails for any reason, we print a warning and skip rather than abort + the whole Slack setup. + """ + try: + from hermes_cli.slack_cli import _build_full_manifest + from hermes_constants import get_hermes_home + + manifest = _build_full_manifest( + bot_name="Hermes", + bot_description="Your Hermes agent on Slack", + ) + target = Path(get_hermes_home()) / "slack-manifest.json" + target.parent.mkdir(parents=True, exist_ok=True) + import json as _json + target.write_text( + _json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print_success(f"Slack app manifest written to: {target}") + print_info( + " Paste it into https://api.slack.com/apps → your app → Features " + "→ App Manifest → Edit, then Save. Slack will prompt to " + "reinstall if scopes or slash commands changed." + ) + print_info( + " Re-run `hermes slack manifest --write` anytime to refresh after " + "Hermes adds new commands." + ) + except Exception as exc: # pragma: no cover - best-effort UX helper + print_warning(f"Couldn't write Slack manifest: {exc}") + print_info( + " You can generate it manually later with: " + "hermes slack manifest --write" + ) + + def _setup_matrix(): """Configure Matrix credentials.""" print_header("Matrix") diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py new file mode 100644 index 0000000000..d76f8a6e06 --- /dev/null +++ b/hermes_cli/slack_cli.py @@ -0,0 +1,152 @@ +"""``hermes slack ...`` CLI subcommands. + +Today only ``hermes slack manifest`` is implemented — it generates the +Slack app manifest JSON for registering every gateway command as a native +Slack slash (``/btw``, ``/stop``, ``/model``, …) so users get the same +first-class slash UX Discord and Telegram already have. + +Typical workflow:: + + $ hermes slack manifest > slack-manifest.json + # or: + $ hermes slack manifest --write + +Then paste the printed JSON into the Slack app config (Features → App +Manifest → Edit) and click Save. Slack diffs the manifest and prompts +for reinstall when scopes/commands change. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def _build_full_manifest(bot_name: str, bot_description: str) -> dict: + """Build a full Slack manifest merging display info + our slash list. + + The slash-command list is always generated from ``COMMAND_REGISTRY`` so + it stays in sync with the rest of Hermes. Other manifest sections + (display info, OAuth scopes, socket mode) are set to sensible defaults + for a Hermes deployment — users can tweak them in the Slack UI after + pasting. + """ + from hermes_cli.commands import slack_app_manifest + + partial = slack_app_manifest() + slashes = partial["features"]["slash_commands"] + + return { + "_metadata": { + "major_version": 1, + "minor_version": 1, + }, + "display_information": { + "name": bot_name[:35], + "description": (bot_description or "Your Hermes agent on Slack")[:140], + "background_color": "#1a1a2e", + }, + "features": { + "bot_user": { + "display_name": bot_name[:80], + "always_online": True, + }, + "slash_commands": slashes, + "assistant_view": { + "assistant_description": "Chat with Hermes in threads and DMs.", + }, + }, + "oauth_config": { + "scopes": { + "bot": [ + "app_mentions:read", + "assistant:write", + "channels:history", + "channels:read", + "chat:write", + "commands", + "files:read", + "files:write", + "groups:history", + "im:history", + "im:read", + "im:write", + "users:read", + ], + }, + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "app_mention", + "assistant_thread_context_changed", + "assistant_thread_started", + "message.channels", + "message.groups", + "message.im", + ], + }, + "interactivity": { + "is_enabled": True, + }, + "org_deploy_enabled": False, + "socket_mode_enabled": True, + "token_rotation_enabled": False, + }, + } + + +def slack_manifest_command(args) -> int: + """Print or write a Slack app manifest JSON. + + Flags (all parsed in ``hermes_cli/main.py``): + --write [PATH] Write to file instead of stdout (default path: + ``$HERMES_HOME/slack-manifest.json``) + --name NAME Override the bot display name (default: "Hermes") + --description DESC Override the bot description + --slashes-only Emit only the ``features.slash_commands`` array (for + merging into an existing manifest manually) + """ + name = getattr(args, "name", None) or "Hermes" + description = getattr(args, "description", None) or "Your Hermes agent on Slack" + + if getattr(args, "slashes_only", False): + from hermes_cli.commands import slack_app_manifest + + manifest = slack_app_manifest()["features"]["slash_commands"] + else: + manifest = _build_full_manifest(name, description) + + payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + + write_target = getattr(args, "write", None) + if write_target is not None: + if isinstance(write_target, bool) and write_target: + # --write with no value → default location + try: + from hermes_constants import get_hermes_home + + target = Path(get_hermes_home()) / "slack-manifest.json" + except Exception: + target = Path.home() / ".hermes" / "slack-manifest.json" + else: + target = Path(write_target).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(payload, encoding="utf-8") + print(f"Slack manifest written to: {target}", file=sys.stderr) + print( + "\nNext steps:\n" + " 1. Open https://api.slack.com/apps and pick your Hermes app\n" + " (or create a new one: Create New App → From an app manifest).\n" + f" 2. Features → App Manifest → paste the contents of\n" + f" {target}\n" + " 3. Save; Slack will prompt to reinstall the app if scopes or\n" + " slash commands changed.\n" + " 4. Make sure Socket Mode is enabled and you have a bot token\n" + " (xoxb-...) and app token (xapp-...) configured via\n" + " `hermes setup`.\n", + file=sys.stderr, + ) + else: + sys.stdout.write(payload) + return 0 diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index cdd27364b7..877d100d6f 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -147,7 +147,20 @@ class TestAppMentionHandler: assert "app_mention" in registered_events assert "assistant_thread_started" in registered_events assert "assistant_thread_context_changed" in registered_events - assert "/hermes" in registered_commands + # Slack slash commands are registered via a single regex matcher + # covering every COMMAND_REGISTRY entry (e.g. /hermes, /btw, /stop, + # /model, ...) so users get native-slash parity with Discord and + # Telegram. Verify the regex matches the key expected slashes. + assert len(registered_commands) == 1, ( + f"expected 1 combined slash matcher, got {registered_commands!r}" + ) + slash_matcher = registered_commands[0] + import re as _re + assert isinstance(slash_matcher, _re.Pattern) + for expected in ("/hermes", "/btw", "/stop", "/model", "/help"): + assert slash_matcher.match(expected), ( + f"Slack slash regex does not match {expected}" + ) class TestSlackConnectCleanup: @@ -1544,6 +1557,83 @@ class TestSlashCommands: msg = adapter.handle_message.call_args[0][0] assert msg.text == "/reasoning" + # ------------------------------------------------------------------ + # Native slash commands — /btw, /stop, /model, ... dispatched directly + # instead of as /hermes subcommands. This is the Discord/Telegram parity + # fix: the slash name itself becomes the command. + # ------------------------------------------------------------------ + + @pytest.mark.asyncio + async def test_native_btw_slash(self, adapter): + """/btw with args must dispatch to /background, not /hermes btw.""" + command = { + "command": "/btw", + "text": "fix the failing test", + "user_id": "U1", + "channel_id": "C1", + } + await adapter._handle_slash_command(command) + msg = adapter.handle_message.call_args[0][0] + # The gateway command dispatcher resolves /btw -> background via + # resolve_command() — our handler's job is just to deliver + # "/btw <args>" to the gateway runner, which is what this asserts. + assert msg.text == "/btw fix the failing test" + + @pytest.mark.asyncio + async def test_native_stop_slash_no_args(self, adapter): + command = { + "command": "/stop", + "text": "", + "user_id": "U1", + "channel_id": "C1", + } + await adapter._handle_slash_command(command) + msg = adapter.handle_message.call_args[0][0] + assert msg.text == "/stop" + + @pytest.mark.asyncio + async def test_native_model_slash_with_args(self, adapter): + command = { + "command": "/model", + "text": "anthropic/claude-sonnet-4", + "user_id": "U1", + "channel_id": "C1", + } + await adapter._handle_slash_command(command) + msg = adapter.handle_message.call_args[0][0] + assert msg.text == "/model anthropic/claude-sonnet-4" + + @pytest.mark.asyncio + async def test_legacy_hermes_prefix_still_works(self, adapter): + """Backward compat: /hermes btw foo must still route to /btw foo. + + Old workspace manifests only declared /hermes as the single slash. + After users refresh their manifest they get /btw natively, but the + legacy form must keep working during the transition. + """ + command = { + "command": "/hermes", + "text": "btw run the tests", + "user_id": "U1", + "channel_id": "C1", + } + await adapter._handle_slash_command(command) + msg = adapter.handle_message.call_args[0][0] + assert msg.text == "/btw run the tests" + + @pytest.mark.asyncio + async def test_legacy_hermes_freeform_question(self, adapter): + """/hermes <free-form text> must stay as the raw text (non-command).""" + command = { + "command": "/hermes", + "text": "what's the weather today?", + "user_id": "U1", + "channel_id": "C1", + } + await adapter._handle_slash_command(command) + msg = adapter.handle_message.call_args[0][0] + assert msg.text == "what's the weather today?" + # --------------------------------------------------------------------------- # TestMessageSplitting diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index d77a076ebf..26bba9d58f 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -20,6 +20,8 @@ from hermes_cli.commands import ( discord_skill_commands, gateway_help_lines, resolve_command, + slack_app_manifest, + slack_native_slashes, slack_subcommand_map, telegram_bot_commands, telegram_menu_commands, @@ -256,6 +258,115 @@ class TestSlackSubcommandMap: assert cmd.name not in mapping +class TestSlackNativeSlashes: + """Slack native slash command generation — used to register every + COMMAND_REGISTRY entry as a first-class Slack slash, matching Discord + and Telegram.""" + + def test_returns_triples(self): + slashes = slack_native_slashes() + assert len(slashes) >= 10 + for entry in slashes: + assert isinstance(entry, tuple) and len(entry) == 3 + name, desc, hint = entry + assert isinstance(name, str) and name + assert isinstance(desc, str) + assert isinstance(hint, str) + + def test_hermes_catchall_is_first(self): + """``/hermes`` must be reserved as the first slot so the legacy + ``/hermes <subcommand>`` form keeps working after we add new + commands and hit the 50-slash cap.""" + slashes = slack_native_slashes() + assert slashes[0][0] == "hermes" + + def test_names_respect_slack_limits(self): + for name, _desc, _hint in slack_native_slashes(): + # Slack: lowercase a-z, 0-9, hyphens, underscores; max 32 chars + assert len(name) <= 32, f"slash {name!r} exceeds 32 chars" + assert name == name.lower() + for ch in name: + assert ch.isalnum() or ch in "-_", f"invalid char {ch!r} in {name!r}" + + def test_under_fifty_command_cap(self): + """Slack allows at most 50 slash commands per app.""" + assert len(slack_native_slashes()) <= 50 + + def test_unique_names(self): + names = [n for n, _d, _h in slack_native_slashes()] + assert len(names) == len(set(names)), "duplicate Slack slash names" + + def test_includes_canonical_commands(self): + names = {n for n, _d, _h in slack_native_slashes()} + # Sample of gateway-available canonical commands + for expected in ("new", "stop", "background", "model", "help", "status"): + assert expected in names, f"missing canonical /{expected}" + + def test_includes_aliases_as_first_class_slashes(self): + """Aliases (/btw, /bg, /reset, /q) must be registered as standalone + slashes — this is the whole point of native-slashes parity.""" + names = {n for n, _d, _h in slack_native_slashes()} + assert "btw" in names + assert "bg" in names + assert "reset" in names + assert "q" in names + + def test_telegram_parity(self): + """Every Telegram bot command must be registerable on Slack too. + + This catches the old behavior where Slack users couldn't invoke + commands like /btw natively. If a future command surfaces on + Telegram but not Slack (because of Slack's 50-slash cap), this + test fails loudly so we can curate the list rather than silently + dropping parity. + """ + slack_names = {n for n, _d, _h in slack_native_slashes()} + tg_names = {n for n, _d in telegram_bot_commands()} + # Some Telegram names have underscores where Slack uses hyphens + # (e.g. set_home vs sethome). Normalize both sides for comparison. + def _norm(s: str) -> str: + return s.replace("-", "_").replace("__", "_").strip("_") + + slack_norm = {_norm(n) for n in slack_names} + tg_norm = {_norm(n) for n in tg_names} + missing = tg_norm - slack_norm + assert not missing, ( + f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}" + ) + + +class TestSlackAppManifest: + """Generated Slack app manifest (used by `hermes slack manifest`).""" + + def test_returns_dict(self): + m = slack_app_manifest() + assert isinstance(m, dict) + assert "features" in m + assert "slash_commands" in m["features"] + + def test_each_slash_has_required_fields(self): + m = slack_app_manifest() + for entry in m["features"]["slash_commands"]: + assert entry["command"].startswith("/") + assert "description" in entry + assert "url" in entry + # should_escape must be present (Slack defaults to True which + # HTML-escapes args — we want the raw text) + assert "should_escape" in entry + + def test_btw_is_in_manifest(self): + """Regression: /btw must be a native Slack slash, not just a + /hermes subcommand.""" + m = slack_app_manifest() + commands = [c["command"] for c in m["features"]["slash_commands"]] + assert "/btw" in commands + + def test_custom_request_url(self): + m = slack_app_manifest(request_url="https://example.com/slack") + for entry in m["features"]["slash_commands"]: + assert entry["url"] == "https://example.com/slack" + + # --------------------------------------------------------------------------- # Config-gated gateway commands # --------------------------------------------------------------------------- diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 947994844b..9a804859eb 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -41,6 +41,7 @@ hermes [global-options] <command> [subcommand/options] | `hermes gateway` | Run or manage the messaging gateway service. | | `hermes setup` | Interactive setup wizard for all or part of the configuration. | | `hermes whatsapp` | Configure and pair the WhatsApp bridge. | +| `hermes slack` | Slack helpers (currently: generate the app manifest with every command as a native slash). | | `hermes auth` | Manage credentials — add, list, remove, reset, set strategy. Handles OAuth flows for Codex/Nous/Anthropic. | | `hermes login` / `logout` | **Deprecated** — use `hermes auth` instead. | | `hermes status` | Show agent, auth, and platform status. | @@ -221,6 +222,33 @@ hermes whatsapp Runs the WhatsApp pairing/setup flow, including mode selection and QR-code pairing. +## `hermes slack` + +```bash +hermes slack manifest # print manifest to stdout +hermes slack manifest --write # write to ~/.hermes/slack-manifest.json +hermes slack manifest --slashes-only # just the features.slash_commands array +``` + +Generates a Slack app manifest that registers every gateway command in +`COMMAND_REGISTRY` (`/btw`, `/stop`, `/model`, …) as a first-class +Slack slash command — matching Discord and Telegram parity. Paste the +output into your Slack app config at +[https://api.slack.com/apps](https://api.slack.com/apps) → your app → +**Features → App Manifest → Edit**, then **Save**. Slack prompts for +reinstall if scopes or slash commands changed. + +| Flag | Default | Purpose | +|------|---------|---------| +| `--write [PATH]` | stdout | Write to a file instead of stdout. Bare `--write` writes `$HERMES_HOME/slack-manifest.json`. | +| `--name NAME` | `Hermes` | Bot display name in Slack. | +| `--description DESC` | default blurb | Bot description shown in the Slack app directory. | +| `--slashes-only` | off | Emit only `features.slash_commands` for merging into a manually-maintained manifest. | + +Run `hermes slack manifest --write` again after `hermes update` to pick +up any new commands. + + ## `hermes login` / `hermes logout` *(Deprecated)* :::caution diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index a7eff683da..2f598fcfe9 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -29,13 +29,36 @@ the steps below. ## Step 1: Create a Slack App +The fastest path is to paste a manifest Hermes generates for you. It +declares every built-in slash command (`/btw`, `/stop`, `/model`, …), +every required OAuth scope, every event subscription, and enables Socket +Mode — all at once. + +### Option A: From a Hermes-generated manifest (recommended) + +1. Generate the manifest: + ```bash + hermes slack manifest --write + ``` + This writes `~/.hermes/slack-manifest.json` and prints paste-in + instructions. +2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) → + **Create New App** → **From an app manifest** +3. Pick your workspace, paste the JSON contents, review, click **Next** + → **Create** +4. Skip ahead to **Step 6: Install App to Workspace**. The manifest + handled scopes, events, and slash commands for you. + +### Option B: From scratch (manual) + 1. Go to [https://api.slack.com/apps](https://api.slack.com/apps) 2. Click **Create New App** 3. Choose **From scratch** 4. Enter an app name (e.g., "Hermes Agent") and select your workspace 5. Click **Create App** -You'll land on the app's **Basic Information** page. +You'll land on the app's **Basic Information** page. Continue with +Steps 2–6 below. --- @@ -203,6 +226,57 @@ The bot will **not** automatically join channels. You must invite it to each cha --- +## Slash Commands + +Every Hermes command (`/btw`, `/stop`, `/new`, `/model`, `/help`, ...) +is a native Slack slash command — exactly the way they work on Telegram +and Discord. Type `/` in Slack and the autocomplete picker lists every +Hermes command with its description. + +Under the hood: Hermes ships with a generated Slack app manifest (see +Step 1, Option A) that declares every command in +[`COMMAND_REGISTRY`](https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/commands.py) +as a slash command. In Socket Mode, Slack routes the command event +through the WebSocket regardless of the manifest's `url` field. + +### Refreshing slash commands after updates + +When Hermes adds new commands (e.g. after `hermes update`), regenerate +the manifest and update your Slack app: + +```bash +hermes slack manifest --write +``` + +Then in Slack: +1. Open [https://api.slack.com/apps](https://api.slack.com/apps) → + your Hermes app +2. **Features → App Manifest → Edit** +3. Paste the new contents of `~/.hermes/slack-manifest.json` +4. **Save**. Slack will prompt to reinstall the app if scopes or slash + commands changed. + +### Legacy `/hermes <subcommand>` still works + +For backward compatibility with older manifests, you can still type +`/hermes btw run the tests` — Hermes routes it the same way as `/btw +run the tests`. Free-form questions also work: `/hermes what's the +weather?` is treated as a regular message. + +### Advanced: emit only the slash-commands array + +If you maintain your Slack manifest by hand and just want the slash +command list: + +```bash +hermes slack manifest --slashes-only > /tmp/slashes.json +``` + +Paste that array into the `features.slash_commands` key of your +existing manifest. + +--- + ## How the Bot Responds Understanding how Hermes behaves in different contexts: