1use crate::program::ProgramCatalog;
4use crate::text::truncate_utf8;
5use crate::tools::types::{Tool, ToolContext, ToolOutput};
6use crate::tools::ToolRegistry;
7use anyhow::{anyhow, Result};
8use async_trait::async_trait;
9use rquickjs::function::{Async, Func};
10use rquickjs::{async_with, AsyncContext, AsyncRuntime, CatchResultExt, Error as JsError, Promise};
11use serde::Deserialize;
12use std::collections::HashSet;
13use std::sync::Arc;
14use std::time::Instant;
15use tokio::sync::Mutex;
16use tokio::time::{timeout, Duration};
17
18const DEFAULT_SCRIPT_TIMEOUT_MS: u64 = 30_000;
19const DEFAULT_SCRIPT_MAX_TOOL_CALLS: usize = 20;
20const DEFAULT_SCRIPT_MAX_OUTPUT_BYTES: usize = 64 * 1024;
21const MAX_SCRIPT_SOURCE_BYTES: usize = 64 * 1024;
22
23pub struct ProgramTool {
24 registry: Arc<ToolRegistry>,
25}
26
27impl ProgramTool {
28 pub fn new(registry: Arc<ToolRegistry>) -> Self {
29 Self { registry }
30 }
31
32 pub fn with_catalog(registry: Arc<ToolRegistry>, _catalog: ProgramCatalog) -> Self {
33 Self { registry }
34 }
35}
36
37#[async_trait]
38impl Tool for ProgramTool {
39 fn name(&self) -> &str {
40 "program"
41 }
42
43 fn description(&self) -> &str {
44 "Run a sandboxed JavaScript PTC script. The script defines async function run(ctx, inputs) and may call only allowed ctx tools."
45 }
46
47 fn parameters(&self) -> serde_json::Value {
48 serde_json::json!({
49 "type": "object",
50 "additionalProperties": false,
51 "properties": {
52 "type": {
53 "type": "string",
54 "description": "Required. Program kind. Only \"script\" is supported.",
55 "enum": ["script"]
56 },
57 "inputs": {
58 "type": "object",
59 "description": "Optional. JSON inputs passed to the script as the second argument."
60 },
61 "language": {
62 "type": "string",
63 "description": "Script language. Only JavaScript is supported.",
64 "enum": ["javascript"]
65 },
66 "source": {
67 "type": "string",
68 "description": "Inline JavaScript source defining async function run(ctx, inputs)."
69 },
70 "path": {
71 "type": "string",
72 "description": "Workspace-relative path to a .js or .mjs script defining async function run(ctx, inputs). Used when source is omitted."
73 },
74 "allowed_tools": {
75 "type": "array",
76 "description": "Tool names the script may call through ctx. Defaults to all registered tools except program.",
77 "items": { "type": "string" }
78 },
79 "limits": {
80 "type": "object",
81 "description": "Optional timeoutMs, maxToolCalls, and maxOutputBytes.",
82 "additionalProperties": false,
83 "properties": {
84 "timeoutMs": { "type": "integer", "minimum": 1 },
85 "maxToolCalls": { "type": "integer", "minimum": 1 },
86 "maxOutputBytes": { "type": "integer", "minimum": 1 }
87 }
88 }
89 },
90 "required": ["type"]
91 })
92 }
93
94 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
95 let Some(kind) = args.get("type").and_then(|value| value.as_str()) else {
96 return Ok(ToolOutput::error("type parameter is required"));
97 };
98 if kind != "script" {
99 return Ok(ToolOutput::error(format!(
100 "Unsupported program type: {kind}. Only \"script\" is supported."
101 )));
102 }
103 let inputs = args
104 .get("inputs")
105 .cloned()
106 .unwrap_or_else(|| serde_json::json!({}));
107
108 execute_script_program(args, inputs, Arc::clone(&self.registry), ctx).await
109 }
110}
111
112#[derive(Debug, Deserialize)]
113#[serde(rename_all = "camelCase")]
114struct ScriptLimits {
115 timeout_ms: Option<u64>,
116 max_tool_calls: Option<usize>,
117 max_output_bytes: Option<usize>,
118}
119
120#[derive(Debug, Clone)]
121struct ScriptCallRecord {
122 tool_name: String,
123 success: bool,
124 exit_code: i32,
125 output_bytes: usize,
126 metadata: Option<serde_json::Value>,
127}
128
129async fn execute_script_program(
130 args: &serde_json::Value,
131 inputs: serde_json::Value,
132 registry: Arc<ToolRegistry>,
133 ctx: &ToolContext,
134) -> Result<ToolOutput> {
135 let language = args
136 .get("language")
137 .and_then(|value| value.as_str())
138 .unwrap_or("javascript");
139 if language != "javascript" {
140 return Ok(ToolOutput::error(format!(
141 "Unsupported script language: {language}"
142 )));
143 }
144
145 let source = match load_script_source(args, ctx).await {
146 Ok(source) => source,
147 Err(message) => return Ok(ToolOutput::error(message)),
148 };
149 if source.len() > MAX_SCRIPT_SOURCE_BYTES {
150 return Ok(ToolOutput::error(format!(
151 "script source is too large: {} bytes exceeds {} bytes",
152 source.len(),
153 MAX_SCRIPT_SOURCE_BYTES
154 )));
155 }
156 if let Err(message) = validate_script_source(&source) {
157 return Ok(ToolOutput::error(message));
158 }
159
160 let allowed_tools = script_allowed_tools(args, ®istry);
161 let limits = script_limits(args);
162 match run_quickjs_script(
163 &source,
164 inputs,
165 registry,
166 ctx.clone(),
167 allowed_tools,
168 limits,
169 )
170 .await
171 {
172 Ok(output) => Ok(output),
173 Err(err) => Ok(ToolOutput::error(format!("program script failed: {err}"))),
174 }
175}
176
177async fn load_script_source(
178 args: &serde_json::Value,
179 ctx: &ToolContext,
180) -> std::result::Result<String, String> {
181 if let Some(source) = args.get("source").and_then(|value| value.as_str()) {
182 return Ok(source.to_string());
183 }
184
185 let Some(path) = args.get("path").and_then(|value| value.as_str()) else {
186 return Err("program script requires either source or path".to_string());
187 };
188 if !(path.ends_with(".js") || path.ends_with(".mjs")) {
189 return Err("program script path must point to a .js or .mjs file".to_string());
190 }
191
192 let resolved = ctx
193 .resolve_path(path)
194 .map_err(|err| format!("failed to resolve script path: {err}"))?;
195 tokio::fs::read_to_string(&resolved)
196 .await
197 .map_err(|err| format!("failed to read script path '{}': {err}", path))
198}
199
200fn script_allowed_tools(args: &serde_json::Value, registry: &ToolRegistry) -> HashSet<String> {
201 let mut allowed = args
202 .get("allowed_tools")
203 .and_then(|value| value.as_array())
204 .map(|items| {
205 items
206 .iter()
207 .filter_map(|item| item.as_str())
208 .map(ToString::to_string)
209 .collect::<HashSet<_>>()
210 })
211 .unwrap_or_else(|| registry.list().into_iter().collect());
212
213 allowed.remove("program");
214 allowed
215}
216
217fn script_limits(args: &serde_json::Value) -> ScriptLimits {
218 args.get("limits")
219 .cloned()
220 .and_then(|value| serde_json::from_value(value).ok())
221 .unwrap_or(ScriptLimits {
222 timeout_ms: None,
223 max_tool_calls: None,
224 max_output_bytes: None,
225 })
226}
227
228fn validate_script_source(source: &str) -> std::result::Result<(), String> {
229 let forbidden = [
230 ("import ", "imports are not allowed inside PTC scripts"),
231 (
232 "import(",
233 "dynamic imports are not allowed inside PTC scripts",
234 ),
235 ("eval(", "eval is not allowed inside PTC scripts"),
236 (
237 "Function(",
238 "Function constructor is not allowed inside PTC scripts",
239 ),
240 ("Worker(", "Worker is not allowed inside PTC scripts"),
241 ("WebSocket", "WebSocket is not allowed inside PTC scripts"),
242 (
243 "fetch(",
244 "fetch is not allowed inside PTC scripts; use ctx tools instead",
245 ),
246 ];
247
248 for (needle, message) in forbidden {
249 if source.contains(needle) {
250 return Err(message.to_string());
251 }
252 }
253 Ok(())
254}
255
256async fn run_quickjs_script(
257 source: &str,
258 inputs: serde_json::Value,
259 registry: Arc<ToolRegistry>,
260 ctx: ToolContext,
261 allowed_tools: HashSet<String>,
262 limits: ScriptLimits,
263) -> Result<ToolOutput> {
264 let timeout_ms = limits.timeout_ms.unwrap_or(DEFAULT_SCRIPT_TIMEOUT_MS);
265 let max_tool_calls = limits
266 .max_tool_calls
267 .unwrap_or(DEFAULT_SCRIPT_MAX_TOOL_CALLS);
268 let max_output_bytes = limits
269 .max_output_bytes
270 .unwrap_or(DEFAULT_SCRIPT_MAX_OUTPUT_BYTES);
271 let executable_source = script_source_with_host_entrypoint(source)?;
272 let state = Arc::new(Mutex::new(ScriptVmState {
273 registry,
274 ctx,
275 allowed_tools,
276 max_tool_calls,
277 max_output_bytes,
278 tool_calls: 0,
279 records: Vec::new(),
280 }));
281
282 let vm_state = Arc::clone(&state);
283 let result = timeout(
284 Duration::from_millis(timeout_ms),
285 tokio::task::spawn_blocking(move || {
286 let runtime = tokio::runtime::Builder::new_current_thread()
287 .enable_all()
288 .build()
289 .map_err(|err| anyhow!("failed to create program VM runtime: {err}"))?;
290 runtime.block_on(run_embedded_script(
291 executable_source,
292 inputs,
293 vm_state,
294 timeout_ms,
295 ))
296 }),
297 )
298 .await;
299
300 match result {
301 Ok(Ok(Ok(result))) => {
302 let records = state.lock().await.records.clone();
303 let output = render_script_output(&result, &records, "");
304 Ok(ToolOutput::success(output).with_metadata(serde_json::json!({
305 "program": {
306 "name": "script",
307 "language": "javascript",
308 "runtime": "embedded-quickjs",
309 "success": true,
310 "tool_calls": records.iter().map(script_record_to_value).collect::<Vec<_>>(),
311 },
312 "script_result": result,
313 })))
314 }
315 Ok(Ok(Err(err))) if is_quickjs_timeout(&err) => Ok(ToolOutput::error(format!(
316 "program script timed out after {timeout_ms} ms"
317 ))),
318 Ok(Ok(Err(err))) => Ok(ToolOutput::error(format!("program script error:\n{err}"))),
319 Ok(Err(err)) => Ok(ToolOutput::error(format!(
320 "program VM thread failed: {err}"
321 ))),
322 Err(_) => Ok(ToolOutput::error(format!(
323 "program script timed out after {timeout_ms} ms"
324 ))),
325 }
326}
327
328fn script_source_with_host_entrypoint(source: &str) -> Result<String> {
329 let rewritten = if source.contains("export default async function run") {
330 source.replacen("export default async function run", "async function run", 1)
331 } else if source.contains("export default function run") {
332 source.replacen("export default function run", "function run", 1)
333 } else if source.contains("async function run") || source.contains("function run") {
334 source.to_string()
335 } else {
336 return Err(anyhow!(
337 "PTC script must define async function run(ctx, inputs)"
338 ));
339 };
340
341 Ok(format!(
342 r#"{rewritten}
343
344globalThis.__a3sResultJson = (async () => JSON.stringify(await run(globalThis.__a3sCtx, globalThis.__a3sInputs)))();
345"#
346 ))
347}
348
349async fn run_embedded_script(
350 source: String,
351 inputs: serde_json::Value,
352 state: Arc<Mutex<ScriptVmState>>,
353 timeout_ms: u64,
354) -> Result<serde_json::Value> {
355 let runtime = AsyncRuntime::new()?;
356 let started = Instant::now();
357 runtime
358 .set_interrupt_handler(Some(Box::new(move || {
359 started.elapsed() >= Duration::from_millis(timeout_ms)
360 })))
361 .await;
362 runtime.set_memory_limit(64 * 1024 * 1024).await;
363 runtime.set_max_stack_size(512 * 1024).await;
364
365 let context = AsyncContext::full(&runtime).await?;
366 let inputs_json = serde_json::to_string(&inputs)?;
367 let script = format!("{}\n{}", embedded_script_bootstrap(&inputs_json), source);
368 let result_json = async_with!(context => |ctx| {
369 let state = Arc::clone(&state);
370 let host_tool = move |tool: String, args_json: String| {
371 let state = Arc::clone(&state);
372 async move { execute_host_tool_json(state, tool, args_json).await }
373 };
374 if let Err(err) = ctx.globals().set("__a3sHostTool", Func::from(Async(host_tool))) {
375 return Err(format!("failed to install program host tool: {err}"));
376 }
377 let promise: Promise = match ctx.eval(script) {
378 Ok(promise) => promise,
379 Err(err) => return Err(format!("failed to evaluate program script: {err}")),
380 };
381 promise
382 .into_future::<String>()
383 .await
384 .catch(&ctx)
385 .map_err(|err| err.to_string())
386 })
387 .await
388 .map_err(anyhow::Error::msg)?;
389
390 serde_json::from_str(&result_json)
391 .map_err(|err| anyhow!("program script returned invalid JSON: {err}"))
392}
393
394struct ScriptVmState {
395 registry: Arc<ToolRegistry>,
396 ctx: ToolContext,
397 allowed_tools: HashSet<String>,
398 max_tool_calls: usize,
399 max_output_bytes: usize,
400 tool_calls: usize,
401 records: Vec<ScriptCallRecord>,
402}
403
404fn embedded_script_bootstrap(inputs_json: &str) -> String {
405 format!(
406 r#"
407const __a3sCallTool = async (tool, args = {{}}) => {{
408 const response = await globalThis.__a3sHostTool(String(tool), JSON.stringify(args ?? {{}}));
409 return JSON.parse(response);
410}};
411
412const __a3sCtx = Object.freeze({{
413 tool: __a3sCallTool,
414 readFile: (path) => __a3sCallTool("read", {{ file_path: path }}).then((r) => r.output),
415 read: (path) => __a3sCallTool("read", {{ file_path: path }}),
416 grep: (pattern, options = {{}}) => __a3sCallTool("grep", {{ pattern, ...options }}).then((r) => r.output),
417 glob: (pattern, options = {{}}) => __a3sCallTool("glob", {{ pattern, ...options }}).then((r) => r.output),
418 ls: (path = ".") => __a3sCallTool("ls", {{ path }}).then((r) => r.output),
419 bash: (command) => __a3sCallTool("bash", {{ command }}).then((r) => r.output),
420 git: (args = {{}}) => __a3sCallTool("git", args),
421 webSearch: (params) => __a3sCallTool("web_search", params),
422 verify: (args) => __a3sCallTool("bash", args),
423}});
424
425Object.defineProperty(globalThis, "__a3sCtx", {{ value: __a3sCtx, configurable: false }});
426Object.defineProperty(globalThis, "__a3sInputs", {{ value: {inputs_json}, configurable: false }});
427Object.defineProperty(globalThis, "fetch", {{ value: undefined, configurable: false, writable: false }});
428Object.defineProperty(globalThis, "WebSocket", {{ value: undefined, configurable: false, writable: false }});
429Object.defineProperty(globalThis, "Worker", {{ value: undefined, configurable: false, writable: false }});
430"#
431 )
432}
433
434async fn execute_host_tool_json(
435 state: Arc<Mutex<ScriptVmState>>,
436 tool: String,
437 args_json: String,
438) -> rquickjs::Result<String> {
439 let args = serde_json::from_str(&args_json).map_err(|err| {
440 JsError::new_from_js_message("string", "object", format!("invalid tool args JSON: {err}"))
441 })?;
442 let (registry, ctx, max_output_bytes) = {
443 let mut script = state.lock().await;
444 if !script.allowed_tools.contains(&tool) {
445 return Err(JsError::new_from_js_message(
446 "tool",
447 "allowed tool",
448 format!("tool '{tool}' is not allowed for this PTC script"),
449 ));
450 }
451 script.tool_calls += 1;
452 if script.tool_calls > script.max_tool_calls {
453 return Err(JsError::new_from_js_message(
454 "tool call",
455 "limited tool call",
456 format!("PTC script exceeded maxToolCalls={}", script.max_tool_calls),
457 ));
458 }
459 (
460 Arc::clone(&script.registry),
461 script.ctx.clone(),
462 script.max_output_bytes,
463 )
464 };
465
466 let result = registry
467 .execute_with_context(&tool, &args, &ctx)
468 .await
469 .map_err(|err| JsError::new_from_js_message("tool", "result", err.to_string()))?;
470 let mut output = result.output;
471 if output.len() > max_output_bytes {
472 output = truncate_utf8(&output, max_output_bytes).to_string();
473 }
474 let success = result.exit_code == 0;
475 let metadata = result.metadata.clone();
476 let exit_code = result.exit_code;
477 let name = result.name;
478
479 {
480 let mut script = state.lock().await;
481 script.records.push(ScriptCallRecord {
482 tool_name: tool,
483 success,
484 exit_code,
485 output_bytes: output.len(),
486 metadata: metadata.clone(),
487 });
488 }
489
490 serde_json::to_string(&serde_json::json!({
491 "name": name,
492 "output": output,
493 "exitCode": exit_code,
494 "metadata": metadata,
495 }))
496 .map_err(|err| JsError::new_from_js_message("tool result", "json", err.to_string()))
497}
498
499fn is_quickjs_timeout(err: &anyhow::Error) -> bool {
500 let text = err.to_string();
501 text.contains("interrupted") || text.contains("InternalError")
502}
503
504fn script_record_to_value(record: &ScriptCallRecord) -> serde_json::Value {
505 serde_json::json!({
506 "tool_name": record.tool_name,
507 "success": record.success,
508 "exit_code": record.exit_code,
509 "output_bytes": record.output_bytes,
510 "metadata": record.metadata,
511 })
512}
513
514fn render_script_output(
515 result: &serde_json::Value,
516 records: &[ScriptCallRecord],
517 stderr: &str,
518) -> String {
519 let mut output = String::from("Program script completed.");
520 if let Some(summary) = result.get("summary").and_then(|value| value.as_str()) {
521 output.push('\n');
522 output.push_str(summary);
523 }
524
525 output.push_str(&format!("\n\nTool calls: {}", records.len()));
526 for (index, record) in records.iter().enumerate() {
527 output.push_str(&format!(
528 "\n{}. {} ({}, exit_code={}, output_bytes={})",
529 index + 1,
530 record.tool_name,
531 if record.success { "ok" } else { "failed" },
532 record.exit_code,
533 record.output_bytes
534 ));
535 }
536
537 output.push_str("\n\nResult:\n");
538 output.push_str(&serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()));
539
540 if !stderr.is_empty() {
541 output.push_str("\n\nstderr:\n");
542 output.push_str(stderr);
543 }
544
545 output
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use async_trait::async_trait;
552 use std::path::PathBuf;
553
554 struct EchoTool;
555
556 #[async_trait]
557 impl Tool for EchoTool {
558 fn name(&self) -> &str {
559 "echo"
560 }
561
562 fn description(&self) -> &str {
563 "Echo test tool"
564 }
565
566 fn parameters(&self) -> serde_json::Value {
567 serde_json::json!({
568 "type": "object",
569 "properties": {
570 "message": { "type": "string" }
571 }
572 })
573 }
574
575 async fn execute(
576 &self,
577 args: &serde_json::Value,
578 _ctx: &ToolContext,
579 ) -> Result<ToolOutput> {
580 let message = args
581 .get("message")
582 .and_then(|value| value.as_str())
583 .unwrap_or("");
584 Ok(ToolOutput::success(format!("echo:{message}")))
585 }
586 }
587
588 #[tokio::test]
589 async fn program_tool_rejects_non_script_type() {
590 let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
591 let output = tool
592 .execute(
593 &serde_json::json!({ "type": "program_code_search" }),
594 &ToolContext::new(PathBuf::from("/tmp")),
595 )
596 .await
597 .unwrap();
598
599 assert!(!output.success);
600 assert!(output.content.contains("Only \"script\" is supported"));
601 }
602
603 #[tokio::test]
604 async fn program_tool_rejects_missing_script_source_and_path() {
605 let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
606 let output = tool
607 .execute(
608 &serde_json::json!({ "type": "script" }),
609 &ToolContext::new(PathBuf::from("/tmp")),
610 )
611 .await
612 .unwrap();
613
614 assert!(!output.success);
615 assert!(output.content.contains("requires either source or path"));
616 }
617
618 #[tokio::test]
619 async fn program_tool_rejects_unsupported_language() {
620 let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
621 let output = tool
622 .execute(
623 &serde_json::json!({
624 "type": "script",
625 "language": "typescript",
626 "source": "async function run() { return {}; }"
627 }),
628 &ToolContext::new(PathBuf::from("/tmp")),
629 )
630 .await
631 .unwrap();
632
633 assert!(!output.success);
634 assert!(output.content.contains("Unsupported script language"));
635 }
636
637 #[tokio::test]
638 async fn program_tool_rejects_unsupported_script_path() {
639 let dir = tempfile::tempdir().unwrap();
640 std::fs::write(dir.path().join("script.txt"), "async function run() {}").unwrap();
641 let tool = ProgramTool::new(Arc::new(ToolRegistry::new(dir.path().to_path_buf())));
642 let output = tool
643 .execute(
644 &serde_json::json!({
645 "type": "script",
646 "path": "script.txt"
647 }),
648 &ToolContext::new(dir.path().to_path_buf()),
649 )
650 .await
651 .unwrap();
652
653 assert!(!output.success);
654 assert!(output.content.contains(".js or .mjs file"));
655 }
656
657 #[test]
658 fn program_tool_default_allowed_tools_include_registry_tools_except_program() {
659 let registry = ToolRegistry::new(PathBuf::from("/tmp"));
660 registry.register(Arc::new(EchoTool));
661 registry.register_builtin(Arc::new(ProgramTool::new(Arc::new(ToolRegistry::new(
662 PathBuf::from("/tmp"),
663 )))));
664
665 let allowed = script_allowed_tools(&serde_json::json!({}), ®istry);
666
667 assert!(allowed.contains("echo"));
668 assert!(!allowed.contains("program"));
669 }
670
671 #[tokio::test]
672 async fn program_tool_source_uses_default_all_registered_tools() {
673 let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
674 registry.register(Arc::new(EchoTool));
675 let tool = ProgramTool::new(Arc::clone(®istry));
676 let output = tool
677 .execute(
678 &serde_json::json!({
679 "type": "script",
680 "source": r#"
681 async function run(ctx, inputs) {
682 const result = await ctx.tool("echo", { message: inputs.message });
683 return { summary: result.output, result };
684 }
685 "#,
686 "inputs": { "message": "hello" }
687 }),
688 &ToolContext::new(PathBuf::from("/tmp")),
689 )
690 .await
691 .unwrap();
692
693 assert!(output.success, "{}", output.content);
694 assert!(output.content.contains("echo:hello"));
695 let metadata = output.metadata.unwrap();
696 assert_eq!(metadata["program"]["runtime"], "embedded-quickjs");
697 assert_eq!(metadata["script_result"]["summary"], "echo:hello");
698 }
699
700 #[tokio::test]
701 async fn program_tool_explicit_allowed_tools_restrict_default_tools() {
702 let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
703 registry.register(Arc::new(EchoTool));
704 let tool = ProgramTool::new(Arc::clone(®istry));
705 let output = tool
706 .execute(
707 &serde_json::json!({
708 "type": "script",
709 "source": r#"
710 async function run(ctx) {
711 await ctx.tool("echo", { message: "blocked" });
712 return {};
713 }
714 "#,
715 "allowed_tools": ["read"]
716 }),
717 &ToolContext::new(PathBuf::from("/tmp")),
718 )
719 .await
720 .unwrap();
721
722 assert!(!output.success);
723 assert!(output.content.contains("tool 'echo' is not allowed"));
724 }
725
726 #[tokio::test]
727 async fn program_tool_enforces_max_tool_calls() {
728 let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
729 registry.register(Arc::new(EchoTool));
730 let tool = ProgramTool::new(Arc::clone(®istry));
731 let output = tool
732 .execute(
733 &serde_json::json!({
734 "type": "script",
735 "source": r#"
736 async function run(ctx) {
737 await ctx.tool("echo", { message: "one" });
738 await ctx.tool("echo", { message: "two" });
739 return {};
740 }
741 "#,
742 "limits": { "maxToolCalls": 1 }
743 }),
744 &ToolContext::new(PathBuf::from("/tmp")),
745 )
746 .await
747 .unwrap();
748
749 assert!(!output.success);
750 assert!(output.content.contains("exceeded maxToolCalls=1"));
751 }
752
753 #[test]
754 fn program_tool_rejects_fetch_source_access() {
755 let err =
756 validate_script_source("export default async function run() { return fetch('/'); }")
757 .unwrap_err();
758 assert!(err.contains("fetch is not allowed"));
759 }
760
761 #[test]
762 fn program_tool_accepts_plain_function_run_entrypoint() {
763 let source = script_source_with_host_entrypoint(
764 "async function run(ctx, inputs) { return { summary: inputs.message }; }",
765 )
766 .unwrap();
767
768 assert!(source.contains("globalThis.__a3sResultJson"));
769 assert!(source.contains("async function run"));
770 }
771
772 #[test]
773 fn program_tool_renders_result_summary_and_tool_records() {
774 let output = render_script_output(
775 &serde_json::json!({ "summary": "done", "items": [1] }),
776 &[ScriptCallRecord {
777 tool_name: "echo".to_string(),
778 success: true,
779 exit_code: 0,
780 output_bytes: 8,
781 metadata: Some(serde_json::json!({ "kind": "test" })),
782 }],
783 "",
784 );
785
786 assert!(output.contains("Program script completed."));
787 assert!(output.contains("done"));
788 assert!(output.contains("echo (ok"));
789 assert!(output.contains("\"items\""));
790 }
791}