Normandy.Agents.Dispatch (normandy v1.1.1)

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

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

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; prepared is the built tool struct, call the 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.

default_pipeline()

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

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.

execute(config, prepared, call, pipeline)

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.

prepare_tool(tool, input)

@spec prepare_tool(
  struct(),
  map()
) :: struct()

Builds the tool struct from LLM-supplied input. Uses the tool's prepare_input/2 if exported; otherwise maps known keys onto struct fields.

to_tool_call(call)

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

validate_input(tool, input)

@spec validate_input(
  struct(),
  map()
) :: :ok | {:error, list()}

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.