Implementer interaktiv REPL-modus i synops-agent

- --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)
This commit is contained in:
vegard 2026-03-19 18:16:54 +00:00
parent 00a92ebe2f
commit a8b6c7ca7b
2 changed files with 482 additions and 189 deletions

View file

@ -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"] }

View file

@ -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<String>,
/// 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<dyn LlmProvider>,
messages: Vec<Message>,
tool_defs: Vec<provider::ToolDef>,
total_usage: HashMap<String, TokenUsage>,
total_cost: f64,
total_iterations: usize,
retry_config: RetryConfig,
compaction_config: CompactionConfig,
working_dir: PathBuf,
max_iterations: usize,
max_cost: Option<f64>,
verbose: bool,
/// Set to true by Ctrl+C handler during a turn; checked by tool execution.
interrupted: Arc<AtomicBool>,
}
/// 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<TurnResult, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
tracing_subscriber::fmt()
@ -76,9 +292,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
"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"),
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),
// Initialize messages with system prompt
let messages = vec![Message {
role: "system".into(),
content: Some(system_prompt),
tool_calls: None,
tool_call_id: Some(tc.id.clone()),
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,
});
}
}
// 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);
let result = session.run_turn().await?;
session.print_summary();
if budget_exhausted {
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");
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<dyn std::error::Error>> {
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;