Skip to content
Merged
78 changes: 78 additions & 0 deletions docs/workflow-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,83 @@ 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: '<name>'. Searched in: <dir>
```

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: '<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.
```

#### 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:
Expand Down Expand Up @@ -1480,6 +1557,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

Expand Down
10 changes: 10 additions & 0 deletions plugins/conductor/skills/conductor/references/authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions src/conductor/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"\$\{([^}:]+)(?::-([^}]*))?\}")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions src/conductor/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1098,6 +1100,24 @@ 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_file_str(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_file_str(
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."""
Expand Down
88 changes: 83 additions & 5 deletions src/conductor/executor/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@
import json
from typing import Any

from jinja2 import BaseLoader, Environment, StrictUndefined, TemplateSyntaxError
from jinja2 import (
BaseLoader,
Environment,
FileSystemLoader,
StrictUndefined,
TemplateNotFound,
TemplateSyntaxError,
)
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


class _DictSafeEnvironment(Environment):
Expand All @@ -39,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.

Expand All @@ -63,8 +95,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:
Expand Down Expand Up @@ -107,9 +143,51 @@ 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
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:
tmpl = self.env.from_string(template)
if isinstance(template, FileString):
is_file_backed = True
loader = _EnvResolvingFileSystemLoader(str(template.source_path.parent))
env = _DictSafeEnvironment(
loader=loader,
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 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)
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)
Expand Down
41 changes: 41 additions & 0 deletions src/conductor/file_string.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading