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