A turn ends when the model stops calling tools, and the record holds the whole request - #136
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A turn ends when the model stops calling tools
Imp.Predict.ReActV2inherited DSPy's shape: a step with no tool calls bought one more request withtool_choicenamingsubmit. 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 ananswersummarizing 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 asubmit'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_submitis the opt-out for a single-output signature.New option
finish_onmaps a tool name tofn arguments, result, inputs -> {:finish, outputs} | :continue end— the shape Pydantic AI calls an output tool.{:finish, outputs}validates against the signature exactly as asubmitwould, and the run finishes withtermination_reason: :finished_by_toolandfinished_by_toolnaming 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 asubmitin the same step still wins. Outputs that fail validation become that call's recorded result, the same error a badsubmitrecords, 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/2emittedmodel_requestwith 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, ornilwhen none). The definitions are emitted once per run per distinct hash, as the new:tools_offeredevent.Imp.Run.first_seen?/1is the per-run state behind "once per run"; the Control process holds the set.Imp.Core.request_parts/1already returnsconfig.options, the full option keyword list including:toolsand:tool_choice, so nothing had to move.A host can say something about a stored turn
Imp.Adapter.Chat.format/3gains: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 throughrender_native_tool_history_turn/3, which consults neither:output_renderernor: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).