ReActV2: submit only for signatures that need it - #139
Merged
Merged
Conversation
… 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.
…-submit # Conflicts: # .dialyzer_ignore.exs
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.
What changes
ReActV2 offers
submitonly to signatures that need it.:string: nosubmittool 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_answerwhen the output's constraints reject the prose) each make one more request. That request offers the same tools withtool_choice: "none"and carrieslast_prose_noteif the caller gave one. Its text is the answer, withtermination_reason: :last_proseandtermination_causenaming 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 inunexecuted_tool_calls. IfImp.Deadlinehas already passed, no request is made and the run ends withtermination_reason: :deadline_exceeded(andtermination_cause). A full context window still ends the run at once, as before.submitand the forced submit are unchanged from DSPy'sreact_v2.py: same tool description, same guidance text, sametool_choiceforcing, plus Imp's existing required-only fallback and typed extraction.Removed options:
proseandon_max_iters. Neither has a choice left: the signature decides.forced_submit_noticeapplies only to signatures withsubmit, andlast_prose_noteonly to signatures without it; each is refused at construction for the other kind. Dumps no longer carryprose/on_max_iters; a dump that has them still loads, and the keys are ignored.Also changed:
answer), as asubmitevent does. Context projection uses these to find where episodes end, and ACP session replay reads the answer from them.:reasoningevent. Before this, an ACP client showed the answer twice: once as a thought and once as the message.Imp.Adapter.Chatguidance: withfinish_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 besideput_submit/2andwith_guidance/2.Imp.Observability.prediction_statusnow counts:answered,:last_proseand:finished_by_toolas 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
submitforquestion -> answernow answer in prose. Tests that exercisesubmititself usequestion -> answer, confidence: float.tool_choice "none" on the wire
test/react_v2_last_request_wire_test.exsruns 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 sametoolsas 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 samenormalize_tool_choiceclauses, and both omittoolsandtool_choicetogether when the tool list is empty. Reverting to "no tools" fails all three wire tests..dialyzer_ignore.exsconflicts with #138. Both PRs move thechat.exfetch_metafilter line. Whichever merges second needs{"lib/imp/adapter/chat.ex", :pattern_match_cov, {786, 8}}, and #138's own multimodal_runner line.Evidence
submitfails 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.mix checkon7f62248b: format and--warnings-as-errorspass; 2,873 tests, 1 failure (MLXLMTrainerTestreadiness-timeout output capture), which only fails under machine load and is unrelated. CI: every check passes on7f62248b.Not covered here
tool_choice: "none". Such a call is kept out of the history and listed inunexecuted_tool_calls.