1use 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;
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);
25const MAX_SCRIPT_SOURCE_BYTES: usize = 128 * 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_SCRIPT_SOURCE_BYTES {
162 return Ok(ToolOutput::error(format!(
163 "script source is too large: {} bytes exceeds {} bytes",
164 source.len(),
165 MAX_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("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 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 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 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 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 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;