Support non interactive mode and model selection

This commit is contained in:
Illia Polosukhin
2026-02-03 14:47:26 -08:00
parent 2cc9aed364
commit 2c26ba8431
17 changed files with 999 additions and 141 deletions
+9 -7
View File
@@ -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(),
+72
View File
@@ -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<String>),
/// 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<ApprovalOverlay>,
/// Model selector overlay (if active).
pub model_selector: Option<ModelSelectorOverlay>,
/// Scroll offset for messages.
pub scroll_offset: u16,
/// Whether the app should quit.
@@ -122,11 +131,19 @@ pub struct AppState {
pub status_message: Option<String>,
/// 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<String>,
}
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<String>) {
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<String>) {
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<String>) {
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<String>) {
let content = content.into();
+38
View File
@@ -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);
}
}
}
+8
View File
@@ -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<AppEvent> {
self.event_tx.clone()
}
}
impl Default for TuiChannel {
+156
View File
@@ -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<String>,
}
/// 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();
}
}
+89 -5
View File
@@ -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<Span> = 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);
}
+3 -1
View File
@@ -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;
+143
View File
@@ -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<String>,
}
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<MessageStream, ChannelError> {
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(())
}
}
+8
View File
@@ -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<String>,
/// Configuration file path (optional, uses env vars by default)
#[arg(short, long, global = true)]
pub config: Option<std::path::PathBuf>,
+12 -3
View File
@@ -94,8 +94,11 @@ impl LlmConfig {
fn from_env() -> Result<Self, ConfigError> {
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,
})
}
+1
View File
@@ -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;
+1 -1
View File
@@ -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,
+134
View File
@@ -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<String>,
}
/// 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<Vec<ModelInfo>, 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<String>,
#[serde(default, alias = "modelName", alias = "model_name")]
model_name: Option<String>,
}
#[derive(Deserialize)]
struct ModelEntry {
#[serde(default)]
name: Option<String>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default, alias = "modelName", alias = "model_name")]
model_name: Option<String>,
#[serde(default, alias = "modelId", alias = "model_id")]
model_id: Option<String>,
#[serde(default)]
metadata: Option<ModelMetadata>,
}
impl ModelEntry {
fn get_name(&self) -> Option<String> {
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<Vec<ModelEntry>>,
#[serde(default)]
data: Option<Vec<ModelEntry>>,
}
if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text) {
if let Some(entries) = resp.models.or(resp.data) {
let models: Vec<ModelInfo> = 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::<Vec<ModelEntry>>(&response_text) {
let models: Vec<ModelInfo> = 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<T: Serialize + std::fmt::Debug, R: for<'de> 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<Vec<String>, 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
+6
View File
@@ -224,6 +224,12 @@ pub trait LlmProvider: Send + Sync {
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError>;
/// List available models from the provider.
/// Default implementation returns empty list.
async fn list_models(&self) -> Result<Vec<String>, 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();
+83 -20
View File
@@ -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!(
+107
View File
@@ -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<String>,
}
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());
}
}
+129 -104
View File
@@ -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<String> = 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;
}
}
}
}
}