agent-trace 0.1.0

Git-backed document memory, trace continuity, and permissioned writes for agent workflows
Documentation
use crate::config::{SynthesisConfig, SynthesisProvider};
use crate::llm::prompts::{
    summarize_change_prompt, summarize_event_history_prompt, summarize_session_prompt,
    synthesize_context_prompt, trace_to_doc_summaries, update_running_summary_prompt,
};
use crate::llm::synthesis_engine::SynthesisEngine;
use crate::llm::trace_insights::TraceDocument;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;

#[derive(Debug, Clone)]
pub struct HttpBackend {
    pub provider: SynthesisProvider,
    pub model: String,
    pub base_url: String,
    pub api_key: Option<String>,
    pub max_tokens: usize,
    pub temperature: f32,
    label: String,
}

impl HttpBackend {
    pub fn from_config(
        provider: SynthesisProvider,
        cfg: &SynthesisConfig,
        api_key: Option<String>,
        label: impl Into<String>,
    ) -> Self {
        Self {
            provider,
            model: cfg.model.clone(),
            base_url: cfg.effective_base_url(),
            api_key,
            max_tokens: cfg.max_tokens,
            temperature: cfg.temperature,
            label: label.into(),
        }
    }

    pub fn health_check(&self) -> Result<()> {
        let client = reqwest::blocking::Client::builder()
            .timeout(Duration::from_secs(10))
            .build()?;
        let url = format!("{}/models", self.base_url.trim_end_matches('/'));
        let mut req = client.get(&url);
        if let Some(key) = &self.api_key {
            req = self.apply_auth(req, key);
        }
        let resp = req.send().with_context(|| format!("GET {url}"))?;
        if resp.status().is_success() || resp.status().as_u16() == 404 {
            return Ok(());
        }
        bail!("HTTP {} from {}", resp.status(), url);
    }

    fn apply_auth(
        &self,
        req: reqwest::blocking::RequestBuilder,
        key: &str,
    ) -> reqwest::blocking::RequestBuilder {
        match self.provider {
            SynthesisProvider::Anthropic => req
                .header("x-api-key", key)
                .header("anthropic-version", "2023-06-01"),
            SynthesisProvider::Openrouter => req
                .header("Authorization", format!("Bearer {key}"))
                .header("HTTP-Referer", "https://github.com/XxAndrewOxX/AgentTrace")
                .header("X-Title", "agent-trace"),
            _ => req.header("Authorization", format!("Bearer {key}")),
        }
    }

    pub fn complete(&self, system: &str, user: &str) -> Result<String> {
        if self.provider == SynthesisProvider::Anthropic {
            return self.complete_anthropic(system, user);
        }
        self.complete_openai(system, user)
    }

    fn complete_openai(&self, system: &str, user: &str) -> Result<String> {
        #[derive(Serialize)]
        struct ChatRequest<'a> {
            model: &'a str,
            messages: Vec<Message<'a>>,
            max_tokens: usize,
            temperature: f32,
        }
        #[derive(Serialize)]
        struct Message<'a> {
            role: &'a str,
            content: &'a str,
        }
        #[derive(Deserialize)]
        struct ChatResponse {
            choices: Vec<Choice>,
        }
        #[derive(Deserialize)]
        struct Choice {
            message: ChoiceMessage,
        }
        #[derive(Deserialize)]
        struct ChoiceMessage {
            content: String,
        }

        let client = reqwest::blocking::Client::builder()
            .timeout(Duration::from_secs(120))
            .build()?;
        let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
        let body = ChatRequest {
            model: &self.model,
            messages: vec![
                Message {
                    role: "system",
                    content: system,
                },
                Message {
                    role: "user",
                    content: user,
                },
            ],
            max_tokens: self.max_tokens.min(4096),
            temperature: self.temperature,
        };
        let mut req = client.post(&url).json(&body);
        if let Some(key) = &self.api_key {
            req = self.apply_auth(req, key);
        }
        let resp = req.send().with_context(|| format!("POST {url}"))?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().unwrap_or_default();
            bail!("chat completion failed: HTTP {status}: {text}");
        }
        let parsed: ChatResponse = resp.json()?;
        parsed
            .choices
            .into_iter()
            .next()
            .map(|c| c.message.content)
            .filter(|s| !s.trim().is_empty())
            .ok_or_else(|| anyhow::anyhow!("empty completion response"))
    }

    fn complete_anthropic(&self, system: &str, user: &str) -> Result<String> {
        #[derive(Serialize)]
        struct AnthropicRequest<'a> {
            model: &'a str,
            max_tokens: usize,
            system: &'a str,
            messages: Vec<AnthropicMessage<'a>>,
        }
        #[derive(Serialize)]
        struct AnthropicMessage<'a> {
            role: &'a str,
            content: &'a str,
        }
        #[derive(Deserialize)]
        struct AnthropicResponse {
            content: Vec<AnthropicBlock>,
        }
        #[derive(Deserialize)]
        struct AnthropicBlock {
            text: String,
        }

        let key = self
            .api_key
            .as_deref()
            .ok_or_else(|| anyhow::anyhow!("anthropic API key required"))?;
        let client = reqwest::blocking::Client::builder()
            .timeout(Duration::from_secs(120))
            .build()?;
        let url = format!("{}/messages", self.base_url.trim_end_matches('/'));
        let body = AnthropicRequest {
            model: &self.model,
            max_tokens: self.max_tokens.min(4096),
            system,
            messages: vec![AnthropicMessage {
                role: "user",
                content: user,
            }],
        };
        let resp = client
            .post(&url)
            .header("x-api-key", key)
            .header("anthropic-version", "2023-06-01")
            .json(&body)
            .send()
            .with_context(|| format!("POST {url}"))?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().unwrap_or_default();
            bail!("anthropic completion failed: HTTP {status}: {text}");
        }
        let parsed: AnthropicResponse = resp.json()?;
        parsed
            .content
            .into_iter()
            .next()
            .map(|b| b.text)
            .filter(|s| !s.trim().is_empty())
            .ok_or_else(|| anyhow::anyhow!("empty anthropic response"))
    }

    pub fn label(&self) -> &str {
        &self.label
    }
}

const SYNTHESIS_SYSTEM: &str =
    "You are a concise technical writer for an agent document manager. Be factual and brief.";

impl SynthesisEngine for HttpBackend {
    fn summarize_change(&self, path: &str, doc_type: &str, diff: &str) -> Result<String, String> {
        let user = summarize_change_prompt(path, doc_type, diff);
        self.complete(SYNTHESIS_SYSTEM, &user)
            .map_err(|e| e.to_string())
    }

    fn synthesize_context(
        &self,
        documents: &[TraceDocument],
        updates: &[String],
    ) -> Result<String, String> {
        let docs = trace_to_doc_summaries(documents);
        let user = synthesize_context_prompt(&docs, updates);
        self.complete(SYNTHESIS_SYSTEM, &user)
            .map_err(|e| e.to_string())
    }

    fn update_running_summary(
        &self,
        previous: &str,
        events: &str,
        plan: &str,
    ) -> Result<String, String> {
        let user = update_running_summary_prompt(previous, events, plan);
        self.complete(SYNTHESIS_SYSTEM, &user)
            .map_err(|e| e.to_string())
    }

    fn summarize_session(&self, session_id: &str, events: &[String]) -> Result<String, String> {
        let user = summarize_session_prompt(session_id, events);
        self.complete(SYNTHESIS_SYSTEM, &user)
            .map_err(|e| e.to_string())
    }

    fn summarize_event_history(&self, events: &str) -> Result<String, String> {
        let user = summarize_event_history_prompt(events);
        self.complete(SYNTHESIS_SYSTEM, &user)
            .map_err(|e| e.to_string())
    }

    fn backend_label(&self) -> &str {
        &self.label
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn http_backend_construct() {
        let cfg = SynthesisConfig::default();
        let b = HttpBackend::from_config(SynthesisProvider::Ollama, &cfg, None, "ollama");
        assert_eq!(b.model, "qwen2.5:1.5b");
    }
}