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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/adr/0001-product-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,32 @@ missing extra must degrade one feature, never break the package import.
Exit codes: `0` success, `1` execution failure, `2` validation/compile failure,
`130` interrupted.

## Actions and template resolution

A step names either a tool and an operation on it (`tool: filesystem` with
`action: read`), or an action the runtime executes itself (`action: generate`).
`core/actions.py` is the single source of truth for the second group:
`HybridControlSystem` dispatches off it and `ToolValidator` accepts exactly the
names in it, so `validate` and `run` cannot disagree about whether an action
exists (#241).

Template rendering deliberately falls back to returning the original text when
a reference is undefined, because resolution runs in several passes and a
reference that cannot resolve yet may resolve later. That fallback must not
survive the handoff to a tool:

> A template reference that reaches a tool still unresolved fails the step
> before any side effect. No file is written, the step's envelope carries
> `success: false` naming the reference, and the run exits 1.

The test is *survival*, not presence: a marker in the rendered output that was
not in the input is content, not a failure. Jinja's own escape `{{ '{{' }}`
renders to a literal `{{`, and a step result may legitimately contain text that
looks like a template (#153).

Rendering is all-or-nothing per string. One undefined reference aborts the
whole render, so every marker in that string is reported, not just the bad one.

## Test layers

| Layer | Marker | Network | Secrets | Runs in default CI |
Expand Down
30 changes: 30 additions & 0 deletions src/orchestrator/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,36 @@ def __init__(self, parameter: str, reason: str, **kwargs):
)


class UnresolvedTemplateError(ValidationError):
"""Raised when a template reaches a tool with references still unresolved.

Template rendering deliberately falls back to returning the original text
when a reference is undefined, because resolution runs in several passes
and a reference that cannot be resolved yet may resolve later. The one
place that fallback must not survive is the handoff to a tool: past that
point the literal `{{ ... }}` is written to a file, sent to a model, or
returned as a result, and the step reports success having produced its own
source code instead of a value (#153).
"""

def __init__(self, tool_name: str, parameter: str, unresolved: list, **kwargs):
rendered = ", ".join(repr(marker) for marker in unresolved)
message = (
f"Parameter '{parameter}' of tool '{tool_name}' still contains "
f"unresolved template references: {rendered}. The step was stopped "
f"before it could act on them."
)
super().__init__(
message,
details={
"tool": tool_name,
"parameter": parameter,
"unresolved": list(unresolved),
},
**kwargs
)


# Resource errors
class ResourceError(OrchestratorError):
"""Base class for resource-related errors."""
Expand Down
77 changes: 75 additions & 2 deletions src/orchestrator/core/unified_template_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,46 @@
"""

import logging
import re
from typing import Any, Dict, List, Optional, Union
from dataclasses import dataclass
from contextlib import contextmanager

from .exceptions import UnresolvedTemplateError
from .template_manager import TemplateManager
from .context_manager import ContextManager
from .loop_context import LoopContextVariables, GlobalLoopContextManager

logger = logging.getLogger(__name__)

# `{{ ... }}` substitutions and `{% ... %}` control blocks. Comments (`{# #}`)
# are not included: they are erased by rendering, so one surviving is a syntax
# problem the renderer already reports.
_TEMPLATE_MARKER = re.compile(r"\{\{.*?\}\}|\{%.*?%\}", re.DOTALL)


def _find_template_markers(value: Any) -> List[str]:
"""Every template marker surviving anywhere inside `value`.

Walks nested containers, because a parameter is routinely a list of paths
or a dict of fields and an unresolved reference buried in one of those
reaches a tool exactly as readily as a top-level string does.
"""
if isinstance(value, str):
return _TEMPLATE_MARKER.findall(value)
if isinstance(value, dict):
found = []
for key, item in value.items():
found.extend(_find_template_markers(key))
found.extend(_find_template_markers(item))
return found
if isinstance(value, (list, tuple, set)):
found = []
for item in value:
found.extend(_find_template_markers(item))
return found
return []


@dataclass
class TemplateResolutionContext:
Expand Down Expand Up @@ -334,14 +364,57 @@ def resolve_before_tool_execution(
# Register context and resolve templates
self.register_context(context)
resolved_parameters = self.resolve_templates(tool_parameters)

if self.debug_mode:
logger.info(f"Resolved templates for tool '{tool_name}'")
for key, (original, resolved) in self._compare_dicts(tool_parameters, resolved_parameters).items():
if original != resolved:
logger.debug(f" {key}: '{original}' -> '{resolved}'")


# This method promises "all templates resolved", and it is the last
# point at which that promise can still be kept: past here the value is
# written to a file or sent to a model. Rendering falls back to
# returning the original text when a reference is undefined, so without
# this check a literal `{{ step.field }}` lands in the output and the
# step reports success (#153).
self._reject_unresolved_templates(
tool_name, tool_parameters, resolved_parameters
)

return resolved_parameters

def _reject_unresolved_templates(
self,
tool_name: str,
original: Dict[str, Any],
resolved: Dict[str, Any],
) -> None:
"""Raise if a template reference survived rendering unchanged.

The test is *survival*, not mere presence. A marker in the output that
was not in the input is content, not a failure to resolve: Jinja's own
escape, `{{ '{{' }}`, renders to a literal `{{`, and a step result may
legitimately contain text that looks like a template. Flagging those
would make it impossible to write documentation about templates.

A marker that appears in the input and comes back byte-identical did
not render, which is exactly the #153 defect.

Parameters whose names begin with an underscore are internal plumbing
the control systems attach on the way to a tool (`_template_manager`,
`_resolution_context` and friends), not user-authored values, so they
are not inspected.
"""
for name, value in resolved.items():
if name.startswith("_"):
continue
after = _find_template_markers(value)
if not after:
continue
before = set(_find_template_markers(original.get(name)))
survived = [marker for marker in after if marker in before]
if survived:
raise UnresolvedTemplateError(tool_name, name, survived)

def validate_templates(self, template: str) -> List[str]:
"""Validate template syntax and check for undefined variables.
Expand Down
Loading
Loading