Skip to content

A turn ends when the model stops calling tools, and the record holds the whole request - #136

Merged
deepfates merged 5 commits into
mainfrom
claude/turn-ending
Sep 22, 2026
Merged

deepfates merged 5 commits into
mainfrom
claude/turn-ending

Conversation

@deepfates

Copy link
Copy Markdown
Owner

A turn ends when the model stops calling tools

Imp.Predict.ReActV2 inherited DSPy's shape: a step with no tool calls bought one more request with tool_choice naming submit. Measured on a live Dwell resident (openrouter:thinkingmachines/inkling), that request costs a full extra call per turn and, when the model had already acted with a tool, comes back with an answer summarizing what it did rather than what it said. Anthropic's tool runner, the OpenAI Agents SDK, LangGraph's ReAct and Pydantic AI all end the turn when the model returns text with no tool call.

A prose step now finishes the run with that prose as the output and termination_reason: :answered, in that one request, when the task signature declares exactly one output of type :string. The prose is validated through the same parse a submit's arguments go through. A signature with several outputs, one non-text output, or a step that says nothing at all still takes the forced submit. prose: :forced_submit is the opt-out for a single-output signature.

New option finish_on maps a tool name to fn arguments, result, inputs -> {:finish, outputs} | :continue end — the shape Pydantic AI calls an output tool. {:finish, outputs} validates against the signature exactly as a submit would, and the run finishes with termination_reason: :finished_by_tool and finished_by_tool naming the tool. When one step calls several terminal tools the first in call order finishes the run; the rest still execute and are recorded, and a submit in the same step still wins. Outputs that fail validation become that call's recorded result, the same error a bad submit records, and the loop continues. The functions persist by registry name, like a tool runner and a tool policy.

The record holds the whole request

Imp.LM.request/2 emitted model_request with only the messages, so a recorded request could not be reproduced. Its metadata now carries :options (the request options with the tools removed) and :tools_hash (SHA-256 of the canonical JSON of the definitions, or nil when none). The definitions are emitted once per run per distinct hash, as the new :tools_offered event. Imp.Run.first_seen?/1 is the per-run state behind "once per run"; the Control process holds the set. Imp.Core.request_parts/1 already returns config.options, the full option keyword list including :tools and :tool_choice, so nothing had to move.

A host can say something about a stored turn

Imp.Adapter.Chat.format/3 gains :history_note_renderer, fn signature, turn -> nil | String.t(), consulted for every stored history turn after that turn's own messages; its text becomes one user message right behind them. A turn carrying tool calls routes through render_native_tool_history_turn/3, which consults neither :output_renderer nor :input_section_renderer, so until now a host had no way to tell the model something that became true after such a turn ended.

Gates

mix format, mix compile --warnings-as-errors, mix check (2857 tests, 0 failures; baseline 2844), mix dialyzer.check (one ignore line renumbered, no new findings).

deepfates added 5 commits September 21, 2026 20:00
A step that comes back as prose with no tool call now finishes the run
with that prose as the output and termination_reason :answered, when the
task signature declares exactly one output of type :string. It used to
cost one more request with tool_choice naming submit. Measured on a live
resident, that request both spent a call and, when the model had already
acted with a tool, came back with a summary of what it did rather than
what it said. Anthropic's tool runner, the OpenAI Agents SDK, LangGraph's
ReAct and Pydantic AI all end the turn this way, so it is the default;
prose: :forced_submit keeps the old behaviour for a single-output
signature. Several outputs, a non-text output, or a step that says
nothing at all still take the forced submit.

New option finish_on maps a tool name to
fn arguments, result, inputs -> {:finish, outputs} | :continue end. A
tool named there ends the turn with the outputs the function returns,
validated against the signature exactly as a submit's are, with
termination_reason :finished_by_tool and finished_by_tool naming the
tool. When a step calls several terminal tools the first in call order
finishes the run; the rest still execute and are recorded, and a submit
in the same step still wins. Outputs that fail validation are that
call's recorded result, the error a bad submit records, and the loop
continues. The functions persist by registry name, like a tool runner.
A :model_request event's metadata now carries :options, the request
options with the tool definitions removed, and :tools_hash, a SHA-256 of
the canonical JSON of those definitions, or nil when the request offered
none. The definitions themselves are emitted once per run per distinct
hash, as a new :tools_offered event whose input is the tool list as
sent. Before this a recorded request could not be reproduced: the tools
and every other option were dropped. Recording the roster once per run
rather than once per call keeps the record whole without repeating the
largest and least variable part of every request.

Imp.Run.first_seen?/1 is the per-run state that makes once-per-run
possible; the Control process holds the set. Both payloads are redacted
like every other event, and :tools_offered joins the kinds
Imp.Trajectory recognizes.
Imp.Adapter.Chat.format/3 gains :history_note_renderer,
fn signature, turn -> nil | String.t(). It is consulted for every stored
history turn, native tool turns included, after that turn's own
messages, and its text becomes one user message immediately behind them.

A turn carrying tool calls routes through render_native_tool_history_turn,
which consults neither :output_renderer nor :input_section_renderer, so
until now a host had no way to tell the model something that became true
after such a turn ended: that the answer was never delivered, that the
account's allowance ran out. A note is data about the turn rather than a
rewrite of it, so the record the loop keeps is untouched.

Also updates the dialyzer ignore line that these edits moved.
…ence

ex_doc resolves a fully qualified Module.fun/arity in an extra, and
Imp.Adapter.Chat.format/3 is a behaviour callback with no public doc, so
the CHANGELOG entry failed docs.check as a reference to a hidden
function.
mint 1.10.0 carries EEF-CVE-2026-82672 (MEDIUM), an unvalidated
chunk-size line tail that permits response smuggling. mint 1.10.1,
published 2026-09-19, fixes it, so this is a lock update rather than an
audit ignore entry. mix hex.audit reports only the two documented cowlib
entries again.
@deepfates
deepfates merged commit 9f9789d into main Sep 22, 2026
10 checks passed
@deepfates
deepfates deleted the claude/turn-ending branch September 22, 2026 03:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant