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:
parent
00a92ebe2f
commit
a8b6c7ca7b
2 changed files with 482 additions and 189 deletions
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
// 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<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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue