Skip to main content

harn_vm/
step_runtime.rs

1//! Per-step runtime state for `@step`-annotated persona functions.
2//!
3//! The compiler emits a call to the `__register_step` builtin after each
4//! `@step` declaration so the runtime can dispatch on the step's metadata
5//! when its function is invoked. While a step's frame is on the call
6//! stack, an [`ActiveStep`] entry tracks per-step LLM usage, defaults
7//! `llm_call`'s model when the call site doesn't override it, and bounds
8//! cumulative token and cost spend against the step's budget.
9//!
10//! This module owns three thread-locals (a per-program registry, a stack
11//! of currently-active steps, and a log of completed step summaries) but
12//! exposes only narrow helpers — `current_active_step_*` /
13//! `record_step_llm_usage` / etc. — so the call sites in
14//! `crates/harn-vm/src/llm/`, `crates/harn-vm/src/vm/`, and the compiler
15//! stay focused.
16
17use crate::value::VmDictExt;
18use std::cell::{Cell, RefCell};
19use std::collections::BTreeMap;
20use std::sync::Arc;
21
22use serde::Serialize;
23use serde_json::Value as JsonValue;
24
25use crate::orchestration::{
26    current_execution_policy, pop_execution_policy, push_execution_policy, CapabilityPolicy,
27    HookEvent,
28};
29use crate::personas::StageDecl;
30use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
31use crate::value::{VmClosure, VmError, VmValue};
32
33fn vm_str(value: &VmValue) -> Option<&str> {
34    match value {
35        VmValue::String(s) => Some(s.as_ref()),
36        _ => None,
37    }
38}
39
40/// Static metadata captured from a `@step(...)` attribute.
41///
42/// Populated by the `__register_step` builtin (see [`register_step_from_dict`])
43/// when the program first runs, then consulted by `llm_call` and the
44/// frame-pop hooks while the step is active.
45#[derive(Debug, Default, Clone)]
46pub struct StepDefinition {
47    pub name: String,
48    pub function: String,
49    pub model: Option<String>,
50    pub max_tokens: Option<u64>,
51    pub max_usd: Option<f64>,
52    /// One of "fail" (default), "continue", "escalate". Drives how a
53    /// `budget_exceeded` error propagating out of the step is handled —
54    /// see `crates/harn-vm/src/vm/execution.rs`.
55    pub error_boundary: Option<String>,
56}
57
58#[derive(Debug, Default, Clone)]
59pub struct PersonaDefinition {
60    pub name: String,
61    /// Per-stage tool/side-effect scoping. Keyed lookups by stage name happen
62    /// every step entry; the list is small (a handful of stages per persona)
63    /// so a `Vec` keeps insertion order and matches the manifest's authored
64    /// ordering.
65    pub stages: Vec<StageDecl>,
66    /// The persona's declared output style (how it shapes its prose), surfaced
67    /// to Harn via `persona_output_style()`. `None` when the persona declares
68    /// no style.
69    pub output_style: Option<harn_modules::personas::PersonaOutputStyle>,
70}
71
72impl StepDefinition {
73    pub fn boundary(&self) -> StepErrorBoundary {
74        match self.error_boundary.as_deref() {
75            Some("continue") => StepErrorBoundary::Continue,
76            Some("escalate") => StepErrorBoundary::Escalate,
77            _ => StepErrorBoundary::Fail,
78        }
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum StepErrorBoundary {
84    Fail,
85    Continue,
86    Escalate,
87}
88
89/// Tracks one in-flight step. The `frame_depth` is `Vm::frames.len()`
90/// captured immediately after `push_closure_frame` returns, so an
91/// `ActiveStep` is "alive" while `Vm::frames.len() >= frame_depth`.
92#[derive(Debug, Clone)]
93pub struct ActiveStep {
94    pub frame_depth: usize,
95    pub definition: Arc<StepDefinition>,
96    pub persona: Option<String>,
97    pub args: Vec<VmValue>,
98    pub input_tokens: u64,
99    pub output_tokens: u64,
100    pub cost_usd: f64,
101    pub llm_calls: u32,
102    pub last_model: Option<String>,
103    /// Tracing span id opened when the step's frame was pushed; ended on
104    /// completion. 0 when tracing was disabled at push time, in which
105    /// case `span_end` is a no-op anyway.
106    pub span_id: u64,
107    /// True when this step pushed a per-stage `CapabilityPolicy` onto the
108    /// execution policy stack. The runtime pops it when the step's frame
109    /// unwinds, mirroring the RAII guard pattern in
110    /// `crates/harn-serve/src/adapters/acp/modes.rs`.
111    pub stage_policy_pushed: bool,
112}
113
114impl ActiveStep {
115    fn new(
116        frame_depth: usize,
117        definition: Arc<StepDefinition>,
118        persona: Option<String>,
119        args: Vec<VmValue>,
120        span_id: u64,
121        stage_policy_pushed: bool,
122    ) -> Self {
123        Self {
124            frame_depth,
125            definition,
126            persona,
127            args,
128            input_tokens: 0,
129            output_tokens: 0,
130            cost_usd: 0.0,
131            llm_calls: 0,
132            last_model: None,
133            span_id,
134            stage_policy_pushed,
135        }
136    }
137
138    fn total_tokens(&self) -> u64 {
139        self.input_tokens.saturating_add(self.output_tokens)
140    }
141}
142
143#[derive(Debug, Clone)]
144pub struct ActivePersona {
145    pub frame_depth: usize,
146    pub definition: Arc<PersonaDefinition>,
147}
148
149/// Snapshot persisted into [`COMPLETED_STEPS`] when the step's frame
150/// unwinds. Receipts and `harn persona inspect`-style downstream consumers
151/// read it back via [`drain_completed_steps`].
152#[derive(Debug, Clone, Serialize)]
153pub struct CompletedStep {
154    pub name: String,
155    pub function: String,
156    pub model: Option<String>,
157    pub input_tokens: u64,
158    pub output_tokens: u64,
159    pub cost_usd: f64,
160    pub llm_calls: u32,
161    pub status: String,
162    pub error: Option<String>,
163}
164
165thread_local! {
166    static STEP_REGISTRY: RefCell<BTreeMap<String, Arc<StepDefinition>>> =
167        const { RefCell::new(std::collections::BTreeMap::new()) };
168    static PERSONA_REGISTRY: RefCell<BTreeMap<String, Arc<PersonaDefinition>>> =
169        const { RefCell::new(std::collections::BTreeMap::new()) };
170    static STEP_REGISTRY_LEN: Cell<usize> = const { Cell::new(0) };
171    static PERSONA_REGISTRY_LEN: Cell<usize> = const { Cell::new(0) };
172    static PERSONA_STACK: RefCell<Vec<ActivePersona>> = const { RefCell::new(Vec::new()) };
173    static STEP_STACK: RefCell<Vec<ActiveStep>> = const { RefCell::new(Vec::new()) };
174    static COMPLETED_STEPS: RefCell<Vec<CompletedStep>> = const { RefCell::new(Vec::new()) };
175    static PERSONA_HOOKS: RefCell<Vec<PersonaHookRegistration>> = const { RefCell::new(Vec::new()) };
176}
177
178/// Reset every thread-local owned by this module. Called between test
179/// runs and at the start of each top-level program execution so leftover
180/// registrations don't leak across runs.
181pub fn reset_thread_local_state() {
182    STEP_REGISTRY.with(|r| r.borrow_mut().clear());
183    PERSONA_REGISTRY.with(|r| r.borrow_mut().clear());
184    STEP_REGISTRY_LEN.with(|len| len.set(0));
185    PERSONA_REGISTRY_LEN.with(|len| len.set(0));
186    PERSONA_STACK.with(|s| s.borrow_mut().clear());
187    STEP_STACK.with(|s| s.borrow_mut().clear());
188    COMPLETED_STEPS.with(|c| c.borrow_mut().clear());
189    PERSONA_HOOKS.with(|h| h.borrow_mut().clear());
190}
191
192#[inline]
193fn step_registry_empty() -> bool {
194    STEP_REGISTRY_LEN.with(|len| len.get() == 0)
195}
196
197#[inline]
198fn persona_registry_empty() -> bool {
199    PERSONA_REGISTRY_LEN.with(|len| len.get() == 0)
200}
201
202#[inline]
203fn tracked_registries_empty() -> bool {
204    step_registry_empty() && persona_registry_empty()
205}
206
207/// Bind a `@step` function name to its declared metadata. Idempotent: a
208/// second call replaces the prior definition (matches re-evaluation
209/// semantics of `harn run` and the conformance harness).
210pub fn register_step(function: &str, definition: StepDefinition) {
211    let inserted = STEP_REGISTRY.with(|registry| {
212        registry
213            .borrow_mut()
214            .insert(function.to_string(), Arc::new(definition))
215            .is_none()
216    });
217    if inserted {
218        STEP_REGISTRY_LEN.with(|len| len.set(len.get() + 1));
219    }
220}
221
222pub fn register_persona(function: &str, definition: PersonaDefinition) {
223    let inserted = PERSONA_REGISTRY.with(|registry| {
224        registry
225            .borrow_mut()
226            .insert(function.to_string(), Arc::new(definition))
227            .is_none()
228    });
229    if inserted {
230        PERSONA_REGISTRY_LEN.with(|len| len.set(len.get() + 1));
231    }
232}
233
234pub fn register_persona_from_dict(args: Vec<VmValue>) -> Result<VmValue, VmError> {
235    let function = args
236        .first()
237        .and_then(vm_str)
238        .map(|s| s.to_string())
239        .ok_or_else(|| {
240            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
241                "__register_persona: expected (function_name, metadata_dict)",
242            )))
243        })?;
244    let meta = args
245        .get(1)
246        .and_then(VmValue::as_dict)
247        .cloned()
248        .ok_or_else(|| {
249            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
250                "__register_persona: metadata argument must be a dict",
251            )))
252        })?;
253    let definition = PersonaDefinition {
254        name: meta
255            .get("name")
256            .and_then(vm_str)
257            .map(str::to_string)
258            .unwrap_or_else(|| function.clone()),
259        stages: parse_stage_decls(meta.get("stages"))?,
260        output_style: parse_output_style(meta.get("output_style")),
261    };
262    register_persona(&function, definition);
263    Ok(VmValue::Nil)
264}
265
266/// Parse an `output_style` metadata value into a [`PersonaOutputStyle`].
267/// Accepts a bare string (a named style) or a dict with `name`/`instructions`.
268/// Returns `None` for nil or an empty style.
269fn parse_output_style(
270    value: Option<&VmValue>,
271) -> Option<harn_modules::personas::PersonaOutputStyle> {
272    use harn_modules::personas::PersonaOutputStyle;
273    let style = match value? {
274        VmValue::Nil => return None,
275        VmValue::String(name) => PersonaOutputStyle::from_name(name.to_string()),
276        VmValue::Dict(_) => {
277            let dict = value?.as_dict()?;
278            PersonaOutputStyle {
279                name: dict.get("name").and_then(vm_str).map(str::to_string),
280                instructions: dict
281                    .get("instructions")
282                    .and_then(vm_str)
283                    .map(str::to_string),
284            }
285        }
286        _ => return None,
287    };
288    (!style.is_empty()).then_some(style)
289}
290
291/// Build the Harn dict shape for a persona output style.
292fn output_style_to_vm(style: &harn_modules::personas::PersonaOutputStyle) -> VmValue {
293    use crate::value::{intern_key, DictMap};
294    let mut map = DictMap::new();
295    map.insert(
296        intern_key("name"),
297        style
298            .name
299            .as_deref()
300            .map(|name| VmValue::String(arcstr::ArcStr::from(name)))
301            .unwrap_or(VmValue::Nil),
302    );
303    map.insert(
304        intern_key("instructions"),
305        style
306            .instructions
307            .as_deref()
308            .map(|text| VmValue::String(arcstr::ArcStr::from(text)))
309            .unwrap_or(VmValue::Nil),
310    );
311    VmValue::dict(map)
312}
313
314/// Look up a persona's declared output style. With no argument (or nil), reads
315/// the currently-active persona (top of the persona stack); with a persona
316/// function name, reads that persona from the registry. Returns
317/// `{name, instructions}` or nil.
318pub fn persona_output_style(args: Vec<VmValue>) -> VmValue {
319    if let Some(function) = args.first().and_then(vm_str) {
320        return PERSONA_REGISTRY.with(|registry| {
321            registry
322                .borrow()
323                .get(function)
324                .and_then(|definition| definition.output_style.as_ref().map(output_style_to_vm))
325                .unwrap_or(VmValue::Nil)
326        });
327    }
328    PERSONA_STACK.with(|stack| {
329        stack
330            .borrow()
331            .last()
332            .and_then(|active| {
333                active
334                    .definition
335                    .output_style
336                    .as_ref()
337                    .map(output_style_to_vm)
338            })
339            .unwrap_or(VmValue::Nil)
340    })
341}
342
343fn parse_stage_decls(value: Option<&VmValue>) -> Result<Vec<StageDecl>, VmError> {
344    let Some(value) = value else {
345        return Ok(Vec::new());
346    };
347    let entries = match value {
348        VmValue::Nil => return Ok(Vec::new()),
349        VmValue::List(list) => list.as_ref(),
350        _ => {
351            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
352                "__register_persona: stages argument must be a list of dicts",
353            ))));
354        }
355    };
356    let mut out = Vec::with_capacity(entries.len());
357    for entry in entries {
358        let dict = entry.as_dict().ok_or_else(|| {
359            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
360                "__register_persona: each stage entry must be a dict",
361            )))
362        })?;
363        let Some(name) = dict.get("name").and_then(vm_str) else {
364            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
365                "__register_persona: stage dict missing required 'name'",
366            ))));
367        };
368        let allowed_tools = match dict.get("allowed_tools") {
369            None | Some(VmValue::Nil) => None,
370            Some(VmValue::List(items)) => Some(
371                items
372                    .iter()
373                    .map(|item| {
374                        vm_str(item).map(str::to_string).ok_or_else(|| {
375                            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
376                                "__register_persona: stage allowed_tools entries must be strings",
377                            )))
378                        })
379                    })
380                    .collect::<Result<Vec<_>, _>>()?,
381            ),
382            _ => {
383                return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
384                    "__register_persona: stage allowed_tools must be a list of strings",
385                ))));
386            }
387        };
388        let side_effect_level = dict
389            .get("side_effect_level")
390            .and_then(vm_str)
391            .map(str::to_string)
392            .filter(|s| !s.is_empty());
393        let max_iterations = match dict.get("max_iterations") {
394            Some(VmValue::Int(n)) if *n >= 0 => Some(*n as u32),
395            Some(VmValue::Float(f)) if f.is_finite() && *f >= 0.0 => Some(*f as u32),
396            _ => None,
397        };
398        out.push(StageDecl {
399            name: name.to_string(),
400            allowed_tools,
401            side_effect_level,
402            max_iterations,
403            on_exit: None,
404        });
405    }
406    Ok(out)
407}
408
409/// Builtin entry point invoked by compiler-emitted bytecode after every
410/// `@step` function declaration. Accepts a dict mirroring
411/// `harn_modules::PersonaStepMetadata`.
412pub fn register_step_from_dict(args: Vec<VmValue>) -> Result<VmValue, VmError> {
413    let function = args
414        .first()
415        .and_then(vm_str)
416        .map(|s| s.to_string())
417        .ok_or_else(|| {
418            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
419                "__register_step: expected (function_name, metadata_dict)",
420            )))
421        })?;
422    let meta = args
423        .get(1)
424        .and_then(VmValue::as_dict)
425        .cloned()
426        .ok_or_else(|| {
427            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
428                "__register_step: metadata argument must be a dict",
429            )))
430        })?;
431
432    let mut definition = StepDefinition {
433        function: function.clone(),
434        ..StepDefinition::default()
435    };
436    definition.name = meta
437        .get("name")
438        .and_then(vm_str)
439        .map(|s| s.to_string())
440        .unwrap_or_else(|| function.clone());
441    definition.model = meta
442        .get("model")
443        .and_then(vm_str)
444        .map(|s| s.to_string())
445        .filter(|s| !s.is_empty());
446    definition.error_boundary = meta
447        .get("error_boundary")
448        .and_then(vm_str)
449        .map(|s| s.to_string());
450
451    if let Some(VmValue::Dict(budget)) = meta.get("budget") {
452        if let Some(value) = budget.get("max_tokens") {
453            definition.max_tokens = match value {
454                VmValue::Int(n) if *n > 0 => Some(*n as u64),
455                VmValue::Float(f) if f.is_finite() && *f > 0.0 => Some(*f as u64),
456                _ => None,
457            };
458        }
459        if let Some(value) = budget.get("max_usd") {
460            definition.max_usd = match value {
461                VmValue::Float(f) if f.is_finite() && *f >= 0.0 => Some(*f),
462                VmValue::Int(n) if *n >= 0 => Some(*n as f64),
463                _ => None,
464            };
465        }
466    }
467
468    register_step(&function, definition);
469    Ok(VmValue::Nil)
470}
471
472#[derive(Clone)]
473pub struct PersonaHookRegistration {
474    pub persona_pattern: String,
475    pub step_name: Option<String>,
476    pub event: HookEvent,
477    pub threshold_pct: Option<f64>,
478    pub handler: Arc<VmClosure>,
479}
480
481impl std::fmt::Debug for PersonaHookRegistration {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        f.debug_struct("PersonaHookRegistration")
484            .field("persona_pattern", &self.persona_pattern)
485            .field("step_name", &self.step_name)
486            .field("event", &self.event)
487            .field("threshold_pct", &self.threshold_pct)
488            .field("handler", &"..")
489            .finish()
490    }
491}
492
493#[derive(Debug, Clone)]
494pub struct PersonaHookInvocation {
495    pub handler: Arc<VmClosure>,
496    pub event: HookEvent,
497}
498
499pub fn register_persona_hook(
500    persona_pattern: impl Into<String>,
501    event: HookEvent,
502    threshold_pct: Option<f64>,
503    handler: Arc<VmClosure>,
504) {
505    PERSONA_HOOKS.with(|hooks| {
506        hooks.borrow_mut().push(PersonaHookRegistration {
507            persona_pattern: persona_pattern.into(),
508            step_name: None,
509            event,
510            threshold_pct,
511            handler,
512        });
513    });
514}
515
516pub fn register_step_hook(
517    persona_pattern: impl Into<String>,
518    step_name: impl Into<String>,
519    event: HookEvent,
520    threshold_pct: Option<f64>,
521    handler: Arc<VmClosure>,
522) {
523    PERSONA_HOOKS.with(|hooks| {
524        hooks.borrow_mut().push(PersonaHookRegistration {
525            persona_pattern: persona_pattern.into(),
526            step_name: Some(step_name.into()),
527            event,
528            threshold_pct,
529            handler,
530        });
531    });
532}
533
534pub fn clear_persona_hooks() {
535    PERSONA_HOOKS.with(|hooks| hooks.borrow_mut().clear());
536}
537
538pub struct ActiveContextSnapshot {
539    steps: Vec<ActiveStep>,
540    personas: Vec<ActivePersona>,
541}
542
543pub fn take_active_context() -> ActiveContextSnapshot {
544    ActiveContextSnapshot {
545        steps: STEP_STACK.with(|stack| std::mem::take(&mut *stack.borrow_mut())),
546        personas: PERSONA_STACK.with(|stack| std::mem::take(&mut *stack.borrow_mut())),
547    }
548}
549
550pub fn restore_active_context(snapshot: ActiveContextSnapshot) {
551    STEP_STACK.with(|stack| *stack.borrow_mut() = snapshot.steps);
552    PERSONA_STACK.with(|stack| *stack.borrow_mut() = snapshot.personas);
553}
554
555pub fn is_tracked_function(function_name: &str) -> bool {
556    if tracked_registries_empty() {
557        return false;
558    }
559    (!step_registry_empty()
560        && STEP_REGISTRY.with(|registry| registry.borrow().contains_key(function_name)))
561        || (!persona_registry_empty()
562            && PERSONA_REGISTRY.with(|registry| registry.borrow().contains_key(function_name)))
563}
564
565pub fn step_definition_for_function(function_name: &str) -> Option<Arc<StepDefinition>> {
566    if step_registry_empty() {
567        return None;
568    }
569    STEP_REGISTRY.with(|registry| registry.borrow().get(function_name).cloned())
570}
571
572pub fn current_persona_name() -> Option<String> {
573    PERSONA_STACK.with(|stack| stack.borrow().last().map(|p| p.definition.name.clone()))
574}
575
576/// Resolve the per-stage policy for `step_name` against the currently
577/// active persona's stage declarations. Returns `None` when no persona is
578/// active or no stage matches the step name. Caller pushes the result onto
579/// `EXECUTION_POLICY_STACK`.
580///
581/// When an ambient policy is already active, the stage policy is
582/// intersected with it so a stage can only ever tighten the tool surface
583/// and side-effect ceiling — never widen them.
584fn stage_policy_for_active_step(step_name: &str) -> Option<CapabilityPolicy> {
585    let stage_policy = PERSONA_STACK.with(|stack| {
586        let stack = stack.borrow();
587        let persona = stack.last()?;
588        let stage = persona
589            .definition
590            .stages
591            .iter()
592            .find(|stage| stage.name == step_name)?;
593        Some(stage_decl_to_policy(stage))
594    })?;
595    let Some(parent) = current_execution_policy() else {
596        return Some(stage_policy);
597    };
598    // `intersect` is conservative: failure means the stage referenced a tool
599    // the ambient policy already denied. Fall back to a narrowed copy that
600    // drops those entries so the stage can never widen the ceiling.
601    Some(parent.intersect(&stage_policy).unwrap_or_else(|_| {
602        let intersected_tools: Vec<String> = stage_policy
603            .tools
604            .iter()
605            .filter(|tool| parent.tools.is_empty() || parent.tools.contains(*tool))
606            .cloned()
607            .collect();
608        CapabilityPolicy {
609            tools: intersected_tools,
610            ..stage_policy
611        }
612    }))
613}
614
615fn stage_decl_to_policy(stage: &StageDecl) -> CapabilityPolicy {
616    CapabilityPolicy {
617        tools: stage.allowed_tools.clone().unwrap_or_default(),
618        side_effect_level: stage.side_effect_level.clone(),
619        ..CapabilityPolicy::default()
620    }
621}
622
623fn persona_matches(pattern: &str, persona: &str) -> bool {
624    crate::orchestration::glob_match(pattern, persona)
625}
626
627pub fn matching_hooks(
628    event: HookEvent,
629    persona: Option<&str>,
630    step_name: Option<&str>,
631    budget_pct: Option<f64>,
632) -> Vec<PersonaHookInvocation> {
633    let persona = persona.unwrap_or("");
634    PERSONA_HOOKS.with(|hooks| {
635        hooks
636            .borrow()
637            .iter()
638            .filter(|hook| hook.event == event)
639            .filter(|hook| persona_matches(&hook.persona_pattern, persona))
640            .filter(|hook| match (&hook.step_name, step_name) {
641                (Some(expected), Some(actual)) => expected == actual,
642                (Some(_), None) => false,
643                (None, _) => true,
644            })
645            .filter(|hook| match (hook.threshold_pct, budget_pct) {
646                (Some(threshold), Some(pct)) => pct >= threshold,
647                (Some(_), None) => false,
648                (None, _) => true,
649            })
650            .map(|hook| PersonaHookInvocation {
651                handler: hook.handler.clone(),
652                event: hook.event,
653            })
654            .collect()
655    })
656}
657
658pub fn maybe_push_active_persona(function_name: &str, frame_depth: usize) -> bool {
659    if persona_registry_empty() {
660        return false;
661    }
662    let definition =
663        PERSONA_REGISTRY.with(|registry| registry.borrow().get(function_name).cloned());
664    let Some(definition) = definition else {
665        return false;
666    };
667    PERSONA_STACK.with(|stack| {
668        stack.borrow_mut().push(ActivePersona {
669            frame_depth,
670            definition,
671        });
672    });
673    true
674}
675
676/// Push an active step onto the stack iff `function_name` has metadata
677/// registered. Returns `true` when a frame was pushed so the call site
678/// can record that fact. Called from `Vm::push_closure_frame` after the
679/// new frame has been added.
680pub fn maybe_push_active_step(function_name: &str, frame_depth: usize, args: &[VmValue]) -> bool {
681    if step_registry_empty() {
682        return false;
683    }
684    let definition = STEP_REGISTRY.with(|registry| registry.borrow().get(function_name).cloned());
685    let Some(definition) = definition else {
686        return false;
687    };
688    let persona = current_persona_name();
689    let span_id =
690        crate::tracing::span_start(crate::tracing::SpanKind::Step, definition.name.clone());
691    if let Some(persona_name) = persona.as_deref() {
692        crate::tracing::span_set_metadata(
693            span_id,
694            "persona",
695            serde_json::Value::String(persona_name.to_string()),
696        );
697    }
698    if let Some(model) = definition.model.as_deref() {
699        crate::tracing::span_set_metadata(
700            span_id,
701            "model",
702            serde_json::Value::String(model.to_string()),
703        );
704    }
705    let step_name = definition.name.clone();
706    STEP_STACK.with(|stack| {
707        stack.borrow_mut().push(ActiveStep::new(
708            frame_depth,
709            definition,
710            persona,
711            args.to_vec(),
712            span_id,
713            false,
714        ));
715    });
716    if let Some(policy) = stage_policy_for_active_step(&step_name) {
717        push_execution_policy(policy);
718        STEP_STACK.with(|stack| {
719            if let Some(top) = stack.borrow_mut().last_mut() {
720                top.stage_policy_pushed = true;
721            }
722        });
723    }
724    true
725}
726
727/// Drop any step entries whose owning frame has already been unwound,
728/// recording a `CompletedStep` summary for each. The `current_frame_depth`
729/// is `Vm::frames.len()` at the call site — entries with
730/// `frame_depth > current_frame_depth` are stale.
731pub fn prune_below_frame(current_frame_depth: usize) {
732    let mut popped: Vec<ActiveStep> = Vec::new();
733    STEP_STACK.with(|stack| {
734        let mut stack = stack.borrow_mut();
735        while let Some(top) = stack.last() {
736            if top.frame_depth > current_frame_depth {
737                popped.push(stack.pop().unwrap());
738            } else {
739                break;
740            }
741        }
742    });
743    for step in popped {
744        finish_step(step, "completed", None);
745    }
746    PERSONA_STACK.with(|stack| {
747        let mut stack = stack.borrow_mut();
748        while stack
749            .last()
750            .is_some_and(|persona| persona.frame_depth > current_frame_depth)
751        {
752            stack.pop();
753        }
754    });
755}
756
757pub fn take_active_step(current_frame_depth: usize) -> Option<ActiveStep> {
758    STEP_STACK.with(|stack| {
759        let mut stack = stack.borrow_mut();
760        if stack
761            .last()
762            .is_some_and(|step| step.frame_depth == current_frame_depth)
763        {
764            stack.pop()
765        } else {
766            None
767        }
768    })
769}
770
771pub fn finish_active_step(step: ActiveStep, status: &str, error: Option<String>) {
772    finish_step(step, status, error);
773}
774
775/// Pop the topmost active step (if its frame is the current one) and
776/// record an explicit completion status. Used when an error boundary
777/// rewrites or absorbs an in-flight error so the receipt log reflects the
778/// outcome the persona actually saw.
779pub fn pop_and_record(current_frame_depth: usize, status: &str, error: Option<String>) -> bool {
780    let popped = STEP_STACK.with(|stack| {
781        let mut stack = stack.borrow_mut();
782        if stack
783            .last()
784            .map(|step| step.frame_depth == current_frame_depth)
785            .unwrap_or(false)
786        {
787            stack.pop()
788        } else {
789            None
790        }
791    });
792    let Some(step) = popped else {
793        return false;
794    };
795    finish_step(step, status, error);
796    true
797}
798
799fn finish_step(step: ActiveStep, status: &str, error: Option<String>) {
800    if step.stage_policy_pushed {
801        pop_execution_policy();
802    }
803    crate::tracing::span_set_metadata(
804        step.span_id,
805        "status",
806        serde_json::Value::String(status.to_string()),
807    );
808    crate::tracing::span_set_metadata(
809        step.span_id,
810        "llm_calls",
811        serde_json::Value::Number(step.llm_calls.into()),
812    );
813    crate::tracing::span_set_metadata(
814        step.span_id,
815        "input_tokens",
816        serde_json::Value::Number(step.input_tokens.into()),
817    );
818    crate::tracing::span_set_metadata(
819        step.span_id,
820        "output_tokens",
821        serde_json::Value::Number(step.output_tokens.into()),
822    );
823    if let Some(cost_n) = serde_json::Number::from_f64(step.cost_usd) {
824        crate::tracing::span_set_metadata(
825            step.span_id,
826            "cost_usd",
827            serde_json::Value::Number(cost_n),
828        );
829    }
830    crate::tracing::span_end(step.span_id);
831    let summary = CompletedStep {
832        name: step.definition.name.clone(),
833        function: step.definition.function.clone(),
834        model: step
835            .last_model
836            .clone()
837            .or_else(|| step.definition.model.clone()),
838        input_tokens: step.input_tokens,
839        output_tokens: step.output_tokens,
840        cost_usd: step.cost_usd,
841        llm_calls: step.llm_calls,
842        status: status.to_string(),
843        error,
844    };
845    COMPLETED_STEPS.with(|completed| completed.borrow_mut().push(summary));
846}
847
848/// Get a snapshot of the topmost active step, if any. Used by the
849/// llm_call path to fill in defaults — never for mutation.
850pub fn with_active_step<R>(f: impl FnOnce(&ActiveStep) -> R) -> Option<R> {
851    STEP_STACK.with(|stack| stack.borrow().last().map(f))
852}
853
854/// Mutate the topmost active step (typically to attribute LLM usage).
855pub fn with_active_step_mut<R>(f: impl FnOnce(&mut ActiveStep) -> R) -> Option<R> {
856    STEP_STACK.with(|stack| stack.borrow_mut().last_mut().map(f))
857}
858
859/// Frame depth of the topmost active step, or `None` when no step is
860/// active. Used by `handle_error` to detect "this throw is exiting a
861/// step's frame".
862pub fn active_step_frame_depth() -> Option<usize> {
863    STEP_STACK.with(|stack| stack.borrow().last().map(|s| s.frame_depth))
864}
865
866/// Default model the topmost active step should impose on `llm_call`
867/// invocations whose options dict didn't pin a model.
868pub fn active_step_model_default() -> Option<String> {
869    STEP_STACK.with(|stack| {
870        stack
871            .borrow()
872            .last()
873            .and_then(|step| step.definition.model.clone())
874    })
875}
876
877/// Record that `llm_call` consumed `input_tokens` / `output_tokens` for
878/// `cost_usd`. Updates the active step's running totals and returns a
879/// budget-exhaustion error if the step's ceiling is now breached.
880///
881/// The check is performed AFTER the call so the test fixture's first
882/// call (which fits under budget) succeeds and subsequent calls trip the
883/// limit. This matches the existing `accumulate_cost_for_provider`
884/// pattern where global budget is also checked post-hoc.
885pub fn record_step_llm_usage(
886    model: &str,
887    input_tokens: i64,
888    output_tokens: i64,
889    cost_usd: f64,
890) -> Result<(), VmError> {
891    let exhausted = STEP_STACK.with(|stack| -> Option<VmError> {
892        let mut stack = stack.borrow_mut();
893        let step = stack.last_mut()?;
894        step.input_tokens = step.input_tokens.saturating_add(input_tokens.max(0) as u64);
895        step.output_tokens = step
896            .output_tokens
897            .saturating_add(output_tokens.max(0) as u64);
898        step.cost_usd += cost_usd;
899        step.llm_calls = step.llm_calls.saturating_add(1);
900        if !model.is_empty() {
901            step.last_model = Some(model.to_string());
902        }
903
904        if let Some(max_tokens) = step.definition.max_tokens {
905            if step.total_tokens() > max_tokens {
906                return Some(budget_exhausted_error(
907                    &step.definition,
908                    "max_tokens",
909                    max_tokens as f64,
910                    step.total_tokens() as f64,
911                    step.cost_usd,
912                ));
913            }
914        }
915        if let Some(max_usd) = step.definition.max_usd {
916            if step.cost_usd > max_usd {
917                return Some(budget_exhausted_error(
918                    &step.definition,
919                    "max_usd",
920                    max_usd,
921                    step.total_tokens() as f64,
922                    step.cost_usd,
923                ));
924            }
925        }
926        None
927    });
928    if let Some(err) = exhausted {
929        return Err(err);
930    }
931    Ok(())
932}
933
934fn budget_exhausted_error(
935    definition: &StepDefinition,
936    limit: &str,
937    limit_value: f64,
938    consumed_tokens: f64,
939    consumed_cost_usd: f64,
940) -> VmError {
941    let mut dict: crate::value::DictMap = crate::value::DictMap::new();
942    dict.put_str("category", "budget_exceeded");
943    dict.put_str("kind", "budget_exhausted");
944    dict.put_str("reason", "step_budget_exhausted");
945    dict.put_str("step", definition.name.clone());
946    dict.put_str("function", definition.function.clone());
947    dict.put_str("limit", limit);
948    dict.insert(
949        crate::value::intern_key("limit_value"),
950        VmValue::Float(limit_value),
951    );
952    dict.insert(
953        crate::value::intern_key("consumed_tokens"),
954        VmValue::Float(consumed_tokens),
955    );
956    dict.insert(
957        crate::value::intern_key("consumed_cost_usd"),
958        VmValue::Float(consumed_cost_usd),
959    );
960    dict.put_str(
961        "error_boundary",
962        definition
963            .error_boundary
964            .clone()
965            .unwrap_or_else(|| "fail".to_string()),
966    );
967    dict.put_str(
968        "message",
969        format!(
970            "step `{}` exceeded {} budget ({} > {})",
971            definition.name, limit, consumed_tokens as i64, limit_value as i64
972        ),
973    );
974    VmError::Thrown(VmValue::dict(dict))
975}
976
977/// Returns true if the thrown value looks like a budget-exhausted
978/// error — either our typed step-budget dict or the existing
979/// `crates/harn-vm/src/llm/cost.rs::budget_exceeded_error` shape.
980/// Either form is treated identically by `error_boundary` because the
981/// per-step budget machinery layers onto the existing envelope; a step
982/// whose budget the preflight projection rejects is still a budget
983/// exhaustion the step authored.
984pub fn is_step_budget_exhausted(err: &VmError) -> bool {
985    let VmError::Thrown(VmValue::Dict(dict)) = err else {
986        return false;
987    };
988    let category = dict.get("category").and_then(vm_str);
989    let kind = dict.get("kind").and_then(vm_str);
990    let reason = dict.get("reason").and_then(vm_str);
991    if matches!(kind, Some("budget_exhausted")) && matches!(reason, Some("step_budget_exhausted")) {
992        return true;
993    }
994    matches!(category, Some("budget_exceeded"))
995}
996
997/// Annotate an existing budget-exhausted error with `escalated: true`
998/// and the step's identity so the persona body / handoff receiver can
999/// route on it. Returns the original error if it isn't a thrown dict.
1000/// Ensures `step` and `function` keys reflect the just-finished step
1001/// even when the underlying error was raised by the preflight budget
1002/// machinery (which doesn't know which step it's running under).
1003pub fn mark_escalated(err: VmError, step_name: Option<&str>, function: Option<&str>) -> VmError {
1004    let VmError::Thrown(VmValue::Dict(dict)) = err else {
1005        return err;
1006    };
1007    let mut next = (*dict).clone();
1008    next.insert(crate::value::intern_key("escalated"), VmValue::Bool(true));
1009    next.put_str("category", "handoff_escalation");
1010    if let Some(step) = step_name {
1011        next.entry(crate::value::intern_key("step"))
1012            .or_insert_with(|| VmValue::String(arcstr::ArcStr::from(step.to_string())));
1013    }
1014    if let Some(function) = function {
1015        next.entry(crate::value::intern_key("function"))
1016            .or_insert_with(|| VmValue::String(arcstr::ArcStr::from(function.to_string())));
1017    }
1018    VmError::Thrown(VmValue::dict(next))
1019}
1020
1021/// Drain the completed-step log. Used by receipt builders that want a
1022/// per-step model + token + cost breakdown for the just-finished run.
1023pub fn drain_completed_steps() -> Vec<CompletedStep> {
1024    COMPLETED_STEPS.with(|completed| std::mem::take(&mut *completed.borrow_mut()))
1025}
1026
1027/// Read the completed-step log without clearing it. Use when callers
1028/// want a peek without disturbing the global record stream.
1029pub fn peek_completed_steps() -> Vec<CompletedStep> {
1030    COMPLETED_STEPS.with(|completed| completed.borrow().clone())
1031}
1032
1033/// Lower a [`CompletedStep`] into JSON for embedding in receipts /
1034/// inspect output.
1035pub fn completed_step_to_json(step: &CompletedStep) -> JsonValue {
1036    serde_json::to_value(step).unwrap_or(JsonValue::Null)
1037}
1038
1039/// Register the `__register_step` and `__register_persona` host builtins.
1040/// Compiler-emitted bytecode after every `@step` / persona declaration
1041/// calls these with `(function_name, metadata_dict)` so the runtime can
1042/// later dispatch on the step's metadata when its function is invoked.
1043pub fn register_step_builtins(vm: &mut crate::vm::Vm) {
1044    for def in MODULE_BUILTINS {
1045        vm.register_builtin_def(def);
1046    }
1047}
1048
1049pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
1050    &__REGISTER_STEP_DEF,
1051    &__REGISTER_PERSONA_DEF,
1052    &__PERSONA_OUTPUT_STYLE_DEF,
1053];
1054
1055#[harn_builtin(category = "step_runtime", runtime_only = true)]
1056fn __register_step(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1057    register_step_from_dict(args.to_vec())
1058}
1059
1060#[harn_builtin(category = "step_runtime", runtime_only = true)]
1061fn __register_persona(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1062    register_persona_from_dict(args.to_vec())
1063}
1064
1065#[harn_builtin(
1066    sig = "__persona_output_style(function?: string) -> dict",
1067    category = "step_runtime",
1068    runtime_only = true
1069)]
1070fn __persona_output_style(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1071    Ok(persona_output_style(args.to_vec()))
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use crate::value::VmDictExt;
1078
1079    fn fresh_state() {
1080        reset_thread_local_state();
1081    }
1082
1083    #[test]
1084    fn persona_output_style_reads_registry_and_active_stack() {
1085        use crate::value::{intern_key, DictMap};
1086        fresh_state();
1087
1088        // Register a persona whose metadata carries a table-form output style.
1089        let mut style = DictMap::new();
1090        style.put_str("name", "concise");
1091        style.put_str("instructions", "Be terse.");
1092        let mut meta = DictMap::new();
1093        meta.put_str("name", "Reviewer");
1094        meta.insert(intern_key("output_style"), VmValue::dict(style));
1095        register_persona_from_dict(vec![
1096            VmValue::String(arcstr::ArcStr::from("reviewer_fn")),
1097            VmValue::dict(meta),
1098        ])
1099        .expect("persona registers");
1100
1101        // Lookup by function name returns the declared style.
1102        let by_name =
1103            persona_output_style(vec![VmValue::String(arcstr::ArcStr::from("reviewer_fn"))]);
1104        let dict = by_name.as_dict().expect("dict");
1105        assert_eq!(
1106            dict.get("name").map(VmValue::display).as_deref(),
1107            Some("concise")
1108        );
1109        assert_eq!(
1110            dict.get("instructions").map(VmValue::display).as_deref(),
1111            Some("Be terse.")
1112        );
1113
1114        // No active persona on the stack → nil.
1115        assert!(matches!(persona_output_style(vec![]), VmValue::Nil));
1116        // Unknown persona → nil.
1117        assert!(matches!(
1118            persona_output_style(vec![VmValue::String(arcstr::ArcStr::from("nope"))]),
1119            VmValue::Nil
1120        ));
1121    }
1122
1123    #[test]
1124    fn registers_and_pops_step_from_dict() {
1125        fresh_state();
1126        let mut budget: crate::value::DictMap = crate::value::DictMap::new();
1127        budget.insert(crate::value::intern_key("max_tokens"), VmValue::Int(100));
1128        budget.insert(crate::value::intern_key("max_usd"), VmValue::Float(0.05));
1129        let mut meta: crate::value::DictMap = crate::value::DictMap::new();
1130        meta.put_str("name", "plan");
1131        meta.put_str("model", "claude-haiku-4-5");
1132        meta.put_str("error_boundary", "continue");
1133        meta.insert(crate::value::intern_key("budget"), VmValue::dict(budget));
1134
1135        register_step_from_dict(vec![
1136            VmValue::String(arcstr::ArcStr::from("plan_step")),
1137            VmValue::dict(meta),
1138        ])
1139        .expect("registration succeeds");
1140
1141        assert!(maybe_push_active_step("plan_step", 3, &[]));
1142        assert_eq!(active_step_frame_depth(), Some(3));
1143        assert_eq!(
1144            active_step_model_default().as_deref(),
1145            Some("claude-haiku-4-5")
1146        );
1147
1148        record_step_llm_usage("claude-haiku-4-5", 10, 20, 0.001).expect("under budget");
1149        with_active_step(|step| {
1150            assert_eq!(step.input_tokens, 10);
1151            assert_eq!(step.output_tokens, 20);
1152            assert!((step.cost_usd - 0.001).abs() < 1e-9);
1153        });
1154
1155        let err =
1156            record_step_llm_usage("claude-haiku-4-5", 50, 50, 0.0).expect_err("should exhaust");
1157        assert!(is_step_budget_exhausted(&err));
1158
1159        prune_below_frame(2);
1160        let completed = drain_completed_steps();
1161        assert_eq!(completed.len(), 1);
1162        assert_eq!(completed[0].llm_calls, 2);
1163    }
1164
1165    #[test]
1166    fn unregistered_function_does_not_push() {
1167        fresh_state();
1168        assert!(!maybe_push_active_step("not_a_step", 1, &[]));
1169        assert!(active_step_frame_depth().is_none());
1170    }
1171
1172    #[test]
1173    fn tracked_registry_empty_fast_path_tracks_registrations_and_reset() {
1174        fresh_state();
1175        assert!(tracked_registries_empty());
1176        assert!(!is_tracked_function("plan_step"));
1177
1178        register_step(
1179            "plan_step",
1180            StepDefinition {
1181                name: "plan".to_string(),
1182                function: "plan_step".to_string(),
1183                ..StepDefinition::default()
1184            },
1185        );
1186        assert!(!tracked_registries_empty());
1187        assert!(is_tracked_function("plan_step"));
1188        assert!(step_definition_for_function("plan_step").is_some());
1189
1190        register_step(
1191            "plan_step",
1192            StepDefinition {
1193                name: "plan_v2".to_string(),
1194                function: "plan_step".to_string(),
1195                ..StepDefinition::default()
1196            },
1197        );
1198        assert!(is_tracked_function("plan_step"));
1199
1200        fresh_state();
1201        assert!(tracked_registries_empty());
1202        assert!(!is_tracked_function("plan_step"));
1203    }
1204
1205    #[test]
1206    fn stage_policy_narrows_but_does_not_widen_parent_policy() {
1207        fresh_state();
1208        let mut meta: crate::value::DictMap = crate::value::DictMap::new();
1209        meta.put_str("name", "research");
1210        register_step_from_dict(vec![
1211            VmValue::String(arcstr::ArcStr::from("research_step")),
1212            VmValue::dict(meta),
1213        ])
1214        .expect("step registration");
1215
1216        let mut stage_dict: crate::value::DictMap = crate::value::DictMap::new();
1217        stage_dict.put_str("name", "research");
1218        // Stage tries to add `edit` on top of a parent that only allowed `read`.
1219        stage_dict.insert(
1220            crate::value::intern_key("allowed_tools"),
1221            VmValue::List(std::sync::Arc::new(vec![
1222                VmValue::String(arcstr::ArcStr::from("read")),
1223                VmValue::String(arcstr::ArcStr::from("edit")),
1224            ])),
1225        );
1226        let mut persona_meta: crate::value::DictMap = crate::value::DictMap::new();
1227        persona_meta.put_str("name", "scoped");
1228        persona_meta.insert(
1229            crate::value::intern_key("stages"),
1230            VmValue::List(std::sync::Arc::new(vec![VmValue::Dict(
1231                std::sync::Arc::new(stage_dict),
1232            )])),
1233        );
1234        register_persona_from_dict(vec![
1235            VmValue::String(arcstr::ArcStr::from("scoped_persona")),
1236            VmValue::dict(persona_meta),
1237        ])
1238        .expect("persona registration");
1239
1240        push_execution_policy(CapabilityPolicy {
1241            tools: vec!["read".to_string()],
1242            ..CapabilityPolicy::default()
1243        });
1244        assert!(maybe_push_active_persona("scoped_persona", 1));
1245        assert!(maybe_push_active_step("research_step", 2, &[]));
1246        let policy = current_execution_policy().expect("stage policy active");
1247        // `edit` is filtered out because the parent already denied it.
1248        assert_eq!(policy.tools, vec!["read".to_string()]);
1249
1250        prune_below_frame(0);
1251        pop_execution_policy();
1252        assert!(current_execution_policy().is_none());
1253    }
1254
1255    #[test]
1256    fn stage_policy_is_pushed_and_popped_around_step() {
1257        fresh_state();
1258        let mut meta: crate::value::DictMap = crate::value::DictMap::new();
1259        meta.put_str("name", "research");
1260        register_step_from_dict(vec![
1261            VmValue::String(arcstr::ArcStr::from("research_step")),
1262            VmValue::dict(meta),
1263        ])
1264        .expect("step registration succeeds");
1265
1266        let mut stage_dict: crate::value::DictMap = crate::value::DictMap::new();
1267        stage_dict.put_str("name", "research");
1268        stage_dict.insert(
1269            crate::value::intern_key("allowed_tools"),
1270            VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1271                arcstr::ArcStr::from("read"),
1272            )])),
1273        );
1274        let mut persona_meta: crate::value::DictMap = crate::value::DictMap::new();
1275        persona_meta.put_str("name", "scoped");
1276        persona_meta.insert(
1277            crate::value::intern_key("stages"),
1278            VmValue::List(std::sync::Arc::new(vec![VmValue::Dict(
1279                std::sync::Arc::new(stage_dict),
1280            )])),
1281        );
1282        register_persona_from_dict(vec![
1283            VmValue::String(arcstr::ArcStr::from("scoped_persona")),
1284            VmValue::dict(persona_meta),
1285        ])
1286        .expect("persona registration succeeds");
1287
1288        assert!(maybe_push_active_persona("scoped_persona", 1));
1289        assert!(crate::orchestration::current_execution_policy().is_none());
1290        assert!(maybe_push_active_step("research_step", 2, &[]));
1291        let policy = crate::orchestration::current_execution_policy()
1292            .expect("stage policy is active inside step");
1293        assert_eq!(policy.tools, vec!["read".to_string()]);
1294
1295        prune_below_frame(0);
1296        assert!(crate::orchestration::current_execution_policy().is_none());
1297    }
1298}