synops/tools/synops-agent/src/main.rs
vegard 0bfad1eb8a Implementer retry med backoff og token-budsjett i synops-agent
- Retry med exponential backoff for retryable API-feil (429, 500, 502, 503)
  med konfigurerbar --max-retries (default: 3) og Retry-After-støtte
- --max-cost flagg for token-budsjett (USD), stopper og rapporterer
  gjenstående arbeid ved budsjettgrense (exit code 2)
- Konfigurerbar --max-tokens per provider (erstatter hardkodet 4096/8192)
- Sanntids kostnadsregnskap per modell med cost_per_million_tokens-tabell
- Detaljert token/kostnad-rapport ved avslutning

Ref: docs/proposals/agent_harness.md §3 (selvovervåking)
2026-03-19 18:12:27 +00:00

360 lines
11 KiB
Rust

//! 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<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,
/// Maks kostnad i USD (f.eks. 0.50). Stopper og rapporterer ved grense.
#[arg(long)]
max_cost: Option<f64>,
/// Maks output-tokens per LLM-kall (overstyrer provider-default)
#[arg(long)]
max_tokens: Option<u32>,
/// Maks antall retries ved API-feil (default: 3)
#[arg(long, default_value = "3")]
max_retries: u32,
}
#[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, 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<String, TokenUsage> = 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<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;