diff --git a/src/agent/router.rs b/src/agent/router.rs index b6cd59d3..273daa9b 100644 --- a/src/agent/router.rs +++ b/src/agent/router.rs @@ -115,13 +115,15 @@ impl Router { fn extract_intent(&self, content: &str) -> MessageIntent { let lower = content.to_lowercase(); - // Job creation patterns - if lower.starts_with("create ") - || lower.starts_with("make ") - || lower.starts_with("new job") - || lower.contains("i need") - || lower.contains("can you") - { + // Job creation patterns - must be explicit about creating a job + // More specific patterns to avoid capturing general conversation + let is_job_creation = lower.starts_with("create job ") + || lower.starts_with("new job ") + || lower.starts_with("schedule job ") + || lower.starts_with("run job ") + || (lower.contains("create") && lower.contains("job")); + + if is_job_creation { return MessageIntent::CreateJob { title: extract_title(content), description: content.to_string(), diff --git a/src/channels/cli/app.rs b/src/channels/cli/app.rs index 50281765..72d9caa5 100644 --- a/src/channels/cli/app.rs +++ b/src/channels/cli/app.rs @@ -3,6 +3,7 @@ use std::collections::VecDeque; use crate::channels::cli::composer::ChatComposer; +use crate::channels::cli::model_selector::{ModelSelectorOverlay, ModelSelectorRequest}; use crate::channels::cli::overlay::{ApprovalOverlay, ApprovalRequest}; /// Events that can occur in the TUI. @@ -24,6 +25,10 @@ pub enum AppEvent { LogMessage(String), /// Thinking/status message (shown in chat window). ThinkingMessage(String), + /// Error message (shown in chat window). + ErrorMessage(String), + /// Available models fetched from API. + AvailableModels(Vec), /// Force a redraw. Redraw, /// Quit the application. @@ -39,6 +44,8 @@ pub enum InputMode { Editing, /// Approval overlay is active. Approval, + /// Model selector overlay is active. + ModelSelector, } /// Message in the chat history. @@ -110,6 +117,8 @@ pub struct AppState { pub composer: ChatComposer, /// Approval overlay (if active). pub approval: Option, + /// Model selector overlay (if active). + pub model_selector: Option, /// Scroll offset for messages. pub scroll_offset: u16, /// Whether the app should quit. @@ -122,11 +131,19 @@ pub struct AppState { pub status_message: Option, /// Whether Ctrl+D was pressed (waiting for second press to quit). pub ctrl_d_pending: bool, + /// Currently selected model. + pub current_model: String, + /// Available models (fetched from API). + pub available_models: Vec, } impl AppState { /// Create a new app state. pub fn new() -> Self { + // Load saved model from settings + let settings = crate::settings::Settings::load(); + let current_model = settings.model_or("claude-3-5-sonnet-20241022"); + Self { mode: InputMode::Editing, messages: vec![ChatMessage::system( @@ -134,12 +151,60 @@ impl AppState { )], composer: ChatComposer::new(), approval: None, + model_selector: None, scroll_offset: 0, should_quit: false, pending_approvals: VecDeque::new(), streaming_buffer: None, status_message: None, ctrl_d_pending: false, + current_model, + available_models: Vec::new(), + } + } + + /// Show the model selector. + pub fn show_model_selector(&mut self) { + let request = ModelSelectorRequest { + current_model: self.current_model.clone(), + available_models: self.available_models.clone(), + }; + self.model_selector = Some(ModelSelectorOverlay::new(request)); + self.mode = InputMode::ModelSelector; + } + + /// Handle model selection. + pub fn handle_model_selection(&mut self, selected: Option) { + self.model_selector = None; + self.mode = InputMode::Editing; + + if let Some(model) = selected { + if model != self.current_model { + self.current_model = model.clone(); + // Save to settings + let mut settings = crate::settings::Settings::load(); + if let Err(e) = settings.set_model(&model) { + tracing::warn!("Failed to save model setting: {}", e); + } + self.messages.push(ChatMessage::system(format!( + "Switched to model: {}", + ModelSelectorOverlay::format_model_name(&model) + ))); + } + } + } + + /// Set available models (also updates selector if open). + pub fn set_available_models(&mut self, models: Vec) { + self.available_models = models.clone(); + + // Update the selector if it's currently open + if let Some(ref mut selector) = self.model_selector { + selector.request.available_models = models; + // Reset selection index if it's out of bounds + if selector.selection_index >= selector.request.available_models.len() { + selector.selection_index = 0; + } } } @@ -161,6 +226,13 @@ impl AppState { self.scroll_to_bottom(); } + /// Add an error message to the chat. + pub fn add_error_message(&mut self, content: impl Into) { + self.messages + .push(ChatMessage::system(format!("Error: {}", content.into())).with_status(MessageStatus::Error)); + self.scroll_to_bottom(); + } + /// Add or update a thinking/status message (shown as system message). pub fn set_thinking(&mut self, content: impl Into) { let content = content.into(); diff --git a/src/channels/cli/events.rs b/src/channels/cli/events.rs index f6b21ec2..680a056b 100644 --- a/src/channels/cli/events.rs +++ b/src/channels/cli/events.rs @@ -110,6 +110,7 @@ fn handle_key( InputMode::Normal => handle_normal_mode(app, key), InputMode::Editing => handle_editing_mode(app, key, msg_tx), InputMode::Approval => handle_approval_mode(app, key), + InputMode::ModelSelector => handle_model_selector_mode(app, key), } } @@ -152,6 +153,13 @@ fn handle_editing_mode( KeyCode::Enter => { if !app.composer.is_empty() { let input = app.composer.submit(); + + // Handle /model command locally (TUI-specific) + if input.trim().eq_ignore_ascii_case("/model") { + app.show_model_selector(); + return Ok(()); + } + app.add_user_message(&input); // Send message to agent @@ -251,6 +259,30 @@ fn handle_approval_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { Ok(()) } +/// Handle keys in model selector mode. +fn handle_model_selector_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { + if let Some(ref mut overlay) = app.model_selector { + match key.code { + KeyCode::Left | KeyCode::Char('h') => { + overlay.select_prev(); + } + KeyCode::Right | KeyCode::Char('l') => { + overlay.select_next(); + } + KeyCode::Enter | KeyCode::Char(' ') => { + let selected = overlay.selected_model().map(|s| s.to_string()); + app.handle_model_selection(selected); + } + KeyCode::Esc => { + // Cancel without changing model + app.handle_model_selection(None); + } + _ => {} + } + } + Ok(()) +} + /// Handle an application event. fn handle_app_event(app: &mut AppState, event: AppEvent) { match event { @@ -291,5 +323,11 @@ fn handle_app_event(app: &mut AppState, event: AppEvent) { AppEvent::ThinkingMessage(msg) => { app.set_thinking(msg); } + AppEvent::ErrorMessage(msg) => { + app.add_error_message(msg); + } + AppEvent::AvailableModels(models) => { + app.set_available_models(models); + } } } diff --git a/src/channels/cli/mod.rs b/src/channels/cli/mod.rs index 0515e9e9..dfb60adf 100644 --- a/src/channels/cli/mod.rs +++ b/src/channels/cli/mod.rs @@ -9,6 +9,7 @@ mod app; mod composer; mod events; +mod model_selector; mod overlay; mod render; @@ -30,6 +31,7 @@ use crate::error::ChannelError; pub use app::{AppEvent, AppState, InputMode}; pub use composer::ChatComposer; +pub use model_selector::{ModelSelectorOverlay, ModelSelectorRequest}; pub use overlay::{ApprovalOverlay, ApprovalRequest}; /// TUI channel for interactive terminal input with Ratatui. @@ -55,6 +57,12 @@ impl TuiChannel { pub fn log_writer(&self) -> TuiLogWriter { TuiLogWriter::new(self.event_tx.clone()) } + + /// Get a sender for sending events to the TUI. + /// Use this to send available models or other events from outside the channel. + pub fn event_sender(&self) -> mpsc::Sender { + self.event_tx.clone() + } } impl Default for TuiChannel { diff --git a/src/channels/cli/model_selector.rs b/src/channels/cli/model_selector.rs new file mode 100644 index 00000000..23e7d477 --- /dev/null +++ b/src/channels/cli/model_selector.rs @@ -0,0 +1,156 @@ +//! Model selector overlay for switching LLM models. + +/// Request to show the model selector. +#[derive(Debug, Clone)] +pub struct ModelSelectorRequest { + /// Currently selected model. + pub current_model: String, + /// Available models to choose from. + pub available_models: Vec, +} + +/// Model selector overlay state. +#[derive(Debug, Clone)] +pub struct ModelSelectorOverlay { + /// The request that triggered this overlay. + pub request: ModelSelectorRequest, + /// Currently highlighted index. + pub selection_index: usize, +} + +impl ModelSelectorOverlay { + /// Create a new model selector overlay. + pub fn new(request: ModelSelectorRequest) -> Self { + // Find the current model in the list, default to 0 + let selection_index = request + .available_models + .iter() + .position(|m| m == &request.current_model) + .unwrap_or(0); + + Self { + request, + selection_index, + } + } + + /// Get the list of available models. + pub fn models(&self) -> &[String] { + &self.request.available_models + } + + /// Move selection up. + pub fn select_prev(&mut self) { + let len = self.request.available_models.len(); + if len == 0 { + return; + } + if self.selection_index > 0 { + self.selection_index -= 1; + } else { + // Wrap to bottom + self.selection_index = len - 1; + } + } + + /// Move selection down. + pub fn select_next(&mut self) { + let len = self.request.available_models.len(); + if len == 0 { + return; + } + if self.selection_index < len - 1 { + self.selection_index += 1; + } else { + // Wrap to top + self.selection_index = 0; + } + } + + /// Get the currently selected model name. + pub fn selected_model(&self) -> Option<&str> { + self.request + .available_models + .get(self.selection_index) + .map(|s| s.as_str()) + } + + /// Check if the selection is the current model. + pub fn is_current(&self) -> bool { + self.selected_model() == Some(&self.request.current_model) + } + + /// Format a model name for display (shorten long names). + pub fn format_model_name(model: &str) -> String { + // Shorten fireworks model names + if let Some(rest) = model.strip_prefix("fireworks::accounts/fireworks/models/") { + return format!("fireworks/{}", rest); + } + // Shorten other long prefixes + if let Some(rest) = model.strip_prefix("accounts/") { + return rest.to_string(); + } + model.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_model_selector_navigation() { + let request = ModelSelectorRequest { + current_model: "gpt-4o".to_string(), + available_models: vec![ + "claude-3-5-sonnet".to_string(), + "gpt-4o".to_string(), + "gpt-4o-mini".to_string(), + ], + }; + let mut overlay = ModelSelectorOverlay::new(request); + + // Should start at gpt-4o index (1) + assert_eq!(overlay.selected_model(), Some("gpt-4o")); + + // Navigate down + overlay.select_next(); + assert_eq!(overlay.selected_model(), Some("gpt-4o-mini")); + + // Navigate down (wrap) + overlay.select_next(); + assert_eq!(overlay.selected_model(), Some("claude-3-5-sonnet")); + + // Navigate up + overlay.select_prev(); + assert_eq!(overlay.selected_model(), Some("gpt-4o-mini")); + } + + #[test] + fn test_format_model_name() { + assert_eq!( + ModelSelectorOverlay::format_model_name("claude-3-5-sonnet-20241022"), + "claude-3-5-sonnet-20241022" + ); + assert_eq!( + ModelSelectorOverlay::format_model_name( + "fireworks::accounts/fireworks/models/llama-v3p1-405b-instruct" + ), + "fireworks/llama-v3p1-405b-instruct" + ); + } + + #[test] + fn test_empty_models() { + let request = ModelSelectorRequest { + current_model: "unknown".to_string(), + available_models: vec![], + }; + let mut overlay = ModelSelectorOverlay::new(request); + assert_eq!(overlay.selected_model(), None); + + // Should not panic + overlay.select_next(); + overlay.select_prev(); + } +} diff --git a/src/channels/cli/render.rs b/src/channels/cli/render.rs index d07332a2..85a67856 100644 --- a/src/channels/cli/render.rs +++ b/src/channels/cli/render.rs @@ -9,6 +9,7 @@ use ratatui::{ }; use crate::channels::cli::app::{AppState, InputMode, MessageRole, MessageStatus}; +use crate::channels::cli::model_selector::ModelSelectorOverlay; use crate::channels::cli::overlay::ApprovalSelection; /// Render the entire UI. @@ -100,12 +101,18 @@ fn render_messages(frame: &mut Frame, app: &AppState, area: Rect) { frame.render_widget(messages, area); } -/// Render the input area. +/// Render the input area (or model selector when in ModelSelector mode). fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { + // In ModelSelector mode, render inline selector instead of input + if app.mode == InputMode::ModelSelector { + render_model_selector_inline(frame, app, area); + return; + } + let input_style = match app.mode { InputMode::Editing => Style::default().fg(Color::Yellow), InputMode::Normal => Style::default(), - InputMode::Approval => Style::default().fg(Color::DarkGray), + InputMode::Approval | InputMode::ModelSelector => Style::default().fg(Color::DarkGray), }; let buffer = app.composer.buffer(); @@ -142,15 +149,91 @@ fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { } } +/// Render inline model selector in the input area. +fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) { + let Some(ref overlay) = app.model_selector else { + return; + }; + + let models = overlay.models(); + + // Build horizontal list of models + let mut spans: Vec = Vec::new(); + + if models.is_empty() { + spans.push(Span::styled( + "Loading models...", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + )); + } else { + for (i, model) in models.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + + let display_name = ModelSelectorOverlay::format_model_name(model); + let is_selected = i == overlay.selection_index; + let is_current = model == &overlay.request.current_model; + + let style = if is_selected { + Style::default().bg(Color::Blue).fg(Color::White) + } else if is_current { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::White) + }; + + let prefix = if is_current { "●" } else { " " }; + spans.push(Span::styled(format!("{}{}", prefix, display_name), style)); + } + } + + let content = Paragraph::new(Line::from(spans)) + .block( + Block::default() + .borders(Borders::ALL) + .title(Span::styled("Select Model", Style::default().fg(Color::Cyan))), + ) + .scroll((0, calculate_model_scroll(overlay, area.width.saturating_sub(2)))); + + frame.render_widget(content, area); +} + +/// Calculate horizontal scroll offset to keep selected model visible. +fn calculate_model_scroll(overlay: &ModelSelectorOverlay, visible_width: u16) -> u16 { + let models = overlay.models(); + if models.is_empty() { + return 0; + } + + // Estimate position of selected model (rough calculation) + let mut pos: u16 = 0; + for (i, model) in models.iter().enumerate() { + let name_len = ModelSelectorOverlay::format_model_name(model).len() as u16 + 3; // +3 for prefix and spacing + if i == overlay.selection_index { + // Check if selection is beyond visible area + if pos > visible_width { + return pos.saturating_sub(visible_width / 2); + } + return 0; + } + pos += name_len; + } + 0 +} + /// Render the status line. fn render_status(frame: &mut Frame, app: &AppState, area: Rect) { let status_text = if let Some(ref msg) = app.status_message { msg.clone() } else { match app.mode { - InputMode::Normal => "Press 'i' to edit, 'q' to quit".to_string(), - InputMode::Editing => "Type message, Enter to send, Esc to cancel".to_string(), - InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel all".to_string(), + InputMode::Normal | InputMode::Editing => { + let model = ModelSelectorOverlay::format_model_name(&app.current_model); + format!("{} | /model to switch", model) + } + InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel".to_string(), + InputMode::ModelSelector => "←/→ navigate, Enter=select, Esc=cancel".to_string(), } }; @@ -252,3 +335,4 @@ fn render_approval_overlay(frame: &mut Frame, app: &AppState) { frame.render_widget(content, overlay_area); } + diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 2806b63d..d47be5f9 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -7,8 +7,10 @@ mod channel; pub mod cli; mod http; mod manager; +mod repl; pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; -pub use cli::TuiChannel; +pub use cli::{AppEvent, TuiChannel}; pub use http::HttpChannel; pub use manager::ChannelManager; +pub use repl::ReplChannel; diff --git a/src/channels/repl.rs b/src/channels/repl.rs new file mode 100644 index 00000000..c302f36c --- /dev/null +++ b/src/channels/repl.rs @@ -0,0 +1,143 @@ +//! Simple REPL channel for testing without TUI. +//! +//! Provides a basic stdin/stdout interface for testing the agent. + +use std::io::{self, BufRead, Write}; + +use async_trait::async_trait; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; +use crate::error::ChannelError; + +/// Simple REPL channel using stdin/stdout. +pub struct ReplChannel { + /// Optional single message to send (for -m flag). + single_message: Option, +} + +impl ReplChannel { + /// Create a new REPL channel. + pub fn new() -> Self { + Self { + single_message: None, + } + } + + /// Create a REPL channel that sends a single message and exits. + pub fn with_message(message: String) -> Self { + Self { + single_message: Some(message), + } + } +} + +impl Default for ReplChannel { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Channel for ReplChannel { + fn name(&self) -> &str { + "repl" + } + + async fn start(&self) -> Result { + let (tx, rx) = mpsc::channel(32); + let single_message = self.single_message.clone(); + + std::thread::spawn(move || { + // If single message mode, send it and exit + if let Some(msg) = single_message { + let incoming = IncomingMessage::new("repl", "user", &msg); + if tx.blocking_send(incoming).is_err() { + return; + } + // Wait a bit for response, then the channel will close + return; + } + + // Interactive REPL mode + let stdin = io::stdin(); + let mut stdout = io::stdout(); + + loop { + // Print prompt + print!("> "); + let _ = stdout.flush(); + + // Read line + let mut line = String::new(); + match stdin.lock().read_line(&mut line) { + Ok(0) => break, // EOF + Ok(_) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + if line == "/quit" || line == "/exit" { + break; + } + + let msg = IncomingMessage::new("repl", "user", line); + if tx.blocking_send(msg).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + Ok(Box::pin(ReceiverStream::new(rx))) + } + + async fn respond( + &self, + _msg: &IncomingMessage, + response: OutgoingResponse, + ) -> Result<(), ChannelError> { + println!("\n{}\n", response.content); + Ok(()) + } + + async fn send_status(&self, status: StatusUpdate) -> Result<(), ChannelError> { + match status { + StatusUpdate::Thinking(msg) => eprintln!("[thinking] {}", msg), + StatusUpdate::ToolStarted { name } => eprintln!("[tool] Starting: {}", name), + StatusUpdate::ToolCompleted { name, success } => { + if success { + eprintln!("[tool] Completed: {}", name); + } else { + eprintln!("[tool] Failed: {}", name); + } + } + StatusUpdate::StreamChunk(chunk) => { + print!("{}", chunk); + let _ = io::stdout().flush(); + } + StatusUpdate::Status(msg) => eprintln!("[status] {}", msg), + } + Ok(()) + } + + async fn broadcast( + &self, + _user_id: &str, + response: OutgoingResponse, + ) -> Result<(), ChannelError> { + println!("\n[broadcast] {}\n", response.content); + Ok(()) + } + + async fn health_check(&self) -> Result<(), ChannelError> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), ChannelError> { + Ok(()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5c9dfbb1..8e277796 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -27,6 +27,14 @@ pub struct Cli { #[arg(long, global = true)] pub no_db: bool, + /// Simple REPL mode without TUI (for testing) + #[arg(long, global = true)] + pub repl: bool, + + /// Single message mode - send one message and exit + #[arg(short, long, global = true)] + pub message: Option, + /// Configuration file path (optional, uses env vars by default) #[arg(short, long, global = true)] pub config: Option, diff --git a/src/config.rs b/src/config.rs index 074cbb70..1f431e21 100644 --- a/src/config.rs +++ b/src/config.rs @@ -94,8 +94,11 @@ impl LlmConfig { fn from_env() -> Result { Ok(Self { nearai: NearAiConfig { - model: optional_env("NEARAI_MODEL")? - .unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string()), + // Load model from saved settings first, then env, then default + model: crate::settings::Settings::load() + .selected_model + .or_else(|| optional_env("NEARAI_MODEL").ok().flatten()) + .unwrap_or_else(|| "zai-org/GLM-4.7".to_string()), base_url: optional_env("NEARAI_BASE_URL")? .unwrap_or_else(|| "https://api.near.ai".to_string()), auth_base_url: optional_env("NEARAI_AUTH_URL")? @@ -210,8 +213,14 @@ impl ChannelsConfig { None }; + let cli_enabled = optional_env("CLI_ENABLED")? + .map(|s| s.to_lowercase() != "false" && s != "0") + .unwrap_or(true); + Ok(Self { - cli: CliConfig { enabled: true }, + cli: CliConfig { + enabled: cli_enabled, + }, http, }) } diff --git a/src/lib.rs b/src/lib.rs index 2869dc85..b90c7be5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,6 +50,7 @@ pub mod history; pub mod llm; pub mod safety; pub mod secrets; +pub mod settings; pub mod tools; pub mod workspace; diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 69d85d42..90e200c7 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -7,7 +7,7 @@ mod provider; mod reasoning; pub mod session; -pub use nearai::NearAiProvider; +pub use nearai::{ModelInfo, NearAiProvider}; pub use provider::{ ChatMessage, CompletionRequest, CompletionResponse, LlmProvider, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult, diff --git a/src/llm/nearai.rs b/src/llm/nearai.rs index 0acf93ba..deb63e44 100644 --- a/src/llm/nearai.rs +++ b/src/llm/nearai.rs @@ -20,6 +20,17 @@ use crate::llm::provider::{ }; use crate::llm::session::SessionManager; +/// Information about an available model from NEAR AI API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + /// Model identifier. + #[serde(alias = "id", alias = "model")] + pub name: String, + /// Optional provider name. + #[serde(default)] + pub provider: Option, +} + /// NEAR AI Chat API provider. pub struct NearAiProvider { client: Client, @@ -50,6 +61,123 @@ impl NearAiProvider { ) } + /// Fetch available models from the NEAR AI API. + pub async fn list_models(&self) -> Result, LlmError> { + use secrecy::ExposeSecret; + + let token = self.session.get_token().await?; + let url = self.api_url("model/list"); + + tracing::debug!("Fetching models from: {}", url); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", token.expose_secret())) + .send() + .await + .map_err(|e| LlmError::RequestFailed { + provider: "nearai".to_string(), + reason: format!("Failed to fetch models: {}", e), + })?; + + let status = response.status(); + let response_text = response.text().await.unwrap_or_default(); + + if !status.is_success() { + // Check for session expiration + if status.as_u16() == 401 { + return Err(LlmError::SessionExpired { + provider: "nearai".to_string(), + }); + } + + return Err(LlmError::RequestFailed { + provider: "nearai".to_string(), + reason: format!("HTTP {}: {}", status, response_text), + }); + } + + // Parse the response - NEAR AI returns {"limit": N, "models": [...]} + // Each model object may have the name in different fields + #[derive(Deserialize)] + struct ModelMetadata { + #[serde(default)] + name: Option, + #[serde(default, alias = "modelName", alias = "model_name")] + model_name: Option, + } + + #[derive(Deserialize)] + struct ModelEntry { + #[serde(default)] + name: Option, + #[serde(default)] + id: Option, + #[serde(default)] + model: Option, + #[serde(default, alias = "modelName", alias = "model_name")] + model_name: Option, + #[serde(default, alias = "modelId", alias = "model_id")] + model_id: Option, + #[serde(default)] + metadata: Option, + } + + impl ModelEntry { + fn get_name(&self) -> Option { + self.name + .clone() + .or_else(|| self.id.clone()) + .or_else(|| self.model.clone()) + .or_else(|| self.model_name.clone()) + .or_else(|| self.model_id.clone()) + .or_else(|| self.metadata.as_ref().and_then(|m| m.name.clone())) + .or_else(|| self.metadata.as_ref().and_then(|m| m.model_name.clone())) + } + } + + #[derive(Deserialize)] + struct ModelsResponse { + #[serde(default)] + models: Option>, + #[serde(default)] + data: Option>, + } + + if let Ok(resp) = serde_json::from_str::(&response_text) { + if let Some(entries) = resp.models.or(resp.data) { + let models: Vec = entries + .into_iter() + .filter_map(|e| e.get_name().map(|name| ModelInfo { name, provider: None })) + .collect(); + if !models.is_empty() { + return Ok(models); + } + } + } + + // Try direct array format + if let Ok(entries) = serde_json::from_str::>(&response_text) { + let models: Vec = entries + .into_iter() + .filter_map(|e| e.get_name().map(|name| ModelInfo { name, provider: None })) + .collect(); + if !models.is_empty() { + return Ok(models); + } + } + + // Couldn't find model names in response + Err(LlmError::InvalidResponse { + provider: "nearai".to_string(), + reason: format!( + "No model names found in response: {}", + &response_text[..response_text.len().min(300)] + ), + }) + } + /// Send a request with automatic session renewal on 401. async fn send_request Deserialize<'de>>( &self, @@ -431,6 +559,12 @@ impl LlmProvider for NearAiProvider { // These are approximate and may vary by model (dec!(0.000003), dec!(0.000015)) } + + async fn list_models(&self) -> Result, LlmError> { + // Use the inherent method and extract IDs + let models = NearAiProvider::list_models(self).await?; + Ok(models.into_iter().map(|m| m.name).collect()) + } } // NEAR AI API types diff --git a/src/llm/provider.rs b/src/llm/provider.rs index 64487e5f..63469664 100644 --- a/src/llm/provider.rs +++ b/src/llm/provider.rs @@ -224,6 +224,12 @@ pub trait LlmProvider: Send + Sync { request: ToolCompletionRequest, ) -> Result; + /// List available models from the provider. + /// Default implementation returns empty list. + async fn list_models(&self) -> Result, LlmError> { + Ok(Vec::new()) + } + /// Calculate cost for a completion. fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { let (input_cost, output_cost) = self.cost_per_token(); diff --git a/src/main.rs b/src/main.rs index 2516fe2c..1741d993 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use near_agent::{ agent::{Agent, AgentDeps}, - channels::{ChannelManager, HttpChannel, TuiChannel}, + channels::{AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel}, cli::{Cli, Command, run_tool_command}, config::Config, history::Store, @@ -59,24 +59,55 @@ async fn main() -> anyhow::Result<()> { // This happens before TUI so the menu displays correctly session.ensure_authenticated().await?; - // Now create TUI channel and set up logging - let tui_channel = TuiChannel::new(); - let tui_log_writer = tui_channel.log_writer(); - - // Initialize tracing with TUI writer + // Initialize tracing and channels based on mode let env_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("near_agent=info,tower_http=debug")); - tracing_subscriber::registry() - .with(env_filter) - .with( - tracing_subscriber::fmt::layer() - .with_writer(tui_log_writer) - .without_time() - .with_target(false) - .with_level(true), - ) - .init(); + // Determine which mode to use: REPL, single message, or TUI + let use_repl = cli.repl || cli.message.is_some(); + + // Create appropriate channel based on mode + let (tui_channel, tui_event_sender, repl_channel) = if use_repl { + // REPL mode - use simple stdin/stdout + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer().with_target(false)) + .init(); + + let repl = if let Some(ref msg) = cli.message { + ReplChannel::with_message(msg.clone()) + } else { + ReplChannel::new() + }; + + (None, None, Some(repl)) + } else if config.channels.cli.enabled { + // TUI mode + let channel = TuiChannel::new(); + let log_writer = channel.log_writer(); + let event_sender = channel.event_sender(); + + tracing_subscriber::registry() + .with(env_filter) + .with( + tracing_subscriber::fmt::layer() + .with_writer(log_writer) + .without_time() + .with_target(false) + .with_level(true), + ) + .init(); + + (Some(channel), Some(event_sender), None) + } else { + // No CLI - just logging + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer().with_target(false)) + .init(); + + (None, None, None) + }; tracing::info!("Starting NEAR Agent..."); tracing::info!("Loaded configuration for agent: {}", config.agent.name); @@ -97,6 +128,29 @@ async fn main() -> anyhow::Result<()> { let llm = create_llm_provider(&config.llm, session.clone())?; tracing::info!("LLM provider initialized: {}", llm.model_name()); + // Fetch available models and send to TUI (async, non-blocking) + if let Some(ref event_tx) = tui_event_sender { + let llm_for_models = llm.clone(); + let event_tx = event_tx.clone(); + tokio::spawn(async move { + match llm_for_models.list_models().await { + Ok(models) if !models.is_empty() => { + let _ = event_tx.send(AppEvent::AvailableModels(models)).await; + } + Ok(_) => { + let _ = event_tx + .send(AppEvent::ErrorMessage("No models available from API".into())) + .await; + } + Err(e) => { + let _ = event_tx + .send(AppEvent::ErrorMessage(format!("Failed to fetch models: {}", e))) + .await; + } + } + }); + } + // Initialize safety layer let safety = Arc::new(SafetyLayer::new(&config.safety)); tracing::info!("Safety layer initialized"); @@ -205,14 +259,23 @@ async fn main() -> anyhow::Result<()> { // Initialize channel manager let mut channels = ChannelManager::new(); - // Add TUI channel (already created for logging hookup) - if config.channels.cli.enabled { - channels.add(Box::new(tui_channel)); + // Add REPL channel if in REPL mode + if let Some(repl) = repl_channel { + channels.add(Box::new(repl)); + if cli.message.is_some() { + tracing::info!("Single message mode"); + } else { + tracing::info!("REPL mode enabled"); + } + } + // Add TUI channel if CLI is enabled (already created for logging hookup) + else if let Some(tui) = tui_channel { + channels.add(Box::new(tui)); tracing::info!("TUI channel enabled"); } // Add HTTP channel if configured and not CLI-only mode - if !cli.cli_only { + if !cli.cli_only && !use_repl { if let Some(ref http_config) = config.channels.http { channels.add(Box::new(HttpChannel::new(http_config.clone()))); tracing::info!( diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 00000000..651ab513 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,107 @@ +//! User settings persistence. +//! +//! Stores user preferences like selected model in ~/.near-agent/settings.json. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// User settings persisted to disk. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Settings { + /// Currently selected model. + #[serde(default)] + pub selected_model: Option, +} + +impl Settings { + /// Get the default settings file path (~/.near-agent/settings.json). + pub fn default_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".near-agent") + .join("settings.json") + } + + /// Load settings from disk, returning default if not found. + pub fn load() -> Self { + Self::load_from(&Self::default_path()) + } + + /// Load settings from a specific path. + pub fn load_from(path: &PathBuf) -> Self { + match std::fs::read_to_string(path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + /// Save settings to disk. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&Self::default_path()) + } + + /// Save settings to a specific path. + pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let json = serde_json::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + + std::fs::write(path, json) + } + + /// Get the selected model, falling back to the provided default. + pub fn model_or(&self, default: &str) -> String { + self.selected_model + .clone() + .unwrap_or_else(|| default.to_string()) + } + + /// Set the selected model and save. + pub fn set_model(&mut self, model: &str) -> std::io::Result<()> { + self.selected_model = Some(model.to_string()); + self.save() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_settings_save_load() { + let dir = tempdir().unwrap(); + let path = dir.path().join("settings.json"); + + let settings = Settings { + selected_model: Some("claude-3-5-sonnet-20241022".to_string()), + }; + + settings.save_to(&path).unwrap(); + + let loaded = Settings::load_from(&path); + assert_eq!( + loaded.selected_model, + Some("claude-3-5-sonnet-20241022".to_string()) + ); + } + + #[test] + fn test_model_or_default() { + let settings = Settings::default(); + assert_eq!( + settings.model_or("default-model"), + "default-model".to_string() + ); + + let settings = Settings { + selected_model: Some("my-model".to_string()), + }; + assert_eq!(settings.model_or("default-model"), "my-model".to_string()); + } +} diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index 4194e9ae..447e1979 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -40,7 +40,9 @@ use uuid::Uuid; use crate::context::JobContext; use crate::error::ToolError as AgentToolError; -use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, ToolDefinition}; +use crate::llm::{ + ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolDefinition, +}; use crate::safety::SafetyLayer; use crate::tools::ToolRegistry; use crate::tools::tool::{Tool, ToolError, ToolOutput}; @@ -486,6 +488,7 @@ When defining capabilities for your tool, specify which host functions it needs: // Main build loop let mut current_phase = BuildPhase::Scaffolding; let mut last_error: Option = None; + let mut tools_executed = false; loop { iteration += 1; @@ -515,121 +518,143 @@ When defining capabilities for your tool, specify which host functions it needs: }); } - // Get next action from LLM - let selections = reasoning.select_tools(&reason_ctx).await.map_err(|e| { - AgentToolError::BuilderFailed(format!("LLM tool selection failed: {}", e)) - })?; + // Refresh tool definitions each iteration + reason_ctx.available_tools = self.get_build_tools().await; - if selections.is_empty() { - // No tools selected - get response and check if done - let response = reasoning.respond(&reason_ctx).await.map_err(|e| { + // Get response from LLM (may be text or tool calls) + let result = reasoning + .respond_with_tools(&reason_ctx) + .await + .map_err(|e| { AgentToolError::BuilderFailed(format!("LLM response failed: {}", e)) })?; - reason_ctx.messages.push(ChatMessage::assistant(&response)); - - // Check for completion signals - let response_lower = response.to_lowercase(); - if response_lower.contains("build complete") - || response_lower.contains("successfully built") - || response_lower.contains("all tests pass") - { - logs.push(BuildLog { - timestamp: Utc::now(), - phase: BuildPhase::Complete, - message: "Build completed successfully".into(), - details: Some(response), - }); - - // Determine artifact path - let artifact_path = self.find_artifact(requirement, project_dir).await; - - return Ok(BuildResult { - build_id, - requirement: requirement.clone(), - artifact_path, - logs, - success: true, - error: None, - started_at, - completed_at: Utc::now(), - iterations: iteration, - validation_warnings: Vec::new(), - tests_passed: 0, - tests_failed: 0, - registered: false, - }); - } - - // Ask for next steps - reason_ctx - .messages - .push(ChatMessage::user("Continue with the next step.")); - continue; - } - - // Execute selected tools - for selection in &selections { - logs.push(BuildLog { - timestamp: Utc::now(), - phase: current_phase, - message: format!("Executing: {}", selection.tool_name), - details: Some(selection.reasoning.clone()), - }); - - // Execute tool - let tool_result = self - .execute_build_tool(&selection.tool_name, &selection.parameters, project_dir) - .await; - - match tool_result { - Ok(output) => { - let output_str = - serde_json::to_string_pretty(&output.result).unwrap_or_default(); - - // Add to context - reason_ctx.messages.push(ChatMessage::tool_result( - "tool_call", - &selection.tool_name, - output_str.clone(), + match result { + RespondResult::Text(response) => { + // If no tools have been executed, prompt for tool use + if !tools_executed && iteration < 3 { + tracing::debug!( + "Builder: no tools executed yet (iteration {}), prompting for action", + iteration + ); + reason_ctx.messages.push(ChatMessage::assistant(&response)); + reason_ctx.messages.push(ChatMessage::user( + "Please use the available tools to implement this. Start by creating the necessary files.", )); - - // Update phase based on tool - current_phase = match selection.tool_name.as_str() { - "write_file" => BuildPhase::Implementing, - "shell" if selection.parameters.to_string().contains("build") => { - BuildPhase::Building - } - "shell" if selection.parameters.to_string().contains("test") => { - BuildPhase::Testing - } - _ => current_phase, - }; - - // Check for build/test errors in output - if output_str.contains("error") || output_str.contains("failed") { - last_error = Some(output_str); - current_phase = BuildPhase::Fixing; - } + continue; } - Err(e) => { - let error_msg = format!("Tool error: {}", e); - last_error = Some(error_msg.clone()); - reason_ctx.messages.push(ChatMessage::tool_result( - "tool_call", - &selection.tool_name, - format!("Error: {}", e), - )); + reason_ctx.messages.push(ChatMessage::assistant(&response)); + // Check for completion signals + let response_lower = response.to_lowercase(); + if response_lower.contains("build complete") + || response_lower.contains("successfully built") + || response_lower.contains("all tests pass") + || (tools_executed && response_lower.contains("complete")) + { logs.push(BuildLog { timestamp: Utc::now(), - phase: BuildPhase::Fixing, - message: "Tool execution failed".into(), - details: Some(error_msg), + phase: BuildPhase::Complete, + message: "Build completed successfully".into(), + details: Some(response), }); - current_phase = BuildPhase::Fixing; + // Determine artifact path + let artifact_path = self.find_artifact(requirement, project_dir).await; + + return Ok(BuildResult { + build_id, + requirement: requirement.clone(), + artifact_path, + logs, + success: true, + error: None, + started_at, + completed_at: Utc::now(), + iterations: iteration, + validation_warnings: Vec::new(), + tests_passed: 0, + tests_failed: 0, + registered: false, + }); + } + + // Ask for next steps + reason_ctx + .messages + .push(ChatMessage::user("Continue with the next step.")); + } + RespondResult::ToolCalls(tool_calls) => { + tools_executed = true; + + // Execute each tool call + for tc in tool_calls { + logs.push(BuildLog { + timestamp: Utc::now(), + phase: current_phase, + message: format!("Executing: {}", tc.name), + details: Some(format!("{:?}", tc.arguments)), + }); + + // Execute tool + let tool_result = self + .execute_build_tool(&tc.name, &tc.arguments, project_dir) + .await; + + match tool_result { + Ok(output) => { + let output_str = serde_json::to_string_pretty(&output.result) + .unwrap_or_default(); + + // Add to context + reason_ctx.messages.push(ChatMessage::tool_result( + &tc.id, + &tc.name, + output_str.clone(), + )); + + // Update phase based on tool + current_phase = match tc.name.as_str() { + "write_file" => BuildPhase::Implementing, + "shell" if tc.arguments.to_string().contains("build") => { + BuildPhase::Building + } + "shell" if tc.arguments.to_string().contains("test") => { + BuildPhase::Testing + } + _ => current_phase, + }; + + // Check for build/test errors in output + if output_str.to_lowercase().contains("error:") + || output_str.to_lowercase().contains("error[") + || output_str.to_lowercase().contains("failed") + { + last_error = Some(output_str); + current_phase = BuildPhase::Fixing; + } + } + Err(e) => { + let error_msg = format!("Tool error: {}", e); + last_error = Some(error_msg.clone()); + + reason_ctx.messages.push(ChatMessage::tool_result( + &tc.id, + &tc.name, + format!("Error: {}", e), + )); + + logs.push(BuildLog { + timestamp: Utc::now(), + phase: BuildPhase::Fixing, + message: "Tool execution failed".into(), + details: Some(error_msg), + }); + + current_phase = BuildPhase::Fixing; + } + } } } }