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:
parent
945e5a90dc
commit
2e3433798f
5 changed files with 4318 additions and 0 deletions
2941
tools/synops-agent/Cargo.lock
generated
Normal file
2941
tools/synops-agent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
40
tools/synops-agent/Cargo.toml
Normal file
40
tools/synops-agent/Cargo.toml
Normal 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"] }
|
||||||
275
tools/synops-agent/src/main.rs
Normal file
275
tools/synops-agent/src/main.rs
Normal 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;
|
||||||
690
tools/synops-agent/src/provider.rs
Normal file
690
tools/synops-agent/src/provider.rs
Normal 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
372
tools/synops-agent/src/tools.rs
Normal file
372
tools/synops-agent/src/tools.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue