Skip to content

Python: [Bug]: Function-invocation-limit orphan: MAF streams a tool call (TOOL_CALL_START/ARGS/END) and then discards it, leaving an AG-UI card stuck "Running" with no TOOL_CALL_RESULT #7045

Description

@antsok

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:

  1. sets tool_choice = "none" for the next model call,
  2. 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,
  3. finalizes the response,
  4. overwrites the finalized response with the limit-fallback text (_ensure_function_invocation_limit_fallback_response) — which strips the function_callonly because a pure tool-call response has no "visible content",
  5. 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)

  1. 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).
  2. Use a provider that ignores tool_choice="none" for tool calls (Ollama glm-5.2:cloud).
  3. 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").
  4. 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–2791async 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.
  • 2795response = await inner_stream.get_final_response() — the finalized response still has the function_call.
  • 2797–2803function_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–2818if 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:

  1. 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.
  2. 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.
  3. 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")

Metadata

Metadata

Assignees

Labels

pythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Fields

No fields configured for Bug.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions