fix(hermes): delegate through Hermes' public subagent lifecycle instead of nested delegate_task (#803)

agency_agents_delegate previously nested delegate_task through ctx.dispatch_tool(), which returns error JSON as a string instead of raising — so the router reported delegated: true with {"error": "delegate_task requires a parent agent context."} and never delegated (#802, #838). It now launches the specialist through ctx.subagent_lifecycle (upstream hermes-agent PR #72501): bounded wait, cooperative cancel on timeout, honest delegated: false + fallback prompt on launch/terminal failure, 32,000-char context cap, toolsets option removed. Kept in scripts/build-hermes-plugin.py so regeneration preserves it; six behavior tests in scripts/test-hermes-plugin.py.

Verified live in an isolated Hermes git-main (v0.21.0) box on local Qwen: main plugin -> delegated: true with the error string in 0.01 s; this plugin -> real child session, real result.

Fixes #802. Fixes #838.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
wnwhermes
2026-09-05 12:32:07 -05:00
committed by GitHub
co-authored by Claude Fable 5.1
parent af128a9288
commit 80b338fea8
3 changed files with 263 additions and 23 deletions
+91 -21
View File
@@ -120,6 +120,12 @@ _DATA_PATH = Path(__file__).parent / "data" / "agents.json"
_AGENTS: list[dict[str, Any]] | None = None
_WORD_RE = re.compile(r"[a-z0-9][a-z0-9+.#_-]*", re.I)
_MAX_LIFECYCLE_CONTEXT_CHARS = 32_000
_DELEGATION_WAIT_SECONDS = 330
_CANCELLATION_WAIT_SECONDS = 30
_TRUNCATION_MARKER = (
"\n\n[Specialist instructions truncated to fit the Hermes lifecycle context limit.]"
)
def _load_agents() -> list[dict[str, Any]]:
@@ -215,6 +221,14 @@ def _specialist_prompt(agent: dict[str, Any], task: str = "") -> str:
)
def _lifecycle_context(agent: dict[str, Any]) -> str:
context = _specialist_prompt(agent)
if len(context) <= _MAX_LIFECYCLE_CONTEXT_CHARS:
return context
keep = _MAX_LIFECYCLE_CONTEXT_CHARS - len(_TRUNCATION_MARKER)
return context[:keep] + _TRUNCATION_MARKER
def _json(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2)
@@ -276,8 +290,8 @@ PROMPT_SCHEMA = {
DELEGATE_DESCRIPTION = (
"Delegate a task to one selected Agency specialist through Hermes' "
"delegate_task tool when available. Falls back to returning the composed "
"specialist prompt if delegation is unavailable."
"public subagent lifecycle. Falls back to returning the composed specialist "
"prompt if delegation is unavailable."
)
DELEGATE_SCHEMA = {
"name": "agency_agents_delegate",
@@ -288,11 +302,6 @@ DELEGATE_SCHEMA = {
"agent": {"type": "string", "description": "Agent slug or exact display name."},
"slug": {"type": "string", "description": "Alias for agent. Pass the slug from agency_agents_search results."},
"task": {"type": "string", "description": "Concrete task for the specialist."},
"toolsets": {
"type": "array",
"items": {"type": "string"},
"description": "Optional Hermes toolsets for the delegated worker, e.g. ['terminal','file'].",
},
},
"required": ["task"],
},
@@ -359,24 +368,85 @@ def register(ctx):
return _json(_not_found(identifier))
if not task:
return _json({"success": False, "error": "task is required"})
composed = _specialist_prompt(agent, task)
delegate_args: dict[str, Any] = {
"goal": task,
"context": composed,
}
toolsets = args.get("toolsets")
if isinstance(toolsets, list) and toolsets:
delegate_args["toolsets"] = [str(item) for item in toolsets]
fallback_prompt = _specialist_prompt(agent, task)
handle = None
try:
result = ctx.dispatch_tool("delegate_task", delegate_args)
return _json({"success": True, "agent": _summary(agent), "delegated": True, "result": result})
from agent.subagent_lifecycle import SubagentLaunchRequest
lifecycle = ctx.subagent_lifecycle
handle = lifecycle.launch(SubagentLaunchRequest(
goal=task,
context=_lifecycle_context(agent),
))
terminal = lifecycle.wait(
handle, timeout_seconds=_DELEGATION_WAIT_SECONDS
)
if terminal.timed_out:
try:
lifecycle.cancel(
handle,
reason="Agency delegation exceeded the plugin wait limit.",
)
terminal = lifecycle.wait(
handle, timeout_seconds=_CANCELLATION_WAIT_SECONDS
)
except Exception as exc:
return _json({
"success": True,
"agent": _summary(agent),
"delegated": True,
"pending": True,
"subagent_id": handle.subagent_id,
"warning": f"subagent cancellation could not be confirmed: {exc}",
})
if not terminal.completed:
return _json({
"success": True,
"agent": _summary(agent),
"delegated": True,
"pending": True,
"subagent_id": handle.subagent_id,
"state": terminal.state.value,
"warning": "subagent cancellation was requested but is not terminal",
})
result = lifecycle.result(handle)
if not result.ready or result.terminal_state.value != "SUCCEEDED":
detail = (
result.error_message
or result.error_classification
or result.terminal_state.value
)
return _json({
"success": True,
"agent": _summary(agent),
"delegated": False,
"warning": f"subagent delegation failed: {detail}",
"prompt": fallback_prompt,
})
return _json({
"success": True,
"agent": _summary(agent),
"delegated": True,
"subagent_id": handle.subagent_id,
"result": result.summary,
"structured_result": result.structured_payload,
})
except Exception as exc: # pragma: no cover - depends on Hermes runtime
if handle is not None:
return _json({
"success": True,
"agent": _summary(agent),
"delegated": True,
"pending": True,
"subagent_id": handle.subagent_id,
"warning": f"subagent state could not be confirmed: {exc}",
})
return _json({
"success": True,
"agent": _summary(agent),
"delegated": False,
"warning": f"delegate_task unavailable: {exc}",
"prompt": composed,
"warning": f"subagent delegation unavailable: {exc}",
"prompt": fallback_prompt,
})
ctx.register_tool(
@@ -429,7 +499,7 @@ def readme(agent_count: int) -> str:
- `agency_agents_search` — find matching specialists by query/division.
- `agency_agents_inspect` — inspect one specialist's metadata or full body.
- `agency_agents_load` — compose one specialist prompt for the current task.
- `agency_agents_delegate` — delegate through Hermes `delegate_task` when available.
- `agency_agents_delegate` — delegate through Hermes' public subagent lifecycle.
Each tool is registered with Hermes' complete function-tool schema, including
its name, description, and JSON `parameters`. The available arguments are:
@@ -439,7 +509,7 @@ def readme(agent_count: int) -> str:
| `agency_agents_search` | `query` (required), optional `division` and `limit` |
| `agency_agents_inspect` | `agent` or `slug`, optional `include_body` |
| `agency_agents_load` | `agent` or `slug`, optional `task` |
| `agency_agents_delegate` | `agent` or `slug`, `task` (required), optional `toolsets` |
| `agency_agents_delegate` | `agent` or `slug`, `task` (required) |
A normal flow is: search by capability, take a returned `slug`, then inspect,
load, or delegate to that specialist. You can ask Hermes to do this in natural