Skip to content

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 str content ("Here is the summary...")
  • A list of content blocks ending with a text block, with no tool_use blocks (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_calls AND a text block in the same AIMessage, expecting the framework to surface the text as the final response rather than execute another tool round-trip.
  • Loop-detection cut. tools_condition forces "finish" when it sees the same name:args tool-call signature repeat ≥3 times in the last 10 messages (see core/edges/tool_condition.py:38-46). The terminal AIMessage has tool_calls by definition (that's what triggered the cut).
  • LangGraph recursion_limit cap. The graph hits its iteration ceiling (default 25, see runtime.py:228). The terminal AIMessage almost always has tool_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_conditionnot 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 AIMessageno_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:

  1. 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.

  1. ReactLoopEndEvent callback (src/symfonic/core/contracts/callbacks.py:393-450) fires via CallbackManager.on_react_loop_end for 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 implement on_react_loop_end are skipped via CallbackManager._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

FrameworkConfig(synthesize_on_empty_final=True)

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 None write with a text salvage that pre-v7.17 was discarding model-authored content. Existing adopters reading final_response get more text in cases where they previously got "". No adopter who relied on final_response == "" as a signal would have been getting a meaningful signal anyway — the v7.16 behavior was a bug.
  • synthesize_on_empty_final defaults to False. Adopters who want the recovery completion opt in.
  • ReactLoopEndEvent is 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