fix(agent): improve ReAct parser and tool schemas for better LLM compatibility
- Fix ACTION_INPUT regex to properly capture multiline JSON responses - Changed from stopping at first newline to capturing all remaining text - Resolves parsing errors when LLM generates formatted JSON with line breaks - Enhance tool schemas with detailed descriptions and parameter specifications - Add comprehensive Message schema for generate_text tool - Clarify distinction between resources/get (file read) and resources/list (directory listing) - Include clear usage guidance in tool descriptions - Set default model to llama3.2:latest instead of invalid "ollama" - Add parse error debugging to help troubleshoot LLM response issues The agent infrastructure now correctly handles multiline tool arguments and provides better guidance to LLMs through improved tool schemas. Remaining errors are due to LLM quality (model making poor tool choices or generating malformed responses), not infrastructure bugs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ description = "Terminal User Interface for OWLEN LLM client"
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-ollama = { path = "../owlen-ollama" }
|
||||
# Removed circular dependency on `owlen-cli`. The TUI no longer directly depends on the CLI crate.
|
||||
|
||||
# TUI framework
|
||||
ratatui = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||
use owlen_core::{
|
||||
provider::{Provider, ProviderConfig},
|
||||
session::{SessionController, SessionOutcome},
|
||||
@@ -14,7 +15,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::config;
|
||||
use crate::events::Event;
|
||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
||||
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -108,6 +110,18 @@ pub enum SessionEvent {
|
||||
endpoints: Vec<String>,
|
||||
callback_id: Uuid,
|
||||
},
|
||||
/// Agent iteration update (shows THOUGHT/ACTION/OBSERVATION)
|
||||
AgentUpdate {
|
||||
content: String,
|
||||
},
|
||||
/// Agent execution completed with final answer
|
||||
AgentCompleted {
|
||||
answer: String,
|
||||
},
|
||||
/// Agent execution failed
|
||||
AgentFailed {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub const HELP_TAB_COUNT: usize = 7;
|
||||
@@ -138,11 +152,13 @@ pub struct ChatApp {
|
||||
loading_animation_frame: usize, // Frame counter for loading animation
|
||||
is_loading: bool, // Whether we're currently loading a response
|
||||
current_thinking: Option<String>, // Current thinking content from last assistant message
|
||||
pending_key: Option<char>, // For multi-key sequences like gg, dd
|
||||
clipboard: String, // Vim-style clipboard for yank/paste
|
||||
command_buffer: String, // Buffer for command mode input
|
||||
// Holds the latest formatted Agentic ReAct actions (thought/action/observation)
|
||||
agent_actions: Option<String>,
|
||||
pending_key: Option<char>, // For multi-key sequences like gg, dd
|
||||
clipboard: String, // Vim-style clipboard for yank/paste
|
||||
command_buffer: String, // Buffer for command mode input
|
||||
command_suggestions: Vec<String>, // Filtered command suggestions based on current input
|
||||
selected_suggestion: usize, // Index of selected suggestion
|
||||
selected_suggestion: usize, // Index of selected suggestion
|
||||
visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel
|
||||
visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels
|
||||
focused_panel: FocusedPanel, // Currently focused panel for scrolling
|
||||
@@ -156,6 +172,12 @@ pub struct ChatApp {
|
||||
selected_theme_index: usize, // Index of selected theme in browser
|
||||
pending_consent: Option<ConsentDialogState>, // Pending consent request
|
||||
system_status: String, // System/status messages (tool execution, status, etc)
|
||||
/// Simple execution budget: maximum number of tool calls allowed per session.
|
||||
_execution_budget: usize,
|
||||
/// Agent mode enabled
|
||||
agent_mode: bool,
|
||||
/// Agent running flag
|
||||
agent_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -210,6 +232,7 @@ impl ChatApp {
|
||||
loading_animation_frame: 0,
|
||||
is_loading: false,
|
||||
current_thinking: None,
|
||||
agent_actions: None,
|
||||
pending_key: None,
|
||||
clipboard: String::new(),
|
||||
command_buffer: String::new(),
|
||||
@@ -228,6 +251,9 @@ impl ChatApp {
|
||||
selected_theme_index: 0,
|
||||
pending_consent: None,
|
||||
system_status: String::new(),
|
||||
_execution_budget: 50,
|
||||
agent_mode: false,
|
||||
agent_running: false,
|
||||
};
|
||||
|
||||
Ok((app, session_rx))
|
||||
@@ -396,6 +422,8 @@ impl ChatApp {
|
||||
("privacy-enable", "Enable a privacy-sensitive tool"),
|
||||
("privacy-disable", "Disable a privacy-sensitive tool"),
|
||||
("privacy-clear", "Clear stored secure data"),
|
||||
("agent", "Enable agent mode for autonomous task execution"),
|
||||
("stop-agent", "Stop the running agent"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1495,6 +1523,25 @@ impl ChatApp {
|
||||
self.command_suggestions.clear();
|
||||
return Ok(AppState::Running);
|
||||
}
|
||||
// "run-agent" command removed to break circular dependency on owlen-cli.
|
||||
"agent" => {
|
||||
if self.agent_running {
|
||||
self.status = "Agent is already running".to_string();
|
||||
} else {
|
||||
self.agent_mode = true;
|
||||
self.status = "Agent mode enabled. Next message will be processed by agent.".to_string();
|
||||
}
|
||||
}
|
||||
"stop-agent" => {
|
||||
if self.agent_running {
|
||||
self.agent_running = false;
|
||||
self.agent_mode = false;
|
||||
self.status = "Agent execution stopped".to_string();
|
||||
self.agent_actions = None;
|
||||
} else {
|
||||
self.status = "No agent is currently running".to_string();
|
||||
}
|
||||
}
|
||||
"n" | "new" => {
|
||||
self.controller.start_new_conversation(None, None);
|
||||
self.status = "Started new conversation".to_string();
|
||||
@@ -2166,6 +2213,28 @@ impl ChatApp {
|
||||
});
|
||||
self.status = "Consent required - Press Y to allow, N to deny".to_string();
|
||||
}
|
||||
SessionEvent::AgentUpdate { content } => {
|
||||
// Update agent actions panel with latest ReAct iteration
|
||||
self.set_agent_actions(content);
|
||||
}
|
||||
SessionEvent::AgentCompleted { answer } => {
|
||||
// Agent finished, add final answer to conversation
|
||||
self.controller
|
||||
.conversation_mut()
|
||||
.push_assistant_message(answer);
|
||||
self.agent_running = false;
|
||||
self.agent_mode = false;
|
||||
self.agent_actions = None;
|
||||
self.status = "Agent completed successfully".to_string();
|
||||
self.stop_loading_animation();
|
||||
}
|
||||
SessionEvent::AgentFailed { error } => {
|
||||
// Agent failed, show error
|
||||
self.error = Some(format!("Agent failed: {}", error));
|
||||
self.agent_running = false;
|
||||
self.agent_actions = None;
|
||||
self.stop_loading_animation();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2577,6 +2646,11 @@ impl ChatApp {
|
||||
|
||||
self.pending_llm_request = false;
|
||||
|
||||
// Check if agent mode is enabled
|
||||
if self.agent_mode {
|
||||
return self.process_agent_request().await;
|
||||
}
|
||||
|
||||
// Step 1: Show loading model status and start animation
|
||||
self.status = format!("Loading model '{}'...", self.controller.selected_model());
|
||||
self.start_loading_animation();
|
||||
@@ -2640,6 +2714,77 @@ impl ChatApp {
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_agent_request(&mut self) -> Result<()> {
|
||||
use owlen_core::agent::{AgentConfig, AgentExecutor};
|
||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||
use std::sync::Arc;
|
||||
|
||||
self.agent_running = true;
|
||||
self.status = "Agent is running...".to_string();
|
||||
self.start_loading_animation();
|
||||
|
||||
// Get the last user message
|
||||
let user_message = self
|
||||
.controller
|
||||
.conversation()
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == owlen_core::types::Role::User)
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Create agent config
|
||||
let config = AgentConfig {
|
||||
max_iterations: 10,
|
||||
model: self.controller.selected_model().to_string(),
|
||||
temperature: Some(0.7),
|
||||
max_tokens: None,
|
||||
max_tool_calls: 20,
|
||||
};
|
||||
|
||||
// Get the provider
|
||||
let provider = self.controller.provider().clone();
|
||||
|
||||
// Create MCP client
|
||||
let mcp_client = match RemoteMcpClient::new() {
|
||||
Ok(client) => Arc::new(client),
|
||||
Err(e) => {
|
||||
self.error = Some(format!("Failed to initialize MCP client: {}", e));
|
||||
self.agent_running = false;
|
||||
self.agent_mode = false;
|
||||
self.stop_loading_animation();
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Create agent executor
|
||||
let executor = AgentExecutor::new(provider, mcp_client, config, None);
|
||||
|
||||
// Run agent
|
||||
match executor.run(user_message).await {
|
||||
Ok(answer) => {
|
||||
self.controller
|
||||
.conversation_mut()
|
||||
.push_assistant_message(answer);
|
||||
self.agent_running = false;
|
||||
self.agent_mode = false;
|
||||
self.agent_actions = None;
|
||||
self.status = "Agent completed successfully".to_string();
|
||||
self.stop_loading_animation();
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(format!("Agent failed: {}", e));
|
||||
self.agent_running = false;
|
||||
self.agent_mode = false;
|
||||
self.agent_actions = None;
|
||||
self.stop_loading_animation();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn process_pending_tool_execution(&mut self) -> Result<()> {
|
||||
if self.pending_tool_execution.is_none() {
|
||||
return Ok(());
|
||||
@@ -2813,6 +2958,26 @@ impl ChatApp {
|
||||
self.current_thinking.as_ref()
|
||||
}
|
||||
|
||||
/// Get a reference to the latest agent actions, if any.
|
||||
pub fn agent_actions(&self) -> Option<&String> {
|
||||
self.agent_actions.as_ref()
|
||||
}
|
||||
|
||||
/// Set the current agent actions content.
|
||||
pub fn set_agent_actions(&mut self, actions: String) {
|
||||
self.agent_actions = Some(actions);
|
||||
}
|
||||
|
||||
/// Check if agent mode is enabled
|
||||
pub fn is_agent_mode(&self) -> bool {
|
||||
self.agent_mode
|
||||
}
|
||||
|
||||
/// Check if agent is currently running
|
||||
pub fn is_agent_running(&self) -> bool {
|
||||
self.agent_running
|
||||
}
|
||||
|
||||
pub fn get_rendered_lines(&self) -> Vec<String> {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
|
||||
@@ -51,6 +51,15 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
||||
0
|
||||
};
|
||||
|
||||
// Calculate agent actions panel height (similar to thinking)
|
||||
let actions_height = if let Some(actions) = app.agent_actions() {
|
||||
let content_width = available_width.saturating_sub(4);
|
||||
let visual_lines = calculate_wrapped_line_count(actions.lines(), content_width);
|
||||
(visual_lines as u16).min(6) + 2 // +2 for borders, max 6 lines
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut constraints = vec![
|
||||
Constraint::Length(4), // Header
|
||||
Constraint::Min(8), // Messages
|
||||
@@ -59,6 +68,10 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
||||
if thinking_height > 0 {
|
||||
constraints.push(Constraint::Length(thinking_height)); // Thinking
|
||||
}
|
||||
// Insert agent actions panel after thinking (if any)
|
||||
if actions_height > 0 {
|
||||
constraints.push(Constraint::Length(actions_height)); // Agent actions
|
||||
}
|
||||
|
||||
constraints.push(Constraint::Length(input_height)); // Input
|
||||
constraints.push(Constraint::Length(5)); // System/Status output (3 lines content + 2 borders)
|
||||
@@ -80,6 +93,11 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
||||
render_thinking(frame, layout[idx], app);
|
||||
idx += 1;
|
||||
}
|
||||
// Render agent actions panel if present
|
||||
if actions_height > 0 {
|
||||
render_agent_actions(frame, layout[idx], app);
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
render_input(frame, layout[idx], app);
|
||||
idx += 1;
|
||||
@@ -898,6 +916,191 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
||||
}
|
||||
}
|
||||
|
||||
// Render a panel displaying the latest ReAct agent actions (thought/action/observation).
|
||||
// Color-coded: THOUGHT (blue), ACTION (yellow), OBSERVATION (green)
|
||||
fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
||||
let theme = app.theme().clone();
|
||||
|
||||
if let Some(actions) = app.agent_actions().cloned() {
|
||||
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
|
||||
let content_width = area.width.saturating_sub(4);
|
||||
|
||||
// Parse and color-code ReAct components
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
for line in actions.lines() {
|
||||
let line_trimmed = line.trim();
|
||||
|
||||
// Detect ReAct components and apply color coding
|
||||
if line_trimmed.starts_with("THOUGHT:") {
|
||||
// Blue for THOUGHT
|
||||
let thought_content = line_trimmed.strip_prefix("THOUGHT:").unwrap_or("").trim();
|
||||
let wrapped = wrap(thought_content, content_width as usize);
|
||||
|
||||
// First line with label
|
||||
if let Some(first) = wrapped.first() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"THOUGHT: ",
|
||||
Style::default()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(first.to_string(), Style::default().fg(Color::Blue)),
|
||||
]));
|
||||
}
|
||||
|
||||
// Continuation lines
|
||||
for chunk in wrapped.iter().skip(1) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", chunk),
|
||||
Style::default().fg(Color::Blue),
|
||||
)));
|
||||
}
|
||||
} else if line_trimmed.starts_with("ACTION:") {
|
||||
// Yellow for ACTION
|
||||
let action_content = line_trimmed.strip_prefix("ACTION:").unwrap_or("").trim();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"ACTION: ",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
action_content,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
} else if line_trimmed.starts_with("ACTION_INPUT:") {
|
||||
// Cyan for ACTION_INPUT
|
||||
let input_content = line_trimmed
|
||||
.strip_prefix("ACTION_INPUT:")
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let wrapped = wrap(input_content, content_width as usize);
|
||||
|
||||
if let Some(first) = wrapped.first() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"ACTION_INPUT: ",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(first.to_string(), Style::default().fg(Color::Cyan)),
|
||||
]));
|
||||
}
|
||||
|
||||
for chunk in wrapped.iter().skip(1) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", chunk),
|
||||
Style::default().fg(Color::Cyan),
|
||||
)));
|
||||
}
|
||||
} else if line_trimmed.starts_with("OBSERVATION:") {
|
||||
// Green for OBSERVATION
|
||||
let obs_content = line_trimmed
|
||||
.strip_prefix("OBSERVATION:")
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let wrapped = wrap(obs_content, content_width as usize);
|
||||
|
||||
if let Some(first) = wrapped.first() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"OBSERVATION: ",
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(first.to_string(), Style::default().fg(Color::Green)),
|
||||
]));
|
||||
}
|
||||
|
||||
for chunk in wrapped.iter().skip(1) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", chunk),
|
||||
Style::default().fg(Color::Green),
|
||||
)));
|
||||
}
|
||||
} else if line_trimmed.starts_with("FINAL_ANSWER:") {
|
||||
// Magenta for FINAL_ANSWER
|
||||
let answer_content = line_trimmed
|
||||
.strip_prefix("FINAL_ANSWER:")
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let wrapped = wrap(answer_content, content_width as usize);
|
||||
|
||||
if let Some(first) = wrapped.first() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"FINAL_ANSWER: ",
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
first.to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
for chunk in wrapped.iter().skip(1) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", chunk),
|
||||
Style::default().fg(Color::Magenta),
|
||||
)));
|
||||
}
|
||||
} else if !line_trimmed.is_empty() {
|
||||
// Regular text
|
||||
let wrapped = wrap(line_trimmed, content_width as usize);
|
||||
for chunk in wrapped {
|
||||
lines.push(Line::from(Span::styled(
|
||||
chunk.into_owned(),
|
||||
Style::default().fg(theme.text),
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
// Empty line
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight border if this panel is focused
|
||||
let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) {
|
||||
// Reuse the same focus logic; could add a dedicated enum variant later.
|
||||
theme.focused_panel_border
|
||||
} else {
|
||||
theme.unfocused_panel_border
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.style(Style::default().bg(theme.background))
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(
|
||||
" 🤖 Agent Actions ",
|
||||
Style::default()
|
||||
.fg(theme.thinking_panel_title)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(border_color))
|
||||
.style(Style::default().bg(theme.background).fg(theme.text)),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
_ = viewport_height;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
||||
let theme = app.theme();
|
||||
let title = match app.mode() {
|
||||
@@ -1068,17 +1271,35 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
|
||||
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
|
||||
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
format!(" {} ", mode_text),
|
||||
let mut spans = vec![Span::styled(
|
||||
format!(" {} ", mode_text),
|
||||
Style::default()
|
||||
.fg(theme.background)
|
||||
.bg(mode_bg_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
|
||||
// Add agent status indicator if agent mode is active
|
||||
if app.is_agent_running() {
|
||||
spans.push(Span::styled(
|
||||
" 🤖 AGENT RUNNING ",
|
||||
Style::default()
|
||||
.fg(theme.background)
|
||||
.bg(mode_bg_color)
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" ", Style::default().fg(theme.text)),
|
||||
Span::styled(help_text, Style::default().fg(theme.info)),
|
||||
];
|
||||
));
|
||||
} else if app.is_agent_mode() {
|
||||
spans.push(Span::styled(
|
||||
" 🤖 AGENT MODE ",
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
|
||||
spans.push(Span::styled(" ", Style::default().fg(theme.text)));
|
||||
spans.push(Span::styled(help_text, Style::default().fg(theme.info)));
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans))
|
||||
.alignment(Alignment::Left)
|
||||
|
||||
Reference in New Issue
Block a user