mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
55dc3ce734
commit
ded17b178c
@@ -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",
|
||||
]
|
||||
|
||||
+2
-2
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
+67
-85
@@ -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.
|
||||
"""
|
||||
|
||||
-65
@@ -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,
|
||||
|
||||
+15
-110
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
+47
-148
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user