diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index e103a06f879..3f2ad314ab9 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -1,8 +1,8 @@ """Tests for the Kanban tool surface (tools/kanban_tools.py). Verifies: - - Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees - zero kanban tools in its schema; a worker session sees the kanban set. + - A normal chat session sees zero kanban tools; dispatcher-spawned + workers see lifecycle tools; orchestrator profiles see board tools. - Each handler's happy path. - Error paths (missing required args, bad metadata type, etc). """ @@ -40,7 +40,7 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path): def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): - """Worker sessions (HERMES_KANBAN_TASK set) must have kanban tools.""" + """Worker sessions get task lifecycle tools, not board-routing tools.""" monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake") home = tmp_path / ".hermes" home.mkdir() @@ -55,17 +55,15 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): names = {s["function"].get("name") for s in schema if "function" in s} kanban = {n for n in names if n and n.startswith("kanban_")} expected = { - "kanban_list", "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", - "kanban_assign", "kanban_unblock", "kanban_archive", } assert kanban == expected, f"expected {expected}, got {kanban}" -def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): - """Orchestrator profiles with toolsets: [kanban] see the same tools.""" - monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) +def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_path): + """Task scope wins over profile config for board-routing tools.""" + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake") home = tmp_path / ".hermes" home.mkdir() (home / "config.yaml").write_text("toolsets:\n - kanban\n") @@ -84,7 +82,32 @@ def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): "kanban_assign", "kanban_unblock", "kanban_archive", - }.issubset(kanban) + }.isdisjoint(kanban) + + +def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): + """Orchestrator profiles with toolsets: [kanban] see all board tools.""" + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text("toolsets:\n - kanban\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + + import tools.kanban_tools # ensure registered + from tools.registry import invalidate_check_fn_cache, registry + from toolsets import resolve_toolset + + invalidate_check_fn_cache() + schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) + names = {s["function"].get("name") for s in schema if "function" in s} + kanban = {n for n in names if n and n.startswith("kanban_")} + expected = { + "kanban_list", + "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", + "kanban_comment", "kanban_create", "kanban_link", + "kanban_assign", "kanban_unblock", "kanban_archive", + } + assert kanban == expected, f"expected {expected}, got {kanban}" # --------------------------------------------------------------------------- @@ -140,8 +163,9 @@ def test_show_explicit_task_id(worker_env): assert d["task"]["id"] == other -def test_list_filters_tasks(worker_env): +def test_list_filters_tasks(monkeypatch, worker_env): """kanban_list gives orchestrators filtered board discovery.""" + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from hermes_cli import kanban_db as kb conn = kb.connect() try: @@ -170,19 +194,81 @@ def test_list_filters_tasks(worker_env): assert tenant_ids == [c] -def test_list_rejects_invalid_status(worker_env): +def test_list_rejects_invalid_status(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from tools import kanban_tools as kt out = kt._handle_list({"status": "not-a-state"}) assert "status must be one of" in json.loads(out).get("error", "") -def test_list_rejects_bad_limit(worker_env): +def test_list_rejects_bad_limit(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from tools import kanban_tools as kt assert json.loads(kt._handle_list({"limit": "nope"})).get("error") assert json.loads(kt._handle_list({"limit": 0})).get("error") + assert json.loads(kt._handle_list({"limit": 201})).get("error") -def test_list_parses_include_archived_string_false(worker_env): +def test_list_defaults_limit_and_reports_truncation(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + for i in range(55): + kb.create_task(conn, title=f"task {i:02d}", assignee="factory") + finally: + conn.close() + + from tools import kanban_tools as kt + d = json.loads(kt._handle_list({"assignee": "factory"})) + assert d["count"] == 50 + assert d["limit"] == 50 + assert d["truncated"] is True + assert d["next_limit"] == 100 + + +def test_list_treats_null_limit_as_default(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + kb.create_task(conn, title="task", assignee="factory") + finally: + conn.close() + + from tools import kanban_tools as kt + d = json.loads(kt._handle_list({"assignee": "factory", "limit": None})) + assert d["count"] == 1 + assert d["limit"] == 50 + assert d["truncated"] is False + + +def test_list_honors_explicit_limit_without_truncation(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + for i in range(3): + kb.create_task(conn, title=f"task {i}", assignee="factory") + finally: + conn.close() + + from tools import kanban_tools as kt + d = json.loads(kt._handle_list({"assignee": "factory", "limit": 10})) + assert d["count"] == 3 + assert d["limit"] == 10 + assert d["truncated"] is False + assert d["next_limit"] is None + + +def test_worker_list_is_orchestrator_only(worker_env): + from tools import kanban_tools as kt + out = kt._handle_list({}) + assert "orchestrator-only" in json.loads(out).get("error", "") + + +def test_list_parses_include_archived_string_false(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from hermes_cli import kanban_db as kb conn = kb.connect() try: @@ -202,7 +288,8 @@ def test_list_parses_include_archived_string_false(worker_env): assert archived not in ids -def test_list_parses_include_archived_string_true(worker_env): +def test_list_parses_include_archived_string_true(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from hermes_cli import kanban_db as kb conn = kb.connect() try: @@ -222,7 +309,8 @@ def test_list_parses_include_archived_string_true(worker_env): assert archived in ids -def test_list_rejects_bad_include_archived(worker_env): +def test_list_rejects_bad_include_archived(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from tools import kanban_tools as kt out = kt._handle_list({"include_archived": "sometimes"}) assert "include_archived must be" in json.loads(out).get("error", "") @@ -588,13 +676,15 @@ def test_assign_can_unassign(monkeypatch, worker_env): assert d["assignee"] is None -def test_assign_rejects_missing_args(worker_env): +def test_assign_rejects_missing_args(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from tools import kanban_tools as kt assert json.loads(kt._handle_assign({"assignee": "x"})).get("error") assert json.loads(kt._handle_assign({"task_id": worker_env})).get("error") -def test_assign_rejects_running_claimed_task(worker_env): +def test_assign_rejects_running_claimed_task(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from tools import kanban_tools as kt out = kt._handle_assign({"task_id": worker_env, "assignee": "reviewer"}) d = json.loads(out) @@ -624,7 +714,8 @@ def test_unblock_happy_path(monkeypatch, worker_env): conn.close() -def test_unblock_rejects_non_blocked_task(worker_env): +def test_unblock_rejects_non_blocked_task(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from tools import kanban_tools as kt out = kt._handle_unblock({"task_id": worker_env}) assert json.loads(out).get("error") @@ -811,12 +902,11 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path): # --------------------------------------------------------------------------- # # A worker process has HERMES_KANBAN_TASK set to its own task id. The -# destructive tools (kanban_complete, kanban_block, kanban_heartbeat, -# kanban_assign, kanban_unblock, kanban_archive) must refuse to operate -# on any OTHER task id, even if the caller supplies an explicit `task_id` -# argument. Workers legitimately call kanban_show / kanban_list / -# kanban_comment / kanban_create / kanban_link on other tasks, so those -# are unrestricted. +# lifecycle tools (kanban_complete, kanban_block, kanban_heartbeat) must +# refuse to operate on any OTHER task id, even if the caller supplies an +# explicit `task_id` argument. Board-routing tools (kanban_list, +# kanban_assign, kanban_unblock, kanban_archive) are orchestrator-only and +# hidden from dispatcher-spawned workers entirely. # # Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally # exempt — their job is routing, and they sometimes close out child @@ -889,8 +979,8 @@ def test_worker_heartbeat_rejects_foreign_task_id(worker_env): assert "refusing to mutate" in d.get("error", "") -def test_worker_assign_rejects_foreign_task_id(worker_env): - """A worker cannot reassign a task that isn't its own.""" +def test_worker_assign_is_orchestrator_only(worker_env): + """A worker cannot use the assignment router, even on its own task.""" from hermes_cli import kanban_db as kb conn = kb.connect() try: @@ -901,7 +991,7 @@ def test_worker_assign_rejects_foreign_task_id(worker_env): from tools import kanban_tools as kt out = kt._handle_assign({"task_id": other, "assignee": "reviewer"}) d = json.loads(out) - assert "refusing to mutate" in d.get("error", "") + assert "orchestrator-only" in d.get("error", "") conn = kb.connect() try: @@ -910,8 +1000,8 @@ def test_worker_assign_rejects_foreign_task_id(worker_env): conn.close() -def test_worker_unblock_rejects_foreign_task_id(worker_env): - """A worker cannot unblock a task that isn't its own.""" +def test_worker_unblock_is_orchestrator_only(worker_env): + """A worker cannot reopen blocked tasks through the router surface.""" from hermes_cli import kanban_db as kb conn = kb.connect() try: @@ -923,7 +1013,7 @@ def test_worker_unblock_rejects_foreign_task_id(worker_env): from tools import kanban_tools as kt out = kt._handle_unblock({"task_id": other}) d = json.loads(out) - assert "refusing to mutate" in d.get("error", "") + assert "orchestrator-only" in d.get("error", "") conn = kb.connect() try: @@ -932,8 +1022,8 @@ def test_worker_unblock_rejects_foreign_task_id(worker_env): conn.close() -def test_worker_archive_rejects_foreign_task_id(worker_env): - """A worker cannot archive a task that isn't its own.""" +def test_worker_archive_is_orchestrator_only(worker_env): + """A worker cannot archive tasks instead of completing or blocking.""" from hermes_cli import kanban_db as kb conn = kb.connect() try: @@ -944,7 +1034,7 @@ def test_worker_archive_rejects_foreign_task_id(worker_env): from tools import kanban_tools as kt out = kt._handle_archive({"task_id": other}) d = json.loads(out) - assert "refusing to mutate" in d.get("error", "") + assert "orchestrator-only" in d.get("error", "") conn = kb.connect() try: diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index e95f53e21ca..47684e43517 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -1,8 +1,10 @@ """Kanban tools — structured tool-call surface for worker + orchestrator agents. -These tools are only registered into the model's schema when the agent is -running under the dispatcher (env var ``HERMES_KANBAN_TASK`` set). A -normal ``hermes chat`` session sees **zero** kanban tools in its schema. +Task lifecycle tools are registered into the model's schema when the +agent is running under the dispatcher (env var ``HERMES_KANBAN_TASK`` +set). Board-routing tools are registered only for non-worker profiles +that explicitly enable the ``kanban`` toolset. A normal ``hermes chat`` +session sees **zero** kanban tools in its schema. Why tools instead of just shelling out to ``hermes kanban``? @@ -20,8 +22,8 @@ Why tools instead of just shelling out to ``hermes kanban``? Humans continue to use the CLI (``hermes kanban …``), the dashboard (``hermes dashboard``), and the slash command (``/kanban …``) — all -three bypass the agent entirely. The tools are ONLY for the worker -agent's handoff back to the kernel. +three bypass the agent entirely. The tools are only for agent handoffs +back to the kernel. """ from __future__ import annotations @@ -39,8 +41,25 @@ logger = logging.getLogger(__name__) # Gating # --------------------------------------------------------------------------- +KANBAN_LIST_DEFAULT_LIMIT = 50 +KANBAN_LIST_MAX_LIMIT = 200 + + +def _profile_has_kanban_toolset() -> bool: + # Uses load_config() which has mtime-based caching, so this adds + # negligible overhead. The check_fn results are further TTL-cached + # (~30s) by the tool registry. + try: + from hermes_cli.config import load_config + cfg = load_config() + toolsets = cfg.get("toolsets", []) + return "kanban" in toolsets + except Exception: + return False + + def _check_kanban_mode() -> bool: - """Tools are available when: + """Task-lifecycle tools are available when: 1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR 2. The current profile has ``kanban`` in its toolsets config @@ -53,18 +72,20 @@ def _check_kanban_mode() -> bool: """ if os.environ.get("HERMES_KANBAN_TASK"): return True + return _profile_has_kanban_toolset() - # Check if the current profile has the kanban toolset enabled. - # Uses load_config() which has mtime-based caching, so this adds - # negligible overhead. The check_fn results are further TTL-cached - # (~30s) by the tool registry. - try: - from hermes_cli.config import load_config - cfg = load_config() - toolsets = cfg.get("toolsets", []) - return "kanban" in toolsets - except Exception: + +def _check_kanban_orchestrator_mode() -> bool: + """Board-routing tools are intentionally hidden from task workers. + + Dispatcher-spawned workers should close their own task via the + lifecycle tools (complete/block/heartbeat), not route or archive board + state. Profiles that explicitly opt into the kanban toolset and are + not scoped to a single task are the orchestrator surface. + """ + if os.environ.get("HERMES_KANBAN_TASK"): return False + return _profile_has_kanban_toolset() # --------------------------------------------------------------------------- @@ -159,6 +180,16 @@ def _parse_bool_arg(args: dict, name: str, *, default: bool = False): return default, f"{name} must be a boolean or 'true'/'false'" +def _require_orchestrator_tool(tool_name: str) -> Optional[str]: + if os.environ.get("HERMES_KANBAN_TASK"): + return tool_error( + f"{tool_name} is orchestrator-only; dispatcher-spawned workers " + "must use kanban_complete, kanban_block, kanban_heartbeat, or " + "kanban_comment for their assigned task." + ) + return None + + def _task_summary_dict(kb, conn, task) -> dict[str, Any]: """Compact task shape for board-listing tools.""" parents = kb.parent_ids(conn, task.id) @@ -261,6 +292,9 @@ def _handle_show(args: dict, **kw) -> str: def _handle_list(args: dict, **kw) -> str: """List task summaries with the same core filters as the CLI.""" + guard = _require_orchestrator_tool("kanban_list") + if guard: + return guard assignee = args.get("assignee") status = args.get("status") tenant = args.get("tenant") @@ -268,6 +302,8 @@ def _handle_list(args: dict, **kw) -> str: if bool_error: return tool_error(bool_error) limit = args.get("limit") + if limit is None: + limit = KANBAN_LIST_DEFAULT_LIMIT if limit is not None: try: limit = int(limit) @@ -275,23 +311,35 @@ def _handle_list(args: dict, **kw) -> str: return tool_error("limit must be an integer") if limit < 1: return tool_error("limit must be >= 1") + if limit > KANBAN_LIST_MAX_LIMIT: + return tool_error(f"limit must be <= {KANBAN_LIST_MAX_LIMIT}") try: kb, conn = _connect() try: # Match CLI list: dependencies that cleared since the last # dispatcher tick should be visible to orchestrators immediately. promoted = kb.recompute_ready(conn) - tasks = kb.list_tasks( + rows = kb.list_tasks( conn, assignee=assignee, status=status, tenant=tenant, include_archived=include_archived, - limit=limit, + # Fetch one extra row so model-facing output can report that + # a bounded listing was truncated without dumping the board. + limit=limit + 1, ) + truncated = len(rows) > limit + tasks = rows[:limit] return json.dumps({ "tasks": [_task_summary_dict(kb, conn, t) for t in tasks], "count": len(tasks), + "limit": limit, + "truncated": truncated, + "next_limit": ( + min(limit * 2, KANBAN_LIST_MAX_LIMIT) + if truncated and limit < KANBAN_LIST_MAX_LIMIT else None + ), "promoted": promoted, }) finally: @@ -539,6 +587,9 @@ def _handle_create(args: dict, **kw) -> str: def _handle_assign(args: dict, **kw) -> str: """Assign or reassign a task. ``assignee`` may be 'none' to unassign.""" + guard = _require_orchestrator_tool("kanban_assign") + if guard: + return guard tid = args.get("task_id") if not tid: return tool_error("task_id is required") @@ -567,6 +618,9 @@ def _handle_assign(args: dict, **kw) -> str: def _handle_unblock(args: dict, **kw) -> str: """Transition a blocked task back to ready.""" + guard = _require_orchestrator_tool("kanban_unblock") + if guard: + return guard tid = args.get("task_id") if not tid: return tool_error("task_id is required") @@ -589,6 +643,9 @@ def _handle_unblock(args: dict, **kw) -> str: def _handle_archive(args: dict, **kw) -> str: """Archive a task so it leaves active board views.""" + guard = _require_orchestrator_tool("kanban_archive") + if guard: + return guard tid = args.get("task_id") if not tid: return tool_error("task_id is required") @@ -668,7 +725,8 @@ KANBAN_LIST_SCHEMA = { "work to route. Supports the same core filters as the CLI: assignee, " "status, tenant, include_archived, and limit. Returns compact rows " "with ids, title, status, assignee, priority, parent/child ids, and " - "counts. Also recomputes ready tasks before listing, matching the CLI." + "counts. Bounded to 50 rows by default, 200 max, with truncation " + "metadata. Also recomputes ready tasks before listing, matching the CLI." ), "parameters": { "type": "object", @@ -695,7 +753,7 @@ KANBAN_LIST_SCHEMA = { }, "limit": { "type": "integer", - "description": "Optional maximum number of tasks to return.", + "description": "Optional maximum rows to return (default 50, max 200).", }, }, "required": [], @@ -983,8 +1041,8 @@ KANBAN_ASSIGN_SCHEMA = { "Assign or reassign a Kanban task to a profile/lane. Pass " "assignee='none' to unassign. Refuses running tasks that still " "have an active claim, matching the CLI/kernel safety guard. " - "Dispatcher-spawned workers may only assign their own task; " - "orchestrator profiles with the kanban toolset can route board work." + "Only orchestrator profiles with the kanban toolset can route board " + "work; dispatcher-spawned task workers never see this tool." ), "parameters": { "type": "object", @@ -1008,9 +1066,9 @@ KANBAN_ASSIGN_SCHEMA = { KANBAN_UNBLOCK_SCHEMA = { "name": "kanban_unblock", "description": ( - "Move a blocked Kanban task back to ready. Dispatcher-spawned " - "workers may only unblock their own task; orchestrator profiles " - "with the kanban toolset can unblock routed work." + "Move a blocked Kanban task back to ready. Only orchestrator profiles " + "with the kanban toolset can unblock routed work; dispatcher-spawned " + "task workers never see this tool." ), "parameters": { "type": "object", @@ -1029,8 +1087,8 @@ KANBAN_ARCHIVE_SCHEMA = { "description": ( "Archive a Kanban task so it leaves active board views. If a run is " "still open, the kernel closes it as reclaimed. Dispatcher-spawned " - "workers may only archive their own task; orchestrator profiles with " - "the kanban toolset can archive routed work." + "task workers never see this tool; orchestrator profiles with the " + "kanban toolset can archive routed work." ), "parameters": { "type": "object", @@ -1080,7 +1138,7 @@ registry.register( toolset="kanban", schema=KANBAN_LIST_SCHEMA, handler=_handle_list, - check_fn=_check_kanban_mode, + check_fn=_check_kanban_orchestrator_mode, emoji="📋", ) @@ -1134,7 +1192,7 @@ registry.register( toolset="kanban", schema=KANBAN_ASSIGN_SCHEMA, handler=_handle_assign, - check_fn=_check_kanban_mode, + check_fn=_check_kanban_orchestrator_mode, emoji="👤", ) @@ -1143,7 +1201,7 @@ registry.register( toolset="kanban", schema=KANBAN_UNBLOCK_SCHEMA, handler=_handle_unblock, - check_fn=_check_kanban_mode, + check_fn=_check_kanban_orchestrator_mode, emoji="▶", ) @@ -1152,7 +1210,7 @@ registry.register( toolset="kanban", schema=KANBAN_ARCHIVE_SCHEMA, handler=_handle_archive, - check_fn=_check_kanban_mode, + check_fn=_check_kanban_orchestrator_mode, emoji="🗄", ) diff --git a/toolsets.py b/toolsets.py index 71414ed58ab..60f6f156120 100644 --- a/toolsets.py +++ b/toolsets.py @@ -60,10 +60,10 @@ _HERMES_CORE_TOOLS = [ "send_message", # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", - # Kanban multi-agent coordination — only in schema when the agent is - # spawned as a kanban worker (HERMES_KANBAN_TASK env set) or the current - # profile explicitly enables the kanban toolset. Gated via check_fn in - # tools/kanban_tools.py. + # Kanban multi-agent coordination. Lifecycle tools are visible to + # dispatcher-spawned workers; board-routing tools are visible only to + # non-worker profiles that explicitly enable the kanban toolset. Gated + # per tool via check_fn in tools/kanban_tools.py. "kanban_show", "kanban_list", "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", @@ -218,14 +218,13 @@ TOOLSETS = { "kanban": { "description": ( - "Kanban multi-agent coordination — only active when the agent " - "is spawned by the kanban dispatcher (HERMES_KANBAN_TASK env " - "set). The dispatcher runs inside the gateway by default; see " - "`kanban.dispatch_in_gateway` in config.yaml. Lets workers mark " - "tasks done with structured handoffs, block for human input, " - "heartbeat during long ops, comment on threads, and (for " - "orchestrators) list, assign, unblock, archive, and fan out " - "tasks." + "Kanban multi-agent coordination. The dispatcher runs inside " + "the gateway by default; see `kanban.dispatch_in_gateway` in " + "config.yaml. Dispatcher-spawned workers get lifecycle tools " + "for show/complete/block/heartbeat/comment/create/link. " + "Non-worker orchestrator profiles that explicitly enable this " + "toolset also get list/assign/unblock/archive board-routing " + "tools." ), "tools": [ "kanban_show", "kanban_list", "kanban_complete", "kanban_block", diff --git a/website/docs/user-guide/features/kanban-tutorial.md b/website/docs/user-guide/features/kanban-tutorial.md index b4e43139244..dcec41ff9a1 100644 --- a/website/docs/user-guide/features/kanban-tutorial.md +++ b/website/docs/user-guide/features/kanban-tutorial.md @@ -10,7 +10,7 @@ hermes dashboard # opens http://127.0.0.1:9119 in your browser # click Kanban in the left nav ``` -The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_assign`, `kanban_unblock`, `kanban_archive`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards//kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from. +The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through dedicated `kanban_*` [lifecycle tools](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`). Orchestrator profiles that opt into the `kanban` toolset also get board-routing tools (`kanban_list`, `kanban_assign`, `kanban_unblock`, `kanban_archive`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards//kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from. This tutorial uses the `default` board throughout. If you want multiple isolated queues (one per project / repo / domain), see [Boards (multi-project)](./kanban#boards-multi-project) in the overview — the same CLI / dashboard / worker flows apply per board, and workers physically cannot see tasks on other boards. diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 0f0378f20a2..77a8c28efb3 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -14,7 +14,7 @@ Hermes Kanban is a durable task board, shared across all your Hermes profiles, t The board has two front doors, both backed by the same `~/.hermes/kanban.db`: -- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_assign`, `kanban_unblock`, `kanban_archive`. The dispatcher spawns each worker with these tools already in its schema; orchestrator profiles can also enable the `kanban` toolset explicitly. The model reads and routes tasks by calling tools directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below. +- **Agents drive the board through a dedicated `kanban_*` toolset** — task workers get lifecycle tools (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`), while orchestrator profiles that explicitly enable `kanban` also get board-routing tools (`kanban_list`, `kanban_assign`, `kanban_unblock`, `kanban_archive`). The model reads and routes tasks by calling tools directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below. - **You (and scripts, and cron) drive the board through `hermes kanban …`** on the CLI, `/kanban …` as a slash command, or the dashboard. These are for humans and automation — the places without a tool-calling model behind them. Both surfaces route through the same `kanban_db` layer, so reads see a consistent view and writes can't drift. The rest of this page shows CLI examples because they're easy to copy-paste, but every CLI verb has a tool-call equivalent the model uses. @@ -231,12 +231,12 @@ hermes kanban block t_abc "need input" --ids t_def t_hij ## How workers interact with the board -**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema. The same toolset is also available to orchestrator profiles that enable `kanban` in their toolsets config. These tools read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI. +**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on dedicated **kanban lifecycle tools** in the model's schema. Orchestrator profiles that enable `kanban` in their toolsets config also get board-routing tools for listing, assignment, unblocking, and archival. These tools read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI. | Tool | Purpose | Required params | |---|---|---| | `kanban_show` | Read the current task (title, body, prior attempts, parent handoffs, comments, full pre-formatted `worker_context`). Defaults to the env's task id. | — | -| `kanban_list` | List task summaries with filters for `assignee`, `status`, `tenant`, archived visibility, and limit. Intended for orchestrators discovering board work. | — | +| `kanban_list` | (Orchestrators) list task summaries with filters for `assignee`, `status`, `tenant`, archived visibility, and limit. Defaults to 50 rows, max 200, and reports truncation metadata. | — | | `kanban_complete` | Finish with `summary` + `metadata` structured handoff. | at least one of `summary` / `result` | | `kanban_block` | Escalate for human input with a `reason`. | `reason` | | `kanban_heartbeat` | Signal liveness during long operations. Pure side-effect. | — | @@ -282,7 +282,7 @@ kanban_create( kanban_complete(summary="decomposed into 2 research tasks + 1 writer; linked dependencies") ``` -The "(Orchestrators)" tools — `kanban_list`, `kanban_create`, `kanban_link`, `kanban_assign`, `kanban_unblock`, `kanban_archive`, and `kanban_comment` on foreign tasks — are available through the same toolset; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out or route unrelated work, and orchestrator profiles don't execute implementation work. Dispatcher-spawned workers are still task-scoped for destructive lifecycle operations and cannot mutate unrelated tasks. +The "(Orchestrators)" tools — `kanban_list`, `kanban_assign`, `kanban_unblock`, and `kanban_archive` — are only exposed to profiles that explicitly enable the `kanban` toolset and are not scoped to a dispatcher task. Dispatcher-spawned workers still get task lifecycle tools (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`) and remain task-scoped for destructive lifecycle operations. ### Why tools instead of shelling to `hermes kanban` @@ -292,7 +292,7 @@ Three reasons: 2. **No shell-quoting fragility.** Passing `--metadata '{"files": [...]}'` through shlex + argparse is a latent footgun. Structured tool args skip it entirely. 3. **Better errors.** Tool results are structured JSON the model can reason about, not stderr strings it has to parse. -**Zero schema footprint on normal sessions.** A regular `hermes chat` session has zero `kanban_*` tools in its schema. The `check_fn` on each tool only returns True when `HERMES_KANBAN_TASK` is set, which only happens when the dispatcher spawned this process. No tool bloat for users who never touch kanban. +**Zero schema footprint on normal sessions.** A regular `hermes chat` session has zero `kanban_*` tools in its schema. Lifecycle tool `check_fn`s return true when `HERMES_KANBAN_TASK` is set, which only happens when the dispatcher spawned this process. Board-routing tool `check_fn`s return true only for non-worker profiles that explicitly enable the `kanban` toolset. No tool bloat for users who never touch kanban. The `kanban-worker` and `kanban-orchestrator` skills teach the model which tool to call when and in what order.