fix: normalize DeepSeek tool protocol responses#859
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances the ReAct pattern's final answer generation by explicitly requiring the final_answer tool and introducing a retry mechanism if the model outputs an invalid serialized tool-call protocol instead of a user-facing final answer. The unit tests have been updated to cover these changes. The feedback suggests ensuring that any active streaming sessions (answer_streamer) are cleanly closed/failed if validation fails on either the initial attempt or the retry attempt, preventing dangling streams on the runtime.
93cc501 to
ee1f5d6
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a retry mechanism in the ReAct agent pattern to handle cases where a forced final answer returns an invalid serialized tool-call protocol instead of a user-facing final answer. It ensures the final_answer tool schema is provided and required when forcing a final answer, updates LLM instructions, and adds comprehensive unit tests. Feedback on the changes suggests a robustness improvement in _forced_final_response_requires_retry to prevent a potential TypeError if the tool_calls key is explicitly set to None.
ee1f5d6 to
5f267d6
Compare
5f267d6 to
46d6f68
Compare
46d6f68 to
8467be7
Compare
rogercloud
left a comment
There was a problem hiding this comment.
Summary
This PR fixes DeepSeek's tendency to violate the tool-call protocol by moving detection and rejection out of the ReAct agent and into a provider-owned codec (deepseek_tool_protocol.py, new), applied to both direct DeepSeek calls and DeepSeek-via-OpenRouter. The codec detects and rejects DSML markup leaked as plain assistant text (task 722), nested serialized tool calls embedded inside another call's structured arguments (task 724), calls to unavailable tools, malformed arguments, and schema-forbidden arguments — surfacing each as a typed ToolProtocolViolation. ReAct is left owning only a single generic retry (re-issuing with the same allowed tools) on top of its pre-existing tool-authorization boundary. This supersedes sibling PR #790, which reconstructed and executed recovered tool calls from parsed DSML and thereby risked bypassing forced-final-answer state.
This PR previously received several rounds of review that raised 3 inline findings; all 3 are now confirmed fixed (see "Previously reported issues" below). The branch has since been rebased/squashed into a single commit (8467be77 "fix: keep ReAct tool calls structured") on top of base 01a456691cfc5bdf8a1349d573c407f8f93a145c — the fix commits referenced in the author's earlier replies (ee1f5d6a, 5f267d67) are no longer reachable in the current history. There is therefore no separate "diff since last review" to isolate: this review re-evaluates the PR fresh in its entirety (a full Round 0/1/2 pass over the current diff, with every Major finding independently re-verified a second time), which already reflects the 3 prior fixes and every other change made since.
Previously reported issues
All 3 prior inline findings (from an earlier review of src/xagent/core/agent/pattern/react/react.py) were re-verified against the current code and are FIXED:
- A — Dangling stream on first invalid attempt (was high). FIXED. When
_response_requires_tool_protocol_retry(...)is true (react.py:432-437),await answer_streamer.fail("invalid tool protocol, retrying")now runs before_retry_tool_protocol_response(...). The retry mechanism was generalized/renamed from_forced_final_response_requires_retry/_retry_serialized_final_answerinto_response_requires_tool_protocol_retry/_retry_tool_protocol_responseas part of the broader DeepSeek-protocol work, but the "fail the first streamer before retrying" guarantee carried forward intact. - B — Dangling stream when the retry also fails (was high). FIXED. When the retry's response also violates protocol (react.py:448-460),
await answer_streamer.fail("invalid tool protocol after retry")(line 452-453) runs beforereturn PatternResult(success=False, ...). Confirmed by directly running the regression testtest_react_closes_invalid_retry_final_answer_stream_before_failure(tests/core/agent/test_react.py:1087) — passes, assertingsuccess is False,status == "invalid_tool_protocol", and the stream ending withfinal_answer_errorrather than dangling. - C —
Nonetool_callsTypeError (was medium). FIXED at the reported site._response_requires_tool_protocol_retrynow usesnormalized.get("tool_calls") or [](react.py:712). Confirmed by runningtest_react_accepts_null_tool_calls_in_forced_final_response(tests/core/agent/test_react.py:1044) directly — passes.- Follow-up note (out of scope, not blocking): two other call sites in the same file have the same unguarded
.get("tool_calls", [])pattern (react.py:947 in_normalize_tool_calls, react.py:1891 in_parse_react_decision). Both are pre-existing (identical in the base commit, untouched by this PR), so they are out of scope here — noting only for a possible separate follow-up.
- Follow-up note (out of scope, not blocking): two other call sites in the same file have the same unguarded
Verdict: acceptable-with-reservations
The layering is right: normalization lives at the provider/model boundary, ReAct keeps only a generic retry, and the codec is shared cleanly between deepseek.py and openrouter.py. Critically, unlike #790 this fix does not reconstruct or execute unauthorized recovered calls — so it avoids #790's loop-restart hazard, where executing a recovered leaked tool call could clear force_final_answer_next and bypass the repeated-tool finalization decision. This is a root-cause fix at the correct architectural altitude and a genuine improvement over #790. The two Major findings below (a streaming UX regression and a detection gap that lets the exact leak this PR targets slip through permanently) are worth fixing before merge; both were independently re-verified twice, including a live reproduction of the M2 leak against the current code.
Findings
Major
M1 — Valid DeepSeek final answers no longer stream progressively. src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:67-69,100-112. adapt_deepseek_stream buffers every is_tool_call() chunk and only yields the last accumulated one at stream end. Before this PR, OpenAICompatibleLLM._parse_stream_chunk (openai.py:1184-1257) emitted an incremental accumulated-so-far TOOL_CALL chunk on every delta — genuinely progressive, and ToolCallStringFieldStreamer.handle_chunk (final_answer_stream.py:115-145) is built to consume that incremental sequence and stream the answer field to the user token-by-token. Because this buffering collapses that sequence down to one final chunk, valid final answers now appear in one shot at the end instead of streaming — a PR-introduced UX regression on the common, valid path. test_deepseek_stream_keeps_latest_accumulated_valid_tool_call itself confirms this by asserting only 1 chunk survives from 2 fed in.
Suggested fix (two coordinated changes):
- In
adapt_deepseek_stream, forward each tool-call chunk as it arrives instead of only buffering it (yield chunkin addition to trackingbuffered_tool_chunk), and stop re-emittingbuffered_tool_chunka second time at stream end (the downstream snapshot-merge in_merge_tool_call_chunks/merge_streamed_tool_call_argumentsalready dedupes by accumulated snapshot, so re-forwarding won't duplicate content). Keep the authoritative end-of-stream_response_violationcheck and still emitPROTOCOL_ERRORif a violation is found. - In
ToolCallStringFieldStreamer.handle_chunk(final_answer_stream.py), apply the same two-step guard already used for the TOKEN path to the decodedanswerfield value beforeemit_prefix: hold back a not-yet-safe trailing prefix (reusing_safe_streaming_text_length-style logic) and stop emitting entirely once_serialized_tool_call_start-style detection finds a complete leaked marker, deferring to the existingfinal_answer_errorretraction signal (runtime.fail_final_answer_stream, already wired at react.py:437) rather than a new mechanism.
This restores progressive streaming for the common/valid path while still catching a leak before it's irreversibly shown, at the cost of ~15-25 lines plus a small shared helper; the only user-visible cost is a brief hold on lines that legitimately start with <, matching existing TOKEN-path behavior.
M2 — Marker detection is anchored to line-start, defeating the leak it guards against — and this leak is permanent, not just a transient streaming artifact. src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:221-231 (_serialized_tool_call_start) and :234-240 (_safe_streaming_text_length). Both only detect a DSML/serialized-tool-call marker when it is the first non-whitespace content on its line (candidate.lstrip().startswith("<")); the startswith check short-circuits before the substring checks run. Critically, _response_violation — the authoritative end-of-stream/non-streaming fallback check — routes through the same line-anchored _serialized_tool_call_start, so there is no marker-anywhere fallback anywhere in the module. A marker preceded by same-line prose (e.g. Sure: <||DSML||tool_calls>) is never detected, in streaming or non-streaming, and the raw markup reaches the user permanently, not just transiently mid-stream. This was independently reproduced against the live code: feeding 'Sure: <||DSML||tool_calls>\n<||DSML||invoke name="x">' through get_tool_protocol_error returns None (no violation detected) both as a plain response and as a streamed one, and a marker split across two chunks ("Answer: <|" + "|DSML||tool_calls>") leaks its partial prefix <| before the second chunk even arrives. All 7 existing tests pass because none of them cover a same-line or split-prefix marker.
Suggested fix (concrete, both functions in deepseek_tool_protocol.py):
import re
# A complete serialized-tool-call opener anywhere in the text/line: "<", then
# delimiter/label chars with no ">" or newline in between, containing "dsml"
# followed by "tool_calls". Matches anywhere, not only at line-start.
_SERIALIZED_TOOL_CALL_RE = re.compile(
r"<[^>\n]*dsml[^>\n]*tool_calls",
re.IGNORECASE,
)
# A possibly-incomplete marker prefix anchored to end-of-string, used to withhold
# a trailing partial marker while streaming without holding back ordinary text
# like "< 6" or "<div>".
_PARTIAL_MARKER_RE = re.compile(
r"<[^>\w\s]*(?:d(?:s(?:m(?:l[^>\w\s]*"
r"(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l(?:s)?)?)?)?)?)?)?)?)?)?)?"
r")?)?)?)?\Z",
re.IGNORECASE,
)
def _serialized_tool_call_start(content: Any) -> int | None:
if not isinstance(content, str):
return None
match = _SERIALIZED_TOOL_CALL_RE.search(content)
return match.start() if match else None
def _safe_streaming_text_length(content: str) -> int:
start = _serialized_tool_call_start(content)
if start is not None:
return start
search_from = 0
while True:
idx = content.find("<", search_from)
if idx == -1:
return len(content)
if _PARTIAL_MARKER_RE.match(content, idx):
return idx
search_from = idx + 1This searches the full content/line for the marker instead of anchoring to line-start, while [^>\n]* keeps the original precision of requiring the marker to sit within one <...>-delimited, single-line span (so it isn't meaningfully more prone to false positives on ordinary prose than the current line-based check). Add regression tests for: a same-line marker preceded by prose (non-streaming and streaming), and a marker split across two chunks.
Minor
N1 — Discarded first response is traced as a successful call on protocol-retry (re-scoped from an earlier "double-counting" framing). src/xagent/core/agent/pattern/react/react.py:424-428 calls runtime.on_llm_end on the raw first response before the protocol-violation check at :431-445; on a retry, _retry_tool_protocol_response makes its own second on_llm_start/on_llm_end call (:671/:697). On independent re-verification, recording usage for both calls is correct, not a bug — both are real API round-trips that really consumed tokens, so context.record_llm_usage appending a record for each is honest cost accounting, not double-counting. The genuine (and narrower) issue is only that the discarded first response's trace event carries "success": True" with no marker that it was rejected, which is misleading for anyone reading traces. Suggested fix: compute normalized/_response_requires_tool_protocol_retry(...) before calling on_llm_end on the first response, then pass metadata={"success": False, "phase": "discarded_invalid_protocol"} when a retry is needed — on_llm_end's metadata is merged in after the hardcoded "success": True", so this cleanly overrides it with no signature change and no effect on usage accounting.
N2 — No captured production payload checked in as a DSML fixture. src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:221-231. DSML detection is a narrow literal-substring match; test fixtures use a hand-authored <||DSML||tool_calls> string rather than a real task-722/724 payload. The ||DSML|| markup shape has real provenance — a prior 10+-commit series iterated on this exact shape over ~2 weeks of fix/revert cycles, so it is not an invented guess. Still, checking in a raw captured payload as a fixture would future-proof against format drift.
N3 — 4 of 6 violation codes have no dedicated test coverage. src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:145-208. Only serialized_tool_call_content and nested_serialized_tool_call are exercised; malformed_tool_call, malformed_tool_arguments, unavailable_tool_call, and unexpected_tool_arguments have none. In particular the task-724 "unexpected content argument" scenario is masked in both tests modeling it: the nested-DSML check runs before the schema/additionalProperties check, so the unexpected_tool_arguments branch never fires even though the test payload carries an extra content key. Suggestion: add a test per untested code, including an unexpected_tool_arguments case that omits the nested-DSML trigger so that branch actually runs.
N4 — No handling for a provider that hard-rejects tool_choice="required". src/xagent/core/agent/pattern/react/react.py:354-358,388-398. Forced-final turns require a final_answer tool call (tool_choice="required") for every provider (note: self.tool_choice already defaulted to "required" for ordinary ReAct tool-calling turns across all providers before this PR — this is not new). The graceful fallback for a provider that ignores the constraint and returns plain text (react.py:471) is real and tested with a non-DeepSeek mock. The residual gap: a provider that hard-rejects the request (e.g. a 400 because it cannot honor tool_choice="required") has no test or handling — the exception just propagates uncaught. Low-priority follow-up, not blocking.
N5 — Typed protocol error isn't propagated to DAG/Auto's existing retry paths. src/xagent/core/agent/runtime.py:190-199. The new typed signal (get_tool_protocol_error) is consumed only by ReAct. This is not a silent-failure regression: DAG's per-step execution reuses ReActPattern internally (so it already gets the new retry); DAG's completion-assessment call fails loudly via _fail(...) with a surfaced failure_reason; and Auto's decision-routing call has its own generic empty-tool-call retry loop (RequiredToolCallError → corrective feedback). This PR changes nothing about DAG/Auto behavior. Pre-existing gap, worth a follow-up, not a blocker.
Nit
T1 — Return bool instead of an offset. src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:221-231. _serialized_tool_call_start returns a precise int byte offset, but both call sites only test is None/is not None; no caller uses the numeric value. (Note: the M2 fix above repurposes this offset as the marker start position for _safe_streaming_text_length, so this nit is naturally resolved if M2's fix is applied.)
Simplification opportunities
src/xagent/core/model/chat/tool_protocol.py:14: yagnirecoverable: bool = TrueonToolProtocolViolationis never set toFalseand never read anywhere in production, tests, or serialization consumers (full-repo grep). Remove it.src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:135-208: yagniToolProtocolViolation.codecarries 6 distinct string values, but no production code (react.py:710, runtime.py:190) branches on the value — both only check truthiness/presence. Several tests do assert specific code strings, so collapsing to a bare bool/None would require updating those assertions — not zero-risk, but the production case for keeping the taxonomy is weak.src/xagent/core/model/chat/basic/openrouter.py:63-118: dedupOpenRouterLLM.chat/stream_chat's ~25-30 lines of parameter-forwarding and codec-wrap boilerplate againstDeepSeekLLM.chat/stream_chat(both ultimately call the sameOpenAICompatibleLLMsignature). A shared mixin could extract the common forwarding, but DeepSeek's extra pre-processing would need template-method hooks, so the net reduction is modest and adds indirection — worth doing only if it reads cleaner.
net: ~30-40 lines possible.
Testing
270 tests passed (pytest -q over test_react.py, test_dag.py, test_execution_adapter.py, test_auto.py, test_runtime.py, test_react_segment_loop.py, test_deepseek.py, test_openrouter.py, test_deepseek_tool_protocol.py), independently re-run in a fresh worktree — matches the PR's own stated validation. The 3 previously reported findings were additionally confirmed fixed by running their specific regression tests directly. Both Major findings above (M1, M2) were independently re-verified a second time by a separate reviewer pass, including a live reproduction of the M2 leak against the current code — none of the 270 passing tests cover the scenarios M1/M2 describe.
8467be7 to
bf0edf2
Compare
|
Addressed the new review in bf0edf2. M1/M2 are fixed entirely in the DeepSeek provider codec: valid accumulated tool-call snapshots stream progressively again, while same-line and split-chunk DSML markers are withheld and rejected before reaching the user. Also fixed discarded-response trace status, covered all six violation codes, and removed the unused recoverable field. N2 was not added because we should not check a raw production trace into the repo; N4/N5 are pre-existing non-blocking follow-ups. Local related suite: 281 passed; pre-commit: passed. |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a robust tool protocol validation and retry mechanism specifically designed for DeepSeek models (including those routed via OpenRouter). It intercepts invalid tool protocol responses—such as serialized tool-call markup in plain text or nested serialized tool calls—and attempts a repair turn before failing. The feedback highlights two key areas for improvement: first, wrapping the retry call in a try...except LLMCallInterrupted block to ensure correct interrupt handling and metadata labeling; second, capping the string slice length in _safe_streaming_text_length to prevent potential
bf0edf2 to
5a90f39
Compare
rogercloud
left a comment
There was a problem hiding this comment.
Re-review: commit 5a90f399 (follow-up to review at 8467be77)
Update summary
Since the last review the author pushed a single commit ("keep ReAct tool calls structured") touching deepseek_tool_protocol.py, react.py, runtime.py, and the two test files. It reworks DSML tool-call detection from line-anchored (lstrip().startswith('<')) to a regex .search() that matches the marker anywhere in the text, and restores progressive per-chunk forwarding of valid tool-call snapshots in adapt_deepseek_stream (no longer buffered until stream end). It also bounds the partial-marker tail scan to a 64-char window, wraps the protocol-retry LLM call in try/except LLMCallInterrupted so interrupts get the during_llm checkpoint label, reorders the first response's on_llm_end so a discarded/retried response is traced success=False, phase="discarded_invalid_tool_protocol" (usage/cost still recorded), adds test coverage for all six violation codes, and drops the unused recoverable field. Net effect: both prior Majors and both Gemini findings are addressed.
Prior findings status
All statuses below were independently re-verified against the current code (not taken from the author's self-report).
FIXED
| Item | Origin | Evidence |
|---|---|---|
| M1 streaming buffering regression (Major) | prior review | adapt_deepseek_stream yields each chunk via _safe_streaming_tool_chunk; new test_deepseek_stream_forwards_accumulated_valid_tool_calls (2 chunks survive) |
| M2 line-anchored detection gap (Major) | prior review | _SERIALIZED_TOOL_CALL_RE.search(content); new same-line + split-marker tests, deepseek_tool_protocol.py:57-68/:286-299 |
| N1 discarded response traced as success | prior review | retry check runs before on_llm_end; metadata success=False, phase="discarded_invalid_tool_protocol" |
| N3 4/6 violation codes untested | prior review | all 6 codes now tested, incl. non-masked unexpected_tool_arguments |
| T1 unused byte offset (Nit) | prior review | _safe_streaming_text_length now consumes the offset as a real value |
G4 uncaught LLMCallInterrupted on retry (High) |
gemini | retry wrapped in try/except, routes to _interrupt_if_requested(label="during_llm"); new test_react_pattern_pause_during_tool_protocol_retry_is_during_llm |
| G5 O(N²) tail slicing (Medium) | gemini | slice bounded to _PARTIAL_MARKER_SCAN_LIMIT = 64 at the slice point |
| "recoverable" field simplification | prior review | unused recoverable: bool = True removed from ToolProtocolViolation |
| A/B/C (dangling stream ×2, None tool_calls TypeError) | earliest round | unchanged, still fixed (carried forward for completeness) |
NOT FIXED (still open, low-priority / non-blocking — as originally assessed)
- N2 — no captured production DSML fixture; fixtures remain hand-authored literals. Nice-to-have.
- N4 — no handling/test for a provider hard-rejecting
tool_choice="required". Untouched.
WAIVED (re-confirmed non-issue)
- N5 — typed protocol error not propagated to DAG/Auto: DAG reuses
ReActPatterninternally; DAG completion-assessment and Auto decision-routing both already fail/retry generically against the synthetic protocol-error response. Nothing regressed.
Verdict
The two prior Major findings (M1, M2) and both Gemini findings (G4, G5) are fixed, and this round's fresh independent Round 0/1 pass surfaced no new Major or Critical issues. Design is sound: provider isolation is clean (no DeepSeek/DSML knowledge leaked into react.py/runtime.py), the ToolProtocolViolation / get_tool_protocol_error() seam is provider-agnostic, and ChunkType.PROTOCOL_ERROR threads uniformly through streaming and non-streaming paths. This PR no longer has any blocking Major findings and is acceptable to merge, modulo the two still-open low-priority Minors above and the two new Minors below.
New findings
New-Minor-1 — protocol-error response drops usage on the non-streaming path
src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:35-36, src/xagent/core/model/chat/tool_protocol.py:19-32 — Severity: Minor.
normalize_deepseek_response builds its error response via tool_protocol_error_response(violation, raw=raw), which does not carry usage forward. When hit, runtime.on_llm_end's _extract_token_usage finds no usage key and context.record_llm_usage is skipped for that turn — a narrow loss of context-level cost/usage accounting, contradicting the "retain real usage accounting" intent. Not reachable through ReAct's normal loop today (ReAct always uses run_streaming_llm_call, whose consume_stream() builds its own error dict that merges usage); it only matters for the streaming-unavailable fallback or a future non-streaming .chat() caller with tools. Suggested fix: have tool_protocol_error_response copy usage from the raw response when present.
New-Minor-2 (perf) — full-text rescan on every TOKEN chunk
src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:57-68, :286-299 — Severity: Minor.
_serialized_content_violation / _safe_streaming_text_length run on the FULL accumulated text on every TOKEN chunk, each doing a regex .search() / re.finditer("<", ...) over the whole string so far — O(N²) over the length of a long streamed answer. G5's O(N²) slicing is fixed, but this enclosing full-text rescan remains. Negligible for typical few-KB responses (C-level regex) but grows for very long completions. Follow-up: scan only from the previously-scanned position forward, or track a rolling window.
Design-level notes (informational, not blocking): the DSML marker grammar (||DSML||tool_calls) is hard-coded to the one observed variant — worth a one-line comment noting it tracks an observed provider quirk. And adapt_deepseek_stream implicitly assumes each tool-call chunk is a cumulative snapshot (verified correct against openai.py's _process_stream_chunk, but an unguarded cross-file invariant).
Simplification opportunities
src/xagent/core/agent/pattern/react/react.py:662: yagni:_retry_tool_protocol_responserecomputestools = [self._final_answer_tool_schema()] if force_final_answer else tool_schemas, but its sole caller already setstool_schemasto exactly that whenforce_final_answeris true. Replace withtools = tool_schemas.src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:325: yagni:_function_payload'sgetattr(tool_call, "function", None)and flat-dict fallbacks handle shapes that never occur — every caller produces a dict with a nestedfunction. Simplify totool_call.get("function", {}) if isinstance(tool_call, dict) else {}.src/xagent/core/agent/runtime.py:154-160: shrink: the protocol-error chunk check is inlined, duplicating the pattern already factored into_chunk_text_delta/_chunk_tool_calls/_chunk_usage. Extract a matching_chunk_protocol_error(chunk)helper.- (carry-forward, unchanged)
src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:135-208: yagni:ToolProtocolViolation.code's 6-value taxonomy is still only checked for truthiness in production; collapsing to a bool/None is possible but modest given test assertions on code strings. - (carry-forward, unchanged)
src/xagent/core/model/chat/basic/openrouter.py:63-118vsdeepseek.py: ~25-30 lines of duplicated parameter-forwarding/codec-wrap boilerplate; a shared mixin needs template-method hooks for DeepSeek's pre-processing, so net reduction stays modest.
net: -20 to -30 lines possible (3 new confirmed this round, plus 2 open carry-forward items).
Testing
pytest -q passes on test_react.py (66 passed) and test_deepseek_tool_protocol.py (14 passed). The new regression tests for each fix were run directly and pass: progressive streaming, same-line and split-marker detection, interrupt-during-retry checkpoint label, discarded-response trace metadata, and all six violation codes. No regressions found in this update.
Summary
Root causes
Task 722 reached a repeated-tool forced-final turn. DeepSeek V4 Flash emitted a
fetch_web_contentcall using its internal DSML grammar as ordinary assistant text. Because it was text rather than a native tool call, the response reached the final-answer stream.Task 724 came through Auto Router -> OpenRouter -> DeepSeek and exposed a second shape. DeepSeek returned a native
final_answercall, but embedded a serializedwrite_filecall inanswerand put the script in an unexpectedcontentargument. The outer tool call looked valid enough for ReAct to finish, so neitherwrite_filenor TTS ran.These are provider response-normalization failures, not ReAct syntax. The provider codec validates non-streaming responses and progressively forwards safe accumulated tool-call snapshots. It withholds a possible split marker tail until it can be classified, performs authoritative validation on the complete response, and emits a generic protocol error without exposing DSML to the agent pattern or user stream.
Difference from #790
#790 parses leaked DSML inside the local forced-final path, then gives that parser every
base_tool_schemaeven though the actual provider request intentionally exposes no work tools. A recoveredfetch_web_contentcall can therefore be executed, clearingforce_final_answer_nextand bypassing the repeated-tool finalization decision. The visible leak becomes another real work-tool call and can restart the loop.Its parser also has an unmatched-marker case where an incomplete DSML prefix before a later complete tool-call block causes the complete block to be discarded.
This PR does not reconstruct and execute unauthorized calls. The provider reports an invalid response; ReAct retries with exactly the tools allowed for that turn. Forced-final retries expose only
final_answer, while normal turns such as task 724 retain their existing work tools.Review follow-up
recoverablefieldValidation
pytest -q tests/core/agent/test_react.py tests/core/agent/test_dag.py tests/core/agent/test_execution_adapter.py tests/core/agent/test_auto.py tests/core/agent/test_runtime.py tests/core/agent/test_react_segment_loop.py tests/core/model/chat/basic/test_deepseek.py tests/core/model/chat/basic/test_openrouter.py tests/core/model/chat/basic/test_deepseek_tool_protocol.py(282 passed)write_fileplus unexpectedcontentregression