You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a tool is executed while resolving an approval (rather than in the model's normal tool-call turn), its function_result is never streamed out of the function-invocation layer, so streaming consumers never observe it.
The approval-resolution path runs through the prepped_messages branch of _process_function_requests (packages/core/agent_framework/_tools.py). That branch executes the approved calls, splices their results into the local message history via _replace_approval_contents_with_results, and then returns with "update_role": None, "function_call_results": None (_tools.py:2371 on main). Because update_role is None, the streaming loop never yields those results as a ChatResponseUpdate.
This is invisible for the second_process_function_requests call in the streaming loop (_tools.py:2840), which is followed by a yield gate:
ifrole:=result.get("update_role"):
yieldChatResponseUpdate(contents=result.get("function_call_results") or [], role=role)
_tools.py:2854
but the first call site (_tools.py:2766), which is the one that resolves approvals, has no such yield gate at all. So a tool executed during approval resolution runs, its result is merged into the messages fed to the next model call, but nothing is ever streamed for it.
Impact
This surfaces most clearly for provider-injected tools approved through the AG-UI transport (the scenario fixed by #7043 / PR #7091). A tool registered by a context provider during before_run (e.g. FileAccessProvider, CodeInterpreterProvider) is absent from the transport's static tool map, so it is deferred and executed in-run by ToolApprovalMiddleware during agent.run. That execution goes through the approval-resolution branch above.
Result: after the user approves such a tool, its side effect fires, but no result is streamed. The AG-UI emitter (packages/ag-ui/agent_framework_ag_ui/_run_common.py, _emit_tool_result at _run_common.py:713, dispatched from _emit_content at _run_common.py:1051) emits exactly one TOOL_CALL_RESULT for any function_result that appears in a streamed update — but it never sees one for the deferred tool, so no TOOL_CALL_RESULT event reaches the client and the result is absent from the messages snapshot. This is asymmetric with statically executed approvals, which the AG-UI transport emits explicitly via _make_approval_tool_result_events (packages/ag-ui/agent_framework_ag_ui/_agent_run.py). From the client's perspective the approved tool ran silently: the action happened, but there is no observable result event to correlate with the approval.
Reproduction
Approve a provider-injected tool through the AG-UI endpoint and observe that no TOOL_CALL_RESULT is emitted on resume, even though the tool's side effect fires.
fromcollections.abcimportAsyncIteratorfromtypingimportAnyfromagent_frameworkimport (
Agent, ChatResponseUpdate, Content, ContextProvider, FunctionTool,
Message, ToolApprovalMiddleware,
)
fromagent_framework_ag_uiimportadd_agent_framework_fastapi_endpointfromagent_framework_ag_ui._agentimportAgentFrameworkAgentfromfastapiimportFastAPIfromfastapi.testclientimportTestClientside_effects: list[str] = []
state= {"phase": "pause"}
defprovider_write() ->str:
side_effects.append("wrote")
return"wrote to disk"provider_tool=FunctionTool(
name="provider_write", description="Write to disk", func=provider_write,
approval_mode="always_require",
)
classToolInjectingProvider(ContextProvider):
asyncdefbefore_run(self, *, agent, session, context, state) ->None:
context.extend_tools(self.source_id, [provider_tool]) # registered only at before_runasyncdefstream_fn(messages: list[Message], options: dict[str, Any], **kwargs: Any) ->AsyncIterator[ChatResponseUpdate]:
ifstate["phase"] =="pause":
yieldChatResponseUpdate(
contents=[Content.from_function_call(call_id="call_provider", name="provider_write", arguments="{}")],
role="assistant",
)
returnyieldChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")
# provider_write is NOT in the static tools list -- only injected via before_run.agent=Agent(
name="t", instructions="t", client=<streamingclientwrappingstream_fn>, tools=[],
middleware=[ToolApprovalMiddleware()],
context_providers=[ToolInjectingProvider(source_id="inj")],
)
app=FastAPI()
add_agent_framework_fastapi_endpoint(app, AgentFrameworkAgent(agent=agent, require_confirmation=False), path="/approval")
client=TestClient(app)
# Pause -> the harness surfaces an approval interrupt for call_provider.client.post("/approval", json={"runId": "p", "threadId": "t1", "messages": [{"role": "user", "content": "go"}]})
# Approve -> the deferred tool runs during agent.run.state["phase"] ="resume"resume=client.post("/approval", json={
"runId": "r", "threadId": "t1", "messages": [],
"resume": [{"interruptId": "call_provider", "status": "resolved", "payload": {"accepted": True}}],
})
# Observed: side_effects == ["wrote"] (the tool ran)# but zero TOOL_CALL_RESULT events in the resume stream, and the# result is absent from the MESSAGES_SNAPSHOT.# Expected: exactly one TOOL_CALL_RESULT for call_provider, matching a static approval.
Why the obvious framings fall short
"It's an AG-UI transport bug." The transport already emits results correctly for the results it is given (_make_approval_tool_result_events) and its emitter already handles any streamed function_result. The result never reaches it because the core layer never streams it — so a transport-only workaround would have to re-derive the result post-run and re-order it after the model's final text, which is brittle and produces wrong event ordering.
Have the approval-resolution branch of _process_function_requests return the executed function_result items with update_role="tool" (instead of None), and add the matching yield gate after the first _process_function_requests call in the streaming loop, mirroring the existing gate at _tools.py:2854. The AG-UI emitter then produces exactly one TOOL_CALL_RESULT with correct ordering, with no transport changes. The non-streaming path is unaffected — it does not consume update_role and already carries the result via the spliced messages.
Relationship to other issues
Distinct from #7043 (approved provider tools were rewritten as rejected; fixed in PR #7091 by keeping deferred ids in fcc_todo). This issue is the follow-on: with rejection fixed, the deferred tool runs but its result is never streamed. Adjacent to #7223 (AG-UI snapshot ordering) in that both concern how in-run content reaches the AG-UI event stream, but the root cause here is in the core function-invocation layer, not the transport.
Description
Problem
When a tool is executed while resolving an approval (rather than in the model's normal tool-call turn), its
function_resultis never streamed out of the function-invocation layer, so streaming consumers never observe it.The approval-resolution path runs through the
prepped_messagesbranch of_process_function_requests(packages/core/agent_framework/_tools.py). That branch executes the approved calls, splices their results into the local message history via_replace_approval_contents_with_results, and then returns with"update_role": None, "function_call_results": None(_tools.py:2371onmain). Becauseupdate_roleisNone, the streaming loop never yields those results as aChatResponseUpdate.This is invisible for the second
_process_function_requestscall in the streaming loop (_tools.py:2840), which is followed by a yield gate:but the first call site (
_tools.py:2766), which is the one that resolves approvals, has no such yield gate at all. So a tool executed during approval resolution runs, its result is merged into the messages fed to the next model call, but nothing is ever streamed for it.Impact
This surfaces most clearly for provider-injected tools approved through the AG-UI transport (the scenario fixed by #7043 / PR #7091). A tool registered by a context provider during
before_run(e.g.FileAccessProvider,CodeInterpreterProvider) is absent from the transport's static tool map, so it is deferred and executed in-run byToolApprovalMiddlewareduringagent.run. That execution goes through the approval-resolution branch above.Result: after the user approves such a tool, its side effect fires, but no result is streamed. The AG-UI emitter (
packages/ag-ui/agent_framework_ag_ui/_run_common.py,_emit_tool_resultat_run_common.py:713, dispatched from_emit_contentat_run_common.py:1051) emits exactly oneTOOL_CALL_RESULTfor anyfunction_resultthat appears in a streamed update — but it never sees one for the deferred tool, so noTOOL_CALL_RESULTevent reaches the client and the result is absent from the messages snapshot. This is asymmetric with statically executed approvals, which the AG-UI transport emits explicitly via_make_approval_tool_result_events(packages/ag-ui/agent_framework_ag_ui/_agent_run.py). From the client's perspective the approved tool ran silently: the action happened, but there is no observable result event to correlate with the approval.Reproduction
Approve a provider-injected tool through the AG-UI endpoint and observe that no
TOOL_CALL_RESULTis emitted on resume, even though the tool's side effect fires.Why the obvious framings fall short
_make_approval_tool_result_events) and its emitter already handles any streamedfunction_result. The result never reaches it because the core layer never streams it — so a transport-only workaround would have to re-derive the result post-run and re-order it after the model's final text, which is brittle and produces wrong event ordering.before_runinjects provider tools — approved calls to provider-injected tools silently fail #7043) is correct — the tool must run in-run once provider tools are registered. The gap is only that the in-run execution's result isn't surfaced to the stream the way a normal tool result is.Proposed fix
Have the approval-resolution branch of
_process_function_requestsreturn the executedfunction_resultitems withupdate_role="tool"(instead ofNone), and add the matching yield gate after the first_process_function_requestscall in the streaming loop, mirroring the existing gate at_tools.py:2854. The AG-UI emitter then produces exactly oneTOOL_CALL_RESULTwith correct ordering, with no transport changes. The non-streaming path is unaffected — it does not consumeupdate_roleand already carries the result via the spliced messages.Relationship to other issues
Distinct from #7043 (approved provider tools were rewritten as rejected; fixed in PR #7091 by keeping deferred ids in
fcc_todo). This issue is the follow-on: with rejection fixed, the deferred tool runs but its result is never streamed. Adjacent to #7223 (AG-UI snapshot ordering) in that both concern how in-run content reaches the AG-UI event stream, but the root cause here is in the core function-invocation layer, not the transport.Package Versions
agent-framework-core: 1.11.0, agent-framework-ag-ui: 1.11.0
Python Version
Python 3.13