Skip to content

Python: [Bug]: Tool results from approval-resolution execution are never streamed, so provider-injected approvals emit no TOOL_CALL_RESULT #7241

Description

@kartikmadan11

Description

Problem

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:

if role := result.get("update_role"):
    yield ChatResponseUpdate(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.

from collections.abc import AsyncIterator
from typing import Any

from agent_framework import (
    Agent, ChatResponseUpdate, Content, ContextProvider, FunctionTool,
    Message, ToolApprovalMiddleware,
)
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework_ag_ui._agent import AgentFrameworkAgent
from fastapi import FastAPI
from fastapi.testclient import TestClient

side_effects: list[str] = []
state = {"phase": "pause"}

def provider_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",
)

class ToolInjectingProvider(ContextProvider):
    async def before_run(self, *, agent, session, context, state) -> None:
        context.extend_tools(self.source_id, [provider_tool])  # registered only at before_run

async def stream_fn(messages: list[Message], options: dict[str, Any], **kwargs: Any) -> AsyncIterator[ChatResponseUpdate]:
    if state["phase"] == "pause":
        yield ChatResponseUpdate(
            contents=[Content.from_function_call(call_id="call_provider", name="provider_write", arguments="{}")],
            role="assistant",
        )
        return
    yield ChatResponseUpdate(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=<streaming client wrapping stream_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.
  • "Deferring the tool was the bug." Deferral (from Python: [Bug]: AG-UI transport executes approved tool calls before before_run injects 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_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.

Package Versions

agent-framework-core: 1.11.0, agent-framework-ag-ui: 1.11.0

Python Version

Python 3.13

Metadata

Metadata

Assignees

Labels

ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integrationpythonUsage: [Issues, PRs], Target: Python

Type

No type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions