diff --git a/src/conductor/engine/workflow.py b/src/conductor/engine/workflow.py index 484192b..41bdf5b 100644 --- a/src/conductor/engine/workflow.py +++ b/src/conductor/engine/workflow.py @@ -1716,11 +1716,32 @@ 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. + # Note: mutable types (array, object) return fresh copies via the method below. + _TYPE_ZERO_VALUES: dict[str, Any] = { + "string": "", + "number": 0, + "boolean": False, + } + + 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. 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 +1757,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._zero_value_for_type(input_def.type) 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."""