From 48a58661e8fa8cbfb203e105578d104e235b9e25 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Fri, 24 Apr 2026 18:24:32 -0700 Subject: [PATCH 1/2] fix(engine): use type-appropriate zero values for optional input defaults Optional workflow inputs without an explicit `default:` previously defaulted to Python None, which renders as "None" in templates and isn't caught by Jinja's `| default()` filter without the boolean flag. Now uses type-appropriate zero values: "" for string, 0 for number, false for boolean, [] for array, {} for object. This ensures templates render cleanly without requiring `| default()` guards or `if X else Y` workarounds. Explicit `default:` values in the schema are still honored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/conductor/engine/workflow.py | 21 +++++++++++-- tests/test_engine/test_workflow.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/conductor/engine/workflow.py b/src/conductor/engine/workflow.py index 484192b..ac29a04 100644 --- a/src/conductor/engine/workflow.py +++ b/src/conductor/engine/workflow.py @@ -1716,11 +1716,25 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: self._save_checkpoint_on_failure(e) raise + # Type-appropriate zero values for optional inputs with no declared default. + # Using None causes templates to render "None" instead of empty string, + # and | default() won't catch None without the boolean=true flag. + _TYPE_ZERO_VALUES: dict[str, Any] = { + "string": "", + "number": 0, + "boolean": False, + "array": [], + "object": {}, + } + def _apply_input_defaults(self, inputs: dict[str, Any]) -> dict[str, Any]: """Apply default values from input schema for missing optional inputs. This ensures all defined inputs are present in the context, either - with provided values or their schema defaults (None if no default). + with provided values or their schema defaults. Optional inputs + without an explicit default get a type-appropriate zero value + (empty string, 0, false, [], {}) so they render cleanly in + templates without requiring ``| default()`` guards. Args: inputs: The input values provided at runtime. @@ -1736,8 +1750,9 @@ def _apply_input_defaults(self, inputs: dict[str, Any]) -> dict[str, Any]: if input_def.default is not None: merged[name] = input_def.default elif not input_def.required: - # Optional with no default - set to None so templates can check it - merged[name] = None + # Optional with no explicit default — use type-appropriate + # zero value so templates render cleanly (not "None"). + merged[name] = self._TYPE_ZERO_VALUES.get(input_def.type, None) return merged diff --git a/tests/test_engine/test_workflow.py b/tests/test_engine/test_workflow.py index 14843a4..463587c 100644 --- a/tests/test_engine/test_workflow.py +++ b/tests/test_engine/test_workflow.py @@ -14,6 +14,7 @@ AgentDef, ContextConfig, GateOption, + InputDef, LimitsConfig, OutputField, ParallelGroup, @@ -298,6 +299,52 @@ def mock_handler(agent, prompt, context): # Workflow.input.goal should not be in agent2's context since it's not in input list assert "other" not in agent2_context.get("workflow", {}).get("input", {}) + @pytest.mark.asyncio + async def test_optional_input_defaults_render_cleanly(self) -> None: + """Optional inputs without defaults use type-appropriate zero values. + + Ensures optional string inputs render as '' (not 'None'), + optional numbers as 0 (not 'None'), etc. + """ + config = WorkflowConfig( + workflow=WorkflowDef( + name="optional-defaults", + entry_point="echo", + input={ + "required_id": InputDef(type="number", required=True), + "optional_msg": InputDef(type="string", required=False), + "optional_count": InputDef(type="number", required=False), + "with_default": InputDef(type="string", required=False, default="hello"), + }, + ), + agents=[ + AgentDef( + name="echo", + type="script", + command="pwsh", + args=[ + "-Command", + ( + "Write-Output 'msg={{ workflow.input.optional_msg }}" + " count={{ workflow.input.optional_count }}" + " def={{ workflow.input.with_default }}'; exit 0" + ), + ], + routes=[RouteDef(to="$end")], + ), + ], + ) + + provider = CopilotProvider(mock_handler=lambda a, p, c: {}) + engine = WorkflowEngine(config, provider) + + await engine.run({"required_id": 42}) + + stdout = engine.context.agent_outputs["echo"]["stdout"].strip() + assert "msg=" in stdout + assert "None" not in stdout # Should never render Python's None + assert "def=hello" in stdout # Explicit default works + class TestWorkflowEngineRouting: """Tests for workflow routing.""" From cd8dac3bdc215ea7891f0b95014e8ed2ab7c73ee Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Tue, 28 Apr 2026 09:57:15 -0700 Subject: [PATCH 2/2] fix(engine): avoid mutable shared defaults in _TYPE_ZERO_VALUES Replace shared [] and {} instances in the class-level _TYPE_ZERO_VALUES dict with a _zero_value_for_type() method that returns fresh copies for mutable types (array, object). Prevents potential shared-state bugs if a caller ever mutates the returned default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/conductor/engine/workflow.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/conductor/engine/workflow.py b/src/conductor/engine/workflow.py index ac29a04..41bdf5b 100644 --- a/src/conductor/engine/workflow.py +++ b/src/conductor/engine/workflow.py @@ -1719,14 +1719,21 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: # Type-appropriate zero values for optional inputs with no declared default. # Using None causes templates to render "None" instead of empty string, # and | default() won't catch None without the boolean=true flag. + # Note: mutable types (array, object) return fresh copies via the method below. _TYPE_ZERO_VALUES: dict[str, Any] = { "string": "", "number": 0, "boolean": False, - "array": [], - "object": {}, } + def _zero_value_for_type(self, type_name: str) -> Any: + """Return a type-appropriate zero value, with fresh copies for mutable types.""" + if type_name == "array": + return [] + if type_name == "object": + return {} + return self._TYPE_ZERO_VALUES.get(type_name) + def _apply_input_defaults(self, inputs: dict[str, Any]) -> dict[str, Any]: """Apply default values from input schema for missing optional inputs. @@ -1752,7 +1759,7 @@ def _apply_input_defaults(self, inputs: dict[str, Any]) -> dict[str, Any]: elif not input_def.required: # Optional with no explicit default — use type-appropriate # zero value so templates render cleanly (not "None"). - merged[name] = self._TYPE_ZERO_VALUES.get(input_def.type, None) + merged[name] = self._zero_value_for_type(input_def.type) return merged