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::{registry_tool_invoker, ToolInvocation, ToolInvoker, 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 PROGRAM_CANCELLATION_SETTLE_GRACE: Duration = Duration::from_millis(500);
25// Engineered workflows include planner, maker, checker, deterministic evidence
26// gates, recovery, and event projection logic in one auditable script. Keep a
27// firm bound while leaving enough room for explicit contracts and diagnostics.
28pub const MAX_PROGRAM_SCRIPT_SOURCE_BYTES: usize = 192 * 1024;
29
30pub struct ProgramTool {
31    fallback_invoker: Arc<dyn ToolInvoker>,
32}
33
34impl ProgramTool {
35    pub fn new(registry: Arc<ToolRegistry>) -> Self {
36        Self {
37            fallback_invoker: registry_tool_invoker(registry),
38        }
39    }
40
41    pub fn with_catalog(registry: Arc<ToolRegistry>, _catalog: ProgramCatalog) -> Self {
42        Self::new(registry)
43    }
44}
45
46#[async_trait]
47impl Tool for ProgramTool {
48    fn name(&self) -> &str {
49        "program"
50    }
51
52    fn description(&self) -> &str {
53        "Run a sandboxed JavaScript PTC script. The script defines async function run(ctx, inputs) and may call only allowed ctx tools."
54    }
55
56    fn parameters(&self) -> serde_json::Value {
57        serde_json::json!({
58            "type": "object",
59            "additionalProperties": false,
60            "properties": {
61                "type": {
62                    "type": "string",
63                    "description": "Required. Program kind. Only \"script\" is supported.",
64                    "enum": ["script"]
65                },
66                "inputs": {
67                    "type": "object",
68                    "description": "Optional. JSON inputs passed to the script as the second argument."
69                },
70                "language": {
71                    "type": "string",
72                    "description": "Script language. Only JavaScript is supported.",
73                    "enum": ["javascript"]
74                },
75                "source": {
76                    "type": "string",
77                    "description": "Inline JavaScript source defining async function run(ctx, inputs)."
78                },
79                "path": {
80                    "type": "string",
81                    "description": "Workspace-relative path to a .js or .mjs script defining async function run(ctx, inputs). Used when source is omitted."
82                },
83                "allowed_tools": {
84                    "type": "array",
85                    "description": "Tool names the script may call through ctx. Defaults to all registered tools except program.",
86                    "items": { "type": "string" }
87                },
88                "limits": {
89                    "type": "object",
90                    "description": "Optional timeoutMs, maxToolCalls, and maxOutputBytes.",
91                    "additionalProperties": false,
92                    "properties": {
93                        "timeoutMs": { "type": "integer", "minimum": 1 },
94                        "maxToolCalls": { "type": "integer", "minimum": 1 },
95                        "maxOutputBytes": { "type": "integer", "minimum": 1 }
96                    }
97                }
98            },
99            "required": ["type"]
100        })
101    }
102
103    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
104        let Some(kind) = args.get("type").and_then(|value| value.as_str()) else {
105            return Ok(ToolOutput::error("type parameter is required"));
106        };
107        if kind != "script" {
108            return Ok(ToolOutput::error(format!(
109                "Unsupported program type: {kind}. Only \"script\" is supported."
110            )));
111        }
112        let inputs = args
113            .get("inputs")
114            .cloned()
115            .unwrap_or_else(|| serde_json::json!({}));
116
117        let invoker = ctx
118            .tool_invoker()
119            .unwrap_or_else(|| Arc::clone(&self.fallback_invoker));
120        execute_script_program(args, inputs, invoker, ctx).await
121    }
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(rename_all = "camelCase")]
126struct ScriptLimits {
127    timeout_ms: Option<u64>,
128    max_tool_calls: Option<usize>,
129    max_output_bytes: Option<usize>,
130}
131
132#[derive(Debug, Clone)]
133struct ScriptCallRecord {
134    tool_name: String,
135    success: bool,
136    exit_code: i32,
137    output_bytes: usize,
138    metadata: Option<serde_json::Value>,
139}
140
141async fn execute_script_program(
142    args: &serde_json::Value,
143    inputs: serde_json::Value,
144    invoker: Arc<dyn ToolInvoker>,
145    ctx: &ToolContext,
146) -> Result<ToolOutput> {
147    let language = args
148        .get("language")
149        .and_then(|value| value.as_str())
150        .unwrap_or("javascript");
151    if language != "javascript" {
152        return Ok(ToolOutput::error(format!(
153            "Unsupported script language: {language}"
154        )));
155    }
156
157    let source = match load_script_source(args, ctx).await {
158        Ok(source) => source,
159        Err(message) => return Ok(ToolOutput::error(message)),
160    };
161    if source.len() > MAX_PROGRAM_SCRIPT_SOURCE_BYTES {
162        return Ok(ToolOutput::error(format!(
163            "script source is too large: {} bytes exceeds {} bytes",
164            source.len(),
165            MAX_PROGRAM_SCRIPT_SOURCE_BYTES
166        )));
167    }
168    if let Err(message) = validate_script_source(&source) {
169        return Ok(ToolOutput::error(message));
170    }
171
172    let allowed_tools = script_allowed_tools(args, invoker.available_tools());
173    let limits = script_limits(args);
174    match run_quickjs_script(&source, inputs, invoker, ctx.clone(), allowed_tools, limits).await {
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, available_tools: Vec<String>) -> 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(|| available_tools.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    invoker: Arc<dyn ToolInvoker>,
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    let parent_cancellation = ctx.cancellation_token();
292    let program_cancellation = parent_cancellation.child_token();
293    // Captured on the outer multi-threaded runtime (we're async here, before the
294    // VM's nested single-thread runtime is built) so host tools run on the
295    // session runtime instead of being trapped inside the QuickJS VM runtime.
296    let outer = tokio::runtime::Handle::current();
297    let state = Arc::new(Mutex::new(ScriptVmState {
298        invoker,
299        ctx: ctx.with_cancellation(program_cancellation.clone()),
300        allowed_tools,
301        max_tool_calls,
302        max_output_bytes,
303        tool_calls: 0,
304        records: Vec::new(),
305        outer,
306    }));
307
308    let vm_state = Arc::clone(&state);
309    let mut vm = 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            program_cancellation,
320        ))
321    });
322
323    enum Stop {
324        Cancelled,
325        TimedOut,
326    }
327    let result = tokio::select! {
328        biased;
329        _ = parent_cancellation.cancelled() => None,
330        result = &mut vm => Some(result),
331        _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => None,
332    };
333    let stop = if result.is_none() {
334        if parent_cancellation.is_cancelled() {
335            Some(Stop::Cancelled)
336        } else {
337            Some(Stop::TimedOut)
338        }
339    } else {
340        None
341    };
342
343    if let Some(stop) = stop {
344        // The child token is already cancelled when the parent stopped. On an
345        // internal deadline, cancel it explicitly so every nested invocation
346        // receives the same terminal signal as the VM.
347        state.lock().await.ctx.cancellation_token().cancel();
348        if timeout(PROGRAM_CANCELLATION_SETTLE_GRACE, &mut vm)
349            .await
350            .is_err()
351        {
352            vm.abort();
353            let _ = vm.await;
354        }
355        return Ok(ToolOutput::error(match stop {
356            Stop::Cancelled => "program script cancelled by caller".to_string(),
357            Stop::TimedOut => format!("program script timed out after {timeout_ms} ms"),
358        }));
359    }
360
361    let result = result.expect("completed VM result is present");
362
363    match result {
364        Ok(Ok(result)) => {
365            let records = state.lock().await.records.clone();
366            let output = render_script_output(&result, &records, "");
367            Ok(ToolOutput::success(output).with_metadata(serde_json::json!({
368                "program": {
369                    "name": "script",
370                    "language": "javascript",
371                    "runtime": "embedded-quickjs",
372                    "success": true,
373                    "tool_calls": records.iter().map(script_record_to_value).collect::<Vec<_>>(),
374                },
375                "script_result": result,
376            })))
377        }
378        Ok(Err(err)) if is_quickjs_timeout(&err) => Ok(ToolOutput::error(format!(
379            "program script timed out after {timeout_ms} ms"
380        ))),
381        Ok(Err(err)) => Ok(ToolOutput::error(format!("program script error:\n{err}"))),
382        Err(err) => Ok(ToolOutput::error(format!(
383            "program VM thread failed: {err}"
384        ))),
385    }
386}
387
388fn script_source_with_host_entrypoint(source: &str) -> Result<String> {
389    let rewritten = if source.contains("export default async function run") {
390        source.replacen("export default async function run", "async function run", 1)
391    } else if source.contains("export default function run") {
392        source.replacen("export default function run", "function run", 1)
393    } else if source.contains("async function run") || source.contains("function run") {
394        source.to_string()
395    } else {
396        return Err(anyhow!(
397            "PTC script must define async function run(ctx, inputs)"
398        ));
399    };
400
401    Ok(format!(
402        r#"{rewritten}
403
404globalThis.__a3sResultJson = (async () => JSON.stringify(await run(globalThis.__a3sCtx, globalThis.__a3sInputs)))();
405"#
406    ))
407}
408
409async fn run_embedded_script(
410    source: String,
411    inputs: serde_json::Value,
412    state: Arc<Mutex<ScriptVmState>>,
413    timeout_ms: u64,
414    cancellation: tokio_util::sync::CancellationToken,
415) -> Result<serde_json::Value> {
416    let runtime = AsyncRuntime::new()?;
417    let started = Instant::now();
418    runtime
419        .set_interrupt_handler(Some(Box::new(move || {
420            cancellation.is_cancelled() || started.elapsed() >= Duration::from_millis(timeout_ms)
421        })))
422        .await;
423    runtime.set_memory_limit(64 * 1024 * 1024).await;
424    runtime.set_max_stack_size(512 * 1024).await;
425
426    let context = AsyncContext::full(&runtime).await?;
427    let inputs_json = serde_json::to_string(&inputs)?;
428    let script = format!("{}\n{}", embedded_script_bootstrap(&inputs_json), source);
429    let result_json = async_with!(context => |ctx| {
430        let state = Arc::clone(&state);
431        let host_tool = move |tool: String, args_json: String| {
432            let state = Arc::clone(&state);
433            async move { execute_host_tool_json(state, tool, args_json).await }
434        };
435        if let Err(err) = ctx.globals().set("__a3sHostTool", Func::from(Async(host_tool))) {
436            return Err(format!("failed to install program host tool: {err}"));
437        }
438        let promise: Promise = match ctx.eval(script) {
439            Ok(promise) => promise,
440            Err(err) => return Err(format!("failed to evaluate program script: {err}")),
441        };
442        promise
443            .into_future::<String>()
444            .await
445            .catch(&ctx)
446            .map_err(|err| err.to_string())
447    })
448    .await
449    .map_err(anyhow::Error::msg)?;
450
451    serde_json::from_str(&result_json)
452        .map_err(|err| anyhow!("program script returned invalid JSON: {err}"))
453}
454
455struct ScriptVmState {
456    invoker: Arc<dyn ToolInvoker>,
457    ctx: ToolContext,
458    allowed_tools: HashSet<String>,
459    max_tool_calls: usize,
460    max_output_bytes: usize,
461    tool_calls: usize,
462    records: Vec<ScriptCallRecord>,
463    /// Handle to the OUTER multi-threaded session runtime. The script VM runs on
464    /// a nested single-thread runtime; host tool calls are dispatched here so
465    /// delegated `task` runs are not trapped inside the VM runtime.
466    outer: tokio::runtime::Handle,
467}
468
469fn embedded_script_bootstrap(inputs_json: &str) -> String {
470    format!(
471        r#"
472const __a3sCallTool = async (tool, args = {{}}) => {{
473  const response = await globalThis.__a3sHostTool(String(tool), JSON.stringify(args ?? {{}}));
474  return JSON.parse(response);
475}};
476
477const __a3sTools = Object.freeze(new Proxy({{}}, {{
478  get(_target, prop) {{
479    if (typeof prop !== "string" || prop === "then") return undefined;
480    return (args = {{}}) => __a3sCallTool(prop, args);
481  }},
482  has(_target, prop) {{
483    return typeof prop === "string";
484  }},
485}}));
486
487const __a3sReadArgs = (path, options = {{}}) => ({{ ...(options ?? {{}}), file_path: path }});
488const __a3sCtx = Object.freeze({{
489  tool: __a3sCallTool,
490  tools: __a3sTools,
491  readFile: (path, options = {{}}) => __a3sCallTool("read", __a3sReadArgs(path, options)).then((r) => r.output),
492  read: (path, options = {{}}) => __a3sCallTool("read", __a3sReadArgs(path, options)),
493  grep: (pattern, options = {{}}) => __a3sCallTool("grep", {{ pattern, ...options }}).then((r) => r.output),
494  glob: (pattern, options = {{}}) => __a3sCallTool("glob", {{ pattern, ...options }}).then((r) => r.output),
495  ls: (path = ".") => __a3sCallTool("ls", {{ path }}).then((r) => r.output),
496  bash: (command) => __a3sCallTool("bash", {{ command }}).then((r) => r.output),
497  git: (args = {{}}) => __a3sCallTool("git", args),
498  webSearch: (params) => __a3sCallTool("web_search", params),
499  verify: (args) => __a3sCallTool("bash", args),
500}});
501
502Object.defineProperty(globalThis, "__a3sCtx", {{ value: __a3sCtx, configurable: false }});
503Object.defineProperty(globalThis, "__a3sInputs", {{ value: {inputs_json}, configurable: false }});
504Object.defineProperty(globalThis, "fetch", {{ value: undefined, configurable: false, writable: false }});
505Object.defineProperty(globalThis, "WebSocket", {{ value: undefined, configurable: false, writable: false }});
506Object.defineProperty(globalThis, "Worker", {{ value: undefined, configurable: false, writable: false }});
507"#
508    )
509}
510
511async fn execute_host_tool_json(
512    state: Arc<Mutex<ScriptVmState>>,
513    tool: String,
514    args_json: String,
515) -> rquickjs::Result<String> {
516    let args = serde_json::from_str(&args_json).map_err(|err| {
517        JsError::new_from_js_message("string", "object", format!("invalid tool args JSON: {err}"))
518    })?;
519    let (invoker, ctx, max_output_bytes, outer) = {
520        let mut script = state.lock().await;
521        if !script.allowed_tools.contains(&tool) {
522            return Err(JsError::new_from_js_message(
523                "tool",
524                "allowed tool",
525                format!("tool '{tool}' is not allowed for this PTC script"),
526            ));
527        }
528        script.tool_calls += 1;
529        if script.tool_calls > script.max_tool_calls {
530            return Err(JsError::new_from_js_message(
531                "tool call",
532                "limited tool call",
533                format!("PTC script exceeded maxToolCalls={}", script.max_tool_calls),
534            ));
535        }
536        (
537            Arc::clone(&script.invoker),
538            script.ctx.clone(),
539            script.max_output_bytes,
540            script.outer.clone(),
541        )
542    };
543
544    // Run the tool on the OUTER multi-threaded runtime (not this nested
545    // single-thread VM runtime) so host tools can use the session runtime
546    // normally. `parallel_task` is intentionally filtered before this point.
547    let tool_for_spawn = tool.clone();
548    let result = outer
549        .spawn(async move {
550            invoker
551                .invoke(ToolInvocation::nested(tool_for_spawn, args), &ctx)
552                .await
553        })
554        .await
555        .map_err(|err| JsError::new_from_js_message("tool", "spawn", err.to_string()))?;
556    let mut output = result.output;
557    if output.len() > max_output_bytes {
558        output = truncate_utf8(&output, max_output_bytes).to_string();
559    }
560    let success = result.exit_code == 0;
561    let metadata = result.metadata.clone();
562    let exit_code = result.exit_code;
563    let name = result.name;
564
565    {
566        let mut script = state.lock().await;
567        script.records.push(ScriptCallRecord {
568            tool_name: tool,
569            success,
570            exit_code,
571            output_bytes: output.len(),
572            metadata: metadata.clone(),
573        });
574    }
575
576    serde_json::to_string(&serde_json::json!({
577        "name": name,
578        "output": output,
579        "exitCode": exit_code,
580        "metadata": metadata,
581    }))
582    .map_err(|err| JsError::new_from_js_message("tool result", "json", err.to_string()))
583}
584
585fn is_quickjs_timeout(err: &anyhow::Error) -> bool {
586    let text = err.to_string();
587    text.contains("interrupted") || text.contains("InternalError")
588}
589
590fn script_record_to_value(record: &ScriptCallRecord) -> serde_json::Value {
591    serde_json::json!({
592        "tool_name": record.tool_name,
593        "success": record.success,
594        "exit_code": record.exit_code,
595        "output_bytes": record.output_bytes,
596        "metadata": record.metadata,
597    })
598}
599
600fn render_script_output(
601    result: &serde_json::Value,
602    records: &[ScriptCallRecord],
603    stderr: &str,
604) -> String {
605    let mut output = String::from("Program script completed.");
606    if let Some(summary) = result.get("summary").and_then(|value| value.as_str()) {
607        output.push('\n');
608        output.push_str(summary);
609    }
610
611    output.push_str(&format!("\n\nTool calls: {}", records.len()));
612    for (index, record) in records.iter().enumerate() {
613        output.push_str(&format!(
614            "\n{}. {} ({}, exit_code={}, output_bytes={})",
615            index + 1,
616            record.tool_name,
617            if record.success { "ok" } else { "failed" },
618            record.exit_code,
619            record.output_bytes
620        ));
621    }
622
623    output.push_str("\n\nResult:\n");
624    output.push_str(&serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()));
625
626    if !stderr.is_empty() {
627        output.push_str("\n\nstderr:\n");
628        output.push_str(stderr);
629    }
630
631    output
632}
633
634#[cfg(test)]
635#[path = "program_tool/tests.rs"]
636mod tests;