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_bound_tool_invoker, 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;
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    pub(crate) fn with_catalog_registry_bound(
46        registry: Arc<ToolRegistry>,
47        _catalog: ProgramCatalog,
48    ) -> Self {
49        Self {
50            fallback_invoker: registry_bound_tool_invoker(registry),
51        }
52    }
53}
54
55#[async_trait]
56impl Tool for ProgramTool {
57    fn name(&self) -> &str {
58        "program"
59    }
60
61    fn description(&self) -> &str {
62        "Run a sandboxed JavaScript PTC script. The script defines async function run(ctx, inputs) and may call only allowed ctx tools."
63    }
64
65    fn parameters(&self) -> serde_json::Value {
66        serde_json::json!({
67            "type": "object",
68            "additionalProperties": false,
69            "properties": {
70                "type": {
71                    "type": "string",
72                    "description": "Required. Program kind. Only \"script\" is supported.",
73                    "enum": ["script"]
74                },
75                "inputs": {
76                    "type": "object",
77                    "description": "Optional. JSON inputs passed to the script as the second argument."
78                },
79                "language": {
80                    "type": "string",
81                    "description": "Script language. Only JavaScript is supported.",
82                    "enum": ["javascript"]
83                },
84                "source": {
85                    "type": "string",
86                    "description": "Inline JavaScript source defining async function run(ctx, inputs)."
87                },
88                "path": {
89                    "type": "string",
90                    "description": "Workspace-relative path to a .js or .mjs script defining async function run(ctx, inputs). Used when source is omitted."
91                },
92                "allowed_tools": {
93                    "type": "array",
94                    "description": "Tool names the script may call through ctx. Defaults to all registered tools except program, dynamic_workflow, and the legacy parallel_task alias.",
95                    "items": { "type": "string" }
96                },
97                "limits": {
98                    "type": "object",
99                    "description": "Optional timeoutMs, maxToolCalls, and maxOutputBytes.",
100                    "additionalProperties": false,
101                    "properties": {
102                        "timeoutMs": { "type": "integer", "minimum": 1 },
103                        "maxToolCalls": { "type": "integer", "minimum": 1 },
104                        "maxOutputBytes": { "type": "integer", "minimum": 1 }
105                    }
106                }
107            },
108            "required": ["type"]
109        })
110    }
111
112    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
113        let Some(kind) = args.get("type").and_then(|value| value.as_str()) else {
114            return Ok(ToolOutput::error("type parameter is required"));
115        };
116        if kind != "script" {
117            return Ok(ToolOutput::error(format!(
118                "Unsupported program type: {kind}. Only \"script\" is supported."
119            )));
120        }
121        let inputs = args
122            .get("inputs")
123            .cloned()
124            .unwrap_or_else(|| serde_json::json!({}));
125
126        let invoker = ctx
127            .tool_invoker()
128            .unwrap_or_else(|| Arc::clone(&self.fallback_invoker));
129        execute_script_program(args, inputs, invoker, ctx).await
130    }
131}
132
133#[derive(Debug, Deserialize)]
134#[serde(rename_all = "camelCase")]
135struct ScriptLimits {
136    timeout_ms: Option<u64>,
137    max_tool_calls: Option<usize>,
138    max_output_bytes: Option<usize>,
139}
140
141#[derive(Debug, Clone)]
142struct ScriptCallRecord {
143    tool_name: String,
144    success: bool,
145    exit_code: i32,
146    output_bytes: usize,
147    metadata: Option<serde_json::Value>,
148}
149
150async fn execute_script_program(
151    args: &serde_json::Value,
152    inputs: serde_json::Value,
153    invoker: Arc<dyn ToolInvoker>,
154    ctx: &ToolContext,
155) -> Result<ToolOutput> {
156    let language = args
157        .get("language")
158        .and_then(|value| value.as_str())
159        .unwrap_or("javascript");
160    if language != "javascript" {
161        return Ok(ToolOutput::error(format!(
162            "Unsupported script language: {language}"
163        )));
164    }
165
166    let source = match load_script_source(args, ctx).await {
167        Ok(source) => source,
168        Err(message) => return Ok(ToolOutput::error(message)),
169    };
170    if source.len() > MAX_PROGRAM_SCRIPT_SOURCE_BYTES {
171        return Ok(ToolOutput::error(format!(
172            "script source is too large: {} bytes exceeds {} bytes",
173            source.len(),
174            MAX_PROGRAM_SCRIPT_SOURCE_BYTES
175        )));
176    }
177    if let Err(message) = validate_script_source(&source) {
178        return Ok(ToolOutput::error(message));
179    }
180
181    let allowed_tools = script_allowed_tools(args, invoker.available_tools());
182    let limits = script_limits(args);
183    match run_quickjs_script(&source, inputs, invoker, ctx.clone(), allowed_tools, limits).await {
184        Ok(output) => Ok(output),
185        Err(err) => Ok(ToolOutput::error(format!("program script failed: {err}"))),
186    }
187}
188
189async fn load_script_source(
190    args: &serde_json::Value,
191    ctx: &ToolContext,
192) -> std::result::Result<String, String> {
193    if let Some(source) = args.get("source").and_then(|value| value.as_str()) {
194        return Ok(source.to_string());
195    }
196
197    let Some(path) = args.get("path").and_then(|value| value.as_str()) else {
198        return Err("program script requires either source or path".to_string());
199    };
200    if !(path.ends_with(".js") || path.ends_with(".mjs")) {
201        return Err("program script path must point to a .js or .mjs file".to_string());
202    }
203
204    let workspace_path = ctx
205        .resolve_workspace_path(path)
206        .map_err(|err| format!("failed to resolve script path: {err}"))?;
207    ctx.workspace_services
208        .fs()
209        .read_text(&workspace_path)
210        .await
211        .map_err(|err| format!("failed to read script path '{}': {err}", path))
212}
213
214fn script_allowed_tools(args: &serde_json::Value, available_tools: Vec<String>) -> HashSet<String> {
215    let mut allowed = args
216        .get("allowed_tools")
217        .and_then(|value| value.as_array())
218        .map(|items| {
219            items
220                .iter()
221                .filter_map(|item| item.as_str())
222                .map(ToString::to_string)
223                .collect::<HashSet<_>>()
224        })
225        .unwrap_or_else(|| available_tools.into_iter().collect());
226
227    allowed.remove("program");
228    // QuickJS is a single-threaded embedded VM, so PTC scripts must not expose
229    // recursive orchestrators or the legacy fan-out alias directly. Unified
230    // `task` calls are checked per invocation below; Dynamic Workflows can
231    // schedule host-side task fan-out.
232    allowed.remove("dynamic_workflow");
233    allowed.remove("parallel_task");
234    allowed
235}
236
237fn script_limits(args: &serde_json::Value) -> ScriptLimits {
238    args.get("limits")
239        .cloned()
240        .and_then(|value| serde_json::from_value(value).ok())
241        .unwrap_or(ScriptLimits {
242            timeout_ms: None,
243            max_tool_calls: None,
244            max_output_bytes: None,
245        })
246}
247
248fn validate_script_source(source: &str) -> std::result::Result<(), String> {
249    let forbidden = [
250        ("import ", "imports are not allowed inside PTC scripts"),
251        (
252            "import(",
253            "dynamic imports are not allowed inside PTC scripts",
254        ),
255        ("eval(", "eval is not allowed inside PTC scripts"),
256        (
257            "Function(",
258            "Function constructor is not allowed inside PTC scripts",
259        ),
260        ("Worker(", "Worker is not allowed inside PTC scripts"),
261        ("WebSocket", "WebSocket is not allowed inside PTC scripts"),
262        (
263            "fetch(",
264            "fetch is not allowed inside PTC scripts; use ctx tools instead",
265        ),
266    ];
267
268    for (needle, message) in forbidden {
269        if source.contains(needle) {
270            return Err(message.to_string());
271        }
272    }
273    Ok(())
274}
275
276async fn run_quickjs_script(
277    source: &str,
278    inputs: serde_json::Value,
279    invoker: Arc<dyn ToolInvoker>,
280    ctx: ToolContext,
281    allowed_tools: HashSet<String>,
282    limits: ScriptLimits,
283) -> Result<ToolOutput> {
284    // A script that can delegate runs child agents (each a full LLM turn, often
285    // 30s to several minutes), so the 30s default is far too short and silently
286    // times out real workflows. Default delegation-capable scripts to a generous
287    // timeout; pure compute/search scripts keep the short default. An explicit
288    // limits.timeoutMs always wins.
289    let delegating = allowed_tools.contains("task");
290    let timeout_ms = limits.timeout_ms.unwrap_or(if delegating {
291        DELEGATION_SCRIPT_TIMEOUT_MS
292    } else {
293        DEFAULT_SCRIPT_TIMEOUT_MS
294    });
295    let max_tool_calls = limits
296        .max_tool_calls
297        .unwrap_or(DEFAULT_SCRIPT_MAX_TOOL_CALLS);
298    let max_output_bytes = limits
299        .max_output_bytes
300        .unwrap_or(DEFAULT_SCRIPT_MAX_OUTPUT_BYTES);
301    let executable_source = script_source_with_host_entrypoint(source)?;
302    let parent_cancellation = ctx.cancellation_token();
303    let program_cancellation = parent_cancellation.child_token();
304    // Captured on the outer multi-threaded runtime (we're async here, before the
305    // VM's nested single-thread runtime is built) so host tools run on the
306    // session runtime instead of being trapped inside the QuickJS VM runtime.
307    let outer = tokio::runtime::Handle::current();
308    let state = Arc::new(Mutex::new(ScriptVmState {
309        invoker,
310        ctx: ctx.with_cancellation(program_cancellation.clone()),
311        allowed_tools,
312        max_tool_calls,
313        max_output_bytes,
314        tool_calls: 0,
315        records: Vec::new(),
316        outer,
317    }));
318
319    let vm_state = Arc::clone(&state);
320    let mut vm = tokio::task::spawn_blocking(move || {
321        let runtime = tokio::runtime::Builder::new_current_thread()
322            .enable_all()
323            .build()
324            .map_err(|err| anyhow!("failed to create program VM runtime: {err}"))?;
325        runtime.block_on(run_embedded_script(
326            executable_source,
327            inputs,
328            vm_state,
329            timeout_ms,
330            program_cancellation,
331        ))
332    });
333
334    enum Stop {
335        Cancelled,
336        TimedOut,
337    }
338    let result = tokio::select! {
339        biased;
340        _ = parent_cancellation.cancelled() => None,
341        result = &mut vm => Some(result),
342        _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => None,
343    };
344    let stop = if result.is_none() {
345        if parent_cancellation.is_cancelled() {
346            Some(Stop::Cancelled)
347        } else {
348            Some(Stop::TimedOut)
349        }
350    } else {
351        None
352    };
353
354    if let Some(stop) = stop {
355        // The child token is already cancelled when the parent stopped. On an
356        // internal deadline, cancel it explicitly so every nested invocation
357        // receives the same terminal signal as the VM.
358        state.lock().await.ctx.cancellation_token().cancel();
359        if timeout(PROGRAM_CANCELLATION_SETTLE_GRACE, &mut vm)
360            .await
361            .is_err()
362        {
363            vm.abort();
364            let _ = vm.await;
365        }
366        return Ok(ToolOutput::error(match stop {
367            Stop::Cancelled => "program script cancelled by caller".to_string(),
368            Stop::TimedOut => format!("program script timed out after {timeout_ms} ms"),
369        }));
370    }
371
372    let result = result.expect("completed VM result is present");
373
374    match result {
375        Ok(Ok(result)) => {
376            let records = state.lock().await.records.clone();
377            let rendered = render_script_output(&result, &records, "");
378            let truncated = rendered.len() > max_output_bytes;
379            let output = bound_program_output(rendered, max_output_bytes);
380            let script_result = if truncated {
381                serde_json::json!({ "truncated": true })
382            } else {
383                result
384            };
385            Ok(ToolOutput::success(output).with_metadata(serde_json::json!({
386                "program": {
387                    "name": "script",
388                    "language": "javascript",
389                    "runtime": "embedded-quickjs",
390                    "success": true,
391                    "tool_calls": records.iter().map(script_record_to_value).collect::<Vec<_>>(),
392                },
393                "script_result": script_result,
394            })))
395        }
396        Ok(Err(err)) if is_quickjs_timeout(&err) => Ok(ToolOutput::error(format!(
397            "program script timed out after {timeout_ms} ms"
398        ))),
399        Ok(Err(err)) => Ok(ToolOutput::error(format!("program script error:\n{err}"))),
400        Err(err) => Ok(ToolOutput::error(format!(
401            "program VM thread failed: {err}"
402        ))),
403    }
404}
405
406fn script_source_with_host_entrypoint(source: &str) -> Result<String> {
407    let rewritten = if source.contains("export default async function run") {
408        source.replacen("export default async function run", "async function run", 1)
409    } else if source.contains("export default function run") {
410        source.replacen("export default function run", "function run", 1)
411    } else if source.contains("async function run") || source.contains("function run") {
412        source.to_string()
413    } else {
414        return Err(anyhow!(
415            "PTC script must define async function run(ctx, inputs)"
416        ));
417    };
418
419    Ok(format!(
420        r#"{rewritten}
421
422globalThis.__a3sResultJson = (async () => JSON.stringify(await run(globalThis.__a3sCtx, globalThis.__a3sInputs)))();
423"#
424    ))
425}
426
427async fn run_embedded_script(
428    source: String,
429    inputs: serde_json::Value,
430    state: Arc<Mutex<ScriptVmState>>,
431    timeout_ms: u64,
432    cancellation: tokio_util::sync::CancellationToken,
433) -> Result<serde_json::Value> {
434    let runtime = AsyncRuntime::new()?;
435    let started = Instant::now();
436    runtime
437        .set_interrupt_handler(Some(Box::new(move || {
438            cancellation.is_cancelled() || started.elapsed() >= Duration::from_millis(timeout_ms)
439        })))
440        .await;
441    runtime.set_memory_limit(64 * 1024 * 1024).await;
442    runtime.set_max_stack_size(512 * 1024).await;
443
444    let context = AsyncContext::full(&runtime).await?;
445    let inputs_json = serde_json::to_string(&inputs)?;
446    let script = format!("{}\n{}", embedded_script_bootstrap(&inputs_json), source);
447    let result_json = async_with!(context => |ctx| {
448        let state = Arc::clone(&state);
449        let host_tool = move |tool: String, args_json: String| {
450            let state = Arc::clone(&state);
451            async move { execute_host_tool_json(state, tool, args_json).await }
452        };
453        if let Err(err) = ctx.globals().set("__a3sHostTool", Func::from(Async(host_tool))) {
454            return Err(format!("failed to install program host tool: {err}"));
455        }
456        // Catch the thrown value. `Error::Exception` alone prints as
457        // "Exception generated by QuickJS" and drops the interrupt message
458        // ("interrupted"), so a deadline would look like a script bug.
459        let promise: Promise = match ctx.eval(script).catch(&ctx) {
460            Ok(promise) => promise,
461            Err(err) => return Err(format!("failed to evaluate program script: {err}")),
462        };
463        promise
464            .into_future::<String>()
465            .await
466            .catch(&ctx)
467            .map_err(|err| err.to_string())
468    })
469    .await
470    .map_err(anyhow::Error::msg)?;
471
472    serde_json::from_str(&result_json)
473        .map_err(|err| anyhow!("program script returned invalid JSON: {err}"))
474}
475
476struct ScriptVmState {
477    invoker: Arc<dyn ToolInvoker>,
478    ctx: ToolContext,
479    allowed_tools: HashSet<String>,
480    max_tool_calls: usize,
481    max_output_bytes: usize,
482    tool_calls: usize,
483    records: Vec<ScriptCallRecord>,
484    /// Handle to the OUTER multi-threaded session runtime. The script VM runs on
485    /// a nested single-thread runtime; host tool calls are dispatched here so
486    /// delegated `task` runs are not trapped inside the VM runtime.
487    outer: tokio::runtime::Handle,
488}
489
490fn embedded_script_bootstrap(inputs_json: &str) -> String {
491    format!(
492        r#"
493const __a3sCallTool = async (tool, args = {{}}) => {{
494  const response = await globalThis.__a3sHostTool(String(tool), JSON.stringify(args ?? {{}}));
495  return JSON.parse(response);
496}};
497
498const __a3sTools = Object.freeze(new Proxy({{}}, {{
499  get(_target, prop) {{
500    if (typeof prop !== "string" || prop === "then") return undefined;
501    return (args = {{}}) => __a3sCallTool(prop, args);
502  }},
503  has(_target, prop) {{
504    return typeof prop === "string";
505  }},
506}}));
507
508const __a3sReadArgs = (path, options = {{}}) => ({{ ...(options ?? {{}}), file_path: path }});
509const __a3sLegacySearchArgs = (mode, query, options = {{}}) => {{
510  const args = {{ ...(options ?? {{}}), mode, query }};
511  if (Object.prototype.hasOwnProperty.call(args, "glob")) {{
512    args.include = args.glob;
513    delete args.glob;
514  }}
515  if (Object.prototype.hasOwnProperty.call(args, "-i")) {{
516    args.case_sensitive = !args["-i"];
517    delete args["-i"];
518  }}
519  return args;
520}};
521const __a3sCtx = Object.freeze({{
522  tool: __a3sCallTool,
523  tools: __a3sTools,
524  readFile: (path, options = {{}}) => __a3sCallTool("read", __a3sReadArgs(path, options)).then((r) => r.text ?? r.output),
525  read: (path, options = {{}}) => __a3sCallTool("read", __a3sReadArgs(path, options)),
526  search: (query, options = {{}}) => __a3sCallTool("search", {{ ...options, query }}).then((r) => r.output),
527  grep: (query, options = {{}}) => __a3sCallTool("search", __a3sLegacySearchArgs("grep", query, options)).then((r) => r.output),
528  bm25: (query, options = {{}}) => __a3sCallTool("search", __a3sLegacySearchArgs("bm25", query, options)).then((r) => r.output),
529  glob: (query, options = {{}}) => __a3sCallTool("search", {{ ...options, mode: "glob", query }}).then((r) => r.output),
530  ls: (path = ".") => __a3sCallTool("ls", {{ path }}).then((r) => r.output),
531  bash: (command) => __a3sCallTool("bash", {{ command }}).then((r) => r.output),
532  git: (args = {{}}) => __a3sCallTool("git", args),
533  webSearch: (params) => __a3sCallTool("web_search", params),
534  verify: (args) => __a3sCallTool("bash", args),
535}});
536
537Object.defineProperty(globalThis, "__a3sCtx", {{ value: __a3sCtx, configurable: false }});
538Object.defineProperty(globalThis, "__a3sInputs", {{ value: {inputs_json}, configurable: false }});
539Object.defineProperty(globalThis, "fetch", {{ value: undefined, configurable: false, writable: false }});
540Object.defineProperty(globalThis, "WebSocket", {{ value: undefined, configurable: false, writable: false }});
541Object.defineProperty(globalThis, "Worker", {{ value: undefined, configurable: false, writable: false }});
542"#
543    )
544}
545
546async fn execute_host_tool_json(
547    state: Arc<Mutex<ScriptVmState>>,
548    tool: String,
549    args_json: String,
550) -> rquickjs::Result<String> {
551    let args = serde_json::from_str(&args_json).map_err(|err| {
552        JsError::new_from_js_message("string", "object", format!("invalid tool args JSON: {err}"))
553    })?;
554    let (invoker, ctx, max_output_bytes, outer) = {
555        let mut script = state.lock().await;
556        if !script_tool_is_allowed(&script.allowed_tools, &tool, &args) {
557            return Err(JsError::new_from_js_message(
558                "tool",
559                "allowed tool",
560                format!("tool '{tool}' is not allowed for this PTC script"),
561            ));
562        }
563        script.tool_calls += 1;
564        if script.tool_calls > script.max_tool_calls {
565            return Err(JsError::new_from_js_message(
566                "tool call",
567                "limited tool call",
568                format!("PTC script exceeded maxToolCalls={}", script.max_tool_calls),
569            ));
570        }
571        (
572            Arc::clone(&script.invoker),
573            script.ctx.clone(),
574            script.max_output_bytes,
575            script.outer.clone(),
576        )
577    };
578
579    // Run the tool on the OUTER multi-threaded runtime (not this nested
580    // single-thread VM runtime) so host tools can use the session runtime
581    // normally. `parallel_task` is intentionally filtered before this point.
582    let tool_for_spawn = tool.clone();
583    let result = outer
584        .spawn(async move {
585            invoker
586                .invoke(ctx.nested_tool_invocation(tool_for_spawn, args), &ctx)
587                .await
588        })
589        .await
590        .map_err(|err| JsError::new_from_js_message("tool", "spawn", err.to_string()))?;
591    let mut output = result.output;
592    if output.len() > max_output_bytes {
593        output = truncate_utf8(&output, max_output_bytes).to_string();
594    }
595    let success = result.exit_code == 0;
596    let metadata = result.metadata.clone();
597    let exit_code = result.exit_code;
598    let name = result.name;
599    let text = (tool == "read" && success)
600        .then(|| program_read_text(&output, metadata.as_ref()))
601        .flatten();
602
603    {
604        let mut script = state.lock().await;
605        script.records.push(ScriptCallRecord {
606            tool_name: tool,
607            success,
608            exit_code,
609            output_bytes: output.len(),
610            metadata: metadata.clone(),
611        });
612    }
613
614    serde_json::to_string(&serde_json::json!({
615        "name": name,
616        "output": output,
617        "text": text,
618        "exitCode": exit_code,
619        "metadata": metadata,
620    }))
621    .map_err(|err| JsError::new_from_js_message("tool result", "json", err.to_string()))
622}
623
624/// Recover the line payload from the single-file `read` tool's anchored
625/// display format. `ctx.readFile` is a programmatic convenience API, while
626/// `ctx.read` deliberately retains the model-facing line numbers and
627/// continuation footer. The metadata count makes the transform structural:
628/// content that happens to resemble a continuation footer is never removed.
629fn program_read_text(output: &str, metadata: Option<&serde_json::Value>) -> Option<String> {
630    let returned_lines = metadata?
631        .pointer("/range/returned_lines")?
632        .as_u64()
633        .and_then(|value| usize::try_from(value).ok())?;
634    if returned_lines == 0 {
635        return Some(String::new());
636    }
637
638    let mut text = String::new();
639    let mut rendered_lines = output.split_inclusive('\n');
640    for _ in 0..returned_lines {
641        let rendered = rendered_lines.next()?;
642        let (anchor, content) = rendered.split_once('\t')?;
643        if anchor.len() != 6 || anchor.trim().parse::<usize>().is_err() {
644            return None;
645        }
646        text.push_str(content);
647    }
648    Some(text)
649}
650
651fn script_tool_is_allowed(
652    allowed_tools: &HashSet<String>,
653    tool: &str,
654    args: &serde_json::Value,
655) -> bool {
656    if tool == "task"
657        && args
658            .get("tasks")
659            .and_then(serde_json::Value::as_array)
660            .is_some_and(|tasks| tasks.len() > 1)
661    {
662        return false;
663    }
664    if allowed_tools.contains(tool) {
665        return true;
666    }
667    tool == "search"
668        && args
669            .get("mode")
670            .and_then(serde_json::Value::as_str)
671            .is_some_and(|mode| allowed_tools.contains(mode))
672}
673
674fn is_quickjs_timeout(err: &anyhow::Error) -> bool {
675    let text = err.to_string();
676    text.contains("interrupted") || text.contains("InternalError")
677}
678
679fn script_record_to_value(record: &ScriptCallRecord) -> serde_json::Value {
680    serde_json::json!({
681        "tool_name": record.tool_name,
682        "success": record.success,
683        "exit_code": record.exit_code,
684        "output_bytes": record.output_bytes,
685        "metadata": record.metadata,
686    })
687}
688
689fn bound_program_output(output: String, max_output_bytes: usize) -> String {
690    const MARKER: &str = "\n[output truncated]";
691    if output.len() <= max_output_bytes {
692        return output;
693    }
694    let budget = max_output_bytes.saturating_sub(MARKER.len());
695    format!("{}{MARKER}", truncate_utf8(&output, budget))
696}
697
698fn render_script_output(
699    result: &serde_json::Value,
700    records: &[ScriptCallRecord],
701    stderr: &str,
702) -> String {
703    let mut output = String::from("Program script completed.");
704    if let Some(summary) = result.get("summary").and_then(|value| value.as_str()) {
705        output.push('\n');
706        output.push_str(summary);
707    }
708
709    output.push_str(&format!("\n\nTool calls: {}", records.len()));
710    for (index, record) in records.iter().enumerate() {
711        output.push_str(&format!(
712            "\n{}. {} ({}, exit_code={}, output_bytes={})",
713            index + 1,
714            record.tool_name,
715            if record.success { "ok" } else { "failed" },
716            record.exit_code,
717            record.output_bytes
718        ));
719    }
720
721    output.push_str("\n\nResult:\n");
722    output.push_str(&serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()));
723
724    if !stderr.is_empty() {
725        output.push_str("\n\nstderr:\n");
726        output.push_str(stderr);
727    }
728
729    output
730}
731
732#[cfg(test)]
733#[path = "program_tool/tests.rs"]
734mod tests;