mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-19 04:52:06 +00:00
fix(kanban): restrict board routing tools
This commit is contained in:
parent
f7c395931f
commit
ce35185782
5 changed files with 229 additions and 82 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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="🗄",
|
||||
)
|
||||
|
||||
|
|
|
|||
23
toolsets.py
23
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",
|
||||
|
|
|
|||
|
|
@ -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/<slug>/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/<slug>/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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue