agent-fleet 0.1.0

Autonomous OSS-repo health for solo maintainers (Rust port of @p-vbordei/agent-fleet)
Documentation
//! Tick loop: drive the Anthropic model through a gh-only sandbox (SPEC ยง3.2).

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,
}

/// Synchronous command executor (matches TS shape; the loop itself is async,
/// but `gh` invocations are short and synchronous).
pub type ExecFn = Box<dyn Fn(&str) -> ExecResult + Send + Sync>;

/// Anthropic client trait. The real reqwest-backed client implements this;
/// tests provide their own fake.
#[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![],
        };

        // Append assistant message preserving content order.
        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;
            }

            // C3 interlock: at most one issue per tick run.
            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),
    }
}

/// Reqwest-backed Anthropic client. Not exercised by tests (which use a fake).
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()))
    }
}