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:
- 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.
- 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.
Description
Resuming an
always_requiretool 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_contextcreates a session whenever the agent has any context provider (_agents.py:1407-1409):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_requestthen rejects the response, because the request it is looking for was recorded in the previous run's session:_has_authoritative_approval_sessiononly 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 — buildsAgent(client=..., name=..., instructions=..., tools=[...])with no context providers. It therefore takes thereturn responsepassthrough 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:
_has_authoritative_approval_sessionreturnsFalseand 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._prepare_run_context: if the messages carryfunction_approval_requestcontents, 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
AgentSessionper run from the conversation and pass it torun(session=...), seedingstate["tool_approval"]["pending_approval_requests"]from thefunction_approval_requestcontents 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.Output:
Error Messages / Stack Traces
No exception is raised. The only signal is a warning, which names the symptom rather than the cause:
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
mainstill has both halves as of this report: the auto-creation at_agents.py:1407-1409, and_has_authoritative_approval_sessionexempting 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.