Description
1. Summary
In agent_framework/_tools.py, the streaming invocation loop (_stream) streams the model's response to the caller (yield update) before it inspects the finalized response and decides whether to execute. The two are separate objects. When the function-invocation limit has landed, the loop:
- sets
tool_choice = "none" for the next model call,
- streams whatever the model emits anyway (Ollama/GLM does not fully honor
tool_choice="none"), emitting TOOL_CALL_START/ARGS/END for any function_call content,
- finalizes the response,
- overwrites the finalized response with the limit-fallback text (
_ensure_function_invocation_limit_fallback_response) — which strips the function_call — only because a pure tool-call response has no "visible content",
- sees no actionable function call in the (now overwritten) response and
returns, never executing.
Step 2 already delivered the call to the AG-UI client; step 5 ensures no TOOL_CALL_RESULT is ever produced. The client holds a TOOL_CALL_START with no matching result — an orphan.
2. Symptoms
- A tool call appears in the AG-UI client (chip/card renders) and stays "Running" until
RUN_FINISHED, then flips to Done with no output (display-only lag — see §8 web-tool-status-display-lag for the other cause of the same UI effect).
- The client-side thread contains an assistant
function_call with no matching role:tool result.
- The model, on the next turn, re-issues the same call (it never saw a result telling it the call did not run) — a "stuck in tool calls" loop. Observed live:
todos_remove was STARTed 4× with 0 RESULTs.
- Provider-conditional: only manifests when the provider does not fully honor
tool_choice="none" and still streams a tool call. Ollama (glm-5.2:cloud) reproduces; a provider that hard-stops tool calls on tool_choice="none" would not.
3. Evidence — live capture (2026-07-10)
Harness advisor agent hosted via the AG-UI endpoint, CopilotKit frontend, Ollama glm-5.2:cloud. From the Aspire dcp console-err log (full, untruncated payloads):
| Signal |
Count |
Meaning |
TOOL_CALL_START |
319 |
tool calls streamed to the client |
TOOL_CALL_END |
255 |
calls that finished streaming |
TOOL_CALL_RESULT |
169 |
calls that actually got a result |
"Function invocation limit reached before a final answer could be produced." (in model payloads) |
7135 |
the limit-fallback text — direct proof _ensure_function_invocation_limit_fallback_response / _function_invocation_limit_fallback_update fires on essentially every run |
START (319) − RESULT (169) = 150 orphaned calls across the session. todos_remove specifically: STARTed 4×, RESULT 0×. The 7135 fallback-text occurrences in the payloads are the smoking gun that the limit path is the active terminal branch run after run; the model never converges and keeps re-emitting the same call past the limit.
The logger.info("Maximum function calls reached …") line is not captured at the err log's level, but the fallback text appearing in the payloads is direct evidence the overwrite path executed.
4. Reproduction (minimal)
- Any agent with
function_invocation_configuration["max_function_calls"] set to a small N (e.g. the harness default is low enough that a tool-heavy prompt overshoots it).
- Use a provider that ignores
tool_choice="none" for tool calls (Ollama glm-5.2:cloud).
- Prompt the agent to call a tool past the limit, with no narration text accompanying the call (see §5 — the overwrite only fires when the response has no "visible content").
- Observe: the tool card renders and never receives a result; the client thread holds the orphaned
function_call; the next turn re-issues the call.
No approval, no provider-injected tool, no custom middleware required — stock loop, stock endpoint.
Code Sample
Error Messages / Stack Traces
Package Versions
agent-framework-core: 1.10.0, agent-framework-ag-ui: 1.0.0rc7
Python Version
Python 3.12
Additional Context
5. Root cause
The streaming loop diverges from the finalized response. _tools.py (_stream, lines 2739–2918):
- 2763–2769 — limit detected from the prior batch's overshoot:
tool_choice = "none".
- 2790–2791 —
async for update in inner_stream: yield update — the model's response (including any function_call) is streamed to the transport, which emits TOOL_CALL_START/ARGS/END. This happens before any limit/execution decision.
- 2795 —
response = await inner_stream.get_final_response() — the finalized response still has the function_call.
- 2797–2803 —
function_call_limit_reached = (tool_choice == "none" and max_function_calls is not None and total >= max); if true, response = _ensure_function_invocation_limit_fallback_response(response).
- 2811–2818 —
if not any(_is_actionable_function_call(item) for msg in response.messages …): … return — no execution.
_ensure_function_invocation_limit_fallback_response (1909–1921) only replaces the response when _response_has_visible_content(response) is False:
def _ensure_function_invocation_limit_fallback_response(response):
if _response_has_visible_content(response):
return response # narration present → kept → executed (no orphan)
fallback_content = Content.from_text(_FUNCTION_INVOCATION_LIMIT_FALLBACK_TEXT)
if response.messages:
response.messages[-1].role = "assistant"
response.messages[-1].contents = [fallback_content] # function_call STRIPPED
...
_response_has_visible_content (1898–1906) returns True only for non-empty text or a type in _USER_VISIBLE_CONTENT_TYPES = {"data", "uri", "error", "hosted_file", "hosted_vector_store"} (line 99). function_call is not in that set. So:
- Model emits a tool call with narration text → visible content True → response kept → §2811 sees an actionable call → executes →
RESULT emitted. ✅ no orphan.
- Model emits a tool call with no narration → visible content False → response overwritten (call stripped) → §2811 sees no actionable call →
return, no execution → no RESULT. ❌ orphan.
The streamed updates (step 2790) and the post-overwrite finalized response (step 2803) are different objects. The client received the call from the former; the executor's decision is made from the latter. That is the orphan.
Asymmetry that confirms the seam
A tool call emitted before the limit lands is executed normally — the loop's _process_function_requests (2823) runs with the full tool set and produces a RESULT. Only the post-limit call is orphaned, because the limit branch replaces the response and returns before execution. The execution path itself is fine; the bug is that the loop streams a call it has already decided not to keep.
6. Application-level workaround (what we do today)
We reconcile orphans in the AG-UI continuity shim (ats.web_approvals_continuity.AGUIContinuityAgentFrameworkAgent). The wrapper already tracks, per run, the set of call ids that received a TOOL_CALL_START (started_tool_calls) and a TOOL_CALL_RESULT (resulted_tool_calls). At RUN_FINISHED, after it drains recovered/harvested results, it now computes:
orphans = started_tool_calls - resulted_tool_calls - suppressed_duplicate_calls
orphans -= protected_call_ids(thread) # approval-pending — must stay open across runs
orphans -= known_results(thread).keys() # a real captured result exists — leave to harvest/backfill
and emits a synthetic terminal TOOL_CALL_RESULT for each orphan with an honest note:
Tool call not executed: the run reached the function-invocation limit (maximum_auto_invocations) before this call could run, so no side effect occurred. Produce a final answer rather than re-issuing this call.
This (a) closes the stuck card, (b) is truthful — no fake success — and (c) on the next round-trip tells the model why the call did not run, so it stops re-issuing (breaking the "stuck in tool calls" loop). The note is registered as a placeholder prefix so any later real result beats it.
Guarded to avoid false positives:
- Approval-pending calls (parked in session state —
queued_approval_requests / collected_approval_responses / approval groups) are excluded via protected_call_ids; they legitimately await the user across runs and must stay open.
- Calls with a captured real result (
known_results) are excluded; the existing harvest/backfill path re-attaches the real outcome, and the placeholder must never shadow it.
Verified by unit test test_continuity_agent_reconciles_orphaned_tool_calls_at_run_finished: an orphan (START/END, no RESULT) gets the synthetic note; an approval-pending call (parked in state) is left open; an executed call keeps its real result; the synthetic result is emitted immediately before RUN_FINISHED.
Limitations of the workaround
- Within-run only.
started_tool_calls is reset each run(), so orphans from a run that died before RUN_FINISHED (RUN_ERROR mid-stream) are not visible to the next run's reconciliation. Closing those would require persisting started ids across runs (future work).
- Display + next-turn healing, not a fix for the divergence. The server-side MAF history still holds the fallback text (the
function_call was stripped server-side at overwrite time); the client thread holds the function_call + our synthetic result. The two diverge, though the client's view is the cleaner one (a complete call/result pair the provider accepts). The underlying stream-vs-finalized-response divergence in _tools.py remains.
7. Proposed upstream fix
Any of the following, in our order of preference:
- Do not stream a call you will not keep. Move the limit check ahead of the stream-yield, or buffer the response before yielding, so a post-limit
function_call is never delivered to the client. The cleanest fix: when function_call_limit_reached, drop function_call contents before they are yielded (or skip yielding that update) rather than streaming them and then overwriting the finalized response.
- Execute what you streamed. If the call was already streamed, execute it (the overshoot already runs to completion by design — §2848 comment "the current batch always runs to completion even if it overshoots"; extend that same grace to the post-limit disobedient call) and emit a real
TOOL_CALL_RESULT, instead of discarding it.
- At minimum, emit a terminal
TOOL_CALL_RESULT for any TOOL_CALL_START that will not get one. If the framework decides not to execute a streamed call, it owns closing it in the AG-UI transport — a synthetic result (or an explicit error/tool_call_end with a terminal status) so the client never dangles. Today the framework silently drops the call after streaming it.
Option 1 or 2 removes the divergence at the source; option 3 is the minimal "don't leave the client hanging" fix and is exactly what our shim does app-side today.
8. Related issues
| Issue / PR |
State |
Relationship |
| #7043 |
opened |
approved call to a provider-injected tool fails because the tool is missing from the transport's pre-run tool map. This report needs no approval and the tool is registered — the failure is the streaming loop discarding a streamed call at the limit. Independent. |
9. Environment
agent-framework-core 1.10.0, agent-framework-ag-ui 1.0.0rc7, Python 3.12, Windows 11
- Frontend: CopilotKit over the FastAPI AG-UI endpoint; evidence captured via .NET Aspire telemetry (dcp console-err logs, full payloads)
- Provider: Ollama (
glm-5.2:cloud); defect is provider-conditional (requires the provider to ignore tool_choice="none")
Description
1. Summary
In
agent_framework/_tools.py, the streaming invocation loop (_stream) streams the model's response to the caller (yield update) before it inspects the finalized response and decides whether to execute. The two are separate objects. When the function-invocation limit has landed, the loop:tool_choice = "none"for the next model call,tool_choice="none"), emittingTOOL_CALL_START/ARGS/ENDfor anyfunction_callcontent,_ensure_function_invocation_limit_fallback_response) — which strips thefunction_call— only because a pure tool-call response has no "visible content",returns, never executing.Step 2 already delivered the call to the AG-UI client; step 5 ensures no
TOOL_CALL_RESULTis ever produced. The client holds aTOOL_CALL_STARTwith no matching result — an orphan.2. Symptoms
RUN_FINISHED, then flips to Done with no output (display-only lag — see §8web-tool-status-display-lagfor the other cause of the same UI effect).function_callwith no matchingrole:toolresult.todos_removewas STARTed 4× with 0 RESULTs.tool_choice="none"and still streams a tool call. Ollama (glm-5.2:cloud) reproduces; a provider that hard-stops tool calls ontool_choice="none"would not.3. Evidence — live capture (2026-07-10)
Harness advisor agent hosted via the AG-UI endpoint, CopilotKit frontend, Ollama
glm-5.2:cloud. From the Aspire dcp console-err log (full, untruncated payloads):TOOL_CALL_STARTTOOL_CALL_ENDTOOL_CALL_RESULT"Function invocation limit reached before a final answer could be produced."(in model payloads)_ensure_function_invocation_limit_fallback_response/_function_invocation_limit_fallback_updatefires on essentially every runSTART (319) − RESULT (169) = 150orphaned calls across the session.todos_removespecifically: STARTed 4×, RESULT 0×. The 7135 fallback-text occurrences in the payloads are the smoking gun that the limit path is the active terminal branch run after run; the model never converges and keeps re-emitting the same call past the limit.The
logger.info("Maximum function calls reached …")line is not captured at the err log's level, but the fallback text appearing in the payloads is direct evidence the overwrite path executed.4. Reproduction (minimal)
function_invocation_configuration["max_function_calls"]set to a small N (e.g. the harness default is low enough that a tool-heavy prompt overshoots it).tool_choice="none"for tool calls (Ollamaglm-5.2:cloud).function_call; the next turn re-issues the call.No approval, no provider-injected tool, no custom middleware required — stock loop, stock endpoint.
Code Sample
Error Messages / Stack Traces
Package Versions
agent-framework-core: 1.10.0, agent-framework-ag-ui: 1.0.0rc7
Python Version
Python 3.12
Additional Context
5. Root cause
The streaming loop diverges from the finalized response.
_tools.py(_stream, lines 2739–2918):tool_choice = "none".async for update in inner_stream: yield update— the model's response (including anyfunction_call) is streamed to the transport, which emitsTOOL_CALL_START/ARGS/END. This happens before any limit/execution decision.response = await inner_stream.get_final_response()— the finalized response still has thefunction_call.function_call_limit_reached = (tool_choice == "none" and max_function_calls is not None and total >= max); if true,response = _ensure_function_invocation_limit_fallback_response(response).if not any(_is_actionable_function_call(item) for msg in response.messages …): … return— no execution._ensure_function_invocation_limit_fallback_response(1909–1921) only replaces the response when_response_has_visible_content(response)is False:_response_has_visible_content(1898–1906) returns True only for non-emptytextor a type in_USER_VISIBLE_CONTENT_TYPES = {"data", "uri", "error", "hosted_file", "hosted_vector_store"}(line 99).function_callis not in that set. So:RESULTemitted. ✅ no orphan.return, no execution → noRESULT. ❌ orphan.The streamed updates (step 2790) and the post-overwrite finalized
response(step 2803) are different objects. The client received the call from the former; the executor's decision is made from the latter. That is the orphan.Asymmetry that confirms the seam
A tool call emitted before the limit lands is executed normally — the loop's
_process_function_requests(2823) runs with the full tool set and produces aRESULT. Only the post-limit call is orphaned, because the limit branch replaces the response and returns before execution. The execution path itself is fine; the bug is that the loop streams a call it has already decided not to keep.6. Application-level workaround (what we do today)
We reconcile orphans in the AG-UI continuity shim (
ats.web_approvals_continuity.AGUIContinuityAgentFrameworkAgent). The wrapper already tracks, per run, the set of call ids that received aTOOL_CALL_START(started_tool_calls) and aTOOL_CALL_RESULT(resulted_tool_calls). AtRUN_FINISHED, after it drains recovered/harvested results, it now computes:and emits a synthetic terminal
TOOL_CALL_RESULTfor each orphan with an honest note:This (a) closes the stuck card, (b) is truthful — no fake success — and (c) on the next round-trip tells the model why the call did not run, so it stops re-issuing (breaking the "stuck in tool calls" loop). The note is registered as a placeholder prefix so any later real result beats it.
Guarded to avoid false positives:
queued_approval_requests/collected_approval_responses/ approval groups) are excluded viaprotected_call_ids; they legitimately await the user across runs and must stay open.known_results) are excluded; the existing harvest/backfill path re-attaches the real outcome, and the placeholder must never shadow it.Verified by unit test
test_continuity_agent_reconciles_orphaned_tool_calls_at_run_finished: an orphan (START/END, no RESULT) gets the synthetic note; an approval-pending call (parked in state) is left open; an executed call keeps its real result; the synthetic result is emitted immediately beforeRUN_FINISHED.Limitations of the workaround
started_tool_callsis reset eachrun(), so orphans from a run that died beforeRUN_FINISHED(RUN_ERROR mid-stream) are not visible to the next run's reconciliation. Closing those would require persisting started ids across runs (future work).function_callwas stripped server-side at overwrite time); the client thread holds thefunction_call+ our synthetic result. The two diverge, though the client's view is the cleaner one (a complete call/result pair the provider accepts). The underlying stream-vs-finalized-response divergence in_tools.pyremains.7. Proposed upstream fix
Any of the following, in our order of preference:
function_callis never delivered to the client. The cleanest fix: whenfunction_call_limit_reached, dropfunction_callcontents before they are yielded (or skip yielding that update) rather than streaming them and then overwriting the finalized response.TOOL_CALL_RESULT, instead of discarding it.TOOL_CALL_RESULTfor anyTOOL_CALL_STARTthat will not get one. If the framework decides not to execute a streamed call, it owns closing it in the AG-UI transport — a synthetic result (or an explicit error/tool_call_endwith a terminal status) so the client never dangles. Today the framework silently drops the call after streaming it.Option 1 or 2 removes the divergence at the source; option 3 is the minimal "don't leave the client hanging" fix and is exactly what our shim does app-side today.
8. Related issues
9. Environment
agent-framework-core1.10.0,agent-framework-ag-ui1.0.0rc7, Python 3.12, Windows 11glm-5.2:cloud); defect is provider-conditional (requires the provider to ignoretool_choice="none")