mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
fix(kanban): isolate delegated children from parent task
This commit is contained in:
parent
a553bc74e4
commit
47bbc12e18
7 changed files with 457 additions and 48 deletions
51
agent/delegation_context.py
Normal file
51
agent/delegation_context.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
266
tests/tools/test_delegate_kanban_isolation.py
Normal file
266
tests/tools/test_delegate_kanban_isolation.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue