Skip to main content

a3s_code_core/tools/
program_tool.rs

1//! Tool wrapper for programmatic tool calling.
2
3use 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, &registry);
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!({}), &registry);
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(&registry));
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(&registry));
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(&registry));
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}