From b6afb8443c46ea9d7d35488c2d7bb3616850985f Mon Sep 17 00:00:00 2001 From: Casey West Date: Sun, 19 Jul 2026 14:58:30 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(delegate):=20strip=20kanban?= =?UTF-8?q?=20toolset=20from=20delegated=20children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegate_task child inherits the parent's enabled toolsets AND the parent's HERMES_KANBAN_TASK env. A child that kept the kanban toolset could call kanban_complete with no explicit task_id, which falls back to HERMES_KANBAN_TASK and resolves the PARENT's card — falsely flipping it to done with the child's summary while the real deliverable was never produced (false-done, control-surface-bleed). Add "kanban" to the composite toolsets stripped from every child in _strip_blocked_tools, alongside delegation and code_execution. The strip is unconditional — orchestrator children re-gain delegation by role but never regain kanban, so no delegated agent holds card-terminal authority bound to its parent's card. --- tests/tools/test_delegate_toolset_scope.py | 26 ++++++++++++++++++++++ tools/delegate_tool.py | 16 ++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_delegate_toolset_scope.py b/tests/tools/test_delegate_toolset_scope.py index fd90dc1b5612..ea3f339b84e7 100644 --- a/tests/tools/test_delegate_toolset_scope.py +++ b/tests/tools/test_delegate_toolset_scope.py @@ -54,6 +54,32 @@ def test_strip_blocked_removes_delegation(self): assert "memory" not in child assert "terminal" in child + def test_strip_blocked_removes_kanban(self): + """The kanban toolset must never pass through to a delegated child. + + A child inherits the parent's ``HERMES_KANBAN_TASK`` env, so a child + that also held ``kanban_complete`` / ``kanban_block`` would resolve + the *parent's* card and could falsely flip it to done with the + child's summary (control-surface-bleed / false-done). Children get no + card-terminal authority. + """ + child = _strip_blocked_tools(["terminal", "file", "kanban"]) + assert "kanban" not in child + assert "terminal" in child + assert "file" in child + + def test_kanban_stripped_even_when_inherited_from_parent(self): + """No-toolsets-requested inheritance path must also drop kanban. + + When ``toolsets`` is None the child inherits the parent's full set + through ``_strip_blocked_tools`` — the kanban strip has to hold on + that path too, not just when the LLM explicitly requests it. + """ + parent_toolsets = ["terminal", "file", "kanban", "web"] + child = _strip_blocked_tools(parent_toolsets) + assert "kanban" not in child + assert sorted(child) == ["file", "terminal", "web"] + def test_empty_intersection_yields_empty_toolsets(self): """If parent has no overlap with requested, child gets nothing extra.""" parent = SimpleNamespace(enabled_toolsets=["terminal"]) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 893502ec04ff..f33aba6a6478 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -769,13 +769,23 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]: """Remove toolsets that contain only blocked tools. The strip set is derived from DELEGATE_BLOCKED_TOOLS plus the explicit - composite/scenario toolsets (delegation, code_execution) that have no - one-to-one tool. This keeps the blocklist and the strip set in lockstep + composite/scenario toolsets (delegation, code_execution, kanban) that have + no one-to-one tool. This keeps the blocklist and the strip set in lockstep so new blocked tools can't silently leak through as toolset names. + + ``kanban`` is stripped unconditionally — even for orchestrator children. + A delegated child inherits the parent's ``HERMES_KANBAN_TASK`` env, so a + child holding the kanban terminal verbs (``kanban_complete`` / + ``kanban_block``) would resolve the *parent's* card and could retire it + with the child's summary while the real deliverable was never produced + (false-done, control-surface-bleed). Children get no card-terminal + authority. """ # Composite toolsets that should never pass through to children, even # though their individual tools aren't all in DELEGATE_BLOCKED_TOOLS. - _COMPOSITE_BLOCKED_TOOLSETS = frozenset({"delegation", "code_execution"}) + _COMPOSITE_BLOCKED_TOOLSETS = frozenset( + {"delegation", "code_execution", "kanban"} + ) blocked_toolset_names = { name for name, defn in TOOLSETS.items()