1use 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#[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 pub error_boundary: Option<String>,
56}
57
58#[derive(Debug, Default, Clone)]
59pub struct PersonaDefinition {
60 pub name: String,
61 pub stages: Vec<StageDecl>,
66 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#[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 pub span_id: u64,
107 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#[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
178pub 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
207pub 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
266fn 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
291fn 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
314pub 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
409pub 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
576fn 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 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
676pub 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
727pub 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
775pub 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
848pub fn with_active_step<R>(f: impl FnOnce(&ActiveStep) -> R) -> Option<R> {
851 STEP_STACK.with(|stack| stack.borrow().last().map(f))
852}
853
854pub 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
859pub fn active_step_frame_depth() -> Option<usize> {
863 STEP_STACK.with(|stack| stack.borrow().last().map(|s| s.frame_depth))
864}
865
866pub 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
877pub 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
977pub 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
997pub 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
1021pub fn drain_completed_steps() -> Vec<CompletedStep> {
1024 COMPLETED_STEPS.with(|completed| std::mem::take(&mut *completed.borrow_mut()))
1025}
1026
1027pub fn peek_completed_steps() -> Vec<CompletedStep> {
1030 COMPLETED_STEPS.with(|completed| completed.borrow().clone())
1031}
1032
1033pub fn completed_step_to_json(step: &CompletedStep) -> JsonValue {
1036 serde_json::to_value(step).unwrap_or(JsonValue::Null)
1037}
1038
1039pub 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 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 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 assert!(matches!(persona_output_style(vec![]), VmValue::Nil));
1116 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_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 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}