diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py index f4567578853..c6e3204e4e3 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -658,6 +658,29 @@ def _eval_custom_function(self, formula: str) -> Any | None: # Reuse the helper method for consistent text extraction return self._eval_and_replace_message_text(inner_expr) + # Int(expr) - convert to integer (Python-side fallback so the function + # works even when PowerFx is unavailable or returns an ErrorValue for + # non-numeric input). Returns 0 for blank/unconvertible values, matching + # PowerFx's Blank-as-zero coercion behaviour. + match = re.match(r"Int\((.+)\)$", formula.strip()) + if match: + inner_expr = match.group(1).strip() + raw = self.eval(f"={inner_expr}") + try: + return int(float(str(raw))) if raw not in (None, "") else 0 + except (ValueError, TypeError): + return 0 + + # Float(expr) / Value(expr) - convert to float (Python-side fallback). + match = re.match(r"(?:Float|Value)\((.+)\)$", formula.strip()) + if match: + inner_expr = match.group(1).strip() + raw = self.eval(f"={inner_expr}") + try: + return float(str(raw)) if raw not in (None, "") else 0.0 + except (ValueError, TypeError): + return 0.0 + return None def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str, Any]]) -> str: diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py index 6aca5682e75..b02973ea376 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py @@ -400,6 +400,7 @@ async def handle_action( self, trigger: dict[str, Any] | str + | Message | list[Message] | ActionTrigger | ActionComplete @@ -408,6 +409,10 @@ async def handle_action( ctx: WorkflowContext[ActionComplete], ) -> None: """Simply pass through to continue the workflow.""" + # Normalize a single Message to list[Message] so _ensure_state_initialized + # can extract the user text via the list[Message] path. + if isinstance(trigger, Message): + trigger = [trigger] await self._ensure_state_initialized(ctx, trigger) await ctx.send_message(ActionComplete()) diff --git a/python/packages/declarative/tests/test_workflow_factory.py b/python/packages/declarative/tests/test_workflow_factory.py index acf677f6c84..10d571a6e75 100644 --- a/python/packages/declarative/tests/test_workflow_factory.py +++ b/python/packages/declarative/tests/test_workflow_factory.py @@ -6,6 +6,7 @@ import pytest +from agent_framework import Content, Message from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError from agent_framework_declarative._workflows._factory import WorkflowFactory @@ -318,6 +319,46 @@ async def test_as_agent_continuation_preserves_prior_state(self): f"System.LastMessageText not refreshed on turn 2: {post_state.get('System')!r}" ) + @pytest.mark.asyncio + async def test_entry_join_executor_single_message_trigger(self): + """Regression test: JoinExecutor must correctly initialize state when passed + a single Message (not list[Message]) as the trigger. + + Without the normalization fix in JoinExecutor, a lone Message would bypass + the list[Message] path in _ensure_state_initialized, leaving + System.LastMessage.Text empty. This caused =Int(System.LastMessage.Text) to + evaluate as 0 and age-based conditions to always resolve to the wrong branch. + """ + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: single-message-trigger-test +actions: + - kind: SetValue + path: Local.age + value: =Int(System.LastMessage.Text) + - kind: If + condition: =Local.age < 13 + then: + - kind: SendActivity + activity: + text: child + else: + - kind: SendActivity + activity: + text: adult +""") + + msg = Message(role="user", contents=[Content.from_text("25")]) + result = await workflow.run(msg) + outputs = result.get_outputs() + assert any("adult" in str(o) for o in outputs), ( + f"Expected 'adult' for age=25 passed as single Message, got: {outputs}. " + "JoinExecutor may not be normalizing single Message to list[Message]." + ) + assert not any("child" in str(o) for o in outputs), ( + f"Did not expect 'child' for age=25 passed as single Message, got: {outputs}" + ) + class TestWorkflowFactoryAgentRegistration: """Tests for agent registration.""" diff --git a/python/samples/02-agents/devui/workflow_declarative/workflow.py b/python/samples/02-agents/devui/workflow_declarative/workflow.py index 70a746d76b8..30d19434583 100644 --- a/python/samples/02-agents/devui/workflow_declarative/workflow.py +++ b/python/samples/02-agents/devui/workflow_declarative/workflow.py @@ -6,6 +6,7 @@ Demonstrates conditional branching based on age input using YAML-defined workflow. """ +import logging from pathlib import Path from agent_framework.declarative import WorkflowFactory @@ -18,6 +19,7 @@ def main(): """Run the declarative workflow with DevUI.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") serve(entities=[workflow], auto_open=True) diff --git a/python/samples/02-agents/devui/workflow_declarative/workflow.yaml b/python/samples/02-agents/devui/workflow_declarative/workflow.yaml index 947f1688389..a04540b141b 100644 --- a/python/samples/02-agents/devui/workflow_declarative/workflow.yaml +++ b/python/samples/02-agents/devui/workflow_declarative/workflow.yaml @@ -1,52 +1,47 @@ name: conditional-workflow description: Demonstrates conditional branching based on user input -inputs: - age: - type: integer - description: The user's age in years - actions: - kind: SetValue id: get_age displayName: Get user age - path: turn.age - value: =inputs.age + path: Local.age + value: =Int(System.LastMessage.Text) - kind: If id: check_age displayName: Check age category - condition: =turn.age < 13 + condition: =Local.age < 13 then: - kind: SetValue - path: turn.category + path: Local.category value: child - kind: SendActivity activity: text: "Welcome, young one! Here are some fun activities for kids." else: - kind: If - condition: =turn.age < 20 + condition: =Local.age < 20 then: - kind: SetValue - path: turn.category + path: Local.category value: teenager - kind: SendActivity activity: text: "Hey there! Check out these cool things for teens." else: - kind: If - condition: =turn.age < 65 + condition: =Local.age < 65 then: - kind: SetValue - path: turn.category + path: Local.category value: adult - kind: SendActivity activity: text: "Welcome! Here are our professional services." else: - kind: SetValue - path: turn.category + path: Local.category value: senior - kind: SendActivity activity: @@ -56,9 +51,9 @@ actions: id: summary displayName: Send category summary activity: - text: '=Concat("You have been categorized as: ", turn.category)' + text: '=Concat("You have been categorized as: ", Local.category)' - kind: SetValue id: set_output - path: workflow.outputs.category - value: =turn.category + path: Workflow.Outputs.category + value: =Local.category