Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions src/conductor/engine/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
47 changes: 47 additions & 0 deletions tests/test_engine/test_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
AgentDef,
ContextConfig,
GateOption,
InputDef,
LimitsConfig,
OutputField,
ParallelGroup,
Expand Down Expand Up @@ -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."""
Expand Down
Loading