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::take_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        if let Some(ctx) = active_context {
354            crate::step_runtime::restore_active_context(ctx);
355        }
356
357        result
358    }
359
360    /// Invoke a value as a callable. Supports `VmValue::Closure` and
361    /// `VmValue::BuiltinRef`, so builtin names passed by reference (e.g.
362    /// `dict.rekeyed(snake_to_camel)`) dispatch through the same code path as
363    /// user-defined closures.
364    pub(crate) async fn call_callable_value(
365        &mut self,
366        callable: &VmValue,
367        args: &[VmValue],
368    ) -> Result<VmValue, VmError> {
369        self.call_callable_args(callable, CallArgs::Slice(args))
370            .await
371    }
372
373    pub(crate) async fn call_callable_owned(
374        &mut self,
375        callable: &VmValue,
376        args: Vec<VmValue>,
377    ) -> Result<VmValue, VmError> {
378        self.call_callable_args(callable, CallArgs::Owned(args))
379            .await
380    }
381
382    pub(crate) async fn call_callable_zero(
383        &mut self,
384        callable: &VmValue,
385    ) -> Result<VmValue, VmError> {
386        self.call_callable_args(callable, CallArgs::Empty).await
387    }
388
389    pub(crate) async fn call_callable_one(
390        &mut self,
391        callable: &VmValue,
392        arg: &VmValue,
393    ) -> Result<VmValue, VmError> {
394        self.call_callable_args(callable, CallArgs::One(arg)).await
395    }
396
397    pub(crate) async fn call_callable_two(
398        &mut self,
399        callable: &VmValue,
400        first: &VmValue,
401        second: &VmValue,
402    ) -> Result<VmValue, VmError> {
403        self.call_callable_args(callable, CallArgs::Two(first, second))
404            .await
405    }
406
407    pub(crate) async fn call_callable_args(
408        &mut self,
409        callable: &VmValue,
410        args: CallArgs<'_>,
411    ) -> Result<VmValue, VmError> {
412        match callable {
413            VmValue::Closure(closure) => self.call_closure_args(closure, args).await,
414            VmValue::Dict(registry) => {
415                let handler =
416                    crate::vm::tool_callable::require_single_harn_tool_handler(registry, || {
417                        "expected callable, got dict".to_string()
418                    })?;
419                self.call_closure_args(&handler, args).await
420            }
421            VmValue::BuiltinRef(name) => {
422                if !crate::autonomy::needs_async_side_effect_enforcement(name) {
423                    if let Some(result) = self.call_sync_builtin_by_ref_args(name, &args) {
424                        return result;
425                    }
426                }
427                self.call_named_builtin(name, args.into_vec()).await
428            }
429            VmValue::BuiltinRefId(r) => {
430                if let Some(result) =
431                    self.try_call_sync_builtin_id_or_name_args(Some(r.id), &r.name, &args)
432                {
433                    return result;
434                }
435                self.call_builtin_id_or_name(r.id, &r.name, args.into_vec())
436                    .await
437            }
438            other => Err(VmError::TypeError(format!(
439                "expected callable, got {}",
440                other.type_name()
441            ))),
442        }
443    }
444
445    fn call_sync_builtin_by_ref_args(
446        &mut self,
447        name: &str,
448        args: &CallArgs<'_>,
449    ) -> Option<Result<VmValue, VmError>> {
450        self.try_call_sync_builtin_id_or_name_args(None, name, args)
451    }
452
453    /// Returns true if `v` is callable via `call_callable_value`.
454    pub(crate) fn is_callable_value(v: &VmValue) -> bool {
455        matches!(
456            v,
457            VmValue::Closure(_) | VmValue::BuiltinRef(_) | VmValue::BuiltinRefId(_)
458        ) || crate::vm::tool_callable::is_single_harn_tool_registry_value(v)
459    }
460
461    /// Public wrapper for `call_closure`, used by the MCP server to invoke
462    /// tool handler closures from outside the VM execution loop.
463    pub async fn call_closure_pub(
464        &mut self,
465        closure: &VmClosure,
466        args: &[VmValue],
467    ) -> Result<VmValue, VmError> {
468        self.ensure_execution_available()?;
469        self.cancel_grace_instructions_remaining = None;
470        self.call_closure(closure, args).await
471    }
472
473    /// Resolve a named builtin: sync builtins → async builtins → bridge → error.
474    /// Used by Call, TailCall, and Pipe handlers to avoid duplicating this lookup.
475    pub(crate) async fn call_named_builtin(
476        &mut self,
477        name: &str,
478        args: Vec<VmValue>,
479    ) -> Result<VmValue, VmError> {
480        self.call_builtin_impl(name, args, None).await
481    }
482
483    pub(crate) async fn call_builtin_id_or_name(
484        &mut self,
485        id: BuiltinId,
486        name: &str,
487        args: Vec<VmValue>,
488    ) -> Result<VmValue, VmError> {
489        self.call_builtin_impl(name, args, Some(id)).await
490    }
491
492    /// Install the thread-local [`crate::op_interrupt`] context for the
493    /// duration of a sync builtin call, so blocking builtins (subprocess
494    /// waits in particular) can observe scope cancellation and `deadline`
495    /// expiry that the async `tokio::select!` wrapper cannot deliver while
496    /// the op future is stuck inside a synchronous handler. Returns `None`
497    /// (no thread-local traffic) when nothing is armed.
498    pub(in crate::vm) fn sync_builtin_interrupt_guard(
499        &self,
500    ) -> Option<crate::op_interrupt::OpInterruptGuard> {
501        // Mirror `execution.rs::next_deadline`: innermost scope deadline,
502        // tightened by the interrupt-handler deadline when that is sooner.
503        let scope_deadline = self.deadlines.last().map(|(deadline, _)| *deadline);
504        let deadline = match (scope_deadline, self.interrupt_handler_deadline) {
505            (Some(scope), Some(interrupt)) => Some(scope.min(interrupt)),
506            (scope, interrupt) => scope.or(interrupt),
507        };
508        if self.cancel_token.is_none() && deadline.is_none() {
509            return None;
510        }
511        Some(crate::op_interrupt::install(
512            self.cancel_token.clone(),
513            deadline,
514        ))
515    }
516
517    pub(crate) fn try_call_sync_builtin_id_or_name_args(
518        &mut self,
519        direct_id: Option<BuiltinId>,
520        name: &str,
521        args: &CallArgs<'_>,
522    ) -> Option<Result<VmValue, VmError>> {
523        if self.denied_builtins.contains(name) {
524            return Some(Err(VmError::CategorizedError {
525                message: format!("Tool '{name}' is not permitted."),
526                category: ErrorCategory::ToolRejected,
527            }));
528        }
529        let builtin = match self.resolve_sync_builtin_id_or_name(direct_id, name)? {
530            Ok(builtin) => builtin,
531            Err(error) => return Some(Err(error)),
532        };
533        let _observe = Self::observe_builtin_call(name);
534        if let Err(error) = args.with_slice(|slice| self.validate_sync_builtin_args(name, slice)) {
535            return Some(Err(error));
536        }
537
538        let _interrupt = self.sync_builtin_interrupt_guard();
539        Some(args.with_slice(|slice| builtin(slice, &mut self.output)))
540    }
541
542    pub(crate) fn try_call_sync_builtin_id_or_name_from_stack_args(
543        &mut self,
544        direct_id: Option<BuiltinId>,
545        name: &str,
546        args_start: usize,
547    ) -> Option<Result<VmValue, VmError>> {
548        if self.denied_builtins.contains(name) {
549            return Some(Err(VmError::CategorizedError {
550                message: format!("Tool '{name}' is not permitted."),
551                category: ErrorCategory::ToolRejected,
552            }));
553        }
554        let builtin = match self.resolve_sync_builtin_id_or_name(direct_id, name)? {
555            Ok(builtin) => builtin,
556            Err(error) => return Some(Err(error)),
557        };
558        if args_start > self.stack.len() {
559            return Some(Err(VmError::Runtime(
560                "call argument stack underflow".to_string(),
561            )));
562        }
563
564        let _observe = Self::observe_builtin_call(name);
565        if let Err(error) = self.validate_sync_builtin_args(name, &self.stack[args_start..]) {
566            return Some(Err(error));
567        }
568
569        let _interrupt = self.sync_builtin_interrupt_guard();
570        Some(builtin(&self.stack[args_start..], &mut self.output))
571    }
572
573    async fn call_builtin_impl(
574        &mut self,
575        name: &str,
576        args: Vec<VmValue>,
577        direct_id: Option<BuiltinId>,
578    ) -> Result<VmValue, VmError> {
579        let _observe = Self::observe_builtin_call(name);
580
581        // Sandbox check: deny builtins blocked by --deny/--allow flags.
582        if self.denied_builtins.contains(name) {
583            return Err(VmError::CategorizedError {
584                message: format!("Tool '{name}' is not permitted."),
585                category: ErrorCategory::ToolRejected,
586            });
587        }
588        let autonomy = if crate::autonomy::needs_async_side_effect_enforcement(name) {
589            crate::autonomy::enforce_builtin_side_effect_boxed(name, &args).await?
590        } else {
591            None
592        };
593        if let Some(crate::autonomy::AutonomyDecision::Skip(value)) = autonomy {
594            return Ok(value);
595        }
596        if !matches!(
597            autonomy,
598            Some(crate::autonomy::AutonomyDecision::AllowApproved)
599        ) {
600            crate::orchestration::enforce_current_policy_for_builtin(name, &args)?;
601        }
602        crate::typecheck::validate_builtin_call(name, &args, None)?;
603
604        if let Some(result) =
605            crate::runtime_context::dispatch_runtime_context_builtin(self, name, &args)
606        {
607            return result;
608        }
609
610        if let Some(id) = direct_id {
611            if let Some(entry) = self.builtins_by_id.get(&id).cloned() {
612                if entry.name.as_ref() == name {
613                    return self.call_builtin_entry(name, entry.dispatch, args).await;
614                }
615            }
616        }
617
618        if let Some(builtin) = self.builtins.get(name).cloned() {
619            self.call_builtin_entry(name, VmBuiltinDispatch::Sync(builtin), args)
620                .await
621        } else if let Some(async_builtin) = self.async_builtins.get(name).cloned() {
622            self.call_builtin_entry(name, VmBuiltinDispatch::Async(async_builtin), args)
623                .await
624        } else if let Some(bridge) = &self.bridge {
625            crate::orchestration::enforce_current_policy_for_bridge_builtin(name)?;
626            let args_json: Vec<serde_json::Value> =
627                args.iter().map(crate::llm::vm_value_to_json).collect();
628            let result = bridge
629                .call(
630                    "builtin_call",
631                    serde_json::json!({"name": name, "args": args_json}),
632                )
633                .await?;
634            Ok(crate::bridge::json_result_to_vm_value(&result))
635        } else {
636            let all_builtins = self
637                .builtins
638                .keys()
639                .chain(self.async_builtins.keys())
640                .map(|s| s.as_str());
641            if let Some(suggestion) = crate::value::closest_match(name, all_builtins) {
642                return Err(VmError::Runtime(format!(
643                    "Undefined builtin: {name} (did you mean `{suggestion}`?)"
644                )));
645            }
646            Err(VmError::UndefinedBuiltin(name.to_string()))
647        }
648    }
649
650    async fn call_builtin_entry(
651        &mut self,
652        name: &str,
653        dispatch: VmBuiltinDispatch,
654        args: Vec<VmValue>,
655    ) -> Result<VmValue, VmError> {
656        let result = match dispatch {
657            VmBuiltinDispatch::Sync(builtin) => {
658                let _interrupt = self.sync_builtin_interrupt_guard();
659                builtin(&args, &mut self.output)
660            }
661            VmBuiltinDispatch::Async(async_builtin) => {
662                // Bind a fresh child VM as the async-builtin context for the
663                // duration of this future, threading the explicit ctx handle
664                // into the handler. Drain any output VM-side closures
665                // forwarded into the ctx back to the parent.
666                let (result, captured) =
667                    crate::vm::run_async_builtin_with(self.child_vm_inline(), |ctx| {
668                        async_builtin(ctx, args)
669                    })
670                    .await;
671                if !captured.is_empty() {
672                    self.output.push_str(&captured);
673                }
674                result
675            }
676        }?;
677        if matches!(
678            name,
679            "sync_mutex_acquire"
680                | "sync_semaphore_acquire"
681                | "sync_gate_acquire"
682                | "sync_rwlock_acquire"
683        ) {
684            if let VmValue::SyncPermit(permit) = &result {
685                self.adopt_sync_permit_for_current_scope(permit.as_ref().clone());
686            }
687        }
688        Ok(result)
689    }
690}
691
692/// Build the discoverable [`VmBuiltinMetadata`] for one entry of a
693/// `#[harn_builtin]`-emitted `VmBuiltinDef`, threading the optional
694/// category / doc / signature_text fields without duplicating the chain
695/// across the Sync / Async dispatch arms in `register_builtin_def`.
696fn builtin_def_metadata(
697    def: &'static crate::stdlib::macros::VmBuiltinDef,
698    name: &'static str,
699    arity: VmBuiltinArity,
700    kind: VmBuiltinKind,
701) -> VmBuiltinMetadata {
702    let mut meta = match kind {
703        VmBuiltinKind::Sync => VmBuiltinMetadata::sync_static(name),
704        VmBuiltinKind::Async => VmBuiltinMetadata::async_static(name),
705    }
706    .arity(arity);
707    if let Some(category) = def.category {
708        meta = meta.category_static(category);
709    }
710    if let Some(doc) = def.doc {
711        meta = meta.doc_static(doc);
712    }
713    if let Some(sig_text) = def.signature_text {
714        meta = meta.signature_static(sig_text);
715    } else {
716        // Builtins declared via `sig_expr = …` (a canonical
717        // `harn_builtin_meta::signatures` const) carry no human-typed `sig`
718        // string, so render the parsed signature back through its `Display`
719        // impl. `Display` round-trips through the macro sig grammar (enforced
720        // by the signature-text drift test), so `harn explain` / LSP hover
721        // still surface an accurate, canonical signature.
722        meta = meta.signature_owned(format!("{}", def.sig));
723    }
724    meta
725}
726
727/// Derive a [`VmBuiltinArity`] from a parsed [`BuiltinSignature`]. Required
728/// params count toward the floor; optional params and `has_rest` widen the
729/// ceiling. Returns `Variadic` for `(...args: any)`-shaped sigs that have
730/// no required params.
731fn arity_from_sig(sig: &harn_builtin_meta::BuiltinSignature) -> VmBuiltinArity {
732    let required = sig.params.iter().filter(|p| !p.optional).count();
733    let total = sig.params.len();
734    if sig.has_rest {
735        if required == 0 {
736            VmBuiltinArity::Variadic
737        } else {
738            VmBuiltinArity::Min(required)
739        }
740    } else if required == total {
741        VmBuiltinArity::Exact(total)
742    } else {
743        VmBuiltinArity::Range {
744            min: required,
745            max: total,
746        }
747    }
748}