fix(kanban): isolate delegated children from parent task
This commit is contained in:
@@ -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
|
||||||
+15
-1
@@ -39,6 +39,15 @@ logger = logging.getLogger(__name__)
|
|||||||
_WARNED_DISABLED_BUNDLES: set = set()
|
_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)
|
# Async Bridging (single source of truth -- used by registry.dispatch too)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -323,6 +332,7 @@ def get_tool_definitions(
|
|||||||
cfg_fp,
|
cfg_fp,
|
||||||
bool(os.environ.get("HERMES_KANBAN_TASK")),
|
bool(os.environ.get("HERMES_KANBAN_TASK")),
|
||||||
bool(skip_tool_search_assembly),
|
bool(skip_tool_search_assembly),
|
||||||
|
_is_delegated_child_context(),
|
||||||
)
|
)
|
||||||
cached = _tool_defs_cache.get(cache_key)
|
cached = _tool_defs_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -366,7 +376,11 @@ def _compute_tool_definitions(
|
|||||||
|
|
||||||
if enabled_toolsets is not None:
|
if enabled_toolsets is not None:
|
||||||
effective_enabled_toolsets = list(enabled_toolsets)
|
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
|
# Dispatcher-spawned workers are scoped by HERMES_KANBAN_TASK and
|
||||||
# must always receive the lifecycle handoff tools. Assignee
|
# must always receive the lifecycle handoff tools. Assignee
|
||||||
# profiles may intentionally restrict their normal chat toolsets
|
# profiles may intentionally restrict their normal chat toolsets
|
||||||
|
|||||||
@@ -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()
|
||||||
+53
-46
@@ -780,6 +780,7 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
|
|||||||
if name in _COMPOSITE_BLOCKED_TOOLSETS
|
if name in _COMPOSITE_BLOCKED_TOOLSETS
|
||||||
or all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
|
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]
|
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(
|
child_disabled_toolsets = list(
|
||||||
dict.fromkeys(
|
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):
|
if isinstance(child_max_tokens, int):
|
||||||
child_optional_kwargs["max_tokens"] = child_max_tokens
|
child_optional_kwargs["max_tokens"] = child_max_tokens
|
||||||
|
|
||||||
child = AIAgent(
|
from agent.delegation_context import delegated_child_context
|
||||||
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,
|
with delegated_child_context():
|
||||||
prefill_messages=getattr(parent_agent, "prefill_messages", None),
|
child = AIAgent(
|
||||||
fallback_model=parent_fallback,
|
base_url=effective_base_url,
|
||||||
enabled_toolsets=child_toolsets,
|
api_key=effective_api_key,
|
||||||
disabled_toolsets=child_disabled_toolsets,
|
model=effective_model,
|
||||||
quiet_mode=True,
|
provider=effective_provider,
|
||||||
ephemeral_system_prompt=child_prompt,
|
api_mode=effective_api_mode,
|
||||||
log_prefix=f"[subagent-{task_index}]",
|
acp_command=effective_acp_command,
|
||||||
platform="subagent",
|
acp_args=effective_acp_args,
|
||||||
skip_context_files=True,
|
max_iterations=max_iterations,
|
||||||
skip_memory=True,
|
|
||||||
clarify_callback=None,
|
reasoning_config=child_reasoning,
|
||||||
thinking_callback=child_thinking_cb,
|
prefill_messages=getattr(parent_agent, "prefill_messages", None),
|
||||||
session_db=getattr(parent_agent, "_session_db", None),
|
fallback_model=parent_fallback,
|
||||||
parent_session_id=getattr(parent_agent, "session_id", None),
|
enabled_toolsets=child_toolsets,
|
||||||
providers_allowed=child_providers_allowed,
|
disabled_toolsets=child_disabled_toolsets,
|
||||||
providers_ignored=child_providers_ignored,
|
quiet_mode=True,
|
||||||
providers_order=child_providers_order,
|
ephemeral_system_prompt=child_prompt,
|
||||||
provider_sort=child_provider_sort,
|
log_prefix=f"[subagent-{task_index}]",
|
||||||
provider_require_parameters=child_provider_require_parameters,
|
platform="subagent",
|
||||||
provider_data_collection=child_provider_data_collection,
|
skip_context_files=True,
|
||||||
request_overrides=(
|
skip_memory=True,
|
||||||
dict(override_request_overrides or {})
|
clarify_callback=None,
|
||||||
if override_provider
|
thinking_callback=child_thinking_cb,
|
||||||
else dict(getattr(parent_agent, "request_overrides", {}) or {})
|
session_db=getattr(parent_agent, "_session_db", None),
|
||||||
),
|
parent_session_id=getattr(parent_agent, "session_id", None),
|
||||||
openrouter_min_coding_score=child_openrouter_min_coding_score,
|
providers_allowed=child_providers_allowed,
|
||||||
tool_progress_callback=child_progress_cb,
|
providers_ignored=child_providers_ignored,
|
||||||
iteration_budget=None, # fresh budget per subagent
|
providers_order=child_providers_order,
|
||||||
**child_optional_kwargs,
|
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)
|
child._print_fn = getattr(parent_agent, "_print_fn", None)
|
||||||
# Now the child exists, its session id can ride on every relayed event
|
# Now the child exists, its session id can ride on every relayed event
|
||||||
# (including the spawn_requested below — first emit happens after this).
|
# (including the spawn_requested below — first emit happens after this).
|
||||||
@@ -2001,11 +2005,14 @@ def _run_single_child(
|
|||||||
|
|
||||||
def _run_with_thread_capture():
|
def _run_with_thread_capture():
|
||||||
_worker_thread_holder["t"] = threading.current_thread()
|
_worker_thread_holder["t"] = threading.current_thread()
|
||||||
return child.run_conversation(
|
from agent.delegation_context import delegated_child_context
|
||||||
user_message=goal,
|
|
||||||
task_id=child_task_id,
|
with delegated_child_context():
|
||||||
stream_callback=_relay_child_text,
|
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)
|
_child_future = _timeout_executor.submit(_run_with_thread_capture)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -490,6 +490,17 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non
|
|||||||
|
|
||||||
_apply_windows_msys_bash_env_defaults(sanitized)
|
_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
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,33 @@ def _profile_has_kanban_toolset() -> bool:
|
|||||||
return False
|
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:
|
def _check_kanban_mode() -> bool:
|
||||||
"""Task-lifecycle tools are available when:
|
"""Task-lifecycle tools are available when:
|
||||||
|
|
||||||
@@ -74,6 +101,8 @@ def _check_kanban_mode() -> bool:
|
|||||||
embedded by default) and orchestrator profiles with the kanban
|
embedded by default) and orchestrator profiles with the kanban
|
||||||
toolset enabled see the Kanban lifecycle tool surface.
|
toolset enabled see the Kanban lifecycle tool surface.
|
||||||
"""
|
"""
|
||||||
|
if _is_delegated_child_context():
|
||||||
|
return False
|
||||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||||
return True
|
return True
|
||||||
return _profile_has_kanban_toolset()
|
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
|
board state. Profiles that explicitly opt into the kanban toolset
|
||||||
and are NOT scoped to a single task are the orchestrator surface.
|
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"):
|
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||||
return False
|
return False
|
||||||
return _profile_has_kanban_toolset()
|
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."""
|
"""Resolve ``task_id`` arg or fall back to the env var the dispatcher set."""
|
||||||
if arg:
|
if arg:
|
||||||
return arg
|
return arg
|
||||||
|
if _is_delegated_child_context():
|
||||||
|
return None
|
||||||
env_tid = os.environ.get("HERMES_KANBAN_TASK")
|
env_tid = os.environ.get("HERMES_KANBAN_TASK")
|
||||||
return env_tid or None
|
return env_tid or None
|
||||||
|
|
||||||
@@ -505,6 +538,9 @@ def _handle_list(args: dict, **kw) -> str:
|
|||||||
|
|
||||||
def _handle_complete(args: dict, **kw) -> str:
|
def _handle_complete(args: dict, **kw) -> str:
|
||||||
"""Mark the current task done with a structured handoff."""
|
"""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"))
|
tid = _default_task_id(args.get("task_id"))
|
||||||
if not tid:
|
if not tid:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
@@ -681,6 +717,9 @@ def _handle_complete(args: dict, **kw) -> str:
|
|||||||
|
|
||||||
def _handle_block(args: dict, **kw) -> str:
|
def _handle_block(args: dict, **kw) -> str:
|
||||||
"""Transition the task to blocked with a reason a human will read."""
|
"""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"))
|
tid = _default_task_id(args.get("task_id"))
|
||||||
if not tid:
|
if not tid:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
@@ -767,6 +806,9 @@ def _handle_heartbeat(args: dict, **kw) -> str:
|
|||||||
by ``release_stale_claims`` — which is exactly the trap that
|
by ``release_stale_claims`` — which is exactly the trap that
|
||||||
``heartbeat_claim``'s docstring warns against.
|
``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"))
|
tid = _default_task_id(args.get("task_id"))
|
||||||
if not tid:
|
if not tid:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
@@ -810,6 +852,9 @@ def _handle_heartbeat(args: dict, **kw) -> str:
|
|||||||
|
|
||||||
def _handle_comment(args: dict, **kw) -> str:
|
def _handle_comment(args: dict, **kw) -> str:
|
||||||
"""Append a comment to a task's thread."""
|
"""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")
|
tid = args.get("task_id")
|
||||||
if not tid:
|
if not tid:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
@@ -855,6 +900,9 @@ def _handle_attach(args: dict, **kw) -> str:
|
|||||||
"""
|
"""
|
||||||
from hermes_cli import kanban_db as kb
|
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"))
|
tid = _default_task_id(args.get("task_id"))
|
||||||
if not tid:
|
if not tid:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
@@ -974,6 +1022,9 @@ def _handle_attach_url(args: dict, **kw) -> str:
|
|||||||
"""
|
"""
|
||||||
from hermes_cli import kanban_db as kb
|
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"))
|
tid = _default_task_id(args.get("task_id"))
|
||||||
if not tid:
|
if not tid:
|
||||||
return tool_error(
|
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
|
``parents`` can be a list of task ids; dependency-gated promotion
|
||||||
works as usual.
|
works as usual.
|
||||||
"""
|
"""
|
||||||
|
delegated_err = _reject_delegated_child_mutation("kanban_create")
|
||||||
|
if delegated_err:
|
||||||
|
return delegated_err
|
||||||
title = args.get("title")
|
title = args.get("title")
|
||||||
if not title or not str(title).strip():
|
if not title or not str(title).strip():
|
||||||
return tool_error("title is required")
|
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:
|
def _handle_unblock(args: dict, **kw) -> str:
|
||||||
"""Transition a blocked task to ready, or todo while parents remain open."""
|
"""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")
|
guard = _require_orchestrator_tool("kanban_unblock")
|
||||||
if guard:
|
if guard:
|
||||||
return guard
|
return guard
|
||||||
@@ -1319,6 +1376,9 @@ def _handle_unblock(args: dict, **kw) -> str:
|
|||||||
|
|
||||||
def _handle_link(args: dict, **kw) -> str:
|
def _handle_link(args: dict, **kw) -> str:
|
||||||
"""Add a parent→child dependency edge after the fact."""
|
"""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")
|
parent_id = args.get("parent_id")
|
||||||
child_id = args.get("child_id")
|
child_id = args.get("child_id")
|
||||||
if not parent_id or not child_id:
|
if not parent_id or not child_id:
|
||||||
|
|||||||
@@ -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). |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `safe` | `image_generate`, `vision_analyze`, `web_extract`, `web_search` (via `includes`) | Read-only research + media generation. No file writes, no terminal, no code execution. |
|
||||||
|
|||||||
Reference in New Issue
Block a user