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 workspace_path = ctx
193        .resolve_workspace_path(path)
194        .map_err(|err| format!("failed to resolve script path: {err}"))?;
195    ctx.workspace_services
196        .fs()
197        .read_text(&workspace_path)
198        .await
199        .map_err(|err| format!("failed to read script path '{}': {err}", path))
200}
201
202fn script_allowed_tools(args: &serde_json::Value, registry: &ToolRegistry) -> HashSet<String> {
203    let mut allowed = args
204        .get("allowed_tools")
205        .and_then(|value| value.as_array())
206        .map(|items| {
207            items
208                .iter()
209                .filter_map(|item| item.as_str())
210                .map(ToString::to_string)
211                .collect::<HashSet<_>>()
212        })
213        .unwrap_or_else(|| registry.list().into_iter().collect());
214
215    allowed.remove("program");
216    // `task`/`parallel_task` ARE allowed in PTC scripts now: host tool calls run
217    // on the outer multi-threaded runtime (see execute_host_tool_json), so
218    // `ctx.tool("parallel_task", …)` fans out child agents in parallel.
219    allowed
220}
221
222fn script_limits(args: &serde_json::Value) -> ScriptLimits {
223    args.get("limits")
224        .cloned()
225        .and_then(|value| serde_json::from_value(value).ok())
226        .unwrap_or(ScriptLimits {
227            timeout_ms: None,
228            max_tool_calls: None,
229            max_output_bytes: None,
230        })
231}
232
233fn validate_script_source(source: &str) -> std::result::Result<(), String> {
234    let forbidden = [
235        ("import ", "imports are not allowed inside PTC scripts"),
236        (
237            "import(",
238            "dynamic imports are not allowed inside PTC scripts",
239        ),
240        ("eval(", "eval is not allowed inside PTC scripts"),
241        (
242            "Function(",
243            "Function constructor is not allowed inside PTC scripts",
244        ),
245        ("Worker(", "Worker is not allowed inside PTC scripts"),
246        ("WebSocket", "WebSocket is not allowed inside PTC scripts"),
247        (
248            "fetch(",
249            "fetch is not allowed inside PTC scripts; use ctx tools instead",
250        ),
251    ];
252
253    for (needle, message) in forbidden {
254        if source.contains(needle) {
255            return Err(message.to_string());
256        }
257    }
258    Ok(())
259}
260
261async fn run_quickjs_script(
262    source: &str,
263    inputs: serde_json::Value,
264    registry: Arc<ToolRegistry>,
265    ctx: ToolContext,
266    allowed_tools: HashSet<String>,
267    limits: ScriptLimits,
268) -> Result<ToolOutput> {
269    let timeout_ms = limits.timeout_ms.unwrap_or(DEFAULT_SCRIPT_TIMEOUT_MS);
270    let max_tool_calls = limits
271        .max_tool_calls
272        .unwrap_or(DEFAULT_SCRIPT_MAX_TOOL_CALLS);
273    let max_output_bytes = limits
274        .max_output_bytes
275        .unwrap_or(DEFAULT_SCRIPT_MAX_OUTPUT_BYTES);
276    let executable_source = script_source_with_host_entrypoint(source)?;
277    // Captured on the outer multi-threaded runtime (we're async here, before the
278    // VM's nested single-thread runtime is built) so host tool calls fan out.
279    let outer = tokio::runtime::Handle::current();
280    let state = Arc::new(Mutex::new(ScriptVmState {
281        registry,
282        ctx,
283        allowed_tools,
284        max_tool_calls,
285        max_output_bytes,
286        tool_calls: 0,
287        records: Vec::new(),
288        outer,
289    }));
290
291    let vm_state = Arc::clone(&state);
292    let result = timeout(
293        Duration::from_millis(timeout_ms),
294        tokio::task::spawn_blocking(move || {
295            let runtime = tokio::runtime::Builder::new_current_thread()
296                .enable_all()
297                .build()
298                .map_err(|err| anyhow!("failed to create program VM runtime: {err}"))?;
299            runtime.block_on(run_embedded_script(
300                executable_source,
301                inputs,
302                vm_state,
303                timeout_ms,
304            ))
305        }),
306    )
307    .await;
308
309    match result {
310        Ok(Ok(Ok(result))) => {
311            let records = state.lock().await.records.clone();
312            let output = render_script_output(&result, &records, "");
313            Ok(ToolOutput::success(output).with_metadata(serde_json::json!({
314                "program": {
315                    "name": "script",
316                    "language": "javascript",
317                    "runtime": "embedded-quickjs",
318                    "success": true,
319                    "tool_calls": records.iter().map(script_record_to_value).collect::<Vec<_>>(),
320                },
321                "script_result": result,
322            })))
323        }
324        Ok(Ok(Err(err))) if is_quickjs_timeout(&err) => Ok(ToolOutput::error(format!(
325            "program script timed out after {timeout_ms} ms"
326        ))),
327        Ok(Ok(Err(err))) => Ok(ToolOutput::error(format!("program script error:\n{err}"))),
328        Ok(Err(err)) => Ok(ToolOutput::error(format!(
329            "program VM thread failed: {err}"
330        ))),
331        Err(_) => Ok(ToolOutput::error(format!(
332            "program script timed out after {timeout_ms} ms"
333        ))),
334    }
335}
336
337fn script_source_with_host_entrypoint(source: &str) -> Result<String> {
338    let rewritten = if source.contains("export default async function run") {
339        source.replacen("export default async function run", "async function run", 1)
340    } else if source.contains("export default function run") {
341        source.replacen("export default function run", "function run", 1)
342    } else if source.contains("async function run") || source.contains("function run") {
343        source.to_string()
344    } else {
345        return Err(anyhow!(
346            "PTC script must define async function run(ctx, inputs)"
347        ));
348    };
349
350    Ok(format!(
351        r#"{rewritten}
352
353globalThis.__a3sResultJson = (async () => JSON.stringify(await run(globalThis.__a3sCtx, globalThis.__a3sInputs)))();
354"#
355    ))
356}
357
358async fn run_embedded_script(
359    source: String,
360    inputs: serde_json::Value,
361    state: Arc<Mutex<ScriptVmState>>,
362    timeout_ms: u64,
363) -> Result<serde_json::Value> {
364    let runtime = AsyncRuntime::new()?;
365    let started = Instant::now();
366    runtime
367        .set_interrupt_handler(Some(Box::new(move || {
368            started.elapsed() >= Duration::from_millis(timeout_ms)
369        })))
370        .await;
371    runtime.set_memory_limit(64 * 1024 * 1024).await;
372    runtime.set_max_stack_size(512 * 1024).await;
373
374    let context = AsyncContext::full(&runtime).await?;
375    let inputs_json = serde_json::to_string(&inputs)?;
376    let script = format!("{}\n{}", embedded_script_bootstrap(&inputs_json), source);
377    let result_json = async_with!(context => |ctx| {
378        let state = Arc::clone(&state);
379        let host_tool = move |tool: String, args_json: String| {
380            let state = Arc::clone(&state);
381            async move { execute_host_tool_json(state, tool, args_json).await }
382        };
383        if let Err(err) = ctx.globals().set("__a3sHostTool", Func::from(Async(host_tool))) {
384            return Err(format!("failed to install program host tool: {err}"));
385        }
386        let promise: Promise = match ctx.eval(script) {
387            Ok(promise) => promise,
388            Err(err) => return Err(format!("failed to evaluate program script: {err}")),
389        };
390        promise
391            .into_future::<String>()
392            .await
393            .catch(&ctx)
394            .map_err(|err| err.to_string())
395    })
396    .await
397    .map_err(anyhow::Error::msg)?;
398
399    serde_json::from_str(&result_json)
400        .map_err(|err| anyhow!("program script returned invalid JSON: {err}"))
401}
402
403struct ScriptVmState {
404    registry: Arc<ToolRegistry>,
405    ctx: ToolContext,
406    allowed_tools: HashSet<String>,
407    max_tool_calls: usize,
408    max_output_bytes: usize,
409    tool_calls: usize,
410    records: Vec<ScriptCallRecord>,
411    /// Handle to the OUTER multi-threaded session runtime. The script VM runs on
412    /// a nested single-thread runtime; host tool calls are dispatched here so
413    /// delegation tools (`parallel_task`/`task`) can actually fan out children.
414    outer: tokio::runtime::Handle,
415}
416
417fn embedded_script_bootstrap(inputs_json: &str) -> String {
418    format!(
419        r#"
420const __a3sCallTool = async (tool, args = {{}}) => {{
421  const response = await globalThis.__a3sHostTool(String(tool), JSON.stringify(args ?? {{}}));
422  return JSON.parse(response);
423}};
424
425const __a3sCtx = Object.freeze({{
426  tool: __a3sCallTool,
427  readFile: (path) => __a3sCallTool("read", {{ file_path: path }}).then((r) => r.output),
428  read: (path) => __a3sCallTool("read", {{ file_path: path }}),
429  grep: (pattern, options = {{}}) => __a3sCallTool("grep", {{ pattern, ...options }}).then((r) => r.output),
430  glob: (pattern, options = {{}}) => __a3sCallTool("glob", {{ pattern, ...options }}).then((r) => r.output),
431  ls: (path = ".") => __a3sCallTool("ls", {{ path }}).then((r) => r.output),
432  bash: (command) => __a3sCallTool("bash", {{ command }}).then((r) => r.output),
433  git: (args = {{}}) => __a3sCallTool("git", args),
434  webSearch: (params) => __a3sCallTool("web_search", params),
435  verify: (args) => __a3sCallTool("bash", args),
436}});
437
438Object.defineProperty(globalThis, "__a3sCtx", {{ value: __a3sCtx, configurable: false }});
439Object.defineProperty(globalThis, "__a3sInputs", {{ value: {inputs_json}, configurable: false }});
440Object.defineProperty(globalThis, "fetch", {{ value: undefined, configurable: false, writable: false }});
441Object.defineProperty(globalThis, "WebSocket", {{ value: undefined, configurable: false, writable: false }});
442Object.defineProperty(globalThis, "Worker", {{ value: undefined, configurable: false, writable: false }});
443"#
444    )
445}
446
447async fn execute_host_tool_json(
448    state: Arc<Mutex<ScriptVmState>>,
449    tool: String,
450    args_json: String,
451) -> rquickjs::Result<String> {
452    let args = serde_json::from_str(&args_json).map_err(|err| {
453        JsError::new_from_js_message("string", "object", format!("invalid tool args JSON: {err}"))
454    })?;
455    let (registry, ctx, max_output_bytes, outer) = {
456        let mut script = state.lock().await;
457        if !script.allowed_tools.contains(&tool) {
458            return Err(JsError::new_from_js_message(
459                "tool",
460                "allowed tool",
461                format!("tool '{tool}' is not allowed for this PTC script"),
462            ));
463        }
464        script.tool_calls += 1;
465        if script.tool_calls > script.max_tool_calls {
466            return Err(JsError::new_from_js_message(
467                "tool call",
468                "limited tool call",
469                format!("PTC script exceeded maxToolCalls={}", script.max_tool_calls),
470            ));
471        }
472        (
473            Arc::clone(&script.registry),
474            script.ctx.clone(),
475            script.max_output_bytes,
476            script.outer.clone(),
477        )
478    };
479
480    // Run the tool on the OUTER multi-threaded runtime (not this nested
481    // single-thread VM runtime) so delegation tools can spawn child agents that
482    // actually run in parallel — `ctx.tool("parallel_task", …)` now fans out.
483    let tool_for_spawn = tool.clone();
484    let result = outer
485        .spawn(async move {
486            registry
487                .execute_with_context(&tool_for_spawn, &args, &ctx)
488                .await
489        })
490        .await
491        .map_err(|err| JsError::new_from_js_message("tool", "spawn", err.to_string()))?
492        .map_err(|err| JsError::new_from_js_message("tool", "result", err.to_string()))?;
493    let mut output = result.output;
494    if output.len() > max_output_bytes {
495        output = truncate_utf8(&output, max_output_bytes).to_string();
496    }
497    let success = result.exit_code == 0;
498    let metadata = result.metadata.clone();
499    let exit_code = result.exit_code;
500    let name = result.name;
501
502    {
503        let mut script = state.lock().await;
504        script.records.push(ScriptCallRecord {
505            tool_name: tool,
506            success,
507            exit_code,
508            output_bytes: output.len(),
509            metadata: metadata.clone(),
510        });
511    }
512
513    serde_json::to_string(&serde_json::json!({
514        "name": name,
515        "output": output,
516        "exitCode": exit_code,
517        "metadata": metadata,
518    }))
519    .map_err(|err| JsError::new_from_js_message("tool result", "json", err.to_string()))
520}
521
522fn is_quickjs_timeout(err: &anyhow::Error) -> bool {
523    let text = err.to_string();
524    text.contains("interrupted") || text.contains("InternalError")
525}
526
527fn script_record_to_value(record: &ScriptCallRecord) -> serde_json::Value {
528    serde_json::json!({
529        "tool_name": record.tool_name,
530        "success": record.success,
531        "exit_code": record.exit_code,
532        "output_bytes": record.output_bytes,
533        "metadata": record.metadata,
534    })
535}
536
537fn render_script_output(
538    result: &serde_json::Value,
539    records: &[ScriptCallRecord],
540    stderr: &str,
541) -> String {
542    let mut output = String::from("Program script completed.");
543    if let Some(summary) = result.get("summary").and_then(|value| value.as_str()) {
544        output.push('\n');
545        output.push_str(summary);
546    }
547
548    output.push_str(&format!("\n\nTool calls: {}", records.len()));
549    for (index, record) in records.iter().enumerate() {
550        output.push_str(&format!(
551            "\n{}. {} ({}, exit_code={}, output_bytes={})",
552            index + 1,
553            record.tool_name,
554            if record.success { "ok" } else { "failed" },
555            record.exit_code,
556            record.output_bytes
557        ));
558    }
559
560    output.push_str("\n\nResult:\n");
561    output.push_str(&serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()));
562
563    if !stderr.is_empty() {
564        output.push_str("\n\nstderr:\n");
565        output.push_str(stderr);
566    }
567
568    output
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use async_trait::async_trait;
575    use std::path::PathBuf;
576
577    struct EchoTool;
578
579    #[async_trait]
580    impl Tool for EchoTool {
581        fn name(&self) -> &str {
582            "echo"
583        }
584
585        fn description(&self) -> &str {
586            "Echo test tool"
587        }
588
589        fn parameters(&self) -> serde_json::Value {
590            serde_json::json!({
591                "type": "object",
592                "properties": {
593                    "message": { "type": "string" }
594                }
595            })
596        }
597
598        async fn execute(
599            &self,
600            args: &serde_json::Value,
601            _ctx: &ToolContext,
602        ) -> Result<ToolOutput> {
603            let message = args
604                .get("message")
605                .and_then(|value| value.as_str())
606                .unwrap_or("");
607            Ok(ToolOutput::success(format!("echo:{message}")))
608        }
609    }
610
611    #[tokio::test]
612    async fn program_tool_rejects_non_script_type() {
613        let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
614        let output = tool
615            .execute(
616                &serde_json::json!({ "type": "program_code_search" }),
617                &ToolContext::new(PathBuf::from("/tmp")),
618            )
619            .await
620            .unwrap();
621
622        assert!(!output.success);
623        assert!(output.content.contains("Only \"script\" is supported"));
624    }
625
626    #[tokio::test]
627    async fn program_tool_rejects_missing_script_source_and_path() {
628        let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
629        let output = tool
630            .execute(
631                &serde_json::json!({ "type": "script" }),
632                &ToolContext::new(PathBuf::from("/tmp")),
633            )
634            .await
635            .unwrap();
636
637        assert!(!output.success);
638        assert!(output.content.contains("requires either source or path"));
639    }
640
641    #[tokio::test]
642    async fn program_tool_rejects_unsupported_language() {
643        let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
644        let output = tool
645            .execute(
646                &serde_json::json!({
647                    "type": "script",
648                    "language": "typescript",
649                    "source": "async function run() { return {}; }"
650                }),
651                &ToolContext::new(PathBuf::from("/tmp")),
652            )
653            .await
654            .unwrap();
655
656        assert!(!output.success);
657        assert!(output.content.contains("Unsupported script language"));
658    }
659
660    #[tokio::test]
661    async fn program_tool_rejects_unsupported_script_path() {
662        let dir = tempfile::tempdir().unwrap();
663        std::fs::write(dir.path().join("script.txt"), "async function run() {}").unwrap();
664        let tool = ProgramTool::new(Arc::new(ToolRegistry::new(dir.path().to_path_buf())));
665        let output = tool
666            .execute(
667                &serde_json::json!({
668                    "type": "script",
669                    "path": "script.txt"
670                }),
671                &ToolContext::new(dir.path().to_path_buf()),
672            )
673            .await
674            .unwrap();
675
676        assert!(!output.success);
677        assert!(output.content.contains(".js or .mjs file"));
678    }
679
680    #[test]
681    fn program_tool_default_allowed_tools_include_registry_tools_except_program() {
682        let registry = ToolRegistry::new(PathBuf::from("/tmp"));
683        registry.register(Arc::new(EchoTool));
684        registry.register_builtin(Arc::new(ProgramTool::new(Arc::new(ToolRegistry::new(
685            PathBuf::from("/tmp"),
686        )))));
687
688        let allowed = script_allowed_tools(&serde_json::json!({}), &registry);
689
690        assert!(allowed.contains("echo"));
691        assert!(!allowed.contains("program"));
692    }
693
694    #[test]
695    fn program_tool_allows_delegation_tools_in_scripts() {
696        // Delegation tools are allowed in PTC scripts again (host tool calls run
697        // on the outer multi-threaded runtime, so they fan out). Only `program`
698        // stays stripped (no nested PTC recursion).
699        let registry = ToolRegistry::new(PathBuf::from("/tmp"));
700        let args = serde_json::json!({
701            "allowed_tools": ["parallel_task", "task", "program", "echo"]
702        });
703        let allowed = script_allowed_tools(&args, &registry);
704        assert!(allowed.contains("parallel_task"));
705        assert!(allowed.contains("task"));
706        assert!(allowed.contains("echo"));
707        assert!(!allowed.contains("program"));
708    }
709
710    #[tokio::test]
711    async fn program_tool_source_uses_default_all_registered_tools() {
712        let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
713        registry.register(Arc::new(EchoTool));
714        let tool = ProgramTool::new(Arc::clone(&registry));
715        let output = tool
716            .execute(
717                &serde_json::json!({
718                    "type": "script",
719                    "source": r#"
720                        async function run(ctx, inputs) {
721                            const result = await ctx.tool("echo", { message: inputs.message });
722                            return { summary: result.output, result };
723                        }
724                    "#,
725                    "inputs": { "message": "hello" }
726                }),
727                &ToolContext::new(PathBuf::from("/tmp")),
728            )
729            .await
730            .unwrap();
731
732        assert!(output.success, "{}", output.content);
733        assert!(output.content.contains("echo:hello"));
734        let metadata = output.metadata.unwrap();
735        assert_eq!(metadata["program"]["runtime"], "embedded-quickjs");
736        assert_eq!(metadata["script_result"]["summary"], "echo:hello");
737    }
738
739    #[tokio::test]
740    async fn program_tool_explicit_allowed_tools_restrict_default_tools() {
741        let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
742        registry.register(Arc::new(EchoTool));
743        let tool = ProgramTool::new(Arc::clone(&registry));
744        let output = tool
745            .execute(
746                &serde_json::json!({
747                    "type": "script",
748                    "source": r#"
749                        async function run(ctx) {
750                            await ctx.tool("echo", { message: "blocked" });
751                            return {};
752                        }
753                    "#,
754                    "allowed_tools": ["read"]
755                }),
756                &ToolContext::new(PathBuf::from("/tmp")),
757            )
758            .await
759            .unwrap();
760
761        assert!(!output.success);
762        assert!(output.content.contains("tool 'echo' is not allowed"));
763    }
764
765    #[tokio::test]
766    async fn program_tool_enforces_max_tool_calls() {
767        let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
768        registry.register(Arc::new(EchoTool));
769        let tool = ProgramTool::new(Arc::clone(&registry));
770        let output = tool
771            .execute(
772                &serde_json::json!({
773                    "type": "script",
774                    "source": r#"
775                        async function run(ctx) {
776                            await ctx.tool("echo", { message: "one" });
777                            await ctx.tool("echo", { message: "two" });
778                            return {};
779                        }
780                    "#,
781                    "limits": { "maxToolCalls": 1 }
782                }),
783                &ToolContext::new(PathBuf::from("/tmp")),
784            )
785            .await
786            .unwrap();
787
788        assert!(!output.success);
789        assert!(output.content.contains("exceeded maxToolCalls=1"));
790    }
791
792    #[test]
793    fn program_tool_rejects_fetch_source_access() {
794        let err =
795            validate_script_source("export default async function run() { return fetch('/'); }")
796                .unwrap_err();
797        assert!(err.contains("fetch is not allowed"));
798    }
799
800    #[test]
801    fn program_tool_accepts_plain_function_run_entrypoint() {
802        let source = script_source_with_host_entrypoint(
803            "async function run(ctx, inputs) { return { summary: inputs.message }; }",
804        )
805        .unwrap();
806
807        assert!(source.contains("globalThis.__a3sResultJson"));
808        assert!(source.contains("async function run"));
809    }
810
811    #[test]
812    fn program_tool_renders_result_summary_and_tool_records() {
813        let output = render_script_output(
814            &serde_json::json!({ "summary": "done", "items": [1] }),
815            &[ScriptCallRecord {
816                tool_name: "echo".to_string(),
817                success: true,
818                exit_code: 0,
819                output_bytes: 8,
820                metadata: Some(serde_json::json!({ "kind": "test" })),
821            }],
822            "",
823        );
824
825        assert!(output.contains("Program script completed."));
826        assert!(output.contains("done"));
827        assert!(output.contains("echo (ok"));
828        assert!(output.contains("\"items\""));
829    }
830}