Python: [Breaking] Remove Python-only declarative actions and rename alias kinds to C# canonical names (#6126)

* Remove Python-only declarative actions and rename alias kinds to C# canonical names

* Address PR comments.

* Address PR comments.

* Reduce verbose and duplicate output from sample workflow.
This commit is contained in:
Peter Ibekwe
2026-05-28 03:16:22 -07:00
committed by GitHub
Unverified
parent 55dc3ce734
commit ded17b178c
15 changed files with 577 additions and 771 deletions
@@ -38,10 +38,8 @@ from ._executors_agents import (
)
from ._executors_basic import (
BASIC_ACTION_EXECUTORS,
AppendValueExecutor,
ClearAllVariablesExecutor,
CreateConversationExecutor,
EmitEventExecutor,
ResetVariableExecutor,
SendActivityExecutor,
SetMultipleVariablesExecutor,
@@ -61,12 +59,10 @@ from ._executors_control_flow import (
)
from ._executors_external_input import (
EXTERNAL_INPUT_EXECUTORS,
ConfirmationExecutor,
ExternalInputRequest,
ExternalInputResponse,
QuestionExecutor,
RequestExternalInputExecutor,
WaitForInputExecutor,
)
from ._executors_http import (
HTTP_ACTION_EXECUTORS,
@@ -122,11 +118,9 @@ __all__ = [
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentResult",
"AppendValueExecutor",
"BaseToolExecutor",
"BreakLoopExecutor",
"ClearAllVariablesExecutor",
"ConfirmationExecutor",
"ContinueLoopExecutor",
"ConversationData",
"CreateConversationExecutor",
@@ -139,7 +133,6 @@ __all__ = [
"DeclarativeWorkflowState",
"DefaultHttpRequestHandler",
"DefaultMCPToolHandler",
"EmitEventExecutor",
"EndConversationExecutor",
"EndWorkflowExecutor",
"ExternalInputRequest",
@@ -173,7 +166,6 @@ __all__ = [
"ToolApprovalResponse",
"ToolApprovalState",
"ToolInvocationResult",
"WaitForInputExecutor",
"WorkflowFactory",
"WorkflowState",
]
@@ -915,9 +915,9 @@ class ActionComplete:
@dataclass
class ConditionResult:
"""Result of evaluating a condition (If/Switch).
"""Result of evaluating a condition (If/ConditionGroup).
This message is output by ConditionEvaluatorExecutor and SwitchEvaluatorExecutor
This message is output by ConditionEvaluatorExecutor and ConditionGroupEvaluatorExecutor
to indicate which branch should be taken.
"""
@@ -7,7 +7,7 @@ This module provides the DeclarativeWorkflowBuilder which is analogous to
action definitions and creates a proper workflow graph with:
- Executor nodes for each action
- Edges for sequential flow
- Condition evaluator executors for If/Switch that ensure first-match semantics
- Condition evaluator executors for If/ConditionGroup that ensure first-match semantics
- Loop edges for foreach
"""
@@ -38,7 +38,6 @@ from ._executors_control_flow import (
ForeachNextExecutor,
IfConditionEvaluatorExecutor,
JoinExecutor,
SwitchEvaluatorExecutor,
)
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
from ._executors_http import HTTP_ACTION_EXECUTORS, HttpRequestActionExecutor
@@ -64,7 +63,6 @@ ALL_ACTION_EXECUTORS = {
# Action kinds that terminate control flow (no fall-through to successor)
# These actions transfer control elsewhere and should not have sequential edges to the next action
TERMINATOR_ACTIONS = frozenset({
"Goto",
"GotoAction",
"BreakLoop",
"ContinueLoop",
@@ -80,18 +78,16 @@ TERMINATOR_ACTIONS = frozenset({
ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
"SetValue": ["path"],
"SetVariable": ["variable"],
"AppendValue": ["path", "value"],
"SendActivity": ["activity"],
"InvokeAzureAgent": ["agent"],
"Goto": ["target"],
"GotoAction": ["actionId"],
"Foreach": ["items", "actions"],
"Foreach": ["source", "actions"],
"If": ["condition"],
"Switch": ["value"], # Switch can use value/cases or conditions (ConditionGroup style)
"ConditionGroup": ["conditions"],
"Question": ["question", "variable"],
"RequestExternalInput": ["prompt", "variable"],
"RequestHumanInput": ["variable"],
"WaitForHumanInput": ["variable"],
"EmitEvent": ["event"],
"InvokeFunctionTool": ["functionName"],
"HttpRequestAction": ["url"],
"InvokeMcpTool": ["serverUrl", "toolName"],
@@ -101,11 +97,14 @@ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
# Key: "ActionKind.field", Value: list of alternates that satisfy the requirement
ACTION_ALTERNATE_FIELDS: dict[str, list[str]] = {
"SetValue.path": ["variable"],
"Goto.target": ["actionId"],
"GotoAction.actionId": ["target"],
"InvokeAzureAgent.agent": ["agentName"],
"Foreach.items": ["itemsSource", "source"], # source is used in some schemas
"Switch.value": ["conditions"], # Switch can be condition-based instead of value-based
# Top-level alternates that satisfy the nested-shape requirements without forcing
# callers to spell every field in its long form.
"Question.question": ["text"],
"Question.variable": ["property"],
"RequestExternalInput.prompt": ["message"],
"RequestExternalInput.variable": ["property"],
}
@@ -115,9 +114,9 @@ class DeclarativeWorkflowBuilder:
This builder transforms declarative action definitions into a proper
workflow graph with executor nodes and edges. It handles:
- Sequential actions (simple edges)
- Conditional branching (If/Switch with condition edges)
- Conditional branching (If/ConditionGroup with condition edges)
- Loops (Foreach with loop edges)
- Jumps (Goto with target edges)
- Jumps (GotoAction with target edges)
Example usage:
yaml_def = {
@@ -299,7 +298,7 @@ class DeclarativeWorkflowBuilder:
raise ValueError(f"Action '{kind}' is missing required field '{field}'. Action: {action_def}")
# Collect goto targets for circular reference detection
if kind in ("Goto", "GotoAction"):
if kind == "GotoAction":
target = action_def.get("target") or action_def.get("actionId")
if target:
goto_targets.append((target, explicit_id))
@@ -313,13 +312,19 @@ class DeclarativeWorkflowBuilder:
if else_actions:
self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids)
elif kind in ("Switch", "ConditionGroup"):
cases = action_def.get("cases", action_def.get("conditions", []))
for case in cases:
case_actions = case.get("actions", [])
if case_actions:
self._validate_actions_recursive(case_actions, seen_ids, goto_targets, defined_ids)
else_actions = action_def.get("elseActions", action_def.get("else", action_def.get("default", [])))
elif kind == "ConditionGroup":
for forbidden in ("else", "default"):
if forbidden in action_def:
raise ValueError(
f"Action 'ConditionGroup' field '{forbidden}' is not supported; "
"use 'elseActions' instead."
)
conditions = action_def.get("conditions", [])
for condition_branch in conditions:
branch_actions = condition_branch.get("actions", [])
if branch_actions:
self._validate_actions_recursive(branch_actions, seen_ids, goto_targets, defined_ids)
else_actions = action_def.get("elseActions", [])
if else_actions:
self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids)
@@ -362,7 +367,8 @@ class DeclarativeWorkflowBuilder:
# Check for direct self-reference
if source_id and target_id == source_id:
raise ValueError(
f"Action '{source_id}' has a direct self-referencing Goto, which would cause an infinite loop."
f"Action '{source_id}' has a direct self-referencing GotoAction, "
"which would cause an infinite loop."
)
def _resolve_pending_gotos(self, builder: WorkflowBuilder) -> None:
@@ -380,7 +386,7 @@ class DeclarativeWorkflowBuilder:
builder.add_edge(source=goto_executor, target=target_executor)
else:
available_ids = list(self._executors.keys())
raise ValueError(f"Goto target '{target_id}' not found. Available action IDs: {available_ids}")
raise ValueError(f"GotoAction target '{target_id}' not found. Available action IDs: {available_ids}")
def _create_executors_for_actions(
self,
@@ -453,11 +459,11 @@ class DeclarativeWorkflowBuilder:
# Handle special control flow actions
if kind == "If":
return self._create_if_structure(action_def, builder, parent_context)
if kind == "Switch" or kind == "ConditionGroup":
return self._create_switch_structure(action_def, builder, parent_context)
if kind == "ConditionGroup":
return self._create_condition_group_structure(action_def, builder, parent_context)
if kind == "Foreach":
return self._create_foreach_structure(action_def, builder, parent_context)
if kind == "Goto" or kind == "GotoAction":
if kind == "GotoAction":
return self._create_goto_reference(action_def, builder, parent_context)
if kind == "BreakLoop":
return self._create_break_executor(action_def, builder, parent_context)
@@ -588,7 +594,7 @@ class DeclarativeWorkflowBuilder:
# Wire evaluator to branches with conditions that check ConditionResult.branch_index
# branch_index=0 means "then" branch, branch_index=-1 (ELSE_BRANCH_INDEX) means "else"
# For nested If/Switch structures, wire to the evaluator (entry point)
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
if then_entry:
then_target = self._get_structure_entry(then_entry)
builder.add_edge(
@@ -634,66 +640,42 @@ class DeclarativeWorkflowBuilder:
return IfStructure()
def _create_switch_structure(
def _create_condition_group_structure(
self,
action_def: dict[str, Any],
builder: WorkflowBuilder,
parent_context: dict[str, Any] | None = None,
) -> Any:
"""Create the graph structure for a Switch/ConditionGroup action.
"""Create the graph structure for a ConditionGroup action.
Supports two schema formats:
1. ConditionGroup schema (matches .NET):
- conditions: list of {condition: expr, actions: [...]}
- elseActions: default actions
2. Switch schema (interpreter style):
- value: expression to match
- cases: list of {match: value, actions: [...]}
- default: default actions
Both use evaluator executors that output ConditionResult with branch_index
for first-match semantics.
Evaluates the action's ``conditions`` in order; the first match
selects its ``actions`` branch. If none match, ``elseActions`` runs.
The structure exposes an evaluator entry point and the per-branch
entry/exit pairs used by the caller to wire downstream edges.
Args:
action_def: The Switch/ConditionGroup action definition
action_def: The ConditionGroup action definition
builder: The workflow builder
parent_context: Context from parent
Returns:
A SwitchStructure containing branch info for wiring
A ConditionGroupStructure containing branch info for wiring
"""
action_id = action_def.get("id") or f"Switch_{self._action_index}"
action_id = action_def.get("id") or f"ConditionGroup_{self._action_index}"
self._action_index += 1
# Pass the Switch's ID as context for child action naming
# Pass the ConditionGroup's ID as context for child action naming
branch_context = {
**(parent_context or {}),
"parent_id": action_id,
}
# Detect schema type:
# - If "cases" present: interpreter Switch schema (value/cases/default)
# - If "conditions" present: ConditionGroup schema (conditions/elseActions)
cases = action_def.get("cases", [])
conditions = action_def.get("conditions", [])
if cases:
# Interpreter Switch schema: value/cases/default
evaluator: DeclarativeActionExecutor = SwitchEvaluatorExecutor(
action_def,
cases,
id=f"{action_id}_eval",
)
branch_items = cases
else:
# ConditionGroup schema: conditions/elseActions
evaluator = ConditionGroupEvaluatorExecutor(
action_def,
conditions,
id=f"{action_id}_eval",
)
branch_items = conditions
evaluator: DeclarativeActionExecutor = ConditionGroupEvaluatorExecutor(
action_def,
conditions,
id=f"{action_id}_eval",
)
self._executors[evaluator.id] = evaluator
@@ -701,7 +683,7 @@ class DeclarativeWorkflowBuilder:
branch_entries: list[tuple[int, Any]] = [] # (branch_index, entry_executor)
branch_exits: list[Any] = [] # All exits that need wiring to successor
for i, item in enumerate(branch_items):
for i, item in enumerate(conditions):
branch_actions = item.get("actions", [])
# Use branch-specific context
case_context = {**branch_context, "parent_id": f"{action_id}_case{i}"}
@@ -714,9 +696,7 @@ class DeclarativeWorkflowBuilder:
if branch_exit:
branch_exits.append(branch_exit)
# Handle else/default branch
# .NET uses "elseActions", interpreter uses "else" or "default"
else_actions = action_def.get("elseActions", action_def.get("else", action_def.get("default", [])))
else_actions = action_def.get("elseActions", [])
default_entry = None
default_passthrough = None
if else_actions:
@@ -734,7 +714,7 @@ class DeclarativeWorkflowBuilder:
branch_exits.append(default_passthrough)
# Wire evaluator to branches with conditions that check ConditionResult.branch_index
# For nested If/Switch structures, wire to the evaluator (entry point)
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
for branch_index, branch_entry in branch_entries:
# Capture branch_index in closure properly using a factory function for type inference
def make_branch_condition(expected: int) -> Any:
@@ -762,8 +742,8 @@ class DeclarativeWorkflowBuilder:
condition=lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX,
)
# Create a SwitchStructure to hold all the info needed for wiring
class SwitchStructure:
# Create a ConditionGroupStructure to hold all the info needed for wiring
class ConditionGroupStructure:
def __init__(self) -> None:
self.id = action_id
self.evaluator = evaluator # The entry point for this structure
@@ -771,9 +751,9 @@ class DeclarativeWorkflowBuilder:
self.default_entry = default_entry
self.default_passthrough = default_passthrough
self.branch_exits = branch_exits # All exits that need wiring to successor
self._is_switch_structure = True
self._is_condition_group_structure = True
return SwitchStructure()
return ConditionGroupStructure()
def _create_foreach_structure(
self,
@@ -823,7 +803,7 @@ class DeclarativeWorkflowBuilder:
body_entry = self._create_executors_for_actions(body_actions, builder, loop_context)
if body_entry:
# For nested If/Switch structures, wire to the evaluator (entry point)
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
body_target = self._get_structure_entry(body_entry)
# Init -> body (when has_next=True)
@@ -835,7 +815,7 @@ class DeclarativeWorkflowBuilder:
# Wire from the LAST body action so the loop only advances after the
# whole body completes. _get_branch_exit walks the chain, skips
# terminators (Break/Continue), and returns nested If/Switch
# terminators (Break/Continue), and returns nested If/ConditionGroup
# structures so _get_source_exits can flatten their branch exits.
body_exit = self._get_branch_exit(body_entry)
if body_exit is not None:
@@ -963,8 +943,8 @@ class DeclarativeWorkflowBuilder:
"""Add a sequential edge between two executors.
Handles control flow structures:
- If source is a structure (If/Switch), wire from all branch exits
- If target is a structure (If/Switch), wire with conditional edges to branches
- If source is a structure (If/ConditionGroup), wire from all branch exits
- If target is a structure (If/ConditionGroup), wire with conditional edges to branches
"""
# Get all source exit points
source_exits = self._get_source_exits(source)
@@ -999,12 +979,12 @@ class DeclarativeWorkflowBuilder:
) -> None:
"""Wire a single source executor to a target (which may be a structure).
For If/Switch structures, wire to the evaluator executor. The evaluator
For If/ConditionGroup structures, wire to the evaluator executor. The evaluator
handles condition evaluation and outputs ConditionResult, which is then
routed to the appropriate branch by edges created in _create_*_structure.
"""
# Check if target is an IfStructure or SwitchStructure (wire to evaluator)
if getattr(target, "_is_if_structure", False) or getattr(target, "_is_switch_structure", False):
# Check if target is an IfStructure or ConditionGroupStructure (wire to evaluator)
if getattr(target, "_is_if_structure", False) or getattr(target, "_is_condition_group_structure", False):
# Wire from source to the evaluator - the evaluator then routes to branches
builder.add_edge(source=source, target=target.evaluator)
@@ -1015,7 +995,7 @@ class DeclarativeWorkflowBuilder:
def _get_structure_entry(self, entry: Any) -> Any:
"""Get the entry point executor for a structure or regular executor.
For If/Switch structures, returns the evaluator. For regular executors,
For If/ConditionGroup structures, returns the evaluator. For regular executors,
returns the executor itself.
Args:
@@ -1024,14 +1004,16 @@ class DeclarativeWorkflowBuilder:
Returns:
The entry point executor
"""
is_structure = getattr(entry, "_is_if_structure", False) or getattr(entry, "_is_switch_structure", False)
is_structure = getattr(entry, "_is_if_structure", False) or getattr(
entry, "_is_condition_group_structure", False
)
return entry.evaluator if is_structure else entry
def _get_branch_exit(self, branch_entry: Any) -> Any | None:
"""Get the exit point of a branch for downstream wiring.
Returns the last executor (or its ``_exit_executor``) for a linear chain,
the nested If/Switch structure itself when the chain ends in one (so
the nested If/ConditionGroup structure itself when the chain ends in one (so
callers can flatten ``branch_exits`` via :meth:`_get_source_exits`), or
``None`` when the branch is empty or ends in a terminator action.
"""
@@ -179,28 +179,6 @@ class SetMultipleVariablesExecutor(DeclarativeActionExecutor):
await ctx.send_message(ActionComplete())
class AppendValueExecutor(DeclarativeActionExecutor):
"""Executor for the AppendValue action."""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the AppendValue action."""
state = await self._ensure_state_initialized(ctx, trigger)
path = self._action_def.get("path")
value = self._action_def.get("value")
if path:
evaluated_value = state.eval_if_expression(value)
state.append(path, evaluated_value)
await ctx.send_message(ActionComplete())
class ResetVariableExecutor(DeclarativeActionExecutor):
"""Executor for the ResetVariable action."""
@@ -279,47 +257,6 @@ class SendActivityExecutor(DeclarativeActionExecutor):
await ctx.send_message(ActionComplete())
class EmitEventExecutor(DeclarativeActionExecutor):
"""Executor for the EmitEvent action.
Emits a custom event to the workflow event stream.
Supports two schema formats:
1. Graph mode: eventName, eventValue
2. Interpreter mode: event.name, event.data
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete, dict[str, Any]],
) -> None:
"""Handle the EmitEvent action."""
state = await self._ensure_state_initialized(ctx, trigger)
# Support both schema formats:
# - Graph mode: eventName, eventValue
# - Interpreter mode: event.name, event.data
event_def = self._action_def.get("event", {})
event_name = self._action_def.get("eventName") or event_def.get("name", "")
event_value = self._action_def.get("eventValue")
if event_value is None:
event_value = event_def.get("data")
if event_name:
evaluated_name = state.eval_if_expression(event_name)
evaluated_value = state.eval_if_expression(event_value)
event_data = {
"eventName": evaluated_name,
"eventValue": evaluated_value,
}
await ctx.yield_output(event_data)
await ctx.send_message(ActionComplete())
class EditTableExecutor(DeclarativeActionExecutor):
"""Executor for the EditTable action.
@@ -628,11 +565,9 @@ BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"SetVariable": SetVariableExecutor,
"SetTextVariable": SetTextVariableExecutor,
"SetMultipleVariables": SetMultipleVariablesExecutor,
"AppendValue": AppendValueExecutor,
"ResetVariable": ResetVariableExecutor,
"ClearAllVariables": ClearAllVariablesExecutor,
"SendActivity": SendActivityExecutor,
"EmitEvent": EmitEventExecutor,
"ParseValue": ParseValueExecutor,
"EditTable": EditTableExecutor,
"EditTableV2": EditTableV2Executor,
@@ -3,7 +3,7 @@
"""Control flow executors for the graph-based declarative workflow system.
Control flow in the graph-based system is handled differently than the interpreter:
- If/Switch: Condition evaluation happens in a dedicated evaluator executor that
- If/ConditionGroup: Condition evaluation happens in a dedicated evaluator executor that
returns a ConditionResult with the first-matching branch index. Edge conditions
then check the branch_index to route to the correct branch. This ensures only
one branch executes (first-match semantics), matching the interpreter behavior.
@@ -39,7 +39,7 @@ ELSE_BRANCH_INDEX = -1
class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
"""Evaluates conditions for ConditionGroup/Switch and outputs the first-matching branch.
"""Evaluates conditions for ConditionGroup and outputs the first-matching branch.
This executor implements first-match semantics by evaluating conditions sequentially
and outputting a ConditionResult with the index of the first matching branch.
@@ -59,7 +59,7 @@ class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
"""Initialize the condition evaluator.
Args:
action_def: The ConditionGroup/Switch action definition
action_def: The ConditionGroup action definition
conditions: List of condition items, each with 'condition' and optional 'id'
id: Optional executor ID
"""
@@ -99,71 +99,6 @@ class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
class SwitchEvaluatorExecutor(DeclarativeActionExecutor):
"""Evaluates a Switch action by matching a value against cases.
The Switch action uses a different schema than ConditionGroup:
- value: expression to evaluate once
- cases: list of {match: value_to_match, actions: [...]}
- default: default actions if no case matches
This evaluator evaluates the value expression once, then compares it
against each case's match value sequentially. First match wins.
"""
def __init__(
self,
action_def: dict[str, Any],
cases: list[dict[str, Any]],
*,
id: str | None = None,
):
"""Initialize the switch evaluator.
Args:
action_def: The Switch action definition (contains 'value' expression)
cases: List of case items, each with 'match' and optional 'actions'
id: Optional executor ID
"""
super().__init__(action_def, id=id)
self._cases = cases
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ConditionResult],
) -> None:
"""Evaluate the switch value and find the first matching case."""
state = await self._ensure_state_initialized(ctx, trigger)
value_expr = self._action_def.get("value")
if not value_expr:
# No value to switch on - use default
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
return
# Evaluate the switch value once
switch_value = state.eval_if_expression(value_expr)
# Compare against each case's match value
for index, case_item in enumerate(self._cases):
match_expr = case_item.get("match")
if match_expr is None:
continue
# Evaluate the match value
match_value = state.eval_if_expression(match_expr)
if switch_value == match_value:
# Found matching case
await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=switch_value))
return
# No case matched - use default branch
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
class IfConditionEvaluatorExecutor(DeclarativeActionExecutor):
"""Evaluates a single If condition and outputs a ConditionResult.
@@ -221,12 +156,7 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
"""Initialize the loop and check for first item."""
state = await self._ensure_state_initialized(ctx, trigger)
# Support multiple schema formats:
# - Graph mode: itemsSource, items
# - Interpreter mode: source
items_expr = (
self._action_def.get("itemsSource") or self._action_def.get("items") or self._action_def.get("source")
)
items_expr = self._action_def.get("source")
items_raw: Any = state.eval_if_expression(items_expr) or []
items: list[Any]
@@ -244,25 +174,12 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
}
state.set_state_data(state_data)
# Check if we have items
if items:
# Set the iteration variable
# Support multiple schema formats:
# - Graph mode: iteratorVariable, item (default "Local.item")
# - Interpreter mode: itemName (default "item", stored in Local scope)
item_var = self._action_def.get("iteratorVariable") or self._action_def.get("item")
if not item_var:
# Interpreter mode: itemName defaults to "item", store in Local scope
item_name = self._action_def.get("itemName", "item")
item_var = f"Local.{item_name}"
# Support multiple schema formats for index:
# - Graph mode: indexVariable, index
# - Interpreter mode: indexName (default "index", stored in Local scope)
index_var = self._action_def.get("indexVariable") or self._action_def.get("index")
if not index_var and "indexName" in self._action_def:
index_name = self._action_def.get("indexName", "index")
index_var = f"Local.{index_name}"
# Bind the current item and (when requested) the index under the Local scope.
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
index_var = (
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
)
state.set(item_var, items[0])
if index_var:
@@ -325,23 +242,11 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
loop_state["index"] = current_index
state.set_state_data(state_data)
# Set the iteration variable
# Support multiple schema formats:
# - Graph mode: iteratorVariable, item (default "Local.item")
# - Interpreter mode: itemName (default "item", stored in Local scope)
item_var = self._action_def.get("iteratorVariable") or self._action_def.get("item")
if not item_var:
# Interpreter mode: itemName defaults to "item", store in Local scope
item_name = self._action_def.get("itemName", "item")
item_var = f"Local.{item_name}"
# Support multiple schema formats for index:
# - Graph mode: indexVariable, index
# - Interpreter mode: indexName (default "index", stored in Local scope)
index_var = self._action_def.get("indexVariable") or self._action_def.get("index")
if not index_var and "indexName" in self._action_def:
index_name = self._action_def.get("indexName", "index")
index_var = f"Local.{index_name}"
# Rebind the current item and (when requested) the index under the Local scope.
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
index_var = (
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
)
state.set(item_var, items[current_index])
if index_var:
@@ -486,7 +391,7 @@ class EndConversationExecutor(DeclarativeActionExecutor):
class JoinExecutor(DeclarativeActionExecutor):
"""Executor that joins multiple branches back together.
Used after If/Switch to merge control flow back to a single path.
Used after If/ConditionGroup to merge control flow back to a single path.
Also used as passthrough nodes for else/default branches.
"""
@@ -2,14 +2,14 @@
"""External input executors for declarative workflows.
These executors handle interactions that require external input (user questions,
confirmations, etc.), using the request_info pattern to pause the workflow and
wait for responses.
These executors handle interactions that require external input (user questions
and external integrations), using the request_info pattern to pause the workflow
and wait for responses.
"""
import uuid
from dataclasses import dataclass, field
from typing import Any
from typing import Any, cast
from agent_framework import (
WorkflowContext,
@@ -23,18 +23,49 @@ from ._declarative_base import (
)
def _get_prompt_text(action_def: dict[str, Any], primary_key: str, fallback_key: str) -> Any:
"""Return the prompt text from an action definition.
Accepts a nested ``{primary_key: {"text": ...}}`` mapping, a bare
string under ``primary_key``, or a top-level ``fallback_key`` value.
"""
match action_def.get(primary_key):
case {"text": text}:
return text
case str() as text:
return text
case _:
return action_def.get(fallback_key, "")
def _get_output_path(action_def: dict[str, Any], default: str) -> str:
"""Return the state path where the action result should be written.
Looks at ``variable``, then ``output.property``, then top-level
``property``, falling back to ``default``.
"""
output = action_def.get("output")
nested = cast(dict[str, Any], output).get("property") if isinstance(output, dict) else None
return action_def.get("variable") or nested or action_def.get("property") or default
@dataclass
class ExternalInputRequest:
"""Request for external input (triggers workflow pause).
Aligns with .NET ExternalInputRequest pattern. Used by Question, Confirmation,
WaitForInput, and RequestExternalInput executors to signal that user input is
needed. The workflow will pause via request_info and wait for an ExternalInputResponse.
Aligns with .NET ExternalInputRequest pattern. Used by Question and
RequestExternalInput executors to signal that user input is needed.
The workflow will pause via request_info and wait for an ExternalInputResponse.
Attributes:
request_id: Unique identifier for this request.
message: The prompt or question to display to the user.
request_type: Type of input requested (question, confirmation, user_input, external).
request_type: A free-form discriminator describing the kind of input
being requested. ``QuestionExecutor`` emits ``"question"`` and
``RequestExternalInputExecutor`` defaults to ``"external"``; callers
may supply any other string via the ``requestType`` field on a
``RequestExternalInput`` action (e.g. ``"approval"``) and it is
propagated unchanged.
metadata: Additional context (choices, output_property, timeout, etc.).
"""
@@ -75,15 +106,12 @@ class QuestionExecutor(DeclarativeActionExecutor):
"""Ask the question and wait for a response."""
state = await self._ensure_state_initialized(ctx, trigger)
question_text = self._action_def.get("text") or self._action_def.get("question", "")
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
"property", "Local.answer"
)
question_text = _get_prompt_text(self._action_def, primary_key="question", fallback_key="text")
output_property = _get_output_path(self._action_def, default="Local.answer")
default_value = self._action_def.get("default", self._action_def.get("defaultValue"))
choices = self._action_def.get("choices", [])
default_value = self._action_def.get("defaultValue")
allow_free_text = self._action_def.get("allowFreeText", True)
# Evaluate the question text if it's an expression
evaluated_question = state.eval_if_expression(question_text)
# Build choices metadata
@@ -139,133 +167,6 @@ class QuestionExecutor(DeclarativeActionExecutor):
await ctx.send_message(ActionComplete())
class ConfirmationExecutor(DeclarativeActionExecutor):
"""Executor that asks for a yes/no confirmation.
A specialized version of Question that expects a boolean response.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Ask for confirmation."""
state = await self._ensure_state_initialized(ctx, trigger)
message = self._action_def.get("text") or self._action_def.get("message", "")
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
"property", "Local.confirmed"
)
yes_label = self._action_def.get("yesLabel", "Yes")
no_label = self._action_def.get("noLabel", "No")
default_value = self._action_def.get("defaultValue", False)
# Evaluate the message if it's an expression
evaluated_message = state.eval_if_expression(message)
# Request confirmation - workflow pauses here
await ctx.request_info(
ExternalInputRequest(
request_id=str(uuid.uuid4()),
message=str(evaluated_message),
request_type="confirmation",
metadata={
"output_property": output_property,
"yes_label": yes_label,
"no_label": no_label,
"default_value": default_value,
},
),
ExternalInputResponse,
)
@response_handler
async def handle_response(
self,
original_request: ExternalInputRequest,
response: ExternalInputResponse,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the user's confirmation response."""
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.confirmed")
# Convert response to boolean
if response.value is not None:
confirmed = bool(response.value)
else:
# Interpret common affirmative responses
user_input_lower = response.user_input.lower().strip()
confirmed = user_input_lower in ("yes", "y", "true", "1", "confirm", "ok")
if output_property:
state.set(output_property, confirmed)
await ctx.send_message(ActionComplete())
class WaitForInputExecutor(DeclarativeActionExecutor):
"""Executor that waits for user input during a conversation.
Used when the workflow needs to pause and wait for the next user message
in a conversational flow.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Wait for user input."""
state = await self._ensure_state_initialized(ctx, trigger)
prompt = self._action_def.get("prompt")
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
"property", "Local.input"
)
timeout_seconds = self._action_def.get("timeout")
# Emit prompt if specified
if prompt:
evaluated_prompt = state.eval_if_expression(prompt)
await ctx.yield_output(str(evaluated_prompt))
# Request user input - workflow pauses here
await ctx.request_info(
ExternalInputRequest(
request_id=str(uuid.uuid4()),
message=str(prompt) if prompt else "Waiting for input...",
request_type="user_input",
metadata={
"output_property": output_property,
"timeout_seconds": timeout_seconds,
},
),
ExternalInputResponse,
)
@response_handler
async def handle_response(
self,
original_request: ExternalInputRequest,
response: ExternalInputResponse,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle the user's input."""
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.input")
if output_property:
state.set(output_property, response.user_input)
await ctx.send_message(ActionComplete())
class RequestExternalInputExecutor(DeclarativeActionExecutor):
"""Executor that requests external input/approval.
@@ -282,16 +183,15 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
"""Request external input."""
state = await self._ensure_state_initialized(ctx, trigger)
message = _get_prompt_text(self._action_def, primary_key="prompt", fallback_key="message")
output_property = _get_output_path(self._action_def, default="Local.externalInput")
default_value = self._action_def.get("default")
request_type = self._action_def.get("requestType", "external")
message = self._action_def.get("message", "")
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
"property", "Local.externalInput"
)
timeout_seconds = self._action_def.get("timeout")
required_fields = self._action_def.get("requiredFields", [])
metadata = self._action_def.get("metadata", {})
# Evaluate the message if it's an expression
evaluated_message = state.eval_if_expression(message)
# Build request metadata
@@ -299,6 +199,7 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
**metadata,
"output_property": output_property,
"required_fields": required_fields,
"default_value": default_value,
}
if timeout_seconds:
@@ -338,7 +239,5 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
# Mapping of external input action kinds to executor classes
EXTERNAL_INPUT_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"Question": QuestionExecutor,
"Confirmation": ConfirmationExecutor,
"WaitForInput": WaitForInputExecutor,
"RequestExternalInput": RequestExternalInputExecutor,
}
@@ -515,27 +515,6 @@ class TestBasicExecutorsCoverage:
assert state.get("Local.b") == 2
assert state.get("Local.c") == 3
async def test_append_value_executor(self, mock_context, mock_state):
"""Test AppendValueExecutor."""
from agent_framework_declarative._workflows._executors_basic import (
AppendValueExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.items", ["a"])
action_def = {
"kind": "AppendValue",
"path": "Local.items",
"value": "b",
}
executor = AppendValueExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.items")
assert result == ["a", "b"]
async def test_reset_variable_executor(self, mock_context, mock_state):
"""Test ResetVariableExecutor."""
from agent_framework_declarative._workflows._executors_basic import (
@@ -632,52 +611,6 @@ class TestBasicExecutorsCoverage:
mock_context.yield_output.assert_called_once_with("Dynamic message")
async def test_emit_event_executor_graph_mode(self, mock_context, mock_state):
"""Test EmitEventExecutor with graph-mode schema (eventName/eventValue)."""
from agent_framework_declarative._workflows._executors_basic import (
EmitEventExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "EmitEvent",
"eventName": "myEvent",
"eventValue": {"key": "value"},
}
executor = EmitEventExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.yield_output.assert_called_once()
event_data = mock_context.yield_output.call_args[0][0]
assert event_data["eventName"] == "myEvent"
assert event_data["eventValue"] == {"key": "value"}
async def test_emit_event_executor_interpreter_mode(self, mock_context, mock_state):
"""Test EmitEventExecutor with interpreter-mode schema (event.name/event.data)."""
from agent_framework_declarative._workflows._executors_basic import (
EmitEventExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "EmitEvent",
"event": {
"name": "interpreterEvent",
"data": {"payload": "test"},
},
}
executor = EmitEventExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.yield_output.assert_called_once()
event_data = mock_context.yield_output.call_args[0][0]
assert event_data["eventName"] == "interpreterEvent"
assert event_data["eventValue"] == {"payload": "test"}
# ---------------------------------------------------------------------------
# Agent Executors Tests - Covering _executors_agents.py gaps
@@ -1155,8 +1088,8 @@ class TestControlFlowCoverage:
"""Tests for control flow executors covering uncovered code paths."""
@_requires_powerfx
async def test_foreach_with_source_alias(self, mock_context, mock_state):
"""Test ForeachInitExecutor with 'source' alias (interpreter mode)."""
async def test_foreach_with_source(self, mock_context, mock_state):
"""Test ForeachInitExecutor with the 'source' field."""
from agent_framework_declarative._workflows._executors_control_flow import (
ForeachInitExecutor,
)
@@ -1205,8 +1138,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="foreach_init")
@@ -1217,81 +1150,6 @@ class TestControlFlowCoverage:
assert msg.current_index == 1
assert msg.current_item == "b"
@_requires_powerfx
async def test_switch_evaluator_with_value_cases(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor with value/cases schema."""
from agent_framework_declarative._workflows._executors_control_flow import (
SwitchEvaluatorExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.status", "pending")
action_def = {
"kind": "Switch",
"value": "=Local.status",
}
cases = [
{"match": "active"},
{"match": "pending"},
]
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
await executor.handle_action(ActionTrigger(), mock_context)
msg = mock_context.send_message.call_args[0][0]
assert isinstance(msg, ConditionResult)
assert msg.matched is True
assert msg.branch_index == 1 # Second case matched
@_requires_powerfx
async def test_switch_evaluator_default_case(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor falls through to default."""
from agent_framework_declarative._workflows._executors_control_flow import (
SwitchEvaluatorExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.status", "unknown")
action_def = {
"kind": "Switch",
"value": "=Local.status",
}
cases = [
{"match": "active"},
{"match": "pending"},
]
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
await executor.handle_action(ActionTrigger(), mock_context)
msg = mock_context.send_message.call_args[0][0]
assert isinstance(msg, ConditionResult)
assert msg.matched is False
assert msg.branch_index == -1 # Default case
async def test_switch_evaluator_no_value(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor with no value defaults to else."""
from agent_framework_declarative._workflows._executors_control_flow import (
SwitchEvaluatorExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {"kind": "Switch"} # No value
cases = [{"match": "x"}]
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
await executor.handle_action(ActionTrigger(), mock_context)
msg = mock_context.send_message.call_args[0][0]
assert isinstance(msg, ConditionResult)
assert msg.branch_index == -1
async def test_join_executor_accepts_condition_result(self, mock_context, mock_state):
"""Test JoinExecutor accepts ConditionResult as trigger."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1357,8 +1215,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="missing_loop")
@@ -1391,8 +1249,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
@@ -1425,8 +1283,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
@@ -1459,8 +1317,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
@@ -1719,60 +1577,6 @@ class TestDeclarativeActionExecutorBase:
class TestHumanInputExecutorsCoverage:
"""Tests for human input executors covering uncovered code paths."""
async def test_wait_for_input_executor_with_prompt(self, mock_context, mock_state):
"""Test WaitForInputExecutor with prompt."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
WaitForInputExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "WaitForInput",
"prompt": "Please enter your name:",
"property": "Local.userName",
"timeout": 30,
}
executor = WaitForInputExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
# Should yield prompt first, then call request_info
assert mock_context.yield_output.call_count == 1
assert mock_context.yield_output.call_args_list[0][0][0] == "Please enter your name:"
# request_info call for ExternalInputRequest
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.request_type == "user_input"
async def test_wait_for_input_executor_no_prompt(self, mock_context, mock_state):
"""Test WaitForInputExecutor without prompt."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
WaitForInputExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "WaitForInput",
"property": "Local.input",
}
executor = WaitForInputExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
# Should not yield output (no prompt), just call request_info
assert mock_context.yield_output.call_count == 0
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.request_type == "user_input"
async def test_request_external_input_executor(self, mock_context, mock_state):
"""Test RequestExternalInputExecutor."""
from agent_framework_declarative._workflows._executors_external_input import (
@@ -1786,8 +1590,8 @@ class TestHumanInputExecutorsCoverage:
action_def = {
"kind": "RequestExternalInput",
"requestType": "approval",
"message": "Please approve this request",
"property": "Local.approvalResult",
"prompt": {"text": "Please approve this request"},
"variable": "Local.approvalResult",
"timeout": 3600,
"requiredFields": ["approver", "notes"],
"metadata": {"priority": "high"},
@@ -1817,8 +1621,8 @@ class TestHumanInputExecutorsCoverage:
action_def = {
"kind": "Question",
"question": "Select an option:",
"property": "Local.selection",
"question": {"text": "Select an option:"},
"variable": "Local.selection",
"choices": [
{"value": "a", "label": "Option A"},
{"value": "b"}, # No label, should use value
@@ -1841,6 +1645,111 @@ class TestHumanInputExecutorsCoverage:
assert choices[2] == {"value": "c", "label": "c"}
assert request.metadata["allow_free_text"] is False
async def test_question_executor_reads_nested_question_text(self, mock_context, mock_state):
"""QuestionExecutor reads ``question.text``/``variable``/``default`` into the request."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
QuestionExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "Question",
"question": {"text": "What is your name?"},
"variable": "Local.userName",
"default": "Guest",
}
executor = QuestionExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
# Canonical text comes through as a plain string, not the stringified dict.
assert request.message == "What is your name?"
# Canonical `variable` overrides the legacy default of Local.answer.
assert request.metadata["output_property"] == "Local.userName"
assert request.metadata["default_value"] == "Guest"
async def test_question_executor_reads_top_level_alternates(self, mock_context, mock_state):
"""Top-level ``text``/``property``/``defaultValue`` are accepted as alternates."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
QuestionExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "Question",
"text": "Legacy question",
"property": "Local.legacyAnswer",
"defaultValue": "legacy-default",
}
executor = QuestionExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.message == "Legacy question"
assert request.metadata["output_property"] == "Local.legacyAnswer"
assert request.metadata["default_value"] == "legacy-default"
async def test_request_external_input_reads_nested_prompt_text(self, mock_context, mock_state):
"""RequestExternalInputExecutor reads ``prompt.text``/``variable``/``default``."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
RequestExternalInputExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "RequestExternalInput",
"prompt": {"text": "Please approve"},
"variable": "Local.approved",
"default": "pending",
}
executor = RequestExternalInputExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.message == "Please approve"
assert request.metadata["output_property"] == "Local.approved"
assert request.metadata["default_value"] == "pending"
async def test_request_external_input_reads_top_level_alternates(self, mock_context, mock_state):
"""Top-level ``message``/``property`` are accepted as alternates."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
RequestExternalInputExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "RequestExternalInput",
"message": "Legacy message",
"property": "Local.legacyApproval",
}
executor = RequestExternalInputExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.message == "Legacy message"
assert request.metadata["output_property"] == "Local.legacyApproval"
# ---------------------------------------------------------------------------
# Additional Agent Executor Tests - External Loop Coverage
@@ -2122,7 +2031,7 @@ class TestBuilderControlFlowCreation:
# Create a mock loop_next executor
loop_next = ForeachNextExecutor(
{"kind": "Foreach", "itemsProperty": "items"},
{"kind": "Foreach", "source": "=Local.items"},
init_executor_id="foreach_init",
id="foreach_next",
)
@@ -2181,7 +2090,7 @@ class TestBuilderControlFlowCreation:
# Create a mock loop_next executor
loop_next = ForeachNextExecutor(
{"kind": "Foreach", "itemsProperty": "items"},
{"kind": "Foreach", "source": "=Local.items"},
init_executor_id="foreach_init",
id="foreach_next",
)
@@ -2235,8 +2144,8 @@ class TestBuilderEdgeWiring:
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{"kind": "SendActivity", "id": "step_2", "activity": {"text": "two"}},
@@ -2266,8 +2175,8 @@ class TestBuilderEdgeWiring:
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{"kind": "BreakLoop", "id": "stop"},
@@ -2292,8 +2201,8 @@ class TestBuilderEdgeWiring:
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{
@@ -2704,7 +2613,7 @@ class TestBuilderValidation:
assert workflow is not None
def test_missing_required_field_foreach(self):
"""Test Foreach without items raises error."""
"""Test Foreach without source raises error."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
@@ -2717,7 +2626,7 @@ class TestBuilderValidation:
builder.build()
assert "Foreach" in str(exc_info.value)
assert "items" in str(exc_info.value)
assert "source" in str(exc_info.value)
def test_self_referencing_goto_raises_error(self):
"""Test that a goto referencing itself is detected."""
@@ -2725,7 +2634,7 @@ class TestBuilderValidation:
yaml_def = {
"name": "test_workflow",
"actions": [{"id": "loop", "kind": "Goto", "target": "loop"}],
"actions": [{"id": "loop", "kind": "GotoAction", "actionId": "loop"}],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
@@ -2757,23 +2666,22 @@ class TestBuilderValidation:
workflow = builder.build()
assert workflow is not None
def test_validation_in_switch_branches(self):
"""Test validation catches issues in Switch branches."""
def test_validation_in_condition_group_branches(self):
"""Test validation catches issues in ConditionGroup branches."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "test_workflow",
"actions": [
{
"kind": "Switch",
"value": "=Local.choice",
"cases": [
"kind": "ConditionGroup",
"conditions": [
{
"match": "a",
"condition": '=Local.choice = "a"',
"actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "A"}}],
},
{
"match": "b",
"condition": '=Local.choice = "b"',
"actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "B"}}],
},
],
@@ -2796,7 +2704,7 @@ class TestBuilderValidation:
"actions": [
{
"kind": "Foreach",
"items": "=Local.items",
"source": "=Local.items",
"actions": [{"kind": "SendActivity"}], # Missing 'activity'
}
],
@@ -207,16 +207,16 @@ class TestDeclarativeActionExecutor:
# Note: ConditionEvaluatorExecutor tests removed - conditions are now evaluated on edges
@_requires_powerfx
async def test_foreach_init_with_items(self, mock_context, mock_state):
"""Test ForeachInitExecutor with items."""
async def test_foreach_init_with_source(self, mock_context, mock_state):
"""Test ForeachInitExecutor with the 'source' field."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.items", ["a", "b", "c"])
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
}
executor = ForeachInitExecutor(action_def)
@@ -240,8 +240,8 @@ class TestDeclarativeActionExecutor:
# Use a literal empty list - no expression evaluation needed
action_def = {
"kind": "Foreach",
"itemsSource": [], # Direct empty list, not an expression
"iteratorVariable": "Local.item",
"source": [], # Direct empty list, not an expression
"itemName": "item",
}
executor = ForeachInitExecutor(action_def)
@@ -264,7 +264,6 @@ class TestDeclarativeWorkflowBuilder:
"SetValue",
"SetVariable",
"SendActivity",
"EmitEvent",
"EndWorkflow",
"InvokeAzureAgent",
"Question",
@@ -335,8 +334,8 @@ class TestDeclarativeWorkflowBuilder:
{
"kind": "Foreach",
"id": "process_items",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
],
@@ -353,13 +352,13 @@ class TestDeclarativeWorkflowBuilder:
assert "process_items_exit" in builder._executors
assert "show_item" in builder._executors
def test_build_workflow_with_switch(self):
"""Test building a workflow with Switch control flow."""
def test_build_workflow_with_condition_group(self):
"""Test building a workflow with ConditionGroup control flow."""
yaml_def = {
"name": "switch_workflow",
"name": "condition_group_workflow",
"actions": [
{
"kind": "Switch",
"kind": "ConditionGroup",
"id": "check_status",
"conditions": [
{
@@ -375,7 +374,7 @@ class TestDeclarativeWorkflowBuilder:
],
},
],
"else": [
"elseActions": [
{"kind": "SendActivity", "id": "say_unknown", "activity": {"text": "Unknown"}},
],
},
@@ -385,12 +384,12 @@ class TestDeclarativeWorkflowBuilder:
workflow = builder.build()
assert workflow is not None
# Verify switch executors were created
# Verify ConditionGroup branch executors were created
# Note: No join executors - branches wire directly to successor
assert "say_active" in builder._executors
assert "say_pending" in builder._executors
assert "say_unknown" in builder._executors
# Entry node is created when Switch is first action
# Entry node is created when ConditionGroup is first action
assert "_workflow_entry" in builder._executors
@@ -493,9 +492,9 @@ class TestHumanInputExecutors:
action_def = {
"kind": "Question",
"text": "What is your name?",
"property": "Local.name",
"defaultValue": "Anonymous",
"question": {"text": "What is your name?"},
"variable": "Local.name",
"default": "Anonymous",
}
executor = QuestionExecutor(action_def)
@@ -509,36 +508,6 @@ class TestHumanInputExecutors:
assert request.request_type == "question"
assert "What is your name?" in request.message
@pytest.mark.asyncio
async def test_confirmation_executor(self, mock_context, mock_state):
"""Test ConfirmationExecutor."""
from agent_framework_declarative._workflows import (
ConfirmationExecutor,
ExternalInputRequest,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "Confirmation",
"text": "Do you want to continue?",
"property": "Local.confirmed",
"yesLabel": "Yes, continue",
"noLabel": "No, stop",
}
executor = ConfirmationExecutor(action_def)
# Execute
await executor.handle_action(ActionTrigger(), mock_context)
# Verify request_info was called with ExternalInputRequest
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.request_type == "confirmation"
assert "continue" in request.message.lower()
@_requires_powerfx
class TestParseValueExecutor:
@@ -100,8 +100,8 @@ class TestGraphBasedWorkflowExecution:
{
"kind": "Foreach",
"id": "process_items",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
],
@@ -131,8 +131,8 @@ class TestGraphBasedWorkflowExecution:
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
@@ -151,14 +151,14 @@ class TestGraphBasedWorkflowExecution:
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
@pytest.mark.asyncio
async def test_workflow_with_switch(self):
"""Test workflow with Switch/ConditionGroup."""
async def test_workflow_with_condition_group(self):
"""Test workflow with ConditionGroup."""
yaml_def = {
"name": "switch_workflow",
"name": "condition_group_workflow",
"actions": [
{"kind": "SetValue", "id": "set_level", "path": "Local.level", "value": 2},
{
"kind": "Switch",
"kind": "ConditionGroup",
"id": "check_level",
"conditions": [
{
@@ -174,7 +174,7 @@ class TestGraphBasedWorkflowExecution:
],
},
],
"else": [
"elseActions": [
{"kind": "SendActivity", "id": "default", "activity": {"text": "Other level"}},
],
},
@@ -122,14 +122,16 @@ actions:
- cherry
itemName: fruit
actions:
- kind: AppendValue
path: Local.fruits
value: processed
- kind: SendActivity
activity:
text: processed
""")
_result = await workflow.run({}) # noqa: F841
# The foreach should have processed 3 items
# We can check this by examining the workflow outputs
result = await workflow.run({})
outputs = result.get_outputs()
# The foreach should have processed 3 items, emitting "processed" each time.
processed_outputs = [o for o in outputs if "processed" in str(o)]
assert len(processed_outputs) == 3
@pytest.mark.asyncio
async def test_execute_if_workflow(self):
@@ -556,28 +558,27 @@ actions:
@_requires_powerfx
class TestWorkflowFactorySwitch:
"""Tests for Switch/Case action."""
class TestWorkflowFactoryConditionGroup:
"""Tests for ConditionGroup action."""
@pytest.mark.asyncio
async def test_switch_with_matching_case(self):
"""Test Switch with a matching case."""
async def test_condition_group_with_matching_condition(self):
"""Test ConditionGroup with a matching condition."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: switch-test
name: condition-group-test
actions:
- kind: SetValue
path: Local.color
value: red
- kind: Switch
value: =Local.color
cases:
- match: red
- kind: ConditionGroup
conditions:
- condition: =Local.color = "red"
actions:
- kind: SendActivity
activity:
text: Color is red
- match: blue
- condition: =Local.color = "blue"
actions:
- kind: SendActivity
activity:
@@ -590,29 +591,28 @@ actions:
assert any("Color is red" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_switch_with_default(self):
"""Test Switch falling through to default."""
async def test_condition_group_with_else_actions(self):
"""Test ConditionGroup falling through to elseActions when no condition matches."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: switch-default-test
name: condition-group-else-test
actions:
- kind: SetValue
path: Local.color
value: green
- kind: Switch
value: =Local.color
cases:
- match: red
- kind: ConditionGroup
conditions:
- condition: =Local.color = "red"
actions:
- kind: SendActivity
activity:
text: Red
- match: blue
- condition: =Local.color = "blue"
actions:
- kind: SendActivity
activity:
text: Blue
default:
elseActions:
- kind: SendActivity
activity:
text: Unknown color
@@ -653,54 +653,273 @@ actions:
assert any("Done" in str(o) for o in outputs)
class TestRenamedAliasKindsAreUnknown:
"""Tests that the previously-accepted ``Switch``/``Goto`` kind names are now unknown.
YAML that still names one of these kinds falls through the existing
unknown-kind warning path (the action is silently skipped) instead
of being routed to ``ConditionGroup``/``GotoAction``.
"""
@pytest.mark.asyncio
async def test_append_value(self):
"""Test AppendValue action."""
async def test_switch_kind_is_unknown(self, caplog):
"""A workflow whose YAML uses kind: Switch logs an unknown-kind warning."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: append-test
with caplog.at_level(
"WARNING",
logger="agent_framework_declarative._workflows._declarative_builder",
):
workflow = factory.create_workflow_from_yaml("""
name: switch-alias-removed
actions:
- kind: SetValue
path: Local.list
value: []
- kind: AppendValue
path: Local.list
value: first
- kind: AppendValue
path: Local.list
value: second
- kind: Switch
value: =Local.color
cases:
- match: red
actions:
- kind: SendActivity
activity:
text: Color is red
- kind: SendActivity
activity:
text: Done
""")
result = await workflow.run({})
result = await workflow.run({})
# Switch is no longer a recognised kind -> warning emitted + action skipped.
assert any("Unknown action kind 'Switch'" in record.getMessage() for record in caplog.records)
# The trailing SendActivity still runs so the workflow completes successfully.
outputs = result.get_outputs()
assert any("Done" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_emit_event(self):
"""Test EmitEvent action."""
async def test_goto_kind_is_unknown(self, caplog):
"""A workflow whose YAML uses kind: Goto logs an unknown-kind warning."""
factory = WorkflowFactory()
with caplog.at_level(
"WARNING",
logger="agent_framework_declarative._workflows._declarative_builder",
):
workflow = factory.create_workflow_from_yaml("""
name: goto-alias-removed
actions:
- id: target
kind: SendActivity
activity:
text: Arrived
- kind: Goto
target: target
""")
result = await workflow.run({})
# Goto is no longer a recognised kind -> warning emitted + action skipped.
assert any("Unknown action kind 'Goto'" in record.getMessage() for record in caplog.records)
# The first SendActivity still emits its output.
outputs = result.get_outputs()
assert any("Arrived" in str(o) for o in outputs)
class TestDroppedShapesAreRejected:
"""Tests that previously-accepted alternate YAML shapes are now rejected at validation.
``ConditionGroup`` no longer accepts the ``value``/``cases`` shape and
``Foreach`` no longer accepts the ``items`` field. Both kinds raise a
``ValueError`` from the builder when the required field is missing.
"""
def test_condition_group_with_cases_raises(self):
"""ConditionGroup using value/cases (no conditions) must fail validation."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "cg-cases-rejected",
"actions": [
{
"kind": "ConditionGroup",
"value": "=Local.color",
"cases": [
{"match": "red", "actions": [{"kind": "SendActivity", "activity": {"text": "Red"}}]},
],
}
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
with pytest.raises(ValueError, match="conditions"):
builder.build()
def test_foreach_with_items_raises(self):
"""Foreach using items (no source) must fail validation."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "fe-items-rejected",
"actions": [
{
"kind": "Foreach",
"items": "=Local.list",
"actions": [{"kind": "SendActivity", "activity": {"text": "hi"}}],
}
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
with pytest.raises(ValueError, match="source"):
builder.build()
def test_condition_group_with_else_field_raises(self):
"""ConditionGroup with an ``else`` field must fail fast and point at ``elseActions``."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "cg-else-rejected",
"actions": [
{
"kind": "ConditionGroup",
"conditions": [
{
"condition": "=Local.x = 1",
"actions": [{"kind": "SendActivity", "activity": {"text": "one"}}],
},
],
"else": [{"kind": "SendActivity", "activity": {"text": "other"}}],
}
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
with pytest.raises(ValueError, match="elseActions"):
builder.build()
def test_condition_group_with_default_field_raises(self):
"""ConditionGroup with a ``default`` field must fail fast and point at ``elseActions``."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "cg-default-rejected",
"actions": [
{
"kind": "ConditionGroup",
"conditions": [
{
"condition": "=Local.x = 1",
"actions": [{"kind": "SendActivity", "activity": {"text": "one"}}],
},
],
"default": [{"kind": "SendActivity", "activity": {"text": "other"}}],
}
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
with pytest.raises(ValueError, match="elseActions"):
builder.build()
class TestQuestionAndRequestExternalInputShapes:
"""Tests for accepted YAML shapes of ``Question`` and ``RequestExternalInput``.
Both kinds accept either a nested ``{question|prompt: {text: ...}}`` form
or a top-level alternate (``text``/``message``) for the prompt content,
and either ``variable`` or top-level ``property`` for the destination path.
Missing both spellings of a required field raises during validation.
"""
def test_question_nested_question_text_builds(self):
"""A workflow whose Question uses nested ``question.text`` builds without error."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: emit-event-test
name: question-nested
actions:
- kind: EmitEvent
event:
name: test_event
data:
message: Hello
- kind: SendActivity
activity:
text: Event emitted
- kind: Question
question:
text: "What is your name?"
variable: Local.userName
default: "Guest"
""")
assert workflow is not None
def test_request_external_input_nested_prompt_text_builds(self):
"""A workflow whose RequestExternalInput uses nested ``prompt.text`` builds without error."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: rei-nested
actions:
- kind: RequestExternalInput
prompt:
text: "Please approve"
variable: Local.approved
default: pending
""")
assert workflow is not None
def test_question_missing_question_raises(self):
"""A Question action missing both `question` and the `text` alternate must fail validation."""
factory = WorkflowFactory()
with pytest.raises((ValueError, DeclarativeWorkflowError), match="question"):
factory.create_workflow_from_yaml("""
name: question-missing-question
actions:
- kind: Question
variable: Local.x
""")
result = await workflow.run({})
outputs = result.get_outputs()
def test_question_missing_variable_raises(self):
"""A Question action missing both `variable` and the `property` alternate must fail validation."""
factory = WorkflowFactory()
with pytest.raises((ValueError, DeclarativeWorkflowError), match="variable"):
factory.create_workflow_from_yaml("""
name: question-missing-variable
actions:
- kind: Question
question:
text: "Hi"
""")
# Workflow should complete
assert any("Event emitted" in str(o) for o in outputs)
def test_request_external_input_missing_prompt_raises(self):
"""RequestExternalInput missing both `prompt` and the `message` alternate must fail validation."""
factory = WorkflowFactory()
with pytest.raises((ValueError, DeclarativeWorkflowError), match="prompt"):
factory.create_workflow_from_yaml("""
name: rei-missing-prompt
actions:
- kind: RequestExternalInput
variable: Local.x
""")
def test_request_external_input_missing_variable_raises(self):
"""RequestExternalInput missing both `variable` and the `property` alternate must fail validation."""
factory = WorkflowFactory()
with pytest.raises((ValueError, DeclarativeWorkflowError), match="variable"):
factory.create_workflow_from_yaml("""
name: rei-missing-variable
actions:
- kind: RequestExternalInput
prompt:
text: "Hi"
""")
def test_question_top_level_field_names_accepted(self):
"""Top-level ``text`` + ``property`` + ``defaultValue`` are accepted on Question."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: question-legacy
actions:
- kind: Question
text: "What is your name?"
property: Local.userName
defaultValue: "Guest"
""")
assert workflow is not None
def test_request_external_input_top_level_field_names_accepted(self):
"""Top-level ``message`` + ``property`` are accepted on RequestExternalInput."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: rei-legacy
actions:
- kind: RequestExternalInput
message: "Please approve"
property: Local.approved
""")
assert workflow is not None
class TestWorkflowFactoryYamlErrors:
@@ -227,7 +227,6 @@ class TestHandlerCoverage:
"OnConversationStart", # Trigger kind, not an action
"ConditionGroup", # Decomposed into evaluator/join nodes
"GotoAction", # Resolved as graph edges, not executor nodes
"Goto", # Alias for GotoAction
}
missing_executors = all_action_kinds - registered_executors - structural_kinds