React loop termination contract¶
Since: v7.17.0 (a qwen3-max followup, 2026-06-03)
Owner files: src/symfonic/core/nodes/react.py, src/symfonic/core/edges/tool_condition.py, src/symfonic/agent/config.py
Sibling contracts: agent.md (the engine entry points that read final_response), capabilities.md (the tool-routing layer that drives tool_calls).
What this doc pins¶
The framework's React loop terminates on one of three signals. In every case the loop produces a single string at state["final_response"] that downstream code (SymfonicAgent.run, SymfonicAgent.stream_typed, the CLI, all telemetry callbacks) reads as the user-facing response.
This page is the public contract for that string: what shape the terminal AIMessage takes on each path, what final_response contains, what diagnostic surface is emitted, and what knob exists to recover when the model genuinely emitted no text.
The three termination paths¶
1. Canonical Anthropic / OpenAI shape — no_tool_calls¶
The model emits a terminal AIMessage with no tool_calls and one of:
- A plain
strcontent ("Here is the summary...") - A list of content blocks ending with a
textblock, with notool_useblocks (Anthropic extended-thinking trace where the model finished reasoning and produced final text)
tools_condition (src/symfonic/core/edges/tool_condition.py) returns "finish" because no tool was called.
final_response = the model's textual content, list-flattened via _flatten_content_blocks so Anthropic content blocks are concatenated by type=="text" membership.
This is the path Anthropic and OpenAI primary models hit on every well-formed turn. Pre-v7.17 framework code was implicitly tuned to this shape.
2. Forced termination on a tool-call iteration — tool_calls_with_no_text¶
The model emits a terminal AIMessage with tool_calls present. Three sub-cases all land on this path:
- OpenAI-compatible providers emitting both content and tool_calls in a single message. Some providers (qwen3-max via DashScope is the documented case) end a turn by emitting
tool_callsAND atextblock in the sameAIMessage, expecting the framework to surface the text as the final response rather than execute another tool round-trip. - Loop-detection cut.
tools_conditionforces"finish"when it sees the samename:argstool-call signature repeat ≥3 times in the last 10 messages (seecore/edges/tool_condition.py:38-46). The terminalAIMessagehastool_callsby definition (that's what triggered the cut). - LangGraph
recursion_limitcap. The graph hits its iteration ceiling (default 25, seeruntime.py:228). The terminalAIMessagealmost always hastool_calls(the loop kept tool-calling, which is why the cap fired).
final_response (v7.17.0):
- If the terminal AIMessage carries text content alongside the tool_calls, _flatten_content_blocks salvages it and writes that text to final_response. This recovers the silent-bail data-loss bug that bled value at a production adopter pre-v7.17.
- If the terminal AIMessage carries only tool_use blocks (no text), final_response is None. Diagnostics fire (see below). If FrameworkConfig.synthesize_on_empty_final=True, the engine issues a recovery completion (see the synthesize fallback knob).
Architectural principle (pinned for future authors): tools_condition — not the React node — owns the "should the loop continue?" decision. If the routing condition decides to terminate while tool_calls are present, that's a forced-termination signal. The React node MUST surface whatever text the model authored; throwing it away because tool_calls are present is the bug v7.17 closes.
3. Empty terminal AIMessage — no_content_no_tool_calls¶
The model returns an AIMessage with no text, no list blocks, and no tool_calls — an empty completion. Reasons range from upstream provider hiccups (token budget exhausted mid-stream) to a misconfigured max_tokens to the model genuinely deciding it had nothing to say.
tools_condition returns "finish" (no tool was called).
final_response = None. The downstream engine path coerces None to "" at engine.py:3161 (result.get("final_response") or ""). Diagnostics fire. If synthesize_on_empty_final=True, the engine issues a recovery completion.
Diagnostic surface¶
When the React loop terminates with an empty final_response (paths 2 with no salvageable text, or 3), two things happen:
- INFO log at
src/symfonic/core/nodes/react.py:
react: empty final_response on terminator
(iteration_index=<int>,
content_type=<"str" | "list" | "NoneType">,
has_tool_calls=<bool>,
tool_call_count=<int>,
termination_reason=<"tool_calls_with_no_text" | "no_content_no_tool_calls">)
This is the minimum-viable diagnostic surface — visible by default with the standard logging.INFO level. Pre-v7.17 the empty-final-response exit was silent and adopters had to infer it from downstream fallback strings.
ReactLoopEndEventcallback (src/symfonic/core/contracts/callbacks.py:393-450) fires viaCallbackManager.on_react_loop_endfor any registered handler. The event carries the same structured payload as the INFO log so adopters can wire to OpenTelemetry, custom analytics, or per-tenant alerting. The hook is optional — handlers that don't implementon_react_loop_endare skipped viaCallbackManager._dispatch_optional(zero cost when no handler registers).
Both terminate paths emit the event:
- termination_reason="tool_calls_with_no_text" — path 2 with no salvageable text
- termination_reason="no_content_no_tool_calls" — path 3
The synthesize_on_empty_final knob¶
Default: False (v7.16.x behavior preserved byte-for-byte).
When it fires: the React loop terminated with an empty final_response after the v7.17 content-block salvage attempt. If salvage produced text (path 2 with a recoverable text block), the knob does NOT fire — salvage is sufficient.
What it does: issues a single follow-up LLM call asking the model to summarize the work it just did. The call is bound to the chat model without tools so the model cannot start a fresh tool-call loop. Bound cost is exactly one extra completion per silent-bail turn.
Telemetry: the synthesis call emits LLMEndEvent with node_name="react_synthesize_fallback" so adopters can attribute the cost and latency separately from the main React loop's emissions.
Scope: applies to the SymfonicAgent.run() sync path only. The streaming paths (stream, stream_typed) rely on token accumulation from the LangGraph astream_events callback hierarchy; injecting an extra synchronous LLM call mid-stream is incompatible with the streaming contract. Streaming adopters get the salvage fix from path 2 only — silent-bail turns that survive salvage in a streaming context surface as empty final_response exactly as in v7.16.x.
Architectural separation: salvage covers the data-loss case (the model emitted text we threw away — always on, no knob, since it closes a bug). This knob covers the model-never-emitted-text case (genuine empty completion or single-turn shape with no text block) — opt-in, since the right answer is "let the model not respond" for some adopters.
Migration¶
Pure-additive. All v7.16.x behavior is preserved:
- The salvage path replaces a silent
Nonewrite with a text salvage that pre-v7.17 was discarding model-authored content. Existing adopters readingfinal_responseget more text in cases where they previously got"". No adopter who relied onfinal_response == ""as a signal would have been getting a meaningful signal anyway — the v7.16 behavior was a bug. synthesize_on_empty_finaldefaults toFalse. Adopters who want the recovery completion opt in.ReactLoopEndEventis an optional callback. Adopters with no handler registered see no behavior change.
Adopter wiring example¶
Register a callback to capture ReactLoopEndEvent for analytics:
from symfonic.agent import SymfonicAgent, FrameworkConfig
from symfonic.core.contracts.callbacks import ReactLoopEndEvent
from symfonic.core.callbacks.protocol import CallbackHandler
class SilentBailMetrics(CallbackHandler):
async def on_react_loop_end(self, event: ReactLoopEndEvent) -> None:
# Forward to your metrics sink, structured log pipeline,
# or alerting system.
await my_metrics.increment(
"react.silent_bail",
tags={
"termination_reason": event.termination_reason,
"has_tool_calls": event.has_tool_calls,
"iteration_index": event.iteration_index,
},
)
agent = SymfonicAgent(
config=FrameworkConfig(
synthesize_on_empty_final=True, # opt in to recovery completion
),
callbacks=[SilentBailMetrics()],
)
Source map¶
For maintainers / future contributors:
| Concern | File:line |
|---|---|
| Salvage rule + INFO log + event emission | src/symfonic/core/nodes/react.py:963-1043 |
_flatten_content_blocks helper |
src/symfonic/agent/engine.py:838-862 |
| Loop-detection cut | src/symfonic/core/edges/tool_condition.py:21-48 |
ReactLoopEndEvent dataclass |
src/symfonic/core/contracts/callbacks.py:393-450 |
synthesize_on_empty_final config field |
src/symfonic/agent/config.py:1299-1336 |
| Synthesis-pass implementation | src/symfonic/agent/engine.py (post-graph branch) |
| Engine read site (sync path) | src/symfonic/agent/engine.py:3161 |
| Engine read sites (streaming) | src/symfonic/agent/engine.py:3649, 3665, 4038, 4078 |
| Release-gate field allowlist | tests/release/test_v6_1_0_release_gate.py:308-318 (new_in_v7_17_0) |
| Regression tests | tests/core/nodes/test_react_terminal_extraction.py, tests/core/nodes/test_react_synthesize_fallback.py, tests/agent/callbacks/test_react_loop_end_event.py |