From 005b606ee981c9b2a009e24ac886c58d2ab61ec5 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 12:53:15 +0300 Subject: [PATCH 1/2] fix(template): support boolean argument in Jinja default filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conductor overrides Jinja's built-in `default` filter with a two-argument implementation, so templates using the standard third `boolean` argument failed with TypeError: _default_filter() takes from 1 to 2 positional arguments but 3 were given. Changes: - _default_filter now accepts boolean: bool = False - boolean=False (default): preserves current Conductor behavior — only None maps to the default; "", 0, False, [], {} pass through as-is - boolean=True: standard Jinja falsey semantics — any falsey value maps to the default; truthy values pass through unchanged - The `default` parameter name is kept (not renamed to `default_value`) so existing callers passing it as a keyword argument keep working Tests (14 new under TestTemplateRendererDefaultFilter): - boolean=True with each falsey value returns the default - boolean=True with a truthy value returns the value unchanged - Two-argument form: None maps to default; "", 0, False, [], {} pass through - Explicit boolean=False for the same edge cases Out of scope: Jinja Undefined handling is unchanged — Conductor runs with StrictUndefined, so undefined variables raise TemplateError before they ever reach the filter (fail-fast preserved). Fixes #288 --- src/conductor/executor/template.py | 15 ++- tests/test_executor/test_template.py | 143 +++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/conductor/executor/template.py b/src/conductor/executor/template.py index e16f27f4..d73e91cb 100644 --- a/src/conductor/executor/template.py +++ b/src/conductor/executor/template.py @@ -80,18 +80,23 @@ def _json_filter(value: Any, indent: int = 2) -> str: return json.dumps(value, indent=indent, default=str) @staticmethod - def _default_filter(value: Any, default: Any = "") -> Any: + def _default_filter(value: Any, default: Any = "", boolean: bool = False) -> Any: """Return default if value is None or undefined. Args: value: The value to check. - default: Default value to return if value is None. + default: Default value to return if value is None or falsey. + boolean: If True, return default for any falsey value. Returns: - The value if not None, otherwise the default. + The value if not None (or not falsey if boolean=True), otherwise the default. """ - if value is None: - return default + if boolean: + if not value: + return default + else: + if value is None: + return default return value def render(self, template: str, context: dict[str, Any]) -> str: diff --git a/tests/test_executor/test_template.py b/tests/test_executor/test_template.py index d20311f3..8b73058e 100644 --- a/tests/test_executor/test_template.py +++ b/tests/test_executor/test_template.py @@ -120,6 +120,149 @@ def test_default_filter_empty_string(self) -> None: ) assert result == "Value: " + def test_default_filter_boolean_true_falsey(self) -> None: + """Test that default filter with boolean=True returns default for falsey values. + + Requirements: + - When boolean=True, falsey values like None, empty string, 0, False, + empty list, and empty dict must return the fallback default value. + """ + renderer = TemplateRenderer() + falsey_values = [None, "", 0, False, [], {}] + for val in falsey_values: + result = renderer.render( + "Value: {{ value | default('fallback', boolean=True) }}", + {"value": val}, + ) + assert result == "Value: fallback" + + result_shorthand = renderer.render( + "Value: {{ value | default('fallback', true) }}", + {"value": val}, + ) + assert result_shorthand == "Value: fallback" + + def test_default_filter_boolean_true_truthy(self) -> None: + """Test that default filter with boolean=True returns the truthy value. + + Requirements: + - When boolean=True, truthy values must be returned unchanged. + """ + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=True) }}", + {"value": "actual"}, + ) + assert result == "Value: actual" + + def test_default_filter_two_arg_none(self) -> None: + """Test default filter with two arguments returns default when value is None.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": None}, + ) + assert result == "Value: fallback" + + def test_default_filter_two_arg_empty_string_preserved(self) -> None: + """Test default filter with two arguments preserves empty string.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": ""}, + ) + assert result == "Value: " + + def test_default_filter_two_arg_zero_preserved(self) -> None: + """Test default filter with two arguments preserves 0 and returns '0'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": 0}, + ) + assert result == "Value: 0" + + def test_default_filter_two_arg_false_preserved(self) -> None: + """Test default filter with two arguments preserves False and returns 'False'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": False}, + ) + assert result == "Value: False" + + def test_default_filter_two_arg_empty_list_preserved(self) -> None: + """Test default filter with two arguments preserves empty list and returns '[]'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": []}, + ) + assert result == "Value: []" + + def test_default_filter_two_arg_empty_dict_preserved(self) -> None: + """Test default filter with two arguments preserves empty dict and returns '{}'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": {}}, + ) + assert result == "Value: {}" + + def test_default_filter_boolean_false_none(self) -> None: + """Test default filter with boolean=False returns default when value is None.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": None}, + ) + assert result == "Value: fallback" + + def test_default_filter_boolean_false_empty_string_preserved(self) -> None: + """Test default filter with boolean=False preserves empty string.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": ""}, + ) + assert result == "Value: " + + def test_default_filter_boolean_false_zero_preserved(self) -> None: + """Test default filter with boolean=False preserves 0 and returns '0'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": 0}, + ) + assert result == "Value: 0" + + def test_default_filter_boolean_false_false_preserved(self) -> None: + """Test default filter with boolean=False preserves False and returns 'False'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": False}, + ) + assert result == "Value: False" + + def test_default_filter_boolean_false_empty_list_preserved(self) -> None: + """Test default filter with boolean=False preserves empty list and returns '[]'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": []}, + ) + assert result == "Value: []" + + def test_default_filter_boolean_false_empty_dict_preserved(self) -> None: + """Test default filter with boolean=False preserves empty dict and returns '{}'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": {}}, + ) + assert result == "Value: {}" + class TestTemplateRendererConditionals: """Tests for conditional expressions in templates.""" From 0ab6c6c1882f06b759590e6547181f17638e3a33 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 20:57:24 +0300 Subject: [PATCH 2/2] fix(template): return fallback for undefined vars in default filter Under StrictUndefined, a missing variable reaches the custom `default` filter as a StrictUndefined object. The boolean=True path then called StrictUndefined.__bool__ and raised instead of returning the fallback, so both `{{ v | default('x') }}` and `{{ v | default('x', true) }}` raised for undefined variables. This broke templates that use the documented Jinja calling convention to guard optional values. Check isinstance(value, Undefined) first so any undefined value returns the fallback, matching Jinja's built-in default() semantics while keeping StrictUndefined fail-fast for unguarded expressions. Reword the default param docstring to describe the actual behavior (fallback for None, Undefined, or any falsey value when boolean=True). Also correct a stale comment in workflow.py that claimed default() cannot catch None without boolean=true. Add regression tests covering a missing variable in both the two-arg and boolean=True forms (including the positional true shorthand) and a missing nested dict key. Addresses review feedback on #292. Fixes #288. --- src/conductor/engine/workflow.py | 4 +-- src/conductor/executor/template.py | 20 +++++++----- tests/test_executor/test_template.py | 49 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/conductor/engine/workflow.py b/src/conductor/engine/workflow.py index cd7e11cb..e035c876 100644 --- a/src/conductor/engine/workflow.py +++ b/src/conductor/engine/workflow.py @@ -3998,8 +3998,8 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: 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. + # Using None causes templates to render the literal "None" instead of a clean + # value, so we substitute a type-appropriate zero value up front. # Note: mutable types (array, object) return fresh copies via the method below. _TYPE_ZERO_VALUES: dict[str, Any] = { "string": "", diff --git a/src/conductor/executor/template.py b/src/conductor/executor/template.py index d73e91cb..fcf685d3 100644 --- a/src/conductor/executor/template.py +++ b/src/conductor/executor/template.py @@ -9,7 +9,7 @@ import json from typing import Any -from jinja2 import BaseLoader, Environment, StrictUndefined, TemplateSyntaxError +from jinja2 import BaseLoader, Environment, StrictUndefined, TemplateSyntaxError, Undefined from jinja2 import UndefinedError as Jinja2UndefinedError from conductor.exceptions import TemplateError @@ -85,18 +85,20 @@ def _default_filter(value: Any, default: Any = "", boolean: bool = False) -> Any Args: value: The value to check. - default: Default value to return if value is None or falsey. + default: Value returned for None, Undefined, or for any falsey value + when boolean=True. boolean: If True, return default for any falsey value. Returns: - The value if not None (or not falsey if boolean=True), otherwise the default. + The value if not None/Undefined (or not falsey if boolean=True), + otherwise the default. """ - if boolean: - if not value: - return default - else: - if value is None: - return default + if isinstance(value, Undefined): + return default + if boolean and not value: + return default + if value is None: + return default return value def render(self, template: str, context: dict[str, Any]) -> str: diff --git a/tests/test_executor/test_template.py b/tests/test_executor/test_template.py index 8b73058e..4dc430ce 100644 --- a/tests/test_executor/test_template.py +++ b/tests/test_executor/test_template.py @@ -263,6 +263,55 @@ def test_default_filter_boolean_false_empty_dict_preserved(self) -> None: ) assert result == "Value: {}" + def test_default_filter_missing_variable_returns_default(self) -> None: + """Test default filter returns fallback for an undefined variable. + + Requirements: + - A missing variable guarded by default() must render the fallback instead + of raising TemplateError under StrictUndefined. + """ + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ missing | default('fallback') }}", + {}, + ) + assert result == "Value: fallback" + + def test_default_filter_boolean_true_missing_variable_returns_default(self) -> None: + """Test default filter with boolean=True returns fallback for undefined values. + + Requirements: + - A missing variable guarded by default(..., boolean=True) or default(..., true) + must render the fallback instead of evaluating StrictUndefined truthiness. + """ + renderer = TemplateRenderer() + + result = renderer.render( + "Value: {{ missing | default('fallback', boolean=True) }}", + {}, + ) + assert result == "Value: fallback" + + shorthand_result = renderer.render( + "Value: {{ missing | default('fallback', true) }}", + {}, + ) + assert shorthand_result == "Value: fallback" + + def test_default_filter_missing_nested_key_returns_default(self) -> None: + """Test default filter returns fallback for a missing nested dict key. + + Requirements: + - A missing key on an existing dict guarded by default() must render the + fallback instead of raising TemplateError. + """ + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ data.missing | default('fallback') }}", + {"data": {}}, + ) + assert result == "Value: fallback" + class TestTemplateRendererConditionals: """Tests for conditional expressions in templates."""