Skip to content

Python: [Bug]: tool approval resume silently fails when the agent has a context provider #8410

Description

Description

Resuming an always_require tool approval works or silently fails depending on whether the agent happens to have a context provider — something unrelated to approvals, and not mentioned anywhere in the approval docs or samples.

Agent._prepare_run_context creates a session whenever the agent has any context provider (_agents.py:1407-1409):

active_session = session
if active_session is None and self.context_providers:
    active_session = AgentSession()

That session is per run, so on the run that resumes an approval it is a brand-new, empty object. _bind_approval_response_to_pending_request then rejects the response, because the request it is looking for was recorded in the previous run's session:

if not _has_authoritative_approval_session(invocation_session):
    return response                       # <- the documented sample's path
pending = _load_pending_approval_requests(invocation_session)   # {} on a fresh session

_has_authoritative_approval_session only exempts the session the function-middleware layer creates for itself (flagged _run_local_function_middleware_session, _tools.py:4446). A context-provider session carries no such flag, so it counts as authoritative and gates the binding.

Net effect: the approval response is dropped with a warning about occurrence identity, the request stays open, and the caller loops re-asking the same question. The tool never runs and no error is raised.

The documented sample, function_tool_with_approval.py — the one rendered at Tool approval — builds Agent(client=..., name=..., instructions=..., tools=[...]) with no context providers. It therefore takes the return response passthrough and works, without stating that this is why. Adding any context provider (skills, memory, RAG — a common setup) breaks it.

I am not arguing the session requirement itself is wrong: ADR 0006-userapproval.md §3 is explicit that a resume needs a thread with "the equivalent contents as the original thread". The problem is that the requirement is conditional on an unrelated feature, and the condition is invisible from the public API and the docs.

Two possible fixes, in rough order of preference:

  1. Treat a framework-created context-provider session the same way as the function-middleware one — flag it, so _has_authoritative_approval_session returns False and binding falls back to the message contents. That makes both samples behave identically and matches what a caller who never asked for a session would expect.
  2. Seed the session from the incoming messages in _prepare_run_context: if the messages carry function_approval_request contents, record them as pending before the run. The information is already there; only the bookkeeping object is missing.

Failing either, a note in the sample and the docs page saying that the session-less form only works for an agent with no context providers, and what to do otherwise, would at least make it discoverable.

As a workaround we rebuild an equivalent AgentSession per run from the conversation and pass it to run(session=...), seeding state["tool_approval"]["pending_approval_requests"] from the function_approval_request contents in the messages. That relies on two private names, which is why a supported path would be welcome.

Code Sample

Same shape as the documented sample; the only variable is context_providers. The chat client is scripted, so this needs no credentials and no network.

import asyncio
import importlib.metadata as md

from agent_framework import (
    Agent, ChatResponse, ChatResponseUpdate, Content, ContextProvider,
    Message, ResponseStream, tool,
)
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
from agent_framework.observability import ChatTelemetryLayer

EXECUTED: list[str] = []


@tool(approval_mode="always_require")
def get_weather(city: str) -> str:
    """Get the weather for a city."""
    EXECUTED.append(city)
    return f"sunny in {city}"


class Memo(ContextProvider):
    """A context provider that does nothing at all."""


class ScriptedClient(
    FunctionInvocationLayer, ChatMiddlewareLayer, ChatTelemetryLayer, BaseChatClient
):
    """Asks for get_weather once, then answers with text. Stands in for a model."""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.calls = 0

    def _inner_get_response(self, *, messages, stream, options, **kwargs):
        self.calls += 1
        if self.calls == 1:
            call = Content.from_function_call(
                call_id="call_1", name="get_weather", arguments={"city": "LA"}
            )
            call.id = "call_1"
            contents = [call]
        else:
            contents = [Content.from_text("The weather in LA is sunny.")]
        message = Message("assistant", contents)
        if stream:
            async def updates():
                yield ChatResponseUpdate(role="assistant", contents=contents)
            return ResponseStream(updates(), finalizer=ChatResponse.from_updates)

        async def once():
            return ChatResponse(messages=[message])
        return once()


async def approve_and_resume(label: str, context_providers) -> None:
    EXECUTED.clear()
    agent = Agent(
        client=ScriptedClient(),
        name="WeatherAgent",
        instructions="You are a helpful weather assistant.",
        tools=[get_weather],
        context_providers=context_providers,
    )

    first = await agent.run("What is the weather in LA?")
    request = next(
        content
        for message in first.messages
        for content in message.contents
        if content.type == "function_approval_request"
    )

    resumed = [
        Message("user", [Content.from_text("What is the weather in LA?")]),
        *first.messages,
        Message("user", [request.to_function_approval_response(True)]),
    ]
    await agent.run(resumed)

    outcome = EXECUTED or "NOT EXECUTED - the approval response was discarded"
    print(f"{label:38s} -> {outcome}")


async def main() -> None:
    print(f"agent-framework-core {md.version('agent-framework-core')}\n")
    await approve_and_resume("no context providers (the sample)", None)
    await approve_and_resume("one no-op context provider", [Memo(source_id="memo")])


if __name__ == "__main__":
    asyncio.run(main())

Output:

agent-framework-core 1.18.0

no context providers (the sample)      -> ['LA']
one no-op context provider             -> NOT EXECUTED - the approval response was discarded

Error Messages / Stack Traces

No exception is raised. The only signal is a warning, which names the symptom rather than the cause:

Ignored an approval response with id 'call_1' because it did not match the active
approval occurrence identity; the pending request was retained for retry.

Because the request stays open, a host that loops until the approval is settled will re-ask indefinitely; each iteration costs a model call.

Package Versions

agent-framework-core 1.18.0 (also reproduced on 1.17.0)

Python Version

3.14

Additional Context

main still has both halves as of this report: the auto-creation at _agents.py:1407-1409, and _has_authoritative_approval_session exempting only the function-middleware session.

Found while building a chat application whose agents all carry a skills context provider, so every agent took the broken path and the documented sample could not be followed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentmiddlewareUsage: [Issues, PRs], Target: middleware related featurespythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowskillsUsage: [Issues, PRs], Target: skills related features

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions