use async_trait::async_trait;
use regex::Regex;
use serde_json::{json, Value};
use thiserror::Error;
use crate::config::FleetEntry;
use crate::prompts::render_tick_prompt;
use crate::sandbox::is_allowed_command;
pub const MAX_TURNS: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecResult {
pub stdout: String,
pub stderr: String,
pub code: i32,
}
pub type ExecFn = Box<dyn Fn(&str) -> ExecResult + Send + Sync>;
#[async_trait]
pub trait AnthropicClient: Send + Sync {
async fn create_message(&self, messages: &[Value]) -> Result<Value, TickError>;
}
#[derive(Debug, Error)]
pub enum TickError {
#[error("anthropic error: {0}")]
Anthropic(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TickOutcome {
NoFindings,
IssueCreated { url: String },
Error { message: String },
}
fn url_re() -> Regex {
Regex::new(r"https://github\.com/\S+").unwrap()
}
pub async fn tick_one(
entry: &FleetEntry,
client: &dyn AnthropicClient,
exec: &ExecFn,
iso_date: &str,
) -> TickOutcome {
let prompt = render_tick_prompt(&entry.repo, iso_date);
let mut messages: Vec<Value> = vec![json!({ "role": "user", "content": prompt })];
let mut issue_url: Option<String> = None;
for _ in 0..MAX_TURNS {
let resp = match client.create_message(&messages).await {
Ok(v) => v,
Err(e) => return TickOutcome::Error { message: e.to_string() },
};
let content = resp.get("content").cloned().unwrap_or(Value::Array(vec![]));
let blocks: Vec<Value> = match content {
Value::Array(a) => a,
_ => vec![],
};
messages.push(json!({ "role": "assistant", "content": blocks.clone() }));
let tool_uses: Vec<&Value> = blocks
.iter()
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
.collect();
let text_blocks: Vec<&Value> = blocks
.iter()
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
.collect();
if tool_uses.is_empty() {
if let Some(url) = issue_url {
return TickOutcome::IssueCreated { url };
}
let text: String = text_blocks
.iter()
.filter_map(|b| b.get("text").and_then(|s| s.as_str()))
.collect::<Vec<_>>()
.join(" ");
if text.contains("no-findings") {
return TickOutcome::NoFindings;
}
return TickOutcome::NoFindings;
}
let mut tool_results: Vec<Value> = Vec::with_capacity(tool_uses.len());
for tu in tool_uses {
let tu_id = tu
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = tu.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name != "bash" {
tool_results.push(json!({
"type": "tool_result",
"tool_use_id": tu_id,
"content": "unknown tool",
"is_error": true,
}));
continue;
}
let cmd = tu
.get("input")
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
let allow = is_allowed_command(&cmd);
if !allow.allowed() {
let reason = allow.reason().unwrap_or("denied").to_string();
tool_results.push(json!({
"type": "tool_result",
"tool_use_id": tu_id,
"content": format!("error: {}", reason),
"is_error": true,
}));
continue;
}
if cmd.starts_with("gh issue create") {
if issue_url.is_some() {
tool_results.push(json!({
"type": "tool_result",
"tool_use_id": tu_id,
"content": "error: only one issue allowed per tick run",
"is_error": true,
}));
continue;
}
let r = (exec)(&cmd);
if let Some(m) = url_re().find(&r.stdout) {
issue_url = Some(m.as_str().to_string());
}
let body = if !r.stdout.is_empty() {
r.stdout.clone()
} else {
r.stderr.clone()
};
tool_results.push(json!({
"type": "tool_result",
"tool_use_id": tu_id,
"content": body,
"is_error": r.code != 0,
}));
continue;
}
let r = (exec)(&cmd);
let body = if !r.stdout.is_empty() {
r.stdout.clone()
} else {
r.stderr.clone()
};
tool_results.push(json!({
"type": "tool_result",
"tool_use_id": tu_id,
"content": body,
"is_error": r.code != 0,
}));
}
messages.push(json!({ "role": "user", "content": tool_results }));
}
TickOutcome::Error {
message: format!("exceeded {} turns", MAX_TURNS),
}
}
pub struct ReqwestAnthropicClient {
api_key: String,
model: String,
base_url: String,
http: reqwest::Client,
}
impl ReqwestAnthropicClient {
pub fn new(api_key: String) -> Self {
Self {
api_key,
model: "claude-opus-4-7".to_string(),
base_url: "https://api.anthropic.com".to_string(),
http: reqwest::Client::new(),
}
}
}
#[async_trait]
impl AnthropicClient for ReqwestAnthropicClient {
async fn create_message(&self, messages: &[Value]) -> Result<Value, TickError> {
let body = json!({
"model": self.model,
"max_tokens": 4096,
"tools": [{
"name": "bash",
"description": "Run a shell command. Restricted to invocations of `gh`.",
"input_schema": {
"type": "object",
"properties": { "command": { "type": "string" } },
"required": ["command"],
},
}],
"messages": messages,
});
let resp = self
.http
.post(format!("{}/v1/messages", self.base_url))
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| TickError::Anthropic(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(TickError::Anthropic(format!("HTTP {}: {}", status, text)));
}
resp.json::<Value>()
.await
.map_err(|e| TickError::Anthropic(e.to_string()))
}
}