Normandy.Agents.Turn (normandy v2.2.0)

View Source

The pure finite-state-machine core of an agent turn.

A turn is plain data (Normandy.Agents.Turn.State) advanced by step/2, a pure function: step(state, event) -> {state', [effect]}. It performs no I/O. Events come from the shell (LLM responses, tool results, errors); effects are data the shell interprets (call the LLM, dispatch tools, append to memory, emit telemetry, finalize, fail). Keeping the core pure makes every transition unit/property testable without processes, and makes the state serializable for the durable/suspendable shells added in later phases.

States

Seven statuses are defined. :awaiting_approval (suspend/resume for human approval) is entered when a dispatched batch parks calls. :steering is a resting state entered when a turn starts and at every tool-batch boundary: the core emits a {:maybe_compact, info} effect there and resumes on {:compaction_done, _}, which is where context-window compaction runs in the shell. info is %{iterations_left: n, mid_round: boolean}: mid_round is false at the start of a turn and true after a tool batch, while the model's tool round is still open (see Normandy.Behaviours.Compactor).

A response whose stop_reason is :pause_turn (the API paused a long server-tool or MCP-connector turn) and that has no tool calls is appended and sent back as it is, with no user message after it, for the model to continue; it spends one iteration, like a tool batch. A paused final call (after the iteration cap) is continued the same way as the final call, at most three times; a fourth pause fails the turn with {:error, %Normandy.LLMError{type: :pause_turn}}.

Summary

Functions

Whether messages end with a turn's answer: an assistant message that makes no tool call and is not a paused turn (stop_reason pause_turn, sent back for the model to continue). A turn's history ends this way only once the answer is appended (the :output_guarded step); after a tool batch it ends with the results, or the iteration-cap notice.

Builds the initial :provisioning state for a turn.

Whether messages end with a paused turn: an assistant message that makes no tool call and whose stop_reason is pause_turn (:pause_turn or "pause_turn"). The API paused a long server-tool turn there (an MCP connector's mcp_tool_use with no mcp_tool_result yet); the turn is continued by sending it back with nothing after it, and a request that adds a user message after it is rejected.

The parked calls decisions reject, in parked order. Anything not explicitly :approve (a :reject, an unknown value, or no decision) is rejected: approval fails closed.

Re-derives the effects to continue a turn from a persisted, non-terminal state (used by an eager shell after passivation/handoff). Pure.

resume/1 for a shell that holds the turn's stored history (messages, oldest first). A :steering state is saved after its batch's results are appended, so a shell that stopped between the two restarts from the state saved one round earlier. The turn's tool rounds in messages (assistant tool calls with the last message's turn_id) count too: iterations_left never exceeds max_iterations minus them, and a turn they bring to the cap appends the iteration-cap notice (unless it is already the last message) before its final call. Rounds removed by in-turn compaction are not in messages: stored_rounds, {turn_id, rounds} from the stored history (AgentMemory.stored_turn_rounds/1, which reads the last compaction snapshot), counts them when turn_id is the turn messages end in, and the larger count stands. Pure.

Advances the turn one event. Pure: returns {state', [effect]} and does no I/O.

The tool calls of the latest assistant message that have no %ToolResult{} in a later "tool" message, in tool_use order; [] when the latest assistant message made no tool calls.

Functions

answered?(messages)

@spec answered?([Normandy.Components.Message.t()]) :: boolean()

Whether messages end with a turn's answer: an assistant message that makes no tool call and is not a paused turn (stop_reason pause_turn, sent back for the model to continue). A turn's history ends this way only once the answer is appended (the :output_guarded step); after a tool batch it ends with the results, or the iteration-cap notice.

Turn.Server reads it before it resumes a saved :steering turn: a server that stopped after the answer's append and before its :stopped save must not ask the model again.

new(opts \\ [])

Builds the initial :provisioning state for a turn.

Options

  • :max_iterations (pos_integer/0) - Tool-loop iterations before the final call (made with the loop's response model; the shell sends tool_choice: :none). Enforces the pos_integer() contract on State.max_iterations (BaseAgent.init/1 applies the same check at the shell boundary). The default value is 20.

  • :response_model (term/0) - The response model for tool-loop LLM calls, e.g. %ToolCallResponse{}.

  • :output_schema (term/0) - The schema the final answer is converted into ({:convert_output, resp, output_schema}) when it differs from :response_model.

paused_last?(messages)

@spec paused_last?([Normandy.Components.Message.t()]) :: boolean()

Whether messages end with a paused turn: an assistant message that makes no tool call and whose stop_reason is pause_turn (:pause_turn or "pause_turn"). The API paused a long server-tool turn there (an MCP connector's mcp_tool_use with no mcp_tool_result yet); the turn is continued by sending it back with nothing after it, and a request that adds a user message after it is rejected.

Turn.Server reads it before it admits a new input: a turn that failed or was killed after its pause is continued first.

rejected_calls(parked, decisions)

@spec rejected_calls([Normandy.Components.ToolCall.t()], %{
  optional(String.t()) => term()
}) :: [
  Normandy.Components.ToolCall.t()
]

The parked calls decisions reject, in parked order. Anything not explicitly :approve (a :reject, an unknown value, or no decision) is rejected: approval fails closed.

resume(s)

Re-derives the effects to continue a turn from a persisted, non-terminal state (used by an eager shell after passivation/handoff). Pure.

Only states the core persists are resumable: :steering (per-batch boundary) re-issues compaction then continues; :awaiting_approval re-announces itself with an :awaiting_approval event (resumed: true) and then waits for a decision. Terminal states yield no effects.

resume(state, messages, stored_rounds \\ nil)

resume/1 for a shell that holds the turn's stored history (messages, oldest first). A :steering state is saved after its batch's results are appended, so a shell that stopped between the two restarts from the state saved one round earlier. The turn's tool rounds in messages (assistant tool calls with the last message's turn_id) count too: iterations_left never exceeds max_iterations minus them, and a turn they bring to the cap appends the iteration-cap notice (unless it is already the last message) before its final call. Rounds removed by in-turn compaction are not in messages: stored_rounds, {turn_id, rounds} from the stored history (AgentMemory.stored_turn_rounds/1, which reads the last compaction snapshot), counts them when turn_id is the turn messages end in, and the larger count stands. Pure.

step(s, event)

Advances the turn one event. Pure: returns {state', [effect]} and does no I/O.

unanswered_tool_calls(messages)

@spec unanswered_tool_calls([Normandy.Components.Message.t()]) :: [
  Normandy.Components.ToolCall.t() | map()
]

The tool calls of the latest assistant message that have no %ToolResult{} in a later "tool" message, in tool_use order; [] when the latest assistant message made no tool calls.

A turn that fails or is killed between appending the assistant's tool_use and appending its results leaves such calls behind, and the Claude API rejects a request with an unanswered tool_use. Turn.Server uses this to close the batch at the start of the next turn.