1use crate::program::ProgramCatalog;
4use crate::text::truncate_utf8;
5use crate::tools::types::{Tool, ToolContext, ToolOutput};
6use crate::tools::{registry_tool_invoker, 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;
19const 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);
25pub 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, dynamic_workflow, and the legacy parallel_task alias.",
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 allowed.remove("dynamic_workflow");
224 allowed.remove("parallel_task");
225 allowed
226}
227
228fn script_limits(args: &serde_json::Value) -> ScriptLimits {
229 args.get("limits")
230 .cloned()
231 .and_then(|value| serde_json::from_value(value).ok())
232 .unwrap_or(ScriptLimits {
233 timeout_ms: None,
234 max_tool_calls: None,
235 max_output_bytes: None,
236 })
237}
238
239fn validate_script_source(source: &str) -> std::result::Result<(), String> {
240 let forbidden = [
241 ("import ", "imports are not allowed inside PTC scripts"),
242 (
243 "import(",
244 "dynamic imports are not allowed inside PTC scripts",
245 ),
246 ("eval(", "eval is not allowed inside PTC scripts"),
247 (
248 "Function(",
249 "Function constructor is not allowed inside PTC scripts",
250 ),
251 ("Worker(", "Worker is not allowed inside PTC scripts"),
252 ("WebSocket", "WebSocket is not allowed inside PTC scripts"),
253 (
254 "fetch(",
255 "fetch is not allowed inside PTC scripts; use ctx tools instead",
256 ),
257 ];
258
259 for (needle, message) in forbidden {
260 if source.contains(needle) {
261 return Err(message.to_string());
262 }
263 }
264 Ok(())
265}
266
267async fn run_quickjs_script(
268 source: &str,
269 inputs: serde_json::Value,
270 invoker: Arc<dyn ToolInvoker>,
271 ctx: ToolContext,
272 allowed_tools: HashSet<String>,
273 limits: ScriptLimits,
274) -> Result<ToolOutput> {
275 let delegating = allowed_tools.contains("task");
281 let timeout_ms = limits.timeout_ms.unwrap_or(if delegating {
282 DELEGATION_SCRIPT_TIMEOUT_MS
283 } else {
284 DEFAULT_SCRIPT_TIMEOUT_MS
285 });
286 let max_tool_calls = limits
287 .max_tool_calls
288 .unwrap_or(DEFAULT_SCRIPT_MAX_TOOL_CALLS);
289 let max_output_bytes = limits
290 .max_output_bytes
291 .unwrap_or(DEFAULT_SCRIPT_MAX_OUTPUT_BYTES);
292 let executable_source = script_source_with_host_entrypoint(source)?;
293 let parent_cancellation = ctx.cancellation_token();
294 let program_cancellation = parent_cancellation.child_token();
295 let outer = tokio::runtime::Handle::current();
299 let state = Arc::new(Mutex::new(ScriptVmState {
300 invoker,
301 ctx: ctx.with_cancellation(program_cancellation.clone()),
302 allowed_tools,
303 max_tool_calls,
304 max_output_bytes,
305 tool_calls: 0,
306 records: Vec::new(),
307 outer,
308 }));
309
310 let vm_state = Arc::clone(&state);
311 let mut vm = tokio::task::spawn_blocking(move || {
312 let runtime = tokio::runtime::Builder::new_current_thread()
313 .enable_all()
314 .build()
315 .map_err(|err| anyhow!("failed to create program VM runtime: {err}"))?;
316 runtime.block_on(run_embedded_script(
317 executable_source,
318 inputs,
319 vm_state,
320 timeout_ms,
321 program_cancellation,
322 ))
323 });
324
325 enum Stop {
326 Cancelled,
327 TimedOut,
328 }
329 let result = tokio::select! {
330 biased;
331 _ = parent_cancellation.cancelled() => None,
332 result = &mut vm => Some(result),
333 _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => None,
334 };
335 let stop = if result.is_none() {
336 if parent_cancellation.is_cancelled() {
337 Some(Stop::Cancelled)
338 } else {
339 Some(Stop::TimedOut)
340 }
341 } else {
342 None
343 };
344
345 if let Some(stop) = stop {
346 state.lock().await.ctx.cancellation_token().cancel();
350 if timeout(PROGRAM_CANCELLATION_SETTLE_GRACE, &mut vm)
351 .await
352 .is_err()
353 {
354 vm.abort();
355 let _ = vm.await;
356 }
357 return Ok(ToolOutput::error(match stop {
358 Stop::Cancelled => "program script cancelled by caller".to_string(),
359 Stop::TimedOut => format!("program script timed out after {timeout_ms} ms"),
360 }));
361 }
362
363 let result = result.expect("completed VM result is present");
364
365 match result {
366 Ok(Ok(result)) => {
367 let records = state.lock().await.records.clone();
368 let output = render_script_output(&result, &records, "");
369 Ok(ToolOutput::success(output).with_metadata(serde_json::json!({
370 "program": {
371 "name": "script",
372 "language": "javascript",
373 "runtime": "embedded-quickjs",
374 "success": true,
375 "tool_calls": records.iter().map(script_record_to_value).collect::<Vec<_>>(),
376 },
377 "script_result": result,
378 })))
379 }
380 Ok(Err(err)) if is_quickjs_timeout(&err) => Ok(ToolOutput::error(format!(
381 "program script timed out after {timeout_ms} ms"
382 ))),
383 Ok(Err(err)) => Ok(ToolOutput::error(format!("program script error:\n{err}"))),
384 Err(err) => Ok(ToolOutput::error(format!(
385 "program VM thread failed: {err}"
386 ))),
387 }
388}
389
390fn script_source_with_host_entrypoint(source: &str) -> Result<String> {
391 let rewritten = if source.contains("export default async function run") {
392 source.replacen("export default async function run", "async function run", 1)
393 } else if source.contains("export default function run") {
394 source.replacen("export default function run", "function run", 1)
395 } else if source.contains("async function run") || source.contains("function run") {
396 source.to_string()
397 } else {
398 return Err(anyhow!(
399 "PTC script must define async function run(ctx, inputs)"
400 ));
401 };
402
403 Ok(format!(
404 r#"{rewritten}
405
406globalThis.__a3sResultJson = (async () => JSON.stringify(await run(globalThis.__a3sCtx, globalThis.__a3sInputs)))();
407"#
408 ))
409}
410
411async fn run_embedded_script(
412 source: String,
413 inputs: serde_json::Value,
414 state: Arc<Mutex<ScriptVmState>>,
415 timeout_ms: u64,
416 cancellation: tokio_util::sync::CancellationToken,
417) -> Result<serde_json::Value> {
418 let runtime = AsyncRuntime::new()?;
419 let started = Instant::now();
420 runtime
421 .set_interrupt_handler(Some(Box::new(move || {
422 cancellation.is_cancelled() || started.elapsed() >= Duration::from_millis(timeout_ms)
423 })))
424 .await;
425 runtime.set_memory_limit(64 * 1024 * 1024).await;
426 runtime.set_max_stack_size(512 * 1024).await;
427
428 let context = AsyncContext::full(&runtime).await?;
429 let inputs_json = serde_json::to_string(&inputs)?;
430 let script = format!("{}\n{}", embedded_script_bootstrap(&inputs_json), source);
431 let result_json = async_with!(context => |ctx| {
432 let state = Arc::clone(&state);
433 let host_tool = move |tool: String, args_json: String| {
434 let state = Arc::clone(&state);
435 async move { execute_host_tool_json(state, tool, args_json).await }
436 };
437 if let Err(err) = ctx.globals().set("__a3sHostTool", Func::from(Async(host_tool))) {
438 return Err(format!("failed to install program host tool: {err}"));
439 }
440 let promise: Promise = match ctx.eval(script) {
441 Ok(promise) => promise,
442 Err(err) => return Err(format!("failed to evaluate program script: {err}")),
443 };
444 promise
445 .into_future::<String>()
446 .await
447 .catch(&ctx)
448 .map_err(|err| err.to_string())
449 })
450 .await
451 .map_err(anyhow::Error::msg)?;
452
453 serde_json::from_str(&result_json)
454 .map_err(|err| anyhow!("program script returned invalid JSON: {err}"))
455}
456
457struct ScriptVmState {
458 invoker: Arc<dyn ToolInvoker>,
459 ctx: ToolContext,
460 allowed_tools: HashSet<String>,
461 max_tool_calls: usize,
462 max_output_bytes: usize,
463 tool_calls: usize,
464 records: Vec<ScriptCallRecord>,
465 outer: tokio::runtime::Handle,
469}
470
471fn embedded_script_bootstrap(inputs_json: &str) -> String {
472 format!(
473 r#"
474const __a3sCallTool = async (tool, args = {{}}) => {{
475 const response = await globalThis.__a3sHostTool(String(tool), JSON.stringify(args ?? {{}}));
476 return JSON.parse(response);
477}};
478
479const __a3sTools = Object.freeze(new Proxy({{}}, {{
480 get(_target, prop) {{
481 if (typeof prop !== "string" || prop === "then") return undefined;
482 return (args = {{}}) => __a3sCallTool(prop, args);
483 }},
484 has(_target, prop) {{
485 return typeof prop === "string";
486 }},
487}}));
488
489const __a3sReadArgs = (path, options = {{}}) => ({{ ...(options ?? {{}}), file_path: path }});
490const __a3sLegacySearchArgs = (mode, query, options = {{}}) => {{
491 const args = {{ ...(options ?? {{}}), mode, query }};
492 if (Object.prototype.hasOwnProperty.call(args, "glob")) {{
493 args.include = args.glob;
494 delete args.glob;
495 }}
496 if (Object.prototype.hasOwnProperty.call(args, "-i")) {{
497 args.case_sensitive = !args["-i"];
498 delete args["-i"];
499 }}
500 return args;
501}};
502const __a3sCtx = Object.freeze({{
503 tool: __a3sCallTool,
504 tools: __a3sTools,
505 readFile: (path, options = {{}}) => __a3sCallTool("read", __a3sReadArgs(path, options)).then((r) => r.output),
506 read: (path, options = {{}}) => __a3sCallTool("read", __a3sReadArgs(path, options)),
507 search: (query, options = {{}}) => __a3sCallTool("search", {{ ...options, query }}).then((r) => r.output),
508 grep: (query, options = {{}}) => __a3sCallTool("search", __a3sLegacySearchArgs("grep", query, options)).then((r) => r.output),
509 bm25: (query, options = {{}}) => __a3sCallTool("search", __a3sLegacySearchArgs("bm25", query, options)).then((r) => r.output),
510 glob: (query, options = {{}}) => __a3sCallTool("search", {{ ...options, mode: "glob", query }}).then((r) => r.output),
511 ls: (path = ".") => __a3sCallTool("ls", {{ path }}).then((r) => r.output),
512 bash: (command) => __a3sCallTool("bash", {{ command }}).then((r) => r.output),
513 git: (args = {{}}) => __a3sCallTool("git", args),
514 webSearch: (params) => __a3sCallTool("web_search", params),
515 verify: (args) => __a3sCallTool("bash", args),
516}});
517
518Object.defineProperty(globalThis, "__a3sCtx", {{ value: __a3sCtx, configurable: false }});
519Object.defineProperty(globalThis, "__a3sInputs", {{ value: {inputs_json}, configurable: false }});
520Object.defineProperty(globalThis, "fetch", {{ value: undefined, configurable: false, writable: false }});
521Object.defineProperty(globalThis, "WebSocket", {{ value: undefined, configurable: false, writable: false }});
522Object.defineProperty(globalThis, "Worker", {{ value: undefined, configurable: false, writable: false }});
523"#
524 )
525}
526
527async fn execute_host_tool_json(
528 state: Arc<Mutex<ScriptVmState>>,
529 tool: String,
530 args_json: String,
531) -> rquickjs::Result<String> {
532 let args = serde_json::from_str(&args_json).map_err(|err| {
533 JsError::new_from_js_message("string", "object", format!("invalid tool args JSON: {err}"))
534 })?;
535 let (invoker, ctx, max_output_bytes, outer) = {
536 let mut script = state.lock().await;
537 if !script_tool_is_allowed(&script.allowed_tools, &tool, &args) {
538 return Err(JsError::new_from_js_message(
539 "tool",
540 "allowed tool",
541 format!("tool '{tool}' is not allowed for this PTC script"),
542 ));
543 }
544 script.tool_calls += 1;
545 if script.tool_calls > script.max_tool_calls {
546 return Err(JsError::new_from_js_message(
547 "tool call",
548 "limited tool call",
549 format!("PTC script exceeded maxToolCalls={}", script.max_tool_calls),
550 ));
551 }
552 (
553 Arc::clone(&script.invoker),
554 script.ctx.clone(),
555 script.max_output_bytes,
556 script.outer.clone(),
557 )
558 };
559
560 let tool_for_spawn = tool.clone();
564 let result = outer
565 .spawn(async move {
566 invoker
567 .invoke(ctx.nested_tool_invocation(tool_for_spawn, args), &ctx)
568 .await
569 })
570 .await
571 .map_err(|err| JsError::new_from_js_message("tool", "spawn", err.to_string()))?;
572 let mut output = result.output;
573 if output.len() > max_output_bytes {
574 output = truncate_utf8(&output, max_output_bytes).to_string();
575 }
576 let success = result.exit_code == 0;
577 let metadata = result.metadata.clone();
578 let exit_code = result.exit_code;
579 let name = result.name;
580
581 {
582 let mut script = state.lock().await;
583 script.records.push(ScriptCallRecord {
584 tool_name: tool,
585 success,
586 exit_code,
587 output_bytes: output.len(),
588 metadata: metadata.clone(),
589 });
590 }
591
592 serde_json::to_string(&serde_json::json!({
593 "name": name,
594 "output": output,
595 "exitCode": exit_code,
596 "metadata": metadata,
597 }))
598 .map_err(|err| JsError::new_from_js_message("tool result", "json", err.to_string()))
599}
600
601fn script_tool_is_allowed(
602 allowed_tools: &HashSet<String>,
603 tool: &str,
604 args: &serde_json::Value,
605) -> bool {
606 if tool == "task"
607 && args
608 .get("tasks")
609 .and_then(serde_json::Value::as_array)
610 .is_some_and(|tasks| tasks.len() > 1)
611 {
612 return false;
613 }
614 if allowed_tools.contains(tool) {
615 return true;
616 }
617 tool == "search"
618 && args
619 .get("mode")
620 .and_then(serde_json::Value::as_str)
621 .is_some_and(|mode| allowed_tools.contains(mode))
622}
623
624fn is_quickjs_timeout(err: &anyhow::Error) -> bool {
625 let text = err.to_string();
626 text.contains("interrupted") || text.contains("InternalError")
627}
628
629fn script_record_to_value(record: &ScriptCallRecord) -> serde_json::Value {
630 serde_json::json!({
631 "tool_name": record.tool_name,
632 "success": record.success,
633 "exit_code": record.exit_code,
634 "output_bytes": record.output_bytes,
635 "metadata": record.metadata,
636 })
637}
638
639fn render_script_output(
640 result: &serde_json::Value,
641 records: &[ScriptCallRecord],
642 stderr: &str,
643) -> String {
644 let mut output = String::from("Program script completed.");
645 if let Some(summary) = result.get("summary").and_then(|value| value.as_str()) {
646 output.push('\n');
647 output.push_str(summary);
648 }
649
650 output.push_str(&format!("\n\nTool calls: {}", records.len()));
651 for (index, record) in records.iter().enumerate() {
652 output.push_str(&format!(
653 "\n{}. {} ({}, exit_code={}, output_bytes={})",
654 index + 1,
655 record.tool_name,
656 if record.success { "ok" } else { "failed" },
657 record.exit_code,
658 record.output_bytes
659 ));
660 }
661
662 output.push_str("\n\nResult:\n");
663 output.push_str(&serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()));
664
665 if !stderr.is_empty() {
666 output.push_str("\n\nstderr:\n");
667 output.push_str(stderr);
668 }
669
670 output
671}
672
673#[cfg(test)]
674#[path = "program_tool/tests.rs"]
675mod tests;