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
|
# Error handling
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
||||||
|
# Readline for interactive mode
|
||||||
|
rustyline = "15"
|
||||||
|
|
||||||
|
# Ctrl+C handling
|
||||||
|
ctrlc = "3"
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
uuid = { version = "1", features = ["v7", "serde"] }
|
uuid = { version = "1", features = ["v7", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
//!
|
//!
|
||||||
//! Bruk:
|
//! Bruk:
|
||||||
//! synops-agent --model openrouter/anthropic/claude-sonnet-4 --task "fiks buggen"
|
//! 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 --interactive
|
||||||
//! synops-agent --model ollama/llama3 --task "skriv en test"
|
//! synops-agent -i --model gemini/gemini-2.5-flash
|
||||||
|
|
||||||
mod context;
|
mod context;
|
||||||
mod provider;
|
mod provider;
|
||||||
|
|
@ -15,11 +15,13 @@ mod tools;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use context::{CompactionConfig, CompactionLevel, check_compaction_level, compact_messages};
|
use context::{CompactionConfig, CompactionLevel, check_compaction_level, compact_messages};
|
||||||
use provider::{
|
use provider::{
|
||||||
ApiKeys, Message, RetryConfig, TokenUsage,
|
ApiKeys, LlmProvider, Message, RetryConfig, TokenUsage,
|
||||||
calculate_cost, complete_with_retry, create_provider,
|
calculate_cost, complete_with_retry, create_provider,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
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)]
|
#[derive(Parser)]
|
||||||
#[command(name = "synops-agent", about = "Modell-agnostisk agent-runtime for Synops")]
|
#[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")]
|
#[arg(short, long, default_value = "openrouter/anthropic/claude-sonnet-4")]
|
||||||
model: String,
|
model: String,
|
||||||
|
|
||||||
/// Oppgave å utføre
|
/// Oppgave å utføre (påkrevd i batch-modus)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
task: String,
|
task: Option<String>,
|
||||||
|
|
||||||
|
/// Interaktiv REPL-modus
|
||||||
|
#[arg(short, long)]
|
||||||
|
interactive: bool,
|
||||||
|
|
||||||
/// System-prompt (valgfritt, legges til før oppgaven)
|
/// System-prompt (valgfritt, legges til før oppgaven)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
|
|
@ -40,7 +46,7 @@ struct Cli {
|
||||||
#[arg(short = 'd', long, default_value = ".")]
|
#[arg(short = 'd', long, default_value = ".")]
|
||||||
working_dir: PathBuf,
|
working_dir: PathBuf,
|
||||||
|
|
||||||
/// Maks antall tool-loop iterasjoner
|
/// Maks antall tool-loop iterasjoner per turn
|
||||||
#[arg(long, default_value = "50")]
|
#[arg(long, default_value = "50")]
|
||||||
max_iterations: usize,
|
max_iterations: usize,
|
||||||
|
|
||||||
|
|
@ -65,6 +71,216 @@ struct Cli {
|
||||||
max_retries: u32,
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
|
|
@ -76,9 +292,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
let cli = Cli::parse();
|
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
|
// Spawn Claude Code direkte hvis --claude
|
||||||
if cli.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();
|
let api_keys = ApiKeys::from_env();
|
||||||
|
|
@ -90,199 +315,261 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
"Starter agent"
|
"Starter agent"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build system prompt
|
|
||||||
let system_prompt = build_system_prompt(cli.system.as_deref(), &cli.working_dir);
|
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 {
|
let compaction_config = CompactionConfig {
|
||||||
context_window: provider.context_window(),
|
context_window: provider.context_window(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
context_window = compaction_config.context_window,
|
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 konfigurert"
|
||||||
);
|
);
|
||||||
|
|
||||||
// === Agent loop ===
|
// Initialize messages with system prompt
|
||||||
loop {
|
let messages = vec![Message {
|
||||||
iteration += 1;
|
role: "system".into(),
|
||||||
if iteration > cli.max_iterations {
|
content: Some(system_prompt),
|
||||||
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_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
|
let result = session.run_turn().await?;
|
||||||
eprintln!("\n--- Token-forbruk ---");
|
session.print_summary();
|
||||||
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 {
|
if matches!(result, TurnResult::BudgetExhausted) {
|
||||||
eprintln!("\n⚠ Budsjettgrense nådd. Oppgaven er ikke fullført.");
|
eprintln!("\n⚠ Budsjettgrense nådd. Oppgaven er ikke fullført.");
|
||||||
eprintln!(" Gjenstående arbeid bør fortsettes med høyere --max-cost");
|
eprintln!(" Gjenstående arbeid bør fortsettes med høyere --max-cost");
|
||||||
eprintln!(" eller manuelt. Kontekst kan gjenopprettes fra meldingsloggen.");
|
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
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.
|
/// Build system prompt with context about the project.
|
||||||
fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String {
|
fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String {
|
||||||
let mut prompt = String::new();
|
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");
|
let claude_md = working_dir.join("CLAUDE.md");
|
||||||
if claude_md.exists() {
|
if claude_md.exists() {
|
||||||
if let Ok(content) = std::fs::read_to_string(&claude_md) {
|
if let Ok(content) = std::fs::read_to_string(&claude_md) {
|
||||||
// Truncate if very long
|
|
||||||
let truncated = if content.len() > 8000 {
|
let truncated = if content.len() > 8000 {
|
||||||
format!("{}...\n(truncated)", &content[..8000])
|
format!("{}...\n(truncated)", &content[..8000])
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -351,10 +637,11 @@ async fn spawn_claude_code(
|
||||||
}
|
}
|
||||||
|
|
||||||
if !output.status.success() {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue