Skip to content

feat(predict): add ReActV2 forced submit - #58

Open
isaacbmiller wants to merge 1 commit into
isaac/react-v2-pr6-reactv2-loopfrom
isaac/react-v2-pr7-reactv2-forced-submit
Open

feat(predict): add ReActV2 forced submit#58
isaacbmiller wants to merge 1 commit into
isaac/react-v2-pr6-reactv2-loopfrom
isaac/react-v2-pr7-reactv2-forced-submit

Conversation

@isaacbmiller

Copy link
Copy Markdown

Summary

  • add forced-submit behavior when the ReActV2 loop stalls or hits recoverable parsing/context errors
  • add extract fallback over rendered history text
  • record forced-submit actions and outputs back into typed history

Stack

  • Base PR: ReActV2 tool loop
  • This is the top of the current stack.

Validation

@greptile-apps

greptile-apps Bot commented May 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a two-tier forced-submit recovery to ReActV2 so the agent can still produce output when the tool loop exits early (empty tool calls, parse error, or context overflow). Tier 1 re-calls the react module with a constrained tool-choice config to elicit a submit call; tier 2 falls back to a ChainOfThought extraction over a plain-text rendering of the history.

  • _forced_submit: sequences the two recovery tiers and records the chosen tool call and observation back into history before returning, ensuring the history is complete regardless of which tier succeeds.
  • _render_history_as_text: new static method that serialises History.frames to a tagged text format ([Input], [Thought], [Action], [Observation]) for the extraction prompt.
  • extract_signature / self.extract: new ChainOfThought predictor built from the original signature's input and output fields plus a trajectory input, initialised once in __init__ alongside self.react.

Confidence Score: 3/5

Safe to merge once the history double-mutation in tier 1 is resolved; all other findings are non-blocking quality issues.

The tier 1 path combines history.append_output(result) and dspy.Prediction(**result) in a single try-except. If the constructor raises a TypeError after the append already committed a complete output frame, the code falls through to tier 2, which may commit a second complete output frame for the same episode. This silently corrupts the history object returned to callers. The rest of the change — the render helper, the extract fallback, and the history recording — looks structurally sound, and the single new test covers the core happy path.

dspy/predict/reactv2.py — specifically the try-except block around lines 169-173 in _forced_submit

Important Files Changed

Filename Overview
dspy/predict/reactv2.py Adds _forced_submit two-tier recovery and _render_history_as_text; has a history double-mutation bug when TypeError comes from dspy.Prediction after history.append_output already succeeded, plus a no-op tier 1 for non-native adapters and inaccurate dict-frame labelling in the text renderer.
tests/predict/test_reactv2.py Adds one test covering the tier 1 forced-submit path; tier 2 extract fallback and error-triggered scenarios (parse_error, context_overflow) have no coverage.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[forward loop exits\nno submit returned] --> B[_forced_submit]
    B --> C[force_tool_call_config\nadapter]
    C --> D{config non-empty?}
    D -- yes --> E[self.react with forced\nsubmit tool choice]
    D -- no --> E
    E --> F{pred has submit\ntool call?}
    F -- no / error --> I
    F -- yes --> G[execute submit tool]
    G --> H{result valid?}
    H -- ValueError --> I
    H -- ok --> J[_append_tool_turn\nhistory]
    J --> K[history.append_output\nresult]
    K --> L{dspy.Prediction\n** result}
    L -- TypeError --> I
    L -- ok --> M[return termination_reason=\nforced_submit]
    I[Tier 2: render history\nas text] --> N[self.extract\ntrajectory=text]
    N --> O{any non-None\noutput fields?}
    O -- yes --> P[history.append_output\nextract result]
    P --> Q[return termination_reason=\nextract]
    O -- no / error --> R[return termination_reason=\nbreak_reason or failed]
Loading

Reviews (1): Last reviewed commit: "feat(predict): add ReActV2 forced submit" | Re-trigger Greptile

Comment thread dspy/predict/reactv2.py
Comment on lines +169 to +173
try:
history.append_output(result)
return dspy.Prediction(history=history, termination_reason="forced_submit", **result)
except TypeError as err:
logger.debug(f"Forced submit result was not a valid output mapping: {_fmt_exc(err)}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 History double-mutation when TypeError comes from dspy.Prediction

history.append_output(result) and dspy.Prediction(history=history, termination_reason="forced_submit", **result) are batched in the same try block. If append_output succeeds but dspy.Prediction(**result) then raises TypeError (e.g., the signature has an output field named history or termination_reason, causing a duplicate-keyword error), history already has a committed complete output frame. Control then falls through to tier 2, which calls history.append_output(result) again — leaving history with two complete=True output frames for the same episode. This silently corrupts the history that gets returned to the caller.

The fix is to scope the except TypeError only around dspy.Prediction(...), leaving history.append_output(result) outside the try-except, so a TypeError from the constructor never triggers tier 2 on an already-closed episode.

Comment thread dspy/predict/reactv2.py
Comment on lines +143 to +144
adapter = dspy.settings.adapter or dspy.ChatAdapter()
call_config = adapter.force_tool_call_config("submit")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tier 1 forced-submit is a no-op for non-native-tool-call adapters

When adapter.use_native_function_calling is False, force_tool_call_config returns {}, so call_config = {}. self.react(..., config={}, ...) is then functionally identical to a plain self.react(...) call — exactly the call that just failed (or returned empty tool calls) in the main loop. For these adapters, tier 1 will almost certainly produce the same broken response and burn a second LM call before falling through to the extract tier. Consider gating tier 1 behind a check that call_config is non-empty.

Comment thread dspy/predict/reactv2.py
Comment on lines +221 to +225
for event in history.frames:
if isinstance(event, dict):
for key, value in event.items():
lines.append(f"[Input] {key}: {value}")
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dict-type frames are unconditionally labelled [Input]

The guard if isinstance(event, dict): ... continue labels every key in a raw dict frame as [Input]. However, HistoryEntry = HistoryFrame | dict[str, Any] allows dict entries that contain output-field keys (e.g., dict entries passed directly in a History(frames=[{...}]) constructor). These would be misrepresented as inputs in the trajectory text fed to the extract LM, which could confuse the model about what has already been answered.

Comment on lines +72 to +85
def test_forced_submit_runs_when_loop_returns_no_tool_calls():
lm = DummyLM(
[
{"next_thought": "No call.", "tool_calls": []},
{"next_thought": "Force submit.", "tool_calls": [{"name": "submit", "args": {"answer": "done"}}]},
]
)
dspy.configure(lm=lm)
react = ReActV2("question -> answer", tools=[add])

result = react(question="test")

assert result.answer == "done"
assert result.termination_reason == "forced_submit"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No test coverage for tier 2 (extract fallback) or error-triggered forced submit

The new test only exercises the happy path of tier 1 (no_tool_calls → forced react → forced_submit). The tier 2 extract path (triggered when tier 1 also fails), and the scenarios where the loop breaks due to parse_error or context_overflow, are entirely untested. A test where force_tool_call_config returns {} (or the LM still returns no submit tool call) would exercise the self.extract(trajectory=...) path and verify the termination_reason == "extract" return.

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