Skip to main content

harn_vm/stdlib/template/
llm_context.rs

1//! Ambient render-time LLM context exposed to `.harn.prompt` templates.
2//!
3//! When `render()` / `render_prompt()` / `render_string()` is invoked from
4//! within an LLM-aware frame (`llm_call`, the default handler stack, or
5//! `agent_loop`), the active provider/model/family/capabilities are
6//! published as the reserved `llm` scope key so authors can write
7//! capability-aware partials without manual plumbing:
8//!
9//! ```text
10//! {{ if llm.capabilities.native_tools }}
11//!   Call `finish_task` when done.
12//! {{ else }}
13//!   When done, output: `<<DONE>>`
14//! {{ end }}
15//! ```
16//!
17//! Bare `render()` calls outside any LLM frame leave `llm = nil`, so
18//! templates branch on `{{ if llm }}` for the doc-gen / CI paths.
19//!
20//! The context lives in a thread-local stack so concurrent agent_loop
21//! iterations on different threads stay isolated; nested
22//! push/pop pairs (e.g. an inner `llm_call` from a middleware handler)
23//! shadow the outer frame for the duration of the inner render.
24
25use crate::value::VmDictExt;
26use std::cell::RefCell;
27use std::collections::BTreeMap;
28use std::sync::atomic::{AtomicU64, Ordering};
29
30use crate::value::VmValue;
31
32/// Resolved provider/model identity plus the corresponding capability
33/// snapshot, materialized at LLM-frame entry and injected as the `llm`
34/// binding during any `render()` call inside that frame.
35#[derive(Debug, Clone)]
36pub struct LlmRenderContext {
37    pub provider: String,
38    pub model: String,
39    pub family: String,
40    /// Snapshot of `provider_capabilities(provider, model)` — a
41    /// `VmValue::Dict` shaped exactly like the builtin's return value.
42    pub capabilities: VmValue,
43}
44
45impl LlmRenderContext {
46    /// Build a context from resolved provider/model strings, looking up
47    /// the capability snapshot and deriving the canonical model family.
48    pub fn resolve(provider: &str, model: &str) -> Self {
49        let caps = crate::llm::capabilities::lookup(provider, model);
50        let capabilities =
51            crate::llm::config_builtins::capabilities_to_vm_value(provider, model, &caps);
52        Self {
53            provider: provider.to_string(),
54            model: model.to_string(),
55            family: crate::llm_config::model_family(provider, model),
56            capabilities,
57        }
58    }
59
60    /// Materialize the context as the `llm` scope value:
61    /// `{provider, model, family, capabilities: <provider_capabilities dict>}`.
62    pub fn to_vm_value(&self) -> VmValue {
63        let mut dict = BTreeMap::new();
64        dict.put_str("provider", self.provider.as_str());
65        dict.put_str("model", self.model.as_str());
66        dict.put_str("family", self.family.as_str());
67        dict.insert("capabilities".to_string(), self.capabilities.clone());
68        VmValue::dict(dict)
69    }
70}
71
72thread_local! {
73    static LLM_RENDER_STACK: RefCell<Vec<LlmRenderContextFrame>> = const { RefCell::new(Vec::new()) };
74}
75
76static NEXT_LLM_RENDER_FRAME_ID: AtomicU64 = AtomicU64::new(1);
77
78/// A stack entry tagged so an RAII guard can clean up the frame it owns without
79/// accidentally popping an unrelated ambient scope when an async task is
80/// cancelled while its task-local stack is swapped out.
81#[derive(Debug, Clone)]
82pub(crate) struct LlmRenderContextFrame {
83    id: u64,
84    context: LlmRenderContext,
85}
86
87fn next_frame_id() -> u64 {
88    NEXT_LLM_RENDER_FRAME_ID.fetch_add(1, Ordering::Relaxed)
89}
90
91fn push_llm_render_context_frame(ctx: LlmRenderContext) -> u64 {
92    let id = next_frame_id();
93    LLM_RENDER_STACK.with(|stack| {
94        stack
95            .borrow_mut()
96            .push(LlmRenderContextFrame { id, context: ctx });
97    });
98    id
99}
100
101/// Push a frame onto the ambient render-context stack. Pair with
102/// `pop_llm_render_context` (or use `LlmRenderContextGuard`) so the
103/// stack stays balanced even on the unwind path.
104pub fn push_llm_render_context(ctx: LlmRenderContext) {
105    push_llm_render_context_frame(ctx);
106}
107
108/// Pop the most recently pushed frame. Returns `None` (rather than
109/// panicking) if the stack was empty, since the host may legitimately
110/// unwind through a balanced push/pop sequence.
111pub fn pop_llm_render_context() -> Option<LlmRenderContext> {
112    LLM_RENDER_STACK.with(|stack| stack.borrow_mut().pop().map(|frame| frame.context))
113}
114
115/// Return a clone of the active frame, or `None` if no LLM context is
116/// in scope. Render entry-points use this to decide whether to inject
117/// the `llm` binding.
118pub fn current_llm_render_context() -> Option<LlmRenderContext> {
119    LLM_RENDER_STACK.with(|stack| stack.borrow().last().map(|frame| frame.context.clone()))
120}
121
122/// Per-task ambient-scope swap of the LLM render-context stack. See
123/// `orchestration::ambient_scope`: this frame is pushed for the duration of an
124/// `llm_call` (held across the model HTTP `.await`), so concurrent fan-out
125/// children each running their own `llm_call` would otherwise render templates
126/// with a sibling's provider/model/capabilities.
127pub(crate) fn swap_llm_render_stack(
128    next: Vec<LlmRenderContextFrame>,
129) -> Vec<LlmRenderContextFrame> {
130    LLM_RENDER_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
131}
132
133/// Reset the stack — wired into `reset_thread_local_state` so tests
134/// and serialized adapter sessions start clean.
135pub(crate) fn reset_llm_render_stack() {
136    LLM_RENDER_STACK.with(|stack| stack.borrow_mut().clear());
137}
138
139/// RAII guard that pushes a context on construction and pops on drop.
140/// Use this in Rust hosts (e.g. `llm_call_impl`) so the stack stays
141/// balanced across `?`-shortcircuits and panics.
142pub struct LlmRenderContextGuard {
143    frame_id: u64,
144    /// Tagged so a misuse (drop-order inversion across nested guards)
145    /// surfaces as a `debug_assert` instead of silently popping the
146    /// wrong frame. Carries no runtime cost in release builds.
147    expected_depth: usize,
148}
149
150impl LlmRenderContextGuard {
151    pub fn enter(ctx: LlmRenderContext) -> Self {
152        let frame_id = push_llm_render_context_frame(ctx);
153        let depth = LLM_RENDER_STACK.with(|stack| stack.borrow().len());
154        Self {
155            frame_id,
156            expected_depth: depth,
157        }
158    }
159}
160
161impl Drop for LlmRenderContextGuard {
162    fn drop(&mut self) {
163        LLM_RENDER_STACK.with(|stack| {
164            let mut stack = stack.borrow_mut();
165            if stack.last().is_some_and(|frame| frame.id == self.frame_id) {
166                stack.pop();
167                return;
168            }
169
170            if let Some(index) = stack.iter().position(|frame| frame.id == self.frame_id) {
171                debug_assert_eq!(
172                    stack.len(),
173                    self.expected_depth,
174                    "LlmRenderContextGuard nested-drop order violated",
175                );
176                debug_assert_eq!(index + 1, stack.len(), "nested-drop order violated");
177                stack.remove(index);
178            }
179        });
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn derive_family(provider: &str, model: &str) -> String {
188        crate::llm_config::model_family(provider, model)
189    }
190
191    #[test]
192    fn family_from_model_id_takes_precedence() {
193        assert_eq!(
194            derive_family("openrouter", "anthropic/claude-3-5-sonnet"),
195            "anthropic-claude"
196        );
197        assert_eq!(derive_family("openrouter", "openai/gpt-4o"), "openai-gpt");
198        assert_eq!(
199            derive_family("openrouter", "google/gemini-1.5-pro"),
200            "google-gemini"
201        );
202        assert_eq!(derive_family("llamacpp", "qwen3.6-35b-a3b"), "qwen");
203    }
204
205    #[test]
206    fn family_falls_back_to_provider_alias() {
207        assert_eq!(
208            derive_family("anthropic", "unknown-future-model"),
209            "anthropic-claude"
210        );
211        assert_eq!(derive_family("azure", "deployment-xyz"), "openai-gpt");
212        assert_eq!(derive_family("vertex", "model-xyz"), "google-gemini");
213        assert_eq!(derive_family("local", "anonymous-snapshot"), "local");
214        assert_eq!(derive_family("", ""), "unknown");
215    }
216
217    #[test]
218    fn push_pop_stack_round_trip() {
219        reset_llm_render_stack();
220        assert!(current_llm_render_context().is_none());
221        push_llm_render_context(LlmRenderContext::resolve("anthropic", "claude-3-5-sonnet"));
222        assert_eq!(
223            current_llm_render_context().map(|c| c.family),
224            Some("anthropic-claude".to_string()),
225        );
226        push_llm_render_context(LlmRenderContext::resolve("openai", "gpt-4o"));
227        assert_eq!(
228            current_llm_render_context().map(|c| c.family),
229            Some("openai-gpt".to_string()),
230        );
231        pop_llm_render_context();
232        assert_eq!(
233            current_llm_render_context().map(|c| c.family),
234            Some("anthropic-claude".to_string()),
235        );
236        pop_llm_render_context();
237        assert!(current_llm_render_context().is_none());
238    }
239
240    #[test]
241    fn guard_pops_on_drop() {
242        reset_llm_render_stack();
243        {
244            let _guard = LlmRenderContextGuard::enter(LlmRenderContext::resolve(
245                "anthropic",
246                "claude-3-5-sonnet",
247            ));
248            assert!(current_llm_render_context().is_some());
249        }
250        assert!(current_llm_render_context().is_none());
251    }
252}