- 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)
360 lines
11 KiB
Rust
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;
|