Skip to main content

harn_vm/stdlib/template/
mod.rs

1//! Prompt-template engine for `.harn.prompt` assets and the `render` /
2//! `render_prompt` builtins.
3//!
4//! # Surface
5//!
6//! ```text
7//! {{ name }}                                 interpolation
8//! {{ user.name }} / {{ items[0] }}           nested path access
9//! {{ name | upper | default: "anon" }}       filter pipeline
10//! {{ if expr }}..{{ elif expr }}..{{ else }}..{{ end }}
11//! {{ for x in xs }}..{{ else }}..{{ end }}   else = empty-iterable fallback
12//! {{ for k, v in dict }}..{{ end }}
13//! {{ include "partial.harn.prompt" }}
14//! {{ include "partial.harn.prompt" with { x: name } }}
15//! {{ section "task" }}..{{ endsection }}
16//! {{# comment — stripped at parse time #}}
17//! {{ raw }}..literal {{braces}}..{{ endraw }}
18//! {{- x -}}                                  whitespace-trim markers
19//! ```
20//!
21//! Back-compat: bare `{{ident}}` resolves silently to the empty fallthrough
22//! (writes back the literal text on miss) — preserving the pre-v2 contract.
23//! All new constructs raise `TemplateError` on parse or evaluation failure.
24
25use std::cell::RefCell;
26use std::collections::{BTreeMap, HashSet};
27use std::path::Path;
28use std::sync::{Mutex, OnceLock};
29
30use crate::value::{VmError, VmValue};
31
32mod assets;
33mod ast;
34mod error;
35mod expr_parser;
36mod filters;
37mod lexer;
38pub mod lint;
39mod llm_context;
40mod parser;
41mod render;
42mod sections;
43
44#[cfg(test)]
45mod tests;
46
47use assets::parse_cached;
48pub(crate) use assets::TemplateAsset;
49use error::TemplateError;
50pub use llm_context::{
51    current_llm_render_context, pop_llm_render_context, push_llm_render_context, LlmRenderContext,
52    LlmRenderContextGuard,
53};
54use render::{render_nodes, RenderCtx, Scope};
55
56// Thread-local registry of recent prompt renders keyed by `prompt_id`.
57// Populated by `render_with_provenance` so the DAP adapter can serve
58// `burin/promptProvenance` and `burin/promptConsumers` reverse queries
59// without forcing the pipeline author to pass the spans dict back up
60// through the bridge. Capped at 64 renders (FIFO) to bound memory.
61thread_local! {
62    static PROMPT_REGISTRY: RefCell<Vec<RegisteredPrompt>> = const { RefCell::new(Vec::new()) };
63    // prompt_id -> [event_index...] where the prompt was consumed by
64    // an LLM call. Populated by emission sites once they thread the
65    // id alongside the rendered text; read by burin/promptConsumers
66    // to power the template gutter's jump-to-next-render action
67    // (#106). A per-session reset is handled by reset_prompt_registry.
68    static PROMPT_RENDER_INDICES: RefCell<BTreeMap<String, Vec<u64>>> =
69        const { RefCell::new(BTreeMap::new()) };
70    // Monotonic render ordinal driven by the prompt_mark_rendered
71    // builtin (#106). A fresh thread-local counter since the IDE
72    // correlates ordinals to event_indices at render time.
73    static PROMPT_RENDER_ORDINAL: RefCell<u64> = const { RefCell::new(0) };
74}
75
76const PROMPT_REGISTRY_CAP: usize = 64;
77
78#[derive(Debug, Clone)]
79pub struct RegisteredPrompt {
80    pub prompt_id: String,
81    pub template_uri: String,
82    pub rendered: String,
83    pub spans: Vec<PromptSourceSpan>,
84}
85
86/// Record a provenance map in the thread-local registry and return the
87/// assigned `prompt_id`. Newest entries push to the back; when the cap
88/// is reached the oldest entry is dropped so the registry never grows
89/// unboundedly over long sessions.
90pub(crate) fn register_prompt(
91    template_uri: String,
92    rendered: String,
93    spans: Vec<PromptSourceSpan>,
94) -> String {
95    let prompt_id = format!("prompt-{}", next_prompt_serial());
96    PROMPT_REGISTRY.with(|reg| {
97        let mut reg = reg.borrow_mut();
98        if reg.len() >= PROMPT_REGISTRY_CAP {
99            reg.remove(0);
100        }
101        reg.push(RegisteredPrompt {
102            prompt_id: prompt_id.clone(),
103            template_uri,
104            rendered,
105            spans,
106        });
107    });
108    prompt_id
109}
110
111thread_local! {
112    static PROMPT_SERIAL: RefCell<u64> = const { RefCell::new(0) };
113}
114
115fn next_prompt_serial() -> u64 {
116    PROMPT_SERIAL.with(|s| {
117        let mut s = s.borrow_mut();
118        *s += 1;
119        *s
120    })
121}
122
123/// Resolve an output byte offset to its originating template span.
124/// Returns the innermost matching `Expr` / `LegacyBareInterp` span when
125/// one exists, falling back to broader structural spans (If / For /
126/// Include) so a click anywhere in a rendered loop iteration still
127/// navigates somewhere useful.
128pub fn lookup_prompt_span(
129    prompt_id: &str,
130    output_offset: usize,
131) -> Option<(String, PromptSourceSpan)> {
132    PROMPT_REGISTRY.with(|reg| {
133        let reg = reg.borrow();
134        let entry = reg.iter().find(|p| p.prompt_id == prompt_id)?;
135        let best = entry
136            .spans
137            .iter()
138            .filter(|s| {
139                output_offset >= s.output_start
140                    && output_offset < s.output_end.max(s.output_start + 1)
141            })
142            .min_by_key(|s| {
143                let width = s.output_end.saturating_sub(s.output_start);
144                let kind_weight = match s.kind {
145                    PromptSpanKind::Expr => 0,
146                    PromptSpanKind::LegacyBareInterp => 1,
147                    PromptSpanKind::Text => 2,
148                    PromptSpanKind::Section => 3,
149                    PromptSpanKind::Include => 4,
150                    PromptSpanKind::ForIteration => 5,
151                    PromptSpanKind::If => 6,
152                };
153                (kind_weight, width)
154            })?
155            .clone();
156        Some((entry.template_uri.clone(), best))
157    })
158}
159
160/// Return every span across every registered prompt that overlaps a
161/// template range. Powers the inverse "which rendered ranges consumed
162/// this template region?" navigation.
163pub fn lookup_prompt_consumers(
164    template_uri: &str,
165    template_line_start: usize,
166    template_line_end: usize,
167) -> Vec<(String, PromptSourceSpan)> {
168    PROMPT_REGISTRY.with(|reg| {
169        let reg = reg.borrow();
170        reg.iter()
171            .flat_map(|p| {
172                let prompt_id = p.prompt_id.clone();
173                p.spans
174                    .iter()
175                    .filter(move |s| {
176                        let line = s.template_line;
177                        s.template_uri == template_uri
178                            && line > 0
179                            && line >= template_line_start
180                            && line <= template_line_end
181                    })
182                    .cloned()
183                    .map(move |s| (prompt_id.clone(), s))
184            })
185            .collect()
186    })
187}
188
189/// Record a render event index against a prompt_id (#106). The
190/// scrubber's jump-to-render action walks this map to move the
191/// playhead to the AgentEvent where the template was consumed.
192/// Stored as a Vec so re-renders of the same prompt id accumulate.
193pub fn record_prompt_render_index(prompt_id: &str, event_index: u64) {
194    PROMPT_RENDER_INDICES.with(|map| {
195        map.borrow_mut()
196            .entry(prompt_id.to_string())
197            .or_default()
198            .push(event_index);
199    });
200}
201
202/// Produce the next monotonic ordinal for a render-mark. Pipelines
203/// invoke the `prompt_mark_rendered` builtin which calls this to
204/// obtain a sequence number without having to know about per-session
205/// event counters. The IDE scrubber orders matching consumers by
206/// this ordinal when the emitted_at_ms timestamps collide.
207pub fn next_prompt_render_ordinal() -> u64 {
208    PROMPT_RENDER_ORDINAL.with(|c| {
209        let mut n = c.borrow_mut();
210        *n += 1;
211        *n
212    })
213}
214
215/// Fetch every event index where `prompt_id` was rendered. Called
216/// by the DAP adapter to populate the `eventIndices` list in the
217/// `burin/promptConsumers` response.
218pub fn prompt_render_indices(prompt_id: &str) -> Vec<u64> {
219    PROMPT_RENDER_INDICES.with(|map| map.borrow().get(prompt_id).cloned().unwrap_or_default())
220}
221
222/// Clear the registry. Wired into `reset_thread_local_state` so tests
223/// and serialized adapter sessions start from a clean slate.
224pub(crate) fn reset_prompt_registry() {
225    PROMPT_REGISTRY.with(|reg| reg.borrow_mut().clear());
226    PROMPT_SERIAL.with(|s| *s.borrow_mut() = 0);
227    PROMPT_RENDER_INDICES.with(|map| map.borrow_mut().clear());
228    PROMPT_RENDER_ORDINAL.with(|c| *c.borrow_mut() = 0);
229    llm_context::reset_llm_render_stack();
230    if let Some(cache) = LLM_SHADOW_WARN_CACHE.get() {
231        if let Ok(mut g) = cache.lock() {
232            g.clear();
233        }
234    }
235}
236
237/// One-shot dedup for the user-supplied-`llm`-binding shadow warning.
238/// Keyed by template URI so a recurring render in a loop only emits
239/// the warning once per template per process.
240static LLM_SHADOW_WARN_CACHE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
241
242/// Build the merged bindings map that includes the ambient `llm` key
243/// when an LLM render context is in scope. Returns `None` to mean
244/// "no change required — pass the caller's bindings through unchanged":
245/// either there is no active context, or the user already supplied an
246/// `llm` binding (in which case we emit a lint warning and let their
247/// value win for back-compat).
248fn augment_bindings_with_llm(
249    asset: &TemplateAsset,
250    bindings: Option<&BTreeMap<String, VmValue>>,
251) -> Option<BTreeMap<String, VmValue>> {
252    let ctx = current_llm_render_context()?;
253    if bindings.is_some_and(|m| m.contains_key("llm")) {
254        warn_user_llm_shadowed(asset);
255        return None;
256    }
257    let mut merged = bindings.cloned().unwrap_or_default();
258    merged.insert("llm".to_string(), ctx.to_vm_value());
259    Some(merged)
260}
261
262fn warn_user_llm_shadowed(asset: &TemplateAsset) {
263    let cache = LLM_SHADOW_WARN_CACHE.get_or_init(|| Mutex::new(HashSet::new()));
264    let key = asset.uri.clone();
265    {
266        let mut guard = match cache.lock() {
267            Ok(g) => g,
268            Err(_) => return,
269        };
270        if !guard.insert(key.clone()) {
271            return;
272        }
273    }
274    crate::events::log_warn_meta(
275        "template.llm_scope",
276        "user-supplied `llm` binding shadows auto-injected LLM render context; \
277         rename your key to avoid relying on this back-compat path",
278        BTreeMap::from([
279            ("template_uri".to_string(), serde_json::Value::String(key)),
280            (
281                "reason".to_string(),
282                serde_json::Value::String("user_binding_shadowed".to_string()),
283            ),
284        ]),
285    );
286}
287
288/// Parse-only validation for lint/preflight. Returns a human-readable error
289/// message when the template body is syntactically invalid; `Ok(())` when the
290/// template would parse. Does not resolve `{{ include }}` targets — those are
291/// validated at render time with their own error reporting.
292pub fn validate_template_syntax(src: &str) -> Result<(), String> {
293    parser::parse(src).map(|_| ()).map_err(|e| e.message())
294}
295
296/// Full-featured entrypoint that preserves errors. `base` is the directory
297/// used to resolve `{{ include "..." }}` paths; `source_path` (if known) is
298/// included in error messages.
299pub(crate) fn render_template_result(
300    template: &str,
301    bindings: Option<&BTreeMap<String, VmValue>>,
302    base: Option<&Path>,
303    source_path: Option<&Path>,
304) -> Result<String, TemplateError> {
305    let (rendered, _spans) =
306        render_template_with_provenance(template, bindings, base, source_path, false)?;
307    Ok(rendered)
308}
309
310/// Render a template for callers outside the VM crate that need the same
311/// prompt-template semantics as `render(...)` / `render_prompt(...)`.
312pub fn render_template_to_string(
313    template: &str,
314    bindings: Option<&BTreeMap<String, VmValue>>,
315    base: Option<&Path>,
316    source_path: Option<&Path>,
317) -> Result<String, String> {
318    render_template_result(template, bindings, base, source_path).map_err(|error| error.message())
319}
320
321/// One byte-range in a rendered prompt mapped back to its source
322/// template. Foundation for the prompt-provenance UX (burin-code #93):
323/// hover a chunk of the live prompt in the debugger and jump to the
324/// `.harn.prompt` line that produced it.
325///
326/// `output_start` / `output_end` are byte offsets into the rendered
327/// string. `template_line` / `template_col` are 1-based positions in
328/// the source template. `bound_value` carries a short preview of the
329/// expression's runtime value when it's a scalar; omitted for
330/// structural nodes (if/for/include) so callers don't log a giant
331/// dict display for a single `{% for %}`.
332#[derive(Debug, Clone)]
333pub struct PromptSourceSpan {
334    pub template_line: usize,
335    pub template_col: usize,
336    pub output_start: usize,
337    pub output_end: usize,
338    pub kind: PromptSpanKind,
339    pub bound_value: Option<String>,
340    /// When the span was rendered from inside an `include` (possibly
341    /// transitively), this points at the including call's span in the
342    /// parent template. Chained boxes let the IDE walk `A → B → C`
343    /// cross-template breadcrumbs when a deep render spans three
344    /// files. `None` for top-level spans.
345    pub parent_span: Option<Box<PromptSourceSpan>>,
346    /// Template URI for the file that authored this span. Top-level
347    /// spans carry the root render's template uri; included-child
348    /// spans carry the included file's uri so breadcrumb navigation
349    /// can open the right file when the user clicks through the
350    /// `parent_span` chain. Defaults to empty string for callers that
351    /// don't plumb it through.
352    pub template_uri: String,
353}
354
355/// One conditional or section decision recorded during a template
356/// render. Powers the "variant resolution" trace surfaced in the
357/// portal so on-call engineers can answer "which capability branch
358/// fired for this model?" without re-running the template. Recorded
359/// deterministically — same `llm` snapshot + bindings always produce
360/// the same trace, which is what makes replay reproducible (#1668).
361#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
362pub struct BranchDecision {
363    pub kind: BranchKind,
364    pub template_uri: String,
365    pub line: usize,
366    pub col: usize,
367    /// Short identifier for the taken branch. For `{{ if }}`/`elif`:
368    /// `"if"`, `"elif:<idx>"`, `"else"`, or `"none"` when nothing
369    /// matched and no `{{ else }}` was provided. For `{{ section }}`:
370    /// the materialized envelope (e.g. `"xml"`, `"markdown"`,
371    /// `"native_tools"`, `"react"`).
372    pub branch_id: String,
373    /// Human-readable label. For conditionals: the source-derived
374    /// condition expression (e.g. `llm.capabilities.native_tools`).
375    /// For sections: the section name (e.g. `tools`).
376    pub branch_label: Option<String>,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
380#[serde(rename_all = "snake_case")]
381pub enum BranchKind {
382    If,
383    Section,
384}
385
386impl BranchKind {
387    pub fn as_str(self) -> &'static str {
388        match self {
389            BranchKind::If => "if",
390            BranchKind::Section => "section",
391        }
392    }
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum PromptSpanKind {
397    /// Literal template text between directives.
398    Text,
399    /// `{{ expr }}` interpolation — the most common kind the IDE
400    /// wants to highlight on hover.
401    Expr,
402    /// Legacy bare `{{ident}}` fallthrough, surfaced separately so the
403    /// IDE can visually distinguish resolved from pass-through.
404    LegacyBareInterp,
405    /// Conditional branch text that actually rendered (the taken branch).
406    If,
407    /// One loop iteration's rendered body.
408    ForIteration,
409    /// Rendered partial/include expansion. Child spans carry the
410    /// included template's own `template_uri`.
411    Include,
412    /// Capability-adaptive logical prompt section.
413    Section,
414}
415
416/// Provenance-aware rendering. Returns the rendered string plus — when
417/// `collect_provenance` is true — one `PromptSourceSpan` per node so the
418/// IDE can link rendered byte ranges back to template source offsets.
419/// When `collect_provenance` is false, this degrades to the cheap
420/// non-tracked rendering path that the legacy callers use.
421pub(crate) fn render_template_with_provenance(
422    template: &str,
423    bindings: Option<&BTreeMap<String, VmValue>>,
424    base: Option<&Path>,
425    source_path: Option<&Path>,
426    collect_provenance: bool,
427) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
428    let asset = TemplateAsset::inline(template, base, source_path);
429    render_asset_with_provenance_result(&asset, bindings, collect_provenance)
430}
431
432pub(crate) fn render_asset_result(
433    asset: &TemplateAsset,
434    bindings: Option<&BTreeMap<String, VmValue>>,
435) -> Result<String, TemplateError> {
436    let (rendered, _spans) = render_asset_with_provenance_result(asset, bindings, false)?;
437    Ok(rendered)
438}
439
440pub(crate) fn render_stdlib_prompt_asset(
441    path: &str,
442    bindings: Option<&BTreeMap<String, VmValue>>,
443) -> Result<String, VmError> {
444    let target = if path.starts_with("std/") {
445        path.to_string()
446    } else {
447        format!("std/{path}")
448    };
449    let asset = TemplateAsset::render_target(&target).map_err(VmError::Runtime)?;
450    render_asset_result(&asset, bindings).map_err(VmError::from)
451}
452
453/// Test-only helper: render an inline template under the active LLM
454/// render context and return the rendered text plus the branch trace
455/// that drove `template.render` event emission. The same trace is
456/// emitted to the transcript JSONL when a transcript dir is wired in;
457/// exposing it here lets unit tests assert determinism without
458/// scraping the JSONL.
459#[cfg(test)]
460pub(crate) fn render_template_collect_branch_trace(
461    template: &str,
462) -> Result<(String, Vec<BranchDecision>), TemplateError> {
463    let asset = TemplateAsset::inline(template, None, None);
464    render_asset_with_provenance_and_trace_result(&asset, None, false, true)
465        .map(|(rendered, _spans, trace)| (rendered, trace))
466}
467
468pub(crate) fn render_asset_with_provenance_result(
469    asset: &TemplateAsset,
470    bindings: Option<&BTreeMap<String, VmValue>>,
471    collect_provenance: bool,
472) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
473    let (rendered, spans, _trace) =
474        render_asset_with_provenance_and_trace_result(asset, bindings, collect_provenance, false)?;
475    Ok((rendered, spans))
476}
477
478fn render_asset_with_provenance_and_trace_result(
479    asset: &TemplateAsset,
480    bindings: Option<&BTreeMap<String, VmValue>>,
481    collect_provenance: bool,
482    force_branch_trace: bool,
483) -> Result<(String, Vec<PromptSourceSpan>, Vec<BranchDecision>), TemplateError> {
484    let nodes = parse_cached(asset)?;
485    let mut out = String::with_capacity(asset.source.len());
486    // Materialize the ambient `llm` binding when the caller is inside
487    // an LLM frame (`llm_call` / `agent_loop` / handler-stack). User
488    // bindings that already supply `llm` win — emit a one-shot lint
489    // warning to flag the shadowed auto-injection so the author can
490    // rename their key.
491    let augmented = augment_bindings_with_llm(asset, bindings);
492    let scope_bindings = augmented.as_ref().or(bindings);
493    let mut scope = Scope::new(scope_bindings);
494    // Only collect a branch trace when an LLM frame is in scope —
495    // that's the only context where the trace adds debugging value
496    // (capability-adaptive rendering), and the empty-trace events
497    // would otherwise spam every doc-gen / CI render.
498    let llm_ctx = current_llm_render_context();
499    let mut rc = RenderCtx {
500        current_asset: asset.clone(),
501        include_stack: Vec::new(),
502        current_include_parent: None,
503        branch_trace: (force_branch_trace || llm_ctx.is_some()).then(Vec::new),
504    };
505    let mut spans = if collect_provenance {
506        Some(Vec::new())
507    } else {
508        None
509    };
510    render_nodes(&nodes, &mut scope, &mut rc, &mut out, spans.as_mut()).map_err(|mut e| {
511        if e.path.is_none() {
512            e.path = asset.error_path();
513        }
514        if e.uri.is_none() {
515            e.uri = asset.error_uri();
516        }
517        e
518    })?;
519    let trace = rc.branch_trace.take().unwrap_or_default();
520    if let Some(ctx) = llm_ctx {
521        emit_template_render_event(asset, &ctx, &trace, out.len());
522    }
523    Ok((out, spans.unwrap_or_default(), trace))
524}
525
526/// Render a template and return the capability branch trace that drove
527/// logical-section materialization. This is the deterministic counterpart
528/// to the `template.render` transcript event and is used by prompt evals
529/// that need to score section shape without scraping JSONL artifacts.
530pub fn render_template_to_string_with_branch_trace(
531    template: &str,
532    bindings: Option<&BTreeMap<String, VmValue>>,
533    base: Option<&Path>,
534    source_path: Option<&Path>,
535) -> Result<(String, Vec<BranchDecision>), String> {
536    let asset = TemplateAsset::inline(template, base, source_path);
537    render_asset_with_provenance_and_trace_result(&asset, bindings, false, true)
538        .map(|(rendered, _spans, trace)| (rendered, trace))
539        .map_err(|error| error.message())
540}
541
542/// Emit a `template.render` transcript event capturing the resolved
543/// LLM identity + capability snapshot and the branch trace produced
544/// during rendering. Implementation in
545/// [`crate::llm::agent_observe::record_template_render`].
546fn emit_template_render_event(
547    asset: &TemplateAsset,
548    ctx: &LlmRenderContext,
549    trace: &[BranchDecision],
550    rendered_bytes: usize,
551) {
552    crate::llm::agent_observe::record_template_render(
553        &asset.uri,
554        asset.template_revision_hash().as_str(),
555        ctx,
556        trace,
557        rendered_bytes,
558    );
559}