From a8b6c7ca7b0a43f78311cb9ab66e2aadd0f711b2 Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 18:16:54 +0000 Subject: [PATCH] Implementer interaktiv REPL-modus i synops-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --interactive / -i starter REPL med readline (rustyline) - Token-teller og kostnad i prompt: [12.3kT $0.042] claude-sonnet-4 > - Ctrl+C avbryter pågående tool-kall, ikke hele agenten - Meldingshistorikk bevares mellom turns - Multi-line input med \ på slutten av linjen - Innebygde kommandoer: /stats, /clear, /help, exit - Historikk lagres i ~/.synops/agent_history.txt - Refaktorert agent-loop til AgentSession struct for gjenbruk - --task er nå valgfri (påkrevd kun i batch-modus) --- tools/synops-agent/Cargo.toml | 6 + tools/synops-agent/src/main.rs | 665 +++++++++++++++++++++++---------- 2 files changed, 482 insertions(+), 189 deletions(-) diff --git a/tools/synops-agent/Cargo.toml b/tools/synops-agent/Cargo.toml index 4b909f4..3b6cf98 100644 --- a/tools/synops-agent/Cargo.toml +++ b/tools/synops-agent/Cargo.toml @@ -35,6 +35,12 @@ async-trait = "0.1" # Error handling thiserror = "2" +# Readline for interactive mode +rustyline = "15" + +# Ctrl+C handling +ctrlc = "3" + # Misc uuid = { version = "1", features = ["v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/tools/synops-agent/src/main.rs b/tools/synops-agent/src/main.rs index b0055b1..fff78b5 100644 --- a/tools/synops-agent/src/main.rs +++ b/tools/synops-agent/src/main.rs @@ -5,8 +5,8 @@ //! //! 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" +//! synops-agent --interactive +//! synops-agent -i --model gemini/gemini-2.5-flash mod context; mod provider; @@ -15,11 +15,13 @@ mod tools; use clap::Parser; use context::{CompactionConfig, CompactionLevel, check_compaction_level, compact_messages}; use provider::{ - ApiKeys, Message, RetryConfig, TokenUsage, + ApiKeys, LlmProvider, Message, RetryConfig, TokenUsage, calculate_cost, complete_with_retry, create_provider, }; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; #[derive(Parser)] #[command(name = "synops-agent", about = "Modell-agnostisk agent-runtime for Synops")] @@ -28,9 +30,13 @@ struct Cli { #[arg(short, long, default_value = "openrouter/anthropic/claude-sonnet-4")] model: String, - /// Oppgave å utføre + /// Oppgave å utføre (påkrevd i batch-modus) #[arg(short, long)] - task: String, + task: Option, + + /// Interaktiv REPL-modus + #[arg(short, long)] + interactive: bool, /// System-prompt (valgfritt, legges til før oppgaven) #[arg(short, long)] @@ -40,7 +46,7 @@ struct Cli { #[arg(short = 'd', long, default_value = ".")] working_dir: PathBuf, - /// Maks antall tool-loop iterasjoner + /// Maks antall tool-loop iterasjoner per turn #[arg(long, default_value = "50")] max_iterations: usize, @@ -65,6 +71,216 @@ struct Cli { max_retries: u32, } +/// Shared state for the agent session. +struct AgentSession { + provider: Box, + messages: Vec, + tool_defs: Vec, + total_usage: HashMap, + total_cost: f64, + total_iterations: usize, + retry_config: RetryConfig, + compaction_config: CompactionConfig, + working_dir: PathBuf, + max_iterations: usize, + max_cost: Option, + verbose: bool, + /// Set to true by Ctrl+C handler during a turn; checked by tool execution. + interrupted: Arc, +} + +/// Result of running one turn of the agent loop. +enum TurnResult { + /// Agent finished (no more tool calls). + Done, + /// Budget exhausted. + BudgetExhausted, + /// Max iterations hit. + MaxIterations, + /// Interrupted by Ctrl+C. + Interrupted, +} + +impl AgentSession { + /// Run the agent loop for the current messages until the model stops calling tools. + async fn run_turn(&mut self) -> Result> { + let mut iteration = 0; + self.interrupted.store(false, Ordering::SeqCst); + + loop { + iteration += 1; + self.total_iterations += 1; + + if iteration > self.max_iterations { + tracing::warn!("Nådde maks iterasjoner ({})", self.max_iterations); + return Ok(TurnResult::MaxIterations); + } + + // Budget check + if let Some(max_cost) = self.max_cost { + if self.total_cost >= max_cost { + tracing::warn!( + cost = format!("${:.4}", self.total_cost), + budget = format!("${:.2}", max_cost), + "Budsjettgrense nådd — stopper" + ); + return Ok(TurnResult::BudgetExhausted); + } + } + + // Check if interrupted before calling LLM + if self.interrupted.load(Ordering::SeqCst) { + return Ok(TurnResult::Interrupted); + } + + // Call LLM with retry + let response = complete_with_retry( + self.provider.as_ref(), + &self.messages, + &self.tool_defs, + &self.retry_config, + ) + .await?; + + // Accumulate token usage and cost + let entry = self + .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); + self.total_cost += call_cost; + + if self.verbose { + tracing::info!( + iteration, + input = response.usage.input_tokens, + output = response.usage.output_tokens, + call_cost = format!("${:.4}", call_cost), + total_cost = format!("${:.4}", self.total_cost), + "LLM-kall" + ); + } + + // Adaptive Context Compaction + let level = + check_compaction_level(response.usage.input_tokens, &self.compaction_config); + if level != CompactionLevel::None { + let ratio = response.usage.input_tokens as f64 + / self.compaction_config.context_window as f64; + tracing::warn!( + prompt_tokens = response.usage.input_tokens, + context_window = self.compaction_config.context_window, + ratio = format!("{:.1}%", ratio * 100.0), + level = ?level, + "ACC: kontekstkomprimering trigget" + ); + compact_messages(&mut self.messages, level, &self.compaction_config); + } + + // Check for tool calls + let has_tool_calls = response + .message + .tool_calls + .as_ref() + .is_some_and(|tc| !tc.is_empty()); + + // Print any text response + if let Some(ref text) = response.message.content { + if !text.is_empty() { + println!("{}", text); + } + } + + // Add assistant message to history + self.messages.push(response.message.clone()); + + if !has_tool_calls { + return Ok(TurnResult::Done); + } + + // Execute tool calls + let tool_calls = response.message.tool_calls.unwrap(); + for tc in &tool_calls { + // Check interrupt before each tool call + if self.interrupted.load(Ordering::SeqCst) { + // Add a synthetic tool result so the conversation stays valid + self.messages.push(Message { + role: "tool".into(), + content: Some("[interrupted by user]".into()), + tool_calls: None, + tool_call_id: Some(tc.id.clone()), + }); + return Ok(TurnResult::Interrupted); + } + + 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, &self.working_dir).await; + + if self.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 + self.messages.push(Message { + role: "tool".into(), + content: Some(result), + tool_calls: None, + tool_call_id: Some(tc.id.clone()), + }); + } + } + } + + /// Total input tokens across all models. + fn total_input_tokens(&self) -> u64 { + self.total_usage.values().map(|u| u.input_tokens).sum() + } + + /// Total output tokens across all models. + fn total_output_tokens(&self) -> u64 { + self.total_usage.values().map(|u| u.output_tokens).sum() + } + + /// Print token usage summary to stderr. + fn print_summary(&self) { + eprintln!("\n--- Token-forbruk ---"); + for (model, usage) in &self.total_usage { + let model_cost = calculate_cost(model, usage); + eprintln!( + " {}: {} inn / {} ut (${:.4})", + model, usage.input_tokens, usage.output_tokens, model_cost + ); + } + eprintln!( + " Totalt: {} inn / {} ut", + self.total_input_tokens(), + self.total_output_tokens() + ); + eprintln!(" Kostnad: ${:.4}", self.total_cost); + if let Some(max_cost) = self.max_cost { + eprintln!( + " Budsjett: ${:.2} ({:.0}% brukt)", + max_cost, + (self.total_cost / max_cost) * 100.0 + ); + } + eprintln!(" Iterasjoner: {}", self.total_iterations); + } +} + #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() @@ -76,9 +292,18 @@ async fn main() -> Result<(), Box> { let cli = Cli::parse(); + // Validate: need either --task or --interactive + if cli.task.is_none() && !cli.interactive { + eprintln!("Feil: Enten --task eller --interactive er påkrevd."); + eprintln!(" synops-agent --task \"oppgaven\""); + eprintln!(" synops-agent --interactive"); + std::process::exit(1); + } + // Spawn Claude Code direkte hvis --claude if cli.claude { - return spawn_claude_code(&cli.task, &cli.working_dir).await; + let task = cli.task.as_deref().unwrap_or("interactive session"); + return spawn_claude_code(task, &cli.working_dir).await; } let api_keys = ApiKeys::from_env(); @@ -90,199 +315,261 @@ async fn main() -> Result<(), Box> { "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"), + 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; + // Initialize messages with system prompt + let messages = vec![Message { + role: "system".into(), + content: Some(system_prompt), + tool_calls: None, + tool_call_id: None, + }]; + + let interrupted = Arc::new(AtomicBool::new(false)); + + let mut session = AgentSession { + provider, + messages, + tool_defs: tools::tool_definitions(), + total_usage: HashMap::new(), + total_cost: 0.0, + total_iterations: 0, + retry_config: RetryConfig { + max_retries: cli.max_retries, + ..Default::default() + }, + compaction_config, + working_dir: cli.working_dir.clone(), + max_iterations: cli.max_iterations, + max_cost: cli.max_cost, + verbose: cli.verbose, + interrupted: interrupted.clone(), + }; + + if cli.interactive { + run_interactive(&mut session).await?; + } else { + // Batch mode: add task as user message and run + let task = cli.task.unwrap(); + session.messages.push(Message { + role: "user".into(), + content: Some(task), + tool_calls: None, + tool_call_id: None, + }); + + let result = session.run_turn().await?; + session.print_summary(); + + if matches!(result, TurnResult::BudgetExhausted) { + eprintln!("\n⚠ Budsjettgrense nådd. Oppgaven er ikke fullført."); + eprintln!(" Gjenstående arbeid bør fortsettes med høyere --max-cost"); + std::process::exit(2); } - - // 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(()) } +/// Interactive REPL mode. +async fn run_interactive( + session: &mut AgentSession, +) -> Result<(), Box> { + use rustyline::error::ReadlineError; + use rustyline::{DefaultEditor, Config, EditMode}; + + let config = Config::builder() + .edit_mode(EditMode::Emacs) + .auto_add_history(true) + .build(); + let mut rl = DefaultEditor::with_config(config)?; + + // Load history + let history_path = dirs_history_path(); + let _ = rl.load_history(&history_path); + + let model_short = short_model_name(session.provider.model_id()).to_owned(); + + eprintln!("synops-agent interaktiv modus"); + eprintln!("Modell: {}", session.provider.model_id()); + eprintln!("Ctrl+C avbryter pågående kall. Ctrl+D eller \"exit\" avslutter."); + eprintln!(); + + // Install Ctrl+C handler that sets the interrupted flag + let interrupted = session.interrupted.clone(); + ctrlc::set_handler(move || { + interrupted.store(true, Ordering::SeqCst); + }) + .expect("Kunne ikke sette Ctrl+C handler"); + + loop { + let total_tokens = session.total_input_tokens() + session.total_output_tokens(); + let prompt = if total_tokens > 0 { + format!("[{}T ${:.3}] {} > ", format_tokens(total_tokens), session.total_cost, model_short) + } else { + format!("{} > ", model_short) + }; + + // Reset interrupted flag before reading input + session.interrupted.store(false, Ordering::SeqCst); + + let input = match rl.readline(&prompt) { + Ok(line) => line, + Err(ReadlineError::Interrupted) => { + // Ctrl+C while waiting for input — just show new prompt + eprintln!("^C"); + continue; + } + Err(ReadlineError::Eof) => { + // Ctrl+D — exit + eprintln!("\nAvslutter."); + break; + } + Err(e) => { + eprintln!("Readline-feil: {}", e); + break; + } + }; + + // Support multi-line: if input ends with '\', keep reading + let input = if input.ends_with('\\') { + let mut multi = input.trim_end_matches('\\').to_string(); + loop { + match rl.readline("... ") { + Ok(line) => { + if line.ends_with('\\') { + multi.push('\n'); + multi.push_str(line.trim_end_matches('\\')); + } else { + multi.push('\n'); + multi.push_str(&line); + break; + } + } + Err(_) => break, + } + } + multi + } else { + input + }; + + let trimmed = input.trim(); + if trimmed.is_empty() { + continue; + } + + // Built-in commands + match trimmed { + "exit" | "quit" | "/exit" | "/quit" => { + eprintln!("Avslutter."); + break; + } + "/stats" | "/tokens" => { + session.print_summary(); + continue; + } + "/clear" => { + // Keep system prompt, discard conversation + session.messages.truncate(1); + session.total_usage.clear(); + session.total_cost = 0.0; + session.total_iterations = 0; + eprintln!("Samtale nullstilt."); + continue; + } + "/help" => { + eprintln!("Kommandoer:"); + eprintln!(" /stats — Vis token-forbruk og kostnad"); + eprintln!(" /clear — Nullstill samtalen"); + eprintln!(" /help — Vis denne hjelpen"); + eprintln!(" exit — Avslutt"); + eprintln!(); + eprintln!("Multi-line: Avslutt linje med \\ for å fortsette."); + eprintln!("Ctrl+C avbryter pågående LLM-kall/verktøy."); + continue; + } + _ => {} + } + + // Add user message and run turn + session.messages.push(Message { + role: "user".into(), + content: Some(input), + tool_calls: None, + tool_call_id: None, + }); + + match session.run_turn().await { + Ok(TurnResult::Done) => { + // Normal completion + } + Ok(TurnResult::Interrupted) => { + eprintln!("\n[avbrutt]"); + } + Ok(TurnResult::BudgetExhausted) => { + eprintln!("\n⚠ Budsjettgrense nådd (${:.4})", session.total_cost); + break; + } + Ok(TurnResult::MaxIterations) => { + eprintln!( + "\n⚠ Maks iterasjoner ({}) nådd for denne turn.", + session.max_iterations + ); + } + Err(e) => { + eprintln!("\nFeil: {}", e); + } + } + } + + session.print_summary(); + + // Save history + let _ = rl.save_history(&history_path); + + Ok(()) +} + +/// Format token count for display (e.g. 1234 → "1.2k", 1234567 → "1.2M"). +fn format_tokens(tokens: u64) -> String { + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}k", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +/// Shorten model name for prompt display. +fn short_model_name(model: &str) -> &str { + // Take the last path segment: "anthropic/claude-sonnet-4" → "claude-sonnet-4" + model.rsplit('/').next().unwrap_or(model) +} + +/// Path for REPL history file. +fn dirs_history_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + let dir = PathBuf::from(home).join(".synops"); + let _ = std::fs::create_dir_all(&dir); + dir.join("agent_history.txt") +} + /// Build system prompt with context about the project. fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String { let mut prompt = String::new(); @@ -298,7 +585,6 @@ fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String { 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 { @@ -351,10 +637,11 @@ async fn spawn_claude_code( } if !output.status.success() { - eprintln!("Claude Code avsluttet med kode: {}", output.status.code().unwrap_or(-1)); + eprintln!( + "Claude Code avsluttet med kode: {}", + output.status.code().unwrap_or(-1) + ); } Ok(()) } - -use std::path::Path;