Normandy.Agents.Dispatch
(normandy v2.2.0)
View Source
The single chokepoint every agent tool call flows through.
dispatch_one/3 runs one tool call through a fixed pipeline:
toolset lookup → before-hooks → input validation → policy check → budget pre-check →
execute → budget record → after-hooks. The behaviours are carried on a
Pipeline struct. Every agent run builds its pipeline from the agent's
Normandy.Behaviours.Config (Normandy.Behaviours.Config.to_pipeline/1: its policy,
budget, function hooks and before_tool/after_tool hook modules), with an execute step
wrapped in the [:normandy, :tool, :execute] span. default_pipeline/0 (allow-all
policy, no-op budget, no hooks) is what dispatch_one/2 and classify/2 use when no
pipeline is passed.
The pipeline is also exposed as two composable halves: classify/3 (toolset lookup →
before-hooks → input validation → policy, producing a verdict with no side effects) and
execute/4 (budget → execute → record → after, running an already-classified call).
dispatch_one/3 is exactly classify ➞ execute; a durable shell consults
classify/3 to park a :needs_approval call before any side effect runs, and runs an
approved one with execute_approved/3.
Input
The model's input is validated against the tool's input schema
(Normandy.Tools.Input.validate/2) after the before-hooks, so a hook's rewrite is what is
checked. The validated input travels in an Invocation beside the registered tool, and
reaches the tool as context.input; the tool struct is never changed. Invalid input (an
unknown key, a missing required key, a wrong type) answers the call with an is_error
result naming the keys, and the turn goes on.
Results the model sees
A call answered without running its tool as asked gets is_error: true and an
output of %{error: message} plus only the keys that apply (never a null). The
message says what happened and what to do next. Keys: denied: true (a policy deny,
the synchronous :needs_approval collapse, a budget refusal, a before-hook halt, a
rejected or expired approval), rule_id and rationale (a policy's, when set),
validation_errors (input that failed the tool's schema: a fixable mistake, so not
denied), expired: true and interrupted: true. A tool's own {:error, binary},
or a raised exception, reaches the model as that text alone; any other error term as
%{error: term}.
Telemetry
A call answered without running its tool emits [:normandy, :tool, :denied]
(%{count: 1}, metadata as listed in Normandy.Telemetry) with
reason :policy (a policy deny, or a :needs_approval verdict collapsed by
dispatch_one/3), :hook_halt (a before-hook halted it) or :budget (the
budget pre-check refused it). Approval outcomes (:approval_rejected,
:approval_expired) are emitted by Turn.Server, the only shell that parks.
Toolset misses and invalid or schema-failing input are errors, not denials,
and emit nothing. A result cut by the cap (below) emits
[:normandy, :tool, :result_truncated] (%{original_chars, kept_chars}, metadata as
listed in Normandy.Telemetry).
Result cap
Every result leaves the chokepoint through finish/3, after the after_tool slot and
the after-hooks (which see the whole output): an output JSON cannot express, or a
binary that is not valid UTF-8, becomes an error result; then a result whose text (what
the model reads: a binary as-is, a number as its digits, anything else as JSON) is
longer than the limit is cut to the limit, in characters (graphemes, so a
multi-codepoint character is never split), and ends with
" [truncated: <kept> of <total> chars shown]". Error results are capped too. The
limit is the tool's Normandy.Tools.Toolset.register/3 :max_result_chars when it was
registered with one (nil there turns the cap off for that tool), else the agent's
:max_tool_result_chars (default 100_000; nil = no cap). A map or list output is cut
as its JSON text, so the model gets a string.
after_tool
Pipeline.after_tool (fn config, call, result -> result end, default nil) runs on
every result the chokepoint returns: executed, budget-denied, every classify/3 denial
(toolset miss, invalid or schema-failing input, a before-hook halt, a policy deny) and
dispatch_one/3's interim approval denial. It runs before the function after_hooks,
which see only results of tools that ran, and the result answers the call's id
whatever it returns. Normandy.Behaviours.Config.to_pipeline/1 sets it from the agent's
hook modules. For a denial it receives the call as the model sent it (input
normalized); for an executed result, the call the before-hooks produced.
The config it gets depends on where the result was made. A call answered at
classification (a denial, or a :needs_approval verdict answered without an approval)
is answered in the calling process with the full config. In a batch (BaseAgent.run/3,
BaseAgent.stream/3, Turn.Server), an executed call's after-hooks run in a task whose
config leaves out the conversation's memory entries, so config.memory's entries are
empty there; read the turn id, name, model, toolset and tool_context, not the history.
policy_fn and execute_fn
policy_fn is called as (config, tool_call, invocation). execute_fn is called as
(config, invocation, tool_name) when it has arity 3, or
(config, invocation, tool_name, tool_call) when it has arity 4; executor_opts/3 builds
the Normandy.Tools.Executor options (context with the input, registration timeout) from
them.
Summary
Functions
Classifies one tool call: toolset lookup → before-hooks → input validation → policy. Returns
the routing decision WITHOUT executing the tool, so a durable shell can act on a
:needs_approval verdict (park) before any side effect runs. Its only side
effects are the [:normandy, :tool, :denied] event for a policy deny or hook halt and,
for a :deny result, the pipeline's after_tool slot (every result the model sees
passes it once; an :execute or :needs_approval verdict has no result yet).
The default pipeline: allow-all policy, no-op budget, no hooks, the bare executor, passing
the call's %Normandy.Tools.Context{} (with the validated input) and timeout. It behaves as
Normandy.Behaviours.Config.to_pipeline/1 of a default %Normandy.Behaviours.Config{};
agent runs use that function's pipeline for the agent's own behaviours.
Runs one tool call through the chokepoint pipeline and returns a %ToolResult{}.
Emits [:normandy, :tool, :denied] for call, answered without running its
tool. Shells that answer a call outside the pipeline (Turn.Server approval
rejection or expiry) call this too, so every denial has one shape.
Executes a classified ({:execute, invocation, call}) tool call: budget pre-check →
execute → budget record → after_tool slot → after-hooks. A budget denial passes
after_tool too. Returns a %ToolResult{}. Skips
re-classification — the verdict was already decided by classify/3 (and, for an
approved call, by a human), so re-running policy here would re-deny/re-park.
Executes a call a human approved (a :needs_approval verdict a durable shell parked):
looks the tool up again and validates the parked call's input again (both are
deterministic), then runs execute/4. Policy is not consulted again: it would park the
call again. A tool no longer registered, or input that no longer validates, answers the
call with the same result classify/3 would give, through the after_tool slot.
The Normandy.Tools.Executor options for invocation: the call's
%Normandy.Tools.Context{} carrying the validated input, and the invocation's timeout.
Normalizes a raw LLM tool call (struct or string-keyed map) into a %ToolCall{}.
Functions
@spec classify( map(), Normandy.Components.ToolCall.t() | map(), Normandy.Agents.Dispatch.Pipeline.t() ) :: {:execute, Normandy.Agents.Dispatch.Invocation.t(), Normandy.Components.ToolCall.t()} | {:deny, Normandy.Components.ToolResult.t()} | {:needs_approval, Normandy.Agents.Dispatch.Invocation.t(), Normandy.Components.ToolCall.t(), map()}
Classifies one tool call: toolset lookup → before-hooks → input validation → policy. Returns
the routing decision WITHOUT executing the tool, so a durable shell can act on a
:needs_approval verdict (park) before any side effect runs. Its only side
effects are the [:normandy, :tool, :denied] event for a policy deny or hook halt and,
for a :deny result, the pipeline's after_tool slot (every result the model sees
passes it once; an :execute or :needs_approval verdict has no result yet).
{:execute, %Invocation{}, call}— allowed;callis the post-before-hook%ToolCall{}(hooks may have rewritten it).{:deny, %ToolResult{}}— a toolset miss, input that is not an object or fails validation, a before-hook:halt, or a policy:deny, already shaped into the error/denial result.{:needs_approval, %Invocation{}, call, info}— policy wants human approval.
@spec default_pipeline() :: Normandy.Agents.Dispatch.Pipeline.t()
The default pipeline: allow-all policy, no-op budget, no hooks, the bare executor, passing
the call's %Normandy.Tools.Context{} (with the validated input) and timeout. It behaves as
Normandy.Behaviours.Config.to_pipeline/1 of a default %Normandy.Behaviours.Config{};
agent runs use that function's pipeline for the agent's own behaviours.
@spec dispatch_one( map(), Normandy.Components.ToolCall.t() | map(), Normandy.Agents.Dispatch.Pipeline.t() ) :: Normandy.Components.ToolResult.t()
Runs one tool call through the chokepoint pipeline and returns a %ToolResult{}.
Re-expressed as classify ➞ execute; observable behavior is unchanged. Accepts
either a %ToolCall{} (non-streaming) or a raw string-keyed map (streaming); the
latter is normalized first. A :needs_approval verdict collapses to the interim
denial result here (the synchronous path cannot wait for a human); only the
durable shell parks on it.
@spec emit_denied(map(), Normandy.Components.ToolCall.t(), atom(), String.t() | nil) :: :ok
Emits [:normandy, :tool, :denied] for call, answered without running its
tool. Shells that answer a call outside the pipeline (Turn.Server approval
rejection or expiry) call this too, so every denial has one shape.
@spec execute( map(), Normandy.Agents.Dispatch.Invocation.t(), Normandy.Components.ToolCall.t(), Normandy.Agents.Dispatch.Pipeline.t() ) :: Normandy.Components.ToolResult.t()
Executes a classified ({:execute, invocation, call}) tool call: budget pre-check →
execute → budget record → after_tool slot → after-hooks. A budget denial passes
after_tool too. Returns a %ToolResult{}. Skips
re-classification — the verdict was already decided by classify/3 (and, for an
approved call, by a human), so re-running policy here would re-deny/re-park.
@spec execute_approved( map(), Normandy.Components.ToolCall.t(), Normandy.Agents.Dispatch.Pipeline.t() ) :: Normandy.Components.ToolResult.t()
Executes a call a human approved (a :needs_approval verdict a durable shell parked):
looks the tool up again and validates the parked call's input again (both are
deterministic), then runs execute/4. Policy is not consulted again: it would park the
call again. A tool no longer registered, or input that no longer validates, answers the
call with the same result classify/3 would give, through the after_tool slot.
The approval covers the call's name and input, not the tool's code: after a redeploy, or
a supplement that registers another tool under that name, the approved call runs
whatever tool now holds the name. To bind an approval to one implementation, version
the tool's name ("refund_v2") when its behaviour changes.
@spec executor_opts( map(), Normandy.Agents.Dispatch.Invocation.t(), Normandy.Components.ToolCall.t() ) :: keyword()
The Normandy.Tools.Executor options for invocation: the call's
%Normandy.Tools.Context{} carrying the validated input, and the invocation's timeout.
@spec to_tool_call(Normandy.Components.ToolCall.t() | map()) :: Normandy.Components.ToolCall.t()
Normalizes a raw LLM tool call (struct or string-keyed map) into a %ToolCall{}.