Skip to content

ReActV2: submit only for signatures that need it - #139

Merged
deepfates merged 11 commits into
mainfrom
claude/narrow-submit
Sep 24, 2026
Merged

deepfates merged 11 commits into
mainfrom
claude/narrow-submit

Conversation

@deepfates

@deepfates deepfates commented Sep 23, 2026

Copy link
Copy Markdown
Owner

What changes

ReActV2 offers submit only to signatures that need it.

  • One output of type :string: no submit tool is offered. A step that writes text and calls no tool is the answer (termination_reason: :answered). Every interruption takes one path: the step limit, a failed request (:prediction_error, :parse_error) and a step that calls nothing and says nothing (:empty_completion, or :invalid_answer when the output's constraints reject the prose) each make one more request. That request offers the same tools with tool_choice: "none" and carries last_prose_note if the caller gave one. Its text is the answer, with termination_reason: :last_prose and termination_cause naming the interruption. It keeps the tools for two reasons: Anthropic refuses a request whose history holds tool_use/tool_result blocks when no tools are declared, and changing the tool list changes the prompt prefix the provider caches. If the model calls a tool anyway, the call is not run and is not added to the history, where it would be replayed without a result. The completion's text is the answer, and the calls are listed in unexecuted_tool_calls. If Imp.Deadline has already passed, no request is made and the run ends with termination_reason: :deadline_exceeded (and termination_cause). A full context window still ends the run at once, as before.
  • Several outputs, or one non-text output: submit and the forced submit are unchanged from DSPy's react_v2.py: same tool description, same guidance text, same tool_choice forcing, plus Imp's existing required-only fallback and typed extraction.

Removed options: prose and on_max_iters. Neither has a choice left: the signature decides. forced_submit_notice applies only to signatures with submit, and last_prose_note only to signatures without it; each is refused at construction for the other kind. Dumps no longer carry prose/on_max_iters; a dump that has them still loads, and the keys are ignored.

Also changed:

  • The answered and last-prose history events carry the output field (answer), as a submit event does. Context projection uses these to find where episodes end, and ACP session replay reads the answer from them.
  • A prose answer is no longer also emitted as a :reasoning event. Before this, an ACP client showed the answer twice: once as a thought and once as the message.
  • If the first step fails before any step has used the inputs, the inputs go into the history before the note, so the note is the last thing the model reads.
  • Imp.Adapter.Chat guidance: with finish_tool: nil, the finish line says "When the final answer is ready, write it as plain text without calling a tool." With a finish tool, the text is DSPy's, word for word. The divergence and its reason are written beside put_submit/2 and with_guidance/2.
  • Imp.Observability.prediction_status now counts :answered, :last_prose and :finished_by_tool as complete. Before, it reported them as incomplete/failed. This fix is in its own commit.

Demos, the workspace agent example, the deployment agent-optimization example and the tests that scripted a submit for question -> answer now answer in prose. Tests that exercise submit itself use question -> answer, confidence: float.

tool_choice "none" on the wire

test/react_v2_last_request_wire_test.exs runs each provider path through the real ReqLLM stack against a local server and reads back the encoded body: OpenAI "tool_choice": "none", OpenRouter "none", Anthropic {"type": "none"}, each with the same tools as the first request. The test passes on ReqLLM 1.17.1 (this branch). I also ran it on 1.24.0 by cherry-picking these commits onto #138's branch, and the ReActV2 test files (50 tests) passed there too. Both versions have the same normalize_tool_choice clauses, and both omit tools and tool_choice together when the tool list is empty. Reverting to "no tools" fails all three wire tests.

.dialyzer_ignore.exs conflicts with #138. Both PRs move the chat.ex fetch_meta filter line. Whichever merges second needs {"lib/imp/adapter/chat.ex", :pattern_match_cov, {786, 8}}, and #138's own multimodal_runner line.

Evidence

  • Mutation checks, each run against the named tests: always adding submit fails 4 tests; routing interruptions back to the forced submit fails 9; removing the deadline check fails the deadline test; dropping the output from the answered event fails the no-submit test and the ACP MCP answer test; putting the note before unspent inputs fails the failed-step test; emitting the answer as reasoning fails the ACP live-updates test; dropping the tools from the last request fails 10 (7 last-prose, 3 wire). The observability test failed before its fix.
  • Local mix check on 7f62248b: format and --warnings-as-errors pass; 2,873 tests, 1 failure (MLXLMTrainerTest readiness-timeout output capture), which only fails under machine load and is unrelated. CI: every check passes on 7f62248b.

Not covered here

  • No live provider run.
  • Imp does not add a fallback for a provider that ignores tool_choice: "none". Such a call is kept out of the history and listed in unexecuted_tool_calls.

… complete

prediction_status listed only :submit, :forced_submit and :direct as complete
endings, so a ReActV2 run that ended with its answer in prose, in the text of
the last request, or through a terminal tool was reported as incomplete and
its status as failed.
A signature with exactly one output of type :string gets no submit tool. Its
answer is the prose the model writes when it stops calling tools, and every
interruption (the step limit, a failed request, a step that calls nothing and
says nothing) makes one more request that offers no tools, whose text is the
answer (termination_reason :last_prose, termination_cause naming the
interruption). A passed Imp.Deadline ends the run with :deadline_exceeded
instead of making that request. Signatures with several or typed outputs keep
DSPy's submit and forced submit unchanged.

The prose and on_max_iters options are removed. last_prose_note applies to
signatures without submit and forced_submit_notice to signatures with it; each
is refused for the other. The answered and last-prose history events carry the
output, as a submit's event does, and the answer is no longer also emitted as a
:reasoning event.
A request that declares no tools while its history holds tool calls is
refused by Anthropic, and a changed roster changes the prompt prefix a
provider caches. The last request of an interrupted turn now sends the same
tools as every step with tool_choice "none". A tool call the model makes
anyway is not run and not replayed; the completion's text is the answer and
the prediction names the calls in unexecuted_tool_calls. A wire test runs the
request through ReqLLM for OpenAI, OpenRouter and Anthropic.
Saying nothing is how a model declines to answer. Treating it as an
interruption asked again, so declining cost a second request.
…equest

OpenRouter relays an upstream provider's refusal as a successful HTTP
response whose body is an error object with no choices; ReqLLM decodes it
to an empty message with the error in provider_meta. A stored Dwell turn
shows one (DeepInfra: failed to compile structural_tag grammar). Read as a
completion it said nothing, which ReActV2 now takes as the model declining,
so a refused request ended the turn as an empty answer with no last
request. It is returned as ReqLLM.Error.API.Request, so the step is a
prediction error and the turn takes its last action.

CHANGELOG: the empty step is an empty answer, and :empty_completion is no
longer a termination cause.
…thout submit

A resident's stored history holds turns answered through submit. Replayed to
a loop that no longer offers submit, the call named a tool the request did
not declare, and on DeepInfra thinkingmachines/inkling then wrote raw
tool-call markup as its answer (3 of 3 requests; 0 of 3 with the same turns
as text). The call was the turn's answer, so it is shown as the answer.
…nderer

A plain history turn and the current turn were rendered with the host's
section renderer, but a turn that called tools used Chat's own field markers,
so a Dwell resident read its past turns under [[ ## source ## ]] headers and
its current turn as plain text.
A submit the loop rejected (missing output fields, invalid arguments) was
rendered as assistant text like an accepted one, so a replayed history showed
the model answering with the rejected arguments and then answering again. Only
the last accepted submit becomes the answer, as it was for the loop; rejected
ones are dropped with their errors. A submit recorded without an id is matched
to its result by name and call order, so the step's other id-less results are
no longer dropped with it.
@deepfates
deepfates merged commit 3be76f1 into main Sep 24, 2026
10 checks passed
@deepfates
deepfates deleted the claude/narrow-submit branch September 24, 2026 03:57
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