Skip to content

feat: add hook-driven agent routing - #4495

Merged
dgageot merged 5 commits into
mainfrom
feat/hook-routing
Oct 1, 2026
Merged

dgageot merged 5 commits into
mainfrom
feat/hook-routing

Conversation

@hamza-jeddad

@hamza-jeddad hamza-jeddad commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

What changed

  • Agents get a routing block (allowed_agents, default_agent) and two new hook events, before_agent_run and after_agent_complete. Both are in pkg/config/latest and agent-schema.json, and are validated at load time: one selector per event, no routing cycles, no force_handoff together with after_agent_complete, and evaluator routes must cover every choice.
  • A hook can return {"transition":{"action":"route","agent":"..."}}. Control output is always validated strictly, and a block beats a route (pkg/hooks).
  • A new evaluator selector turns a choice evaluator's answer into a route through a routing_policy (routes, min_probability). It checks the whole probability distribution and falls back to default_agent on ties, low confidence, or provider failures.
  • The runtime (pkg/runtime/routing.go, loop.go) keeps per-invocation state, switches agents per session instead of mutating the shared agent, selects before the model or response cache is used, and routes after cached and structured-output answers too.
  • Every decision is stored in the session as a routing_decision item and emitted as an event (pkg/session/routing_decision.go). Records hold the evaluator, chosen outcome, probability, destination, and fallback reason, never the request or the answer.
  • Four examples (examples/hook_routing*.yaml) and docs for hooks, evaluators and multi-agent.

Why

A hook can now choose which agent runs, without asking an LLM to call transfer_task or handoff. That makes routing deterministic and cheap, and an agent that is routed away from never calls its own model. The decision record exists so a run's routing can be audited after the fact.

How it works

before_agent_run fires once per agent activation and can replace that activation with another agent. after_agent_complete fires after a successful answer and can continue the same conversation with another agent. Both validate the target against routing.allowed_agents. Hook crashes, timeouts, malformed output, and disallowed targets stop the run instead of continuing with the wrong agent. Cancellation, budget exhaustion, and invalid usage accounting also stop the run and never use the default agent. Each user request restarts at the entry agent, and a run is capped at 100 transitions.

Testing

  • New and extended tests in pkg/config, pkg/hooks, pkg/runtime, pkg/session, pkg/teamloader, pkg/cli, pkg/server, pkg/a2a, pkg/chatserver, pkg/mcp, and e2e/tui, all with fake models and evaluators and no network.
  • They cover budgets and invalid or unknown accounting, structured output, evaluator scoping and imports, headless --exec and --json runs, serve api, A2A, the OpenAI-compatible chat server, and the native TUI.
  • task build and task lint pass, and go test -race on the touched packages shows no failures or data races.
  • task test passes except TestExtraWorkspace in pkg/sandbox. It fails on any machine whose ~/.config/cagent/config.yaml has an alias named default, because the test reads that file. It fails the same way on main and passes with an empty home directory.
  • I ran examples/hook_routing_local.yaml by hand against a local AutoJEV server and confirmed the decisions were stored. The examples that use the hosted TypeSafe endpoint were not run against it.

Notes

  • before_agent_run fires before session_start and user_prompt_submit, so a prompt blocked by user_prompt_submit still costs one assessment.
  • A draft can stream to the user before after_agent_complete reviews it. The review does not retract it.
  • Not included: isolated or nested steps, parallel branches, retries, resuming mid-route, and selectors other than choice evaluators and commands.
  • ACP is not tested separately. It builds an ordinary unpinned session and uses the same run loop.
  • The TUI and /export do not display decision records yet. They are in the session database and in JSON exports.
  • The commits are SSH-signed but have no DCO Signed-off-by trailer. I can add them if that is required.

@hamza-jeddad
hamza-jeddad requested a review from a team as a code owner October 1, 2026 13:39
Hook routing recorded evaluator token usage but not the decision itself:
the selected outcome, probability, destination and fallback reason only
existed in a transient agent_route event and were lost on reload.

Store every control-hook decision as a routing_decision session item, and
emit a matching routing_decision event. Records cover routes, forced
handoffs, hooks that left the agent unchanged, and blocked runs. They are
idempotent per ID, survive reload, branch, fork and clone, and are audit
data only: they never enter the model context and carry no cost.

Records deliberately exclude the request, the agent output and the
conversation, since sessions are exported and shared.
Add two evaluator-backed routing examples that call a local AutoJEV
server through the typesafe evaluator provider (base_url, model autojev,
cost: {} since there is no hosted price to infer):

- hook_routing_local.yaml picks the agent before any agent answers
  (before_agent_run).
- hook_routing_completion.yaml routes a finished draft to review or to a
  finalizer (after_agent_complete).

Link both from the agent routing hooks documentation.
Add tests for the routing behaviours that had code but no coverage:

- budgets: a real token budget, or one already spent, stops the run; an
  assessment that exhausts the budget never falls back to the default agent,
  for both entry and completion routing
- accounting: negative, NaN or infinite cost and negative or overflowing token
  counts fail closed; unknown usage still routes and is flagged as unpriced
- structured output: an accepted tool-mode answer reaches after_agent_complete
  with the validated JSON; a rejected one never routes
- evaluator scope: an agent's own evaluator wins over the team's, a bound scope
  never borrows the team's, and an imported agent keeps its own bindings
- headless: cli.Run in text and JSON mode, including continuation, low
  confidence fallback and a non-zero exit when a decision fails
- entry points: serve api (decision streamed and stored), A2A, the
  OpenAI-compatible chat server and MCP all use unpinned sessions that route
- native TUI: a finished draft continues to the reviewer

Extract newRoutingFixtureFor so a fixture can use the completion event and
extra runtime options, and let scriptedProvider return a scripted stream.
@aheritier aheritier added area/config For configuration parsing, YAML, environment variables area/core Core agent runtime, session management area/docs Documentation changes area/mcp MCP protocol, MCP tool servers, integration area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/sessions For features/issues/fixes related to session lifecycle (resume, persistence, export) area/tui For features/issues/fixes related to the TUI kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Oct 1, 2026

@dgageot dgageot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three correctness issues below, each reproduced with a focused regression test. The existing suite passes: task build, task test, task lint, and race tests for runtime/hooks/session/config/teamloader.

Comment thread pkg/runtime/routing.go
Comment thread pkg/hooks/evaluator_routing.go
Comment thread pkg/runtime/loop.go
@hamza-jeddad
hamza-jeddad requested a review from dgageot October 1, 2026 15:56
@dgageot
dgageot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit b615695 Oct 1, 2026
22 checks passed
@dgageot
dgageot deleted the feat/hook-routing branch October 1, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config For configuration parsing, YAML, environment variables area/core Core agent runtime, session management area/docs Documentation changes area/mcp MCP protocol, MCP tool servers, integration area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/sessions For features/issues/fixes related to session lifecycle (resume, persistence, export) area/tui For features/issues/fixes related to the TUI kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants