mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [Breaking] Remove WorkflowCompletedEvent, introduce workflow output and migrate to ctx.yield_output() + a huge refactoring (#845)
* Introduce input and output types for executor and workflow * WorkflowOutputContext handles two types * Remove can_handle_types from Executor * Update validation * Move workflow executor * Move workflow executor * Fix issues in WorkflowExecutor * refactor executor * update execute signature to create workflow context within Executor * fix simple sub workflow test; fix validation * fix output types in WorkflowExecutor * fix issue in Executor handling of SubWorkflowRequestInfo * update tests to use proper workflow output * update orchestration patterns to use output * Update sample -- not finished * Update python/packages/main/tests/workflow/test_workflow_states.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/main/tests/workflow/test_concurrent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address comments * WorkflowOutputContext --> WorkflowContext * remove WorkflowCompletedEvent * update samples * Update doc string for important classes; update WorkflowExecutor to support concurrent execution * use Never instead of None for default type * Update usage of WorkflowContext[None to WorkflowContext[Never * address comments * remove filter for None * address comments, minor fixes * quality of life improvement on interceptor types --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
0f913bcdeb
commit
2133043f11
+31
-27
@@ -19,8 +19,8 @@ from agent_framework import (
|
||||
RequestResponse,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
@@ -87,7 +87,7 @@ class BriefPreparer(Executor):
|
||||
self._agent_id = agent_id
|
||||
|
||||
@handler
|
||||
async def prepare(self, brief: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
async def prepare(self, brief: str, ctx: WorkflowContext[AgentExecutorRequest, str]) -> None:
|
||||
# Collapse errant whitespace so the prompt is stable between runs.
|
||||
normalized = " ".join(brief.split()).strip()
|
||||
if not normalized.endswith("."):
|
||||
@@ -133,7 +133,7 @@ class ReviewGateway(Executor):
|
||||
async def on_agent_response(
|
||||
self,
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[HumanApprovalRequest],
|
||||
ctx: WorkflowContext[HumanApprovalRequest, str],
|
||||
) -> None:
|
||||
# Capture the agent output so we can surface it to the reviewer and
|
||||
# persist iterations. The `RequestInfoExecutor` relies on this state to
|
||||
@@ -157,7 +157,7 @@ class ReviewGateway(Executor):
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[HumanApprovalRequest, str],
|
||||
ctx: WorkflowContext[AgentExecutorRequest | str],
|
||||
ctx: WorkflowContext[AgentExecutorRequest | str, str],
|
||||
) -> None:
|
||||
# The RequestResponse wrapper gives us both the human data and the
|
||||
# original request message, even when resuming from checkpoints.
|
||||
@@ -190,11 +190,11 @@ class FinaliseExecutor(Executor):
|
||||
"""Publishes the approved text."""
|
||||
|
||||
@handler
|
||||
async def publish(self, text: str, ctx: WorkflowContext[Any]) -> None:
|
||||
async def publish(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
|
||||
# Store the output so diagnostics or a UI could fetch the final copy.
|
||||
await ctx.set_state({"published_text": text})
|
||||
# Emit a workflow completion event so the runner stops cleanly.
|
||||
await ctx.add_event(WorkflowCompletedEvent(text))
|
||||
# Yield the final output so the workflow completes cleanly.
|
||||
await ctx.yield_output(text)
|
||||
|
||||
|
||||
def create_workflow(*, checkpoint_storage: FileCheckpointStorage | None = None) -> "Workflow":
|
||||
@@ -264,17 +264,17 @@ def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
print(line)
|
||||
|
||||
|
||||
def _print_events(events: list[Any]) -> tuple[WorkflowCompletedEvent | None, list[tuple[str, HumanApprovalRequest]]]:
|
||||
def _print_events(events: list[Any]) -> tuple[str | None, list[tuple[str, HumanApprovalRequest]]]:
|
||||
"""Echo workflow events to the console and collect outstanding requests."""
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed_output: str | None = None
|
||||
requests: list[tuple[str, HumanApprovalRequest]] = []
|
||||
|
||||
for event in events:
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completed = event
|
||||
elif isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanApprovalRequest):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
completed_output = event.data
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanApprovalRequest):
|
||||
# Capture pending human approvals so the caller can ask the user for
|
||||
# input after the current batch of events is processed.
|
||||
requests.append((event.request_id, event.data))
|
||||
@@ -284,7 +284,7 @@ def _print_events(events: list[Any]) -> tuple[WorkflowCompletedEvent | None, lis
|
||||
}:
|
||||
print(f"Workflow state: {event.state.name}")
|
||||
|
||||
return completed, requests
|
||||
return completed_output, requests
|
||||
|
||||
|
||||
def _prompt_for_responses(requests: list[tuple[str, HumanApprovalRequest]]) -> dict[str, str] | None:
|
||||
@@ -350,14 +350,14 @@ async def _consume(stream: AsyncIterable[Any]) -> list[Any]:
|
||||
return [event async for event in stream]
|
||||
|
||||
|
||||
async def run_interactive_session(workflow: "Workflow", initial_message: str) -> WorkflowCompletedEvent | None:
|
||||
async def run_interactive_session(workflow: "Workflow", initial_message: str) -> str | None:
|
||||
"""Run the workflow until it either finishes or pauses for human input."""
|
||||
|
||||
pending_responses: dict[str, str] | None = None
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed_output: str | None = None
|
||||
first = True
|
||||
|
||||
while completed is None:
|
||||
while completed_output is None:
|
||||
if first:
|
||||
# Kick off the workflow with the initial brief. The returned events
|
||||
# include RequestInfo events when the agent produces a draft.
|
||||
@@ -369,10 +369,11 @@ async def run_interactive_session(workflow: "Workflow", initial_message: str) ->
|
||||
else:
|
||||
break
|
||||
|
||||
completed, requests = _print_events(events)
|
||||
pending_responses = _prompt_for_responses(requests)
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending_responses = _prompt_for_responses(requests)
|
||||
|
||||
return completed
|
||||
return completed_output
|
||||
|
||||
|
||||
async def resume_from_checkpoint(
|
||||
@@ -391,21 +392,24 @@ async def resume_from_checkpoint(
|
||||
responses=pre_supplied,
|
||||
)
|
||||
)
|
||||
completed, requests = _print_events(events)
|
||||
if pre_supplied and not requests and completed is None:
|
||||
completed_output, requests = _print_events(events)
|
||||
if pre_supplied and not requests and completed_output is None:
|
||||
# When the checkpoint only needed the provided answers we let the user
|
||||
# know the workflow is waiting for the next superstep (usually another
|
||||
# agent response).
|
||||
print("Pre-supplied responses applied automatically; workflow is now waiting for the next step.")
|
||||
|
||||
pending = _prompt_for_responses(requests)
|
||||
while completed is None and pending:
|
||||
while completed_output is None and pending:
|
||||
events = await _consume(workflow.send_responses_streaming(pending))
|
||||
completed, requests = _print_events(events)
|
||||
pending = _prompt_for_responses(requests)
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending = _prompt_for_responses(requests)
|
||||
else:
|
||||
break
|
||||
|
||||
if completed:
|
||||
print(f"Workflow completed with: {completed.data}")
|
||||
if completed_output:
|
||||
print(f"Workflow completed with: {completed_output}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -427,7 +431,7 @@ async def main() -> None:
|
||||
print("Running workflow (human approval required)...")
|
||||
completed = await run_interactive_session(workflow, initial_message=brief)
|
||||
if completed:
|
||||
print(f"Initial run completed with final copy: {completed.data}")
|
||||
print(f"Initial run completed with final copy: {completed}")
|
||||
else:
|
||||
print("Initial run paused for human input.")
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from agent_framework import (
|
||||
RequestInfoExecutor,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -49,6 +48,7 @@ What you learn:
|
||||
- How to list and inspect checkpoints programmatically.
|
||||
- How to interactively choose a checkpoint to resume from (instead of always resuming
|
||||
from the most recent or a hard-coded one) using run_stream_from_checkpoint.
|
||||
- How workflows complete by yielding outputs when idle, not via explicit completion events.
|
||||
|
||||
Prerequisites:
|
||||
- Azure AI or Azure OpenAI available for AzureChatClient.
|
||||
@@ -115,10 +115,10 @@ class SubmitToLowerAgent(Executor):
|
||||
|
||||
|
||||
class FinalizeFromAgent(Executor):
|
||||
"""Consumes the AgentExecutorResponse and emits the terminal WorkflowCompletedEvent."""
|
||||
"""Consumes the AgentExecutorResponse and yields the final result."""
|
||||
|
||||
@handler
|
||||
async def finalize(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any]) -> None:
|
||||
async def finalize(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any, str]) -> None:
|
||||
result = response.agent_run_response.text or ""
|
||||
|
||||
# Persist executor-local state for auditability when inspecting checkpoints.
|
||||
@@ -130,8 +130,8 @@ class FinalizeFromAgent(Executor):
|
||||
"final": True,
|
||||
})
|
||||
|
||||
# Emit a terminal event so external consumers see the final value.
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
# Yield the final result so external consumers see the final value.
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
class ReverseTextExecutor(Executor):
|
||||
@@ -185,6 +185,7 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> "Workflow":
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
"""Display human-friendly checkpoint metadata using framework summaries."""
|
||||
|
||||
@@ -297,7 +298,6 @@ async def main():
|
||||
Event: ExecutorInvokeEvent(executor_id=submit_lower)
|
||||
Event: ExecutorInvokeEvent(executor_id=lower_agent)
|
||||
Event: ExecutorInvokeEvent(executor_id=finalize)
|
||||
Event: WorkflowCompletedEvent(data=dlrow olleh)
|
||||
|
||||
Checkpoint summary:
|
||||
- dfc63e72-8e8d-454f-9b6d-0d740b9062e6 | label='after_initial_execution' | iter=0 | messages=1 | states=['upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
|
||||
@@ -316,7 +316,6 @@ async def main():
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=submit_lower)
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=lower_agent)
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=finalize)
|
||||
Resumed Event: WorkflowCompletedEvent(data=dlrow olleh)
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user