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, 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, 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    // QuickJS is a single-threaded embedded VM, so PTC scripts must not expose
220    // recursive orchestrators or the legacy fan-out alias directly. Unified
221    // `task` calls are checked per invocation below; Dynamic Workflows can
222    // schedule host-side task fan-out.
223    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    // A script that can delegate runs child agents (each a full LLM turn, often
276    // 30s to several minutes), so the 30s default is far too short and silently
277    // times out real workflows. Default delegation-capable scripts to a generous
278    // timeout; pure compute/search scripts keep the short default. An explicit
279    // limits.timeoutMs always wins.
280    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    // Captured on the outer multi-threaded runtime (we're async here, before the
296    // VM's nested single-thread runtime is built) so host tools run on the
297    // session runtime instead of being trapped inside the QuickJS VM runtime.
298    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        // The child token is already cancelled when the parent stopped. On an
347        // internal deadline, cancel it explicitly so every nested invocation
348        // receives the same terminal signal as the VM.
349        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    /// Handle to the OUTER multi-threaded session runtime. The script VM runs on
466    /// a nested single-thread runtime; host tool calls are dispatched here so
467    /// delegated `task` runs are not trapped inside the VM runtime.
468    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    // Run the tool on the OUTER multi-threaded runtime (not this nested
561    // single-thread VM runtime) so host tools can use the session runtime
562    // normally. `parallel_task` is intentionally filtered before this point.
563    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;