Normandy.Agents.Dispatch
(normandy v1.3.0)
View Source
The single chokepoint every agent tool call flows through.
dispatch_one/3 runs one tool call through a fixed pipeline:
registry resolution → before-hooks → policy check → budget pre-check →
execute → budget record → after-hooks. The behaviours are carried on a
Pipeline struct so they can be injected in tests and replaced by real
implementations in later phases. The default pipeline is allow-all / no-op /
identity, preserving current behavior.
The pipeline is also exposed as two composable halves: classify/3 (registry →
before-hooks → 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.
Summary
Functions
Classifies one tool call: registry resolution → before-hooks → 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.
The default pipeline: allow-all policy, no-op budget, no hooks, bare executor.
Reproduces current behavior. Callers (e.g. BaseAgent) override execute_fn
to add telemetry, and later phases override the behaviour functions.
Runs one tool call through the chokepoint pipeline and returns a %ToolResult{}.
Executes a classified ({:execute, prepared, call}) tool call: budget pre-check →
execute → budget record → after-hooks. 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.
Builds the tool struct from LLM-supplied input. Uses the tool's
prepare_input/2 if exported; otherwise maps known keys onto struct fields.
Normalizes a raw LLM tool call (struct or string-keyed map) into a %ToolCall{}.
Validates LLM-supplied input against a schema-based tool's schema BEFORE any
side effect, so a malformed call is rejected at classify-time rather than
blowing up inside (or three steps after) the tool's execute/1.
Functions
@spec classify( map(), Normandy.Components.ToolCall.t() | map(), Normandy.Agents.Dispatch.Pipeline.t() ) :: {:execute, struct(), Normandy.Components.ToolCall.t()} | {:deny, Normandy.Components.ToolResult.t()} | {:needs_approval, struct(), Normandy.Components.ToolCall.t(), map()}
Classifies one tool call: registry resolution → before-hooks → 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.
{:execute, prepared, call}— allowed;preparedis the built tool struct,callthe post-before-hook%ToolCall{}(hooks may have rewritten it).{:deny, %ToolResult{}}— registry miss, a before-hook:halt, or a policy:deny, already shaped into the error/denial result.{:needs_approval, prepared, 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, bare executor.
Reproduces current behavior. Callers (e.g. BaseAgent) override execute_fn
to add telemetry, and later phases override the behaviour functions.
@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 execute( map(), struct(), Normandy.Components.ToolCall.t(), Normandy.Agents.Dispatch.Pipeline.t() ) :: Normandy.Components.ToolResult.t()
Executes a classified ({:execute, prepared, call}) tool call: budget pre-check →
execute → budget record → after-hooks. 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.
Builds the tool struct from LLM-supplied input. Uses the tool's
prepare_input/2 if exported; otherwise maps known keys onto struct fields.
@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{}.
Validates LLM-supplied input against a schema-based tool's schema BEFORE any
side effect, so a malformed call is rejected at classify-time rather than
blowing up inside (or three steps after) the tool's execute/1.
Returns :ok when the input passes, or when the tool has no generated
validate/1 (plain-schema or hand-rolled tools — current behavior preserved);
{:error, errors} with path-based validation errors otherwise.
The LLM payload is string-keyed, but the schema validator looks up atom field
names, so input is first mapped onto known field atoms using the same
DoS-safe normalization prepare_tool/2 uses (never String.to_atom/1 on
untrusted input). A tool that defines its own prepare_input/2 owns its
validation and is skipped here.