1use 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
21pub type ExecFn = Box<dyn Fn(&str) -> ExecResult + Send + Sync>;
24
25#[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 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 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
183pub 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}