feat: add hook-driven agent routing - #4495
Merged
Merged
Conversation
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.
hamza-jeddad
force-pushed
the
feat/hook-routing
branch
from
October 1, 2026 13:44
a5d0c94 to
9eb3205
Compare
dgageot
requested changes
Oct 1, 2026
dgageot
left a comment
Member
There was a problem hiding this comment.
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.
dgageot
approved these changes
Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
routingblock (allowed_agents,default_agent) and two new hook events,before_agent_runandafter_agent_complete. Both are inpkg/config/latestandagent-schema.json, and are validated at load time: one selector per event, no routing cycles, noforce_handofftogether withafter_agent_complete, and evaluator routes must cover every choice.{"transition":{"action":"route","agent":"..."}}. Control output is always validated strictly, and a block beats a route (pkg/hooks).choiceevaluator's answer into a route through arouting_policy(routes,min_probability). It checks the whole probability distribution and falls back todefault_agenton ties, low confidence, or provider failures.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.routing_decisionitem 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.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_taskorhandoff. 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_runfires once per agent activation and can replace that activation with another agent.after_agent_completefires after a successful answer and can continue the same conversation with another agent. Both validate the target againstrouting.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
pkg/config,pkg/hooks,pkg/runtime,pkg/session,pkg/teamloader,pkg/cli,pkg/server,pkg/a2a,pkg/chatserver,pkg/mcp, ande2e/tui, all with fake models and evaluators and no network.--execand--jsonruns,serve api, A2A, the OpenAI-compatible chat server, and the native TUI.task buildandtask lintpass, andgo test -raceon the touched packages shows no failures or data races.task testpasses exceptTestExtraWorkspaceinpkg/sandbox. It fails on any machine whose~/.config/cagent/config.yamlhas an alias nameddefault, because the test reads that file. It fails the same way onmainand passes with an empty home directory.examples/hook_routing_local.yamlby 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_runfires beforesession_startanduser_prompt_submit, so a prompt blocked byuser_prompt_submitstill costs one assessment.after_agent_completereviews it. The review does not retract it.choiceevaluators and commands./exportdo not display decision records yet. They are in the session database and in JSON exports.Signed-off-bytrailer. I can add them if that is required.