Skip to content

Commit 844d345

Browse files
authored
Python: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data (#3090)
* Fix ExecutorInvokedEvent.data mutation bug * Fix bug related to not yielding output type
1 parent ed5278c commit 844d345

4 files changed

Lines changed: 62 additions & 7 deletions

File tree

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
import contextlib
4+
import copy
45
import functools
56
import inspect
67
import logging
@@ -263,8 +264,9 @@ async def execute(
263264
)
264265

265266
# Invoke the handler with the message and context
267+
# Use deepcopy to capture original input state before handler can mutate it
266268
with _framework_event_origin():
267-
invoke_event = ExecutorInvokedEvent(self.id, message)
269+
invoke_event = ExecutorInvokedEvent(self.id, copy.deepcopy(message))
268270
await context.add_event(invoke_event)
269271
try:
270272
await handler(message, context)
@@ -275,9 +277,11 @@ async def execute(
275277
await context.add_event(failure_event)
276278
raise
277279
with _framework_event_origin():
278-
# Include sent messages as the completion data
280+
# Include sent messages and yielded outputs as the completion data
279281
sent_messages = context.get_sent_messages()
280-
completed_event = ExecutorCompletedEvent(self.id, sent_messages if sent_messages else None)
282+
yielded_outputs = context.get_yielded_outputs()
283+
completion_data = sent_messages + yielded_outputs
284+
completed_event = ExecutorCompletedEvent(self.id, completion_data if completion_data else None)
281285
await context.add_event(completed_event)
282286

283287
def _create_context_for_handler(

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3+
import copy
34
import inspect
45
import logging
56
import uuid
@@ -290,6 +291,9 @@ def __init__(
290291
# Track messages sent via send_message() for ExecutorCompletedEvent
291292
self._sent_messages: list[Any] = []
292293

294+
# Track outputs yielded via yield_output() for ExecutorCompletedEvent
295+
self._yielded_outputs: list[Any] = []
296+
293297
# Store trace contexts and source span IDs for linking (supporting multiple sources)
294298
self._trace_contexts = trace_contexts or []
295299
self._source_span_ids = source_span_ids or []
@@ -336,6 +340,9 @@ async def yield_output(self, output: T_W_Out) -> None:
336340
output: The output to yield. This must conform to the workflow output type(s)
337341
declared on this context.
338342
"""
343+
# Track yielded output for ExecutorCompletedEvent (deepcopy to capture state at yield time)
344+
self._yielded_outputs.append(copy.deepcopy(output))
345+
339346
with _framework_event_origin():
340347
event = WorkflowOutputEvent(data=output, source_executor_id=self._executor_id)
341348
await self._runner_context.add_event(event)
@@ -424,6 +431,14 @@ def get_sent_messages(self) -> list[Any]:
424431
"""
425432
return self._sent_messages.copy()
426433

434+
def get_yielded_outputs(self) -> list[Any]:
435+
"""Get all outputs yielded via yield_output() during this handler execution.
436+
437+
Returns:
438+
A list of outputs that were yielded as workflow outputs.
439+
"""
440+
return self._yielded_outputs.copy()
441+
427442
@deprecated(
428443
"Override `on_checkpoint_save()` methods instead. "
429444
"For cross-executor state sharing, use set_shared_state() instead. "

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import pytest
44

55
from agent_framework import (
6+
ChatMessage,
67
Executor,
78
ExecutorCompletedEvent,
89
ExecutorInvokedEvent,
910
Message,
1011
WorkflowBuilder,
1112
WorkflowContext,
13+
executor,
1214
handler,
1315
)
1416

@@ -182,8 +184,8 @@ async def handle(self, text: str, ctx: WorkflowContext) -> None:
182184
assert collector_completed.data is None
183185

184186

185-
async def test_executor_completed_event_none_when_no_messages_sent():
186-
"""Test that ExecutorCompletedEvent.data is None when no messages are sent."""
187+
async def test_executor_completed_event_includes_yielded_outputs():
188+
"""Test that ExecutorCompletedEvent.data includes yielded outputs."""
187189
from typing_extensions import Never
188190

189191
from agent_framework import WorkflowOutputEvent
@@ -201,9 +203,10 @@ async def handle(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
201203

202204
assert len(completed_events) == 1
203205
assert completed_events[0].executor_id == "yielder"
204-
assert completed_events[0].data is None
206+
# Yielded outputs are now included in ExecutorCompletedEvent.data
207+
assert completed_events[0].data == ["TEST"]
205208

206-
# Verify the output was still yielded correctly
209+
# Verify the output was also yielded as WorkflowOutputEvent
207210
output_events = [e for e in events if isinstance(e, WorkflowOutputEvent)]
208211
assert len(output_events) == 1
209212
assert output_events[0].data == "TEST"
@@ -261,3 +264,35 @@ async def handle(self, response: Response, ctx: WorkflowContext) -> None:
261264
collector_invoked = next(e for e in invoked_events if e.executor_id == "collector")
262265
assert isinstance(collector_invoked.data, Response)
263266
assert collector_invoked.data.results == ["HELLO", "HELLO", "HELLO"]
267+
268+
269+
async def test_executor_invoked_event_data_not_mutated_by_handler():
270+
"""Test that ExecutorInvokedEvent.data captures original input, not mutated input."""
271+
272+
@executor(id="Mutator")
273+
async def mutator(messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
274+
# The handler mutates the input list by appending new messages
275+
original_len = len(messages)
276+
messages.append(ChatMessage(role="assistant", text="Added by executor"))
277+
await ctx.send_message(messages)
278+
# Verify mutation happened
279+
assert len(messages) == original_len + 1
280+
281+
workflow = WorkflowBuilder().set_start_executor(mutator).build()
282+
283+
# Run with a single user message
284+
input_messages = [ChatMessage(role="user", text="hello")]
285+
events = await workflow.run(input_messages)
286+
287+
# Find the invoked event for the Mutator executor
288+
invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)]
289+
assert len(invoked_events) == 1
290+
mutator_invoked = invoked_events[0]
291+
292+
# The event data should contain ONLY the original input (1 user message)
293+
assert mutator_invoked.executor_id == "Mutator"
294+
assert len(mutator_invoked.data) == 1, (
295+
f"Expected 1 message (original input), got {len(mutator_invoked.data)}: "
296+
f"{[m.text for m in mutator_invoked.data]}"
297+
)
298+
assert mutator_invoked.data[0].text == "hello"

python/samples/getting_started/workflows/observability/executor_io_observation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ async def main() -> None:
119119
Input: str: 'HELLO WORLD'
120120
[WORKFLOW OUTPUT] str: 'DLROW OLLEH'
121121
[COMPLETED] reverse_text
122+
Output: list: [str: 'DLROW OLLEH']
122123
"""
123124

124125

0 commit comments

Comments
 (0)