fix(kanban): isolate delegated children from parent task

This commit is contained in:
trkim 2026-07-16 21:33:36 +00:00 committed by Teknium
parent a553bc74e4
commit 47bbc12e18
7 changed files with 457 additions and 48 deletions

View file

@ -0,0 +1,51 @@
"""Context-local state for delegate_task child execution.
The parent Hermes process may itself be a Kanban dispatcher worker with
HERMES_KANBAN_* variables in process env. delegate_task children run inside the
same Python process, but they are not dispatcher-owned Kanban workers. This
module lets code paths that resolve tool schemas or spawn subprocesses fail
closed for delegated children without mutating global os.environ for the parent.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Iterator, Mapping, MutableMapping
_DELEGATED_CHILD_CONTEXT: ContextVar[bool] = ContextVar(
"hermes_delegated_child_context",
default=False,
)
KANBAN_ENV_KEYS: tuple[str, ...] = (
"HERMES_KANBAN_TASK",
"HERMES_KANBAN_RUN_ID",
"HERMES_KANBAN_WORKSPACE",
"HERMES_KANBAN_WORKSPACES_ROOT",
"HERMES_KANBAN_CLAIM_LOCK",
"HERMES_KANBAN_BOARD",
"HERMES_KANBAN_DB",
)
@contextmanager
def delegated_child_context() -> Iterator[None]:
"""Mark the current execution context as a delegate_task child."""
token = _DELEGATED_CHILD_CONTEXT.set(True)
try:
yield
finally:
_DELEGATED_CHILD_CONTEXT.reset(token)
def is_delegated_child_context() -> bool:
"""Return True while code is running for a delegate_task child."""
return bool(_DELEGATED_CHILD_CONTEXT.get())
def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[str, str]:
"""Return *env* with dispatcher-only Kanban variables removed."""
cleaned = dict(env)
for key in KANBAN_ENV_KEYS:
cleaned.pop(key, None)
return cleaned

View file

@ -39,6 +39,15 @@ logger = logging.getLogger(__name__)
_WARNED_DISABLED_BUNDLES: set = set()
def _is_delegated_child_context() -> bool:
try:
from agent.delegation_context import is_delegated_child_context
return is_delegated_child_context()
except Exception:
return False
# =============================================================================
# Async Bridging (single source of truth -- used by registry.dispatch too)
# =============================================================================
@ -323,6 +332,7 @@ def get_tool_definitions(
cfg_fp,
bool(os.environ.get("HERMES_KANBAN_TASK")),
bool(skip_tool_search_assembly),
_is_delegated_child_context(),
)
cached = _tool_defs_cache.get(cache_key)
if cached is not None:
@ -366,7 +376,11 @@ def _compute_tool_definitions(
if enabled_toolsets is not None:
effective_enabled_toolsets = list(enabled_toolsets)
if os.environ.get("HERMES_KANBAN_TASK") and "kanban" not in effective_enabled_toolsets:
if (
os.environ.get("HERMES_KANBAN_TASK")
and not _is_delegated_child_context()
and "kanban" not in effective_enabled_toolsets
):
# Dispatcher-spawned workers are scoped by HERMES_KANBAN_TASK and
# must always receive the lifecycle handoff tools. Assignee
# profiles may intentionally restrict their normal chat toolsets

View file

@ -0,0 +1,266 @@
"""Regression tests for delegate_task isolation from parent Kanban workers."""
from __future__ import annotations
import json
def _make_running_kanban_task(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
attachments_root = tmp_path / "attachments"
workspace = tmp_path / "parent-workspace"
workspace.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_PROFILE", "parent-worker")
monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", str(workspace))
monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(attachments_root))
from hermes_cli import kanban_db as kb
kb._INITIALIZED_PATHS.clear()
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(
conn,
title="parent",
assignee="parent-worker",
workspace_kind="scratch",
workspace_path=str(workspace),
)
claim = kb.claim_task(conn, tid)
assert claim is not None
run_id = claim.id
finally:
conn.close()
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", str(run_id))
return kb, tid, workspace, attachments_root
def test_delegated_child_context_suppresses_env_gated_kanban_tools(monkeypatch, tmp_path):
"""A delegate_task child must not inherit the parent's Kanban tool schema.
The parent process may be a dispatcher worker with HERMES_KANBAN_TASK set;
the child is only a subagent, not the run owner.
"""
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123")
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
import tools.kanban_tools # noqa: F401 - ensure registered
from agent.delegation_context import delegated_child_context
from model_tools import _clear_tool_defs_cache, get_tool_definitions
from tools.registry import invalidate_check_fn_cache
invalidate_check_fn_cache()
_clear_tool_defs_cache()
with delegated_child_context():
schema = get_tool_definitions(enabled_toolsets=["terminal"], quiet_mode=True)
names = {s["function"].get("name") for s in schema if "function" in s}
assert "terminal" in names
assert {n for n in names if n and n.startswith("kanban_")} == set()
def test_build_child_agent_strips_kanban_toolset_even_when_parent_is_worker(monkeypatch):
"""Child construction must fail closed even if the parent exposes kanban."""
captured = {}
class FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)
self.valid_tool_names = {"terminal"}
self.session_id = "child-session"
import run_agent
from tools import delegate_tool
monkeypatch.setattr(run_agent, "AIAgent", FakeAgent)
monkeypatch.setattr(delegate_tool, "_load_config", lambda: {})
class Parent:
enabled_toolsets = ["terminal", "kanban"]
valid_tool_names = {"terminal", "kanban_complete", "kanban_comment"}
model = "test-model"
provider = "test-provider"
base_url = "http://example.invalid"
api_mode = "chat_completions"
platform = "cli"
session_id = "parent-session"
child = delegate_tool._build_child_agent(
task_index=0,
goal="review only",
context=None,
toolsets=None,
model=None,
max_iterations=3,
task_count=1,
parent_agent=Parent(),
)
assert child.valid_tool_names == {"terminal"}
assert "kanban" not in captured["enabled_toolsets"]
assert "kanban" in captured["disabled_toolsets"]
def test_delegate_child_terminal_env_scrubs_parent_kanban_keys(monkeypatch):
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123")
monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", "/tmp/parent-workspace")
monkeypatch.setenv("HERMES_KANBAN_CLAIM_LOCK", "lock")
from agent.delegation_context import delegated_child_context
from tools.environments.local import _sanitize_subprocess_env
with delegated_child_context():
env = _sanitize_subprocess_env({
"HERMES_KANBAN_TASK": "t_parent",
"HERMES_KANBAN_RUN_ID": "123",
"HERMES_KANBAN_WORKSPACE": "/tmp/parent-workspace",
"HERMES_KANBAN_CLAIM_LOCK": "lock",
"PATH": "/usr/bin",
})
assert env["PATH"] == "/usr/bin"
assert "HERMES_KANBAN_TASK" not in env
assert "HERMES_KANBAN_RUN_ID" not in env
assert "HERMES_KANBAN_WORKSPACE" not in env
assert "HERMES_KANBAN_CLAIM_LOCK" not in env
def test_delegate_child_kanban_mutator_guard_rejects_explicit_task_id(monkeypatch):
"""Defense in depth: direct handler access still cannot mutate a board."""
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
from agent.delegation_context import delegated_child_context
from tools import kanban_tools
with delegated_child_context():
raw = kanban_tools._handle_complete({
"task_id": "t_parent",
"summary": "should not complete",
})
payload = json.loads(raw)
assert payload["error"]
assert "delegate_task child" in payload["error"]
def test_delegate_child_attach_guard_leaves_no_row_or_file(monkeypatch, tmp_path):
kb, tid, _workspace, attachments_root = _make_running_kanban_task(monkeypatch, tmp_path)
from agent.delegation_context import delegated_child_context
from tools import kanban_tools
with delegated_child_context():
raw = kanban_tools._handle_attach({
"task_id": tid,
"filename": "leak.txt",
"content_base64": "bGVhay1ieXRlcw==",
"content_type": "text/plain",
})
payload = json.loads(raw)
assert payload["error"]
assert "delegate_task child" in payload["error"]
conn = kb.connect()
try:
assert kb.list_attachments(conn, tid) == []
finally:
conn.close()
task_dir = attachments_root / tid
assert not task_dir.exists() or list(task_dir.iterdir()) == []
def test_delegate_child_attach_url_guard_leaves_no_row_or_file(monkeypatch, tmp_path):
kb, tid, _workspace, attachments_root = _make_running_kanban_task(monkeypatch, tmp_path)
from agent.delegation_context import delegated_child_context
from tools import kanban_tools
def forbidden_download(*_args, **_kwargs):
raise AssertionError("delegated child guard must run before URL download")
monkeypatch.setattr(kanban_tools, "_download_url_with_cap", forbidden_download)
with delegated_child_context():
raw = kanban_tools._handle_attach_url({
"task_id": tid,
"url": "https://example.com/leak.txt",
})
payload = json.loads(raw)
assert payload["error"]
assert "delegate_task child" in payload["error"]
conn = kb.connect()
try:
assert kb.list_attachments(conn, tid) == []
finally:
conn.close()
task_dir = attachments_root / tid
assert not task_dir.exists() or list(task_dir.iterdir()) == []
def test_child_attempting_default_complete_does_not_finish_parent_or_delete_workspace(
monkeypatch,
tmp_path,
):
"""Deterministic E2E: a delegated child cannot complete its parent task."""
kb, tid, workspace, _attachments_root = _make_running_kanban_task(monkeypatch, tmp_path)
from tools import delegate_tool
from tools import kanban_tools
class Parent:
_current_task_id = tid
def _touch_activity(self, _desc):
return None
class Child:
tool_progress_callback = None
_delegate_saved_tool_names = []
_credential_pool = None
_subagent_id = "sa-test"
_delegate_depth = 1
_parent_subagent_id = None
model = "test-model"
session_prompt_tokens = 0
session_completion_tokens = 0
session_estimated_cost_usd = 0.0
session_reasoning_tokens = 0
def get_activity_summary(self):
return {"api_call_count": 0, "max_iterations": 1, "current_tool": None}
def run_conversation(self, user_message, task_id, **_kwargs):
attempted = kanban_tools._handle_complete({"summary": "wrong child completion"})
return {
"final_response": attempted,
"completed": True,
"api_calls": 0,
"messages": [],
}
def close(self):
return None
result = delegate_tool._run_single_child(0, "try to complete parent", Child(), Parent())
conn = kb.connect()
try:
task = kb.get_task(conn, tid)
run = kb.latest_run(conn, tid)
finally:
conn.close()
assert result["status"] == "completed"
assert "delegate_task child" in result["summary"]
assert task.status == "running"
assert run.status == "running"
assert workspace.is_dir()

View file

@ -780,6 +780,7 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
if name in _COMPOSITE_BLOCKED_TOOLSETS
or all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
}
blocked_toolset_names.add("kanban")
return [t for t in toolsets if t not in blocked_toolset_names]
@ -1175,7 +1176,7 @@ def _build_child_agent(
]
child_disabled_toolsets = list(
dict.fromkeys(
inherited_disabled + _blocked_toolsets_for_role(effective_role)
inherited_disabled + _blocked_toolsets_for_role(effective_role) + ["kanban"]
)
)
@ -1360,47 +1361,50 @@ def _build_child_agent(
if isinstance(child_max_tokens, int):
child_optional_kwargs["max_tokens"] = child_max_tokens
child = AIAgent(
base_url=effective_base_url,
api_key=effective_api_key,
model=effective_model,
provider=effective_provider,
api_mode=effective_api_mode,
acp_command=effective_acp_command,
acp_args=effective_acp_args,
max_iterations=max_iterations,
from agent.delegation_context import delegated_child_context
reasoning_config=child_reasoning,
prefill_messages=getattr(parent_agent, "prefill_messages", None),
fallback_model=parent_fallback,
enabled_toolsets=child_toolsets,
disabled_toolsets=child_disabled_toolsets,
quiet_mode=True,
ephemeral_system_prompt=child_prompt,
log_prefix=f"[subagent-{task_index}]",
platform="subagent",
skip_context_files=True,
skip_memory=True,
clarify_callback=None,
thinking_callback=child_thinking_cb,
session_db=getattr(parent_agent, "_session_db", None),
parent_session_id=getattr(parent_agent, "session_id", None),
providers_allowed=child_providers_allowed,
providers_ignored=child_providers_ignored,
providers_order=child_providers_order,
provider_sort=child_provider_sort,
provider_require_parameters=child_provider_require_parameters,
provider_data_collection=child_provider_data_collection,
request_overrides=(
dict(override_request_overrides or {})
if override_provider
else dict(getattr(parent_agent, "request_overrides", {}) or {})
),
openrouter_min_coding_score=child_openrouter_min_coding_score,
tool_progress_callback=child_progress_cb,
iteration_budget=None, # fresh budget per subagent
**child_optional_kwargs,
)
with delegated_child_context():
child = AIAgent(
base_url=effective_base_url,
api_key=effective_api_key,
model=effective_model,
provider=effective_provider,
api_mode=effective_api_mode,
acp_command=effective_acp_command,
acp_args=effective_acp_args,
max_iterations=max_iterations,
reasoning_config=child_reasoning,
prefill_messages=getattr(parent_agent, "prefill_messages", None),
fallback_model=parent_fallback,
enabled_toolsets=child_toolsets,
disabled_toolsets=child_disabled_toolsets,
quiet_mode=True,
ephemeral_system_prompt=child_prompt,
log_prefix=f"[subagent-{task_index}]",
platform="subagent",
skip_context_files=True,
skip_memory=True,
clarify_callback=None,
thinking_callback=child_thinking_cb,
session_db=getattr(parent_agent, "_session_db", None),
parent_session_id=getattr(parent_agent, "session_id", None),
providers_allowed=child_providers_allowed,
providers_ignored=child_providers_ignored,
providers_order=child_providers_order,
provider_sort=child_provider_sort,
provider_require_parameters=child_provider_require_parameters,
provider_data_collection=child_provider_data_collection,
request_overrides=(
dict(override_request_overrides or {})
if override_provider
else dict(getattr(parent_agent, "request_overrides", {}) or {})
),
openrouter_min_coding_score=child_openrouter_min_coding_score,
tool_progress_callback=child_progress_cb,
iteration_budget=None, # fresh budget per subagent
**child_optional_kwargs,
)
child._print_fn = getattr(parent_agent, "_print_fn", None)
# Now the child exists, its session id can ride on every relayed event
# (including the spawn_requested below — first emit happens after this).
@ -2001,11 +2005,14 @@ def _run_single_child(
def _run_with_thread_capture():
_worker_thread_holder["t"] = threading.current_thread()
return child.run_conversation(
user_message=goal,
task_id=child_task_id,
stream_callback=_relay_child_text,
)
from agent.delegation_context import delegated_child_context
with delegated_child_context():
return child.run_conversation(
user_message=goal,
task_id=child_task_id,
stream_callback=_relay_child_text,
)
_child_future = _timeout_executor.submit(_run_with_thread_capture)
try:

View file

@ -490,6 +490,17 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non
_apply_windows_msys_bash_env_defaults(sanitized)
try:
from agent.delegation_context import (
is_delegated_child_context,
scrub_kanban_env,
)
if is_delegated_child_context():
sanitized = scrub_kanban_env(sanitized)
except Exception:
pass
return sanitized

View file

@ -62,6 +62,33 @@ def _profile_has_kanban_toolset() -> bool:
return False
def _is_delegated_child_context() -> bool:
try:
from agent.delegation_context import is_delegated_child_context
return is_delegated_child_context()
except Exception:
return False
def _reject_delegated_child_mutation(tool_name: str) -> Optional[str]:
"""Deny Kanban mutations from delegate_task children.
A delegate_task child runs in the same process as its parent, so stale or
inherited HERMES_KANBAN_* env vars are not proof of dispatcher ownership.
The child may summarize findings to its parent, but it must not complete,
block, heartbeat, comment, create, link, or unblock board tasks directly.
"""
if not _is_delegated_child_context():
return None
return tool_error(
f"{tool_name} refused: delegate_task child agents are not Kanban "
"run owners. Return findings to the parent agent; the dispatcher "
"worker or an explicitly configured Kanban orchestrator must perform "
"board mutations."
)
def _check_kanban_mode() -> bool:
"""Task-lifecycle tools are available when:
@ -74,6 +101,8 @@ def _check_kanban_mode() -> bool:
embedded by default) and orchestrator profiles with the kanban
toolset enabled see the Kanban lifecycle tool surface.
"""
if _is_delegated_child_context():
return False
if os.environ.get("HERMES_KANBAN_TASK"):
return True
return _profile_has_kanban_toolset()
@ -88,6 +117,8 @@ def _check_kanban_orchestrator_mode() -> bool:
board state. Profiles that explicitly opt into the kanban toolset
and are NOT scoped to a single task are the orchestrator surface.
"""
if _is_delegated_child_context():
return False
if os.environ.get("HERMES_KANBAN_TASK"):
return False
return _profile_has_kanban_toolset()
@ -101,6 +132,8 @@ def _default_task_id(arg: Optional[str]) -> Optional[str]:
"""Resolve ``task_id`` arg or fall back to the env var the dispatcher set."""
if arg:
return arg
if _is_delegated_child_context():
return None
env_tid = os.environ.get("HERMES_KANBAN_TASK")
return env_tid or None
@ -505,6 +538,9 @@ def _handle_list(args: dict, **kw) -> str:
def _handle_complete(args: dict, **kw) -> str:
"""Mark the current task done with a structured handoff."""
delegated_err = _reject_delegated_child_mutation("kanban_complete")
if delegated_err:
return delegated_err
tid = _default_task_id(args.get("task_id"))
if not tid:
return tool_error(
@ -681,6 +717,9 @@ def _handle_complete(args: dict, **kw) -> str:
def _handle_block(args: dict, **kw) -> str:
"""Transition the task to blocked with a reason a human will read."""
delegated_err = _reject_delegated_child_mutation("kanban_block")
if delegated_err:
return delegated_err
tid = _default_task_id(args.get("task_id"))
if not tid:
return tool_error(
@ -767,6 +806,9 @@ def _handle_heartbeat(args: dict, **kw) -> str:
by ``release_stale_claims`` which is exactly the trap that
``heartbeat_claim``'s docstring warns against.
"""
delegated_err = _reject_delegated_child_mutation("kanban_heartbeat")
if delegated_err:
return delegated_err
tid = _default_task_id(args.get("task_id"))
if not tid:
return tool_error(
@ -810,6 +852,9 @@ def _handle_heartbeat(args: dict, **kw) -> str:
def _handle_comment(args: dict, **kw) -> str:
"""Append a comment to a task's thread."""
delegated_err = _reject_delegated_child_mutation("kanban_comment")
if delegated_err:
return delegated_err
tid = args.get("task_id")
if not tid:
return tool_error(
@ -855,6 +900,9 @@ def _handle_attach(args: dict, **kw) -> str:
"""
from hermes_cli import kanban_db as kb
delegated_err = _reject_delegated_child_mutation("kanban_attach")
if delegated_err:
return delegated_err
tid = _default_task_id(args.get("task_id"))
if not tid:
return tool_error(
@ -974,6 +1022,9 @@ def _handle_attach_url(args: dict, **kw) -> str:
"""
from hermes_cli import kanban_db as kb
delegated_err = _reject_delegated_child_mutation("kanban_attach_url")
if delegated_err:
return delegated_err
tid = _default_task_id(args.get("task_id"))
if not tid:
return tool_error(
@ -1070,6 +1121,9 @@ def _handle_create(args: dict, **kw) -> str:
``parents`` can be a list of task ids; dependency-gated promotion
works as usual.
"""
delegated_err = _reject_delegated_child_mutation("kanban_create")
if delegated_err:
return delegated_err
title = args.get("title")
if not title or not str(title).strip():
return tool_error("title is required")
@ -1290,6 +1344,9 @@ def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool:
def _handle_unblock(args: dict, **kw) -> str:
"""Transition a blocked task to ready, or todo while parents remain open."""
delegated_err = _reject_delegated_child_mutation("kanban_unblock")
if delegated_err:
return delegated_err
guard = _require_orchestrator_tool("kanban_unblock")
if guard:
return guard
@ -1319,6 +1376,9 @@ def _handle_unblock(args: dict, **kw) -> str:
def _handle_link(args: dict, **kw) -> str:
"""Add a parent→child dependency edge after the fact."""
delegated_err = _reject_delegated_child_mutation("kanban_link")
if delegated_err:
return delegated_err
parent_id = args.get("parent_id")
child_id = args.get("child_id")
if not parent_id or not child_id:

View file

@ -69,7 +69,7 @@ Or in-session:
| `context_engine` | (varies) | Runtime tools exposed by the active context-engine plugin (empty until a plugin populates it). |
| `image_gen` | `image_generate` | Text-to-image generation via FAL.ai (with opt-in OpenAI / xAI backends). |
| `video_gen` | `video_generate` | Text-to-video and image-to-video via plugin-registered backends (xAI Grok-Imagine, FAL.ai Veo 3.1 / Pixverse v6 / Kling O3). Pass `image_url` to animate an image; omit it for text-to-video. |
| `kanban` | `kanban_block`, `kanban_comment`, `kanban_complete`, `kanban_create`, `kanban_heartbeat`, `kanban_link`, `kanban_list`, `kanban_show`, `kanban_unblock` | Multi-agent coordination tools. Registered for dispatcher-spawned task workers (`HERMES_KANBAN_TASK`) and for profiles that explicitly list the `kanban` toolset by name (the `all`/`*` wildcard does **not** enable it). Workers mark tasks done, block, heartbeat, comment, and create/link follow-up tasks; orchestrator profiles additionally get board-routing tools like list/unblock. |
| `kanban` | `kanban_block`, `kanban_comment`, `kanban_complete`, `kanban_create`, `kanban_heartbeat`, `kanban_link`, `kanban_list`, `kanban_show`, `kanban_unblock` | Multi-agent coordination tools. Registered for dispatcher-spawned task workers (`HERMES_KANBAN_TASK`) and for profiles that explicitly list the `kanban` toolset by name (the `all`/`*` wildcard does **not** enable it). Workers mark tasks done, block, heartbeat, comment, and create/link follow-up tasks; orchestrator profiles additionally get board-routing tools like list/unblock. `delegate_task` children are not Kanban run owners: their schema strips/disables this toolset and runtime guards reject direct board mutations, even if parent `HERMES_KANBAN_*` env vars are present. |
| `memory` | `memory` | Persistent cross-session memory management. |
| `project` | `project_create`, `project_list`, `project_switch` | Create and switch desktop [Projects](../user-guide/cli.md) (named, multi-folder workspaces). GUI / desktop sessions only. |
| `safe` | `image_generate`, `vision_analyze`, `web_extract`, `web_search` (via `includes`) | Read-only research + media generation. No file writes, no terminal, no code execution. |