1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ToolCallSurface {
17 Shell,
18 Python,
19 TypeScript,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ToolCallDecision {
25 Allow,
26 Deny,
27}
28
29pub 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#[derive(Clone)]
62pub 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#[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 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 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(¶ms, &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: ¶ms,
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 #[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(¶ms)));
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(¶ms),
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 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}