Skip to main content

perspt_policy/
program.rs

1//! Deterministic Starlark composition for bounded tool proposals.
2//!
3//! A program cannot touch files, environment, network, time, or the kernel.
4//! It returns JSON describing nested calls; the parent agent loop submits each
5//! call to the ordinary five-clause admissibility path.
6
7use 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
43/// Evaluate a pure Starlark expression that returns a JSON string.
44///
45/// The source's final expression must be a zero-argument function. Calling it
46/// must return a JSON array of `{ "tool": ..., "arguments": {...} }` values.
47/// Starlark is deliberately given only its standard pure globals and no loader.
48pub 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}