From 1f18763368bb511a919c8a08ea18a8672e73c98d Mon Sep 17 00:00:00 2001 From: aifanatic Date: Mon, 26 Oct 2026 13:35:45 -0500 Subject: [PATCH 1/2] Python: fix(declarative): treat omitted workflow inputs as blank in PowerFx When a caller omits an input that a formula references, PowerFx fails to compile inputs. / Workflow.Inputs. ("Name isn't valid") and eval() returns None for the whole expression. That breaks the documented default pattern =If(IsBlank(inputs.name), "World", inputs.name): the simple_workflow sample printed "Hello, !" for workflow.run({}). Bind referenced-but-missing inputs to blank for that evaluation only, the same result as passing the input as None, without writing them to state. Also print the simple_workflow sample's second run, which was discarded. Co-Authored-By: Claude Opus 5.5 --- .../_workflows/_declarative_base.py | 23 ++++++++++++++++++- .../tests/test_powerfx_yaml_compatibility.py | 17 ++++++++++++++ .../declarative/simple_workflow/main.py | 2 ++ 3 files changed, 41 insertions(+), 1 deletion(-) 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 7338aa62260..33263f9cf62 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -70,6 +70,27 @@ _ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)") +_INPUT_REFERENCE_RE = re.compile(r"(? dict[str, Any]: + """Expose inputs the formula references but the caller did not supply as blank. + + PowerFx rejects ``inputs.name`` at compile time when ``name`` is not a field of the + inputs record, which turns the whole expression into ``None`` and defeats defaults + such as ``If(IsBlank(inputs.name), "World", inputs.name)``. Binding the missing + fields to blank only for this evaluation makes them behave like an explicit ``None`` + input without changing the workflow state. + """ + inputs = symbols.get("inputs") + if not isinstance(inputs, Mapping): + return symbols + input_values = cast(Mapping[str, Any], inputs) + missing = {name for name in _INPUT_REFERENCE_RE.findall(formula) if name not in input_values} + if not missing: + return symbols + bound_inputs: dict[str, Any] = {**input_values, **dict.fromkeys(sorted(missing))} + return {**symbols, "Workflow": {**symbols["Workflow"], "Inputs": bound_inputs}, "inputs": bound_inputs} @dataclass(frozen=True) @@ -605,7 +626,7 @@ def eval(self, expression: str) -> Any: f"Install dotnet and the powerfx package for full PowerFx support." ) - symbols = self._to_powerfx_symbols() + symbols = _bind_missing_input_references(formula, self._to_powerfx_symbols()) # Use setlocale(category) query form so we can restore the exact prior value. # getlocale() returns a normalized tuple and is not always a lossless # round-trip for setlocale across platforms/locales. diff --git a/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py b/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py index 308982c6324..311f0676215 100644 --- a/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py +++ b/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py @@ -104,6 +104,23 @@ async def test_if_with_isblank(self, mock_state): result = state.eval('=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)') assert result == "Alice" + @pytest.mark.parametrize( + "expression", + [ + '=If(IsBlank(inputs.name), "World", inputs.name)', + '=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)', + ], + ) + async def test_if_with_isblank_when_input_is_missing(self, mock_state, expression): + """Test that an input the caller did not supply is blank, so IsBlank defaults apply.""" + state = DeclarativeWorkflowState(mock_state) + state.initialize({"other": 1}) + + assert state.eval(expression) == "World" + assert state.eval("=IsBlank(inputs.name)") is True + # The missing input is only bound for evaluation, not written to state. + assert "name" not in state.get_state_data()["Inputs"] + async def test_not_function(self, mock_state): """Test Not function.""" state = DeclarativeWorkflowState(mock_state) diff --git a/python/samples/03-workflows/declarative/simple_workflow/main.py b/python/samples/03-workflows/declarative/simple_workflow/main.py index 4deb1532f12..5499a4de9b8 100644 --- a/python/samples/03-workflows/declarative/simple_workflow/main.py +++ b/python/samples/03-workflows/declarative/simple_workflow/main.py @@ -25,6 +25,8 @@ async def main() -> None: # Run with a custom name print("\nRunning with custom name 'Alice':") result = await workflow.run({"name": "Alice"}) + for output in result.get_outputs(): + print(f" Output: {output}") print("\n" + "-" * 40) print("Workflow completed!") From d77e971347a07c4677729505dfcf7c76c2df10da Mon Sep 17 00:00:00 2001 From: aifanatic Date: Tue, 27 Oct 2026 07:25:45 -0500 Subject: [PATCH 2/2] fix(declarative): find input references the way PowerFx parses them Skip comments and string literals when looking for inputs. and Workflow.Inputs., and accept single-quoted field names and whitespace or comments around the dots. Co-Authored-By: Claude Opus 5.5 --- .../_workflows/_declarative_base.py | 55 ++++++++++++++++++- .../tests/test_powerfx_yaml_compatibility.py | 21 +++++++ 2 files changed, 74 insertions(+), 2 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 33263f9cf62..c7a215c6ad0 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -47,7 +47,9 @@ from ._errors import DeclarativeWorkflowError from ._powerfx_functions import ( + _after_trailing_trivia, # pyright: ignore[reportPrivateUsage] _iter_powerfx_expression_indices, # pyright: ignore[reportPrivateUsage] + _skip_powerfx_opaque_token, # pyright: ignore[reportPrivateUsage] whole_call_args, ) from ._powerfx_limits import _PowerFxStateBudget, _validate_powerfx_state # pyright: ignore[reportPrivateUsage] @@ -70,7 +72,56 @@ _ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)") -_INPUT_REFERENCE_RE = re.compile(r"(? tuple[str, int] | None: + """Read a plain or single-quoted PowerFx identifier at ``start``. + + Returns the identifier name and the index after it, or ``None`` when no + identifier starts there. + """ + if formula.startswith("'", start): + end = _skip_powerfx_opaque_token(formula, start) + if end - start < 3 or formula[end - 1] != "'": + return None + return formula[start + 1 : end - 1].replace("''", "'"), end + match = _POWERFX_IDENTIFIER_RE.match(formula, start) + return (match.group(), match.end()) if match else None + + +def _read_member_access(formula: str, start: int) -> tuple[str, int] | None: + """Read ``.`` at ``start``, allowing whitespace and comments around the dot.""" + dot = _after_trailing_trivia(formula, start) + if not formula.startswith(".", dot): + return None + return _read_powerfx_identifier(formula, _after_trailing_trivia(formula, dot + 1)) + + +def _referenced_input_names(formula: str) -> set[str]: + """Return the field names of ``inputs.`` / ``Workflow.Inputs.`` references in code. + + String literals and comments are skipped, so input-like text inside them is not a reference. + """ + names: set[str] = set() + for cursor in _iter_powerfx_expression_indices(formula): + if cursor > 0 and (formula[cursor - 1].isalnum() or formula[cursor - 1] in "_."): + continue + root = _read_powerfx_identifier(formula, cursor) + if root is None: + continue + root_name, after_root = root + if root_name == "Workflow": + member = _read_member_access(formula, after_root) + if member is None or member[0] != "Inputs": + continue + after_root = member[1] + elif root_name != "inputs": + continue + field_reference = _read_member_access(formula, after_root) + if field_reference is not None: + names.add(field_reference[0]) + return names def _bind_missing_input_references(formula: str, symbols: dict[str, Any]) -> dict[str, Any]: @@ -86,7 +137,7 @@ def _bind_missing_input_references(formula: str, symbols: dict[str, Any]) -> dic if not isinstance(inputs, Mapping): return symbols input_values = cast(Mapping[str, Any], inputs) - missing = {name for name in _INPUT_REFERENCE_RE.findall(formula) if name not in input_values} + missing = {name for name in _referenced_input_names(formula) if name not in input_values} if not missing: return symbols bound_inputs: dict[str, Any] = {**input_values, **dict.fromkeys(sorted(missing))} diff --git a/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py b/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py index 311f0676215..e30b3d4d405 100644 --- a/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py +++ b/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py @@ -109,6 +109,11 @@ async def test_if_with_isblank(self, mock_state): [ '=If(IsBlank(inputs.name), "World", inputs.name)', '=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)', + "=If(IsBlank(inputs.'name'), \"World\", inputs.'name')", + "=If(IsBlank(Workflow.Inputs.'name'), \"World\", Workflow.Inputs.'name')", + '=If(IsBlank(inputs . name), "World", inputs /* field */ . name)', + '=If(IsBlank(Workflow . Inputs . name), "World", Workflow // root\n.Inputs.name)', + '=$"{If(IsBlank(inputs.name), "World", inputs.name)}"', ], ) async def test_if_with_isblank_when_input_is_missing(self, mock_state, expression): @@ -121,6 +126,22 @@ async def test_if_with_isblank_when_input_is_missing(self, mock_state, expressio # The missing input is only bound for evaluation, not written to state. assert "name" not in state.get_state_data()["Inputs"] + @pytest.mark.parametrize( + "expression", + [ + "=inputs /* inputs.name */", + "=inputs // Workflow.Inputs.name", + '=If(Len("inputs.name") > 0, inputs, inputs)', + '=If(Len($"Workflow.Inputs.name") > 0, inputs, inputs)', + ], + ) + async def test_input_references_in_comments_and_literals_are_not_bound(self, mock_state, expression): + """Test that input-like text in comments and string literals does not add blank inputs.""" + state = DeclarativeWorkflowState(mock_state) + state.initialize({"other": 1}) + + assert state.eval(expression) == {"other": 1} + async def test_not_function(self, mock_state): """Test Not function.""" state = DeclarativeWorkflowState(mock_state)