From 97da7868105c938011b6a1d13784ed3071bd06ff Mon Sep 17 00:00:00 2001 From: Amit Dhawan Date: Mon, 20 Jul 2026 14:28:19 +0530 Subject: [PATCH 1/4] Python: fix declarative workflow DevUI sample and JoinExecutor Message trigger --- .../_workflows/_executors_control_flow.py | 5 ++++ .../devui/workflow_declarative/workflow.py | 2 ++ .../devui/workflow_declarative/workflow.yaml | 29 ++++++++----------- 3 files changed, 19 insertions(+), 17 deletions(-) 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/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 From 940b56fb355db3e6ef69ad3f98aa5b9845b70d24 Mon Sep 17 00:00:00 2001 From: Amit Dhawan Date: Mon, 20 Jul 2026 15:19:38 +0530 Subject: [PATCH 2/4] Python: add regression test for JoinExecutor single Message trigger --- .../tests/test_workflow_factory.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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.""" From 4388b60618298f52f69390e791cc8ee028c62c15 Mon Sep 17 00:00:00 2001 From: Amit Dhawan Date: Mon, 20 Jul 2026 15:25:09 +0530 Subject: [PATCH 3/4] Python: add Int/Float/Value Python-side fallbacks to _eval_custom_function --- .../_workflows/_declarative_base.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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: From 9a14e0e0342f023f9b3e06ec58cddc1974062bdd Mon Sep 17 00:00:00 2001 From: Amit Dhawan Date: Fri, 7 Aug 2026 17:21:57 +0530 Subject: [PATCH 4/4] =?UTF-8?q?Python:=20fix=20Int()=20fallback=20?= =?UTF-8?q?=E2=80=94=20balanced-paren=20guard,=20math.floor=20semantics,?= =?UTF-8?q?=20OverflowError,=20remove=20Float/Value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_workflows/_declarative_base.py | 47 +++++++++++-------- .../tests/test_workflow_factory.py | 2 +- 2 files changed, 29 insertions(+), 20 deletions(-) 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 c6e3204e4e3..bfd779ebe79 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -27,6 +27,7 @@ import locale import logging +import math import os import re import sys @@ -658,28 +659,36 @@ 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. + # Int(expr) - Python-side fallback for the PowerFx Int() function. + # Uses math.floor() to preserve Power Fx floor semantics (Int(-4.2) → -5, + # not -4). Only handles a single balanced call; compound expressions such + # as =Int(a) + Int(b) are left to the PowerFx engine. 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 + # Guard against greedy matches: if inner_expr contains a ')' that + # pushes paren depth negative, this is a compound expression. + depth = 0 + single_call = True + for ch in inner_expr: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth < 0: + single_call = False + break + if single_call: + raw = self.eval(f"={inner_expr}") + if raw in (None, ""): + return 0 + try: + val = float(str(raw)) + except (ValueError, TypeError) as exc: + raise ValueError(f"Int() cannot convert {raw!r} to a number") from exc + if not math.isfinite(val): + raise ValueError(f"Int() received a non-finite value: {raw!r}") + return math.floor(val) return None diff --git a/python/packages/declarative/tests/test_workflow_factory.py b/python/packages/declarative/tests/test_workflow_factory.py index 10d571a6e75..96b6b46349e 100644 --- a/python/packages/declarative/tests/test_workflow_factory.py +++ b/python/packages/declarative/tests/test_workflow_factory.py @@ -5,8 +5,8 @@ from typing import Any, cast import pytest - from agent_framework import Content, Message + from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError from agent_framework_declarative._workflows._factory import WorkflowFactory