Skip to main content

agent_fleet/
tick.rs

1//! Tick loop: drive the Anthropic model through a gh-only sandbox (SPEC ยง3.2).
2
3use async_trait::async_trait;
4use regex::Regex;
5use serde_json::{json, Value};
6use thiserror::Error;
7
8use crate::config::FleetEntry;
9use crate::prompts::render_tick_prompt;
10use crate::sandbox::is_allowed_command;
11
12pub const MAX_TURNS: usize = 10;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ExecResult {
16    pub stdout: String,
17    pub stderr: String,
18    pub code: i32,
19}
20
21/// Synchronous command executor (matches TS shape; the loop itself is async,
22/// but `gh` invocations are short and synchronous).
23pub type ExecFn = Box<dyn Fn(&str) -> ExecResult + Send + Sync>;
24
25/// Anthropic client trait. The real reqwest-backed client implements this;
26/// tests provide their own fake.
27#[async_trait]
28pub trait AnthropicClient: Send + Sync {
29    async fn create_message(&self, messages: &[Value]) -> Result<Value, TickError>;
30}
31
32#[derive(Debug, Error)]
33pub enum TickError {
34    #[error("anthropic error: {0}")]
35    Anthropic(String),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum TickOutcome {
40    NoFindings,
41    IssueCreated { url: String },
42    Error { message: String },
43}
44
45fn url_re() -> Regex {
46    Regex::new(r"https://github\.com/\S+").unwrap()
47}
48
49pub async fn tick_one(
50    entry: &FleetEntry,
51    client: &dyn AnthropicClient,
52    exec: &ExecFn,
53    iso_date: &str,
54) -> TickOutcome {
55    let prompt = render_tick_prompt(&entry.repo, iso_date);
56    let mut messages: Vec<Value> = vec![json!({ "role": "user", "content": prompt })];
57    let mut issue_url: Option<String> = None;
58
59    for _ in 0..MAX_TURNS {
60        let resp = match client.create_message(&messages).await {
61            Ok(v) => v,
62            Err(e) => return TickOutcome::Error { message: e.to_string() },
63        };
64        let content = resp.get("content").cloned().unwrap_or(Value::Array(vec![]));
65        let blocks: Vec<Value> = match content {
66            Value::Array(a) => a,
67            _ => vec![],
68        };
69
70        // Append assistant message preserving content order.
71        messages.push(json!({ "role": "assistant", "content": blocks.clone() }));
72
73        let tool_uses: Vec<&Value> = blocks
74            .iter()
75            .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
76            .collect();
77        let text_blocks: Vec<&Value> = blocks
78            .iter()
79            .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
80            .collect();
81
82        if tool_uses.is_empty() {
83            if let Some(url) = issue_url {
84                return TickOutcome::IssueCreated { url };
85            }
86            let text: String = text_blocks
87                .iter()
88                .filter_map(|b| b.get("text").and_then(|s| s.as_str()))
89                .collect::<Vec<_>>()
90                .join(" ");
91            if text.contains("no-findings") {
92                return TickOutcome::NoFindings;
93            }
94            return TickOutcome::NoFindings;
95        }
96
97        let mut tool_results: Vec<Value> = Vec::with_capacity(tool_uses.len());
98        for tu in tool_uses {
99            let tu_id = tu
100                .get("id")
101                .and_then(|v| v.as_str())
102                .unwrap_or("")
103                .to_string();
104            let name = tu.get("name").and_then(|v| v.as_str()).unwrap_or("");
105            if name != "bash" {
106                tool_results.push(json!({
107                    "type": "tool_result",
108                    "tool_use_id": tu_id,
109                    "content": "unknown tool",
110                    "is_error": true,
111                }));
112                continue;
113            }
114            let cmd = tu
115                .get("input")
116                .and_then(|i| i.get("command"))
117                .and_then(|c| c.as_str())
118                .unwrap_or("")
119                .to_string();
120
121            let allow = is_allowed_command(&cmd);
122            if !allow.allowed() {
123                let reason = allow.reason().unwrap_or("denied").to_string();
124                tool_results.push(json!({
125                    "type": "tool_result",
126                    "tool_use_id": tu_id,
127                    "content": format!("error: {}", reason),
128                    "is_error": true,
129                }));
130                continue;
131            }
132
133            // C3 interlock: at most one issue per tick run.
134            if cmd.starts_with("gh issue create") {
135                if issue_url.is_some() {
136                    tool_results.push(json!({
137                        "type": "tool_result",
138                        "tool_use_id": tu_id,
139                        "content": "error: only one issue allowed per tick run",
140                        "is_error": true,
141                    }));
142                    continue;
143                }
144                let r = (exec)(&cmd);
145                if let Some(m) = url_re().find(&r.stdout) {
146                    issue_url = Some(m.as_str().to_string());
147                }
148                let body = if !r.stdout.is_empty() {
149                    r.stdout.clone()
150                } else {
151                    r.stderr.clone()
152                };
153                tool_results.push(json!({
154                    "type": "tool_result",
155                    "tool_use_id": tu_id,
156                    "content": body,
157                    "is_error": r.code != 0,
158                }));
159                continue;
160            }
161
162            let r = (exec)(&cmd);
163            let body = if !r.stdout.is_empty() {
164                r.stdout.clone()
165            } else {
166                r.stderr.clone()
167            };
168            tool_results.push(json!({
169                "type": "tool_result",
170                "tool_use_id": tu_id,
171                "content": body,
172                "is_error": r.code != 0,
173            }));
174        }
175        messages.push(json!({ "role": "user", "content": tool_results }));
176    }
177
178    TickOutcome::Error {
179        message: format!("exceeded {} turns", MAX_TURNS),
180    }
181}
182
183/// Reqwest-backed Anthropic client. Not exercised by tests (which use a fake).
184pub struct ReqwestAnthropicClient {
185    api_key: String,
186    model: String,
187    base_url: String,
188    http: reqwest::Client,
189}
190
191impl ReqwestAnthropicClient {
192    pub fn new(api_key: String) -> Self {
193        Self {
194            api_key,
195            model: "claude-opus-4-7".to_string(),
196            base_url: "https://api.anthropic.com".to_string(),
197            http: reqwest::Client::new(),
198        }
199    }
200}
201
202#[async_trait]
203impl AnthropicClient for ReqwestAnthropicClient {
204    async fn create_message(&self, messages: &[Value]) -> Result<Value, TickError> {
205        let body = json!({
206            "model": self.model,
207            "max_tokens": 4096,
208            "tools": [{
209                "name": "bash",
210                "description": "Run a shell command. Restricted to invocations of `gh`.",
211                "input_schema": {
212                    "type": "object",
213                    "properties": { "command": { "type": "string" } },
214                    "required": ["command"],
215                },
216            }],
217            "messages": messages,
218        });
219        let resp = self
220            .http
221            .post(format!("{}/v1/messages", self.base_url))
222            .header("x-api-key", &self.api_key)
223            .header("anthropic-version", "2023-06-01")
224            .header("content-type", "application/json")
225            .json(&body)
226            .send()
227            .await
228            .map_err(|e| TickError::Anthropic(e.to_string()))?;
229        if !resp.status().is_success() {
230            let status = resp.status();
231            let text = resp.text().await.unwrap_or_default();
232            return Err(TickError::Anthropic(format!("HTTP {}: {}", status, text)));
233        }
234        resp.json::<Value>()
235            .await
236            .map_err(|e| TickError::Anthropic(e.to_string()))
237    }
238}