Normandy.Agents.Dispatch (normandy v1.0.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{}.

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{}.