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