Skip to content

Commit d20f9b5

Browse files
Add AgentExecutorResponse.with_text() to preserve conversation history through custom executors (#5255)
Fixes #5246 When a custom @executor transforms agent output and sends a plain str, the downstream AgentExecutor.from_str handler loses the full conversation context. This adds a with_text() helper that creates a new AgentExecutorResponse with replaced text while preserving the prior conversation chain, so AgentExecutor.from_response is invoked instead. - Add with_text(text) method to AgentExecutorResponse dataclass - Add 3 regression tests in test_full_conversation.py Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
1 parent 87a8fa2 commit d20f9b5

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

python/packages/core/agent_framework/_workflows/_agent_executor.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,62 @@ class AgentExecutorResponse:
5959
agent_response: AgentResponse
6060
full_conversation: list[Message]
6161

62+
def with_text(self, text: str) -> "AgentExecutorResponse":
63+
"""Create a new AgentExecutorResponse with replaced text, preserving the conversation history.
64+
65+
Use this in custom executors that transform agent output text (e.g. upper-casing, summarising)
66+
when you need downstream AgentExecutors to still have access to the full prior conversation.
67+
68+
Without this helper, sending a plain ``str`` from a custom executor breaks the context chain:
69+
the downstream ``AgentExecutor.from_str`` handler only adds that one string to its cache and
70+
loses all prior messages. By using ``with_text`` the response type stays
71+
``AgentExecutorResponse``, so ``AgentExecutor.from_response`` is invoked instead and the full
72+
conversation is preserved.
73+
74+
Args:
75+
text: The replacement assistant message text.
76+
77+
Returns:
78+
A new ``AgentExecutorResponse`` whose ``agent_response`` contains a single assistant
79+
message with ``text``, and whose ``full_conversation`` is the prior conversation
80+
(everything before the original agent turn) followed by the new assistant message.
81+
82+
Example:
83+
.. code-block:: python
84+
85+
from agent_framework import AgentExecutorResponse, WorkflowContext, executor
86+
87+
88+
@executor(
89+
id="upper_case_executor",
90+
input=AgentExecutorResponse,
91+
output=AgentExecutorResponse,
92+
workflow_output=str,
93+
)
94+
async def upper_case(
95+
response: AgentExecutorResponse,
96+
ctx: WorkflowContext[AgentExecutorResponse, str],
97+
) -> None:
98+
upper_text = response.agent_response.text.upper()
99+
await ctx.send_message(response.with_text(upper_text))
100+
await ctx.yield_output(upper_text)
101+
"""
102+
new_message = Message("assistant", [text])
103+
new_agent_response = AgentResponse(messages=[new_message])
104+
105+
# Strip off the original agent turn and replace with the new text.
106+
n_agent_messages = len(self.agent_response.messages)
107+
prior_messages = (
108+
self.full_conversation[:-n_agent_messages] if n_agent_messages else list(self.full_conversation)
109+
)
110+
new_full_conversation = [*prior_messages, new_message]
111+
112+
return AgentExecutorResponse(
113+
executor_id=self.executor_id,
114+
agent_response=new_agent_response,
115+
full_conversation=new_full_conversation,
116+
)
117+
62118

63119
class AgentExecutor(Executor):
64120
"""built-in executor that wraps an agent for handling messages.
@@ -183,7 +239,25 @@ async def from_str(
183239
"""Accept a raw user prompt string and run the agent.
184240
185241
The new string input will be added to the cache which is used as the conversation context for the agent run.
242+
243+
Warning:
244+
If the upstream executor received an ``AgentExecutorResponse`` but emits a plain
245+
``str``, this handler will be invoked instead of ``from_response``. This resets
246+
the conversation context because only the new string is added to the cache and
247+
all prior messages from the upstream agent are lost.
248+
249+
To preserve the full conversation when transforming agent output in a custom
250+
executor, use ``AgentExecutorResponse.with_text(...)`` so that the message type
251+
stays ``AgentExecutorResponse`` and ``from_response`` is called instead.
186252
"""
253+
if not self._cache and ctx.source_executor_ids != ["Workflow"]:
254+
logger.warning(
255+
"AgentExecutor '%s': from_str handler invoked with an empty cache. "
256+
"If you are chaining from an AgentExecutor, the upstream custom executor may be "
257+
"emitting a plain str instead of using AgentExecutorResponse.with_text(...), "
258+
"which causes the full conversation context to be lost.",
259+
self.id,
260+
)
187261
self._cache.extend(normalize_messages_input(text))
188262
await self._run_agent_and_emit(ctx)
189263

python/packages/core/agent_framework/_workflows/_function_executor.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,19 @@ async def process(self, data: str, ctx: WorkflowContext[str]):
268268
forward references. When provided, takes precedence over introspection from the
269269
``WorkflowContext`` second generic parameter (W_OutT).
270270
271+
Warning:
272+
When placing a custom ``@executor`` **between** two ``AgentExecutor`` nodes, be
273+
careful about the output type. If the custom executor receives an
274+
``AgentExecutorResponse`` but emits a plain ``str``, the downstream
275+
``AgentExecutor.from_str`` handler is invoked instead of ``from_response``.
276+
This resets the conversation context because only the new string is added to
277+
the cache and all prior messages from the upstream agent are lost.
278+
279+
To preserve the full conversation, use
280+
``AgentExecutorResponse.with_text(new_text)`` to create a new response that
281+
keeps the prior history, and set ``output=AgentExecutorResponse`` on the
282+
decorator.
283+
271284
Returns:
272285
A FunctionExecutor instance that can be wired into a Workflow.
273286

python/packages/core/tests/workflow/test_full_conversation.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
WorkflowBuilder,
2424
WorkflowContext,
2525
WorkflowRunState,
26+
executor,
2627
handler,
2728
)
2829
from agent_framework.orchestrations import SequentialBuilder
@@ -478,3 +479,90 @@ async def test_from_response_preserves_service_session_id() -> None:
478479
assert result.get_outputs() is not None
479480

480481
assert spy_agent._captured_service_session_id == "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage]
482+
483+
484+
@executor(
485+
id="upper_case_executor",
486+
input=AgentExecutorResponse,
487+
output=AgentExecutorResponse,
488+
workflow_output=str,
489+
)
490+
async def _upper_case_executor(
491+
response: AgentExecutorResponse,
492+
ctx: WorkflowContext[AgentExecutorResponse, str],
493+
) -> None:
494+
upper_text = response.agent_response.text.upper()
495+
await ctx.send_message(response.with_text(upper_text))
496+
await ctx.yield_output(upper_text)
497+
498+
499+
async def test_with_text_preserves_full_conversation_through_custom_executor() -> None:
500+
"""Custom executor using with_text must preserve the full conversation chain."""
501+
# Mirrors the reproduction from issue #5246:
502+
# agent1 ("User likes sky red") -> agent2 ("User likes sky blue") -> upper_case -> agent3 ("User likes sky green")
503+
agent1 = AgentExecutor(
504+
_SimpleAgent(id="agent1", name="ContextAgent1", reply_text="User likes sky red"), id="agent1"
505+
)
506+
agent2 = AgentExecutor(
507+
_SimpleAgent(id="agent2", name="ContextAgent2", reply_text="User likes sky blue"), id="agent2"
508+
)
509+
agent3 = AgentExecutor(
510+
_SimpleAgent(id="agent3", name="ContextAgent3", reply_text="User likes sky green"), id="agent3"
511+
)
512+
capturer = _CaptureFullConversation(id="capture")
513+
514+
wf = (
515+
WorkflowBuilder(start_executor=agent1, output_executors=[capturer])
516+
.add_chain([agent1, agent2, _upper_case_executor, agent3, capturer])
517+
.build()
518+
)
519+
520+
result = await wf.run("")
521+
payload = next(o for o in result.get_outputs() if isinstance(o, dict))
522+
523+
# The final agent must see the full conversation: user, agent1, UPPER(agent2), agent3
524+
assert payload["roles"] == ["user", "assistant", "assistant", "assistant"]
525+
assert payload["texts"][1] == "User likes sky red"
526+
assert payload["texts"][2] == "USER LIKES SKY BLUE"
527+
assert payload["texts"][3] == "User likes sky green"
528+
529+
530+
async def test_with_text_does_not_mutate_original() -> None:
531+
"""with_text returns a new instance; the original must be unmodified."""
532+
original = AgentExecutorResponse(
533+
executor_id="test_exec",
534+
agent_response=AgentResponse(messages=[Message("assistant", ["original reply"])]),
535+
full_conversation=[Message("user", ["prompt"]), Message("assistant", ["original reply"])],
536+
)
537+
538+
new = original.with_text("transformed reply")
539+
540+
assert new is not original
541+
assert new.agent_response.text == "transformed reply"
542+
assert new.full_conversation[-1].text == "transformed reply"
543+
assert new.full_conversation[-1].role == "assistant"
544+
# Original unchanged
545+
assert original.agent_response.text == "original reply"
546+
assert original.full_conversation[-1].text == "original reply"
547+
548+
549+
async def test_with_text_strips_multi_message_agent_turn() -> None:
550+
"""When the agent turn has multiple messages (tool calls), with_text strips all of them."""
551+
tool_call = Message("assistant", ["<tool_call>"])
552+
tool_result = Message("tool", ["<result>"])
553+
final_reply = Message("assistant", ["actual answer"])
554+
user_msg = Message("user", ["question"])
555+
556+
original = AgentExecutorResponse(
557+
executor_id="exec",
558+
agent_response=AgentResponse(messages=[tool_call, tool_result, final_reply]),
559+
full_conversation=[user_msg, tool_call, tool_result, final_reply],
560+
)
561+
562+
new = original.with_text("summarised answer")
563+
564+
# Only the pre-agent-turn messages should remain, plus the replacement
565+
assert len(new.full_conversation) == 2
566+
assert new.full_conversation[0].text == "question"
567+
assert new.full_conversation[1].text == "summarised answer"
568+
assert new.agent_response.text == "summarised answer"

0 commit comments

Comments
 (0)