//! 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 context; mod provider; mod tools; use clap::Parser; use context::{CompactionConfig, CompactionLevel, check_compaction_level, compact_messages}; use provider::{ ApiKeys, Message, RetryConfig, TokenUsage, calculate_cost, complete_with_retry, 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, /// 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, /// Maks kostnad i USD (f.eks. 0.50). Stopper og rapporterer ved grense. #[arg(long)] max_cost: Option, /// Maks output-tokens per LLM-kall (overstyrer provider-default) #[arg(long)] max_tokens: Option, /// Maks antall retries ved API-feil (default: 3) #[arg(long, default_value = "3")] max_retries: u32, } #[tokio::main] async fn main() -> Result<(), Box> { 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, cli.max_tokens)?; 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 = HashMap::new(); let mut total_cost: f64 = 0.0; let mut iteration = 0; let mut budget_exhausted = false; // Retry config let retry_config = RetryConfig { max_retries: cli.max_retries, ..Default::default() }; // Context compaction config let compaction_config = CompactionConfig { context_window: provider.context_window(), ..Default::default() }; tracing::info!( context_window = compaction_config.context_window, max_cost = cli.max_cost.map(|c| format!("${:.2}", c)).as_deref().unwrap_or("unlimited"), "Agent konfigurert" ); // === Agent loop === loop { iteration += 1; if iteration > cli.max_iterations { tracing::warn!("Nådde maks iterasjoner ({})", cli.max_iterations); break; } // Budget check before calling LLM if let Some(max_cost) = cli.max_cost { if total_cost >= max_cost { budget_exhausted = true; tracing::warn!( cost = format!("${:.4}", total_cost), budget = format!("${:.2}", max_cost), "Budsjettgrense nådd — stopper" ); break; } } // Call LLM with retry let response = complete_with_retry( provider.as_ref(), &messages, &tool_defs, &retry_config, ).await?; // Accumulate token usage and cost 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; let call_cost = calculate_cost(&response.model, &response.usage); total_cost += call_cost; if cli.verbose { tracing::info!( iteration, input = response.usage.input_tokens, output = response.usage.output_tokens, call_cost = format!("${:.4}", call_cost), total_cost = format!("${:.4}", total_cost), "LLM-kall" ); } // === Adaptive Context Compaction === let level = check_compaction_level(response.usage.input_tokens, &compaction_config); if level != CompactionLevel::None { let ratio = response.usage.input_tokens as f64 / compaction_config.context_window as f64; tracing::warn!( prompt_tokens = response.usage.input_tokens, context_window = compaction_config.context_window, ratio = format!("{:.1}%", ratio * 100.0), level = ?level, "ACC: kontekstkomprimering trigget" ); compact_messages(&mut messages, level, &compaction_config); } // 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 { let model_cost = calculate_cost(model, usage); eprintln!( " {}: {} inn / {} ut (${:.4})", model, usage.input_tokens, usage.output_tokens, model_cost ); total_in += usage.input_tokens; total_out += usage.output_tokens; } eprintln!(" Totalt: {} inn / {} ut", total_in, total_out); eprintln!(" Kostnad: ${:.4}", total_cost); if let Some(max_cost) = cli.max_cost { eprintln!(" Budsjett: ${:.2} ({:.0}% brukt)", max_cost, (total_cost / max_cost) * 100.0); } eprintln!(" Iterasjoner: {}", iteration); if budget_exhausted { eprintln!("\n⚠ Budsjettgrense nådd. Oppgaven er ikke fullført."); eprintln!(" Gjenstående arbeid bør fortsettes med høyere --max-cost"); eprintln!(" eller manuelt. Kontekst kan gjenopprettes fra meldingsloggen."); std::process::exit(2); } 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> { 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;