fix(hermes): route delegated work independently
This commit is contained in:
parent
c6b228ae23
commit
109bd1d6d7
@ -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 \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,18 +528,19 @@ 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
|
||||
try:
|
||||
from agent.auxiliary_client import set_runtime_main
|
||||
if agent is runtime_agent:
|
||||
try:
|
||||
from agent.auxiliary_client import set_runtime_main
|
||||
|
||||
set_runtime_main(
|
||||
agent.provider or "",
|
||||
agent.model or "",
|
||||
base_url=agent.base_url or "",
|
||||
api_key=agent.api_key or "",
|
||||
api_mode=agent.api_mode or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
set_runtime_main(
|
||||
agent.provider or "",
|
||||
agent.model or "",
|
||||
base_url=agent.base_url or "",
|
||||
api_key=agent.api_key or "",
|
||||
api_mode=agent.api_mode or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _current_policy() -> dict[str, Any]:
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user