Skip to main content

harn_vm/vm/
dispatch.rs

1use std::future::Future;
2use std::sync::Arc;
3
4use crate::value::{ErrorCategory, VmBuiltinFn, VmClosure, VmError, VmValue};
5use crate::BuiltinId;
6
7use super::{
8    CallArgs, ScopeSpan, Vm, VmBuiltinArity, VmBuiltinDispatch, VmBuiltinEntry, VmBuiltinKind,
9    VmBuiltinMetadata,
10};
11
12/// Everything that watches one builtin call, held open for its duration.
13///
14/// A builtin reaches its handler through one of three paths (two sync fast
15/// paths that differ only in where the arguments live, and the async/bridge
16/// path). Each used to open the auto-trace span itself, so an observer added to
17/// one path silently missed the other two — per-builtin cost recording was
18/// added to the async path first and reported nothing, because a `let` binding
19/// in the arm nobody takes looks exactly like a working one.
20///
21/// Opening this is the one thing a dispatch path must do before invoking a
22/// handler. New observers belong here, not at a call site.
23struct BuiltinObservation<'a> {
24    _span: Option<ScopeSpan>,
25    _timer: Option<crate::builtin_profile::BuiltinTimer<'a>>,
26}
27
28impl Vm {
29    fn builtin_span_kind(name: &str) -> Option<crate::tracing::SpanKind> {
30        match name {
31            "llm_call" | "llm_stream" | "llm_stream_call" | "agent_loop" | "agent_turn" => {
32                Some(crate::tracing::SpanKind::LlmCall)
33            }
34            "mcp_call" => Some(crate::tracing::SpanKind::ToolCall),
35            _ => None,
36        }
37    }
38
39    /// Open the observation scope for one builtin call. Both members are inert
40    /// unless the operator asked for the corresponding output.
41    ///
42    /// The guard is returned BOXED, and only when something is actually being
43    /// observed. A builtin call reaches the VM recursively (a builtin invokes a
44    /// pipeline that dispatches more builtins), so this guard is a live local on
45    /// every frame of that recursion. Held by value, the ~48-byte aggregate did
46    /// not just add its own size per frame — as a recursive-frame local it
47    /// shifted the compiler's spill/inline decisions, so the real growth
48    /// exceeded `size_of::<BuiltinObservation>()` and overflowed the stack on
49    /// deep dispatch even with profiling OFF (both members `None`). Returning
50    /// `Option<Box<_>>` keeps the frame local pointer-sized (an 8-byte niche
51    /// `None` on the inert hot path, with no allocation) and only touches the
52    /// heap when an observer is genuinely active. See harn#4928.
53    fn observe_builtin_call(name: &str) -> Option<Box<BuiltinObservation<'_>>> {
54        let span = Self::builtin_span_kind(name).map(|kind| ScopeSpan::new(kind, name.to_string()));
55        let timer = crate::builtin_profile::BuiltinTimer::start(name);
56        if span.is_none() && timer.is_none() {
57            // Inert: nothing to observe. No allocation, an 8-byte `None` local.
58            return None;
59        }
60        Some(Box::new(BuiltinObservation {
61            _span: span,
62            _timer: timer,
63        }))
64    }
65
66    fn is_runtime_context_builtin(name: &str) -> bool {
67        matches!(
68            name,
69            "runtime_context"
70                | "task_current"
71                | "runtime_context_values"
72                | "runtime_context_get"
73                | "runtime_context_set"
74                | "runtime_context_clear"
75        )
76    }
77
78    fn resolve_sync_builtin_id_or_name(
79        &self,
80        direct_id: Option<BuiltinId>,
81        name: &str,
82    ) -> Option<Result<VmBuiltinFn, VmError>> {
83        if crate::autonomy::needs_async_side_effect_enforcement(name)
84            || Self::is_runtime_context_builtin(name)
85        {
86            return None;
87        }
88
89        let dispatch = if let Some(id) = direct_id {
90            self.builtins_by_id
91                .get(&id)
92                .filter(|entry| entry.name.as_ref() == name)
93                .map(|entry| entry.dispatch.clone())
94        } else {
95            None
96        }
97        .or_else(|| {
98            self.builtins
99                .get(name)
100                .cloned()
101                .map(VmBuiltinDispatch::Sync)
102        });
103
104        let Some(dispatch) = dispatch else {
105            if self.async_builtins.contains_key(name) || self.bridge.is_some() {
106                return None;
107            }
108            let all_builtins = self
109                .builtins
110                .keys()
111                .chain(self.async_builtins.keys())
112                .map(|s| s.as_str());
113            return Some(
114                if let Some(suggestion) = crate::value::closest_match(name, all_builtins) {
115                    Err(VmError::Runtime(format!(
116                        "Undefined builtin: {name} (did you mean `{suggestion}`?)"
117                    )))
118                } else {
119                    Err(VmError::UndefinedBuiltin(name.to_string()))
120                },
121            );
122        };
123
124        match dispatch {
125            VmBuiltinDispatch::Sync(builtin) => Some(Ok(builtin)),
126            VmBuiltinDispatch::Async(_) => None,
127        }
128    }
129
130    fn validate_sync_builtin_args(&self, name: &str, args: &[VmValue]) -> Result<(), VmError> {
131        if self.denied_builtins.contains(name) {
132            return Err(VmError::CategorizedError {
133                message: format!("Tool '{name}' is not permitted."),
134                category: ErrorCategory::ToolRejected,
135            });
136        }
137        crate::orchestration::enforce_current_policy_for_builtin(name, args)?;
138        crate::typecheck::validate_builtin_call(name, args, None)
139    }
140
141    fn index_builtin_id(&mut self, name: &str, dispatch: VmBuiltinDispatch) {
142        let id = BuiltinId::from_name(name);
143        if self.builtin_id_collisions.contains(&id) {
144            return;
145        }
146        if let Some(existing) = self.builtins_by_id.get(&id) {
147            if existing.name.as_ref() != name {
148                Arc::make_mut(&mut self.builtins_by_id).remove(&id);
149                Arc::make_mut(&mut self.builtin_id_collisions).insert(id);
150                return;
151            }
152        }
153        Arc::make_mut(&mut self.builtins_by_id).insert(
154            id,
155            VmBuiltinEntry {
156                name: std::sync::Arc::from(name),
157                dispatch,
158            },
159        );
160    }
161
162    fn refresh_builtin_id(&mut self, name: &str) {
163        if let Some(builtin) = self.builtins.get(name).cloned() {
164            self.index_builtin_id(name, VmBuiltinDispatch::Sync(builtin));
165        } else if let Some(async_builtin) = self.async_builtins.get(name).cloned() {
166            self.index_builtin_id(name, VmBuiltinDispatch::Async(async_builtin));
167        } else {
168            let id = BuiltinId::from_name(name);
169            if self
170                .builtins_by_id
171                .get(&id)
172                .is_some_and(|entry| entry.name.as_ref() == name)
173            {
174                Arc::make_mut(&mut self.builtins_by_id).remove(&id);
175            }
176        }
177    }
178
179    /// Register a sync builtin function.
180    pub fn register_builtin<F>(&mut self, name: &str, f: F)
181    where
182        F: Fn(&[VmValue], &mut String) -> Result<VmValue, VmError> + Send + Sync + 'static,
183    {
184        Arc::make_mut(&mut self.builtins).insert(name.to_string(), Arc::new(f));
185        Arc::make_mut(&mut self.builtin_metadata)
186            .insert(name.to_string(), VmBuiltinMetadata::sync(name.to_string()));
187        self.refresh_builtin_id(name);
188    }
189
190    /// Register a sync builtin function with discoverable metadata.
191    pub fn register_builtin_with_metadata<F>(&mut self, metadata: VmBuiltinMetadata, f: F)
192    where
193        F: Fn(&[VmValue], &mut String) -> Result<VmValue, VmError> + Send + Sync + 'static,
194    {
195        let name = metadata.name().to_string();
196        Arc::make_mut(&mut self.builtins).insert(name.clone(), Arc::new(f));
197        Arc::make_mut(&mut self.builtin_metadata)
198            .insert(name.clone(), metadata.with_kind(VmBuiltinKind::Sync));
199        self.refresh_builtin_id(&name);
200    }
201
202    /// Register a `VmBuiltinDef` (the shape emitted by `#[harn_builtin]`).
203    /// Registers the primary name plus each declared alias, sharing the
204    /// same handler. `runtime_only` defs skip the parser-side publish (the
205    /// vm-side registration still happens). `parser_only` defs skip the
206    /// vm-side registration entirely (handler is `None`).
207    pub fn register_builtin_def(&mut self, def: &'static crate::stdlib::macros::VmBuiltinDef) {
208        use crate::stdlib::macros::VmBuiltinHandler;
209        if def.parser_only {
210            return;
211        }
212        // Derive arity from the parsed `BuiltinSignature` so the discoverable
213        // metadata layer (harn explain, alignment-test metadata check) keeps
214        // parity with the pre-macro DSL builder.
215        let arity = arity_from_sig(&def.sig);
216        let names = std::iter::once(def.sig.name).chain(def.aliases.iter().copied());
217        for name in names {
218            match def.handler {
219                VmBuiltinHandler::Sync(f) => {
220                    let meta = builtin_def_metadata(def, name, arity, VmBuiltinKind::Sync);
221                    self.register_builtin_with_metadata(meta, f);
222                }
223                VmBuiltinHandler::Async(f) => {
224                    let meta = builtin_def_metadata(def, name, arity, VmBuiltinKind::Async);
225                    // Wrap the function pointer that already returns an
226                    // AsyncBuiltinFuture so register_async_builtin_with_metadata's
227                    // generic handler/future bounds are met.
228                    self.register_async_builtin_with_metadata(meta, f);
229                }
230                VmBuiltinHandler::None => {
231                    // Parser-only, but reached here despite parser_only=false.
232                    // This is a configuration bug.
233                    panic!(
234                        "VmBuiltinHandler::None for {name:?} without parser_only=true \
235                         on its BuiltinDef"
236                    );
237                }
238            }
239        }
240    }
241
242    /// Remove a sync builtin (so an async version can take precedence).
243    pub fn unregister_builtin(&mut self, name: &str) {
244        Arc::make_mut(&mut self.builtins).remove(name);
245        if self.async_builtins.contains_key(name) {
246            Arc::make_mut(&mut self.builtin_metadata).insert(
247                name.to_string(),
248                VmBuiltinMetadata::async_builtin(name.to_string()),
249            );
250        } else {
251            Arc::make_mut(&mut self.builtin_metadata).remove(name);
252        }
253        self.refresh_builtin_id(name);
254    }
255
256    /// Register an async builtin function. The handler receives the explicit
257    /// [`crate::vm::AsyncBuiltinCtx`] threaded by the dispatch loop.
258    pub fn register_async_builtin<F, Fut>(&mut self, name: &str, f: F)
259    where
260        F: Fn(crate::vm::AsyncBuiltinCtx, Vec<VmValue>) -> Fut + Send + Sync + 'static,
261        Fut: Future<Output = Result<VmValue, VmError>> + Send + 'static,
262    {
263        Arc::make_mut(&mut self.async_builtins).insert(
264            name.to_string(),
265            Arc::new(move |ctx, args| Box::pin(f(ctx, args))),
266        );
267        Arc::make_mut(&mut self.builtin_metadata).insert(
268            name.to_string(),
269            VmBuiltinMetadata::async_builtin(name.to_string()),
270        );
271        self.refresh_builtin_id(name);
272    }
273
274    /// Register an async builtin function with discoverable metadata. The
275    /// handler receives the explicit [`crate::vm::AsyncBuiltinCtx`].
276    pub fn register_async_builtin_with_metadata<F, Fut>(
277        &mut self,
278        metadata: VmBuiltinMetadata,
279        f: F,
280    ) where
281        F: Fn(crate::vm::AsyncBuiltinCtx, Vec<VmValue>) -> Fut + Send + Sync + 'static,
282        Fut: Future<Output = Result<VmValue, VmError>> + Send + 'static,
283    {
284        let name = metadata.name().to_string();
285        Arc::make_mut(&mut self.async_builtins).insert(
286            name.clone(),
287            Arc::new(move |ctx, args| Box::pin(f(ctx, args))),
288        );
289        Arc::make_mut(&mut self.builtin_metadata)
290            .insert(name.clone(), metadata.with_kind(VmBuiltinKind::Async));
291        self.refresh_builtin_id(&name);
292    }
293
294    pub(crate) fn registered_builtin_id(&self, name: &str) -> Option<BuiltinId> {
295        let id = BuiltinId::from_name(name);
296        if self
297            .builtins_by_id
298            .get(&id)
299            .is_some_and(|entry| entry.name.as_ref() == name)
300        {
301            Some(id)
302        } else {
303            None
304        }
305    }
306
307    /// Invoke a closure inline against the existing VM frame stack.
308    ///
309    /// Dispatch path for every callback-taking method on lists/dicts/sets
310    /// (`.map`, `.filter`, `.reduce`, `.each`, `.sort_by`, …) via
311    /// [`call_callable_value`]. The closure's frame is pushed onto
312    /// `self.frames` using the same machinery as `Op::Call`, and the
313    /// shared dispatch loop ([`Vm::drive_until_frame_depth`]) drains the
314    /// sub-execution back to the caller's depth.
315    ///
316    /// This avoids the per-invocation `Pin<Box<dyn Future>>` heap
317    /// allocation a recursive `async fn` would require — the recursion
318    /// cycle (closure → `.map` → callback → closure) is broken instead at
319    /// [`Vm::call_method`], which keeps a single boxed future per
320    /// method-call site rather than per callback element.
321    ///
322    /// Exception handlers are saved and cleared before the sub-execution
323    /// so an unhandled throw inside the body propagates as a Rust
324    /// `Result::Err` to the caller's dispatch loop. Iterators, deadlines,
325    /// and frames are scoped by `CallFrame::saved_iterator_depth` and the
326    /// per-frame deadline tags.
327    pub(crate) async fn call_closure(
328        &mut self,
329        closure: &VmClosure,
330        args: &[VmValue],
331    ) -> Result<VmValue, VmError> {
332        self.call_closure_args(closure, CallArgs::Slice(args)).await
333    }
334
335    pub(crate) async fn call_closure_args(
336        &mut self,
337        closure: &VmClosure,
338        args: CallArgs<'_>,
339    ) -> Result<VmValue, VmError> {
340        let saved_handlers = std::mem::take(&mut self.exception_handlers);
341        let active_context = (!crate::step_runtime::is_tracked_function(&closure.func.name))
342            .then(crate::step_runtime::suspend_active_context);
343
344        let target_frame_depth = self.frames.len();
345        let frame_result = self.push_closure_frame_args(closure, &args);
346        drop(args);
347        let result = match frame_result {
348            Ok(()) => self.drive_until_frame_depth(target_frame_depth).await,
349            Err(e) => Err(e),
350        };
351
352        self.exception_handlers = saved_handlers;
353        drop(active_context);
354
355        result
356    }
357
358    /// Invoke a value as a callable. Supports `VmValue::Closure` and
359    /// `VmValue::BuiltinRef`, so builtin names passed by reference (e.g.
360    /// `dict.rekeyed(snake_to_camel)`) dispatch through the same code path as
361    /// user-defined closures.
362    pub(crate) async fn call_callable_value(
363        &mut self,
364        callable: &VmValue,
365        args: &[VmValue],
366    ) -> Result<VmValue, VmError> {
367        self.call_callable_args(callable, CallArgs::Slice(args))
368            .await
369    }
370
371    pub(crate) async fn call_callable_owned(
372        &mut self,
373        callable: &VmValue,
374        args: Vec<VmValue>,
375    ) -> Result<VmValue, VmError> {
376        self.call_callable_args(callable, CallArgs::Owned(args))
377            .await
378    }
379
380    pub(crate) async fn call_callable_zero(
381        &mut self,
382        callable: &VmValue,
383    ) -> Result<VmValue, VmError> {
384        self.call_callable_args(callable, CallArgs::Empty).await
385    }
386
387    pub(crate) async fn call_callable_one(
388        &mut self,
389        callable: &VmValue,
390        arg: &VmValue,
391    ) -> Result<VmValue, VmError> {
392        self.call_callable_args(callable, CallArgs::One(arg)).await
393    }
394
395    pub(crate) async fn call_callable_two(
396        &mut self,
397        callable: &VmValue,
398        first: &VmValue,
399        second: &VmValue,
400    ) -> Result<VmValue, VmError> {
401        self.call_callable_args(callable, CallArgs::Two(first, second))
402            .await
403    }
404
405    pub(crate) async fn call_callable_args(
406        &mut self,
407        callable: &VmValue,
408        args: CallArgs<'_>,
409    ) -> Result<VmValue, VmError> {
410        match callable {
411            VmValue::Closure(closure) => self.call_closure_args(closure, args).await,
412            VmValue::Dict(registry) => {
413                let handler =
414                    crate::vm::tool_callable::require_single_harn_tool_handler(registry, || {
415                        "expected callable, got dict".to_string()
416                    })?;
417                self.call_closure_args(&handler, args).await
418            }
419            VmValue::BuiltinRef(name) => {
420                if !crate::autonomy::needs_async_side_effect_enforcement(name) {
421                    if let Some(result) = self.call_sync_builtin_by_ref_args(name, &args) {
422                        return result;
423                    }
424                }
425                self.call_named_builtin(name, args.into_vec()).await
426            }
427            VmValue::BuiltinRefId(r) => {
428                if let Some(result) =
429                    self.try_call_sync_builtin_id_or_name_args(Some(r.id), &r.name, &args)
430                {
431                    return result;
432                }
433                self.call_builtin_id_or_name(r.id, &r.name, args.into_vec())
434                    .await
435            }
436            other => Err(VmError::TypeError(format!(
437                "expected callable, got {}",
438                other.type_name()
439            ))),
440        }
441    }
442
443    fn call_sync_builtin_by_ref_args(
444        &mut self,
445        name: &str,
446        args: &CallArgs<'_>,
447    ) -> Option<Result<VmValue, VmError>> {
448        self.try_call_sync_builtin_id_or_name_args(None, name, args)
449    }
450
451    /// Returns true if `v` is callable via `call_callable_value`.
452    pub(crate) fn is_callable_value(v: &VmValue) -> bool {
453        matches!(
454            v,
455            VmValue::Closure(_) | VmValue::BuiltinRef(_) | VmValue::BuiltinRefId(_)
456        ) || crate::vm::tool_callable::is_single_harn_tool_registry_value(v)
457    }
458
459    /// Public wrapper for `call_closure`, used by the MCP server to invoke
460    /// tool handler closures from outside the VM execution loop.
461    pub async fn call_closure_pub(
462        &mut self,
463        closure: &VmClosure,
464        args: &[VmValue],
465    ) -> Result<VmValue, VmError> {
466        self.ensure_execution_available()?;
467        self.cancel_grace_instructions_remaining = None;
468        self.call_closure(closure, args).await
469    }
470
471    /// Resolve a named builtin: sync builtins → async builtins → bridge → error.
472    /// Used by Call, TailCall, and Pipe handlers to avoid duplicating this lookup.
473    pub(crate) async fn call_named_builtin(
474        &mut self,
475        name: &str,
476        args: Vec<VmValue>,
477    ) -> Result<VmValue, VmError> {
478        self.call_builtin_impl(name, args, None).await
479    }
480
481    pub(crate) async fn call_builtin_id_or_name(
482        &mut self,
483        id: BuiltinId,
484        name: &str,
485        args: Vec<VmValue>,
486    ) -> Result<VmValue, VmError> {
487        self.call_builtin_impl(name, args, Some(id)).await
488    }
489
490    /// Install the thread-local [`crate::op_interrupt`] context for the
491    /// duration of a sync builtin call, so blocking builtins (subprocess
492    /// waits in particular) can observe scope cancellation and `deadline`
493    /// expiry that the async `tokio::select!` wrapper cannot deliver while
494    /// the op future is stuck inside a synchronous handler. Returns `None`
495    /// (no thread-local traffic) when nothing is armed.
496    pub(in crate::vm) fn sync_builtin_interrupt_guard(
497        &self,
498    ) -> Option<crate::op_interrupt::OpInterruptGuard> {
499        // Mirror `execution.rs::next_deadline`: innermost scope deadline,
500        // tightened by the interrupt-handler deadline when that is sooner.
501        let scope_deadline = self.deadlines.last().map(|(deadline, _)| *deadline);
502        let deadline = match (scope_deadline, self.interrupt_handler_deadline) {
503            (Some(scope), Some(interrupt)) => Some(scope.min(interrupt)),
504            (scope, interrupt) => scope.or(interrupt),
505        };
506        if self.cancel_token.is_none() && deadline.is_none() {
507            return None;
508        }
509        Some(crate::op_interrupt::install(
510            self.cancel_token.clone(),
511            deadline,
512        ))
513    }
514
515    pub(crate) fn try_call_sync_builtin_id_or_name_args(
516        &mut self,
517        direct_id: Option<BuiltinId>,
518        name: &str,
519        args: &CallArgs<'_>,
520    ) -> Option<Result<VmValue, VmError>> {
521        if self.denied_builtins.contains(name) {
522            return Some(Err(VmError::CategorizedError {
523                message: format!("Tool '{name}' is not permitted."),
524                category: ErrorCategory::ToolRejected,
525            }));
526        }
527        let builtin = match self.resolve_sync_builtin_id_or_name(direct_id, name)? {
528            Ok(builtin) => builtin,
529            Err(error) => return Some(Err(error)),
530        };
531        let _observe = Self::observe_builtin_call(name);
532        if let Err(error) = args.with_slice(|slice| self.validate_sync_builtin_args(name, slice)) {
533            return Some(Err(error));
534        }
535
536        let _interrupt = self.sync_builtin_interrupt_guard();
537        Some(args.with_slice(|slice| builtin(slice, &mut self.output)))
538    }
539
540    pub(crate) fn try_call_sync_builtin_id_or_name_from_stack_args(
541        &mut self,
542        direct_id: Option<BuiltinId>,
543        name: &str,
544        args_start: usize,
545    ) -> Option<Result<VmValue, VmError>> {
546        if self.denied_builtins.contains(name) {
547            return Some(Err(VmError::CategorizedError {
548                message: format!("Tool '{name}' is not permitted."),
549                category: ErrorCategory::ToolRejected,
550            }));
551        }
552        let builtin = match self.resolve_sync_builtin_id_or_name(direct_id, name)? {
553            Ok(builtin) => builtin,
554            Err(error) => return Some(Err(error)),
555        };
556        if args_start > self.stack.len() {
557            return Some(Err(VmError::Runtime(
558                "call argument stack underflow".to_string(),
559            )));
560        }
561
562        let _observe = Self::observe_builtin_call(name);
563        if let Err(error) = self.validate_sync_builtin_args(name, &self.stack[args_start..]) {
564            return Some(Err(error));
565        }
566
567        let _interrupt = self.sync_builtin_interrupt_guard();
568        Some(builtin(&self.stack[args_start..], &mut self.output))
569    }
570
571    async fn call_builtin_impl(
572        &mut self,
573        name: &str,
574        args: Vec<VmValue>,
575        direct_id: Option<BuiltinId>,
576    ) -> Result<VmValue, VmError> {
577        let _observe = Self::observe_builtin_call(name);
578
579        // Sandbox check: deny builtins blocked by --deny/--allow flags.
580        if self.denied_builtins.contains(name) {
581            return Err(VmError::CategorizedError {
582                message: format!("Tool '{name}' is not permitted."),
583                category: ErrorCategory::ToolRejected,
584            });
585        }
586        let autonomy = if crate::autonomy::needs_async_side_effect_enforcement(name) {
587            crate::autonomy::enforce_builtin_side_effect_boxed(name, &args).await?
588        } else {
589            None
590        };
591        if let Some(crate::autonomy::AutonomyDecision::Skip(value)) = autonomy {
592            return Ok(value);
593        }
594        if !matches!(
595            autonomy,
596            Some(crate::autonomy::AutonomyDecision::AllowApproved)
597        ) {
598            crate::orchestration::enforce_current_policy_for_builtin(name, &args)?;
599        }
600        crate::typecheck::validate_builtin_call(name, &args, None)?;
601
602        if let Some(result) =
603            crate::runtime_context::dispatch_runtime_context_builtin(self, name, &args)
604        {
605            return result;
606        }
607
608        if let Some(id) = direct_id {
609            if let Some(entry) = self.builtins_by_id.get(&id).cloned() {
610                if entry.name.as_ref() == name {
611                    return self.call_builtin_entry(name, entry.dispatch, args).await;
612                }
613            }
614        }
615
616        if let Some(builtin) = self.builtins.get(name).cloned() {
617            self.call_builtin_entry(name, VmBuiltinDispatch::Sync(builtin), args)
618                .await
619        } else if let Some(async_builtin) = self.async_builtins.get(name).cloned() {
620            self.call_builtin_entry(name, VmBuiltinDispatch::Async(async_builtin), args)
621                .await
622        } else if let Some(bridge) = &self.bridge {
623            crate::orchestration::enforce_current_policy_for_bridge_builtin(name)?;
624            let args_json: Vec<serde_json::Value> =
625                args.iter().map(crate::llm::vm_value_to_json).collect();
626            let result = bridge
627                .call(
628                    "builtin_call",
629                    serde_json::json!({"name": name, "args": args_json}),
630                )
631                .await?;
632            Ok(crate::bridge::json_result_to_vm_value(&result))
633        } else {
634            let all_builtins = self
635                .builtins
636                .keys()
637                .chain(self.async_builtins.keys())
638                .map(|s| s.as_str());
639            if let Some(suggestion) = crate::value::closest_match(name, all_builtins) {
640                return Err(VmError::Runtime(format!(
641                    "Undefined builtin: {name} (did you mean `{suggestion}`?)"
642                )));
643            }
644            Err(VmError::UndefinedBuiltin(name.to_string()))
645        }
646    }
647
648    async fn call_builtin_entry(
649        &mut self,
650        name: &str,
651        dispatch: VmBuiltinDispatch,
652        args: Vec<VmValue>,
653    ) -> Result<VmValue, VmError> {
654        let result = match dispatch {
655            VmBuiltinDispatch::Sync(builtin) => {
656                let _interrupt = self.sync_builtin_interrupt_guard();
657                builtin(&args, &mut self.output)
658            }
659            VmBuiltinDispatch::Async(async_builtin) => {
660                // Bind a fresh child VM as the async-builtin context for the
661                // duration of this future, threading the explicit ctx handle
662                // into the handler. Drain any output VM-side closures
663                // forwarded into the ctx back to the parent.
664                let (result, captured) =
665                    crate::vm::run_async_builtin_with(self.child_vm_inline(), |ctx| {
666                        async_builtin(ctx, args)
667                    })
668                    .await;
669                if !captured.is_empty() {
670                    self.output.push_str(&captured);
671                }
672                result
673            }
674        }?;
675        if matches!(
676            name,
677            "sync_mutex_acquire"
678                | "sync_semaphore_acquire"
679                | "sync_gate_acquire"
680                | "sync_rwlock_acquire"
681        ) {
682            if let VmValue::SyncPermit(permit) = &result {
683                self.adopt_sync_permit_for_current_scope(permit.as_ref().clone());
684            }
685        }
686        Ok(result)
687    }
688}
689
690/// Build the discoverable [`VmBuiltinMetadata`] for one entry of a
691/// `#[harn_builtin]`-emitted `VmBuiltinDef`, threading the optional
692/// category / doc / signature_text fields without duplicating the chain
693/// across the Sync / Async dispatch arms in `register_builtin_def`.
694fn builtin_def_metadata(
695    def: &'static crate::stdlib::macros::VmBuiltinDef,
696    name: &'static str,
697    arity: VmBuiltinArity,
698    kind: VmBuiltinKind,
699) -> VmBuiltinMetadata {
700    let mut meta = match kind {
701        VmBuiltinKind::Sync => VmBuiltinMetadata::sync_static(name),
702        VmBuiltinKind::Async => VmBuiltinMetadata::async_static(name),
703    }
704    .arity(arity);
705    if let Some(category) = def.category {
706        meta = meta.category_static(category);
707    }
708    if let Some(doc) = def.doc {
709        meta = meta.doc_static(doc);
710    }
711    if let Some(sig_text) = def.signature_text {
712        meta = meta.signature_static(sig_text);
713    } else {
714        // Builtins declared via `sig_expr = …` (a canonical
715        // `harn_builtin_meta::signatures` const) carry no human-typed `sig`
716        // string, so render the parsed signature back through its `Display`
717        // impl. `Display` round-trips through the macro sig grammar (enforced
718        // by the signature-text drift test), so `harn explain` / LSP hover
719        // still surface an accurate, canonical signature.
720        meta = meta.signature_owned(format!("{}", def.sig));
721    }
722    meta
723}
724
725/// Derive a [`VmBuiltinArity`] from a parsed [`BuiltinSignature`]. Required
726/// params count toward the floor; optional params and `has_rest` widen the
727/// ceiling. Returns `Variadic` for `(...args: any)`-shaped sigs that have
728/// no required params.
729fn arity_from_sig(sig: &harn_builtin_meta::BuiltinSignature) -> VmBuiltinArity {
730    let required = sig.params.iter().filter(|p| !p.optional).count();
731    let total = sig.params.len();
732    if sig.has_rest {
733        if required == 0 {
734            VmBuiltinArity::Variadic
735        } else {
736            VmBuiltinArity::Min(required)
737        }
738    } else if required == total {
739        VmBuiltinArity::Exact(total)
740    } else {
741        VmBuiltinArity::Range {
742            min: required,
743            max: total,
744        }
745    }
746}