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!")