Skip to content

Commit 50d9b13

Browse files
authored
Python: [BREAKING] consolidate workflow run APIs (microsoft#1723)
* consolidate workflow run apis * improve validation, add tests * Proper code tags for docs * Update sample output * Remove cycle validation * PR feedback * Validation * Cleanup
1 parent b0ee702 commit 50d9b13

26 files changed

Lines changed: 720 additions & 475 deletions

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,13 @@ def from_dict(cls, payload: dict[str, Any]) -> "WorkflowAgent.RequestInfoFunctio
6060

6161
@classmethod
6262
def from_json(cls, raw: str) -> "WorkflowAgent.RequestInfoFunctionArgs":
63-
data = json.loads(raw)
64-
if not isinstance(data, dict):
63+
try:
64+
parsed: Any = json.loads(raw)
65+
except json.JSONDecodeError as exc:
66+
raise ValueError(f"RequestInfoFunctionArgs JSON payload is malformed: {exc}") from exc
67+
if not isinstance(parsed, dict):
6568
raise ValueError("RequestInfoFunctionArgs JSON payload must decode to a mapping")
66-
return cls.from_dict(data)
69+
return cls.from_dict(cast(dict[str, Any], parsed))
6770

6871
def __init__(
6972
self,

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

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -336,10 +336,15 @@ async def handle_agent_response(
336336

337337
if await self._check_termination():
338338
logger.info("Handoff workflow termination condition met. Ending conversation.")
339-
await ctx.yield_output(list(conversation))
339+
# Clean the output conversation for display
340+
cleaned_output = clean_conversation_for_handoff(conversation)
341+
await ctx.yield_output(cleaned_output)
340342
return
341343

342-
await ctx.send_message(list(conversation), target_id=self._input_gateway_id)
344+
# Clean conversation before sending to gateway for user input request
345+
# This removes tool messages that shouldn't be shown to users
346+
cleaned_for_display = clean_conversation_for_handoff(conversation)
347+
await ctx.send_message(cleaned_for_display, target_id=self._input_gateway_id)
343348

344349
@handler
345350
async def handle_user_input(
@@ -1274,12 +1279,12 @@ def build(self) -> Workflow:
12741279
updated_executor, tool_targets = self._prepare_agent_with_handoffs(executor, targets_map)
12751280
self._executors[source_exec_id] = updated_executor
12761281
handoff_tool_targets.update(tool_targets)
1277-
else:
1278-
# Default behavior: only coordinator gets handoff tools to all specialists
1279-
if isinstance(starting_executor, AgentExecutor) and specialists:
1280-
starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists)
1281-
self._executors[self._starting_agent_id] = starting_executor
1282-
handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications
1282+
else:
1283+
# Default behavior: only coordinator gets handoff tools to all specialists
1284+
if isinstance(starting_executor, AgentExecutor) and specialists:
1285+
starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists)
1286+
self._executors[self._starting_agent_id] = starting_executor
1287+
handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications
12831288
starting_executor = self._executors[self._starting_agent_id]
12841289
specialists = {
12851290
exec_id: executor for exec_id, executor in self._executors.items() if exec_id != self._starting_agent_id

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

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2442,16 +2442,6 @@ async def _validate_checkpoint_participants(
24422442
f"Missing names: {missing}; unexpected names: {unexpected}."
24432443
)
24442444

2445-
async def run_stream_from_checkpoint(
2446-
self,
2447-
checkpoint_id: str,
2448-
checkpoint_storage: CheckpointStorage | None = None,
2449-
) -> AsyncIterable[WorkflowEvent]:
2450-
"""Resume orchestration from a checkpoint and stream resulting events."""
2451-
await self._validate_checkpoint_participants(checkpoint_id, checkpoint_storage)
2452-
async for event in self._workflow.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage):
2453-
yield event
2454-
24552445
async def run_with_string(self, task_text: str) -> WorkflowRunResult:
24562446
"""Run the workflow with a task string and return all events.
24572447
@@ -2495,32 +2485,6 @@ async def run(self, message: Any | None = None) -> WorkflowRunResult:
24952485
events.append(event)
24962486
return WorkflowRunResult(events)
24972487

2498-
async def run_from_checkpoint(
2499-
self,
2500-
checkpoint_id: str,
2501-
checkpoint_storage: CheckpointStorage | None = None,
2502-
) -> WorkflowRunResult:
2503-
"""Resume orchestration from a checkpoint and collect all resulting events."""
2504-
events: list[WorkflowEvent] = []
2505-
async for event in self.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage):
2506-
events.append(event)
2507-
return WorkflowRunResult(events)
2508-
2509-
async def send_responses_streaming(self, responses: dict[str, Any]) -> AsyncIterable[WorkflowEvent]:
2510-
"""Forward responses to pending requests and stream resulting events.
2511-
2512-
This delegates to the underlying Workflow implementation.
2513-
"""
2514-
async for event in self._workflow.send_responses_streaming(responses):
2515-
yield event
2516-
2517-
async def send_responses(self, responses: dict[str, Any]) -> WorkflowRunResult:
2518-
"""Forward responses to pending requests and return all resulting events.
2519-
2520-
This delegates to the underlying Workflow implementation.
2521-
"""
2522-
return await self._workflow.send_responses(responses)
2523-
25242488
def __getattr__(self, name: str) -> Any:
25252489
"""Delegate unknown attributes to the underlying workflow."""
25262490
return getattr(self._workflow, name)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,12 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat
6363

6464
# Has tool content - only keep if it also has text
6565
if msg.text and msg.text.strip():
66-
# Create fresh text-only message
66+
# Create fresh text-only message while preserving additional_properties
6767
msg_copy = ChatMessage(
6868
role=msg.role,
6969
text=msg.text,
7070
author_name=msg.author_name,
71+
additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
7172
)
7273
cleaned.append(msg_copy)
7374

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

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,18 @@ def has_checkpointing(self) -> bool:
171171
"""
172172
...
173173

174+
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
175+
"""Set runtime checkpoint storage to override build-time configuration.
176+
177+
Args:
178+
storage: The checkpoint storage to use for this run.
179+
"""
180+
...
181+
182+
def clear_runtime_checkpoint_storage(self) -> None:
183+
"""Clear runtime checkpoint storage override."""
184+
...
185+
174186
# Checkpointing APIs (optional, enabled by storage)
175187
def set_workflow_id(self, workflow_id: str) -> None:
176188
"""Set the workflow ID for the context."""
@@ -279,6 +291,7 @@ def __init__(self, checkpoint_storage: CheckpointStorage | None = None):
279291

280292
# Checkpointing configuration/state
281293
self._checkpoint_storage = checkpoint_storage
294+
self._runtime_checkpoint_storage: CheckpointStorage | None = None
282295
self._workflow_id: str | None = None
283296

284297
# Streaming flag - set by workflow's run_stream() vs run()
@@ -329,16 +342,37 @@ async def next_event(self) -> WorkflowEvent:
329342

330343
# region Checkpointing
331344

345+
def _get_effective_checkpoint_storage(self) -> CheckpointStorage | None:
346+
"""Get the effective checkpoint storage (runtime override or build-time)."""
347+
return self._runtime_checkpoint_storage or self._checkpoint_storage
348+
349+
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
350+
"""Set runtime checkpoint storage to override build-time configuration.
351+
352+
Args:
353+
storage: The checkpoint storage to use for this run.
354+
"""
355+
self._runtime_checkpoint_storage = storage
356+
357+
def clear_runtime_checkpoint_storage(self) -> None:
358+
"""Clear runtime checkpoint storage override.
359+
360+
This is called automatically by workflow execution methods after a run completes,
361+
ensuring runtime storage doesn't leak across runs.
362+
"""
363+
self._runtime_checkpoint_storage = None
364+
332365
def has_checkpointing(self) -> bool:
333-
return self._checkpoint_storage is not None
366+
return self._get_effective_checkpoint_storage() is not None
334367

335368
async def create_checkpoint(
336369
self,
337370
shared_state: SharedState,
338371
iteration_count: int,
339372
metadata: dict[str, Any] | None = None,
340373
) -> str:
341-
if not self._checkpoint_storage:
374+
storage = self._get_effective_checkpoint_storage()
375+
if not storage:
342376
raise ValueError("Checkpoint storage not configured")
343377

344378
self._workflow_id = self._workflow_id or str(uuid.uuid4())
@@ -352,19 +386,21 @@ async def create_checkpoint(
352386
iteration_count=state["iteration_count"],
353387
metadata=metadata or {},
354388
)
355-
checkpoint_id = await self._checkpoint_storage.save_checkpoint(checkpoint)
389+
checkpoint_id = await storage.save_checkpoint(checkpoint)
356390
logger.info(f"Created checkpoint {checkpoint_id} for workflow {self._workflow_id}")
357391
return checkpoint_id
358392

359393
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
360-
if not self._checkpoint_storage:
394+
storage = self._get_effective_checkpoint_storage()
395+
if not storage:
361396
raise ValueError("Checkpoint storage not configured")
362-
return await self._checkpoint_storage.load_checkpoint(checkpoint_id)
397+
return await storage.load_checkpoint(checkpoint_id)
363398

364399
def reset_for_new_run(self) -> None:
365400
"""Reset the context for a new workflow run.
366401
367402
This clears messages, events, and resets streaming flag.
403+
Runtime checkpoint storage is NOT cleared here as it's managed at the workflow level.
368404
"""
369405
self._messages.clear()
370406
# Clear any pending events (best-effort) by recreating the queue

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

Lines changed: 0 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@
1212

1313
logger = logging.getLogger(__name__)
1414

15-
# Track cycle signatures we've already reported to avoid spamming logs when workflows
16-
# with intentional feedback loops are constructed multiple times in the same process.
17-
_LOGGED_CYCLE_SIGNATURES: set[tuple[str, ...]] = set()
18-
1915

2016
# region Enums and Base Classes
2117
class ValidationTypeEnum(Enum):
@@ -168,7 +164,6 @@ def validate_workflow(
168164
self._validate_graph_connectivity(start_executor_id)
169165
self._validate_self_loops()
170166
self._validate_dead_ends()
171-
self._validate_cycles()
172167

173168
def _validate_handler_output_annotations(self) -> None:
174169
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
@@ -394,96 +389,6 @@ def _validate_dead_ends(self) -> None:
394389
f"Verify these are intended as final nodes in the workflow."
395390
)
396391

397-
def _validate_cycles(self) -> None:
398-
"""Detect cycles in the workflow graph.
399-
400-
Cycles might be intentional for iterative processing but should be flagged
401-
for review to ensure proper termination conditions exist. We surface each
402-
distinct cycle group only once per process to avoid noisy, repeated warnings
403-
when rebuilding the same workflow.
404-
"""
405-
# Build adjacency list (ensure every executor appears even if it has no outgoing edges)
406-
graph: dict[str, list[str]] = defaultdict(list)
407-
for edge in self._edges:
408-
graph[edge.source_id].append(edge.target_id)
409-
graph.setdefault(edge.target_id, [])
410-
for executor_id in self._executors:
411-
graph.setdefault(executor_id, [])
412-
413-
# Tarjan's algorithm to locate strongly-connected components that form cycles
414-
index: dict[str, int] = {}
415-
lowlink: dict[str, int] = {}
416-
on_stack: set[str] = set()
417-
stack: list[str] = []
418-
current_index = 0
419-
cycle_components: list[list[str]] = []
420-
421-
def strongconnect(node: str) -> None:
422-
nonlocal current_index
423-
424-
index[node] = current_index
425-
lowlink[node] = current_index
426-
current_index += 1
427-
stack.append(node)
428-
on_stack.add(node)
429-
430-
for neighbor in graph[node]:
431-
if neighbor not in index:
432-
strongconnect(neighbor)
433-
lowlink[node] = min(lowlink[node], lowlink[neighbor])
434-
elif neighbor in on_stack:
435-
lowlink[node] = min(lowlink[node], index[neighbor])
436-
437-
if lowlink[node] == index[node]:
438-
component: list[str] = []
439-
while True:
440-
member = stack.pop()
441-
on_stack.discard(member)
442-
component.append(member)
443-
if member == node:
444-
break
445-
446-
# A strongly connected component represents a cycle if it has more than one
447-
# node or if a single node references itself directly.
448-
if len(component) > 1 or any(member in graph[member] for member in component):
449-
cycle_components.append(component)
450-
451-
for executor_id in graph:
452-
if executor_id not in index:
453-
strongconnect(executor_id)
454-
455-
if not cycle_components:
456-
return
457-
458-
unseen_components: list[list[str]] = []
459-
for component in cycle_components:
460-
signature = tuple(sorted(component))
461-
if signature in _LOGGED_CYCLE_SIGNATURES:
462-
continue
463-
_LOGGED_CYCLE_SIGNATURES.add(signature)
464-
unseen_components.append(component)
465-
466-
if not unseen_components:
467-
# All cycles already reported in this process; keep noise low but retain traceability.
468-
logger.debug(
469-
"Cycle detected in workflow graph but previously reported. Components: %s",
470-
[sorted(component) for component in cycle_components],
471-
)
472-
return
473-
474-
def _format_cycle(component: list[str]) -> str:
475-
if not component:
476-
return ""
477-
ordered = list(component)
478-
ordered.append(component[0])
479-
return " -> ".join(ordered)
480-
481-
formatted_cycles = ", ".join(_format_cycle(component) for component in unseen_components)
482-
logger.warning(
483-
"Cycle detected in the workflow graph involving: %s. Ensure termination or iteration limits exist.",
484-
formatted_cycles,
485-
)
486-
487392
# endregion
488393

489394

0 commit comments

Comments
 (0)