mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-23 05:31:23 +00:00
feat(kanban): add orchestrator board tools
This commit is contained in:
parent
f27fcb6a82
commit
f21e94b58c
5 changed files with 580 additions and 19 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,53 @@ 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_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)
|
||||
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_assign",
|
||||
"kanban_unblock",
|
||||
"kanban_archive",
|
||||
}.issubset(kanban)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler happy paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -112,6 +140,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({
|
||||
|
|
@ -384,6 +454,130 @@ def test_link_rejects_cycle(worker_env):
|
|||
assert json.loads(out).get("error")
|
||||
|
||||
|
||||
def test_assign_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="assignable", assignee="factory")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_assign({"task_id": tid, "assignee": "reviewer"})
|
||||
d = json.loads(out)
|
||||
assert d["ok"] is True
|
||||
assert d["assignee"] == "reviewer"
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, tid)
|
||||
events = kb.list_events(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert task.assignee == "reviewer"
|
||||
assert events[-1].kind == "assigned"
|
||||
assert events[-1].payload == {"assignee": "reviewer"}
|
||||
|
||||
|
||||
def test_assign_can_unassign(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="unassignable", assignee="factory")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_assign({"task_id": tid, "assignee": "none"})
|
||||
d = json.loads(out)
|
||||
assert d["ok"] is True
|
||||
assert d["assignee"] is None
|
||||
|
||||
|
||||
def test_assign_rejects_missing_args(worker_env):
|
||||
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):
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_assign({"task_id": worker_env, "assignee": "reviewer"})
|
||||
d = json.loads(out)
|
||||
assert "currently running" in d.get("error", "")
|
||||
|
||||
|
||||
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_archive_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="archive me", assignee="worker")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_archive({"task_id": tid})
|
||||
d = json.loads(out)
|
||||
assert d["ok"] is True
|
||||
assert d["status"] == "archived"
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, tid)
|
||||
events = kb.list_events(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert task.status == "archived"
|
||||
assert events[-1].kind == "archived"
|
||||
|
||||
|
||||
def test_archive_rejects_already_archived_task(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="archive twice", assignee="worker")
|
||||
assert kb.archive_task(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_archive({"task_id": tid})
|
||||
assert json.loads(out).get("error")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: simulate a full worker lifecycle through the tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -525,11 +719,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_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.
|
||||
#
|
||||
# Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally
|
||||
# exempt — their job is routing, and they sometimes close out child
|
||||
|
|
@ -602,6 +797,70 @@ 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."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
other = kb.create_task(conn, title="sibling", assignee="peer")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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", "")
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, other).assignee == "peer"
|
||||
finally:
|
||||
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_archive_rejects_foreign_task_id(worker_env):
|
||||
"""A worker cannot archive 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="sibling", assignee="peer")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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", "")
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, other).status != "archived"
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue