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
@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.
@spec new(keyword()) :: Normandy.Agents.Turn.State.t()
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 sendstool_choice: :none). Enforces thepos_integer()contract onState.max_iterations(BaseAgent.init/1applies the same check at the shell boundary). The default value is20.: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.
@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.
@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.
@spec resume(Normandy.Agents.Turn.State.t()) :: {Normandy.Agents.Turn.State.t(), [tuple()]}
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.
@spec resume( Normandy.Agents.Turn.State.t(), [Normandy.Components.Message.t()], {String.t() | nil, non_neg_integer()} | nil ) :: {Normandy.Agents.Turn.State.t(), [tuple()]}
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.
@spec step(Normandy.Agents.Turn.State.t(), term()) :: {Normandy.Agents.Turn.State.t(), [tuple()]}
Advances the turn one event. Pure: returns {state', [effect]} and does no I/O.
@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.