Repository navigation
Description
Observed Behavior
When an agent inside an AgentExecutor pauses for tool approval and the workflow resumes with the approval response, the AgentExecutorResponse.full_conversation sent downstream only has the approval response and the final reply. The original user message is gone. So is the assistant turn that held the function call and approval request.
A downstream AgentExecutor uses the default context_mode="full", so it gets this cut-down history as its input:
downstream full_conversation:
('user', ['function_approval_response'], '')
('assistant', ['text'], 'Deleted the DB as approved.')
With a real provider, the next agent gets a tool approval/result that matches no call and has no user question.
Expected Behavior
full_conversation after the resume should hold the whole turn, the same as when no approval is needed:
('user', ['text'], 'please delete the db')
('assistant', ['function_approval_request'], '') # or the function_call
('user', ['function_approval_response'], '')
('assistant', ['text'], 'Deleted the DB as approved.')
Steps to Reproduce
- Build a workflow:
AgentExecutor(agent)→ downstream executor (or a secondAgentExecutor). - Run it with a user message. The agent returns a
function_approval_request, so the workflow pauses. - Resume with
wf.run(responses={request_id: approval_response}). - Look at
AgentExecutorResponse.full_conversationdownstream.
Minimal Reproduction
Offline, no API keys needed. Run with the repo's dev environment.
"""AgentExecutorResponse.full_conversation after an approval resume drops the original user turn
and the tool-call/approval-request turn."""
import asyncio
from typing import Any
from agent_framework import (AgentExecutor, AgentExecutorResponse, AgentResponse, AgentResponseUpdate,
AgentSession, BaseAgent, Content, Executor, Message, ResponseStream,
WorkflowBuilder, WorkflowContext, handler)
class ApprovalAgent(BaseAgent):
def __init__(self) -> None:
super().__init__(name="approver")
self.calls = 0
def run(self, messages=None, *, stream=False, session: AgentSession | None = None, **kw: Any):
self.calls += 1
if self.calls == 1:
call = Content.from_function_call(call_id="c1", name="delete_db", arguments="{}")
contents = [Content.from_function_approval_request(id="r1", function_call=call)]
else:
contents = [Content.from_text("Deleted the DB as approved.")]
async def _run():
return AgentResponse(messages=[Message(role="assistant", contents=contents)])
async def _stream():
yield AgentResponseUpdate(role="assistant", contents=contents)
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) if stream else _run()
class Capture(Executor):
seen: list = []
@handler
async def h(self, r: AgentExecutorResponse, ctx: WorkflowContext[None, str]) -> None:
Capture.seen = [(m.role, [c.type for c in m.contents], m.text) for m in r.full_conversation]
await ctx.yield_output("ok")
async def main():
ae = AgentExecutor(ApprovalAgent(), id="agent")
cap = Capture(id="cap")
wf = WorkflowBuilder(start_executor=ae).add_edge(ae, cap).build()
r = await asyncio.wait_for(wf.run("please delete the db"), 5)
req = r.get_request_info_events()[0]
await asyncio.wait_for(wf.run(responses={req.request_id: req.data.to_function_approval_response(approved=True)}), 5)
print("downstream full_conversation:")
for row in Capture.seen:
print(" ", row)
print("expected: starts with ('user', ['text'], 'please delete the db') and includes the approval request")
asyncio.run(main())
class RecordingAgent(BaseAgent):
def __init__(self) -> None:
super().__init__(name="reviewer")
self.received: list = []
def run(self, messages=None, *, stream=False, session=None, **kw: Any):
self.received = [(m.role, [c.type for c in m.contents], m.text) for m in messages]
async def _run():
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("reviewed")])])
return _run()
async def chained():
a = AgentExecutor(ApprovalAgent(), id="agent")
rec = RecordingAgent()
b = AgentExecutor(rec, id="reviewer") # default context_mode="full"
wf = WorkflowBuilder(start_executor=a).add_edge(a, b).build()
r = await asyncio.wait_for(wf.run("please delete the db"), 5)
req = r.get_request_info_events()[0]
await asyncio.wait_for(wf.run(responses={req.request_id: req.data.to_function_approval_response(approved=True)}), 5)
print("\nchained AgentExecutor (context_mode='full') received:")
for row in rec.received:
print(" ", row)
asyncio.run(chained())Root cause (what I found)
In _workflows/_agent_executor.py:
_run_agent_and_emitreturns early when the agent asks for user input, so the cache from before the pause is not emitted.- On resume,
_resume_with_pending_responsesreplaces the cache with only the replies (line ~552):self._cache = normalize_messages_input(messages)
_run_agent_and_emitthen rebuilds the conversation from that cache (line ~782):self._full_conversation = [*self._cache, *(list(response.messages) if response else [])]
- So the pre-pause user input and the paused response's messages never make it into
full_conversation.
I couldn't find a test covering full_conversation across an approval resume.
Possible fix
Keep the pre-pause input and the paused response's messages when the executor pauses for user input, and prepend them when building _full_conversation on resume. I'm happy to open a PR with a regression test if the direction looks right.
Package Versions
agent-framework-core 1.25.0 (main @ 22b5e75)
Python Version
3.13.11
Operating System
macOS
Regression
Unknown
Additional Context
AI assistance: the issue analysis, the repro scripts and this write-up were prepared with an AI coding assistant (Claude Code). The output shown above comes from running the repros against main @ 22b5e75.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
github-actions commentedon Nov 12, 2026
Reproduced on main @ 22b5e75 with an independently written offline script (fake approval-requesting agent → capture executor). After the approval resume, downstream
full_conversationwas[('user', ['function_approval_response']), ('assistant', ['text'])]: the original user message and the approval-request turn are missing. This matches the cause in_workflows/_agent_executor.py, where the resume path replaces_cachewith only the responses and_full_conversationis rebuilt from it.Support boundary: unclear; no maintained spec or doc was checked that defines
full_conversationacross an approval resume, so a maintainer needs to confirm the intended contract. Routed to workflows; priority and scope are for maintainers. Evidence: https://github.com/microsoft/agent-framework/actions/runs/43162812784