mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Support non interactive mode and model selection
This commit is contained in:
+9
-7
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user