1use anyhow::{ensure, Context, Result};
8use serde::{Deserialize, Serialize};
9use starlark::environment::{Globals, Module};
10use starlark::eval::Evaluator;
11use starlark::syntax::{AstModule, Dialect};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct ToolProgramCall {
15 pub tool: String,
16 #[serde(default)]
17 pub arguments: serde_json::Value,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct ToolProgramLimits {
22 pub max_source_bytes: usize,
23 pub max_calls: usize,
24 pub max_result_bytes: usize,
25 pub max_heap_bytes: usize,
26 pub max_ticks: u64,
27 pub max_callstack: usize,
28}
29
30impl Default for ToolProgramLimits {
31 fn default() -> Self {
32 Self {
33 max_source_bytes: 64 * 1024,
34 max_calls: 16,
35 max_result_bytes: 1024 * 1024,
36 max_heap_bytes: 64 * 1024 * 1024,
37 max_ticks: 100_000,
38 max_callstack: 64,
39 }
40 }
41}
42
43pub fn evaluate_tool_program(
49 source: &str,
50 limits: ToolProgramLimits,
51) -> Result<Vec<ToolProgramCall>> {
52 ensure!(
53 source.len() <= limits.max_source_bytes,
54 "tool program exceeds source limit"
55 );
56 let ast = AstModule::parse("tool_program.star", source.to_owned(), &Dialect::Standard)
57 .map_err(|error| anyhow::anyhow!("tool program parse error: {error}"))?;
58 Module::with_temp_heap(|module| {
59 let mut evaluator = Evaluator::new(&module);
60 evaluator.set_max_callstack_size(limits.max_callstack)?;
61 evaluator.set_max_heap_size(limits.max_heap_bytes)?;
62 evaluator.set_max_tick_count(limits.max_ticks)?;
63 let main = evaluator
64 .eval_module(ast, &Globals::standard())
65 .map_err(|error| anyhow::anyhow!("tool program evaluation error: {error}"))?;
66 let result = evaluator
67 .eval_function(main, &[], &[])
68 .map_err(|error| anyhow::anyhow!("tool program main() failed: {error}"))?;
69 let json = result
70 .unpack_str()
71 .context("tool program main() must return a JSON string")?;
72 ensure!(
73 json.len() <= limits.max_result_bytes,
74 "tool program result exceeds output limit"
75 );
76 let calls: Vec<ToolProgramCall> =
77 serde_json::from_str(json).context("tool program returned invalid call JSON")?;
78 ensure!(
79 calls.len() <= limits.max_calls,
80 "tool program exceeds call limit"
81 );
82 for call in &calls {
83 ensure!(
84 !call.tool.is_empty(),
85 "tool program contains an empty tool name"
86 );
87 ensure!(
88 call.tool != "tool_program",
89 "nested tool_program is forbidden"
90 );
91 ensure!(
92 call.arguments.is_object(),
93 "tool program arguments must be JSON objects"
94 );
95 }
96 Ok(calls)
97 })
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn pure_program_produces_bounded_calls() {
106 let calls = evaluate_tool_program(
107 r#"
108def main():
109 return '[{"tool":"read_file","arguments":{"path":"Cargo.toml"}}]'
110main
111"#,
112 ToolProgramLimits::default(),
113 )
114 .unwrap();
115 assert_eq!(calls.len(), 1);
116 assert_eq!(calls[0].tool, "read_file");
117 }
118
119 #[test]
120 fn recursion_is_stopped_by_the_evaluator_budget() {
121 assert!(evaluate_tool_program(
122 "def main():\n return main()\nmain",
123 ToolProgramLimits::default(),
124 )
125 .is_err());
126 }
127
128 #[test]
129 fn nested_programs_are_rejected() {
130 assert!(evaluate_tool_program(
131 r#"def main():
132 return '[{"tool":"tool_program","arguments":{}}]'
133main"#,
134 ToolProgramLimits::default(),
135 )
136 .is_err());
137 }
138}