From 7276dd444dd0444017f8b3a89da102eff57387e0 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:17:45 +0300 Subject: [PATCH 01/10] feat(template): add FileString class to track origin of !file content --- src/conductor/file_string.py | 41 ++++++++++++++++++++ tests/test_file_string.py | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/conductor/file_string.py create mode 100644 tests/test_file_string.py diff --git a/src/conductor/file_string.py b/src/conductor/file_string.py new file mode 100644 index 00000000..3515b75d --- /dev/null +++ b/src/conductor/file_string.py @@ -0,0 +1,41 @@ +"""Leaf module defining FileString class with source path metadata. + +Kept as a leaf module (no intra-``conductor`` imports) so it can be imported +from any layer without risking an import cycle. +""" + +from __future__ import annotations + +from pathlib import Path + + +class FileString(str): + """A string subclass that retains the origin file path. + + Used when loading external prompt files via the ``!file`` tag to track + their paths, allowing relative template inclusions in Jinja environments. + """ + + source_path: Path + + def __new__(cls, value: str, source_path: Path | str) -> FileString: + """Create a new FileString instance. + + Args: + value: The string content. + source_path: The file path of the source file. + + Returns: + A new FileString instance. + """ + obj = super().__new__(cls, value) + obj.source_path = Path(source_path) + return obj + + def __getnewargs__(self) -> tuple[str, Path]: # type: ignore[override] + """Return arguments for constructor during pickle/deepcopy serialization. + + Returns: + A tuple of the string value and its source path. + """ + return (str(self), self.source_path) diff --git a/tests/test_file_string.py b/tests/test_file_string.py new file mode 100644 index 00000000..11c271eb --- /dev/null +++ b/tests/test_file_string.py @@ -0,0 +1,74 @@ +"""Unit tests for the FileString class carrying source path metadata.""" + +from __future__ import annotations + +import copy +import pickle +from pathlib import Path + +from conductor.file_string import FileString + + +def test_construction_and_source_path() -> None: + # Requirement: FileString construction accepts str and Path, normalizes to Path, + # and exposes the source_path attribute. + fs_path = FileString("hello", Path("/tmp/x.md")) + assert fs_path.source_path == Path("/tmp/x.md") + + fs_str = FileString("world", "/tmp/y.md") + assert fs_str.source_path == Path("/tmp/y.md") + + +def test_isinstance_str() -> None: + # Requirement: FileString must be a subclass of str and isinstance(..., str) must be True. + fs = FileString("test", Path("a.txt")) + assert isinstance(fs, str) + assert isinstance(fs, FileString) + + +def test_equality_and_hashing() -> None: + # Requirement: Equality and hashing must work like plain str, and the subclass adds + # no custom equality semantics (i.e. equality ignores source_path). + fs1 = FileString("abc", Path("x")) + fs2 = FileString("abc", Path("y")) + + assert fs1 == "abc" + assert fs1 == fs2 + assert hash(fs1) == hash("abc") + + # Verify different string values are not equal + fs3 = FileString("def", Path("x")) + assert fs1 != fs3 + + +def test_deepcopy_preserves_attributes() -> None: + # Requirement: copy.deepcopy(FileString) must return a FileString with the same value + # and the same source_path, and must not crash. + fs = FileString("value", Path("origin.md")) + fs_deep = copy.deepcopy(fs) + + assert isinstance(fs_deep, FileString) + assert fs_deep == "value" + assert fs_deep.source_path == Path("origin.md") + + +def test_copy_preserves_attributes() -> None: + # Requirement: copy.copy (shallow copy) must also preserve the source_path. + fs = FileString("value", Path("origin.md")) + fs_shallow = copy.copy(fs) + + assert isinstance(fs_shallow, FileString) + assert fs_shallow == "value" + assert fs_shallow.source_path == Path("origin.md") + + +def test_pickle_roundtrip() -> None: + # Requirement: Pickling and unpickling a FileString must preserve its string value + # and its source_path metadata. + fs = FileString("to_pickle", Path("safe.txt")) + dumped = pickle.dumps(fs) + loaded = pickle.loads(dumped) + + assert isinstance(loaded, FileString) + assert loaded == "to_pickle" + assert loaded.source_path == Path("safe.txt") From ea35b9b462b707a3699024f4a23bddd711640a79 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:25:13 +0300 Subject: [PATCH 02/10] feat(loader): preserve source path via FileString --- src/conductor/config/loader.py | 7 +- tests/test_config/test_file_tag_filestring.py | 111 ++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/test_config/test_file_tag_filestring.py diff --git a/src/conductor/config/loader.py b/src/conductor/config/loader.py index c304f4fc..a5ad2907 100644 --- a/src/conductor/config/loader.py +++ b/src/conductor/config/loader.py @@ -18,6 +18,7 @@ from conductor.config.schema import WorkflowConfig from conductor.exceptions import ConfigurationError +from conductor.file_string import FileString # Pattern to match ${VAR} or ${VAR:-default} ENV_VAR_PATTERN = re.compile(r"\$\{([^}:]+)(?::-([^}]*))?\}") @@ -86,6 +87,8 @@ def _resolve_env_vars_recursive(data: Any) -> Any: return {k: _resolve_env_vars_recursive(v) for k, v in data.items()} elif isinstance(data, list): return [_resolve_env_vars_recursive(item) for item in data] + elif isinstance(data, FileString): + return FileString(resolve_env_vars(data), data.source_path) elif isinstance(data, str): return resolve_env_vars(data) else: @@ -162,10 +165,10 @@ def construct_file_tag(self, node: Any) -> Any: if isinstance(parsed, (dict, list)): return parsed # Scalar YAML or None → return raw string content - return content + return FileString(content, source_path=file_path) except YAMLError: # Not valid YAML → return as raw string - return content + return FileString(content, source_path=file_path) finally: cls._base_dir = saved_base_dir cls._file_stack.pop() diff --git a/tests/test_config/test_file_tag_filestring.py b/tests/test_config/test_file_tag_filestring.py new file mode 100644 index 00000000..84366d0d --- /dev/null +++ b/tests/test_config/test_file_tag_filestring.py @@ -0,0 +1,111 @@ +"""Tests for the FileString metadata preservation and coercion for the !file tag.""" + +from __future__ import annotations + +from pathlib import Path + +from conductor.config.loader import ConfigLoader +from conductor.file_string import FileString + + +def test_file_tag_returns_file_string_for_raw_content(tmp_path: Path) -> None: + # Requirement: !file loaded raw string content returns FileString + # preserving path before validation. + prompt_file = tmp_path / "prompt.md" + prompt_file.write_text("Hello prompt content") + + loader = ConfigLoader() + loader._constructor_cls._base_dir = tmp_path + loader._constructor_cls._file_stack = [] + + result = loader._yaml.load("!file prompt.md") + # Clean up constructor state + loader._constructor_cls._base_dir = Path(".") + loader._constructor_cls._file_stack = [] + + assert isinstance(result, FileString) + assert result == "Hello prompt content" + assert result.source_path == (tmp_path / "prompt.md").resolve() + + +def test_file_tag_non_prompt_fields_coerce_to_str(tmp_path: Path) -> None: + # Requirement: non-prompt fields loaded via !file (command, stdin, reason, value) + # coerce to plain str after Pydantic validation (wrap-validators are not yet registered). + (tmp_path / "cmd.sh").write_text("echo 'hello'") + (tmp_path / "payload.txt").write_text("stdin content") + (tmp_path / "reason.md").write_text("termination completed") + (tmp_path / "expr.j2").write_text("hello {{ value }}") + + yaml_content = """\ +workflow: + name: test-coercion + entry_point: script_agent + +agents: + - name: script_agent + type: script + command: !file cmd.sh + stdin: !file payload.txt + routes: + - to: set_step + - name: set_step + type: set + value: !file expr.j2 + routes: + - to: terminate_step + - name: terminate_step + type: terminate + status: success + reason: !file reason.md +""" + + loader = ConfigLoader() + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text(yaml_content) + + config = loader.load(workflow_file) + + agents_map = {agent.name: agent for agent in config.agents} + script_agent = agents_map["script_agent"] + set_step = agents_map["set_step"] + terminate_step = agents_map["terminate_step"] + + # Verify that command is coerced to plain str and is not FileString + assert isinstance(script_agent.command, str) + assert not isinstance(script_agent.command, FileString) + assert script_agent.command == "echo 'hello'" + + # Verify that stdin is coerced to plain str and is not FileString + assert isinstance(script_agent.stdin, str) + assert not isinstance(script_agent.stdin, FileString) + assert script_agent.stdin == "stdin content" + + # Verify that value is coerced to plain str and is not FileString + assert isinstance(set_step.value, str) + assert not isinstance(set_step.value, FileString) + assert set_step.value == "hello {{ value }}" + + # Verify that reason is coerced to plain str and is not FileString + assert isinstance(terminate_step.reason, str) + assert not isinstance(terminate_step.reason, FileString) + assert terminate_step.reason == "termination completed" + + +def test_file_tag_parsed_yaml_returns_plain_dict(tmp_path: Path) -> None: + # Requirement: !file on a file containing YAML dict/list returns + # a plain dict/list, not FileString. + dict_file = tmp_path / "schema.yaml" + dict_file.write_text("foo: bar\nkey: value") + + loader = ConfigLoader() + loader._constructor_cls._base_dir = tmp_path + loader._constructor_cls._file_stack = [] + + result = loader._yaml.load("!file schema.yaml") + # Clean up constructor state + loader._constructor_cls._base_dir = Path(".") + loader._constructor_cls._file_stack = [] + + assert isinstance(result, dict) + assert not isinstance(result, FileString) + assert result == {"foo": "bar", "key": "value"} From 96fc56a9fb164477357af0d19a442ac2aff5a129 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:30:31 +0300 Subject: [PATCH 03/10] feat(loader): keep FileString when resolving environment variables --- .../test_file_tag_env_filestring.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/test_config/test_file_tag_env_filestring.py diff --git a/tests/test_config/test_file_tag_env_filestring.py b/tests/test_config/test_file_tag_env_filestring.py new file mode 100644 index 00000000..c021d105 --- /dev/null +++ b/tests/test_config/test_file_tag_env_filestring.py @@ -0,0 +1,65 @@ +"""Tests for environment variable resolution preserving FileString metadata.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +from conductor.config.loader import ConfigLoader, _resolve_env_vars_recursive +from conductor.file_string import FileString + +FIXTURES_DIR = Path(__file__).parent / "fixtures" / "file_tag" + + +def test_env_vars_preserve_file_string_origin() -> None: + # Requirement: _resolve_env_vars_recursive must preserve FileString type and + # source_path when resolving env vars. + # Assertion for malformed input / no env vars: must return unchanged but still FileString. + fs_no_vars = FileString("Hello World!", Path("/tmp/p.md")) + res_no_vars = _resolve_env_vars_recursive(fs_no_vars) + assert isinstance(res_no_vars, FileString) + assert res_no_vars == "Hello World!" + assert res_no_vars.source_path == Path("/tmp/p.md") + + # With env vars: + fs_with_vars = FileString("Hello ${TEST_VAR_XYZ}!", Path("/tmp/p.md")) + with patch.dict(os.environ, {"TEST_VAR_XYZ": "Universe"}): + res = _resolve_env_vars_recursive(fs_with_vars) + assert isinstance(res, FileString) + assert res == "Hello Universe!" + assert res.source_path == Path("/tmp/p.md") + + +def test_env_vars_plain_string_stays_plain_str() -> None: + # Requirement: _resolve_env_vars_recursive must resolve plain str to a + # plain str (not FileString). + plain_str = "Hello ${TEST_VAR_XYZ}!" + with patch.dict(os.environ, {"TEST_VAR_XYZ": "Universe"}): + res = _resolve_env_vars_recursive(plain_str) + assert type(res) is str + assert res == "Hello Universe!" + + +def test_env_vars_in_included_file_still_resolved_e2e() -> None: + # Requirement: E2E check that environment variables inside files loaded + # via !file tag are resolved correctly through ConfigLoader.load_string. + loader = ConfigLoader() + yaml_content = """\ +workflow: + name: env-test + entry_point: agent1 + +agents: + - name: agent1 + model: gpt-4 + prompt: !file env_vars.md + routes: + - to: $end +""" + with patch.dict(os.environ, {"TEST_FILE_TAG_VAR": "World"}): + config = loader.load_string( + yaml_content, + source_path=FIXTURES_DIR / "env_test.yaml", + ) + assert "Hello World" in config.agents[0].prompt From 3c6839d2cb6349095794a58c17acb84cdd716e37 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:35:34 +0300 Subject: [PATCH 04/10] feat(schema): keep FileString subclass in prompt fields --- src/conductor/config/schema.py | 22 +++ tests/test_config/test_filestring_pydantic.py | 179 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tests/test_config/test_filestring_pydantic.py diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 9622b007..5d4636d1 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -13,12 +13,14 @@ ConfigDict, Field, SecretStr, + ValidatorFunctionWrapHandler, field_validator, model_serializer, model_validator, ) from conductor.duration import parse_duration +from conductor.file_string import FileString from conductor.providers.context_tier import ContextTier from conductor.providers.reasoning import ReasoningEffort from conductor.templating import is_jinja_template @@ -1098,6 +1100,26 @@ def reject_bool_duration(cls, v: Any) -> Any: raise ValueError(f"duration must be a number or duration string, not boolean: {v!r}") return v + @field_validator("prompt", mode="wrap") + @classmethod + def preserve_prompt_filestring( + cls, value: Any, handler: ValidatorFunctionWrapHandler + ) -> Any: + """Preserve FileString subclass on validation for the prompt field.""" + if isinstance(value, FileString): + return value + return handler(value) + + @field_validator("system_prompt", mode="wrap") + @classmethod + def preserve_system_prompt_filestring( + cls, value: Any, handler: ValidatorFunctionWrapHandler + ) -> Any: + """Preserve FileString subclass on validation for the system_prompt field.""" + if isinstance(value, FileString): + return value + return handler(value) + @model_validator(mode="after") def validate_agent_type(self) -> AgentDef: """Ensure agent has required fields for its type.""" diff --git a/tests/test_config/test_filestring_pydantic.py b/tests/test_config/test_filestring_pydantic.py new file mode 100644 index 00000000..10d39304 --- /dev/null +++ b/tests/test_config/test_filestring_pydantic.py @@ -0,0 +1,179 @@ +"""Tests for Pydantic validation behavior with FileString instances.""" + +from __future__ import annotations + +from pathlib import Path + +from conductor.config.schema import AgentDef, WorkflowConfig +from conductor.file_string import FileString + + +def test_prompt_filestring_preserved() -> None: + # Requirement: A FileString prompt passed to WorkflowConfig.model_validate + # must be preserved as a FileString instance in AgentDef.prompt, retaining source_path. + fs_prompt = FileString("Hello prompt content", "/path/to/prompt.md") + + data = { + "workflow": { + "name": "test-workflow", + "entry_point": "my_agent", + }, + "agents": [ + { + "name": "my_agent", + "prompt": fs_prompt, + } + ], + } + + config = WorkflowConfig.model_validate(data) + agent = config.agents[0] + + assert isinstance(agent.prompt, FileString) + assert agent.prompt == "Hello prompt content" + assert agent.prompt.source_path == Path("/path/to/prompt.md") + + +def test_system_prompt_filestring_preserved() -> None: + # Requirement: A FileString system_prompt passed to WorkflowConfig.model_validate + # must be preserved as a FileString instance in AgentDef.system_prompt, retaining source_path. + fs_sys = FileString("Hello system content", "/path/to/sys.md") + + data = { + "workflow": { + "name": "test-workflow", + "entry_point": "my_agent", + }, + "agents": [ + { + "name": "my_agent", + "prompt": "some prompt", + "system_prompt": fs_sys, + } + ], + } + + config = WorkflowConfig.model_validate(data) + agent = config.agents[0] + + assert isinstance(agent.system_prompt, FileString) + assert agent.system_prompt == "Hello system content" + assert agent.system_prompt.source_path == Path("/path/to/sys.md") + + +def test_plain_string_prompt_stays_str() -> None: + # Requirement: A plain string prompt must remain a plain string (exact type str) + # after validation, without being converted to FileString or any other type. + data = { + "workflow": { + "name": "test-workflow", + "entry_point": "my_agent", + }, + "agents": [ + { + "name": "my_agent", + "prompt": "plain prompt", + } + ], + } + + config = WorkflowConfig.model_validate(data) + agent = config.agents[0] + + assert type(agent.prompt) is str + assert not isinstance(agent.prompt, FileString) + + +def test_model_dump_json_returns_plain_string() -> None: + # Requirement: model_dump(mode="json") serializes FileString prompt as a plain string. + fs_prompt = FileString("Hello prompt content", "/path/to/prompt.md") + + data = { + "workflow": { + "name": "test-workflow", + "entry_point": "my_agent", + }, + "agents": [ + { + "name": "my_agent", + "prompt": fs_prompt, + } + ], + } + + config = WorkflowConfig.model_validate(data) + dumped = config.model_dump(mode="json") + + prompt_val = dumped["agents"][0]["prompt"] + assert type(prompt_val) is str + assert prompt_val == "Hello prompt content" + + +def test_model_copy_shallow_preserves_filestring() -> None: + # Requirement: Shallow copying of an AgentDef (e.g. model_copy()) + # preserves the FileString subclass. + fs_prompt = FileString("Hello prompt content", "/path/to/prompt.md") + fs_sys = FileString("Hello system content", "/path/to/sys.md") + + agent = AgentDef( + name="my_agent", + prompt=fs_prompt, + system_prompt=fs_sys, + ) + + copied = agent.model_copy() + + assert isinstance(copied.prompt, FileString) + assert copied.prompt.source_path == Path("/path/to/prompt.md") + assert isinstance(copied.system_prompt, FileString) + assert copied.system_prompt.source_path == Path("/path/to/sys.md") + + +def test_model_dump_validate_roundtrip_yields_plain_str() -> None: + # Requirement: Running a round-trip of model_dump(mode="json") and then validating + # the output yields plain strings (loss of FileString on JSON serialization). + fs_prompt = FileString("Hello prompt content", "/path/to/prompt.md") + + data = { + "workflow": { + "name": "test-workflow", + "entry_point": "my_agent", + }, + "agents": [ + { + "name": "my_agent", + "prompt": fs_prompt, + } + ], + } + + config = WorkflowConfig.model_validate(data) + dumped = config.model_dump(mode="json") + + revalidated = WorkflowConfig.model_validate(dumped) + agent = revalidated.agents[0] + + assert type(agent.prompt) is str + assert not isinstance(agent.prompt, FileString) + + +def test_none_system_prompt_stays_none() -> None: + # Requirement: An omitted or None system_prompt must validate to None and not trigger errors. + data = { + "workflow": { + "name": "test-workflow", + "entry_point": "my_agent", + }, + "agents": [ + { + "name": "my_agent", + "prompt": "some prompt", + "system_prompt": None, + } + ], + } + + config = WorkflowConfig.model_validate(data) + agent = config.agents[0] + + assert agent.system_prompt is None From a83b917a02ee44d29f72c5b5016a237d19efd5be Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:36:28 +0300 Subject: [PATCH 05/10] feat(template): support Jinja includes for file-based prompts --- src/conductor/executor/template.py | 24 ++- .../test_executor/test_template_filestring.py | 176 ++++++++++++++++++ 2 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/test_executor/test_template_filestring.py diff --git a/src/conductor/executor/template.py b/src/conductor/executor/template.py index e16f27f4..59b298bd 100644 --- a/src/conductor/executor/template.py +++ b/src/conductor/executor/template.py @@ -9,10 +9,11 @@ import json from typing import Any -from jinja2 import BaseLoader, Environment, StrictUndefined, TemplateSyntaxError +from jinja2 import BaseLoader, Environment, FileSystemLoader, StrictUndefined, TemplateSyntaxError from jinja2 import UndefinedError as Jinja2UndefinedError from conductor.exceptions import TemplateError +from conductor.file_string import FileString class _DictSafeEnvironment(Environment): @@ -63,8 +64,12 @@ def __init__(self) -> None: ) # Register custom filters - self.env.filters["json"] = self._json_filter - self.env.filters["default"] = self._default_filter + self._register_filters(self.env) + + def _register_filters(self, env: Environment) -> None: + """Register custom filters on the Jinja2 environment.""" + env.filters["json"] = self._json_filter + env.filters["default"] = self._default_filter @staticmethod def _json_filter(value: Any, indent: int = 2) -> str: @@ -108,7 +113,18 @@ def render(self, template: str, context: dict[str, Any]) -> str: TemplateError: If rendering fails due to missing variables or syntax errors. """ try: - tmpl = self.env.from_string(template) + if isinstance(template, FileString) and template.source_path.exists(): + env = _DictSafeEnvironment( + loader=FileSystemLoader(str(template.source_path.parent)), + undefined=StrictUndefined, + autoescape=False, + keep_trailing_newline=True, + ) + self._register_filters(env) + code = env.compile(str(template), filename=str(template.source_path)) + tmpl = env.template_class.from_code(env, code, env.globals) + else: + tmpl = self.env.from_string(template) return tmpl.render(**context) except Jinja2UndefinedError as e: # Extract the variable name from the error message diff --git a/tests/test_executor/test_template_filestring.py b/tests/test_executor/test_template_filestring.py new file mode 100644 index 00000000..fc60202f --- /dev/null +++ b/tests/test_executor/test_template_filestring.py @@ -0,0 +1,176 @@ +"""Unit tests for TemplateRenderer with FileString templates. + +These tests verify that TemplateRenderer correctly loads templates using FileSystemLoader +when a FileString is provided, supporting includes, imports, and inheritance, +while maintaining feature parity (custom filters, StrictUndefined, dict-safe attributes). +""" + +from pathlib import Path, PureWindowsPath + +import pytest + +from conductor.exceptions import TemplateError +from conductor.executor.template import TemplateRenderer +from conductor.file_string import FileString + + +def test_render_file_string_with_include(tmp_path: Path) -> None: + """Requirement: tmp_path with main.md containing {% include "_partial.md" %} and _partial.md. + + Render FileString(main_content, main_path) -> includes partial content; + context vars render in both. + """ + main_file = tmp_path / "main.md" + partial_file = tmp_path / "_partial.md" + + main_file.write_text("Hello {{ name }}! {% include '_partial.md' %}") + partial_file.write_text("This is a partial template for {{ target }}.") + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + result = renderer.render(file_string, {"name": "Alice", "target": "Bob"}) + assert result == "Hello Alice! This is a partial template for Bob." + + +def test_render_file_string_with_import(tmp_path: Path) -> None: + """Requirement: import macro from another file. + + Template contains {% import "_macros.md" as m %}{{ m.greet(name) }}. + """ + main_file = tmp_path / "main.md" + macros_file = tmp_path / "_macros.md" + + main_file.write_text("{% import '_macros.md' as m %}{{ m.greet(name) }}") + macros_file.write_text("{% macro greet(val) %}Greetings, {{ val }}!{% endmacro %}") + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + result = renderer.render(file_string, {"name": "Charlie"}) + assert result == "Greetings, Charlie!" + + +def test_render_file_string_with_extends(tmp_path: Path) -> None: + """Requirement: extend a base template. + + Template contains {% extends "_base.md" %}{% block body %}... + with _base.md declaring {% block body %}{% endblock %}. + """ + main_file = tmp_path / "main.md" + base_file = tmp_path / "_base.md" + + main_file.write_text("{% extends '_base.md' %}{% block body %}Child Content{% endblock %}") + base_file.write_text("Base Header | {% block body %}{% endblock %} | Base Footer") + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + result = renderer.render(file_string, {}) + assert result == "Base Header | Child Content | Base Footer" + + +def test_render_system_prompt_file_string_with_include(tmp_path: Path) -> None: + """Requirement: same include mechanics for a system prompt simulation. + + This ensures that prompt vs system_prompt is not special-cased and both + rely on the underlying FileString include support. + """ + sys_file = tmp_path / "system.md" + partial_file = tmp_path / "_partial.md" + + sys_file.write_text("System instructions: {% include '_partial.md' %}") + partial_file.write_text("Be concise.") + + file_string = FileString(sys_file.read_text(), sys_file) + renderer = TemplateRenderer() + + result = renderer.render(file_string, {}) + assert result == "System instructions: Be concise." + + +def test_inline_prompt_with_include_still_fails() -> None: + """Requirement: plain str with {% include %} raises TemplateError. + + Ensures that default inline template rendering does not use FileSystemLoader + and throws TemplateError when attempting to load another template. + """ + renderer = TemplateRenderer() + with pytest.raises(TemplateError): + renderer.render("Hello {% include '_partial.md' %}", {}) + + +def test_file_string_custom_filters_available(tmp_path: Path) -> None: + """Requirement: json and default filters work in the FileSystemLoader branch. + + e.g. {{ items | json }} and {{ missing | default("x") }} semantics. + Using defined None value for default filter because of StrictUndefined. + """ + main_file = tmp_path / "main.md" + main_file.write_text("JSON: {{ items | json }}, Default: {{ missing | default('fallback') }}") + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + result = renderer.render(file_string, {"items": ["a", "b"], "missing": None}) + assert 'JSON: [\n "a",\n "b"\n]' in result + assert "Default: fallback" in result + + +def test_file_string_strict_undefined_still_enforced(tmp_path: Path) -> None: + """Requirement: missing variable in FileString template raises TemplateError. + + Maintains parity with the inline StrictUndefined rendering. + """ + main_file = tmp_path / "main.md" + main_file.write_text("Hello {{ undefined_var }}!") + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + with pytest.raises(TemplateError) as exc_info: + renderer.render(file_string, {}) + assert "undefined_var" in str(exc_info.value).lower() + + +def test_file_string_dict_safe_getattr(tmp_path: Path) -> None: + """Requirement: _DictSafeEnvironment behavior preserved in file branch. + + Ensures dict keys named like dict methods (e.g., "items") are accessed correctly + rather than returning the method itself. + """ + main_file = tmp_path / "main.md" + main_file.write_text("Items count: {{ obj.items }}") + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + result = renderer.render(file_string, {"obj": {"items": 42}}) + assert result == "Items count: 42" + + +def test_file_string_windows_path_conversion(tmp_path: Path) -> None: + """Requirement: test that constructs FileString with a PureWindowsPath-style string + converted via Path; keep it platform-safe so it also passes on Linux. + """ + # 1. Non-existent Windows path - should fall back to inline render safely + win_path_str = "C:\\foo\\bar\\main.md" + path_obj = Path(PureWindowsPath(win_path_str)) + + file_string = FileString("Hello {{ name }}!", path_obj) + renderer = TemplateRenderer() + result = renderer.render(file_string, {"name": "World"}) + assert result == "Hello World!" + + # 2. Existing path converted via PureWindowsPath to ensure FileSystemLoader works + main_file = tmp_path / "main.md" + partial_file = tmp_path / "_partial.md" + main_file.write_text("Hello {% include '_partial.md' %}!") + partial_file.write_text("World") + + win_path = PureWindowsPath(main_file) + path_obj_existing = Path(win_path) + + file_string_existing = FileString(main_file.read_text(), path_obj_existing) + result_existing = renderer.render(file_string_existing, {}) + assert result_existing == "Hello World!" From 4e0f8a0089247976704e589bf473c8844faef35e Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:40:47 +0300 Subject: [PATCH 06/10] feat(template): include search path in TemplateNotFound errors --- src/conductor/executor/template.py | 29 ++++++++++++- .../test_executor/test_template_filestring.py | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/conductor/executor/template.py b/src/conductor/executor/template.py index 59b298bd..6bc24286 100644 --- a/src/conductor/executor/template.py +++ b/src/conductor/executor/template.py @@ -9,7 +9,14 @@ import json from typing import Any -from jinja2 import BaseLoader, Environment, FileSystemLoader, StrictUndefined, TemplateSyntaxError +from jinja2 import ( + BaseLoader, + Environment, + FileSystemLoader, + StrictUndefined, + TemplateNotFound, + TemplateSyntaxError, +) from jinja2 import UndefinedError as Jinja2UndefinedError from conductor.exceptions import TemplateError @@ -112,10 +119,14 @@ def render(self, template: str, context: dict[str, Any]) -> str: Raises: TemplateError: If rendering fails due to missing variables or syntax errors. """ + loader = None + is_file_backed = False try: if isinstance(template, FileString) and template.source_path.exists(): + is_file_backed = True + loader = FileSystemLoader(str(template.source_path.parent)) env = _DictSafeEnvironment( - loader=FileSystemLoader(str(template.source_path.parent)), + loader=loader, undefined=StrictUndefined, autoescape=False, keep_trailing_newline=True, @@ -126,6 +137,20 @@ def render(self, template: str, context: dict[str, Any]) -> str: else: tmpl = self.env.from_string(template) return tmpl.render(**context) + except TemplateNotFound as e: + if is_file_backed and loader is not None: + search_paths = ", ".join(str(p) for p in loader.searchpath) + raise TemplateError( + f"Template not found: '{e.name}'. Searched in: {search_paths}", + suggestion="Check that the template file exists in the search path", + ) from e + else: + raise TemplateError( + "Template rendering failed: loader-dependent Jinja constructs " + "({% include %}, {% import %}, {% extends %}) require a file-backed prompt " + "via prompt: !file ...", + suggestion="Convert the prompt to a file-backed reference using prompt: !file", + ) from e except Jinja2UndefinedError as e: # Extract the variable name from the error message error_msg = str(e) diff --git a/tests/test_executor/test_template_filestring.py b/tests/test_executor/test_template_filestring.py index fc60202f..20d5d0dd 100644 --- a/tests/test_executor/test_template_filestring.py +++ b/tests/test_executor/test_template_filestring.py @@ -174,3 +174,45 @@ def test_file_string_windows_path_conversion(tmp_path: Path) -> None: file_string_existing = FileString(main_file.read_text(), path_obj_existing) result_existing = renderer.render(file_string_existing, {}) assert result_existing == "Hello World!" + + +def test_template_not_found_error_includes_searchpath(tmp_path: Path) -> None: + """Requirement: FileString in tmp_path including missing template.md raises TemplateError. + + The TemplateError message must match the exact format: + "Template not found: ''. Searched in: , , ..." + and contain the absolute tmp_path directory and the name 'missing template.md'. + """ + main_file = tmp_path / "main.md" + main_file.write_text("Hello {% include 'missing template.md' %}") + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + with pytest.raises(TemplateError) as exc_info: + renderer.render(file_string, {}) + expected_msg = f"Template not found: 'missing template.md'. Searched in: {tmp_path.resolve()}" + assert expected_msg in str(exc_info.value) + + +def test_inline_prompt_include_error_is_clear() -> None: + """Requirement: inline str with {% include 'x.md' %} raises TemplateError. + + The TemplateError message must explicitly mention that loader-dependent Jinja + constructs ({% include %} / {% import %} / {% extends %}) require a file-backed prompt + via prompt: !file ... + """ + renderer = TemplateRenderer() + with pytest.raises(TemplateError) as exc_info: + renderer.render("Hello {% include 'x.md' %}", {}) + msg = str(exc_info.value) + assert "prompt: !file" in msg + assert "loader-dependent" in msg or "require a file-backed prompt" in msg + + +def test_non_template_not_found_error_handling_unchanged() -> None: + """Requirement: a non-TemplateNotFound error (e.g. StrictUndefined) still maps + to the "Undefined variable" TemplateError path unchanged. + """ + renderer = TemplateRenderer() + with pytest.raises(TemplateError) as exc_info: + renderer.render("Hello {{ undefined_var }}", {}) + assert "Undefined variable in template: 'undefined_var' is undefined" in str(exc_info.value) From abe3f871fed234e2522df8282f94cf83732ba345 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:47:33 +0300 Subject: [PATCH 07/10] test(engine): integration test for includes in file-based prompts --- .../test_file_prompt_includes.py | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 tests/test_integration/test_file_prompt_includes.py diff --git a/tests/test_integration/test_file_prompt_includes.py b/tests/test_integration/test_file_prompt_includes.py new file mode 100644 index 00000000..5730d0f9 --- /dev/null +++ b/tests/test_integration/test_file_prompt_includes.py @@ -0,0 +1,282 @@ +"""End-to-end coverage for file-backed prompt includes in workflows.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from conductor.config.loader import ConfigLoader +from conductor.config.schema import AgentDef, WorkflowConfig +from conductor.engine.workflow import WorkflowEngine +from conductor.events import WorkflowEvent, WorkflowEventEmitter +from conductor.exceptions import ExecutionError, TemplateError +from conductor.providers.copilot import CopilotProvider + +MockHandler = Callable[[AgentDef, str, dict[str, Any]], dict[str, Any]] + + +def _load_workflow(workflow_file: Path) -> WorkflowConfig: + loader = ConfigLoader() + return loader.load(workflow_file) + + +def _write_prompt_files(tmp_path: Path, *, main_text: str, partial_text: str) -> None: + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir() + (prompts_dir / "main.md.jinja").write_text(main_text) + (prompts_dir / "_shared.md.jinja").write_text(partial_text) + + +@pytest.mark.asyncio +async def test_workflow_with_prompt_file_include(tmp_path: Path) -> None: + """Pipeline renders an included partial from a file-backed agent prompt.""" + # Requirement: !file -> env resolution -> Pydantic -> AgentExecutor renders + # prompt includes before the provider receives the prompt. + _write_prompt_files( + tmp_path, + main_text="Main says {{ workflow.input.topic }}. {% include '_shared.md.jinja' %}", + partial_text="Shared partial uses the include body.", + ) + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text( + """ +workflow: + name: prompt-file-include + entry_point: answerer +agents: + - name: answerer + model: gpt-4 + prompt: !file prompts/main.md.jinja + output: + answer: + type: string + routes: + - to: $end +output: + answer: "{{ answerer.output.answer }}" +""".strip() + ) + received_prompts: list[str] = [] + + def mock_handler(agent: AgentDef, prompt: str, context: dict[str, Any]) -> dict[str, Any]: + received_prompts.append(prompt) + return {"answer": agent.name} + + config = _load_workflow(workflow_file) + engine = WorkflowEngine( + config, + CopilotProvider(mock_handler=mock_handler), + workflow_path=workflow_file, + ) + + result = await engine.run({"topic": "integration"}) + + assert result == {"answer": "answerer"} + assert received_prompts == ["Main says integration. Shared partial uses the include body."] + + +@pytest.mark.asyncio +async def test_workflow_with_system_prompt_file_include(tmp_path: Path) -> None: + """Pipeline renders an included partial from a file-backed system prompt.""" + # Requirement: system_prompt: !file follows the same include pipeline and the + # provider receives the rendered system_prompt on the copied AgentDef. + _write_prompt_files( + tmp_path, + main_text="System root {{ workflow.input.role }}. {% include '_shared.md.jinja' %}", + partial_text="Shared system policy from include.", + ) + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text( + """ +workflow: + name: system-prompt-file-include + entry_point: answerer +agents: + - name: answerer + model: gpt-4 + system_prompt: !file prompts/main.md.jinja + prompt: "User prompt" + output: + answer: + type: string + routes: + - to: $end +output: + answer: "{{ answerer.output.answer }}" +""".strip() + ) + received_system_prompts: list[str | None] = [] + + def mock_handler(agent: AgentDef, prompt: str, context: dict[str, Any]) -> dict[str, Any]: + received_system_prompts.append(agent.system_prompt) + return {"answer": prompt} + + config = _load_workflow(workflow_file) + engine = WorkflowEngine( + config, + CopilotProvider(mock_handler=mock_handler), + workflow_path=workflow_file, + ) + + result = await engine.run({"role": "reviewer"}) + + assert result == {"answer": "User prompt"} + assert received_system_prompts == ["System root reviewer. Shared system policy from include."] + + +@pytest.mark.asyncio +async def test_for_each_inline_agent_prompt_file_include(tmp_path: Path) -> None: + """For-each inline agents keep FileString prompts through shallow copies.""" + # Requirement: inline for_each prompt: !file renders includes for every item + # and still resolves loop variables after engine model_copy operations. + _write_prompt_files( + tmp_path, + main_text="Item {{ item }} at {{ _index }}. {% include '_shared.md.jinja' %}", + partial_text="Shared loop marker.", + ) + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text( + """ +workflow: + name: for-each-file-include + entry_point: seed +agents: + - name: seed + type: set + value: ready + routes: + - to: loop +for_each: + - name: loop + type: for_each + source: workflow.input.items + as: item + max_concurrent: 1 + agent: + name: worker + model: gpt-4 + prompt: !file prompts/main.md.jinja + output: + result: + type: string + routes: + - to: $end +output: + count: "{{ loop.outputs | length }}" +""".strip() + ) + received_prompts: list[str] = [] + + def mock_handler(agent: AgentDef, prompt: str, context: dict[str, Any]) -> dict[str, Any]: + received_prompts.append(prompt) + return {"result": agent.name} + + config = _load_workflow(workflow_file) + engine = WorkflowEngine( + config, + CopilotProvider(mock_handler=mock_handler), + workflow_path=workflow_file, + ) + + result = await engine.run({"items": ["alpha", "beta"]}) + + assert result == {"count": 2} + assert received_prompts == [ + "Item alpha at 0. Shared loop marker.", + "Item beta at 1. Shared loop marker.", + ] + + +@pytest.mark.asyncio +async def test_human_gate_prompt_file_include(tmp_path: Path) -> None: + """Human gates render file-backed prompts with includes before presentation.""" + # Requirement: human_gate uses the shared renderer, so prompt: !file includes + # surface in the observable gate_presented event under skip_gates execution. + _write_prompt_files( + tmp_path, + main_text="Gate asks {{ workflow.input.request }}. {% include '_shared.md.jinja' %}", + partial_text="Shared gate copy from include.", + ) + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text( + """ +workflow: + name: human-gate-file-include + entry_point: approval +agents: + - name: approval + type: human_gate + prompt: !file prompts/main.md.jinja + options: + - label: Approve + value: approved + route: $end +output: + selected: "{{ approval.output.selected }}" +""".strip() + ) + events: list[WorkflowEvent] = [] + emitter = WorkflowEventEmitter() + emitter.subscribe(events.append) + + config = _load_workflow(workflow_file) + engine = WorkflowEngine( + config, + CopilotProvider(mock_handler=lambda agent, prompt, context: {}), + skip_gates=True, + workflow_path=workflow_file, + event_emitter=emitter, + ) + + result = await engine.run({"request": "decision"}) + + gate_prompts = [event.data["prompt"] for event in events if event.type == "gate_presented"] + assert result == {"selected": "approved"} + assert gate_prompts == ["Gate asks decision. Shared gate copy from include."] + + +@pytest.mark.asyncio +async def test_missing_include_raises_template_error(tmp_path: Path) -> None: + """Missing file-backed includes fail with the missing template name.""" + # Requirement: malformed include references fail the integration path and + # expose the missing include name regardless of wrapper exception wording. + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir() + (prompts_dir / "main.md.jinja").write_text("Main {% include '_missing.md.jinja' %}") + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text( + """ +workflow: + name: missing-include + entry_point: answerer +agents: + - name: answerer + model: gpt-4 + prompt: !file prompts/main.md.jinja + output: + answer: + type: string + routes: + - to: $end +output: + answer: "{{ answerer.output.answer }}" +""".strip() + ) + + def mock_handler(agent: AgentDef, prompt: str, context: dict[str, Any]) -> dict[str, Any]: + return {"answer": "unreachable"} + + config = _load_workflow(workflow_file) + engine = WorkflowEngine( + config, + CopilotProvider(mock_handler=mock_handler), + workflow_path=workflow_file, + ) + + with pytest.raises((ExecutionError, TemplateError)) as exc_info: + await engine.run({}) + + assert "_missing.md.jinja" in str(exc_info.value) From fee4b26053112d1b90f0a5985a2c165b91c1e1ca Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:51:45 +0300 Subject: [PATCH 08/10] docs(workflow-syntax): document Jinja includes for !file prompts --- docs/workflow-syntax.md | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/workflow-syntax.md b/docs/workflow-syntax.md index 62bcd17a..d4a61773 100644 --- a/docs/workflow-syntax.md +++ b/docs/workflow-syntax.md @@ -1439,6 +1439,65 @@ summary: A comprehensive summary of the analysis results. ``` +### Jinja Includes in Prompt Files + +When a prompt or system_prompt is loaded via `!file`, the directory of that file becomes the search root for Jinja template loading. This allows statements like `{% include "_shared.md" %}`, `{% import "_macros.md" as m %}`, and `{% extends "_base.md" %}` to resolve relative to the prompt file's directory rather than the workflow's directory or the current working directory. + +Only `prompt: !file` and `system_prompt: !file` support this behavior. Other fields that use `!file` (such as command, stdin, value, schemas, or tool lists) don't have include loader support. Inline prompts defined as plain strings don't support loader-dependent Jinja tags. If you attempt to use them inline, the system raises a template rendering error suggesting you switch to a file-backed prompt: + +``` +Template rendering failed: loader-dependent Jinja constructs ({% include %}, {% import %}, {% extends %}) require a file-backed prompt via prompt: !file ... +``` + +If an include file is missing, the error identifies the missing template name and the searched directory: + +``` +Template not found: ''. Searched in: +``` + +Note: Prompts for `human_gate` steps loaded via `!file` also support these include features because they use the same shared renderer. + +#### Example + +A workflow using a file-backed prompt: + +```yaml +# workflow.yaml +workflow: + name: review-workflow + description: A workflow that uses Jinja includes in its prompt + entry_point: reviewer + +agents: + - name: reviewer + model: gpt-4 + prompt: !file prompts/review.md.jinja + routes: + - to: $end +``` + +The prompt file referencing a partial file: + +```markdown +# prompts/review.md.jinja +You are a code review assistant. + +{% include "_checklist.md.jinja" %} + +Please review the provided code according to the checklist. +``` + +The partial file: + +```markdown +# prompts/_checklist.md.jinja +Check for: +1. Proper error handling +2. Clear variable names +``` + +During validation, `conductor validate` doesn't scan inside included files to check template references. It only verifies that the direct `!file` targets exist. + ### Environment Variables Environment variable references (`${VAR}` or `${VAR:-default}`) inside included files are resolved after inclusion, during the standard environment variable resolution pass. This means you can use env vars in external files just as you would inline: @@ -1480,6 +1539,7 @@ Only UTF-8 text files are supported. Non-UTF-8 files produce a `ConfigurationErr - **No URLs** — Remote references like `!file https://...` are not supported - **No conditional includes** — File references cannot be parameterized or conditional - **No caching** — Each `!file` reference reads the file independently +- **Jinja includes search root**: Relative template includes (`{% include %}`, etc.) resolve only against the prompt file's own directory, with no fallback to the workflow directory or current working directory. ## Hooks From a09211ddb7d3ac9aff5118b34c3817b03e54c324 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 03:55:22 +0300 Subject: [PATCH 09/10] style(schema): stabilize formatting of FileString wrap validators --- src/conductor/config/schema.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 5d4636d1..71046bd7 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -1102,9 +1102,7 @@ def reject_bool_duration(cls, v: Any) -> Any: @field_validator("prompt", mode="wrap") @classmethod - def preserve_prompt_filestring( - cls, value: Any, handler: ValidatorFunctionWrapHandler - ) -> Any: + def preserve_prompt_file_str(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any: """Preserve FileString subclass on validation for the prompt field.""" if isinstance(value, FileString): return value @@ -1112,7 +1110,7 @@ def preserve_prompt_filestring( @field_validator("system_prompt", mode="wrap") @classmethod - def preserve_system_prompt_filestring( + def preserve_system_prompt_file_str( cls, value: Any, handler: ValidatorFunctionWrapHandler ) -> Any: """Preserve FileString subclass on validation for the system_prompt field.""" From b80e95edb3516f2a2c64b16a5a8ace2eb5c525de Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Mon, 13 Jul 2026 19:44:56 +0300 Subject: [PATCH 10/10] fix(template): resolve env vars in Jinja partials and fail on missing prompt source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jinja loads {% include %}/{% import %}/{% extends %} targets at render time, after the config loader finished ${VAR} expansion — so partial files rendered placeholders literally, and an unset required variable silently passed through instead of raising the usual configuration error. Partial sources now go through the same resolve_env_vars() resolver before Jinja compiles them, and ConfigurationError propagates unwrapped. Also, a prompt file deleted after workflow load used to silently downgrade the FileString to inline rendering, making includes fail with a misleading "convert to prompt: !file" message. Rendering now fails immediately with an explicit error naming the unavailable source path. Review feedback on PR #291 (issue #287). --- docs/workflow-syntax.md | 18 ++ .../skills/conductor/references/authoring.md | 10 ++ src/conductor/executor/template.py | 43 ++++- .../test_executor/test_template_filestring.py | 107 ++++++++++- .../test_file_prompt_includes.py | 166 +++++++++++++++++- 5 files changed, 336 insertions(+), 8 deletions(-) diff --git a/docs/workflow-syntax.md b/docs/workflow-syntax.md index d4a61773..6c763aaf 100644 --- a/docs/workflow-syntax.md +++ b/docs/workflow-syntax.md @@ -1457,6 +1457,24 @@ Template not found: ''. Searched in: Note: Prompts for `human_gate` steps loaded via `!file` also support these include features because they use the same shared renderer. +#### Environment Variables in Partials + +Included, imported, and base template files go through the same environment variable resolution (`${VAR}` / `${VAR:-default}`) as the root prompt file, applied when Jinja loads each partial at render time. An unset **required** variable (no default) inside a partial fails the run with the standard configuration error: + +``` +ConfigurationError: Required environment variable 'X' is not set + 💡 Suggestion: Set the environment variable 'X' or provide a default value using the syntax: ${X:-default_value} +``` + +#### Missing Prompt Source File + +The prompt file must still exist when the workflow runs — relative includes resolve against its directory. If the file was deleted or became inaccessible after the workflow was loaded, rendering fails immediately with an explicit error instead of silently treating the prompt as inline: + +``` +TemplateError: File-backed prompt source is no longer available: '' (loaded via !file). + 💡 Suggestion: Restore the prompt file or fix the !file reference — relative Jinja includes/imports/extends resolve against that file's directory. +``` + #### Example A workflow using a file-backed prompt: diff --git a/plugins/conductor/skills/conductor/references/authoring.md b/plugins/conductor/skills/conductor/references/authoring.md index 48e7a3e4..26afe19e 100644 --- a/plugins/conductor/skills/conductor/references/authoring.md +++ b/plugins/conductor/skills/conductor/references/authoring.md @@ -632,6 +632,16 @@ agents: - Supports **recursive includes** — included YAML files can use `!file` too - Circular references are detected and raise an error +Prompt files loaded via `!file` may also use Jinja loader-dependent tags +(`{% include %}`, `{% import %}`, `{% extends %}`); relative paths resolve +against the prompt file's own directory. `${VAR}` / `${VAR:-default}` +references inside those partials resolve at render time with the same +semantics as the root prompt file — an unset required variable fails the run +with a configuration error. The prompt file must still exist at run time: +if it was deleted after loading, rendering fails with an explicit error +naming the missing source path (it never silently falls back to inline +rendering). + ## Parallel Groups Static parallel groups run a fixed set of agents concurrently: diff --git a/src/conductor/executor/template.py b/src/conductor/executor/template.py index 6bc24286..f2bba67b 100644 --- a/src/conductor/executor/template.py +++ b/src/conductor/executor/template.py @@ -19,7 +19,8 @@ ) from jinja2 import UndefinedError as Jinja2UndefinedError -from conductor.exceptions import TemplateError +from conductor.config.loader import resolve_env_vars +from conductor.exceptions import ConfigurationError, TemplateError from conductor.file_string import FileString @@ -47,6 +48,29 @@ def getattr(self, obj: Any, attribute: str) -> Any: # noqa: ANN401 return super().getattr(obj, attribute) +class _EnvResolvingFileSystemLoader(FileSystemLoader): + """FileSystemLoader that resolves ``${VAR}`` env placeholders in partials. + + The config loader resolves environment variables in the *root* ``!file`` + prompt at YAML load time, but Jinja loads ``{% include %}`` / + ``{% import %}`` / ``{% extends %}`` targets itself at render time — long + after config loading finished. Without this hook, ``${VAR}`` text inside + a partial would reach the model literally, and an unset required variable + would silently pass through instead of raising the usual configuration + error. + + Each partial source is therefore run through the same + :func:`~conductor.config.loader.resolve_env_vars` resolver used by the + config loader before Jinja compiles it, so partials behave identically + to the root prompt file. + """ + + def get_source(self, environment: Environment, template: str) -> tuple[str, str, Any]: + """Load *template* and resolve env vars in its source before compile.""" + source, filename, uptodate = super().get_source(environment, template) + return resolve_env_vars(source), filename, uptodate + + class TemplateRenderer: """Jinja2-based template renderer for prompts and expressions. @@ -121,10 +145,18 @@ def render(self, template: str, context: dict[str, Any]) -> str: """ loader = None is_file_backed = False + if isinstance(template, FileString) and not template.source_path.is_file(): + raise TemplateError( + f"File-backed prompt source is no longer available: " + f"'{template.source_path}' (loaded via !file).", + suggestion="Restore the prompt file or fix the !file reference — " + "relative Jinja includes/imports/extends resolve against " + "that file's directory.", + ) try: - if isinstance(template, FileString) and template.source_path.exists(): + if isinstance(template, FileString): is_file_backed = True - loader = FileSystemLoader(str(template.source_path.parent)) + loader = _EnvResolvingFileSystemLoader(str(template.source_path.parent)) env = _DictSafeEnvironment( loader=loader, undefined=StrictUndefined, @@ -137,6 +169,11 @@ def render(self, template: str, context: dict[str, Any]) -> str: else: tmpl = self.env.from_string(template) return tmpl.render(**context) + except ConfigurationError: + # Env var resolution failed inside a Jinja partial (e.g. an unset + # required ``${VAR}``) — keep the normal configuration error and + # its suggestion instead of downgrading it to a template error. + raise except TemplateNotFound as e: if is_file_backed and loader is not None: search_paths = ", ".join(str(p) for p in loader.searchpath) diff --git a/tests/test_executor/test_template_filestring.py b/tests/test_executor/test_template_filestring.py index 20d5d0dd..061e0322 100644 --- a/tests/test_executor/test_template_filestring.py +++ b/tests/test_executor/test_template_filestring.py @@ -9,7 +9,7 @@ import pytest -from conductor.exceptions import TemplateError +from conductor.exceptions import ConfigurationError, TemplateError from conductor.executor.template import TemplateRenderer from conductor.file_string import FileString @@ -153,14 +153,16 @@ def test_file_string_windows_path_conversion(tmp_path: Path) -> None: """Requirement: test that constructs FileString with a PureWindowsPath-style string converted via Path; keep it platform-safe so it also passes on Linux. """ - # 1. Non-existent Windows path - should fall back to inline render safely + # 1. Non-existent Windows path - fails fast with an explicit source-path + # error instead of silently rendering inline. win_path_str = "C:\\foo\\bar\\main.md" path_obj = Path(PureWindowsPath(win_path_str)) file_string = FileString("Hello {{ name }}!", path_obj) renderer = TemplateRenderer() - result = renderer.render(file_string, {"name": "World"}) - assert result == "Hello World!" + with pytest.raises(TemplateError) as exc_info: + renderer.render(file_string, {"name": "World"}) + assert "no longer available" in str(exc_info.value) # 2. Existing path converted via PureWindowsPath to ensure FileSystemLoader works main_file = tmp_path / "main.md" @@ -176,6 +178,103 @@ def test_file_string_windows_path_conversion(tmp_path: Path) -> None: assert result_existing == "Hello World!" +def test_missing_source_path_raises_explicit_error(tmp_path: Path) -> None: + """Requirement: a FileString whose source file was deleted after loading must + raise a TemplateError naming the unavailable source path instead of silently + rendering as an inline template. + """ + main_file = tmp_path / "main.md" + main_file.write_text("Hello {{ name }}!") + + file_string = FileString(main_file.read_text(), main_file) + main_file.unlink() + + renderer = TemplateRenderer() + with pytest.raises(TemplateError) as exc_info: + renderer.render(file_string, {"name": "World"}) + + msg = str(exc_info.value) + assert str(main_file) in msg + assert "no longer available" in msg + # Must NOT fall back to the inline "convert to !file" guidance — the user + # already uses !file. + assert "prompt: !file" not in msg + assert "loader-dependent" not in msg + # Must NOT be double-wrapped by the generic except-Exception handler — + # the error is raised before the try block, so the message is exact. + assert "Template rendering failed" not in msg + assert "Check template and context for errors" not in msg + assert exc_info.value.__cause__ is None + + +def test_partial_env_var_resolved_at_render_time( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Requirement: ${VAR} inside an included partial is resolved through the + same env resolver as the root prompt, at render time — not left literal. + """ + main_file = tmp_path / "main.md" + partial_file = tmp_path / "_partial.md" + + main_file.write_text("Main. {% include '_partial.md' %}") + partial_file.write_text("Partial says ${CONDUCTOR_TEST_PARTIAL_VAR}.") + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + # Set the env var AFTER the FileString was created to prove resolution + # happens at render time, not from a load-time snapshot. + monkeypatch.setenv("CONDUCTOR_TEST_PARTIAL_VAR", "resolved-value") + result = renderer.render(file_string, {}) + assert result == "Main. Partial says resolved-value." + + +def test_partial_env_var_default_used_when_unset( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Requirement: ${VAR:-default} inside a partial reuses the config loader's + resolver semantics, falling back to the default when the var is unset. + """ + main_file = tmp_path / "main.md" + partial_file = tmp_path / "_partial.md" + + main_file.write_text("{% include '_partial.md' %}") + partial_file.write_text("mode=${CONDUCTOR_TEST_UNSET_VAR:-fallback}") + + monkeypatch.delenv("CONDUCTOR_TEST_UNSET_VAR", raising=False) + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + result = renderer.render(file_string, {}) + assert result == "mode=fallback" + + +def test_partial_unset_required_env_var_raises_configuration_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Requirement: an unset required ${VAR} inside a partial raises the normal + ConfigurationError (message + suggestion), not a generic TemplateError. + """ + main_file = tmp_path / "main.md" + partial_file = tmp_path / "_partial.md" + + main_file.write_text("{% include '_partial.md' %}") + partial_file.write_text("key=${CONDUCTOR_TEST_REQUIRED_VAR}") + + monkeypatch.delenv("CONDUCTOR_TEST_REQUIRED_VAR", raising=False) + + file_string = FileString(main_file.read_text(), main_file) + renderer = TemplateRenderer() + + with pytest.raises(ConfigurationError) as exc_info: + renderer.render(file_string, {}) + + msg = str(exc_info.value) + assert "Required environment variable 'CONDUCTOR_TEST_REQUIRED_VAR' is not set" in msg + assert "Template rendering failed" not in msg + + def test_template_not_found_error_includes_searchpath(tmp_path: Path) -> None: """Requirement: FileString in tmp_path including missing template.md raises TemplateError. diff --git a/tests/test_integration/test_file_prompt_includes.py b/tests/test_integration/test_file_prompt_includes.py index 5730d0f9..06c7810b 100644 --- a/tests/test_integration/test_file_prompt_includes.py +++ b/tests/test_integration/test_file_prompt_includes.py @@ -12,7 +12,7 @@ from conductor.config.schema import AgentDef, WorkflowConfig from conductor.engine.workflow import WorkflowEngine from conductor.events import WorkflowEvent, WorkflowEventEmitter -from conductor.exceptions import ExecutionError, TemplateError +from conductor.exceptions import ConfigurationError, ExecutionError, TemplateError from conductor.providers.copilot import CopilotProvider MockHandler = Callable[[AgentDef, str, dict[str, Any]], dict[str, Any]] @@ -280,3 +280,167 @@ def mock_handler(agent: AgentDef, prompt: str, context: dict[str, Any]) -> dict[ await engine.run({}) assert "_missing.md.jinja" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_partial_env_var_resolved_end_to_end( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """${VAR} inside an included partial resolves at render time through the pipeline.""" + # Requirement: partial files go through the same env resolver as the root + # prompt. The env var is set only AFTER config load to prove resolution + # happens at render time, not from a load-time snapshot. + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir() + (prompts_dir / "main.md.jinja").write_text( + "Main {{ workflow.input.topic }}. {% include '_shared.md.jinja' %}" + ) + (prompts_dir / "_shared.md.jinja").write_text( + "Partial env: ${CONDUCTOR_E2E_PARTIAL_VAR}; defaulted: ${CONDUCTOR_E2E_UNSET:-fallback}." + ) + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text( + """ +workflow: + name: partial-env-e2e + entry_point: answerer +agents: + - name: answerer + model: gpt-4 + prompt: !file prompts/main.md.jinja + output: + answer: + type: string + routes: + - to: $end +output: + answer: "{{ answerer.output.answer }}" +""".strip() + ) + received_prompts: list[str] = [] + + def mock_handler(agent: AgentDef, prompt: str, context: dict[str, Any]) -> dict[str, Any]: + received_prompts.append(prompt) + return {"answer": agent.name} + + monkeypatch.delenv("CONDUCTOR_E2E_PARTIAL_VAR", raising=False) + monkeypatch.delenv("CONDUCTOR_E2E_UNSET", raising=False) + + config = _load_workflow(workflow_file) + engine = WorkflowEngine( + config, + CopilotProvider(mock_handler=mock_handler), + workflow_path=workflow_file, + ) + + monkeypatch.setenv("CONDUCTOR_E2E_PARTIAL_VAR", "resolved-value") + result = await engine.run({"topic": "integration"}) + + assert result == {"answer": "answerer"} + assert received_prompts == [ + "Main integration. Partial env: resolved-value; defaulted: fallback." + ] + + +@pytest.mark.asyncio +async def test_partial_unset_required_env_var_fails_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Unset required ${VAR} in a partial fails the run with the normal configuration error.""" + # Requirement: the error surfaces as ConfigurationError (not a generic + # template failure) so users get the standard env-var guidance. + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir() + (prompts_dir / "main.md.jinja").write_text("{% include '_shared.md.jinja' %}") + (prompts_dir / "_shared.md.jinja").write_text("key=${CONDUCTOR_E2E_REQUIRED_VAR}") + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text( + """ +workflow: + name: partial-env-required + entry_point: answerer +agents: + - name: answerer + model: gpt-4 + prompt: !file prompts/main.md.jinja + output: + answer: + type: string + routes: + - to: $end +output: + answer: "{{ answerer.output.answer }}" +""".strip() + ) + + def mock_handler(agent: AgentDef, prompt: str, context: dict[str, Any]) -> dict[str, Any]: + return {"answer": "unreachable"} + + monkeypatch.delenv("CONDUCTOR_E2E_REQUIRED_VAR", raising=False) + + config = _load_workflow(workflow_file) + engine = WorkflowEngine( + config, + CopilotProvider(mock_handler=mock_handler), + workflow_path=workflow_file, + ) + + with pytest.raises(ConfigurationError) as exc_info: + await engine.run({}) + + msg = str(exc_info.value) + assert "Required environment variable 'CONDUCTOR_E2E_REQUIRED_VAR' is not set" in msg + assert "Template rendering failed" not in msg + + +@pytest.mark.asyncio +async def test_deleted_prompt_source_fails_with_explicit_error(tmp_path: Path) -> None: + """A prompt file deleted after config load fails with a direct source-path error.""" + # Requirement: no silent downgrade to inline rendering — the error names the + # missing file and never suggests converting to !file (already in use). + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir() + main_prompt = prompts_dir / "main.md.jinja" + main_prompt.write_text("Main {{ workflow.input.topic }}.") + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text( + """ +workflow: + name: deleted-source + entry_point: answerer +agents: + - name: answerer + model: gpt-4 + prompt: !file prompts/main.md.jinja + output: + answer: + type: string + routes: + - to: $end +output: + answer: "{{ answerer.output.answer }}" +""".strip() + ) + + def mock_handler(agent: AgentDef, prompt: str, context: dict[str, Any]) -> dict[str, Any]: + return {"answer": "unreachable"} + + config = _load_workflow(workflow_file) + main_prompt.unlink() + + engine = WorkflowEngine( + config, + CopilotProvider(mock_handler=mock_handler), + workflow_path=workflow_file, + ) + + with pytest.raises((ExecutionError, TemplateError)) as exc_info: + await engine.run({"topic": "integration"}) + + msg = str(exc_info.value) + assert "main.md.jinja" in msg + assert "no longer available" in msg + assert "loader-dependent" not in msg + # The error must not be double-wrapped by the renderer's generic handler. + assert "Template rendering failed" not in msg + assert "Check template and context for errors" not in msg