"""Template resolution for step payloads. Supported patterns: {{ last.output_text }} — output_text of the most recent step in history {{ steps.step-1.output_text }} — output_text of a specific step by step_id Resolution is called BEFORE executor.execute(step) in agent_loop. Tools never see unresolved templates. """ from __future__ import annotations import re from typing import Any, Optional from core.state import AgentState # Matches {{ expr }} with optional surrounding whitespace _TMPL_RE = re.compile(r"\{\{\s*([\w.\-]+)\s*\}\}") def resolve_payload( payload: dict[str, Any], state: AgentState ) -> tuple[dict[str, Any], bool, str]: """Resolve all template expressions in a step payload. Returns: (resolved_payload, success, error_message) On failure: ({}, False, "reason") On success: (resolved_dict, True, "") """ resolved: dict[str, Any] = {} for key, value in payload.items(): if isinstance(value, str) and _TMPL_RE.search(value): new_val, ok, err = _resolve_string(value, state) if not ok: return {}, False, err resolved[key] = new_val else: resolved[key] = value return resolved, True, "" def has_templates(payload: dict[str, Any]) -> bool: """Return True if any string value in payload contains a template.""" return any( isinstance(v, str) and bool(_TMPL_RE.search(v)) for v in payload.values() ) def _resolve_string(s: str, state: AgentState) -> tuple[str, bool, str]: """Replace all {{ expr }} occurrences in s. Returns (result, ok, error).""" errors: list[str] = [] def replacer(m: re.Match) -> str: expr = m.group(1).strip() val, ok, err = _eval_expr(expr, state) if not ok: errors.append(err) return "" return val if val is not None else "" result = _TMPL_RE.sub(replacer, s) if errors: return "", False, errors[0] return result, True, "" def _eval_expr(expr: str, state: AgentState) -> tuple[Optional[str], bool, str]: """Evaluate a single template expression against agent state.""" # {{ last.output_text }} if expr == "last.output_text": val = state.get_last_output() if val is None: return None, False, ( "Template '{{ last.output_text }}' could not be resolved: " "history is empty or last step produced no output" ) return val, True, "" # {{ steps..output_text }} if expr.startswith("steps.") and expr.endswith("..output_text"): # "steps.step-1.output_text" → split on first and last dot # Use rsplit to handle step-ids that might contain dots (unlikely but safe) without_prefix = expr[len("steps."):] # "step-1.output_text" if without_prefix.endswith(".output_text"): step_id = without_prefix[: -len(".output_text")] # "step-1" if not step_id: return None, False, f"Template '{{{{ {expr} }}}}' has empty step_id" val = state.get_step_output(step_id) if val is None: return None, False, ( f"Template '{{{{ {expr} }}}}' could not be resolved: " f"step '{step_id}' not found in history or has no output" ) return val, True, "" return None, False, f"Unknown template expression: '{{{{ {expr} }}}}'"