fix(hermes): route delegated work independently

This commit is contained in:
jenkins 2026-08-10 05:16:30 -03:00
parent c6b228ae23
commit 109bd1d6d7
10 changed files with 330 additions and 32 deletions

View File

@ -135,6 +135,7 @@ hooks_before = ''' "pre_llm_call",
hooks_after = ''' "pre_llm_call",
"pre_turn_route",
"pre_internal_route",
"pre_subagent_route",
"post_llm_call",
'''
if plugins.count(hooks_before) != 1:
@ -209,6 +210,42 @@ if loop.count(loop_before) != 1:
f"found {loop.count(loop_before)}"
)
loop_path.write_text(loop.replace(loop_before, loop_after, 1))
delegate_path = Path("/opt/hermes/tools/delegate_tool.py")
delegate = delegate_path.read_text()
delegate_before = ''' # Override with correct parent tool names (before child construction mutated global)
child._delegate_saved_tool_names = _parent_tool_names
children.append((i, t, child))
'''
delegate_after = ''' # Route each bounded child independently before its first LLM call.
# This keeps one multi-part objective from pinning every leaf to
# the parent coordinator's provider/model/effort.
try:
from hermes_cli.plugins import has_hook, invoke_hook
if has_hook("pre_subagent_route"):
invoke_hook(
"pre_subagent_route",
agent=child,
parent_agent=parent_agent,
goal=t["goal"],
context=t.get("context"),
task_index=i,
task_count=n_tasks,
)
except Exception:
logger.warning("pre_subagent_route hook failed", exc_info=True)
# Override with correct parent tool names (before child construction mutated global)
child._delegate_saved_tool_names = _parent_tool_names
children.append((i, t, child))
'''
if delegate.count(delegate_before) != 1:
raise SystemExit(
"Hermes subagent routing context changed: expected 1, "
f"found {delegate.count(delegate_before)}"
)
delegate_path.write_text(delegate.replace(delegate_before, delegate_after, 1))
PY
# Hermes WebUI sends its model/provider/reasoning selection on /v1/runs.
@ -366,13 +403,17 @@ RUN cd /opt/hermes/web \
/opt/hermes/gateway/platforms/api_server.py \
&& grep -Fq '"pre_turn_route"' /opt/hermes/hermes_cli/plugins.py \
&& grep -Fq '"pre_internal_route"' /opt/hermes/hermes_cli/plugins.py \
&& grep -Fq '"pre_subagent_route"' /opt/hermes/hermes_cli/plugins.py \
&& grep -Fq 'invoke_hook(' /opt/hermes/agent/turn_context.py \
&& grep -Fq 'pre_internal_route hook failed' \
/opt/hermes/agent/conversation_loop.py \
&& grep -Fq 'pre_subagent_route hook failed' \
/opt/hermes/tools/delegate_tool.py \
&& /opt/hermes/.venv/bin/python -m py_compile \
/opt/hermes/gateway/platforms/api_server.py \
/opt/hermes/agent/turn_context.py \
/opt/hermes/agent/conversation_loop.py \
/opt/hermes/tools/delegate_tool.py \
/opt/hermes/tools/web_tools.py \
/opt/hermes/tools/python_sandbox_tool.py \
/opt/hermes/plugins/web/public_extract/provider.py \

View File

@ -27,11 +27,20 @@ data:
agent:
api_max_retries: 1
# The coordinator supervises long-running Herdr workers; give it enough
# room to inspect, steer, review, and synthesize without truncating.
# The coordinator supervises native children and durable CLI workers;
# give it enough room to inspect, steer, review, and synthesize.
max_turns: 180
reasoning_effort: medium
delegation:
# Native Hermes owns decomposition and fan-out. Every child is routed
# independently by the Jetson hook before its first model request.
max_concurrent_children: 4
max_iterations: 120
max_spawn_depth: 2
orchestrator_enabled: true
subagent_auto_approve: true
toolsets:
- kanban
@ -158,9 +167,11 @@ data:
Keep every project's conversation, objectives, tasks, evidence, and
blockers in that project's Hermes Project and Kanban board. Cassandra is
the initial project. Use Herdr as the execution fabric for persistent Codex
and Claude Code workers; you remain responsible for planning, routing,
fallback, review, and the final synthesized answer.
the initial project. Use native Hermes delegation as the normal planning,
fan-out, and synthesis path. Use Herdr only as a hidden durability adapter
when work specifically benefits from a persistent real Codex or Claude Code
CLI session. You remain responsible for planning, routing, fallback, review,
and the final synthesized answer.
Prefer Codex for implementation, debugging, test loops, and focused repo
changes. Prefer Claude Code for architecture, long-context investigation,
@ -169,8 +180,9 @@ data:
Use the browser for live or dynamic pages when search/extraction is
insufficient. Use terminal and file tools for direct engineering work; use
Herdr workers when an objective benefits from persistent Codex or Claude
Code execution, parallel review, or cross-provider fallback.
native delegated children for independent bounded work. Use Herdr workers
only when an objective benefits from persistent Codex or Claude Code CLI
execution or a long-lived CLI session that can be resumed later.
Local Jetson inference is the first provider-independent fallback. Use it
for bounded classification, summaries, and continuity when hosted capacity
@ -206,12 +218,27 @@ data:
Profiles are `codex-{low,medium,high,xhigh}` and
`claude-{low,medium,high,xhigh}`, plus `synthesis-xhigh`.
For persistent coding work, plan or launch a worker with:
## Decomposition and delegation
`herdr-dispatch --shape <implementation|architecture|review> --effort <low|medium|high|xhigh> [--provider codex|claude]`
Treat a long objective, checklist, plan, or referential instruction such as
"do it" as a task graph, not one homogeneous model request. Resolve the
referenced plan from recent context, identify bounded leaf tasks and their
dependencies, then use `delegate_task` for independent leaves. Run only
dependency-free leaves in parallel. Each native child and every nested
child is independently classified by the Jetson before its first model
request, so cheap leaves may use low effort while difficult or risky leaves
are raised to high or xhigh. Verify and synthesize all child evidence in the
foreground coordinator. Do not delegate a one-tool mechanical action merely
to create an agent.
Add `--start --project <path> --task <short-name> --prompt <objective>` to
create a separate Herdr worker space and launch the selected CLI. The visible
For persistent real Codex or Claude Code CLI work, launch a worker with:
`herdr-dispatch --auto --start --project <path> --task <short-name> --prompt <bounded-objective>`
AUTO sends that bounded worker objective through the same Jetson classifier
before selecting the real CLI, model, and effort. Explicit `--shape`,
`--effort`, and `--provider` remain available for a deliberate manual
override. The command creates a separate Herdr worker space. The visible
project tab remains a single Hermes coordinator pane. Never split Codex,
Claude, or a second Hermes process into that tab. Worker-space labels include
the parent project, provider, and task (for example
@ -237,10 +264,12 @@ data:
# Agent Hermes
The authenticated root of agent.hermes.bstein.dev opens the persistent
Herdr terminal interface. Give Hermes the outcome you want and it will
classify the difficulty, choose Codex or Claude Code, preserve the task on
the Cassandra board, supervise the worker through Herdr, and synthesize the
evidence. Use `/route status` to inspect the current decision, `/route auto`
browser terminal with the stock Hermes TUI in the foreground. Give Hermes
the outcome you want and it will decompose dependent work, classify every
delegated leaf on the Jetson, choose Codex or Claude, preserve the task on
the Cassandra board, and synthesize the evidence. Persistent real Codex and
Claude Code CLI sessions run through Herdr behind that interface only when
useful. Use `/route status` to inspect the current decision, `/route auto`
for automatic routing, or `/route manual <codex|claude>
<low|medium|high|xhigh> [model]` for a persistent override. The first native
Codex worker requires one device-code login; subsequent sessions persist on

View File

@ -21,7 +21,7 @@ spec:
app: hermes-agent
annotations:
ai.bstein.dev/role: project-coordinator
ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code
ai.bstein.dev/execution: Hermes delegation with durable Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260810-per-internal-prompt-routing"

View File

@ -469,7 +469,14 @@ def _apply_route(ctx: Any, agent: Any, plan: dict[str, Any]) -> None:
target_provider = str(plan["provider"])
target_model = str(plan["model"])
effort = str(plan["effort"])
cli = getattr(ctx._manager, "_cli_ref", None)
# A delegated child shares the plugin manager with the foreground TUI. Do
# not let routing that child rewrite the visible coordinator's model state.
runtime_agent = _runtime_agent(ctx)
cli = (
getattr(ctx._manager, "_cli_ref", None)
if agent is runtime_agent
else None
)
if agent.provider != target_provider or agent.model != target_model:
from hermes_cli.inventory import load_picker_context
@ -521,6 +528,7 @@ def _apply_route(ctx: Any, agent: Any, plan: dict[str, Any]) -> None:
agent._fallback_index = 0
agent._fallback_activated = False
agent._fallback_model = fallbacks[0] if fallbacks else None
if agent is runtime_agent:
try:
from agent.auxiliary_client import set_runtime_main
@ -568,6 +576,29 @@ def _record_internal_plan(
_write_policy(policy)
def _record_subagent_plan(
policy: dict[str, Any], plan: dict[str, Any], goal: str, task_index: int
) -> None:
"""Persist a bounded audit trail for independently routed child work."""
recorded = {
**plan,
"scope": "subagent",
"task_index": task_index,
"goal": goal[:500],
"updated_at": datetime.now(timezone.utc).isoformat(),
}
decisions = policy.get("subagent_decisions")
if not isinstance(decisions, list):
decisions = []
decisions.append(recorded)
policy["subagent_decisions"] = decisions[-50:]
policy["last_subagent_decision"] = recorded
policy["subagent_decisions_total"] = int(
policy.get("subagent_decisions_total") or 0
) + 1
_write_policy(policy)
def _runtime_agent(ctx: Any) -> Any | None:
"""Return the active agent without assuming a single CLI lifecycle."""
cli = getattr(ctx._manager, "_cli_ref", None)
@ -709,6 +740,43 @@ def _pre_internal_route(ctx: Any, **kwargs: Any) -> None:
)
def _pre_subagent_route(ctx: Any, **kwargs: Any) -> None:
"""Classify and route each native Hermes child before it starts work."""
policy = _current_policy()
if policy["mode"] != "auto":
return
child = kwargs.get("agent")
goal = str(kwargs.get("goal") or "").strip()
context = str(kwargs.get("context") or "").strip()
if child is None or not goal:
return
task_text = goal
if context:
task_text += f"\n\nDelegated context:\n{context[-6000:]}"
decision = classify_task(task_text)
decision = Decision(
decision.shape,
decision.effort,
decision.provider,
f"{decision.classifier}-subagent",
f"{decision.reason}; independently classified delegated task",
decision.latency_ms,
)
plan = select_route(_load_json(ROUTING_PATH), decision)
_apply_route(ctx, child, plan)
task_index = int(kwargs.get("task_index") or 0)
_record_subagent_plan(policy, plan, goal, task_index)
parent = kwargs.get("parent_agent") or _runtime_agent(ctx)
emit = getattr(parent, "_emit_status", None)
if callable(emit):
emit(
f"AUTO child #{task_index + 1}"
f"{plan['provider']}/{plan['model']} · {plan['effort']} (Jetson)"
)
def _status_text(ctx: Any) -> str:
policy = _current_policy()
cli = getattr(ctx._manager, "_cli_ref", None)
@ -786,6 +854,9 @@ def register(ctx: Any) -> None:
ctx.register_hook(
"pre_internal_route", lambda **kwargs: _pre_internal_route(ctx, **kwargs)
)
ctx.register_hook(
"pre_subagent_route", lambda **kwargs: _pre_subagent_route(ctx, **kwargs)
)
ctx.register_hook("post_llm_call", lambda **kwargs: _post_turn_route(ctx, **kwargs))
ctx.register_command(
"route",

View File

@ -4,3 +4,4 @@ description: Jetson-assisted provider, model, and reasoning-effort routing for A
provides_hooks:
- pre_turn_route
- pre_internal_route
- pre_subagent_route

View File

@ -4,10 +4,12 @@
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
@ -16,6 +18,7 @@ ALLOWED_EFFORTS = ("low", "medium", "high", "xhigh")
ROUTING_PATH = Path("/opt/data/workspace/coordinator/model-routing.json")
HERDR_BIN = Path("/opt/data/tools/bin/herdr")
CODEX_AUTH = Path("/opt/data/home/.codex/auth.json")
AUTO_ROUTER_PATH = Path("/opt/data/plugins/auto-router/__init__.py")
PROMPT_READY_MARKERS = {
"codex": "OpenAI Codex",
"claude": "Claude Code",
@ -67,6 +70,37 @@ def select_plan(
}
def select_auto_plan(
status: dict[str, Any], prompt: str, router_path: Path = AUTO_ROUTER_PATH
) -> dict[str, Any]:
"""Classify one bounded CLI objective on the Jetson and resolve its route."""
if not prompt.strip():
raise ValueError("AUTO routing requires a non-empty worker prompt")
if not router_path.is_file():
raise RuntimeError(f"AUTO router is unavailable: {router_path}")
spec = importlib.util.spec_from_file_location("hermes_auto_router_dispatch", router_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"AUTO router could not be loaded: {router_path}")
router = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = router
spec.loader.exec_module(router)
decision = router.classify_task(prompt)
plan = select_plan(
status,
str(decision.shape),
str(decision.effort),
str(decision.provider),
)
plan.update(
{
"classifier": str(decision.classifier),
"classification_reason": str(decision.reason),
"classification_latency_ms": int(decision.latency_ms),
}
)
return plan
def _slug(value: str, limit: int = 32) -> str:
"""Return a Herdr-safe stable label."""
cleaned = re.sub(r"[^a-z0-9_-]+", "-", value.lower()).strip("-")
@ -221,8 +255,9 @@ def launch_worker(
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--shape", choices=("implementation", "architecture", "review"), required=True)
parser.add_argument("--effort", choices=ALLOWED_EFFORTS, required=True)
parser.add_argument("--auto", action="store_true")
parser.add_argument("--shape", choices=("implementation", "architecture", "review", "question"))
parser.add_argument("--effort", choices=ALLOWED_EFFORTS)
parser.add_argument("--provider", choices=("codex", "claude"))
parser.add_argument("--routes", type=Path, default=ROUTING_PATH)
parser.add_argument("--start", action="store_true")
@ -231,7 +266,19 @@ def main() -> int:
parser.add_argument("--prompt")
args = parser.parse_args()
plan = select_plan(_load_routes(args.routes), args.shape, args.effort, args.provider)
status = _load_routes(args.routes)
if args.auto:
if args.provider is not None:
parser.error("--provider cannot be combined with --auto")
if args.shape is not None or args.effort is not None:
parser.error("--shape/--effort cannot be combined with --auto")
if not args.prompt:
parser.error("--prompt is required with --auto")
plan = select_auto_plan(status, args.prompt)
else:
if args.shape is None or args.effort is None:
parser.error("--shape and --effort are required unless --auto is used")
plan = select_plan(status, args.shape, args.effort, args.provider)
if args.start:
if args.project is None:
parser.error("--project is required with --start")

View File

@ -8,7 +8,7 @@ asserting health, placement, ownership, or current model availability.
| Surface | Purpose | Identity boundary | State and permissions |
| --- | --- | --- | --- |
| `triage.hermes.bstein.dev` | Brad's automated testing triage | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace, its own PVC and service account; read-only cluster triage plus approved internal evidence endpoints |
| `agent.hermes.bstein.dev` | Brad's project coordinator | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace, separate PVC and no Kubernetes RBAC; Herdr supervises Codex and Claude Code workers |
| `agent.hermes.bstein.dev` | Brad's project coordinator | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace and separate PVC; native Hermes delegates bounded work while Herdr preserves real Codex and Claude Code CLI sessions when needed |
| `chat.hermes.bstein.dev` | Private consumer chat and research through Hermes WebUI or a linked Telegram DM | Keycloak login plus one-time Telegram account link | One Hermes process and PVC per assigned Keycloak subject; no Kubernetes RBAC, terminal, or private-service access |
The instances do not share conversation state, credentials, profiles, skills

View File

@ -319,6 +319,64 @@ def test_manual_route_skips_internal_reclassification(monkeypatch):
)
def test_every_native_subagent_is_classified_and_routed_independently(monkeypatch):
calls = []
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
monkeypatch.setattr(router, "_load_json", lambda path: _status())
monkeypatch.setattr(
router,
"classify_task",
lambda text: calls.append(text)
or router.Decision(
"implementation", "medium", "codex", "jetson", "test", 9
),
)
applied = []
recorded = []
monkeypatch.setattr(
router, "_apply_route", lambda ctx, agent, plan: applied.append((agent, plan))
)
monkeypatch.setattr(
router,
"_record_subagent_plan",
lambda policy, plan, goal, index: recorded.append((plan, goal, index)),
)
class Parent:
def _emit_status(self, message):
self.message = message
child = object()
parent = Parent()
router._pre_subagent_route(
object(),
agent=child,
parent_agent=parent,
goal="Implement the bounded parser fix",
context="Run the focused tests.",
task_index=2,
)
assert len(calls) == 1
assert "Run the focused tests" in calls[0]
assert applied[0][0] is child
assert applied[0][1]["profile"] == "codex-medium"
assert applied[0][1]["classifier"] == "jetson-subagent"
assert recorded[0][1:] == ("Implement the bounded parser fix", 2)
assert parent.message.startswith("AUTO child #3")
def test_manual_route_leaves_native_subagent_on_parent_override(monkeypatch):
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "manual"})
monkeypatch.setattr(
router,
"classify_task",
lambda text: (_ for _ in ()).throw(AssertionError("should not classify")),
)
router._pre_subagent_route(object(), agent=object(), goal="Review the diff")
def test_manual_policy_is_reapplied_on_every_non_command_turn(monkeypatch):
monkeypatch.setattr(
router,

View File

@ -117,6 +117,8 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
assert "specific not in _LEGACY_WEB_BACKENDS" in dockerfile
assert '"pre_internal_route"' in dockerfile
assert "pre_internal_route hook failed" in dockerfile
assert '"pre_subagent_route"' in dockerfile
assert "pre_subagent_route hook failed" in dockerfile
def test_chat_oauth_allows_stale_service_worker_retirement():

View File

@ -54,6 +54,48 @@ def test_herdr_plan_chooses_task_shape_and_caps_effort():
dispatch.select_plan(status, "review", "max")
def test_herdr_auto_classifies_each_cli_worker_with_jetson_router(
tmp_path: Path,
):
router = tmp_path / "auto_router.py"
router.write_text(
"""
class Decision:
shape = "review"
effort = "xhigh"
provider = "claude"
classifier = "jetson"
reason = "bounded local vote"
latency_ms = 17
def classify_task(prompt):
assert "security review" in prompt
return Decision()
""",
encoding="utf-8",
)
status = {
"routes": {
"claude-xhigh": [
"anthropic/claude-opus-5",
"openai-codex/gpt-5.6-sol",
]
}
}
plan = dispatch.select_auto_plan(
status,
"Perform the final security review.",
router,
)
assert plan["worker"] == "claude"
assert plan["model"] == "claude-opus-5"
assert plan["effort"] == "xhigh"
assert plan["classifier"] == "jetson"
assert plan["classification_latency_ms"] == 17
def test_claude_worker_waits_for_prompt_readiness(tmp_path: Path, monkeypatch):
herdr = tmp_path / "herdr"
herdr.touch()
@ -647,6 +689,13 @@ def test_agent_coordinator_has_a_long_running_tool_budget():
config = yaml.safe_load(configmap["data"]["config.yaml"])
assert config["agent"]["max_turns"] == 180
assert config["delegation"] == {
"max_concurrent_children": 4,
"max_iterations": 120,
"max_spawn_depth": 2,
"orchestrator_enabled": True,
"subagent_auto_approve": True,
}
assert config["tool_loop_guardrails"]["hard_stop_enabled"] is True
for platform in ("cli", "api_server"):
tools = config["platform_toolsets"][platform]