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