Skip to main content

apollo/tools/
build_runner.rs

1//! BuildRunnerTool — agent-callable wrapper around `BuildRunner`.
2//!
3//! Exposes cargo build/test/run cycles to the agent loop as a single tool so
4//! the model can compile, parse diagnostics, and retry without shelling out
5//! repeatedly through `exec`. Returns a structured summary of compiler
6//! errors the model can act on directly.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use serde::Deserialize;
13
14use crate::agent::build_runner::{BuildRunner, BuildRunnerConfig};
15use crate::policy::ExecutionPolicy;
16
17use super::traits::*;
18
19pub struct BuildRunnerTool {
20    workspace: PathBuf,
21    config: BuildRunnerConfig,
22    policy: Arc<ExecutionPolicy>,
23}
24
25impl BuildRunnerTool {
26    pub fn new(
27        workspace: PathBuf,
28        config: BuildRunnerConfig,
29        policy: Arc<ExecutionPolicy>,
30    ) -> Self {
31        Self {
32            workspace,
33            config,
34            policy,
35        }
36    }
37
38    pub fn with_default_config(workspace: PathBuf, policy: Arc<ExecutionPolicy>) -> Self {
39        Self::new(workspace, BuildRunnerConfig::default(), policy)
40    }
41}
42
43#[derive(Deserialize)]
44struct BuildRunnerArgs {
45    /// What to run: "build", "test", or "cycle" (build then test).
46    #[serde(default)]
47    action: String,
48    /// Override the package filter (`cargo <cmd> -p <name>`).
49    #[serde(default)]
50    package: String,
51    /// Override the per-invocation timeout in seconds.
52    #[serde(default)]
53    timeout: Option<u64>,
54    /// Override the max retry count.
55    #[serde(default)]
56    max_retries: Option<usize>,
57}
58
59#[async_trait]
60impl Tool for BuildRunnerTool {
61    fn name(&self) -> &str {
62        "build_runner"
63    }
64
65    fn spec(&self) -> ToolSpec {
66        ToolSpec {
67            name: "build_runner".to_string(),
68            description: "Run cargo build/test/cycle with structured error parsing and retry. \
69                Returns a summary of compiler errors (file:line:col + message) the model \
70                can act on directly, plus the raw output. \
71                'build' = cargo build, 'test' = cargo test, 'cycle' = build then test."
72                .to_string(),
73            parameters: serde_json::json!({
74                "type": "object",
75                "properties": {
76                    "action": {
77                        "type": "string",
78                        "enum": ["build", "test", "cycle"],
79                        "description": "build = cargo build, test = cargo test, cycle = build then test (default build)"
80                    },
81                    "package": {
82                        "type": "string",
83                        "description": "Package filter (cargo -p <name>). Empty = whole workspace."
84                    },
85                    "timeout": {
86                        "type": "integer",
87                        "description": "Per-invocation timeout in seconds"
88                    },
89                    "max_retries": {
90                        "type": "integer",
91                        "description": "Maximum retries on failure"
92                    }
93                }
94            }),
95        }
96    }
97
98    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
99        if !self.policy.allow_shell {
100            return ExecutionPolicy::deny("Build execution is disabled by policy");
101        }
102
103        let args: BuildRunnerArgs = serde_json::from_str(arguments).unwrap_or(BuildRunnerArgs {
104            action: String::new(),
105            package: String::new(),
106            timeout: None,
107            max_retries: None,
108        });
109
110        let mut config = self.config.clone();
111        if !args.package.is_empty() {
112            config.package = args.package;
113        }
114        if let Some(t) = args.timeout {
115            config.timeout_secs = t;
116        }
117        if let Some(r) = args.max_retries {
118            config.max_retries = r;
119        }
120
121        let runner = BuildRunner::new(self.workspace.clone(), config);
122        let action = if args.action.is_empty() {
123            "build"
124        } else {
125            args.action.as_str()
126        };
127
128        let output = match action {
129            "build" => {
130                let r = runner.run_build().await;
131                if r.success {
132                    ToolResult::success(r.summary())
133                } else {
134                    ToolResult::error(format!(
135                        "{}\n\n--- raw output ---\n{}",
136                        r.summary(),
137                        r.raw_output
138                    ))
139                }
140            }
141            "test" => {
142                let r = runner.run_test().await;
143                if r.success {
144                    ToolResult::success(r.summary())
145                } else {
146                    ToolResult::error(format!(
147                        "{}\n\n--- raw output ---\n{}",
148                        r.summary(),
149                        r.raw_output
150                    ))
151                }
152            }
153            "cycle" => {
154                let (build, test) = runner.run_cycle().await;
155                if !build.success {
156                    ToolResult::error(format!(
157                        "{}\n\n--- raw output ---\n{}",
158                        build.summary(),
159                        build.raw_output
160                    ))
161                } else if let Some(t) = test {
162                    if t.success {
163                        ToolResult::success(format!("{}\n{}", build.summary(), t.summary()))
164                    } else {
165                        ToolResult::error(format!(
166                            "{}\n\n--- raw output ---\n{}",
167                            t.summary(),
168                            t.raw_output
169                        ))
170                    }
171                } else {
172                    ToolResult::success(build.summary())
173                }
174            }
175            other => ToolResult::error(format!(
176                "Unknown action: {} (use build, test, or cycle)",
177                other
178            )),
179        };
180
181        Ok(output)
182    }
183}