diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6db1130b5cd..c6975d39fe6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1271,6 +1271,21 @@ DEFAULT_CONFIG = { # global threshold regardless. }, + # Kanban subsystem (orchestrator workers + dispatcher-driven child tasks). + # See tools/kanban_tools.py and hermes_cli/kanban_db.py for the actual + # implementations. Per-platform notification opt-out is handled by the + # kanban dashboard (see ``hermes dashboard`` -> Notifications). + "kanban": { + # Auto-subscribe the originating gateway/TUI session to task + # completion + block events when ``kanban_create`` is called from + # inside a session that has a persistent delivery channel. The + # agent that dispatched the task will get notified automatically + # instead of having to poll. Disable to mirror pre-feature + # behaviour — e.g. for a profile that prefers explicit + # ``kanban_notify-subscribe`` calls per task. + "auto_subscribe_on_create": True, + }, + # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). # cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored. "prompt_caching": { diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 2bf89449905..e9b41f812bb 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -1812,3 +1812,193 @@ def test_board_param_in_all_schemas(): assert "board" not in schema["parameters"].get("required", []), ( f"{schema['name']} marks board as required; must be optional" ) + + +# --------------------------------------------------------------------------- +# kanban_create auto-subscribe behaviour +# +# When a worker calls kanban_create from inside a session that has a +# persistent delivery channel, the originating session should be +# subscribed to the new task's completion/block events automatically. +# - Gateway sessions: HERMES_SESSION_PLATFORM + HERMES_SESSION_CHAT_ID set. +# - TUI sessions: HERMES_SESSION_KEY (or HERMES_SESSION_ID) set, with +# the platform/chat_id ContextVars intentionally empty. +# - CLI / cron / test sessions: no delivery channel -> no subscription. +# - Config gate kanban.auto_subscribe_on_create: false -> no subscription +# even when the session has a delivery channel. +# --------------------------------------------------------------------------- + +def _list_subs_for_task(task_id): + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + return list(kb.list_notify_subs(conn, task_id)) + finally: + conn.close() + + +def _sub_index(subs): + """Normalise a list of notify-subs (dicts or objects) into dicts + keyed by platform+chat_id, so assertions work regardless of the + return shape.""" + out = [] + for s in subs: + if isinstance(s, dict): + out.append(s) + else: + out.append({ + "platform": getattr(s, "platform", None), + "chat_id": getattr(s, "chat_id", None), + "thread_id": getattr(s, "thread_id", None), + "user_id": getattr(s, "user_id", None), + }) + return out + + +def test_create_subscribes_gateway_session(monkeypatch, worker_env): + """A gateway session (platform + chat_id set) gets auto-subscribed + to its own kanban_create result, and the response surfaces the + ``subscribed`` flag so the orchestrator can react.""" + from tools import kanban_tools as kt + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "chat-42") + monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "thread-7") + monkeypatch.setenv("HERMES_SESSION_USER_ID", "user-9") + + out = kt._handle_create({ + "title": "auto-sub gateway", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + new_tid = d["task_id"] + assert d["subscribed"] is True, d + + subs = _sub_index(_list_subs_for_task(new_tid)) + assert len(subs) == 1 + s = subs[0] + assert s["platform"] == "telegram" + assert s["chat_id"] == "chat-42" + assert s["thread_id"] == "thread-7" + assert s["user_id"] == "user-9" + + +def test_create_subscribes_tui_session_via_session_key(monkeypatch, worker_env): + """TUI / desktop sessions don't have a platform/chat_id (single + local channel), but the parent process exports HERMES_SESSION_KEY. + We should still auto-subscribe, with platform='tui' and + chat_id=.""" + from tools import kanban_tools as kt + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_USER_ID", raising=False) + monkeypatch.setenv("HERMES_SESSION_KEY", "tui-session-abc") + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + + out = kt._handle_create({ + "title": "auto-sub tui", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + new_tid = d["task_id"] + assert d["subscribed"] is True, d + + subs = _sub_index(_list_subs_for_task(new_tid)) + assert len(subs) == 1 + assert subs[0]["platform"] == "tui" + assert subs[0]["chat_id"] == "tui-session-abc" + + +def test_create_does_not_subscribe_in_cli_session(monkeypatch, worker_env): + """CLI / cron / test sessions have no persistent delivery channel. + _maybe_auto_subscribe returns False and no row is written.""" + from tools import kanban_tools as kt + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_KEY", raising=False) + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + + out = kt._handle_create({ + "title": "no sub cli", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + assert d["subscribed"] is False, d + + assert _list_subs_for_task(d["task_id"]) == [] + + +def test_create_respects_auto_subscribe_on_create_false(monkeypatch, worker_env, tmp_path): + """The config gate kanban.auto_subscribe_on_create=false must + suppress auto-subscription even when the session has a delivery + channel. This is the knob that addresses the upstream design + concern from PR #19718 (reverted in #19721) — users who want + explicit kanban_notify-subscribe calls per task get that.""" + # worker_env already created /.hermes; use a fresh sibling + # home to avoid mkdir() colliding with the worker's directory. + home = tmp_path / "gate-home" / ".hermes" + home.mkdir(parents=True) + (home / "config.yaml").write_text( + "kanban:\n auto_subscribe_on_create: false\n" + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "channel-1") + + from tools import kanban_tools as kt + out = kt._handle_create({ + "title": "no sub gated", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + assert d["subscribed"] is False, d + + assert _list_subs_for_task(d["task_id"]) == [] + + +def test_create_partial_session_context_no_subscribe(monkeypatch, worker_env): + """Only one of (platform, chat_id) set -> no implicit subscribe. + Either both are set (gateway) or neither (TUI / CLI); partial is + ambiguous and the safe default is to skip.""" + from tools import kanban_tools as kt + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "slack") + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_KEY", raising=False) + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + + out = kt._handle_create({ + "title": "no sub partial", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + assert d["subscribed"] is False, d + + +def test_maybe_auto_subscribe_swallows_add_notify_sub_failure(monkeypatch, worker_env): + """If add_notify_sub itself raises (e.g. DB locked, schema drift), + _maybe_auto_subscribe must NOT bubble that up and fail the parent + kanban_create. The function returns False and the parent create + still succeeds with subscribed=False.""" + from tools import kanban_tools as kt + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "chat-42") + + from hermes_cli import kanban_db as kb + + def _boom(*a, **kw): + raise RuntimeError("simulated DB failure") + + monkeypatch.setattr(kb, "add_notify_sub", _boom) + + out = kt._handle_create({ + "title": "auto-sub tolerates add_notify_sub failure", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True, d + assert d["subscribed"] is False, d diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 67157dfc1c6..15988bcba89 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -34,6 +34,7 @@ import os from typing import Any, Optional from tools.registry import registry, tool_error +from hermes_cli.config import cfg_get, load_config logger = logging.getLogger(__name__) @@ -818,9 +819,11 @@ def _handle_create(args: dict, **kw) -> str: session_id=session_id, ) new_task = kb.get_task(conn, new_tid) + subscribed = _maybe_auto_subscribe(conn, new_tid) return _ok( task_id=new_tid, status=new_task.status if new_task else None, + subscribed=subscribed, ) finally: conn.close() @@ -831,6 +834,102 @@ def _handle_create(args: dict, **kw) -> str: return tool_error(f"kanban_create: {e}") +def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool: + """Auto-subscribe the calling session to task completion / block events. + + Returns True if a subscription row was written, False otherwise (no + session context, config gate disabled, or best-effort failure). The + caller surfaces this in the ``subscribed`` field of the kanban_create + response so an orchestrator can decide whether to fall back to an + explicit ``kanban_notify-subscribe`` or to polling. + + Gated by ``kanban.auto_subscribe_on_create`` in config.yaml (default + True). Disable to mirror pre-feature behaviour, e.g. when the + originating user/chat opted out via the per-platform notification + toggle (see ``hermes dashboard``). + + Subscription paths: + + - **Gateway** (telegram/discord/slack/etc): ``HERMES_SESSION_PLATFORM`` + and ``HERMES_SESSION_CHAT_ID`` are set in ContextVars by the + messaging gateway before agent dispatch. The notification poller + already keys off these, so we just register a row. + + - **TUI** (herm desktop / herm TUI): the platform/chat_id ContextVars + are intentionally cleared (TUI is a single-channel local UI, not + a multi-tenant chat surface), but the agent subprocess inherits + ``HERMES_SESSION_KEY`` from the parent session. We subscribe with + ``platform="tui"`` and ``chat_id=``; the TUI notification + poller (``tui_gateway/server.py``) reads ``kanban_notify_subs`` + for these rows and posts the completion message into the running + session. + + - **CLI / cron / test / unattached**: no persistent delivery channel, + no-op. + + Failure mode: any exception inside the function is logged at WARNING + with the offending exception + diagnostic env vars and swallowed. + We never want a notification bookkeeping failure to fail the + kanban_create that the agent is mid-conversation about. + """ + try: + cfg = load_config() + if not cfg_get(cfg, "kanban", "auto_subscribe_on_create", default=True): + return False + except Exception: + # If config can't load we still default to True — this is the + # user-friendly behaviour that mirrors the pre-gate implementation. + pass + + platform = "" + chat_id = "" + try: + from gateway.session_context import get_session_env + platform = get_session_env("HERMES_SESSION_PLATFORM", "") + chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "") + if not platform or not chat_id: + # TUI / desktop fallback: platform/chat_id ContextVars are + # cleared for TUI sessions, but the parent process exports + # HERMES_SESSION_KEY into the subprocess env. Treat that + # as a "tui" subscription so the TUI notification poller + # (tui_gateway/server.py) can pick it up. + # + # HERMES_SESSION_ID is intentionally NOT a fallback here: + # it is set by ACP / the agent subprocess for telemetry + # regardless of whether the parent is a TUI or a CLI, so + # treating it as a notification target would auto-subscribe + # every CLI invocation, which is exactly the over-eager + # behaviour that got #19718 reverted upstream. The TUI + # poller keys on HERMES_SESSION_KEY. + session_key = ( + get_session_env("HERMES_SESSION_KEY", "") + or os.environ.get("HERMES_SESSION_KEY", "") + ) + if not session_key: + return False # CLI / cron / test — no persistent channel + platform = "tui" + chat_id = session_key + thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "") or None + user_id = get_session_env("HERMES_SESSION_USER_ID", "") or None + notifier_profile = os.environ.get("HERMES_PROFILE") + + # Lazy-import to keep the module-level dependency light + from hermes_cli import kanban_db as _kb + _kb.add_notify_sub( + conn, task_id=task_id, + platform=platform, chat_id=chat_id, + thread_id=thread_id, user_id=user_id, + notifier_profile=notifier_profile, + ) + return True + except Exception as _exc: + logger.warning( + "_maybe_auto_subscribe failed: %r (platform=%r key_set=%r)", + _exc, platform, bool(chat_id), + ) + return False + + def _handle_unblock(args: dict, **kw) -> str: """Transition a blocked task back to ready.""" guard = _require_orchestrator_tool("kanban_unblock") diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index d59438a7171..66a1ac0be90 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -535,6 +535,7 @@ Config knobs (all under `kanban:` in `~/.hermes/config.yaml`): | `auto_decompose_per_tick` | `3` | Cap on decompositions per dispatcher tick. Excess defers to the next tick. | | `orchestrator_profile` | `""` | Profile assigned to the root/orchestration task after decomposition. Empty = fall back to active default profile. | | `default_assignee` | `""` | Where a child task lands when the LLM picks an unknown profile. Empty = fall back to active default. | +| `auto_subscribe_on_create` | `true` | When a worker calls `kanban_create` from inside a session with a persistent delivery channel (messaging gateway or TUI), the originating session is auto-subscribed to the new task's completion/block events. The dispatcher still drives the delivery — this only changes whether the caller's chat/key shows up in the notify-sub table. Set to `false` to require explicit `kanban_notify-subscribe` calls per task. | And the two auxiliary LLM slots: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md index 3c5878c089a..febeb213c7b 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md @@ -431,6 +431,7 @@ hermes dashboard # 导航栏中出现 "Kanban" 标签页,位于 "Skills | `auto_decompose_per_tick` | `3` | 每个调度器 tick 的分解上限。超出部分推迟到下一个 tick。 | | `orchestrator_profile` | `""` | 拥有分解权的配置文件。空 = 回退到活动默认配置文件。 | | `default_assignee` | `""` | LLM 选择未知配置文件时子任务的落地位置。空 = 回退到活动默认配置文件。 | +| `auto_subscribe_on_create` | `true` | 当 worker 在具有持久投递通道的会话(消息网关或 TUI)内调用 `kanban_create` 时,原始会话会自动订阅新任务的完成/阻塞事件。调度器仍负责驱动投递 —— 此设置只决定调用者的聊天/密钥是否出现在通知订阅表中。设为 `false` 则要求对每个任务显式调用 `kanban_notify-subscribe`。 | 以及两个辅助 LLM 槽: