synops-agent fase 1: core agent loop med alle providers

Modell-agnostisk agent-runtime i Rust. Egen provider-kode (ingen Rig-dep).
Støtter: OpenRouter, Anthropic, Gemini, xAI, OpenAI, Ollama.
Tool-loop: prompt → tool_calls → execute → loop.
Innebygde verktøy: read_file, write_file, edit_file, bash, grep, glob.
Token-regnskap per modell. --claude for å spawne Claude Code.
Kompilerer og kjører. Trenger API-nøkkel for å faktisk gjøre noe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-19 17:41:28 +00:00
parent 945e5a90dc
commit 2e3433798f
5 changed files with 4318 additions and 0 deletions

2941
tools/synops-agent/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
[package]
name = "synops-agent"
version = "0.1.0"
edition = "2024"
description = "Modell-agnostisk agent-runtime for Synops. Erstatter Claude Code."
[[bin]]
name = "synops-agent"
path = "src/main.rs"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# CLI
clap = { version = "4", features = ["derive"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# HTTP for LLM API calls
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Shared Synops lib (PG, CAS, nodes/edges)
synops-common = { path = "../synops-common" }
# Async trait for provider abstraction
async-trait = "0.1"
# Error handling
thiserror = "2"
# Misc
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }

View file

@ -0,0 +1,275 @@
//! synops-agent — modell-agnostisk agent-runtime.
//!
//! Erstatter Claude Code. Støtter OpenRouter, Anthropic, Gemini,
//! xAI, OpenAI og Ollama. Tool-loop med fil-operasjoner og shell.
//!
//! Bruk:
//! synops-agent --model openrouter/anthropic/claude-sonnet-4 --task "fiks buggen"
//! synops-agent --model gemini/gemini-2.5-flash --task "oppsummer denne filen"
//! synops-agent --model ollama/llama3 --task "skriv en test"
mod provider;
mod tools;
use clap::Parser;
use provider::{ApiKeys, CompletionResponse, Message, TokenUsage, create_provider};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "synops-agent", about = "Modell-agnostisk agent-runtime for Synops")]
struct Cli {
/// Modell: "provider/model" (f.eks. "openrouter/anthropic/claude-sonnet-4")
#[arg(short, long, default_value = "openrouter/anthropic/claude-sonnet-4")]
model: String,
/// Oppgave å utføre
#[arg(short, long)]
task: String,
/// System-prompt (valgfritt, legges til før oppgaven)
#[arg(short, long)]
system: Option<String>,
/// Arbeidsmappe (default: gjeldende)
#[arg(short = 'd', long, default_value = ".")]
working_dir: PathBuf,
/// Maks antall tool-loop iterasjoner
#[arg(long, default_value = "50")]
max_iterations: usize,
/// Vis token-forbruk underveis
#[arg(long)]
verbose: bool,
/// Spawn Claude Code i stedet (bruker abonnement)
#[arg(long)]
claude: bool,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("synops_agent=info".parse().unwrap()),
)
.init();
let cli = Cli::parse();
// Spawn Claude Code direkte hvis --claude
if cli.claude {
return spawn_claude_code(&cli.task, &cli.working_dir).await;
}
let api_keys = ApiKeys::from_env();
let provider = create_provider(&cli.model, &api_keys)?;
tracing::info!(
model = provider.model_id(),
provider = provider.name(),
"Starter agent"
);
// Build system prompt
let system_prompt = build_system_prompt(cli.system.as_deref(), &cli.working_dir);
// Initialize messages
let mut messages = vec![
Message {
role: "system".into(),
content: Some(system_prompt),
tool_calls: None,
tool_call_id: None,
},
Message {
role: "user".into(),
content: Some(cli.task.clone()),
tool_calls: None,
tool_call_id: None,
},
];
let tool_defs = tools::tool_definitions();
// Token accounting
let mut total_usage: HashMap<String, TokenUsage> = HashMap::new();
let mut iteration = 0;
// === Agent loop ===
loop {
iteration += 1;
if iteration > cli.max_iterations {
tracing::warn!("Nådde maks iterasjoner ({})", cli.max_iterations);
break;
}
// Call LLM
let response: CompletionResponse = provider.complete(&messages, &tool_defs).await?;
// Accumulate token usage
let entry = total_usage
.entry(response.model.clone())
.or_insert_with(TokenUsage::default);
entry.input_tokens += response.usage.input_tokens;
entry.output_tokens += response.usage.output_tokens;
if cli.verbose {
tracing::info!(
iteration,
input = response.usage.input_tokens,
output = response.usage.output_tokens,
"LLM-kall"
);
}
// Check for tool calls
let has_tool_calls = response
.message
.tool_calls
.as_ref()
.map(|tc| !tc.is_empty())
.unwrap_or(false);
// Print any text response
if let Some(ref text) = response.message.content {
if !text.is_empty() {
println!("{}", text);
}
}
// Add assistant message to history
messages.push(response.message.clone());
if !has_tool_calls {
// No tool calls — agent is done
break;
}
// Execute tool calls
let tool_calls = response.message.tool_calls.unwrap();
for tc in &tool_calls {
let args: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or_default();
tracing::info!(
tool = tc.function.name,
"Kjører verktøy"
);
let result = tools::execute_tool(&tc.function.name, &args, &cli.working_dir).await;
if cli.verbose {
let preview = if result.len() > 200 {
format!("{}...", &result[..200])
} else {
result.clone()
};
tracing::info!(tool = tc.function.name, result = preview, "Resultat");
}
// Add tool result to messages
messages.push(Message {
role: "tool".into(),
content: Some(result),
tool_calls: None,
tool_call_id: Some(tc.id.clone()),
});
}
}
// Print token summary
eprintln!("\n--- Token-forbruk ---");
let mut total_in = 0u64;
let mut total_out = 0u64;
for (model, usage) in &total_usage {
eprintln!(
" {}: {} inn / {} ut",
model, usage.input_tokens, usage.output_tokens
);
total_in += usage.input_tokens;
total_out += usage.output_tokens;
}
eprintln!(" Totalt: {} inn / {} ut", total_in, total_out);
eprintln!(" Iterasjoner: {}", iteration);
Ok(())
}
/// Build system prompt with context about the project.
fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String {
let mut prompt = String::new();
prompt.push_str("Du er synops-agent, en autonom utviklerassistent for Synops-plattformen.\n");
prompt.push_str("Du har tilgang til verktøy for å lese, skrive og redigere filer, ");
prompt.push_str("kjøre shell-kommandoer, og søke i koden.\n\n");
prompt.push_str("Arbeidsmappe: ");
prompt.push_str(&working_dir.display().to_string());
prompt.push_str("\n\n");
// Try to read CLAUDE.md for project context
let claude_md = working_dir.join("CLAUDE.md");
if claude_md.exists() {
if let Ok(content) = std::fs::read_to_string(&claude_md) {
// Truncate if very long
let truncated = if content.len() > 8000 {
format!("{}...\n(truncated)", &content[..8000])
} else {
content
};
prompt.push_str("# Prosjektkontekst (fra CLAUDE.md)\n\n");
prompt.push_str(&truncated);
prompt.push_str("\n\n");
}
}
if let Some(custom) = custom {
prompt.push_str("# Tilleggsinstruksjoner\n\n");
prompt.push_str(custom);
prompt.push_str("\n\n");
}
prompt.push_str("Regler:\n");
prompt.push_str("- Les relevante filer før du endrer dem\n");
prompt.push_str("- Gjør minimale, fokuserte endringer\n");
prompt.push_str("- Test at kode kompilerer (cargo check, npm run build)\n");
prompt.push_str("- Commit og push når oppgaven er ferdig\n");
prompt
}
/// Spawn Claude Code for heavy tasks (uses paid subscription).
async fn spawn_claude_code(
task: &str,
working_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("Spawner Claude Code for oppgaven");
let output = tokio::process::Command::new("claude")
.arg("-p")
.arg(task)
.arg("--dangerously-skip-permissions")
.current_dir(working_dir)
.output()
.await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.is_empty() {
println!("{}", stdout);
}
if !stderr.is_empty() {
eprintln!("{}", stderr);
}
if !output.status.success() {
eprintln!("Claude Code avsluttet med kode: {}", output.status.code().unwrap_or(-1));
}
Ok(())
}
use std::path::Path;

View file

@ -0,0 +1,690 @@
//! LLM Provider abstraction.
//!
//! Alle store LLM-APIer er varianter av "send messages + tools, få svar tilbake".
//! OpenAI-kompatibelt format (OpenRouter, xAI, OpenAI, Groq, Ollama) bruker
//! identisk wire format. Anthropic og Gemini har egne formater.
//!
//! Vi støtter alle via en felles trait.
use serde::{Deserialize, Serialize};
// ============================================================================
// Felles typer
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: String, // "system", "user", "assistant", "tool"
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub r#type: String, // "function"
pub function: FunctionCall,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCall {
pub name: String,
pub arguments: String, // JSON string
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef {
pub r#type: String, // "function"
pub function: FunctionDef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionDef {
pub name: String,
pub description: String,
pub parameters: serde_json::Value, // JSON Schema
}
#[derive(Debug, Clone)]
pub struct CompletionResponse {
pub message: Message,
pub usage: TokenUsage,
pub model: String,
}
#[derive(Debug, Clone, Default)]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
}
// ============================================================================
// Provider trait
// ============================================================================
#[async_trait::async_trait]
pub trait LlmProvider: Send + Sync {
/// Send messages + tools, get completion back.
async fn complete(
&self,
messages: &[Message],
tools: &[ToolDef],
) -> Result<CompletionResponse, ProviderError>;
/// Provider name (for logging)
fn name(&self) -> &str;
/// Model identifier
fn model_id(&self) -> &str;
}
#[derive(Debug, thiserror::Error)]
pub enum ProviderError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error ({status}): {body}")]
Api { status: u16, body: String },
#[error("Parse error: {0}")]
Parse(String),
#[error("No API key configured for {provider}")]
NoApiKey { provider: String },
}
// ============================================================================
// OpenAI-compatible provider (OpenRouter, xAI, OpenAI, Groq, Ollama)
// ============================================================================
pub struct OpenAiCompatible {
client: reqwest::Client,
base_url: String,
api_key: String,
model: String,
provider_name: String,
}
impl OpenAiCompatible {
pub fn new(
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
provider_name: impl Into<String>,
) -> Self {
Self {
client: reqwest::Client::new(),
base_url: base_url.into(),
api_key: api_key.into(),
model: model.into(),
provider_name: provider_name.into(),
}
}
/// OpenRouter
pub fn openrouter(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(
"https://openrouter.ai/api/v1",
api_key,
model,
"openrouter",
)
}
/// OpenAI
pub fn openai(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(
"https://api.openai.com/v1",
api_key,
model,
"openai",
)
}
/// xAI (Grok)
pub fn xai(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(
"https://api.x.ai/v1",
api_key,
model,
"xai",
)
}
/// Ollama (lokal)
pub fn ollama(model: impl Into<String>) -> Self {
Self::new(
"http://localhost:11434/v1",
"ollama", // Ollama trenger ikke key
model,
"ollama",
)
}
}
#[async_trait::async_trait]
impl LlmProvider for OpenAiCompatible {
async fn complete(
&self,
messages: &[Message],
tools: &[ToolDef],
) -> Result<CompletionResponse, ProviderError> {
let mut body = serde_json::json!({
"model": self.model,
"messages": messages,
});
if !tools.is_empty() {
body["tools"] = serde_json::to_value(tools).unwrap();
}
let res = self
.client
.post(format!("{}/chat/completions", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
let status = res.status().as_u16();
if status >= 400 {
let body = res.text().await.unwrap_or_default();
return Err(ProviderError::Api { status, body });
}
let data: serde_json::Value = res.json().await?;
let choice = data["choices"]
.get(0)
.ok_or_else(|| ProviderError::Parse("No choices in response".into()))?;
let msg = &choice["message"];
let tool_calls: Option<Vec<ToolCall>> = msg
.get("tool_calls")
.and_then(|tc| serde_json::from_value(tc.clone()).ok());
let message = Message {
role: "assistant".into(),
content: msg["content"].as_str().map(|s| s.to_string()),
tool_calls,
tool_call_id: None,
};
let usage = TokenUsage {
input_tokens: data["usage"]["prompt_tokens"].as_u64().unwrap_or(0),
output_tokens: data["usage"]["completion_tokens"].as_u64().unwrap_or(0),
};
let model = data["model"]
.as_str()
.unwrap_or(&self.model)
.to_string();
Ok(CompletionResponse {
message,
usage,
model,
})
}
fn name(&self) -> &str {
&self.provider_name
}
fn model_id(&self) -> &str {
&self.model
}
}
// ============================================================================
// Anthropic provider
// ============================================================================
pub struct Anthropic {
client: reqwest::Client,
api_key: String,
model: String,
}
impl Anthropic {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
model: model.into(),
}
}
}
#[async_trait::async_trait]
impl LlmProvider for Anthropic {
async fn complete(
&self,
messages: &[Message],
tools: &[ToolDef],
) -> Result<CompletionResponse, ProviderError> {
// Convert OpenAI-style messages to Anthropic format
let system = messages
.iter()
.filter(|m| m.role == "system")
.filter_map(|m| m.content.as_deref())
.collect::<Vec<_>>()
.join("\n\n");
let anthropic_messages: Vec<serde_json::Value> = messages
.iter()
.filter(|m| m.role != "system")
.map(|m| {
if m.role == "tool" {
serde_json::json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": m.tool_call_id,
"content": m.content,
}]
})
} else if let Some(ref tool_calls) = m.tool_calls {
let content: Vec<serde_json::Value> = tool_calls
.iter()
.map(|tc| {
serde_json::json!({
"type": "tool_use",
"id": tc.id,
"name": tc.function.name,
"input": serde_json::from_str::<serde_json::Value>(&tc.function.arguments)
.unwrap_or(serde_json::Value::Object(Default::default())),
})
})
.collect();
serde_json::json!({ "role": "assistant", "content": content })
} else {
serde_json::json!({
"role": m.role,
"content": m.content.as_deref().unwrap_or(""),
})
}
})
.collect();
let anthropic_tools: Vec<serde_json::Value> = tools
.iter()
.map(|t| {
serde_json::json!({
"name": t.function.name,
"description": t.function.description,
"input_schema": t.function.parameters,
})
})
.collect();
let mut body = serde_json::json!({
"model": self.model,
"max_tokens": 8192,
"messages": anthropic_messages,
});
if !system.is_empty() {
body["system"] = serde_json::Value::String(system);
}
if !anthropic_tools.is_empty() {
body["tools"] = serde_json::to_value(&anthropic_tools).unwrap();
}
let res = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
let status = res.status().as_u16();
if status >= 400 {
let body = res.text().await.unwrap_or_default();
return Err(ProviderError::Api { status, body });
}
let data: serde_json::Value = res.json().await?;
// Parse Anthropic response → our common format
let content_blocks = data["content"].as_array();
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
if let Some(blocks) = content_blocks {
for block in blocks {
match block["type"].as_str() {
Some("text") => {
if let Some(t) = block["text"].as_str() {
text_parts.push(t.to_string());
}
}
Some("tool_use") => {
tool_calls.push(ToolCall {
id: block["id"].as_str().unwrap_or("").to_string(),
r#type: "function".into(),
function: FunctionCall {
name: block["name"].as_str().unwrap_or("").to_string(),
arguments: serde_json::to_string(&block["input"])
.unwrap_or_default(),
},
});
}
_ => {}
}
}
}
let message = Message {
role: "assistant".into(),
content: if text_parts.is_empty() {
None
} else {
Some(text_parts.join("\n"))
},
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: None,
};
let usage = TokenUsage {
input_tokens: data["usage"]["input_tokens"].as_u64().unwrap_or(0),
output_tokens: data["usage"]["output_tokens"].as_u64().unwrap_or(0),
};
Ok(CompletionResponse {
message,
usage,
model: self.model.clone(),
})
}
fn name(&self) -> &str {
"anthropic"
}
fn model_id(&self) -> &str {
&self.model
}
}
// ============================================================================
// Google Gemini provider
// ============================================================================
pub struct Gemini {
client: reqwest::Client,
api_key: String,
model: String,
}
impl Gemini {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
model: model.into(),
}
}
}
#[async_trait::async_trait]
impl LlmProvider for Gemini {
async fn complete(
&self,
messages: &[Message],
tools: &[ToolDef],
) -> Result<CompletionResponse, ProviderError> {
// Convert to Gemini format
let system = messages
.iter()
.filter(|m| m.role == "system")
.filter_map(|m| m.content.as_deref())
.collect::<Vec<_>>()
.join("\n\n");
let contents: Vec<serde_json::Value> = messages
.iter()
.filter(|m| m.role != "system")
.map(|m| {
let role = match m.role.as_str() {
"assistant" => "model",
"tool" => "function",
_ => "user",
};
if m.role == "tool" {
serde_json::json!({
"role": "function",
"parts": [{
"functionResponse": {
"name": m.tool_call_id.as_deref().unwrap_or("unknown"),
"response": { "result": m.content.as_deref().unwrap_or("") }
}
}]
})
} else if let Some(ref tool_calls) = m.tool_calls {
let parts: Vec<serde_json::Value> = tool_calls
.iter()
.map(|tc| {
serde_json::json!({
"functionCall": {
"name": tc.function.name,
"args": serde_json::from_str::<serde_json::Value>(&tc.function.arguments)
.unwrap_or_default(),
}
})
})
.collect();
serde_json::json!({ "role": role, "parts": parts })
} else {
serde_json::json!({
"role": role,
"parts": [{ "text": m.content.as_deref().unwrap_or("") }]
})
}
})
.collect();
let gemini_tools: Vec<serde_json::Value> = if tools.is_empty() {
vec![]
} else {
let declarations: Vec<serde_json::Value> = tools
.iter()
.map(|t| {
serde_json::json!({
"name": t.function.name,
"description": t.function.description,
"parameters": t.function.parameters,
})
})
.collect();
vec![serde_json::json!({ "functionDeclarations": declarations })]
};
let mut body = serde_json::json!({
"contents": contents,
});
if !system.is_empty() {
body["systemInstruction"] = serde_json::json!({
"parts": [{ "text": system }]
});
}
if !gemini_tools.is_empty() {
body["tools"] = serde_json::to_value(&gemini_tools).unwrap();
}
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
self.model, self.api_key
);
let res = self
.client
.post(&url)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
let status = res.status().as_u16();
if status >= 400 {
let body = res.text().await.unwrap_or_default();
return Err(ProviderError::Api { status, body });
}
let data: serde_json::Value = res.json().await?;
// Parse Gemini response
let parts = data["candidates"][0]["content"]["parts"]
.as_array();
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
if let Some(parts) = parts {
for (i, part) in parts.iter().enumerate() {
if let Some(text) = part["text"].as_str() {
text_parts.push(text.to_string());
}
if let Some(fc) = part.get("functionCall") {
tool_calls.push(ToolCall {
id: format!("call_{}", i),
r#type: "function".into(),
function: FunctionCall {
name: fc["name"].as_str().unwrap_or("").to_string(),
arguments: serde_json::to_string(&fc["args"])
.unwrap_or_default(),
},
});
}
}
}
let message = Message {
role: "assistant".into(),
content: if text_parts.is_empty() {
None
} else {
Some(text_parts.join("\n"))
},
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: None,
};
let usage = TokenUsage {
input_tokens: data["usageMetadata"]["promptTokenCount"]
.as_u64()
.unwrap_or(0),
output_tokens: data["usageMetadata"]["candidatesTokenCount"]
.as_u64()
.unwrap_or(0),
};
Ok(CompletionResponse {
message,
usage,
model: self.model.clone(),
})
}
fn name(&self) -> &str {
"gemini"
}
fn model_id(&self) -> &str {
&self.model
}
}
// ============================================================================
// Provider factory
// ============================================================================
/// Create a provider from a model string.
///
/// Format: "provider/model" or just "model" (defaults to openrouter).
///
/// Examples:
/// "openrouter/anthropic/claude-sonnet-4"
/// "anthropic/claude-sonnet-4"
/// "gemini/gemini-2.5-flash"
/// "xai/grok-3"
/// "openai/gpt-4o"
/// "ollama/llama3"
pub fn create_provider(
model_spec: &str,
api_keys: &ApiKeys,
) -> Result<Box<dyn LlmProvider>, ProviderError> {
let (provider, model) = if let Some(idx) = model_spec.find('/') {
(&model_spec[..idx], &model_spec[idx + 1..])
} else {
("openrouter", model_spec)
};
match provider {
"openrouter" => {
let key = api_keys.openrouter.as_deref()
.ok_or_else(|| ProviderError::NoApiKey { provider: "openrouter".into() })?;
Ok(Box::new(OpenAiCompatible::openrouter(key, model)))
}
"anthropic" => {
let key = api_keys.anthropic.as_deref()
.ok_or_else(|| ProviderError::NoApiKey { provider: "anthropic".into() })?;
Ok(Box::new(Anthropic::new(key, model)))
}
"gemini" | "google" => {
let key = api_keys.gemini.as_deref()
.ok_or_else(|| ProviderError::NoApiKey { provider: "gemini".into() })?;
Ok(Box::new(Gemini::new(key, model)))
}
"xai" | "grok" => {
let key = api_keys.xai.as_deref()
.ok_or_else(|| ProviderError::NoApiKey { provider: "xai".into() })?;
Ok(Box::new(OpenAiCompatible::xai(key, model)))
}
"openai" => {
let key = api_keys.openai.as_deref()
.ok_or_else(|| ProviderError::NoApiKey { provider: "openai".into() })?;
Ok(Box::new(OpenAiCompatible::openai(key, model)))
}
"ollama" | "local" => {
Ok(Box::new(OpenAiCompatible::ollama(model)))
}
_ => Err(ProviderError::Parse(format!("Unknown provider: {}", provider))),
}
}
/// API keys loaded from environment
#[derive(Debug, Clone, Default)]
pub struct ApiKeys {
pub openrouter: Option<String>,
pub anthropic: Option<String>,
pub gemini: Option<String>,
pub xai: Option<String>,
pub openai: Option<String>,
}
impl ApiKeys {
pub fn from_env() -> Self {
Self {
openrouter: std::env::var("OPENROUTER_API_KEY").ok().filter(|s| s != "PLACEHOLDER" && !s.is_empty()),
anthropic: std::env::var("ANTHROPIC_API_KEY").ok().filter(|s| !s.is_empty()),
gemini: std::env::var("GEMINI_API_KEY").ok().filter(|s| !s.is_empty()),
xai: std::env::var("XAI_API_KEY").ok().filter(|s| !s.is_empty()),
openai: std::env::var("OPENAI_API_KEY").ok().filter(|s| !s.is_empty()),
}
}
}

View file

@ -0,0 +1,372 @@
//! Agent tools — file operations, shell, search.
//!
//! Each tool returns a string result that gets sent back to the LLM.
use crate::provider::{ToolDef, FunctionDef};
use std::path::Path;
use tokio::process::Command;
/// Execute a tool call by name. Returns the output string.
pub async fn execute_tool(
name: &str,
args: &serde_json::Value,
working_dir: &Path,
) -> String {
let result = match name {
"read_file" => read_file(args, working_dir).await,
"write_file" => write_file(args, working_dir).await,
"edit_file" => edit_file(args, working_dir).await,
"bash" => bash(args, working_dir).await,
"grep" => grep(args, working_dir).await,
"glob" => glob_search(args, working_dir).await,
"list_files" => list_files(args, working_dir).await,
_ => Err(format!("Unknown tool: {}", name)),
};
match result {
Ok(output) => output,
Err(e) => format!("Error: {}", e),
}
}
/// All available tool definitions (JSON Schema for the LLM).
pub fn tool_definitions() -> Vec<ToolDef> {
vec![
ToolDef {
r#type: "function".into(),
function: FunctionDef {
name: "read_file".into(),
description: "Read a file from disk. Returns the file contents. Use offset/limit for large files.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"file_path": { "type": "string", "description": "Absolute or relative path to the file" },
"offset": { "type": "integer", "description": "Line number to start reading from (1-based, optional)" },
"limit": { "type": "integer", "description": "Number of lines to read (optional)" }
},
"required": ["file_path"]
}),
},
},
ToolDef {
r#type: "function".into(),
function: FunctionDef {
name: "write_file".into(),
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"file_path": { "type": "string", "description": "Path to the file" },
"content": { "type": "string", "description": "Content to write" }
},
"required": ["file_path", "content"]
}),
},
},
ToolDef {
r#type: "function".into(),
function: FunctionDef {
name: "edit_file".into(),
description: "Find and replace text in a file. old_string must match exactly and uniquely.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"file_path": { "type": "string", "description": "Path to the file" },
"old_string": { "type": "string", "description": "Exact text to find" },
"new_string": { "type": "string", "description": "Text to replace with" },
"replace_all": { "type": "boolean", "description": "Replace all occurrences (default: false)" }
},
"required": ["file_path", "old_string", "new_string"]
}),
},
},
ToolDef {
r#type: "function".into(),
function: FunctionDef {
name: "bash".into(),
description: "Execute a shell command. Returns stdout and stderr.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The command to execute" },
"timeout_ms": { "type": "integer", "description": "Timeout in milliseconds (default: 30000)" }
},
"required": ["command"]
}),
},
},
ToolDef {
r#type: "function".into(),
function: FunctionDef {
name: "grep".into(),
description: "Search file contents using regex. Returns matching lines with file paths.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regex pattern to search for" },
"path": { "type": "string", "description": "Directory or file to search in (default: current dir)" },
"include": { "type": "string", "description": "Glob pattern for files to include (e.g. '*.rs')" }
},
"required": ["pattern"]
}),
},
},
ToolDef {
r#type: "function".into(),
function: FunctionDef {
name: "glob".into(),
description: "Find files matching a glob pattern. Returns file paths.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Glob pattern (e.g. '**/*.rs', 'src/**/*.ts')" },
"path": { "type": "string", "description": "Base directory (default: current dir)" }
},
"required": ["pattern"]
}),
},
},
ToolDef {
r#type: "function".into(),
function: FunctionDef {
name: "list_files".into(),
description: "List files and directories in a path.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory to list (default: current dir)" }
}
}),
},
},
]
}
// ============================================================================
// Tool implementations
// ============================================================================
async fn read_file(
args: &serde_json::Value,
working_dir: &Path,
) -> Result<String, String> {
let file_path = args["file_path"]
.as_str()
.ok_or("file_path is required")?;
let path = resolve_path(file_path, working_dir);
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let lines: Vec<&str> = content.lines().collect();
let offset = args["offset"].as_u64().unwrap_or(1).max(1) as usize - 1;
let limit = args["limit"].as_u64().map(|l| l as usize);
let end = limit
.map(|l| (offset + l).min(lines.len()))
.unwrap_or(lines.len());
let selected: Vec<String> = lines[offset.min(lines.len())..end]
.iter()
.enumerate()
.map(|(i, line)| format!("{:>5}\t{}", offset + i + 1, line))
.collect();
Ok(selected.join("\n"))
}
async fn write_file(
args: &serde_json::Value,
working_dir: &Path,
) -> Result<String, String> {
let file_path = args["file_path"]
.as_str()
.ok_or("file_path is required")?;
let content = args["content"]
.as_str()
.ok_or("content is required")?;
let path = resolve_path(file_path, working_dir);
// Create parent directories
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create directories: {}", e))?;
}
tokio::fs::write(&path, content)
.await
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(format!("Wrote {} bytes to {}", content.len(), path.display()))
}
async fn edit_file(
args: &serde_json::Value,
working_dir: &Path,
) -> Result<String, String> {
let file_path = args["file_path"]
.as_str()
.ok_or("file_path is required")?;
let old_string = args["old_string"]
.as_str()
.ok_or("old_string is required")?;
let new_string = args["new_string"]
.as_str()
.ok_or("new_string is required")?;
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
let path = resolve_path(file_path, working_dir);
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let count = content.matches(old_string).count();
if count == 0 {
return Err(format!(
"old_string not found in {}",
path.display()
));
}
if count > 1 && !replace_all {
return Err(format!(
"old_string found {} times in {} — use replace_all or provide more context",
count,
path.display()
));
}
let new_content = if replace_all {
content.replace(old_string, new_string)
} else {
content.replacen(old_string, new_string, 1)
};
tokio::fs::write(&path, &new_content)
.await
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(format!(
"Replaced {} occurrence(s) in {}",
if replace_all { count } else { 1 },
path.display()
))
}
async fn bash(
args: &serde_json::Value,
working_dir: &Path,
) -> Result<String, String> {
let command = args["command"]
.as_str()
.ok_or("command is required")?;
let timeout_ms = args["timeout_ms"].as_u64().unwrap_or(30_000);
let output = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
Command::new("bash")
.arg("-c")
.arg(command)
.current_dir(working_dir)
.output(),
)
.await
.map_err(|_| format!("Command timed out after {}ms", timeout_ms))?
.map_err(|e| format!("Failed to execute: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("STDERR:\n");
result.push_str(&stderr);
}
if !output.status.success() {
result.push_str(&format!("\nExit code: {}", output.status.code().unwrap_or(-1)));
}
// Truncate very long output
if result.len() > 50_000 {
result.truncate(50_000);
result.push_str("\n... (truncated)");
}
Ok(result)
}
async fn grep(
args: &serde_json::Value,
working_dir: &Path,
) -> Result<String, String> {
let pattern = args["pattern"]
.as_str()
.ok_or("pattern is required")?;
let path = args["path"]
.as_str()
.unwrap_or(".");
let include = args["include"].as_str();
let mut cmd_str = format!("rg -n --color=never '{}' {}", pattern, path);
if let Some(inc) = include {
cmd_str = format!("rg -n --color=never --glob '{}' '{}' {}", inc, pattern, path);
}
bash(
&serde_json::json!({ "command": cmd_str, "timeout_ms": 10000 }),
working_dir,
)
.await
}
async fn glob_search(
args: &serde_json::Value,
working_dir: &Path,
) -> Result<String, String> {
let pattern = args["pattern"]
.as_str()
.ok_or("pattern is required")?;
let base = args["path"].as_str().unwrap_or(".");
let cmd_str = format!("find {} -path '{}' -type f 2>/dev/null | sort | head -100", base, pattern);
bash(
&serde_json::json!({ "command": cmd_str, "timeout_ms": 10000 }),
working_dir,
)
.await
}
async fn list_files(
args: &serde_json::Value,
working_dir: &Path,
) -> Result<String, String> {
let path = args["path"].as_str().unwrap_or(".");
bash(
&serde_json::json!({ "command": format!("ls -la {}", path), "timeout_ms": 5000 }),
working_dir,
)
.await
}
// ============================================================================
// Helpers
// ============================================================================
fn resolve_path(file_path: &str, working_dir: &Path) -> std::path::PathBuf {
let p = Path::new(file_path);
if p.is_absolute() {
p.to_path_buf()
} else {
working_dir.join(p)
}
}