Skip to main content

bashkit_scripted_tool/
tool_registry.rs

1// One ToolDef-backed registry owns dispatch for shell, Python, and TypeScript.
2// Surface adapters only translate arguments/results; policy, validation,
3// deadlines, sanitization, callback identity, and tracing stay here.
4
5use crate::scripted_tool::{
6    CallbackKind, RegisteredTool, ScriptedCommandKind, ToolDefInvocationTrace,
7};
8use crate::{ToolArgs, ToolDef, ToolImpl};
9use bashkit::BuiltinContext as Context;
10use serde_json::Value;
11use std::future::Future;
12use std::sync::Arc;
13
14/// Runtime surface that initiated a registry call.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ToolCallSurface {
17    Shell,
18    Python,
19    TypeScript,
20}
21
22/// Result of the registry's approval/policy hook.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ToolCallDecision {
25    Allow,
26    Deny,
27}
28
29/// Immutable data supplied to the approval/policy hook.
30pub struct ToolCall<'a> {
31    name: &'a str,
32    params: &'a Value,
33    tenant_id: Option<&'a str>,
34    surface: ToolCallSurface,
35}
36
37impl ToolCall<'_> {
38    pub fn name(&self) -> &str {
39        self.name
40    }
41
42    pub fn params(&self) -> &Value {
43        self.params
44    }
45
46    pub fn tenant_id(&self) -> Option<&str> {
47        self.tenant_id
48    }
49
50    pub fn surface(&self) -> ToolCallSurface {
51        self.surface
52    }
53}
54
55type PolicyHook = Arc<dyn Fn(&ToolCall<'_>) -> ToolCallDecision + Send + Sync>;
56
57/// Per-`exec*` registry context carried through [`bashkit::ExecutionExtensions`].
58///
59/// Each request owns a trace sink, preventing cross-tenant trace mixing even
60/// when tenants share one immutable registry and callback set.
61#[derive(Clone)]
62// THREAT[TM-ISO-026]: tenant identity and trace are request-owned, never registry state.
63pub struct ToolCallRequest {
64    tenant_id: String,
65    trace: ToolDefInvocationTrace,
66}
67
68impl ToolCallRequest {
69    pub fn new(tenant_id: impl Into<String>, trace: ToolDefInvocationTrace) -> Self {
70        Self {
71            tenant_id: tenant_id.into(),
72            trace,
73        }
74    }
75
76    pub fn tenant_id(&self) -> &str {
77        &self.tenant_id
78    }
79
80    pub fn trace(&self) -> ToolDefInvocationTrace {
81        self.trace.clone()
82    }
83}
84
85// Built only from public request accessors (`BuiltinContext` for shell
86// commands, `RuntimeCallContext` for embedded Python/TypeScript) so the
87// registry needs no interpreter internals.
88#[derive(Clone)]
89struct ToolCallScope {
90    request: Option<bashkit::ExecutionCapability<ToolCallRequest>>,
91    budget: Option<bashkit::ExecutionBudget>,
92    lease: Option<bashkit::ExecutionCapability<()>>,
93    #[cfg(not(target_family = "wasm"))]
94    deadline: Option<bashkit::time::Instant>,
95}
96
97impl ToolCallScope {
98    fn from_context(ctx: &Context<'_>) -> Self {
99        Self::new(
100            ctx.execution_extension::<ToolCallRequest>(),
101            ctx.execution_budget(),
102            ctx.execution_capability(()),
103            ctx.remaining_deadline(),
104        )
105    }
106
107    #[cfg(any(feature = "python", feature = "typescript"))]
108    fn from_runtime() -> Self {
109        let ctx = bashkit::RuntimeCallContext::current();
110        Self::new(
111            ctx.execution_extension::<ToolCallRequest>(),
112            ctx.execution_budget(),
113            ctx.execution_capability(()),
114            ctx.remaining_deadline(),
115        )
116    }
117
118    fn new(
119        request: Option<bashkit::ExecutionCapability<ToolCallRequest>>,
120        budget: Option<bashkit::ExecutionCapability<bashkit::ExecutionBudget>>,
121        lease: Option<bashkit::ExecutionCapability<()>>,
122        #[cfg_attr(target_family = "wasm", allow(unused_variables))] remaining: Option<
123            std::time::Duration,
124        >,
125    ) -> Self {
126        Self {
127            request,
128            budget: budget.and_then(|budget| budget.try_with(Clone::clone).ok()),
129            lease,
130            #[cfg(not(target_family = "wasm"))]
131            deadline: remaining
132                .and_then(|remaining| bashkit::time::Instant::now().checked_add(remaining)),
133        }
134    }
135
136    fn tenant_id(&self) -> Result<Option<String>, bashkit::ExecutionCapabilityError> {
137        self.request
138            .as_ref()
139            .map(|request| request.try_with(|request| request.tenant_id().to_string()))
140            .transpose()
141    }
142
143    fn trace(&self) -> Result<Option<ToolDefInvocationTrace>, bashkit::ExecutionCapabilityError> {
144        self.request
145            .as_ref()
146            .map(|request| request.try_with(ToolCallRequest::trace))
147            .transpose()
148    }
149
150    #[cfg(not(target_family = "wasm"))]
151    fn remaining(&self) -> Option<std::time::Duration> {
152        self.deadline.map(|deadline| {
153            deadline
154                .checked_duration_since(bashkit::time::Instant::now())
155                .unwrap_or_default()
156        })
157    }
158}
159
160#[derive(Clone)]
161pub struct ToolRegistry {
162    inner: Arc<ToolRegistryInner>,
163}
164
165struct ToolRegistryInner {
166    tools: Vec<RegisteredTool>,
167    policy: PolicyHook,
168    sanitize_errors: bool,
169    default_trace: ToolDefInvocationTrace,
170}
171
172pub struct ToolRegistryBuilder {
173    tools: Vec<RegisteredTool>,
174    policy: PolicyHook,
175    sanitize_errors: bool,
176}
177
178impl Default for ToolRegistryBuilder {
179    fn default() -> Self {
180        Self {
181            tools: Vec::new(),
182            policy: Arc::new(|_| ToolCallDecision::Allow),
183            sanitize_errors: true,
184        }
185    }
186}
187
188impl ToolRegistryBuilder {
189    pub fn tool(mut self, tool: ToolImpl) -> Self {
190        self.tools.push(RegisteredTool::from_tool_impl(tool));
191        self
192    }
193
194    pub fn tool_fn(
195        mut self,
196        def: ToolDef,
197        exec: impl Fn(&ToolArgs) -> Result<String, String> + Send + Sync + 'static,
198    ) -> Self {
199        self.tools.push(RegisteredTool {
200            def,
201            callback: CallbackKind::Sync(Arc::new(exec)),
202            dry_run: None,
203        });
204        self
205    }
206
207    pub fn async_tool_fn<F, Fut>(mut self, def: ToolDef, exec: F) -> Self
208    where
209        F: Fn(ToolArgs) -> Fut + Send + Sync + 'static,
210        Fut: Future<Output = Result<String, String>> + Send + 'static,
211    {
212        self.tools.push(RegisteredTool {
213            def,
214            callback: CallbackKind::Async(Arc::new(move |args| Box::pin(exec(args)))),
215            dry_run: None,
216        });
217        self
218    }
219
220    pub fn policy(
221        mut self,
222        policy: impl Fn(&ToolCall<'_>) -> ToolCallDecision + Send + Sync + 'static,
223    ) -> Self {
224        self.policy = Arc::new(policy);
225        self
226    }
227
228    pub fn sanitize_errors(mut self, sanitize: bool) -> Self {
229        self.sanitize_errors = sanitize;
230        self
231    }
232
233    pub fn build(&self) -> ToolRegistry {
234        for tool in &self.tools {
235            assert!(
236                valid_runtime_name(&tool.def.name),
237                "ToolDef name must contain dot-separated identifier segments"
238            );
239        }
240        ToolRegistry {
241            inner: Arc::new(ToolRegistryInner {
242                tools: self.tools.clone(),
243                policy: self.policy.clone(),
244                sanitize_errors: self.sanitize_errors,
245                default_trace: ToolDefInvocationTrace::new(),
246            }),
247        }
248    }
249}
250
251pub(crate) struct RegistryOutput {
252    pub(crate) stdout: String,
253    pub(crate) stderr: String,
254    pub(crate) exit_code: i32,
255}
256
257impl ToolRegistry {
258    pub fn builder() -> ToolRegistryBuilder {
259        ToolRegistryBuilder::default()
260    }
261
262    /// Install this registry across shell, embedded Python, and embedded
263    /// TypeScript on `builder`. Runtime surfaces are included when this
264    /// crate's `python` / `typescript` features are enabled and share the
265    /// registry's callback and policy `Arc`s. Python and TypeScript use the
266    /// limits of the builder's current [`ExecutionProfile`](bashkit::ExecutionProfile),
267    /// so set the profile first.
268    pub fn install(&self, builder: bashkit::BashBuilder) -> bashkit::BashBuilder {
269        #[cfg_attr(
270            not(any(feature = "python", feature = "typescript")),
271            allow(unused_mut)
272        )]
273        let mut builder = builder.extension(crate::ToolDefExtension::from_registry(self.clone()));
274        #[cfg(feature = "python")]
275        {
276            let limits = builder.execution_profile().python_limits().clone();
277            let names = vec!["__bashkit_tool_call".to_string()];
278            let handler = self.python_handler();
279            let prelude = self.python_prelude();
280            builder = builder
281                .builtin(
282                    "python",
283                    Box::new(
284                        bashkit::Python::with_limits(limits.clone())
285                            .with_external_handler_and_prelude(
286                                names.clone(),
287                                handler.clone(),
288                                prelude.clone(),
289                            ),
290                    ),
291                )
292                .builtin(
293                    "python3",
294                    Box::new(
295                        bashkit::Python::with_limits(limits)
296                            .with_external_handler_and_prelude(names, handler, prelude),
297                    ),
298                );
299        }
300        #[cfg(feature = "typescript")]
301        {
302            let limits = builder.execution_profile().typescript_limits().clone();
303            builder = builder.extension(
304                bashkit::TypeScriptExtension::with_external_handler_and_prelude(
305                    limits,
306                    self.typescript_external_names(),
307                    self.typescript_handler(),
308                    self.typescript_prelude(),
309                    self.typescript_rewrites(),
310                ),
311            );
312        }
313        builder
314    }
315
316    pub(crate) fn from_registered(
317        tools: Vec<RegisteredTool>,
318        sanitize_errors: bool,
319        trace: ToolDefInvocationTrace,
320    ) -> Self {
321        Self {
322            inner: Arc::new(ToolRegistryInner {
323                tools,
324                policy: Arc::new(|_| ToolCallDecision::Allow),
325                sanitize_errors,
326                default_trace: trace,
327            }),
328        }
329    }
330
331    pub(crate) fn with_fresh_default_trace(&self) -> Self {
332        Self {
333            inner: Arc::new(ToolRegistryInner {
334                tools: self.inner.tools.clone(),
335                policy: self.inner.policy.clone(),
336                sanitize_errors: self.inner.sanitize_errors,
337                default_trace: ToolDefInvocationTrace::new(),
338            }),
339        }
340    }
341
342    /// Create a fresh trace sink suitable for one request/tenant.
343    pub fn trace(&self) -> ToolDefInvocationTrace {
344        ToolDefInvocationTrace::new()
345    }
346
347    pub(crate) fn tools(&self) -> &[RegisteredTool] {
348        &self.inner.tools
349    }
350
351    pub(crate) fn sanitize_errors(&self) -> bool {
352        self.inner.sanitize_errors
353    }
354
355    pub(crate) fn default_trace(&self) -> ToolDefInvocationTrace {
356        self.inner.default_trace.clone()
357    }
358
359    pub(crate) async fn invoke_from_context(
360        &self,
361        name: &str,
362        params: Value,
363        stdin: Option<String>,
364        surface: ToolCallSurface,
365        ctx: &Context<'_>,
366    ) -> RegistryOutput {
367        self.invoke(
368            name,
369            params,
370            stdin,
371            surface,
372            ToolCallScope::from_context(ctx),
373            ctx.args.to_vec(),
374        )
375        .await
376    }
377
378    #[cfg(any(feature = "python", feature = "typescript"))]
379    pub(crate) async fn invoke_runtime(
380        &self,
381        name: &str,
382        params: Value,
383        surface: ToolCallSurface,
384    ) -> RegistryOutput {
385        self.invoke(
386            name,
387            params,
388            None,
389            surface,
390            ToolCallScope::from_runtime(),
391            Vec::new(),
392        )
393        .await
394    }
395
396    async fn invoke(
397        &self,
398        name: &str,
399        params: Value,
400        stdin: Option<String>,
401        surface: ToolCallSurface,
402        scope: ToolCallScope,
403        trace_args: Vec<String>,
404    ) -> RegistryOutput {
405        let Some(tool) = self.inner.tools.iter().find(|tool| tool.def.name == name) else {
406            return RegistryOutput::error(format!("{name}: unknown tool\n"), 127);
407        };
408        let Some(lease) = scope.lease.as_ref() else {
409            return RegistryOutput::error(
410                "tool callback: execution capability revoked\n".into(),
411                1,
412            );
413        };
414        let trace = match scope.trace() {
415            Ok(Some(trace)) => trace,
416            Ok(None) => self.default_trace(),
417            Err(_) => {
418                return RegistryOutput::error(
419                    "tool callback: execution capability revoked\n".into(),
420                    1,
421                );
422            }
423        };
424        let tenant_id = match scope.tenant_id() {
425            Ok(tenant_id) => tenant_id,
426            Err(_) => {
427                return RegistryOutput::error(
428                    "tool callback: execution capability revoked\n".into(),
429                    1,
430                );
431            }
432        };
433
434        if let Err(error) = validate_schema(&params, &tool.def.input_schema, "input") {
435            trace.push(name, ScriptedCommandKind::Tool, trace_args, 2);
436            return RegistryOutput::error(format!("{name}: schema error: {error}\n"), 2);
437        }
438
439        let call = ToolCall {
440            name,
441            params: &params,
442            tenant_id: tenant_id.as_deref(),
443            surface,
444        };
445        if (self.inner.policy)(&call) == ToolCallDecision::Deny {
446            trace.push(name, ScriptedCommandKind::Tool, trace_args, 1);
447            return RegistryOutput::error(format!("{name}: denied by policy\n"), 1);
448        }
449
450        let context = crate::tool_def::ToolArgsContextData::new(tenant_id, surface);
451        let Some(context) = lease.derive(context) else {
452            return RegistryOutput::error(
453                "tool callback: execution capability revoked\n".into(),
454                1,
455            );
456        };
457        let args = ToolArgs::with_context(params, stdin, context);
458        let callback = async {
459            match &tool.callback {
460                CallbackKind::Sync(callback) => callback(&args),
461                CallbackKind::Async(callback) => callback(args).await,
462            }
463        };
464        let callback = async {
465            match &scope.budget {
466                Some(budget) => budget
467                    .run(callback)
468                    .await
469                    .map_err(|_| InvokeError::Lifecycle)?
470                    .map_err(InvokeError::Callback),
471                None => callback.await.map_err(InvokeError::Callback),
472            }
473        };
474        #[cfg(not(target_family = "wasm"))]
475        let result = if let Some(remaining) = scope.remaining() {
476            match tokio::time::timeout(remaining, callback).await {
477                Ok(result) => result,
478                Err(_) => Err(InvokeError::Timeout),
479            }
480        } else {
481            callback.await
482        };
483        #[cfg(target_family = "wasm")]
484        let result = callback.await;
485
486        let output = match result {
487            Ok(stdout) => RegistryOutput {
488                stdout,
489                stderr: String::new(),
490                exit_code: 0,
491            },
492            #[cfg(not(target_family = "wasm"))]
493            Err(InvokeError::Timeout) => {
494                RegistryOutput::error(format!("{name}: callback timeout\n"), 1)
495            }
496            Err(InvokeError::Callback(_error)) if self.inner.sanitize_errors => {
497                // THREAT[TM-INF-030]: callback diagnostics can contain secrets.
498                #[cfg(feature = "tracing")]
499                tracing::debug!(tool = name, error = %_error, "registry callback error (sanitized)");
500                RegistryOutput::error(format!("{name}: callback failed\n"), 1)
501            }
502            Err(InvokeError::Callback(error)) => RegistryOutput::error(error, 1),
503            Err(InvokeError::Lifecycle) => {
504                RegistryOutput::error(format!("{name}: request cancelled\n"), 1)
505            }
506        };
507        trace.push(
508            name,
509            ScriptedCommandKind::Tool,
510            trace_args,
511            output.exit_code,
512        );
513        output
514    }
515
516    #[cfg(any(feature = "python", feature = "typescript"))]
517    pub(crate) fn discover(&self, params: &Value) -> Value {
518        let category = params.get("category").and_then(Value::as_str);
519        let tag = params.get("tag").and_then(Value::as_str);
520        let search = params
521            .get("search")
522            .and_then(Value::as_str)
523            .map(str::to_lowercase);
524        Value::Array(
525            self.inner
526                .tools
527                .iter()
528                .filter(|tool| category.is_none_or(|v| tool.def.category.as_deref() == Some(v)))
529                .filter(|tool| tag.is_none_or(|v| tool.def.tags.iter().any(|tag| tag == v)))
530                .filter(|tool| {
531                    search.as_ref().is_none_or(|search| {
532                        tool.def.name.to_lowercase().contains(search)
533                            || tool.def.description.to_lowercase().contains(search)
534                    })
535                })
536                .map(|tool| {
537                    serde_json::json!({
538                        "name": tool.def.name,
539                        "description": tool.def.description,
540                        "input_schema": tool.def.input_schema,
541                        "tags": tool.def.tags,
542                        "category": tool.def.category,
543                    })
544                })
545                .collect(),
546        )
547    }
548
549    #[cfg(any(feature = "python", feature = "typescript"))]
550    fn discover_runtime(&self, params: &Value) -> Value {
551        let scope = ToolCallScope::from_runtime();
552        let trace = match scope.trace() {
553            Ok(Some(trace)) => trace,
554            Ok(None) => self.default_trace(),
555            Err(_) => return Value::Array(Vec::new()),
556        };
557        trace.push("discover", ScriptedCommandKind::Discover, Vec::new(), 0);
558        self.discover(params)
559    }
560
561    #[cfg(feature = "typescript")]
562    pub(crate) fn typescript_handler(&self) -> bashkit::TypeScriptExternalFnHandler {
563        let registry = self.clone();
564        Arc::new(move |external_name, args| {
565            let registry = registry.clone();
566            Box::pin(async move {
567                let params = match args.first() {
568                    Some(bashkit::ZapcodeValue::String(json)) => serde_json::from_str(json)
569                        .map_err(|_| "tool input must be valid JSON".to_string())?,
570                    Some(value) => zapcode_to_json(value)?,
571                    None => serde_json::json!({}),
572                };
573                if external_name == "__bashkit_discover" {
574                    return Ok(json_to_zapcode(registry.discover_runtime(&params)));
575                }
576                let Some(tool) = registry
577                    .inner
578                    .tools
579                    .iter()
580                    .find(|tool| typescript_function_name(&tool.def.name) == external_name)
581                else {
582                    return Err("unknown registry tool".to_string());
583                };
584                let output = registry
585                    .invoke_runtime(&tool.def.name, params, ToolCallSurface::TypeScript)
586                    .await;
587                if output.exit_code != 0 {
588                    return Err(output.stderr.trim().to_string());
589                }
590                let value =
591                    serde_json::from_str(&output.stdout).unwrap_or(Value::String(output.stdout));
592                Ok(json_to_zapcode(value))
593            })
594        })
595    }
596
597    #[cfg(feature = "python")]
598    pub(crate) fn python_handler(&self) -> bashkit::PythonExternalFnHandler {
599        use bashkit::{ExcType, ExtFunctionResult, MontyException, MontyObject};
600        let registry = self.clone();
601        Arc::new(move |_external_name, args, _kwargs| {
602            let registry = registry.clone();
603            Box::pin(async move {
604                let name = args.first().and_then(|value| match value {
605                    MontyObject::String(name) => Some(name.clone()),
606                    _ => None,
607                });
608                let params = args
609                    .get(1)
610                    .map(monty_to_json)
611                    .transpose()
612                    .unwrap_or(None)
613                    .unwrap_or_else(|| serde_json::json!({}));
614                if name.as_deref() == Some("__discover") {
615                    return ExtFunctionResult::Return(json_to_monty(
616                        registry.discover_runtime(&params),
617                    ));
618                }
619                let Some(name) = name else {
620                    return ExtFunctionResult::Error(MontyException::new(
621                        ExcType::RuntimeError,
622                        Some("invalid tool call".into()),
623                    ));
624                };
625                let output = registry
626                    .invoke_runtime(&name, params, ToolCallSurface::Python)
627                    .await;
628                if output.exit_code == 0 {
629                    let value = serde_json::from_str(&output.stdout)
630                        .unwrap_or(Value::String(output.stdout));
631                    ExtFunctionResult::Return(json_to_monty(value))
632                } else {
633                    ExtFunctionResult::Error(MontyException::new(
634                        ExcType::RuntimeError,
635                        Some(output.stderr.trim().to_string()),
636                    ))
637                }
638            })
639        })
640    }
641
642    #[cfg(feature = "typescript")]
643    pub(crate) fn typescript_prelude(&self) -> String {
644        String::new()
645    }
646
647    #[cfg(feature = "typescript")]
648    pub(crate) fn typescript_rewrites(&self) -> Vec<(String, String)> {
649        let mut rewrites = self
650            .inner
651            .tools
652            .iter()
653            .map(|tool| {
654                (
655                    format!("tools.{}", tool.def.name),
656                    typescript_function_name(&tool.def.name),
657                )
658            })
659            .collect::<Vec<_>>();
660        rewrites.push(("tools.discover".into(), "__bashkit_discover".into()));
661        rewrites
662    }
663
664    #[cfg(feature = "typescript")]
665    pub(crate) fn typescript_external_names(&self) -> Vec<String> {
666        let mut names = self
667            .inner
668            .tools
669            .iter()
670            .map(|tool| typescript_function_name(&tool.def.name))
671            .collect::<Vec<_>>();
672        names.push("__bashkit_discover".to_string());
673        names
674    }
675
676    #[cfg(feature = "python")]
677    pub(crate) fn python_prelude(&self) -> String {
678        let mut root = serde_json::Map::new();
679        for tool in &self.inner.tools {
680            insert_runtime_tree(&mut root, &tool.def.name);
681        }
682        let mut classes = String::new();
683        let root_class = python_tree(&root, "Tools", &mut classes);
684        format!("{classes}\ntools = {root_class}()\n")
685    }
686}
687
688impl RegistryOutput {
689    fn error(stderr: String, exit_code: i32) -> Self {
690        Self {
691            stdout: String::new(),
692            stderr,
693            exit_code,
694        }
695    }
696}
697
698enum InvokeError {
699    Callback(String),
700    Lifecycle,
701    #[cfg(not(target_family = "wasm"))]
702    Timeout,
703}
704
705fn valid_runtime_name(name: &str) -> bool {
706    !name.is_empty()
707        && name.split('.').all(|segment| {
708            let mut chars = segment.chars();
709            chars
710                .next()
711                .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
712                && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
713        })
714}
715
716fn validate_schema(value: &Value, schema: &Value, path: &str) -> Result<(), String> {
717    validate_schema_inner(value, schema, schema, path, &mut Vec::new())
718}
719
720fn validate_schema_inner<'a>(
721    value: &Value,
722    schema: &'a Value,
723    root: &'a Value,
724    path: &str,
725    ref_stack: &mut Vec<&'a str>,
726) -> Result<(), String> {
727    if schema.as_object().is_none_or(serde_json::Map::is_empty) {
728        return Ok(());
729    }
730    // THREAT[TM-INJ-024]: referenced constraints must gate callbacks too.
731    if let Some(reference) = schema.get("$ref") {
732        let reference = reference
733            .as_str()
734            .ok_or_else(|| format!("{path} schema has a non-string $ref"))?;
735        let pointer = reference
736            .strip_prefix('#')
737            .ok_or_else(|| format!("{path} schema uses unsupported external $ref '{reference}'"))?;
738        if ref_stack.contains(&reference) {
739            return Err(format!("{path} schema has cyclic $ref '{reference}'"));
740        }
741        let target = root
742            .pointer(pointer)
743            .ok_or_else(|| format!("{path} schema has unresolved $ref '{reference}'"))?;
744        ref_stack.push(reference);
745        let result = validate_schema_inner(value, target, root, path, ref_stack);
746        ref_stack.pop();
747        result?;
748    }
749    if let Some(expected) = schema.get("type") {
750        let types = expected
751            .as_str()
752            .into_iter()
753            .chain(
754                expected
755                    .as_array()
756                    .into_iter()
757                    .flatten()
758                    .filter_map(Value::as_str),
759            )
760            .collect::<Vec<_>>();
761        if !types.is_empty() && !types.iter().any(|expected| value_has_type(value, expected)) {
762            return Err(format!("{path} must be {}", types.join(" or ")));
763        }
764    }
765    for keyword in ["oneOf", "anyOf"] {
766        if let Some(branches) = schema.get(keyword).and_then(Value::as_array)
767            && !branches
768                .iter()
769                .any(|branch| validate_schema_inner(value, branch, root, path, ref_stack).is_ok())
770        {
771            return Err(format!("{path} does not match any {keyword} branch"));
772        }
773    }
774    if let Some(branches) = schema.get("allOf").and_then(Value::as_array) {
775        for branch in branches {
776            validate_schema_inner(value, branch, root, path, ref_stack)?;
777        }
778    }
779    if let Some(allowed) = schema.get("enum").and_then(Value::as_array)
780        && !allowed.contains(value)
781    {
782        return Err(format!("{path} is not an allowed value"));
783    }
784    if let Some(object) = value.as_object() {
785        if let Some(required) = schema.get("required").and_then(Value::as_array) {
786            for key in required.iter().filter_map(Value::as_str) {
787                if !object.contains_key(key) {
788                    return Err(format!("required property '{key}' is missing"));
789                }
790            }
791        }
792        let properties = schema.get("properties").and_then(Value::as_object);
793        if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
794            for key in object.keys() {
795                if properties.is_none_or(|properties| !properties.contains_key(key)) {
796                    return Err(format!("unknown property '{key}'"));
797                }
798            }
799        }
800        if let Some(properties) = properties {
801            for (key, child) in object {
802                if let Some(child_schema) = properties.get(key) {
803                    validate_schema_inner(
804                        child,
805                        child_schema,
806                        root,
807                        &format!("{path}.{key}"),
808                        ref_stack,
809                    )?;
810                }
811            }
812        }
813    }
814    if let (Some(items), Some(values)) = (schema.get("items"), value.as_array()) {
815        for (index, child) in values.iter().enumerate() {
816            validate_schema_inner(child, items, root, &format!("{path}[{index}]"), ref_stack)?;
817        }
818    }
819    Ok(())
820}
821
822fn value_has_type(value: &Value, expected: &str) -> bool {
823    match expected {
824        "object" => value.is_object(),
825        "array" => value.is_array(),
826        "string" => value.is_string(),
827        "integer" => value.is_i64() || value.is_u64(),
828        "number" => value.is_number(),
829        "boolean" => value.is_boolean(),
830        "null" => value.is_null(),
831        _ => true,
832    }
833}
834
835#[cfg(feature = "python")]
836fn insert_runtime_tree(root: &mut serde_json::Map<String, Value>, name: &str) {
837    let mut current = root;
838    let mut segments = name.split('.').peekable();
839    while let Some(segment) = segments.next() {
840        if segments.peek().is_none() {
841            current.insert(segment.to_string(), Value::String(name.to_string()));
842            return;
843        }
844        current = current
845            .entry(segment)
846            .or_insert_with(|| Value::Object(Default::default()))
847            .as_object_mut()
848            .expect("tool namespace collision");
849    }
850}
851
852#[cfg(feature = "typescript")]
853fn typescript_function_name(name: &str) -> String {
854    format!("__bashkit_{}", name.replace('.', "_"))
855}
856
857#[cfg(feature = "python")]
858fn python_tree(
859    tree: &serde_json::Map<String, Value>,
860    class_name: &str,
861    classes: &mut String,
862) -> String {
863    let mut nested = Vec::new();
864    for (name, value) in tree {
865        if let Value::Object(children) = value {
866            let child_name = format!("{class_name}_{name}");
867            python_tree(children, &child_name, classes);
868            nested.push((name, child_name));
869        }
870    }
871    classes.push_str(&format!("class {class_name}:\n"));
872    if tree.is_empty() {
873        classes.push_str("    pass\n");
874    }
875    for (name, child_name) in nested {
876        classes.push_str(&format!("    {name} = {child_name}()\n"));
877    }
878    for (name, value) in tree {
879        if let Value::String(full_name) = value {
880            classes.push_str(&format!(
881                "    def {name}(self, args={{}}):\n        return __bashkit_tool_call('{full_name}', args)\n"
882            ));
883        }
884    }
885    if class_name == "Tools" {
886        classes.push_str(
887            "    def discover(self, args={}):\n        return __bashkit_tool_call('__discover', args)\n",
888        );
889    }
890    class_name.to_string()
891}
892
893#[cfg(feature = "typescript")]
894fn zapcode_to_json(value: &bashkit::ZapcodeValue) -> Result<Value, String> {
895    Ok(match value {
896        bashkit::ZapcodeValue::Undefined | bashkit::ZapcodeValue::Null => Value::Null,
897        bashkit::ZapcodeValue::Bool(value) => Value::Bool(*value),
898        bashkit::ZapcodeValue::Int(value) => Value::from(*value),
899        bashkit::ZapcodeValue::Float(value) => serde_json::Number::from_f64(*value)
900            .map(Value::Number)
901            .ok_or_else(|| "non-finite number is not valid tool input".to_string())?,
902        bashkit::ZapcodeValue::String(value) => Value::String(value.to_string()),
903        bashkit::ZapcodeValue::Array(values) => Value::Array(
904            values
905                .iter()
906                .map(zapcode_to_json)
907                .collect::<Result<Vec<_>, _>>()?,
908        ),
909        bashkit::ZapcodeValue::Object(values) => Value::Object(
910            values
911                .iter()
912                .map(|(key, value)| Ok((key.to_string(), zapcode_to_json(value)?)))
913                .collect::<Result<serde_json::Map<_, _>, String>>()?,
914        ),
915        _ => return Err("functions are not valid tool input".to_string()),
916    })
917}
918
919#[cfg(feature = "typescript")]
920fn json_to_zapcode(value: Value) -> bashkit::ZapcodeValue {
921    match value {
922        Value::Null => bashkit::ZapcodeValue::Null,
923        Value::Bool(value) => bashkit::ZapcodeValue::Bool(value),
924        Value::Number(value) => value
925            .as_i64()
926            .map(bashkit::ZapcodeValue::Int)
927            .or_else(|| value.as_f64().map(bashkit::ZapcodeValue::Float))
928            .unwrap_or(bashkit::ZapcodeValue::Null),
929        Value::String(value) => bashkit::ZapcodeValue::String(Arc::from(value)),
930        Value::Array(values) => {
931            bashkit::ZapcodeValue::Array(values.into_iter().map(json_to_zapcode).collect())
932        }
933        Value::Object(values) => bashkit::ZapcodeValue::Object(
934            values
935                .into_iter()
936                .map(|(key, value)| (Arc::from(key), json_to_zapcode(value)))
937                .collect(),
938        ),
939    }
940}
941
942#[cfg(feature = "python")]
943fn monty_to_json(value: &bashkit::MontyObject) -> Result<Value, String> {
944    use bashkit::MontyObject;
945    Ok(match value {
946        MontyObject::None => Value::Null,
947        MontyObject::Bool(value) => Value::Bool(*value),
948        MontyObject::Int(value) => Value::from(*value),
949        MontyObject::Float(value) => serde_json::Number::from_f64(*value)
950            .map(Value::Number)
951            .ok_or_else(|| "non-finite number is not valid tool input".to_string())?,
952        MontyObject::String(value) | MontyObject::Path(value) => Value::String(value.clone()),
953        MontyObject::List(values) | MontyObject::Tuple(values) => Value::Array(
954            values
955                .iter()
956                .map(monty_to_json)
957                .collect::<Result<Vec<_>, _>>()?,
958        ),
959        MontyObject::Dict(values) => Value::Object(
960            values
961                .into_iter()
962                .map(|(key, value)| {
963                    let MontyObject::String(key) = key else {
964                        return Err("tool input object keys must be strings".to_string());
965                    };
966                    Ok((key.clone(), monty_to_json(value)?))
967                })
968                .collect::<Result<serde_json::Map<_, _>, String>>()?,
969        ),
970        _ => return Err("unsupported Python value in tool input".to_string()),
971    })
972}
973
974#[cfg(feature = "python")]
975fn json_to_monty(value: Value) -> bashkit::MontyObject {
976    use bashkit::MontyObject;
977    match value {
978        Value::Null => MontyObject::None,
979        Value::Bool(value) => MontyObject::Bool(value),
980        Value::Number(value) => value
981            .as_i64()
982            .map(MontyObject::Int)
983            .or_else(|| value.as_f64().map(MontyObject::Float))
984            .unwrap_or(MontyObject::None),
985        Value::String(value) => MontyObject::String(value),
986        Value::Array(values) => MontyObject::List(values.into_iter().map(json_to_monty).collect()),
987        Value::Object(values) => MontyObject::Dict(
988            values
989                .into_iter()
990                .map(|(key, value)| (MontyObject::String(key), json_to_monty(value)))
991                .collect::<Vec<_>>()
992                .into(),
993        ),
994    }
995}