mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
feat(kanban): add orchestrator board tools
This commit is contained in:
parent
44cdf555a8
commit
236cbe16b6
5 changed files with 321 additions and 22 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Verifies:
|
||||
- Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees
|
||||
zero kanban tools in its schema; a worker session sees all seven.
|
||||
zero kanban tools in its schema; a worker session sees the kanban set.
|
||||
- Each handler's happy path.
|
||||
- Error paths (missing required args, bad metadata type, etc).
|
||||
"""
|
||||
|
|
@ -27,9 +27,10 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path):
|
|||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
import tools.kanban_tools # ensure registered
|
||||
from tools.registry import registry
|
||||
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_")}
|
||||
|
|
@ -39,26 +40,51 @@ 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 all 7 tools."""
|
||||
"""Worker sessions (HERMES_KANBAN_TASK set) must have kanban tools."""
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
import tools.kanban_tools # ensure registered
|
||||
from tools.registry import registry
|
||||
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_unblock",
|
||||
}
|
||||
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)
|
||||
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_")}
|
||||
assert {
|
||||
"kanban_list",
|
||||
"kanban_unblock",
|
||||
}.issubset(kanban)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler happy paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -112,6 +138,48 @@ def test_show_explicit_task_id(worker_env):
|
|||
assert d["task"]["id"] == other
|
||||
|
||||
|
||||
def test_list_filters_tasks(worker_env):
|
||||
"""kanban_list gives orchestrators filtered board discovery."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
a = kb.create_task(conn, title="alpha", assignee="factory", priority=5)
|
||||
b = kb.create_task(conn, title="beta", assignee="reviewer")
|
||||
c = kb.create_task(conn, title="gamma", assignee="factory", tenant="other")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_list({"assignee": "factory", "status": "ready", "limit": 10})
|
||||
d = json.loads(out)
|
||||
ids = [t["id"] for t in d["tasks"]]
|
||||
assert ids == [a, c]
|
||||
assert d["count"] == 2
|
||||
assert d["tasks"][0]["title"] == "alpha"
|
||||
assert d["tasks"][0]["parent_count"] == 0
|
||||
assert b not in ids
|
||||
|
||||
tenant_out = kt._handle_list({
|
||||
"assignee": "factory",
|
||||
"status": "ready",
|
||||
"tenant": "other",
|
||||
})
|
||||
tenant_ids = [t["id"] for t in json.loads(tenant_out)["tasks"]]
|
||||
assert tenant_ids == [c]
|
||||
|
||||
|
||||
def test_list_rejects_invalid_status(worker_env):
|
||||
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):
|
||||
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")
|
||||
|
||||
|
||||
def test_complete_happy_path(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_complete({
|
||||
|
|
@ -458,9 +526,34 @@ def test_link_rejects_cycle(worker_env):
|
|||
assert json.loads(out).get("error")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: simulate a full worker lifecycle through the tools
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_unblock_happy_path(monkeypatch, worker_env):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="blocked", assignee="worker")
|
||||
kb.block_task(conn, tid, reason="waiting")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_unblock({"task_id": tid})
|
||||
d = json.loads(out)
|
||||
assert d["ok"] is True
|
||||
assert d["status"] == "ready"
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, tid).status == "ready"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_unblock_rejects_non_blocked_task(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_unblock({"task_id": worker_env})
|
||||
assert json.loads(out).get("error")
|
||||
|
||||
|
||||
def test_worker_lifecycle_through_tools(worker_env):
|
||||
"""Drive the full claim -> heartbeat -> comment -> complete lifecycle
|
||||
|
|
@ -599,11 +692,12 @@ 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)
|
||||
# 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_comment / kanban_create / kanban_link on other
|
||||
# tasks, so those are unrestricted.
|
||||
# destructive tools (kanban_complete, kanban_block, kanban_heartbeat,
|
||||
# kanban_unblock) 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.
|
||||
#
|
||||
# Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally
|
||||
# exempt — their job is routing, and they sometimes close out child
|
||||
|
|
@ -712,6 +806,28 @@ def test_worker_can_comment_on_foreign_task(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."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
other = kb.create_task(conn, title="blocked sibling", assignee="peer")
|
||||
kb.block_task(conn, other, reason="waiting")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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", "")
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, other).status == "blocked"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_worker_complete_own_task_still_works(worker_env):
|
||||
"""The ownership check doesn't break the normal own-task happy path."""
|
||||
from tools import kanban_tools as kt
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ def _check_kanban_mode() -> bool:
|
|||
Humans running ``hermes chat`` without the kanban toolset see zero
|
||||
kanban tools. Workers spawned by the kanban dispatcher (gateway-
|
||||
embedded by default) and orchestrator profiles with the kanban
|
||||
toolset enabled see all seven.
|
||||
toolset enabled see the Kanban tool surface.
|
||||
"""
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return True
|
||||
|
|
@ -135,6 +135,41 @@ def _ok(**fields: Any) -> str:
|
|||
return json.dumps({"ok": True, **fields})
|
||||
|
||||
|
||||
def _normalize_profile(value: Any) -> Optional[str]:
|
||||
"""Normalize CLI-compatible assignee sentinels for the tool surface."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text or text.lower() in ("none", "-", "null"):
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _task_summary_dict(kb, conn, task) -> dict[str, Any]:
|
||||
"""Compact task shape for board-listing tools."""
|
||||
parents = kb.parent_ids(conn, task.id)
|
||||
children = kb.child_ids(conn, task.id)
|
||||
return {
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"assignee": task.assignee,
|
||||
"status": task.status,
|
||||
"priority": task.priority,
|
||||
"tenant": task.tenant,
|
||||
"workspace_kind": task.workspace_kind,
|
||||
"workspace_path": task.workspace_path,
|
||||
"created_by": task.created_by,
|
||||
"created_at": task.created_at,
|
||||
"started_at": task.started_at,
|
||||
"completed_at": task.completed_at,
|
||||
"current_run_id": task.current_run_id,
|
||||
"parents": parents,
|
||||
"children": children,
|
||||
"parent_count": len(parents),
|
||||
"child_count": len(children),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -210,6 +245,48 @@ def _handle_show(args: dict, **kw) -> str:
|
|||
return tool_error(f"kanban_show: {e}")
|
||||
|
||||
|
||||
def _handle_list(args: dict, **kw) -> str:
|
||||
"""List task summaries with the same core filters as the CLI."""
|
||||
assignee = args.get("assignee")
|
||||
status = args.get("status")
|
||||
tenant = args.get("tenant")
|
||||
include_archived = bool(args.get("include_archived"))
|
||||
limit = args.get("limit")
|
||||
if limit is not None:
|
||||
try:
|
||||
limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
return tool_error("limit must be an integer")
|
||||
if limit < 1:
|
||||
return tool_error("limit must be >= 1")
|
||||
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(
|
||||
conn,
|
||||
assignee=assignee,
|
||||
status=status,
|
||||
tenant=tenant,
|
||||
include_archived=include_archived,
|
||||
limit=limit,
|
||||
)
|
||||
return json.dumps({
|
||||
"tasks": [_task_summary_dict(kb, conn, t) for t in tasks],
|
||||
"count": len(tasks),
|
||||
"promoted": promoted,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
except ValueError as e:
|
||||
return tool_error(f"kanban_list: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("kanban_list failed")
|
||||
return tool_error(f"kanban_list: {e}")
|
||||
|
||||
|
||||
def _handle_complete(args: dict, **kw) -> str:
|
||||
"""Mark the current task done with a structured handoff."""
|
||||
tid = _default_task_id(args.get("task_id"))
|
||||
|
|
@ -467,6 +544,28 @@ def _handle_create(args: dict, **kw) -> str:
|
|||
return tool_error(f"kanban_create: {e}")
|
||||
|
||||
|
||||
def _handle_unblock(args: dict, **kw) -> str:
|
||||
"""Transition a blocked task back to ready."""
|
||||
tid = args.get("task_id")
|
||||
if not tid:
|
||||
return tool_error("task_id is required")
|
||||
ownership_err = _enforce_worker_task_ownership(str(tid))
|
||||
if ownership_err:
|
||||
return ownership_err
|
||||
try:
|
||||
kb, conn = _connect()
|
||||
try:
|
||||
ok = kb.unblock_task(conn, str(tid))
|
||||
if not ok:
|
||||
return tool_error(f"could not unblock {tid} (not blocked or unknown)")
|
||||
return _ok(task_id=str(tid), status="ready")
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.exception("kanban_unblock failed")
|
||||
return tool_error(f"kanban_unblock: {e}")
|
||||
|
||||
|
||||
def _handle_link(args: dict, **kw) -> str:
|
||||
"""Add a parent→child dependency edge after the fact."""
|
||||
parent_id = args.get("parent_id")
|
||||
|
|
@ -519,6 +618,47 @@ KANBAN_SHOW_SCHEMA = {
|
|||
},
|
||||
}
|
||||
|
||||
KANBAN_LIST_SCHEMA = {
|
||||
"name": "kanban_list",
|
||||
"description": (
|
||||
"List Kanban task summaries so an orchestrator profile can discover "
|
||||
"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."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"assignee": {
|
||||
"type": "string",
|
||||
"description": "Optional assignee/profile filter.",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"triage", "todo", "ready", "running",
|
||||
"blocked", "done", "archived",
|
||||
],
|
||||
"description": "Optional task status filter.",
|
||||
},
|
||||
"tenant": {
|
||||
"type": "string",
|
||||
"description": "Optional tenant/project namespace filter.",
|
||||
},
|
||||
"include_archived": {
|
||||
"type": "boolean",
|
||||
"description": "Include archived tasks. Defaults to false.",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Optional maximum number of tasks to return.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_COMPLETE_SCHEMA = {
|
||||
"name": "kanban_complete",
|
||||
"description": (
|
||||
|
|
@ -787,6 +927,25 @@ KANBAN_CREATE_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."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "Blocked task id to return to ready.",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_LINK_SCHEMA = {
|
||||
"name": "kanban_link",
|
||||
"description": (
|
||||
|
|
@ -818,6 +977,15 @@ registry.register(
|
|||
emoji="📋",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_list",
|
||||
toolset="kanban",
|
||||
schema=KANBAN_LIST_SCHEMA,
|
||||
handler=_handle_list,
|
||||
check_fn=_check_kanban_mode,
|
||||
emoji="📋",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_complete",
|
||||
toolset="kanban",
|
||||
|
|
@ -863,6 +1031,15 @@ registry.register(
|
|||
emoji="➕",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_unblock",
|
||||
toolset="kanban",
|
||||
schema=KANBAN_UNBLOCK_SCHEMA,
|
||||
handler=_handle_unblock,
|
||||
check_fn=_check_kanban_mode,
|
||||
emoji="▶",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_link",
|
||||
toolset="kanban",
|
||||
|
|
|
|||
14
toolsets.py
14
toolsets.py
|
|
@ -61,10 +61,13 @@ _HERMES_CORE_TOOLS = [
|
|||
# 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), otherwise
|
||||
# zero schema footprint. Gated via check_fn in tools/kanban_tools.py.
|
||||
"kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat",
|
||||
# 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_show", "kanban_list",
|
||||
"kanban_complete", "kanban_block", "kanban_heartbeat",
|
||||
"kanban_comment", "kanban_create", "kanban_link",
|
||||
"kanban_unblock",
|
||||
# Computer use (macOS, gated on cua-driver being installed via check_fn)
|
||||
"computer_use",
|
||||
]
|
||||
|
|
@ -233,12 +236,13 @@ TOOLSETS = {
|
|||
"`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) fan out into child tasks."
|
||||
"orchestrators) list, unblock, and fan out tasks."
|
||||
),
|
||||
"tools": [
|
||||
"kanban_show", "kanban_complete", "kanban_block",
|
||||
"kanban_show", "kanban_list", "kanban_complete", "kanban_block",
|
||||
"kanban_heartbeat", "kanban_comment",
|
||||
"kanban_create", "kanban_link",
|
||||
"kanban_unblock",
|
||||
],
|
||||
"includes": [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`). 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 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_unblock`). 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_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`. The dispatcher spawns each worker with these tools already in its schema; the model reads its task and hands work off by calling them 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** — `kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`. 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.
|
||||
- **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,17 +231,19 @@ 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 — seven tools that 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 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.
|
||||
|
||||
| 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_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. | — |
|
||||
| `kanban_comment` | Append a durable note to the task thread. | `task_id`, `body` |
|
||||
| `kanban_create` | (Orchestrators) fan out into child tasks with an `assignee`, optional `parents`, `skills`, etc. | `title`, `assignee` |
|
||||
| `kanban_link` | (Orchestrators) add a `parent_id → child_id` dependency edge after the fact. | `parent_id`, `child_id` |
|
||||
| `kanban_unblock` | (Orchestrators) move a blocked task back to `ready`. | `task_id` |
|
||||
|
||||
A typical worker turn looks like:
|
||||
|
||||
|
|
@ -278,7 +280,7 @@ kanban_create(
|
|||
kanban_complete(summary="decomposed into 2 research tasks + 1 writer; linked dependencies")
|
||||
```
|
||||
|
||||
The three "(Orchestrators)" tools — `kanban_create`, `kanban_link`, and `kanban_comment` on foreign tasks — are available to every worker; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out and orchestrator profiles don't execute.
|
||||
The "(Orchestrators)" tools — `kanban_list`, `kanban_create`, `kanban_link`, `kanban_unblock`, 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.
|
||||
|
||||
### Why tools instead of shelling to `hermes kanban`
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue