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

classify(config, tool_call, pipeline \\ default_pipeline())

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; call is 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.

default_pipeline()

@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.

dispatch_one(config, tool_call, pipeline \\ default_pipeline())

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.

emit_denied(config, call, reason, rule_id \\ nil)

@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.

execute(config, invocation, call, pipeline)

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.

execute_approved(config, call, pipeline)

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.

executor_opts(config, invocation, call)

The Normandy.Tools.Executor options for invocation: the call's %Normandy.Tools.Context{} carrying the validated input, and the invocation's timeout.

to_tool_call(call)

Normalizes a raw LLM tool call (struct or string-keyed map) into a %ToolCall{}.