From 4b1777f3a4e904646dd7864321895757103c58dc Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 29 Jan 2026 13:30:29 +0800 Subject: [PATCH 1/9] feat: added tools registry. 6 core tools. --- Cargo.lock | 3 + Cargo.toml | 3 + src/main.rs | 1 + src/tools/bash.rs | 213 ++++++++++++++++++++++++++++++++++++++++++ src/tools/context.rs | 40 ++++++++ src/tools/edit.rs | 166 ++++++++++++++++++++++++++++++++ src/tools/fs/glob.rs | 109 +++++++++++++++++++++ src/tools/fs/list.rs | 194 ++++++++++++++++++++++++++++++++++++++ src/tools/fs/mod.rs | 9 ++ src/tools/fs/read.rs | 135 ++++++++++++++++++++++++++ src/tools/fs/write.rs | 96 +++++++++++++++++++ src/tools/init.rs | 18 ++++ src/tools/mod.rs | 59 ++++++++++++ src/tools/registry.rs | 50 ++++++++++ src/tools/types.rs | 116 +++++++++++++++++++++++ 15 files changed, 1212 insertions(+) create mode 100644 src/tools/bash.rs create mode 100644 src/tools/context.rs create mode 100644 src/tools/edit.rs create mode 100644 src/tools/fs/glob.rs create mode 100644 src/tools/fs/list.rs create mode 100644 src/tools/fs/mod.rs create mode 100644 src/tools/fs/read.rs create mode 100644 src/tools/fs/write.rs create mode 100644 src/tools/init.rs create mode 100644 src/tools/mod.rs create mode 100644 src/tools/registry.rs create mode 100644 src/tools/types.rs diff --git a/Cargo.lock b/Cargo.lock index f2c2fc5..2c49229 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -804,6 +804,7 @@ dependencies = [ "cuid2", "dirs 5.0.1", "futures", + "glob", "ignore", "lazy_static", "nucleo-matcher", @@ -814,7 +815,9 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "strsim", "textwrap", + "thiserror 1.0.69", "tokio", "tokio-test", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 12c2131..ead7fe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ cuid2 = "0.1" chrono = { version = "0.4", features = ["serde"] } aisdk = { version = "0.4", features = ["openai", "openaichatcompletions"] } tokio-util = "0.7" +glob = "0.3" +strsim = "0.11" +thiserror = "1.0" textwrap = "0.16" unicode-width = "0.1" tui-markdown = "0.3" diff --git a/src/main.rs b/src/main.rs index 258366e..0142ea6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod persistence; mod session; mod streaming; mod theme; +mod tools; mod ui; mod utils; mod views; diff --git a/src/tools/bash.rs b/src/tools/bash.rs new file mode 100644 index 0000000..5ce1741 --- /dev/null +++ b/src/tools/bash.rs @@ -0,0 +1,213 @@ +use crate::tools::{ + get_bool_param, get_integer_param, get_string_param, validate_required, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, ParameterSchema, ParameterType, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT_SECONDS: u64 = 120; +const MAX_OUTPUT_SIZE: usize = 51200; // 50KB + +pub struct BashTool; + +impl BashTool { + pub fn new() -> Self { + Self + } + + fn is_dangerous(command: &str) -> Option { + let dangerous_patterns = [ + "rm -rf /", + "rm -rf /*", + ":(){ :|: & };:", + "> /dev/sda", + "mkfs", + "dd if=/dev/zero", + "chmod -R 777 /", + ]; + + for pattern in &dangerous_patterns { + if command.contains(pattern) { + return Some(format!("Command contains dangerous pattern: {}", pattern)); + } + } + + None + } +} + +#[async_trait] +impl ToolHandler for BashTool { + fn definition(&self) -> Tool { + Tool { + id: "bash".to_string(), + description: "Execute shell commands with timeout and output streaming.".to_string(), + parameters: vec![ + ParameterSchema { + name: "command".to_string(), + description: "Command to execute".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "timeout".to_string(), + description: "Timeout in seconds (default: 120)".to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ParameterSchema { + name: "workdir".to_string(), + description: "Working directory for the command".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "description".to_string(), + description: "Human-readable description of what the command does".to_string(), + required: false, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["command"]) + } + + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { + let command_str = get_string_param(¶ms, "command") + .ok_or_else(|| ToolError::Validation("command is required".to_string()))?; + + let timeout_seconds = get_integer_param(¶ms, "timeout") + .map(|v| if v <= 0 { DEFAULT_TIMEOUT_SECONDS } else { v as u64 }) + .unwrap_or(DEFAULT_TIMEOUT_SECONDS); + + let workdir = get_string_param(¶ms, "path") + .or_else(|| get_string_param(¶ms, "workdir")); + + let description = get_string_param(¶ms, "description") + .unwrap_or_else(|| command_str.clone()); + + if let Some(reason) = Self::is_dangerous(&command_str) { + return Err(ToolError::Permission(reason)); + } + + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(&command_str); + + if let Some(dir) = workdir { + cmd.current_dir(dir); + } + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| ToolError::Execution(format!("Failed to spawn process: {}", e)))?; + + let stdout = child.stdout.take().expect("stdout should be piped"); + let stderr = child.stderr.take().expect("stderr should be piped"); + + let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + + let mut stdout_lines: Vec = Vec::new(); + let mut stderr_lines: Vec = Vec::new(); + + let timeout_duration = Duration::from_secs(timeout_seconds); + + let result = timeout(timeout_duration, async { + loop { + if ctx.is_aborted() { + let _ = child.kill().await; + return Err(ToolError::Execution("Command aborted".to_string())); + } + + tokio::select! { + line = stdout_reader.next_line() => { + match line { + Ok(Some(l)) => { + if stdout_lines.len() < MAX_OUTPUT_SIZE { + stdout_lines.push(l); + } + } + Ok(None) => {} + Err(e) => return Err(ToolError::Execution(format!("Error reading stdout: {}", e))), + } + } + line = stderr_reader.next_line() => { + match line { + Ok(Some(l)) => { + if stderr_lines.len() < MAX_OUTPUT_SIZE { + stderr_lines.push(l); + } + } + Ok(None) => {} + Err(e) => return Err(ToolError::Execution(format!("Error reading stderr: {}", e))), + } + } + status = child.wait() => { + return match status { + Ok(exit_status) => Ok(exit_status), + Err(e) => Err(ToolError::Execution(format!("Process error: {}", e))), + }; + } + } + } + }).await; + + let exit_status = match result { + Ok(Ok(status)) => status, + Ok(Err(e)) => return Err(e), + Err(_) => { + let _ = child.kill().await; + return Err(ToolError::Execution(format!( + "Command timed out after {} seconds", + timeout_seconds + ))); + } + }; + + let mut output_parts = Vec::new(); + + if !stdout_lines.is_empty() { + output_parts.push(stdout_lines.join("\n")); + } + + if !stderr_lines.is_empty() { + if !output_parts.is_empty() { + output_parts.push("\n--- stderr ---".to_string()); + } + output_parts.push(stderr_lines.join("\n")); + } + + let output = if output_parts.is_empty() { + "(no output)".to_string() + } else { + output_parts.join("\n") + }; + + let truncated = stdout_lines.len() >= MAX_OUTPUT_SIZE || stderr_lines.len() >= MAX_OUTPUT_SIZE; + let final_output = if truncated { + format!("{}\n\n[Output truncated to {} bytes]", output, MAX_OUTPUT_SIZE) + } else { + output + }; + + let exit_code = exit_status.code().unwrap_or(-1); + + Ok(ToolResult::new( + format!("Bash: {}", description), + final_output + ) + .with_metadata("exit_code", serde_json::json!(exit_code)) + .with_metadata("command", serde_json::json!(command_str))) + } +} diff --git a/src/tools/context.rs b/src/tools/context.rs new file mode 100644 index 0000000..20b14de --- /dev/null +++ b/src/tools/context.rs @@ -0,0 +1,40 @@ +pub struct ToolContext { + pub session_id: String, + pub message_id: String, + pub agent: String, + pub abort: tokio::sync::watch::Receiver, + pub call_id: Option, + pub extra: Option, +} + +impl ToolContext { + pub fn new( + session_id: impl Into, + message_id: impl Into, + agent: impl Into, + abort: tokio::sync::watch::Receiver, + ) -> Self { + Self { + session_id: session_id.into(), + message_id: message_id.into(), + agent: agent.into(), + abort, + call_id: None, + extra: None, + } + } + + pub fn with_call_id(mut self, call_id: impl Into) -> Self { + self.call_id = Some(call_id.into()); + self + } + + pub fn with_extra(mut self, extra: serde_json::Value) -> Self { + self.extra = Some(extra); + self + } + + pub fn is_aborted(&self) -> bool { + *self.abort.borrow() + } +} diff --git a/src/tools/edit.rs b/src/tools/edit.rs new file mode 100644 index 0000000..8410fc0 --- /dev/null +++ b/src/tools/edit.rs @@ -0,0 +1,166 @@ +use crate::tools::{ + get_bool_param, get_string_param, validate_required, Tool, ToolContext, ToolError, + ToolHandler, ToolResult, ParameterSchema, ParameterType, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::Path; + +const SIMILARITY_THRESHOLD: f64 = 0.8; + +pub struct EditTool; + +impl EditTool { + pub fn new() -> Self { + Self + } + + fn levenshtein_similarity(a: &str, b: &str) -> f64 { + let distance = strsim::levenshtein(a, b); + let max_len = a.len().max(b.len()); + if max_len == 0 { + return 1.0; + } + 1.0 - (distance as f64 / max_len as f64) + } + + fn find_best_match<'a>(content: &str, old_string: &str) -> Option<(usize, usize)> { + if let Some(pos) = content.find(old_string) { + return Some((pos, pos + old_string.len())); + } + + let old_trimmed = old_string.trim(); + if let Some(pos) = content.find(old_trimmed) { + return Some((pos, pos + old_trimmed.len())); + } + + let lines: Vec<&str> = content.lines().collect(); + let old_lines: Vec<&str> = old_string.lines().collect(); + + if old_lines.len() > 1 { + for i in 0..lines.len() { + if i + old_lines.len() <= lines.len() { + let candidate: String = lines[i..i + old_lines.len()].join("\n"); + let similarity = Self::levenshtein_similarity(&candidate, old_string); + + if similarity >= SIMILARITY_THRESHOLD { + let start = lines[..i].join("\n").len(); + let start = if i > 0 { start + 1 } else { start }; + return Some((start, start + candidate.len())); + } + } + } + } + + None + } +} + +#[async_trait] +impl ToolHandler for EditTool { + fn definition(&self) -> Tool { + Tool { + id: "edit".to_string(), + description: "Replace text in files with smart matching. Supports exact match, fuzzy match, and line-trimmed match.".to_string(), + parameters: vec![ + ParameterSchema { + name: "file_path".to_string(), + description: "Path to the file to edit".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "old_string".to_string(), + description: "Text to replace".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "new_string".to_string(), + description: "Replacement text".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "replace_all".to_string(), + description: "Replace all occurrences (default: false)".to_string(), + required: false, + param_type: ParameterType::Boolean, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["file_path", "old_string", "new_string"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let file_path = get_string_param(¶ms, "file_path") + .ok_or_else(|| ToolError::Validation("file_path is required".to_string()))?; + + let old_string = get_string_param(¶ms, "old_string") + .ok_or_else(|| ToolError::Validation("old_string is required".to_string()))?; + + let new_string = get_string_param(¶ms, "new_string") + .ok_or_else(|| ToolError::Validation("new_string is required".to_string()))?; + + let replace_all = get_bool_param(¶ms, "replace_all", false); + + let path = Path::new(&file_path); + + if !path.exists() { + return Err(ToolError::NotFound(format!("File not found: {}", file_path))); + } + + if !path.is_file() { + return Err(ToolError::Validation(format!("Path is not a file: {}", file_path))); + } + + let content = std::fs::read_to_string(path) + .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?; + + if replace_all { + if !content.contains(&old_string) { + return Err(ToolError::NotFound(format!( + "Text not found in file: {}", + old_string.chars().take(50).collect::() + ))); + } + + let new_content = content.replace(&old_string, &new_string); + let count = content.matches(&old_string).count(); + + std::fs::write(path, new_content) + .map_err(|e| ToolError::Execution(format!("Failed to write file: {}", e)))?; + + return Ok(ToolResult::new( + format!("Edit: {}", file_path), + format!("Replaced {} occurrence(s)", count) + )); + } + + match Self::find_best_match(&content, &old_string) { + Some((start, end)) => { + let mut new_content = String::with_capacity(content.len() - (end - start) + new_string.len()); + new_content.push_str(&content[..start]); + new_content.push_str(&new_string); + new_content.push_str(&content[end..]); + + std::fs::write(path, new_content) + .map_err(|e| ToolError::Execution(format!("Failed to write file: {}", e)))?; + + let line_num = content[..start].chars().filter(|c| *c == '\n').count() + 1; + + Ok(ToolResult::new( + format!("Edit: {}", file_path), + format!("Replaced at line {}", line_num) + )) + } + None => Err(ToolError::NotFound(format!( + "Could not find text to replace: {}", + old_string.chars().take(50).collect::() + ))), + } + } +} diff --git a/src/tools/fs/glob.rs b/src/tools/fs/glob.rs new file mode 100644 index 0000000..6e1aaef --- /dev/null +++ b/src/tools/fs/glob.rs @@ -0,0 +1,109 @@ +use crate::tools::{ + get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, + ParameterSchema, ParameterType, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::Path; + +pub struct GlobTool; + +impl GlobTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ToolHandler for GlobTool { + fn definition(&self) -> Tool { + Tool { + id: "glob".to_string(), + description: "Find files by glob pattern. Returns file paths sorted by modification time.".to_string(), + parameters: vec![ + ParameterSchema { + name: "pattern".to_string(), + description: "Glob pattern to match files (e.g., '**/*.rs', '*.md')".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "path".to_string(), + description: "Base directory to search from (default: current working directory)".to_string(), + required: false, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["pattern"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let pattern = get_string_param(¶ms, "pattern") + .ok_or_else(|| ToolError::Validation("pattern is required".to_string()))?; + + let base_path = get_string_param(¶ms, "path") + .unwrap_or_else(|| ".".to_string()); + + let pattern_path = Path::new(&base_path).join(&pattern); + let pattern_str = pattern_path + .to_str() + .ok_or_else(|| ToolError::Execution("Invalid path encoding".to_string()))?; + + let mut entries: Vec<(glob::Paths, String)> = Vec::new(); + + match glob::glob(pattern_str) { + Ok(paths) => { + let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new(); + + for entry in paths { + match entry { + Ok(path) => { + if let Ok(metadata) = std::fs::metadata(&path) { + if let Ok(modified) = metadata.modified() { + files.push((path, modified)); + } else { + files.push((path, std::time::SystemTime::UNIX_EPOCH)); + } + } + } + Err(e) => { + return Err(ToolError::Execution(format!("Glob error: {}", e))); + } + } + } + + files.sort_by(|a, b| b.1.cmp(&a.1)); + + let limit = 100; + let total = files.len(); + let truncated = total > limit; + + let output: Vec = files + .into_iter() + .take(limit) + .map(|(path, _)| path.display().to_string()) + .collect(); + + let result_text = if output.is_empty() { + "No files found matching pattern.".to_string() + } else { + let mut text = output.join("\n"); + if truncated { + text.push_str(&format!("\n\n... and {} more files (showing first {})", total - limit, limit)); + } + text + }; + + Ok(ToolResult::new( + format!("Glob: {}", pattern), + result_text + )) + } + Err(e) => Err(ToolError::Execution(format!("Invalid glob pattern: {}", e))), + } + } +} diff --git a/src/tools/fs/list.rs b/src/tools/fs/list.rs new file mode 100644 index 0000000..bbd65a1 --- /dev/null +++ b/src/tools/fs/list.rs @@ -0,0 +1,194 @@ +use crate::tools::{ + get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, + ParameterSchema, ParameterType, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::Path; + +pub struct ListTool; + +impl ListTool { + pub fn new() -> Self { + Self + } + + fn list_directory( + path: &Path, + ignore_patterns: &[String], + prefix: &str, + is_last: bool, + output: &mut Vec, + depth: usize, + ) -> Result<(), ToolError> { + const MAX_DEPTH: usize = 10; + + if depth > MAX_DEPTH { + return Ok(()); + } + + let connector = if is_last { "└── " } else { "├── " }; + + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + output.push(format!("{}{}{}", prefix, connector, name)); + } + + if !path.is_dir() { + return Ok(()); + } + + let entries: Vec<_> = std::fs::read_dir(path) + .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))? + .filter_map(|e| e.ok()) + .collect(); + + let mut filtered: Vec<_> = entries + .into_iter() + .filter(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + !name.starts_with('.') && !ignore_patterns.iter().any(|p| name.contains(p)) + }) + .collect(); + + filtered.sort_by(|a, b| { + let a_is_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false); + let b_is_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false); + + match (a_is_dir, b_is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.file_name().cmp(&b.file_name()), + } + }); + + let new_prefix = if is_last { + format!("{} ", prefix) + } else { + format!("{}│ ", prefix) + }; + + let count = filtered.len(); + for (i, entry) in filtered.iter().enumerate() { + let is_last_entry = i == count - 1; + Self::list_directory( + &entry.path(), + ignore_patterns, + &new_prefix, + is_last_entry, + output, + depth + 1, + )?; + } + + Ok(()) + } +} + +#[async_trait] +impl ToolHandler for ListTool { + fn definition(&self) -> Tool { + Tool { + id: "list".to_string(), + description: "List directory contents in a tree format. Shows files and subdirectories with visual tree connectors.".to_string(), + parameters: vec![ + ParameterSchema { + name: "path".to_string(), + description: "Directory path to list".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "ignore".to_string(), + description: "Patterns to ignore (e.g., ['node_modules', 'target'])".to_string(), + required: false, + param_type: ParameterType::Array(Box::new(ParameterType::String)), + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["path"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let path_str = get_string_param(¶ms, "path") + .ok_or_else(|| ToolError::Validation("path is required".to_string()))?; + + let ignore_patterns: Vec = params + .get("ignore") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let path = Path::new(&path_str); + + if !path.exists() { + return Err(ToolError::NotFound(format!("Directory not found: {}", path_str))); + } + + if !path.is_dir() { + return Err(ToolError::Validation(format!("Path is not a directory: {}", path_str))); + } + + let mut output = Vec::new(); + + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + output.push(name.to_string()); + } else { + output.push(path_str.clone()); + } + + let entries: Vec<_> = std::fs::read_dir(path) + .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))? + .filter_map(|e| e.ok()) + .collect(); + + let mut filtered: Vec<_> = entries + .into_iter() + .filter(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + !name.starts_with('.') && !ignore_patterns.iter().any(|p| name.contains(p)) + }) + .collect(); + + filtered.sort_by(|a, b| { + let a_is_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false); + let b_is_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false); + + match (a_is_dir, b_is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.file_name().cmp(&b.file_name()), + } + }); + + let count = filtered.len(); + for (i, entry) in filtered.iter().enumerate() { + let is_last = i == count - 1; + Self::list_directory( + &entry.path(), + &ignore_patterns, + "", + is_last, + &mut output, + 1, + )?; + } + + let result_text = if output.len() <= 1 { + format!("{}\n(empty directory)", output.join("\n")) + } else { + output.join("\n") + }; + + Ok(ToolResult::new( + format!("List: {}", path_str), + result_text + )) + } +} diff --git a/src/tools/fs/mod.rs b/src/tools/fs/mod.rs new file mode 100644 index 0000000..1f16de4 --- /dev/null +++ b/src/tools/fs/mod.rs @@ -0,0 +1,9 @@ +pub mod glob; +pub mod list; +pub mod read; +pub mod write; + +pub use glob::GlobTool; +pub use list::ListTool; +pub use read::ReadTool; +pub use write::WriteTool; diff --git a/src/tools/fs/read.rs b/src/tools/fs/read.rs new file mode 100644 index 0000000..b79c496 --- /dev/null +++ b/src/tools/fs/read.rs @@ -0,0 +1,135 @@ +use crate::tools::{ + get_integer_param, get_string_param, validate_required, Tool, ToolContext, ToolError, + ToolHandler, ToolResult, ParameterSchema, ParameterType, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::Path; + +const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50MB +const BINARY_CHECK_SIZE: usize = 8192; // 8KB +const DEFAULT_LIMIT: usize = 2000; + +pub struct ReadTool; + +impl ReadTool { + pub fn new() -> Self { + Self + } + + fn is_binary(data: &[u8]) -> bool { + data.iter().take(BINARY_CHECK_SIZE).any(|b| *b == 0) + } +} + +#[async_trait] +impl ToolHandler for ReadTool { + fn definition(&self) -> Tool { + Tool { + id: "read".to_string(), + description: "Read file contents with line numbers and pagination. Detects binary files automatically.".to_string(), + parameters: vec![ + ParameterSchema { + name: "file_path".to_string(), + description: "Path to the file to read".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "offset".to_string(), + description: "Line offset to start from (0-based, default: 0)".to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ParameterSchema { + name: "limit".to_string(), + description: "Maximum number of lines to read (default: 2000)".to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["file_path"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let file_path = get_string_param(¶ms, "file_path") + .ok_or_else(|| ToolError::Validation("file_path is required".to_string()))?; + + let offset = get_integer_param(¶ms, "offset") + .map(|v| v.max(0) as usize) + .unwrap_or(0); + + let limit = get_integer_param(¶ms, "limit") + .map(|v| if v <= 0 { DEFAULT_LIMIT } else { v as usize }) + .unwrap_or(DEFAULT_LIMIT); + + let path = Path::new(&file_path); + + if !path.exists() { + return Err(ToolError::NotFound(format!("File not found: {}", file_path))); + } + + if !path.is_file() { + return Err(ToolError::Validation(format!("Path is not a file: {}", file_path))); + } + + let metadata = std::fs::metadata(path) + .map_err(|e| ToolError::Execution(format!("Failed to read file metadata: {}", e)))?; + + let file_size = metadata.len(); + + if file_size > MAX_FILE_SIZE { + return Err(ToolError::Execution(format!( + "File is too large ({}MB > {}MB limit)", + file_size / (1024 * 1024), + MAX_FILE_SIZE / (1024 * 1024) + ))); + } + + let content = std::fs::read(path) + .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?; + + if Self::is_binary(&content) { + return Ok(ToolResult::new( + format!("Read: {}", file_path), + "[Binary file - contents not displayed]".to_string() + )); + } + + let text = String::from_utf8_lossy(&content); + let lines: Vec<&str> = text.lines().collect(); + let total_lines = lines.len(); + + if offset >= total_lines { + return Ok(ToolResult::new( + format!("Read: {}", file_path), + format!("[File has {} lines, offset {} is beyond end]", total_lines, offset) + )); + } + + let end = (offset + limit).min(total_lines); + let selected_lines = &lines[offset..end]; + + let numbered_lines: Vec = selected_lines + .iter() + .enumerate() + .map(|(idx, line)| format!("{:05}| {}", offset + idx + 1, line)) + .collect(); + + let mut output = numbered_lines.join("\n"); + + if end < total_lines { + output.push_str(&format!("\n\n... {} more lines (showing {}-{} of {})", + total_lines - end, offset + 1, end, total_lines)); + } + + Ok(ToolResult::new( + format!("Read: {}", file_path), + output + )) + } +} diff --git a/src/tools/fs/write.rs b/src/tools/fs/write.rs new file mode 100644 index 0000000..e3d312d --- /dev/null +++ b/src/tools/fs/write.rs @@ -0,0 +1,96 @@ +use crate::tools::{ + get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, + ParameterSchema, ParameterType, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::Path; + +const BLOCKED_FILES: [&str; 3] = [".env", ".env.local", ".env.production"]; + +pub struct WriteTool; + +impl WriteTool { + pub fn new() -> Self { + Self + } + + fn is_blocked(path: &Path) -> bool { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + BLOCKED_FILES.contains(&file_name) + } else { + false + } + } +} + +#[async_trait] +impl ToolHandler for WriteTool { + fn definition(&self) -> Tool { + Tool { + id: "write".to_string(), + description: "Create or overwrite a file. Creates parent directories if needed.".to_string(), + parameters: vec![ + ParameterSchema { + name: "file_path".to_string(), + description: "Path to the file to write".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "content".to_string(), + description: "Content to write to the file".to_string(), + required: true, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["file_path", "content"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let file_path = get_string_param(¶ms, "file_path") + .ok_or_else(|| ToolError::Validation("file_path is required".to_string()))?; + + let content = get_string_param(¶ms, "content") + .ok_or_else(|| ToolError::Validation("content is required".to_string()))?; + + let path = Path::new(&file_path); + + if Self::is_blocked(path) { + return Err(ToolError::Permission(format!( + "Writing to {} is blocked for security reasons", + file_path + ))); + } + + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent) + .map_err(|e| ToolError::Execution(format!("Failed to create directories: {}", e)))?; + } + } + + let temp_path = path.with_extension("tmp"); + + std::fs::write(&temp_path, content) + .map_err(|e| ToolError::Execution(format!("Failed to write temp file: {}", e)))?; + + std::fs::rename(&temp_path, path) + .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; + + let is_new = !path.exists(); + + Ok(ToolResult::new( + format!("Write: {}", file_path), + if is_new { + format!("Created file with {} bytes", std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) + } else { + format!("Updated file with {} bytes", std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) + } + )) + } +} diff --git a/src/tools/init.rs b/src/tools/init.rs new file mode 100644 index 0000000..997f652 --- /dev/null +++ b/src/tools/init.rs @@ -0,0 +1,18 @@ +use crate::tools::{ + fs::{GlobTool, ListTool, ReadTool, WriteTool}, + BashTool, EditTool, ToolRegistry, +}; +use std::sync::Arc; + +pub async fn initialize_tool_registry() -> ToolRegistry { + let registry = ToolRegistry::new(); + + registry.register(Arc::new(GlobTool::new())).await; + registry.register(Arc::new(ListTool::new())).await; + registry.register(Arc::new(ReadTool::new())).await; + registry.register(Arc::new(WriteTool::new())).await; + registry.register(Arc::new(BashTool::new())).await; + registry.register(Arc::new(EditTool::new())).await; + + registry +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..66e2560 --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,59 @@ +use async_trait::async_trait; +use serde_json::Value; + +pub mod bash; +pub mod context; +pub mod edit; +pub mod fs; +pub mod init; +pub mod registry; +pub mod types; + +pub use bash::BashTool; +pub use context::ToolContext; +pub use edit::EditTool; +pub use init::initialize_tool_registry; +pub use registry::ToolRegistry; +pub use types::{ParameterSchema, ParameterType, Tool, ToolError, ToolId, ToolResult}; + +#[async_trait] +pub trait ToolHandler: Send + Sync { + fn definition(&self) -> Tool; + fn validate(&self, params: &Value) -> Result<(), ToolError>; + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result; +} + +pub fn validate_required(params: &Value, required: &[&str]) -> Result<(), ToolError> { + let obj = params + .as_object() + .ok_or_else(|| ToolError::Validation("Parameters must be an object".to_string()))?; + + for field in required { + if !obj.contains_key(*field) { + return Err(ToolError::Validation(format!( + "Missing required parameter: {}", + field + ))); + } + } + + Ok(()) +} + +pub fn get_string_param(params: &Value, name: &str) -> Option { + params + .get(name) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +pub fn get_integer_param(params: &Value, name: &str) -> Option { + params.get(name).and_then(|v| v.as_i64()) +} + +pub fn get_bool_param(params: &Value, name: &str, default: bool) -> bool { + params + .get(name) + .and_then(|v| v.as_bool()) + .unwrap_or(default) +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs new file mode 100644 index 0000000..bf61ea1 --- /dev/null +++ b/src/tools/registry.rs @@ -0,0 +1,50 @@ +use crate::tools::types::{Tool, ToolId}; +use crate::tools::ToolHandler; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub struct ToolRegistry { + tools: Arc>>>, +} + +impl ToolRegistry { + pub fn new() -> Self { + Self { + tools: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn register(&self, tool: Arc) { + let definition = tool.definition(); + let mut tools = self.tools.write().await; + tools.insert(definition.id.clone(), tool); + } + + pub async fn get(&self, id: &str) -> Option> { + let tools = self.tools.read().await; + tools.get(id).cloned() + } + + pub async fn list(&self) -> Vec { + let tools = self.tools.read().await; + tools + .values() + .map(|t| t.definition()) + .collect() + } + + pub async fn list_schemas(&self) -> Vec { + let tools = self.tools.read().await; + tools + .values() + .map(|t| t.definition().to_openai_schema()) + .collect() + } +} + +impl Default for ToolRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/src/tools/types.rs b/src/tools/types.rs new file mode 100644 index 0000000..e30815e --- /dev/null +++ b/src/tools/types.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub type ToolId = String; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParameterSchema { + pub name: String, + pub description: String, + pub required: bool, + pub param_type: ParameterType, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParameterType { + String, + Integer, + Boolean, + Array(Box), + Object(HashMap), +} + +#[derive(Debug, Clone)] +pub struct Tool { + pub id: ToolId, + pub description: String, + pub parameters: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolResult { + pub title: String, + pub output: String, + pub metadata: HashMap, +} + +#[derive(Debug, thiserror::Error)] +pub enum ToolError { + #[error("Validation error: {0}")] + Validation(String), + #[error("Execution error: {0}")] + Execution(String), + #[error("Permission denied: {0}")] + Permission(String), + #[error("Not found: {0}")] + NotFound(String), +} + +impl Tool { + pub fn to_openai_schema(&self) -> serde_json::Value { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for param in &self.parameters { + properties.insert(param.name.clone(), param.param_type.to_json_schema()); + if param.required { + required.push(param.name.clone()); + } + } + + serde_json::json!({ + "type": "function", + "function": { + "name": self.id, + "description": self.description, + "parameters": { + "type": "object", + "properties": properties, + "required": required + } + } + }) + } +} + +impl ParameterType { + fn to_json_schema(&self) -> serde_json::Value { + match self { + ParameterType::String => serde_json::json!({"type": "string"}), + ParameterType::Integer => serde_json::json!({"type": "integer"}), + ParameterType::Boolean => serde_json::json!({"type": "boolean"}), + ParameterType::Array(inner) => { + serde_json::json!({ + "type": "array", + "items": inner.to_json_schema() + }) + } + ParameterType::Object(props) => { + let mut properties = serde_json::Map::new(); + for (key, val) in props { + properties.insert(key.clone(), val.to_json_schema()); + } + serde_json::json!({ + "type": "object", + "properties": properties + }) + } + } + } +} + +impl ToolResult { + pub fn new(title: impl Into, output: impl Into) -> Self { + Self { + title: title.into(), + output: output.into(), + metadata: HashMap::new(), + } + } + + pub fn with_metadata(mut self, key: impl Into, value: serde_json::Value) -> Self { + self.metadata.insert(key.into(), value); + self + } +} From aa55bbcfb1beb0e244af5efae5ec3e1c1a6052ab Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 30 Jan 2026 20:16:27 +0800 Subject: [PATCH 2/9] feat: working initial tool calls. --- Cargo.lock | 4 +- Cargo.toml | 9 +- src/agent/manager.rs | 196 ++++++++++++++++++++++- src/app.rs | 186 +++++++++++++++++++--- src/llm/client.rs | 65 +++++--- src/llm/mod.rs | 4 + src/llm/tool_calls.rs | 109 +++++++++++++ src/main.rs | 1 + src/prompt/mod.rs | 281 ++++++++++++++++++++++++++++++++ src/tools/aisdk_bridge.rs | 218 +++++++++++++++++++++++++ src/tools/fs/glob.rs | 9 +- src/tools/mod.rs | 1 + src/tools/registry.rs | 1 + src/ui/components/chat.rs | 327 ++++++++++++++++++++++++++++++-------- src/utils/git.rs | 10 ++ 15 files changed, 1304 insertions(+), 117 deletions(-) create mode 100644 src/llm/tool_calls.rs create mode 100644 src/prompt/mod.rs create mode 100644 src/tools/aisdk_bridge.rs diff --git a/Cargo.lock b/Cargo.lock index 2c49229..3580243 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,7 +48,6 @@ dependencies = [ [[package]] name = "aisdk" version = "0.4.0" -source = "git+https://github.com/Blankeos/aisdk-rs?branch=oai-compat-fix#5866c1a21e8a96581d0e5ec6a23a062f2d4dbcac" dependencies = [ "aisdk-macros", "async-trait", @@ -69,7 +68,6 @@ dependencies = [ [[package]] name = "aisdk-macros" version = "0.3.0" -source = "git+https://github.com/Blankeos/aisdk-rs?branch=oai-compat-fix#5866c1a21e8a96581d0e5ec6a23a062f2d4dbcac" dependencies = [ "proc-macro2", "quote", @@ -811,8 +809,10 @@ dependencies = [ "ratatui", "ratatui-core", "ratatui-toolkit", + "regex", "reqwest", "rusqlite", + "schemars", "serde", "serde_json", "strsim", diff --git a/Cargo.toml b/Cargo.toml index ead7fe0..6fa67ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ tokio = { version = "1.40", features = ["full"] } reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +schemars = "1.0" anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } ignore = "0.4" @@ -30,11 +31,12 @@ nucleo-matcher = "0.3" rusqlite = { version = "0.31", features = ["bundled"] } cuid2 = "0.1" chrono = { version = "0.4", features = ["serde"] } -aisdk = { version = "0.4", features = ["openai", "openaichatcompletions"] } +aisdk = { version = "0.4", features = ["openai", "openaichatcompletions", "openaicompatible"] } tokio-util = "0.7" glob = "0.3" strsim = "0.11" thiserror = "1.0" +regex = "1.10" textwrap = "0.16" unicode-width = "0.1" tui-markdown = "0.3" @@ -44,4 +46,7 @@ ratatui-core = "0.1" tokio-test = "0.4" [patch.crates-io] -aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "oai-compat-fix" } +# Local +aisdk = { path = "/Users/carlo/Desktop/Projects/aisdk-rs" } +# After pushing +# aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "oai-compat-fix" } diff --git a/src/agent/manager.rs b/src/agent/manager.rs index 6707951..65dfb53 100644 --- a/src/agent/manager.rs +++ b/src/agent/manager.rs @@ -1,11 +1,201 @@ -pub struct AgentManager; +use crate::prompt::SystemPromptComposer; +use crate::session::types::{Message, MessageRole}; +use crate::tools::{ + initialize_tool_registry, ToolContext, ToolError, ToolHandler, ToolRegistry, ToolResult, +}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::sync::watch; + +pub struct Agent { + pub id: String, + pub name: String, + pub system_prompt: String, + pub tool_registry: ToolRegistry, +} + +pub struct AgentManager { + agent: Agent, + session_id: String, +} + +#[derive(Debug, Clone)] +pub enum AgentEvent { + ToolCallStarted { tool_id: String, call_id: String }, + ToolCallCompleted { tool_id: String, call_id: String, result: ToolResult }, + ToolCallFailed { tool_id: String, call_id: String, error: String }, + Message(String), +} + +impl AgentManager { + pub async fn new( + model_id: &str, + working_directory: impl Into, + is_git_repo: bool, + platform: impl Into, + ) -> anyhow::Result { + let tool_registry = initialize_tool_registry().await; + + let composer = SystemPromptComposer::new( + model_id, + working_directory, + is_git_repo, + platform, + ).with_tool_registry(tool_registry.clone()); + + let system_prompt = composer.compose().await; + + let agent = Agent { + id: cuid2::create_id(), + name: "default".to_string(), + system_prompt, + tool_registry, + }; + + Ok(Self { + agent, + session_id: cuid2::create_id(), + }) + } + + pub fn get_system_prompt(&self) -> &str { + &self.agent.system_prompt + } + + pub fn get_tool_registry(&self) -> &ToolRegistry { + &self.agent.tool_registry + } + + pub async fn execute_tool( + &self, + tool_id: &str, + params: serde_json::Value, + call_id: String, + abort_rx: watch::Receiver, + ) -> Result { + let tool = self + .agent + .tool_registry + .get(tool_id) + .await + .ok_or_else(|| ToolError::NotFound(format!("Tool not found: {}", tool_id)))?; + + tool.validate(¶ms)?; + + let ctx = ToolContext::new( + self.session_id.clone(), + call_id.clone(), + self.agent.name.clone(), + abort_rx, + ) + .with_call_id(call_id); + + tool.execute(params, &ctx).await + } + + pub fn create_system_message(&self, + ) -> Message { + Message::system(self.agent.system_prompt.clone()) + } + + pub async fn process_tool_calls( + &self, + tool_calls: Vec, + event_tx: mpsc::UnboundedSender, + ) -> Vec { + let mut results = Vec::new(); + let (abort_tx, abort_rx) = watch::channel(false); + + for call in tool_calls { + let _ = event_tx.send(AgentEvent::ToolCallStarted { + tool_id: call.tool_id.clone(), + call_id: call.call_id.clone(), + }); + + match self + .execute_tool(&call.tool_id, + call.params.clone(), + call.call_id.clone(), + abort_rx.clone(), + ) + .await + { + Ok(result) => { + let _ = event_tx.send(AgentEvent::ToolCallCompleted { + tool_id: call.tool_id.clone(), + call_id: call.call_id.clone(), + result: result.clone(), + }); + results.push(ToolCallResult { + call_id: call.call_id, + tool_id: call.tool_id, + success: true, + output: result.output, + metadata: result.metadata, + }); + } + Err(e) => { + let _ = event_tx.send(AgentEvent::ToolCallFailed { + tool_id: call.tool_id.clone(), + call_id: call.call_id.clone(), + error: e.to_string(), + }); + results.push(ToolCallResult { + call_id: call.call_id, + tool_id: call.tool_id, + success: false, + output: e.to_string(), + metadata: std::collections::HashMap::new(), + }); + } + } + } + + drop(abort_tx); + results + } +} + +#[derive(Debug, Clone)] +pub struct ToolCall { + pub call_id: String, + pub tool_id: String, + pub params: serde_json::Value, +} + +#[derive(Debug, Clone)] +pub struct ToolCallResult { + pub call_id: String, + pub tool_id: String, + pub success: bool, + pub output: String, + pub metadata: std::collections::HashMap, +} #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn test_agent_manager_creation() { + let manager = AgentManager::new( + "gpt-4", + "/tmp", + false, + "darwin", + ).await; + + assert!(manager.is_ok()); + } + #[test] - fn test_manager() { - let _manager = AgentManager; + fn test_tool_call_struct() { + let call = ToolCall { + call_id: "test-123".to_string(), + tool_id: "read".to_string(), + params: serde_json::json!({"file_path": "/tmp/test.txt"}), + }; + + assert_eq!(call.tool_id, "read"); } } diff --git a/src/app.rs b/src/app.rs index 271c13b..d93e92a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -99,6 +99,9 @@ pub struct App { streaming_model: Option, streaming_provider: Option, last_animation_update: std::time::Instant, + streaming_chat_len_before_assistant: usize, + tool_call_message_indices: std::collections::HashMap, + tool_call_order: Vec, } impl App { @@ -192,6 +195,9 @@ impl App { streaming_model: None, streaming_provider: None, last_animation_update: std::time::Instant::now(), + streaming_chat_len_before_assistant: 0, + tool_call_message_indices: std::collections::HashMap::new(), + tool_call_order: Vec::new(), } } @@ -1253,15 +1259,23 @@ impl App { // Finalize streaming metrics from the chat's tracked values self.chat_state.chat.finalize_streaming_metrics(); - if let Some(last_msg) = self.chat_state.chat.messages.last_mut() { - last_msg.mark_complete(); - // Set model and provider metadata before persisting - // Use the captured values from when streaming started - last_msg.model = self.streaming_model.clone(); - last_msg.provider = self.streaming_provider.clone(); - let _ = self - .session_manager - .add_message_to_current_session(last_msg); + // Persist all new assistant/tool messages for this streaming turn. + let start = self.streaming_chat_len_before_assistant; + for msg in self.chat_state.chat.messages.iter_mut().skip(start) { + match msg.role { + crate::session::types::MessageRole::Assistant => { + if !msg.is_complete { + msg.mark_complete(); + } + msg.model = self.streaming_model.clone(); + msg.provider = self.streaming_provider.clone(); + let _ = self.session_manager.add_message_to_current_session(msg); + } + crate::session::types::MessageRole::Tool => { + let _ = self.session_manager.add_message_to_current_session(msg); + } + _ => {} + } } self.is_streaming = false; self.streaming_model = None; @@ -1276,11 +1290,10 @@ impl App { ratatui_toolkit::ToastLevel::Error, None, )); - if self.chat_state.chat.messages.last().is_some_and(|m| { - m.role == crate::session::types::MessageRole::Assistant && !m.is_complete - }) { - self.chat_state.chat.messages.pop(); - } + self.chat_state + .chat + .messages + .truncate(self.streaming_chat_len_before_assistant); self.cleanup_streaming(); } crate::llm::ChunkMessage::Cancelled => { @@ -1291,17 +1304,117 @@ impl App { ratatui_toolkit::ToastLevel::Info, None, )); - if self.chat_state.chat.messages.last().is_some_and(|m| { - m.role == crate::session::types::MessageRole::Assistant && !m.is_complete - }) { - self.chat_state.chat.messages.pop(); - } + self.chat_state + .chat + .messages + .truncate(self.streaming_chat_len_before_assistant); self.cleanup_streaming(); } crate::llm::ChunkMessage::Metrics { .. } => { // Metrics are now calculated locally from streaming data // This arm is kept for backward compatibility but ignored } + crate::llm::ChunkMessage::ToolCalls(tool_calls) => { + // Seal the current assistant segment so subsequent model text can appear + // after tool rows (interleaved timeline). + if let Some(idx) = self + .chat_state + .chat + .messages + .iter() + .rposition(|m| m.role == crate::session::types::MessageRole::Assistant) + { + if let Some(msg) = self.chat_state.chat.messages.get_mut(idx) { + if !msg.is_complete { + msg.mark_complete(); + } + } + } + + for call in tool_calls { + let args_value: serde_json::Value = serde_json::from_str(&call.function.arguments) + .unwrap_or_else(|_| serde_json::Value::String(call.function.arguments.clone())); + + let content = serde_json::json!({ + "id": call.id, + "name": call.function.name, + "status": "running", + "args": args_value, + }) + .to_string(); + + self.chat_state + .chat + .add_message(crate::session::types::Message::tool(content)); + + let idx = self.chat_state.chat.messages.len().saturating_sub(1); + self.tool_call_message_indices.insert(call.id.clone(), idx); + self.tool_call_order.push(call.id); + } + } + crate::llm::ChunkMessage::ToolResult(result) => { + if let Some(idx) = self.tool_call_message_indices.get(&result.tool_call_id).copied() { + if let Some(msg) = self.chat_state.chat.messages.get_mut(idx) { + let mut v: serde_json::Value = serde_json::from_str(&msg.content) + .unwrap_or_else(|_| serde_json::json!({})); + v["id"] = serde_json::Value::String(result.tool_call_id.clone()); + v["name"] = serde_json::Value::String(result.name.clone()); + + // Merge structured payloads from the AISDK bridge if present. + if let Ok(payload) = serde_json::from_str::(&result.content) { + if payload.is_object() { + if v.get("status").is_none() { + v["status"] = payload + .get("status") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("ok".to_string())); + } else { + v["status"] = payload + .get("status") + .cloned() + .unwrap_or_else(|| v["status"].clone()); + } + if let Some(title) = payload.get("title") { + v["title"] = title.clone(); + } + if let Some(meta) = payload.get("metadata") { + v["metadata"] = meta.clone(); + } + if let Some(line_count) = payload.get("line_count") { + v["line_count"] = line_count.clone(); + } + if let Some(out) = payload.get("output_preview") { + v["output_preview"] = out.clone(); + } + } else { + v["status"] = serde_json::Value::String("ok".to_string()); + v["output_preview"] = serde_json::Value::String(result.content.clone()); + } + } else { + let status = if result.content.trim_start().starts_with("Error:") { + "error" + } else { + "ok" + }; + v["status"] = serde_json::Value::String(status.to_string()); + v["output_preview"] = serde_json::Value::String(result.content.clone()); + } + + msg.content = v.to_string(); + } + } else { + let content = serde_json::json!({ + "id": result.tool_call_id, + "name": result.name, + "status": "ok", + "output_preview": result.content, + }) + .to_string(); + self.chat_state + .chat + .add_message(crate::session::types::Message::tool(content)); + } + } } } } @@ -1322,6 +1435,12 @@ impl App { self.is_streaming = true; + // Track the message boundary for this streaming turn so we can cleanly + // roll back assistant/tool messages on failure or cancellation. + self.streaming_chat_len_before_assistant = self.chat_state.chat.messages.len(); + self.tool_call_message_indices.clear(); + self.tool_call_order.clear(); + // Capture the current model and provider at the start of streaming // so they don't change if the user switches models during streaming self.streaming_model = Some(self.model.clone()); @@ -1334,7 +1453,34 @@ impl App { let provider_name = self.provider_name.clone(); let model = self.model.clone(); - let messages = self.chat_state.chat.messages.clone(); + let cwd = self.cwd.clone(); + let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); + + // Build messages with system prompt + let mut messages = self.chat_state.chat.messages.clone(); + + // Check if we already have a system message + let has_system = messages.iter().any(|m| { + m.role == crate::session::types::MessageRole::System + }); + + if !has_system { + // Create system prompt with tools + let composer = crate::prompt::SystemPromptComposer::new( + &model, + &cwd, + is_git_repo, + std::env::consts::OS, + ); + + let system_prompt = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + composer.compose().await + }) + }); + let system_msg = crate::session::types::Message::system(system_prompt); + messages.insert(0, system_msg); + } tokio::spawn(async move { let result = tokio::time::timeout( diff --git a/src/llm/client.rs b/src/llm/client.rs index 48aa336..e9f5c3e 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1,11 +1,15 @@ use aisdk::{ - core::{LanguageModelRequest, LanguageModelStreamChunkType, Message as AisdkMessage}, + core::{ + utils::step_count_is, LanguageModelRequest, LanguageModelStreamChunkType, + Message as AisdkMessage, + }, providers::{OpenAI, OpenAICompatible}, }; use futures::StreamExt; use tokio_util::sync::CancellationToken; use crate::logging::log; +use crate::tools::aisdk_bridge::convert_to_aisdk_tools; pub struct LLMClient { base_url: String, @@ -44,6 +48,9 @@ impl LLMClient { ) -> Result<(), Box> { let aisdk_messages = self.convert_messages(messages); + let tool_registry = crate::tools::initialize_tool_registry().await; + let aisdk_tools = convert_to_aisdk_tools(&tool_registry, None).await; + let response = if self.uses_openai_compatible() { let provider = OpenAICompatible::::builder() .base_url(&self.base_url) @@ -53,12 +60,16 @@ impl LLMClient { .build() .map_err(|e| Box::new(e) as Box)?; - LanguageModelRequest::builder() + let mut builder = LanguageModelRequest::builder() .model(provider) .messages(aisdk_messages) - .build() - .stream_text() - .await? + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } + + builder.build().stream_text().await? } else { let provider = OpenAI::::builder() .base_url(&self.base_url) @@ -68,12 +79,16 @@ impl LLMClient { .build() .map_err(|e| Box::new(e) as Box)?; - LanguageModelRequest::builder() + let mut builder = LanguageModelRequest::builder() .model(provider) .messages(aisdk_messages) - .build() - .stream_text() - .await? + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } + + builder.build().stream_text().await? }; let mut stream = response.stream; @@ -160,6 +175,9 @@ pub async fn stream_llm_with_cancellation( let aisdk_messages = convert_messages(&messages); + let tool_registry = crate::tools::initialize_tool_registry().await; + let aisdk_tools = convert_to_aisdk_tools(&tool_registry, Some(sender.clone())).await; + let response = if uses_openai_compatible { let provider_config = OpenAICompatible::::builder() .base_url(base_url) @@ -169,12 +187,16 @@ pub async fn stream_llm_with_cancellation( .build() .map_err(|e| Box::new(e) as Box)?; - LanguageModelRequest::builder() + let mut builder = LanguageModelRequest::builder() .model(provider_config) .messages(aisdk_messages) - .build() - .stream_text() - .await? + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } + + builder.build().stream_text().await? } else { let provider_config = OpenAI::::builder() .base_url(base_url) @@ -184,12 +206,16 @@ pub async fn stream_llm_with_cancellation( .build() .map_err(|e| Box::new(e) as Box)?; - LanguageModelRequest::builder() + let mut builder = LanguageModelRequest::builder() .model(provider_config) .messages(aisdk_messages) - .build() - .stream_text() - .await? + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } + + builder.build().stream_text().await? }; let mut stream = response.stream; @@ -213,7 +239,10 @@ pub async fn stream_llm_with_cancellation( token_count += reasoning.chars().count().max(1) / 4; let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } - LanguageModelStreamChunkType::ToolCall(_tool_call) => {} + LanguageModelStreamChunkType::ToolCall(_tool_call) => { + // Tool execution is handled internally by aisdk::stream_text(). + // We intentionally don't surface argument deltas here. + } LanguageModelStreamChunkType::End(_msg) => { let duration_ms = start_time.elapsed().as_millis() as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 4c76ba6..80b4276 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -1,13 +1,17 @@ pub mod client; pub mod provider; +pub mod tool_calls; pub use client::LLMClient; +pub use tool_calls::{FunctionCall, ToolCall, ToolCallResult}; use tokio::sync::mpsc; pub enum ChunkMessage { Text(String), Reasoning(String), + ToolCalls(Vec), + ToolResult(ToolCallResult), End, Failed(String), Cancelled, diff --git a/src/llm/tool_calls.rs b/src/llm/tool_calls.rs new file mode 100644 index 0000000..c5d9755 --- /dev/null +++ b/src/llm/tool_calls.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, + pub function: FunctionCall, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionCall { + pub name: String, + pub arguments: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallResult { + pub tool_call_id: String, + pub role: String, + pub name: String, + pub content: String, +} + +impl ToolCall { + pub fn parse_from_json(json_str: &str) -> Result, serde_json::Error> { + serde_json::from_str(json_str) + } + + pub fn parse_from_text(text: &str) -> Option> { + // Try JSON format first: [...] + if let Some(start) = text.find("") { + if let Some(end) = text.find("") { + let json_str = &text[start + 10..end]; + return Self::parse_from_json(json_str).ok(); + } + } + + // Try XML format: + if let Some(tool_calls) = Self::parse_xml_format(text) { + return Some(tool_calls); + } + + // Fallback: look for JSON array in brackets + if let Some(start) = text.find("[") { + if let Some(end) = text.rfind("]") { + let json_str = &text[start..=end]; + return Self::parse_from_json(json_str).ok(); + } + } + None + } + + fn parse_xml_format(text: &str) -> Option> { + let mut tool_calls = Vec::new(); + + // Match patterns like: or + let re = regex::Regex::new(r"<(\w+)\s+([^/>]+)(?:\s*/>|>.*?)").ok()?; + + for cap in re.captures_iter(text) { + let tool_name = cap.get(1)?.as_str(); + let attrs = cap.get(2)?.as_str(); + + // Parse attributes into JSON object + let mut params = serde_json::Map::new(); + let attr_re = regex::Regex::new(r#"(\w+)=["']([^"']+)["']"#).ok()?; + + for attr_cap in attr_re.captures_iter(attrs) { + let key = attr_cap.get(1)?.as_str(); + let value = attr_cap.get(2)?.as_str(); + params.insert( + key.to_string(), + serde_json::Value::String(value.to_string()), + ); + } + + if !params.is_empty() { + let args = serde_json::to_string(&serde_json::Value::Object(params)).ok()?; + tool_calls.push(ToolCall { + id: format!("call_{}", tool_calls.len() + 1), + call_type: "function".to_string(), + function: FunctionCall { + name: tool_name.to_string(), + arguments: args, + }, + }); + } + } + + if tool_calls.is_empty() { + None + } else { + Some(tool_calls) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_tool_calls() { + let json = r#"[{"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"file_path\":\"/tmp/test.txt\"}"}}]"#; + let calls = ToolCall::parse_from_json(json).unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].function.name, "read"); + } +} diff --git a/src/main.rs b/src/main.rs index 0142ea6..161eefb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod llm; mod logging; mod model; mod persistence; +mod prompt; mod session; mod streaming; mod theme; diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs new file mode 100644 index 0000000..212c616 --- /dev/null +++ b/src/prompt/mod.rs @@ -0,0 +1,281 @@ +use crate::tools::ToolRegistry; + +#[derive(Debug, Clone, PartialEq)] +pub enum ProviderType { + OpenAI, + Anthropic, + Gemini, + Codex, + Generic, +} + +impl ProviderType { + pub fn from_model_id(model_id: &str) -> Self { + let lower = model_id.to_lowercase(); + + if lower.contains("gpt-5") { + ProviderType::Codex + } else if lower.contains("gpt-") || lower.contains("o1") || lower.contains("o3") { + ProviderType::OpenAI + } else if lower.contains("gemini-") { + ProviderType::Gemini + } else if lower.contains("claude") { + ProviderType::Anthropic + } else { + ProviderType::Generic + } + } +} + +pub struct SystemPromptComposer { + provider_type: ProviderType, + working_directory: String, + is_git_repo: bool, + platform: String, + tool_registry: Option, +} + +impl SystemPromptComposer { + pub fn new( + model_id: &str, + working_directory: impl Into, + is_git_repo: bool, + platform: impl Into, + ) -> Self { + Self { + provider_type: ProviderType::from_model_id(model_id), + working_directory: working_directory.into(), + is_git_repo, + platform: platform.into(), + tool_registry: None, + } + } + + pub fn with_tool_registry(mut self, registry: ToolRegistry) -> Self { + self.tool_registry = Some(registry); + self + } + + pub async fn compose(&self, + ) -> String { + let mut parts = Vec::new(); + + parts.push(self.get_header()); + parts.push(self.get_core_prompt()); + parts.push(self.get_environment_context()); + + if let Some(ref registry) = self.tool_registry { + parts.push(self.get_tools_context(registry).await); + } + + parts.push(self.get_custom_instructions()); + + parts + .into_iter() + .filter(|p| !p.is_empty()) + .collect::>() + .join("\n\n---\n\n") + } + + fn get_header(&self) -> String { + match self.provider_type { + ProviderType::Anthropic => { + "You are Claude, an AI assistant made by Anthropic.".to_string() + } + _ => String::new(), + } + } + + fn get_core_prompt(&self) -> String { + match self.provider_type { + ProviderType::OpenAI => self.get_beast_prompt(), + ProviderType::Anthropic => self.get_anthropic_prompt(), + ProviderType::Gemini => self.get_gemini_prompt(), + ProviderType::Codex => self.get_codex_prompt(), + ProviderType::Generic => self.get_anthropic_prompt(), + } + } + + fn get_beast_prompt(&self) -> String { + r#"You are an expert software engineer. You MUST iterate and keep going until the problem is solved. + +Core Directives: +- Plan extensively before each function call +- Fetch URLs provided by user + discover recursive links +- Deeply understand problem via investigation +- Research dependencies on internet for accuracy +- Make incremental, testable changes +- Debug to root cause (not symptoms) +- Test frequently after each change +- Iterate until problem solved + tests pass +- Reflect and validate comprehensively + +Output Philosophy: +- Concise, casual yet professional tone +- Always communicate intent before tool calls +- Respond with direct answers + bullet points +- Avoid unnecessary explanations +- Use emoji for status tracking (✓, ☐, ✗) + +Communication Examples: +- "Let me fetch the URL you provided to gather more information." +- "Ok, I've got all the information I need." +- "Now, I will search the codebase for the relevant function." +- "Whelp - I see we have some problems. Let's fix those up." + +Security: +- Assist with defensive security tasks only +- Refuse to create code for malicious purposes +- Never auto-commit; requires explicit user request + +Your output will be displayed on a command line interface. Your responses should be short and concise (typically < 4 lines, excluding tool calls)."#.to_string() + } + + fn get_anthropic_prompt(&self) -> String { + r#"The user will primarily request software engineering tasks. + +Core Directives: +- Plan tasks with clear breakdown +- Mark todos completed immediately (don't batch) +- Minimize output tokens while maintaining quality +- Avoid preamble/postamble unless asked +- Batch independent tool calls in parallel +- Use dedicated tools over bash when possible +- Keep responses short (< 4 lines typically) +- Answer directly without elaboration +- No unnecessary explanations post-completion +- Provide only requested level of detail + +Security: +- Assist with defensive security tasks only +- Refuse to create code for malicious purposes +- No credential discovery/harvesting assistance + +When referencing specific functions or pieces of code, include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. + +Your output will be displayed on a command line interface. Your responses should be short and concise (typically < 4 lines, excluding tool calls)."#.to_string() + } + + fn get_gemini_prompt(&self) -> String { + r#"You are an expert software engineer. Rigorously adhere to existing project conventions. + +Core Directives: +- Understand via grep/glob (parallel searches) +- Build grounded plan based on context +- Implement adhering to conventions +- Verify with tests if applicable +- Execute linting/type-checking commands +- Validate against original request + +Output Philosophy: +- Adopt professional, direct, concise tone +- Fewer than 3 lines per response +- Focus strictly on user's query +- No conversational filler or preambles +- Format with GitHub-flavored Markdown + +Security: +- Explain bash commands that modify filesystem +- Never introduce code that exposes secrets +- Always use absolute paths +- Avoid interactive shell commands + +Your output will be displayed on a command line interface. Your responses should be short and concise (typically < 4 lines, excluding tool calls)."#.to_string() + } + + fn get_codex_prompt(&self) -> String { + r#"You are an expert software engineer with a concise, direct, friendly personality. + +Core Directives: +- Keep responses concise, direct, friendly +- Send brief preambles before tool calls (8-12 words) +- Break tasks into meaningful, logically ordered steps +- Don't repeat full plan after todowrite +- Fix root cause, not surface patches +- Keep changes minimal and focused +- Validate work via tests/build +- Only terminate when problem completely solved + +Output Philosophy: +- Group related actions in single preamble +- Build on prior context for momentum +- Keep tone light, friendly, curious +- Exception: Skip preambles for trivial single-file reads +- Minimal markdown formatting + +Planning: +- Use plan tool for non-trivial, multi-phase work +- Plans should break task into logical dependencies +- Don't pad with obvious steps +- Update plans mid-task if needed with explanation +- Mark steps completed before moving forward + +File Handling: +- Never re-read files after successful edit +- Use git log/blame for history context +- Never add copyright/license headers +- Don't use one-letter variables +- Use file_path format for citations + +Your output will be displayed on a command line interface. Your responses should be short and concise (typically < 4 lines, excluding tool calls)."#.to_string() + } + + fn get_environment_context(&self) -> String { + let git_status = if self.is_git_repo { "yes" } else { "no" }; + let date = chrono::Local::now().format("%a %b %d %Y").to_string(); + + format!( + r#" + Working directory: {} + Is directory a git repo: {} + Platform: {} + Today's date: {} + "#, + self.working_directory, git_status, self.platform, date + ) + } + + async fn get_tools_context(&self, + registry: &ToolRegistry, + ) -> String { + let schemas = registry.list_schemas().await; + + if schemas.is_empty() { + return String::new(); + } + + let tools_json = serde_json::to_string_pretty(&schemas) + .unwrap_or_else(|_| "[]".to_string()); + + format!( + r#"You have access to the following tools (JSON schema): + +{} + +Tool use: +- Use the model's built-in tool/function calling mechanism (do not print tool calls as text). +- If you need file contents, directory listings, running commands, or edits, call the appropriate tool. +- After tool results are returned, use them to answer. +"#, + tools_json + ) + } + + fn get_custom_instructions(&self) -> String { + String::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_type_detection() { + assert_eq!(ProviderType::from_model_id("gpt-4"), ProviderType::OpenAI); + assert_eq!(ProviderType::from_model_id("gpt-5"), ProviderType::Codex); + assert_eq!(ProviderType::from_model_id("claude-3"), ProviderType::Anthropic); + assert_eq!(ProviderType::from_model_id("gemini-pro"), ProviderType::Gemini); + assert_eq!(ProviderType::from_model_id("unknown"), ProviderType::Generic); + } +} diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs new file mode 100644 index 0000000..7ea5ae2 --- /dev/null +++ b/src/tools/aisdk_bridge.rs @@ -0,0 +1,218 @@ +use crate::tools::{ToolContext, ToolRegistry}; +use aisdk::core::{tools::ToolExecute, Tool}; +use schemars::Schema; +use serde_json::Value; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::llm::ChunkSender; + +static TOOL_CALL_SEQ: AtomicUsize = AtomicUsize::new(0); + +/// Convert our ToolRegistry to AISDK Tools +pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option) -> Vec { + let mut aisdk_tools = Vec::new(); + let tools = registry.list().await; + + for tool_def in tools { + let tool_id = tool_def.id.clone(); + let tool_description = tool_def.description.clone(); + let registry = registry.clone(); + let sender = sender.clone(); + + // Create the execute function + let execute = ToolExecute::new(Box::new(move |input: Value| { + let tool_id = tool_id.clone(); + let tool_id_for_exec = tool_id.clone(); + let tool_id_for_ui = tool_id.clone(); + + let tool_description = tool_description.clone(); + let tool_description_for_ui = tool_description.clone(); + let registry = registry.clone(); + let sender = sender.clone(); + + let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; + let call_id = format!("call_{call_seq}"); + + if let Some(ref sender) = sender { + // Surface tool call start to the UI + let args = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); + let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(vec![crate::llm::ToolCall { + id: call_id.clone(), + call_type: "function".to_string(), + function: crate::llm::FunctionCall { + name: tool_id.clone(), + arguments: args, + }, + }])); + } + + let sender_for_block = sender.clone(); + let call_id_for_block = call_id.clone(); + let tool_id_for_ui_block = tool_id_for_ui.clone(); + + // aisdk tool execution is synchronous (Fn(Value) -> Result), + // but our tools are async. Bridge by blocking in-place on the current runtime. + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] call {} args={} ", + tool_id_for_exec, + input + )); + + let handler = registry + .get(&tool_id_for_exec) + .await + .ok_or_else(|| format!("Tool '{}' not found", tool_id_for_exec))?; + + if let Err(e) = handler.validate(&input) { + return Err(format!("Validation error: {}", e)); + } + + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + let ctx = ToolContext::new("session", "message", "aisdk", abort_rx); + + let tool_result = handler + .execute(input, &ctx) + .await + .map_err(|e| format!("Execution error: {}", e))?; + + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] result {} bytes={}", + tool_id_for_exec, + tool_result.output.len() + )); + + if let Some(ref sender) = sender_for_block { + let preview_limit: usize = 4000; + let mut preview = tool_result.output.clone(); + if preview.len() > preview_limit { + preview.truncate(preview_limit); + preview.push_str("... (truncated)"); + } + + let line_count = tool_result.output.lines().count(); + let meta = serde_json::Value::Object( + tool_result + .metadata + .into_iter() + .collect::>(), + ); + + let payload = serde_json::json!({ + "status": "ok", + "title": tool_result.title, + "output_preview": preview, + "line_count": line_count, + "metadata": meta, + }) + .to_string(); + + let _ = sender.send(crate::llm::ChunkMessage::ToolResult( + crate::llm::ToolCallResult { + tool_call_id: call_id_for_block.clone(), + role: "tool".to_string(), + name: tool_id_for_ui_block.clone(), + content: payload, + }, + )); + } + + Ok(tool_result.output) + }) + }); + + if let (Err(err), Some(ref sender)) = (&result, sender.as_ref()) { + // Error path: emit structured error payload. + let payload = serde_json::json!({ + "status": "error", + "title": tool_description_for_ui, + "output_preview": format!("{}", err), + }) + .to_string(); + let _ = sender.send(crate::llm::ChunkMessage::ToolResult( + crate::llm::ToolCallResult { + tool_call_id: call_id.clone(), + role: "tool".to_string(), + name: tool_id_for_ui.clone(), + content: payload, + }, + )); + } + + result + })); + + // Build the tool schema from parameters + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for param in &tool_def.parameters { + let schema = param_to_json_schema(¶m.param_type); + properties.insert(param.name.clone(), schema); + if param.required { + required.push(param.name.clone()); + } + } + + let input_schema_json = serde_json::json!({ + "type": "object", + "properties": properties, + "required": required + }); + + let schema: Schema = match serde_json::from_value(input_schema_json) { + Ok(s) => s, + Err(e) => { + let _ = crate::logging::log(&format!( + "Error creating schema for tool {}: {} (falling back to any schema)", + tool_def.id, e + )); + Schema::from(true) + } + }; + + let aisdk_tool = match Tool::builder() + .name(&tool_def.id) + .description(&tool_def.description) + .input_schema(schema) + .execute(execute) + .build() { + Ok(t) => t, + Err(e) => { + let _ = crate::logging::log(&format!("Error building tool {}: {}", tool_def.id, e)); + continue; + } + }; + + aisdk_tools.push(aisdk_tool); + } + + aisdk_tools +} + +fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json::Value { + use crate::tools::ParameterType; + + match param_type { + ParameterType::String => serde_json::json!({"type": "string"}), + ParameterType::Integer => serde_json::json!({"type": "integer"}), + ParameterType::Boolean => serde_json::json!({"type": "boolean"}), + ParameterType::Array(inner) => { + serde_json::json!({ + "type": "array", + "items": param_to_json_schema(inner) + }) + } + ParameterType::Object(props) => { + let mut properties = serde_json::Map::new(); + for (key, val) in props { + properties.insert(key.clone(), param_to_json_schema(val)); + } + serde_json::json!({ + "type": "object", + "properties": properties + }) + } + } +} diff --git a/src/tools/fs/glob.rs b/src/tools/fs/glob.rs index 6e1aaef..b6480b2 100644 --- a/src/tools/fs/glob.rs +++ b/src/tools/fs/glob.rs @@ -98,10 +98,11 @@ impl ToolHandler for GlobTool { text }; - Ok(ToolResult::new( - format!("Glob: {}", pattern), - result_text - )) + Ok(ToolResult::new(format!("Glob: {}", pattern), result_text) + .with_metadata("match_count", serde_json::Value::Number((total as i64).into())) + .with_metadata("shown_count", serde_json::Value::Number(((total.min(limit)) as i64).into())) + .with_metadata("limit", serde_json::Value::Number((limit as i64).into())) + .with_metadata("truncated", serde_json::Value::Bool(truncated))) } Err(e) => Err(ToolError::Execution(format!("Invalid glob pattern: {}", e))), } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 66e2560..6a4a080 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use serde_json::Value; pub mod bash; +pub mod aisdk_bridge; pub mod context; pub mod edit; pub mod fs; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index bf61ea1..6d57a4b 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; +#[derive(Clone)] pub struct ToolRegistry { tools: Arc>>>, } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 72b1ced..2a2f5d6 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -9,6 +9,7 @@ use ratatui::{ widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, Frame, }; +use serde_json::Value as JsonValue; #[derive(Debug, Clone, Default)] pub struct Chat { @@ -112,69 +113,73 @@ impl Chat { self.add_message(Message::assistant(content)); } + fn streaming_assistant_idx(&self) -> Option { + self.messages + .iter() + .rposition(|m| m.role == MessageRole::Assistant && !m.is_complete) + } + pub fn append_to_last_assistant(&mut self, chunk: impl AsRef) { let chunk_str = chunk.as_ref(); + + // Append only if the last message is the current streaming assistant segment. if self .messages .last() - .is_some_and(|m| m.role == MessageRole::Assistant) + .is_some_and(|m| m.role == MessageRole::Assistant && !m.is_complete) { if let Some(msg) = self.messages.last_mut() { msg.append(chunk_str); - // Track streaming metrics - record first token time - if self.streaming_first_token_time.is_none() { - self.streaming_first_token_time = Some(std::time::Instant::now()); - } - // Estimate tokens: ~4 characters per token on average - self.streaming_token_count += chunk_str.chars().count().max(1) / 4; - if self.should_autoscroll() { - // Reset scroll to show new content at bottom - self.scroll_offset = usize::MAX; - self.user_scrolled_up = false; - } } } else { - // Starting a new assistant message - let now = std::time::Instant::now(); + // Start a new assistant segment (e.g. after tool rows). + self.add_message(Message::incomplete(chunk_str)); + } + + let now = std::time::Instant::now(); + if self.streaming_start_time.is_none() { self.streaming_start_time = Some(now); + } + if self.streaming_first_token_time.is_none() { self.streaming_first_token_time = Some(now); - // Estimate tokens: ~4 characters per token on average - self.streaming_token_count = chunk_str.chars().count().max(1) / 4; - self.add_assistant_message(chunk_str); + } + + // Estimate tokens: ~4 characters per token on average + self.streaming_token_count += chunk_str.chars().count().max(1) / 4; + if self.should_autoscroll() { + self.scroll_offset = usize::MAX; + self.user_scrolled_up = false; } } pub fn append_reasoning_to_last_assistant(&mut self, chunk: impl AsRef) { let chunk_str = chunk.as_ref(); + if self .messages .last() - .is_some_and(|m| m.role == MessageRole::Assistant) + .is_some_and(|m| m.role == MessageRole::Assistant && !m.is_complete) { if let Some(msg) = self.messages.last_mut() { msg.append_reasoning(chunk_str); - // Track streaming metrics for reasoning tokens too - if self.streaming_first_token_time.is_none() { - self.streaming_first_token_time = Some(std::time::Instant::now()); - } - // Estimate tokens: ~4 characters per token on average - self.streaming_token_count += chunk_str.chars().count().max(1) / 4; - if self.should_autoscroll() { - // Reset scroll to show new content at bottom - self.scroll_offset = usize::MAX; - self.user_scrolled_up = false; - } } } else { - // Create a new assistant message with reasoning let mut msg = Message::incomplete(""); msg.append_reasoning(chunk_str); - // Track streaming metrics - let now = std::time::Instant::now(); + self.add_message(msg); + } + + let now = std::time::Instant::now(); + if self.streaming_start_time.is_none() { self.streaming_start_time = Some(now); + } + if self.streaming_first_token_time.is_none() { self.streaming_first_token_time = Some(now); - self.streaming_token_count = chunk_str.chars().count().max(1) / 4; - self.add_message(msg); + } + self.streaming_token_count += chunk_str.chars().count().max(1) / 4; + if self.should_autoscroll() { + self.scroll_offset = usize::MAX; + self.user_scrolled_up = false; } } @@ -228,11 +233,7 @@ impl Chat { } pub fn is_streaming(&self) -> bool { - self.streaming_first_token_time.is_some() - && self - .messages - .last() - .is_some_and(|m| m.role == MessageRole::Assistant && !m.is_complete) + self.streaming_first_token_time.is_some() && self.streaming_assistant_idx().is_some() } pub fn finalize_streaming_metrics(&mut self) { @@ -240,10 +241,14 @@ impl Chat { let duration_ms = first_token_time.elapsed().as_millis() as u64; let token_count = self.streaming_token_count; - if let Some(last_msg) = self.messages.last_mut() { - if last_msg.role == MessageRole::Assistant { - last_msg.token_count = Some(token_count); - last_msg.duration_ms = Some(duration_ms); + if let Some(idx) = self + .messages + .iter() + .rposition(|m| m.role == MessageRole::Assistant) + { + if let Some(msg) = self.messages.get_mut(idx) { + msg.token_count = Some(token_count); + msg.duration_ms = Some(duration_ms); } } } @@ -269,7 +274,13 @@ impl Chat { return; } - let last_idx = self.messages.len() - 1; + let Some(last_idx) = self.streaming_assistant_idx() else { + if self.streaming_renderer.is_some() { + self.streaming_renderer = None; + self.streaming_message_idx = None; + } + return; + }; // Check if we're still rendering the same message if let Some(renderer_idx) = self.streaming_message_idx { @@ -286,10 +297,10 @@ impl Chat { // Update the renderer content if needed if let Some(ref mut renderer) = self.streaming_renderer { - if let Some(last_msg) = self.messages.last() { - if renderer.content() != last_msg.content { + if let Some(msg) = self.messages.get(last_idx) { + if renderer.content() != msg.content { renderer.reset(); - renderer.append(&last_msg.content); + renderer.append(&msg.content); } } } @@ -471,17 +482,22 @@ impl Chat { ) -> usize { let mut total_height = 0; let message_count = self.messages.len(); + let streaming_idx = self.streaming_assistant_idx(); let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); for (idx, message) in self.messages.iter().enumerate() { + let attached_to_assistant = + idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; let message_lines = self.format_message( message, max_width, idx, message_count, streaming_content, + streaming_idx, model, colors, + attached_to_assistant, ); total_height += message_lines.len(); } @@ -497,17 +513,22 @@ impl Chat { ) -> Vec> { let mut all_lines: Vec> = Vec::new(); let message_count = self.messages.len(); + let streaming_idx = self.streaming_assistant_idx(); let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); for (idx, message) in self.messages.iter().enumerate() { + let attached_to_assistant = + idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; let message_lines = self.format_message( message, max_width, idx, message_count, streaming_content, + streaming_idx, model, colors, + attached_to_assistant, ); all_lines.extend(message_lines); } @@ -522,11 +543,15 @@ impl Chat { idx: usize, message_count: usize, streaming_content: Option<&'a str>, + streaming_idx: Option, model: &'a str, colors: &'a ThemeColors, + attached_to_assistant: bool, ) -> Vec> { let mut lines: Vec> = Vec::new(); + let _ = message_count; + match message.role { MessageRole::User => { // User message: Box with left border colored by agent mode @@ -581,9 +606,7 @@ impl Chat { } } - // Check if this is the streaming message (last incomplete assistant message) - let is_last = idx == message_count.saturating_sub(1); - let is_streaming = is_last && !message.is_complete; + let is_streaming = streaming_idx == Some(idx) && !message.is_complete; if is_streaming { // Use the streaming renderer content for markdown @@ -605,14 +628,22 @@ impl Chat { } // Add empty line before metadata for spacing - lines.push(Line::from("")); - - // Add metadata line (always show for assistant messages) - let metadata = self.format_metadata(message, model, colors); - lines.push(Line::from(metadata)); - - // Add empty line after AI message - lines.push(Line::from("")); + let next_role = self.messages.get(idx + 1).map(|m| m.role.clone()); + let show_metadata = message.is_complete + && !matches!( + next_role, + Some(MessageRole::Tool) | Some(MessageRole::Assistant) + ); + + if show_metadata { + lines.push(Line::from("")); + let metadata = self.format_metadata(message, model, colors); + lines.push(Line::from(metadata)); + lines.push(Line::from("")); + } else { + // Keep spacing consistent between segments. + lines.push(Line::from("")); + } } MessageRole::System => { // System messages: simple display @@ -629,22 +660,182 @@ impl Chat { lines.push(Line::from("")); } MessageRole::Tool => { - // Tool messages: dimmed - let prefix = "Tool: "; - let content = format!("{}{}", prefix, message.content); - let wrapped_lines = textwrap::wrap(&content, max_width); + lines.extend(self.format_tool_row( + message, + max_width, + colors, + attached_to_assistant, + )); + lines.push(Line::from("")); + } + } - for line in wrapped_lines { - lines.push(Line::from(Span::styled( - line.to_string(), - Style::default().fg(Color::Gray), + lines + } + + fn format_tool_row<'a>( + &'a self, + message: &'a Message, + max_width: usize, + colors: &'a ThemeColors, + attached: bool, + ) -> Vec> { + fn preview_value(v: &JsonValue, max_len: usize) -> String { + let mut s = match v { + JsonValue::String(s) => s.clone(), + JsonValue::Number(n) => n.to_string(), + JsonValue::Bool(b) => b.to_string(), + JsonValue::Null => "null".to_string(), + other => other.to_string(), + }; + if s.len() > max_len { + s.truncate(max_len); + s.push_str("…"); + } + if matches!(v, JsonValue::String(_)) { + format!("\"{}\"", s) + } else { + s + } + } + + fn args_preview(args: &JsonValue) -> String { + if let Some(obj) = args.as_object() { + let mut keys: Vec<&String> = obj.keys().collect(); + keys.sort(); + let mut parts = Vec::new(); + for key in keys.into_iter().take(3) { + if let Some(val) = obj.get(key) { + parts.push(format!("{}={}", key, preview_value(val, 24))); + } + } + parts.join(" ") + } else { + preview_value(args, 64) + } + } + + let _ = attached; + let indent = ""; + let mut out: Vec> = Vec::new(); + + let parsed: Option = serde_json::from_str(&message.content).ok(); + let (name, status, args, metadata, output_preview) = + if let Some(JsonValue::Object(obj)) = parsed { + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("tool") + .to_string(); + let status = obj + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("ok") + .to_string(); + let args = obj.get("args").cloned(); + let metadata = obj.get("metadata").cloned(); + let output_preview = obj + .get("output_preview") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + (name, status, args, metadata, output_preview) + } else { + ( + "tool".to_string(), + "ok".to_string(), + None, + None, + Some(message.content.clone()), + ) + }; + + let icon = match status.as_str() { + "running" => "~", + "ok" => "✓", + "error" => "✗", + _ => "•", + }; + + let tool_label = match name.as_str() { + "glob" => "Glob", + "read" => "Read", + "write" => "Write", + "edit" => "Edit", + "bash" => "Bash", + "list" => "List", + "grep" => "Grep", + other => other, + }; + + let args_obj = args.as_ref().and_then(|v| v.as_object()); + let args_str = if name == "glob" { + let pat = args_obj + .and_then(|o| o.get("pattern")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let base = args_obj + .and_then(|o| o.get("path")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut s = String::new(); + if !pat.is_empty() { + s.push_str(&format!("\"{}\"", pat)); + } + if !base.is_empty() && base != "." { + if !s.is_empty() { + s.push(' '); + } + s.push_str(&format!("in \"{}\"", base)); + } + s + } else { + args.as_ref().map(args_preview).unwrap_or_default() + }; + + let mut header = format!("{}{} {}", indent, icon, tool_label); + if !args_str.is_empty() { + header.push(' '); + header.push_str(&args_str); + } + + if name == "glob" { + if let Some(mc) = metadata + .as_ref() + .and_then(|m| m.get("match_count")) + .and_then(|v| v.as_i64()) + { + header.push_str(&format!(" ({} matches)", mc)); + } + } + + let wrapped = textwrap::wrap(&header, max_width); + for line in wrapped { + out.push(Line::from(Span::styled( + line.to_string(), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ))); + } + + if status == "error" { + if let Some(preview) = output_preview { + let first = preview.lines().next().unwrap_or("").trim(); + if !first.is_empty() { + let mut line = first.to_string(); + if line.len() > max_width.saturating_sub(6) { + line.truncate(max_width.saturating_sub(6)); + line.push_str("…"); + } + out.push(Line::from(Span::styled( + format!("{} {}", indent, line), + Style::default().fg(colors.error), ))); } - lines.push(Line::from("")); } } - lines + out } fn get_agent_color(&self, agent_mode: Option<&str>) -> Color { diff --git a/src/utils/git.rs b/src/utils/git.rs index 900e316..7983040 100644 --- a/src/utils/git.rs +++ b/src/utils/git.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::process::Command; pub fn get_current_branch() -> Option { @@ -19,6 +20,15 @@ pub fn get_current_branch() -> Option { } } +pub fn is_git_repo(path: &str) -> Option { + let output = Command::new("git") + .args(["-C", path, "rev-parse", "--git-dir"]) + .output() + .ok()?; + + Some(output.status.success()) +} + #[cfg(test)] mod tests { use super::*; From 495b1b98fffc3bb365ac6bfac288cf14ad22fb1e Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 30 Jan 2026 21:09:21 +0800 Subject: [PATCH 3/9] feat: added anthropic compat. --- Cargo.toml | 2 +- src/llm/client.rs | 235 +++++++++++++++++++++++++++++++--------------- 2 files changed, 162 insertions(+), 75 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6fa67ae..c212e8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ nucleo-matcher = "0.3" rusqlite = { version = "0.31", features = ["bundled"] } cuid2 = "0.1" chrono = { version = "0.4", features = ["serde"] } -aisdk = { version = "0.4", features = ["openai", "openaichatcompletions", "openaicompatible"] } +aisdk = { version = "0.4", features = ["openai", "openaichatcompletions", "openaicompatible", "anthropic"] } tokio-util = "0.7" glob = "0.3" strsim = "0.11" diff --git a/src/llm/client.rs b/src/llm/client.rs index e9f5c3e..2eb61e9 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -3,7 +3,7 @@ use aisdk::{ utils::step_count_is, LanguageModelRequest, LanguageModelStreamChunkType, Message as AisdkMessage, }, - providers::{OpenAI, OpenAICompatible}, + providers::{Anthropic, OpenAI, OpenAICompatible}, }; use futures::StreamExt; use tokio_util::sync::CancellationToken; @@ -36,9 +36,8 @@ impl LLMClient { } } - fn uses_openai_compatible(&self) -> bool { - // Check if the npm package indicates OpenAI-compatible API - self.npm_package == "@ai-sdk/openai-compatible" + fn provider_kind(&self) -> ProviderKind { + ProviderKind::from_provider(&self.provider_name, &self.npm_package) } pub async fn stream_chat( @@ -51,44 +50,70 @@ impl LLMClient { let tool_registry = crate::tools::initialize_tool_registry().await; let aisdk_tools = convert_to_aisdk_tools(&tool_registry, None).await; - let response = if self.uses_openai_compatible() { - let provider = OpenAICompatible::::builder() - .base_url(&self.base_url) - .api_key(&self.api_key) - .model_name(&self.model_name) - .provider_name(&self.provider_name) - .build() - .map_err(|e| Box::new(e) as Box)?; - - let mut builder = LanguageModelRequest::builder() - .model(provider) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + let provider_kind = self.provider_kind(); + let base_url = provider_kind.normalize_base_url(&self.base_url); + + let response = match provider_kind { + ProviderKind::OpenAICompatible => { + let provider = OpenAICompatible::::builder() + .base_url(&base_url) + .api_key(&self.api_key) + .model_name(&self.model_name) + .provider_name(&self.provider_name) + .build() + .map_err(|e| Box::new(e) as Box)?; + + let mut builder = LanguageModelRequest::builder() + .model(provider) + .messages(aisdk_messages) + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } - for tool in aisdk_tools { - builder = builder.with_tool(tool); + builder.build().stream_text().await? } + ProviderKind::Anthropic => { + let provider = Anthropic::::builder() + .base_url(&base_url) + .api_key(&self.api_key) + .model_name(&self.model_name) + .provider_name(&self.provider_name) + .build() + .map_err(|e| Box::new(e) as Box)?; + + let mut builder = LanguageModelRequest::builder() + .model(provider) + .messages(aisdk_messages) + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } - builder.build().stream_text().await? - } else { - let provider = OpenAI::::builder() - .base_url(&self.base_url) - .api_key(&self.api_key) - .model_name(&self.model_name) - .provider_name(&self.provider_name) - .build() - .map_err(|e| Box::new(e) as Box)?; - - let mut builder = LanguageModelRequest::builder() - .model(provider) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); - - for tool in aisdk_tools { - builder = builder.with_tool(tool); + builder.build().stream_text().await? } + ProviderKind::OpenAI => { + let provider = OpenAI::::builder() + .base_url(&base_url) + .api_key(&self.api_key) + .model_name(&self.model_name) + .provider_name(&self.provider_name) + .build() + .map_err(|e| Box::new(e) as Box)?; + + let mut builder = LanguageModelRequest::builder() + .model(provider) + .messages(aisdk_messages) + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } - builder.build().stream_text().await? + builder.build().stream_text().await? + } }; let mut stream = response.stream; @@ -146,6 +171,7 @@ pub async fn stream_llm_with_cancellation( messages: Vec, sender: crate::llm::ChunkSender, ) -> Result<(), Box> { + log("GOING TO STREAM"); use std::time::Instant; let auth_dao = crate::persistence::AuthDAO::new()?; @@ -162,8 +188,9 @@ pub async fn stream_llm_with_cancellation( .get(&provider_name) .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))?; - let base_url = &provider.api; let npm_package = &provider.npm; + let provider_kind = ProviderKind::from_provider(&provider_name, npm_package); + let base_url = provider_kind.normalize_base_url(&provider.api); let _ = log(&format!( "Provider: {}, NPM: {}, Base URL: {}", @@ -171,51 +198,73 @@ pub async fn stream_llm_with_cancellation( )); // Determine which provider to use based on npm package - let uses_openai_compatible = npm_package == "@ai-sdk/openai-compatible"; - let aisdk_messages = convert_messages(&messages); let tool_registry = crate::tools::initialize_tool_registry().await; let aisdk_tools = convert_to_aisdk_tools(&tool_registry, Some(sender.clone())).await; - let response = if uses_openai_compatible { - let provider_config = OpenAICompatible::::builder() - .base_url(base_url) - .api_key(&api_key) - .model_name(&model) - .provider_name(&provider.name) - .build() - .map_err(|e| Box::new(e) as Box)?; - - let mut builder = LanguageModelRequest::builder() - .model(provider_config) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); - - for tool in aisdk_tools { - builder = builder.with_tool(tool); + let response = match provider_kind { + ProviderKind::OpenAICompatible => { + let provider_config = OpenAICompatible::::builder() + .base_url(&base_url) + .api_key(&api_key) + .model_name(&model) + .provider_name(&provider.name) + .build() + .map_err(|e| Box::new(e) as Box)?; + + let mut builder = LanguageModelRequest::builder() + .model(provider_config) + .messages(aisdk_messages) + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } + + builder.build().stream_text().await? } + ProviderKind::Anthropic => { + log("USING ANTHROPIC"); + let provider_config = Anthropic::::builder() + .base_url(&base_url) + .api_key(&api_key) + .model_name(&model) + .provider_name(&provider.name) + .build() + .map_err(|e| Box::new(e) as Box)?; - builder.build().stream_text().await? - } else { - let provider_config = OpenAI::::builder() - .base_url(base_url) - .api_key(&api_key) - .model_name(&model) - .provider_name(&provider.name) - .build() - .map_err(|e| Box::new(e) as Box)?; - - let mut builder = LanguageModelRequest::builder() - .model(provider_config) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); - - for tool in aisdk_tools { - builder = builder.with_tool(tool); + let mut builder = LanguageModelRequest::builder() + .model(provider_config) + .messages(aisdk_messages) + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } + + builder.build().stream_text().await? } + ProviderKind::OpenAI => { + let provider_config = OpenAI::::builder() + .base_url(&base_url) + .api_key(&api_key) + .model_name(&model) + .provider_name(&provider.name) + .build() + .map_err(|e| Box::new(e) as Box)?; + + let mut builder = LanguageModelRequest::builder() + .model(provider_config) + .messages(aisdk_messages) + .stop_when(step_count_is(15)); + + for tool in aisdk_tools { + builder = builder.with_tool(tool); + } - builder.build().stream_text().await? + builder.build().stream_text().await? + } }; let mut stream = response.stream; @@ -290,3 +339,41 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec Self { + // Dirty: But add any workaround/overrides here in case npm_package can be treated differently. + // if provider_name == "kimi-for-coding" { + // return Self::OpenAICompatible; + // } + + match npm_package { + "@ai-sdk/openai-compatible" => Self::OpenAICompatible, + "@ai-sdk/anthropic" => Self::Anthropic, + _ => Self::OpenAI, + } + } + + fn normalize_base_url(self, base_url: &str) -> String { + match self { + ProviderKind::Anthropic => normalize_anthropic_base_url(base_url), + _ => base_url.to_string(), + } + } +} + +fn normalize_anthropic_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + if trimmed.ends_with("/v1") { + trimmed.trim_end_matches("/v1").to_string() + } else { + trimmed.to_string() + } +} From cb63c73fa8b5dda0b4b038be67401042531cdda2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 30 Jan 2026 21:20:43 +0800 Subject: [PATCH 4/9] feat: prevent sending messages while streaming... --- src/app.rs | 9 +++++++++ src/llm/client.rs | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index d93e92a..942cfb1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -562,6 +562,9 @@ impl App { } KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { if self.overlay_focus == OverlayFocus::SuggestionsPopup { + if self.is_streaming { + return true; + } self.autocomplete_and_submit(); true } else { @@ -575,6 +578,9 @@ impl App { fn handle_input_and_app_keys(&mut self, key: KeyEvent) { match key.code { KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { + if self.is_streaming { + return; + } let input_text = self.input.get_text(); if !input_text.is_empty() { use crate::command::parser::parse_input; @@ -748,6 +754,9 @@ impl App { } fn autocomplete_and_submit(&mut self) { + if self.is_streaming { + return; + } if let Some(selected) = get_selected_suggestion(&self.suggestions_popup_state) { let command = format!("/{}", selected.name); diff --git a/src/llm/client.rs b/src/llm/client.rs index 2eb61e9..26101bf 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -225,7 +225,8 @@ pub async fn stream_llm_with_cancellation( builder.build().stream_text().await? } ProviderKind::Anthropic => { - log("USING ANTHROPIC"); + log(&format!("USING ANTHROPIC | {:?} | {:?}", &base_url, &model)); + let provider_config = Anthropic::::builder() .base_url(&base_url) .api_key(&api_key) From eed7c66e0abd8327d0374e687aa0a3799c4b7a16 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 30 Jan 2026 21:26:24 +0800 Subject: [PATCH 5/9] chore: added old plans for implementing tool calls and system prompts. --- _plans/OC_SYSTEMPROMPT_PRD.md | 637 ++++++++++++++++++++++++++++++++++ _plans/OC_TOOL_SYSTEM_PRD.md | 317 +++++++++++++++++ _plans/TOOL_SYSTEM_PLAN.md | 331 ++++++++++++++++++ 3 files changed, 1285 insertions(+) create mode 100644 _plans/OC_SYSTEMPROMPT_PRD.md create mode 100644 _plans/OC_TOOL_SYSTEM_PRD.md create mode 100644 _plans/TOOL_SYSTEM_PLAN.md diff --git a/_plans/OC_SYSTEMPROMPT_PRD.md b/_plans/OC_SYSTEMPROMPT_PRD.md new file mode 100644 index 0000000..3090043 --- /dev/null +++ b/_plans/OC_SYSTEMPROMPT_PRD.md @@ -0,0 +1,637 @@ +Product Requirements Document: OpenCode System Prompts Architecture + +Executive Summary + +OpenCode uses a sophisticated multi-tiered system prompt architecture to deliver contextual agent behavior across different LLM providers and use cases. This PRD documents the complete system prompt ecosystem, including provider-specific optimizations, agent-specific instructions, session management prompts, and the dynamic prompt composition system. + +1. System Prompt Architecture Overview + +OpenCode employs 4 prompt composition layers: + +plaintext + +Layer 1: Header (Provider-specific) +↓ +Layer 2: Core Instructions (Provider-optimized) +↓ +Layer 3: Custom Instructions (User project-specific) +↓ +Layer 4: Agent-specific Prompts (Task-specialized) + +2. Provider-Specific Prompts (Layer 2: Core) + +2.1 Provider Detection + +typescript + +SystemPrompt.provider(model: Provider.Model): string[] { +if model.api.id includes "gpt-5" → PROMPT_CODEX +if model.api.id includes "gpt-" || "o1" || "o3" → PROMPT_BEAST (GPT-4+) +if model.api.id includes "gemini-" → PROMPT_GEMINI +if model.api.id includes "claude" → PROMPT_ANTHROPIC +else → PROMPT_ANTHROPIC_WITHOUT_TODO (fallback) +} + +2.2 GPT Models: "BEAST" Prompt (1,400+ tokens) + +Target: GPT-4, o1, o3 models + +Key Characteristics: + +Autonomy-First: "MUST iterate and keep going until problem is solved" +Extended Reasoning: Encourages thorough thinking, long-form planning +Iterative Workflow: 10-step structured process +Internet Research Mandatory: Forces webfetch for dependency verification +Todo Tracking: Strict emoji-based checklist with status tracking +Code Quality: Rigorous testing, edge case verification, validation loops +Terminal Output: Concise communication via emoji status indicators +Core Directives: + +Plan extensively before each function call +Fetch URLs provided by user + discover recursive links +Deeply understand problem via investigation +Research dependencies on internet for accuracy +Develop detailed todo list with emoji status +Make incremental, testable changes +Debug to root cause (not symptoms) +Test frequently after each change +Iterate until problem solved + tests pass +Reflect and validate comprehensively +Output Philosophy: + +Concise, casual yet professional tone +Always communicate intent before tool calls +Respond with direct answers + bullet points +Avoid unnecessary explanations +Use emoji for status tracking (✓, ☐, ✗) +Communication Examples: + +"Let me fetch the URL you provided to gather more information." +"Ok, I've got all the information I need on the LIFX API and I know how to use it." +"Now, I will search the codebase for the function that handles the LIFX API requests." +"Whelp - I see we have some problems. Let's fix those up." +Notable Features: + +Memory System: .github/instructions/memory.instruction.md for persistent user preferences +Git Restrictions: Never auto-commit; requires explicit user request +Sequential Thinking: Available when model supports it +File Rereading Prevention: Avoid re-reading unchanged files +2.3 Claude Models: "ANTHROPIC" Prompt (~1,100 tokens) + +Target: Claude 3.x, Claude Sonnet + +Key Characteristics: + +Task-Driven: "The user will primarily request software engineering tasks" +Task Tracking: Heavy emphasis on TodoWrite tool usage +Conciseness: "Brief answers are best" with complete info +Professional Objectivity: Technical accuracy over validation +Tool Preference: Task tool for codebase exploration (reduces context) +Code References: file_path:line_number notation for navigation +Core Directives: + +Use TodoWrite tool VERY frequently +Plan tasks with clear breakdown +Mark todos completed immediately (don't batch) +Minimize output tokens while maintaining quality +Avoid preamble/postamble unless asked +Use Task tool for specialized agent work +Batch independent tool calls in parallel +Use dedicated tools over bash when possible +Output Philosophy: + +"Assist with defensive security tasks only" +Keep responses short (< 4 lines typically) +Answer directly without elaboration +No unnecessary explanations post-completion +Provide only requested level of detail +Communication Examples (Extreme Brevity): + +plaintext + +user: 2 + 2 +assistant: 4 +user: what command should I run to list files? +assistant: ls +user: what files are in src/? +assistant: [runs ls] foo.c, bar.c, baz.c + +Security Policy: + +"Assist with defensive security tasks only" +Refuse to create code for malicious purposes +No credential discovery/harvesting assistance +Block SSH key/cookie/wallet bulk crawling +Notable Features: + +Help Command: /help displays help info +Feedback: /bug command for issue reporting +Custom Hooks: Treat hook feedback as user feedback +Code Block Explanation: Skip unless requested +2.4 Gemini Models: "GEMINI" Prompt (~2,100 tokens) + +Target: Gemini 2, Gemini Pro + +Key Characteristics: + +Convention-First: "Rigorously adhere to existing project conventions" +Assumption Rejection: NEVER assume library/framework availability +Verification-Heavy: Check imports, config files, neighboring code +Style Mimicry: Match project formatting, naming, architecture +Path Construction: Always use absolute paths +File System Safety: Explain critical commands before execution +Minimal Output: "3 lines max output excluding tool use" +Self-Verification: Include unit tests and debug statements +Core Directives: + +Understand via grep/glob (parallel searches) +Build grounded plan based on context +Implement adhering to conventions +Verify with tests if applicable +Execute linting/type-checking commands +Validate against original request +Output Philosophy: + +"Adopt professional, direct, concise tone" +Fewer than 3 lines per response +Focus strictly on user's query +No conversational filler or preambles +Format with GitHub-flavored Markdown +Security Rules: + +Explain bash commands that modify filesystem +Never introduce code that exposes secrets +Always use absolute paths (relative paths not supported) +Avoid interactive shell commands (non-interactive when possible) +Respect user confirmations—never retry canceled operations +Notable Features: + +Interactive Avoidance: Flag commands like git rebase -i +Background Processes: Use & for long-running commands +Path Validation: Must combine project root + relative path +Code Comments: Add sparingly, focus on "why" not "what" +No Reverts: Only revert if user requests or error occurs +2.5 OpenAI Codex: "CODEX" Prompt (~2,500+ tokens, most comprehensive) + +Target: GPT-4 Turbo, GPT-4o (when not using BEAST) + +Key Characteristics: + +Personality: "Concise, direct, friendly" yet highly detailed +AGENTS.md Spec: Hierarchical instruction files with scope precedence +Preamble Guidelines: 1-2 sentence explanations before tool calls +Planning Tool: TodoWrite for non-trivial tasks +Personality Examples: +"I've explored the repo; now checking the API route definitions." +"Next, I'll patch the config and update the related tests." +"Alright, build pipeline order is interesting." +Core Directives: + +Obey AGENTS.md files in scope +Keep responses concise, direct, friendly +Send brief preambles before tool calls (8-12 words) +Use todowrite for non-trivial, multi-phase work +Break tasks into meaningful, logically ordered steps +Don't repeat full plan after todowrite +Fix root cause, not surface patches +Keep changes minimal and focused +Validate work via tests/build +Only terminate when problem completely solved +Output Philosophy: + +Group related actions in single preamble +Build on prior context for momentum +Keep tone light, friendly, curious +Exception: Skip preambles for trivial single-file reads +Minimal markdown formatting +Planning Guidance: + +Use plan tool for non-trivial, multi-phase work +Plans should break task into logical dependencies +Don't pad with obvious steps +Update plans mid-task if needed with explanation +Mark steps completed before moving forward +High-Quality Plan Examples: + +plaintext + +1. Add CLI entry with file args +2. Parse Markdown via CommonMark library +3. Apply semantic HTML template +4. Handle code blocks, images, links +5. Add error handling for invalid files + +File Handling: + +Never re-read files after successful edit +Use git log/blame for history context +Never add copyright/license headers +Don't use one-letter variables +Use file_path format for citations (avoid broken 【F:】 syntax) +Approval Modes: + +untrusted: Most commands escalated +on-failure: Allow commands; escalate on failure +on-request: Default sandboxed; request when needed +never: Must persist and solve without user approval +Notable Features: + +Sequential Thinking: Use when available for complex reasoning +Code References: file_path:line_number for navigation +Task Execution Philosophy: Start specific tests, move to broader +No Unrelated Fixes: Skip unrelated bugs (mention in final message) +Formatting Loops: Up to 3 iterations max for formatting 3. Header Prompts (Layer 1: Provider Detection) + +3.1 Anthropic Header + +typescript + +SystemPrompt.header(providerID: string): string[] { +if providerID.includes("anthropic") +→ [PROMPT_ANTHROPIC_SPOOF.trim()] +else +→ [] +} + +The Anthropic header is injected when Claude API is detected. It provides context about Claude-specific features. + +4. Custom Instructions (Layer 3: User Project Context) + +4.1 Custom Instruction File Discovery + +OpenCode searches for custom instructions in this priority order: + +Local Files (searched bottom-up from project): + +AGENTS.md +CLAUDE.md +CONTEXT.md (deprecated) +Global Files: + +~/.opencode/AGENTS.md (global config) +~/.claude/CLAUDE.md (if not disabled) +${OPENCODE_CONFIG_DIR}/AGENTS.md +Config-Specified URLs: + +Any URLs in config.instructions[] (loaded with 5s timeout) +4.2 Custom Instructions Format + +markdown + +# AGENTS.md / CLAUDE.md + +- Use for coding standards, patterns, project structure +- Scope: entire directory tree rooted at containing folder +- Nested files take precedence +- User instructions override file instructions + + 4.3 Environment Context + +Injected automatically: + +plaintext + + + Working directory: ${Instance.directory} + Is directory a git repo: yes/no + Platform: ${process.platform} + Today's date: ${new Date().toDateString()} + + +5. Agent-Specific Prompts (Layer 4: Task Specialization) + +5.1 Agent Prompt System + +Each agent can have a custom prompt override that specializes behavior: + +typescript + +Agent.Info { +prompt?: string // Optional custom system prompt +} + +Agent-Specific Prompts: + +Explore Agent (22 lines) + +plaintext + +You are a file search specialist. You excel at thoroughly navigating +and exploring codebases. +Guidelines: + +- Use Glob for broad file pattern matching +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path +- Use Bash for file operations (copy, move, list) +- Adapt search based on thoroughness level (quick/medium/very thorough) +- Return absolute paths in final response +- Avoid using emojis +- Do not create files or modify system state + +Key Features: + +Rapid pattern-based navigation +Regex search specialization +Thoroughness level awareness +Read-only constraint +Absolute path requirement +Compaction Agent (13 lines) + +plaintext + +You are a helpful AI assistant tasked with summarizing conversations. +When asked to summarize, provide detailed but concise summary focusing on: + +- What was done +- What is currently being worked on +- Which files are being modified +- What needs to be done next +- Key user requests/constraints/preferences +- Important technical decisions and why + +Use Case: Session message truncation for long conversations + +Title Agent (44 lines) + +plaintext + +You are a title generator. You output ONLY a thread title. Nothing else. +Generate a brief title (≤50 chars) that would help user find later. +Rules: + +- Never include tool names +- Focus on main topic for retrieval +- Vary phrasing +- When file mentioned, focus on WHAT user wants, not file name +- Keep exact: technical terms, numbers, filenames, HTTP codes +- Remove: the, this, my, a, an +- Never assume tech stack +- If user message short/conversational → reflect tone + Examples: + "debug 500 errors in production" → "Debugging production 500 errors" + "why is app.js failing" → "app.js failure investigation" + +Constraints: + +Exactly one line output +No explanations +No meta-commentary +No "cannot generate" responses +Summary Agent (12 lines) + +plaintext + +Summarize what was done in this conversation. Write like PR description. +Rules: + +- 2-3 sentences max +- Describe changes made, not process +- Don't mention tests/builds/validation +- Write in first person (I added..., I fixed...) +- Never ask questions +- If question unanswered → preserve exact question +- If imperative request → include exact request + +6. Agent Generation Prompt + +6.1 Custom Agent Creator Prompt (~76 lines) + +OpenCode has a dedicated agent that helps users create custom agents via the "Generate Agent" feature. + +Key Sections: + +Extract Core Intent +Identify purpose, responsibilities, success criteria +Consider project context from CLAUDE.md +For code review agents: assume recent code, not whole codebase +Design Expert Persona +Create compelling expert identity +Embody domain knowledge +Inspire confidence +Architect Instructions +Clear behavioral boundaries +Specific methodologies and best practices +Anticipate edge cases +Align with project standards +Define output formats +Optimize for Performance +Decision-making frameworks +Quality control mechanisms +Efficient workflows +Clear escalation strategies +Create Identifier +Lowercase, numbers, hyphens only +2-4 words +Clearly indicates function +Memorable, easy to type +Avoid generic terms +Output Format +json + +{ +"identifier": "code-reviewer", +"whenToUse": "Use this agent when... [include examples]", +"systemPrompt": "You are... [complete prompt]" +} + +Agent Generation Examples: + +Example 1: Code Review Agent + +Context: Review recently written code, not whole codebase +Output: Comprehensive code review with style/best practices +When to Use: After logical code chunks complete +Example 2: Greeting Responder Agent + +Context: Respond to user greetings with friendly joke +Output: Friendly joke response +When to Use: When user says "hello", "greeting", "hey" 7. System Prompt Composition Logic + +7.1 Full Prompt Stack + +typescript + +SystemPrompt.compose(session, agent, model, provider) { +// 1. Provider header (if applicable) +parts.push(SystemPrompt.header(provider.id)) + +// 2. Core provider-specific prompt +parts.push(SystemPrompt.provider(model)) + +// 3. Environment context +parts.push(await SystemPrompt.environment()) + +// 4. Custom instructions (AGENTS.md, CLAUDE.md, URLs, config) +parts.push(await SystemPrompt.custom()) + +// 5. Agent-specific prompt override (if defined) +if (agent.prompt) { +parts.push(agent.prompt) +} + +// 6. Agent-specific prompt generator (if applicable) +if (agent.agentID === "explore") { +parts.push(PROMPT_EXPLORE) +} +if (agent.agentID === "compaction") { +parts.push(PROMPT_COMPACTION) +} + +return parts.join("\n\n---\n\n") +} + +7.2 Composition Order (Priority) + +Provider Header (context) +Core Provider Prompt (behavior framework) +Environment (facts about runtime) +Custom Instructions (project-specific rules) +Agent Prompt (specialization) +Later sections can override earlier ones. User/developer instructions override all. + +8. Output Truncation Integration + +System prompts include guidance on output truncation: + +plaintext + +// In Beast/Anthropic/Gemini prompts: +"Your output will be displayed on a command line interface. +Your responses should be short and concise (typically < 4 lines, +excluding tool calls)." +// In Codex prompt: +"Minimal Output: Aim for fewer than 3 lines of text output +(excluding tool use/code generation) per response whenever practical." + +9. Security & Safety Guardrails (Embedded in Prompts) + +9.1 Defensive Security Only + +Embedded in: Anthropic (20250930), Anthropic, Claude + +plaintext + +IMPORTANT: Assist with defensive security tasks only. Refuse to create, +modify, or improve code that may be used maliciously. Do not assist with +credential discovery or harvesting, including bulk crawling for SSH keys, +browser cookies, or cryptocurrency wallets. Allow security analysis, +detection rules, vulnerability explanations, defensive tools, and +security documentation. + +9.2 URL Generation Restriction + +Embedded in: Anthropic (20250930), Anthropic + +plaintext + +IMPORTANT: You must NEVER generate or guess URLs for the user unless +you are confident that the URLs are for helping the user with programming. +You may use URLs provided by the user in their messages or local files. + +9.3 File System Safety + +Embedded in: Gemini, Codex + +plaintext + +Before executing commands with 'bash' that modify the file system, +codebase, or system state, you _must_ provide a brief explanation of +the command's purpose and potential impact. + +10. Non-Functional Requirements + +10.1 Prompt Size Management + +Provider Typical Size Tokens +Beast (GPT-4) 1,400+ lines ~2,000 +Anthropic (Claude) 1,100 lines ~1,400 +Gemini 2,100 lines ~2,800 +Codex (GPT-4o) 2,500+ lines ~3,200 +Strategy: + +Provider prompts are static (loaded once) +Custom instructions cached after first load +Agent prompts concatenated at session start +Total system prompt < 20KB typically +10.2 Prompt Loading + +Lazy Loading: Agent prompts loaded on first use +Caching: Custom instructions cached after fetch +Timeout: Custom instruction URLs have 5s timeout +Fallback: If custom instruction fetch fails, continue without +10.3 Deterministic Behavior + +Same model + agent + project = same prompt composition +No randomization in prompt selection +Prompt version tracking via git tags +No runtime prompt mutation 11. Implementation Strategy + +Phase 1: Core (MVP) + +Provider detection logic (GPT vs Claude vs Gemini) +Beast prompt for GPT models +Anthropic prompt for Claude +System prompt composition engine +Environment context injection +Phase 2: Extensions + +Gemini prompt +Codex prompt +AGENTS.md discovery + parsing +Custom instruction loading +URL instruction fetching +Phase 3: Agent Specialization + +Agent-specific prompts (explore, compaction, title, summary) +Custom agent generation (the "Generate Agent" feature) +Agent prompt overrides +Persona builder +Phase 4: Polish + +Prompt versioning + change tracking +A/B testing framework for prompts +Telemetry on prompt effectiveness +Performance optimization (caching) 12. Prompt Testing & Validation + +12.1 Test Cases + +Provider Detection +GPT-5 model → loads CODEX +GPT-4 model → loads BEAST +Claude model → loads ANTHROPIC +Gemini model → loads GEMINI +Composition Order +Header present for Anthropic +Environment context included +Custom instructions prepended +Agent prompt appended +Custom Instructions +AGENTS.md found and loaded +Nested AGENTS.md takes precedence +URL instructions fetched with timeout +Missing files don't break composition +Output Philosophy +Beast: Long-form reasoning + emoji tracking +Anthropic: Minimal output, direct answers +Gemini: Convention-first, <3 lines +Codex: Friendly preambles, logical grouping 13. Key Differences by Provider + +Aspect Beast (GPT-4) Anthropic (Claude) Gemini Codex (GPT-4o) +Autonomy Maximum (iterate until solved) Task-driven Convention-driven Balanced +Output Length Long-form encouraged Minimal (<4 lines) Minimal (<3 lines) Moderate (1-2 sentences) +Planning Mandatory upfront TodoWrite frequent Not emphasized Optional, high-quality only +Internet Research Mandatory Optional Optional Optional +Communication Style Casual, emoji-heavy Direct, professional Concise, formal Friendly, conversational +Tool Preference All tools encouraged Task tool priority Grep/Glob focus Balanced with favorites +Error Handling Retry until solved Move forward Skip unrelated Move forward +Prompt Size Large (1400+ lines) Medium (1100 lines) Very large (2100+ lines) Largest (2500+ lines) 14. Future Enhancements + +Dynamic Prompt Generation: AI-powered prompt generation based on project analysis +Prompt Versioning: Git-based versioning + rollback capability +Multi-Language Support: Translate system prompts to user's preferred language +Model-Specific Optimizations: Fine-tune prompts for new model releases +Telemetry: Track which prompts perform best for analytics +Prompt Marketplace: Community-contributed prompts for specific tasks +Adaptive Prompts: Adjust verbosity/style based on session history +This PRD captures opencode's sophisticated multi-tiered prompt system. The key insight is that opencode doesn't use one system prompt—it composes prompts dynamically based on the provider, agent type, project context, and user preferences. This allows it to optimize for each model's strengths while maintaining consistent user experience. diff --git a/_plans/OC_TOOL_SYSTEM_PRD.md b/_plans/OC_TOOL_SYSTEM_PRD.md new file mode 100644 index 0000000..157df37 --- /dev/null +++ b/_plans/OC_TOOL_SYSTEM_PRD.md @@ -0,0 +1,317 @@ +Product Requirements Document: OpenCode Tool System Clone + +Executive Summary + +OpenCode provides a sophisticated multi-tool agent system for code exploration and manipulation. This PRD documents the complete architecture needed to build an equivalent system that can replicate all of opencode's capabilities. + +1. Architecture Overview + +The system consists of three core components: + +Tool Framework – A generic tool definition and execution system +Tool Registry – Dynamic registration and management of tools +Agent System – Specialized agents with different capabilities and permissions 2. Core Tool Framework + +2.1 Tool Definition Interface + +typescript + +namespace Tool { +interface Info { +id: string +init(ctx?: InitContext): Promise<{ +description: string +parameters: Parameters +execute(args: z.infer, ctx: Context): Promise<{ +title: string +metadata: Metadata +output: string +attachments?: FilePart[] +}> +formatValidationError?(error: z.ZodError): string +}> +} + +type Context = { +sessionID: string +messageID: string +agent: string +abort: AbortSignal +callID?: string +extra?: Record +metadata(input: { title?: string; metadata?: M }): void +ask(input: PermissionRequest): Promise +} + +function define( +id: string, +init: Info['init'] +): Info +} + +Key Features: + +Zod-based parameter validation +Async initialization for lazy loading +Metadata streaming during execution +Graceful abort signal handling +Permission-based access control via ctx.ask() +Automatic output truncation +2.2 Tool Definition Helper + +Tools are defined using Tool.define() which provides: + +Automatic parameter validation with helpful error messages +Output truncation management (respects per-agent limits) +Metadata accumulation during execution +Custom error formatting 3. Core Tools (23 Total) + +3.1 File System Tools + +Tool Purpose Key Parameters +read Read file contents with pagination filePath, offset, limit +write Create/overwrite files with permission checks filePath, content +edit Replace text in files with smart diffing filePath, oldString, newString, replaceAll +glob Find files by glob pattern pattern, path +list List directory contents in tree format path, ignore +bash Execute shell commands with security scanning command, timeout, workdir, description +Advanced Features: + +Image/PDF support in read tool (base64 encoding) +Binary file detection +Diff-based edit strategies (simple, line-trimmed, block anchor fallback) +Levenshtein distance for fuzzy matching in edits +Smart Bash command parsing using tree-sitter for permission requests +File lock management to prevent race conditions +3.2 Search & Navigation Tools + +Tool Purpose Key Parameters +grep Regex search across files pattern, path, include +codesearch AI-powered code search (via Exa MCP) query, tokensNum +websearch Web search with live crawl options query, numResults, livecrawl, type +Advanced Features: + +Results limited to 100 matches max +Results sorted by modification time +SSE response parsing for web/code search +Timeout handling (25-30s) +Live crawl support for fresh content +3.3 External Data Tools + +Tool Purpose Key Parameters +webfetch Fetch and convert web content url, format (text/markdown/html), timeout +Advanced Features: + +HTML to Markdown conversion (turndown service) +Text extraction from HTML +Content-Type aware formatting +5MB response size limit +120s max timeout +Accept header optimization per format +3.4 Task Coordination Tools + +Tool Purpose Key Parameters +task Spawn subagents for parallel work description, prompt, subagent_type, session_id +Advanced Features: + +Dynamic agent list filtering based on caller permissions +Session creation/reuse +Real-time tool execution tracking +Summary aggregation from subagent work +3.5 Metadata & Admin Tools + +Tool Purpose Key Parameters +question CLI-only: Interactive user questions question, options +todo.read List TODO items from workspace path, pattern +todo.write Create/update TODO items path, task, status +skill Register/invoke custom skills name, args +batch Execute multiple tools in batch (experimental) commands +lsp Language server protocol integration (experimental) - +invalid Error handler for unknown tool calls - 4. Tool Registry System + +typescript + +namespace ToolRegistry { +async function all(): Promise +async function ids(): Promise +async function tools(providerID: string, agent?: Agent.Info): Promise +async function register(tool: Tool.Info): void +} + +Features: + +Plugin system: Auto-discovers tools in {configDir}/tool/\*.{js,ts} +Dynamic filtering based on provider (websearch/codesearch only for "opencode" provider or with flag) +Agent-specific tool initialization +Runtime tool registration for custom extensions +Plugin Tool Format: + +typescript + +// tools/my_tool.ts +export default { +description: "...", +args: { param1: z.string(), ... }, +execute(args, ctx): Promise +} + +5. Agent System + +5.1 Agent Definition + +typescript + +namespace Agent { +interface Info { +name: string +description?: string +mode: "subagent" | "primary" | "all" +native?: boolean +hidden?: boolean +topP?: number +temperature?: number +color?: string +permission: PermissionRuleset +model?: { modelID: string; providerID: string } +prompt?: string +options?: Record +steps?: number +} +} + +5.2 Built-in Agents (8 Total) + +Agent Mode Native Permission Purpose +build primary ✓ Everything + questions Build/compile automation +plan primary ✓ Read + plan editing Project planning +general subagent ✓ Everything except TODOs Parallel multi-step work +explore subagent ✓ Grep/glob/read/bash/search Fast codebase exploration +compaction primary ✓ Nothing (hidden) Message truncation +title primary ✓ Nothing (hidden) Session title generation +summary primary ✓ Nothing (hidden) Session summarization +\*custom all ✗ Per-config User-defined agents +5.3 Permission System + +Agents operate under hierarchical permission rules: + +typescript + +PermissionRuleset: { +"_": "allow" | "deny" | "ask" +[permission]: "allow" | "deny" | "ask" +[permission]: { +"_": "allow" | "deny" | "ask" +[pattern]: "allow" | "deny" | "ask" +} +} + +Default Permissions: + +javascript + +{ +"_": "allow", +doom_loop: "ask", +external_directory: { "_": "ask", [Truncate.DIR]: "allow" }, +question: "deny", +read: { +"_": "allow", +"_.env": "deny", +"_.env._": "deny", +"\*.env.example": "allow" +} +} + +6. Non-Functional Requirements + +6.1 Output Truncation + +Per-tool limits: 2000 lines, 50KB (read), varies per tool +Global limits: Configurable per agent +Truncation strategy: Content + "..." + path to overflow file +Output modes: +Preserve full output in metadata +Streaming metadata updates during execution +6.2 Concurrency & Cancellation + +AbortSignal propagation from session +Graceful timeout handling in async tools +Process tree killing (bash tool) +Token-aware request cancellation +6.3 Error Handling + +Custom validation error formatters per tool +Helpful error messages (e.g., "Did you mean..." for missing files) +Schema validation before execution +Detailed error context in responses +6.4 Security + +Permission evaluation before each tool call +Path validation for external directories +Binary file detection to prevent read errors +.env file blocking by default +Command parsing (tree-sitter bash) for permission verification 7. Implementation Priorities + +Phase 1: Core (MVP) + +Tool framework + definition system +6 core file tools (read, write, edit, glob, list, bash) +Tool registry with plugin support +Agent system with permissions +3 primary agents (build, plan, general) +Phase 2: Search & External + +Grep tool +Webfetch tool +Websearch & codesearch tools (MCP integration) +Explore agent +Phase 3: Advanced + +Task tool + subagent spawning +LSP integration +Todo tools +Skill system +Batch tool +Phase 4: Polish + +Session title/summary generation +Message compaction +Custom agent support +Plugin ecosystem 8. Key Integration Points + +Provider System: Tools need to know which LLM provider they're running under (affects feature availability) +Session Management: Tool execution tied to session/message IDs for context +File Change Notification: Bus/event system to broadcast edits +LSP Server: Optional language server for diagnostics +Permission System: Evaluator that makes allow/deny/ask decisions +Plugin Loader: Dynamic module imports from user config directories 9. Data Structures + +Tool Execution Context: + +typescript + +{ +sessionID, messageID, agent, abort, callID, extra, metadata(), ask() +} + +Tool Response: + +typescript + +{ +title: string +output: string +metadata: Record +attachments?: { id, sessionID, messageID, type, mime, url }[] +} + +Permission Request: + +typescript + +{ +permission: string +patterns: string[] +always?: string[] +metadata: Record +} diff --git a/_plans/TOOL_SYSTEM_PLAN.md b/_plans/TOOL_SYSTEM_PLAN.md new file mode 100644 index 0000000..aaadef4 --- /dev/null +++ b/_plans/TOOL_SYSTEM_PLAN.md @@ -0,0 +1,331 @@ +# Crabcode Tool System Implementation Plan + +## Overview + +This document outlines the implementation plan for adding OpenCode-style tool system to crabcode. We'll implement the 6 core file system tools first, building on crabcode's existing Rust architecture. + +## Current State Analysis + +Crabcode already has: +- Basic agent module structure (`src/agent/`) +- File autocomplete functionality (`src/autocomplete/file.rs`) +- Ignore patterns support (stub in `src/utils/ignore.rs`) +- Async runtime (Tokio) +- Serialization (serde) +- Error handling (anyhow) + +## Phase 1: Core Infrastructure (Week 1) + +### 1.1 Tool Framework Foundation + +**New Files:** +- `src/tools/mod.rs` - Tool module root +- `src/tools/types.rs` - Core type definitions +- `src/tools/context.rs` - Tool execution context +- `src/tools/registry.rs` - Tool registration and discovery + +**Key Types to Implement:** + +```rust +// src/tools/types.rs +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub type ToolId = String; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParameterSchema { + pub name: String, + pub description: String, + pub required: bool, + pub param_type: ParameterType, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParameterType { + String, + Integer, + Boolean, + Array(Box), + Object(HashMap), +} + +#[derive(Debug, Clone)] +pub struct Tool { + pub id: ToolId, + pub description: String, + pub parameters: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolResult { + pub title: String, + pub output: String, + pub metadata: HashMap, +} + +#[derive(Debug, thiserror::Error)] +pub enum ToolError { + #[error("Validation error: {0}")] + Validation(String), + #[error("Execution error: {0}")] + Execution(String), + #[error("Permission denied: {0}")] + Permission(String), + #[error("Not found: {0}")] + NotFound(String), +} +``` + +### 1.2 Tool Context + +```rust +// src/tools/context.rs +pub struct ToolContext { + pub session_id: String, + pub message_id: String, + pub agent: String, + pub abort: tokio::sync::watch::Receiver, + pub call_id: Option, + pub extra: Option, +} +``` + +### 1.3 Tool Trait + +```rust +// src/tools/mod.rs +use async_trait::async_trait; +use serde_json::Value; + +#[async_trait] +pub trait ToolHandler: Send + Sync { + fn definition(&self) -> Tool; + fn validate(&self, params: &Value) -> Result<(), ToolError>; + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result; +} +``` + +### 1.4 Tool Registry + +```rust +// src/tools/registry.rs +pub struct ToolRegistry { + tools: Arc>>>, +} + +impl ToolRegistry { + pub fn new() -> Self; + pub async fn register(&self, tool: Arc); + pub async fn get(&self, id: &str) -> Option>; + pub async fn list(&self) -> Vec; +} +``` + +## Phase 2: Core File Tools (Week 2-3) + +### 2.1 Tool: `glob` (Simplest) + +**Purpose:** Find files by glob pattern + +**Parameters:** +- `pattern` (string, required): Glob pattern +- `path` (string, optional): Base directory (default: cwd) + +**Implementation:** Use `glob` crate, sort by mtime, limit to 100 results + +**Dependencies:** `glob = "0.3"` + +### 2.2 Tool: `list` (Directory Tree) + +**Purpose:** List directory contents in tree format + +**Parameters:** +- `path` (string, required): Directory path +- `ignore` (array of strings, optional): Patterns to ignore + +**Implementation:** Recursive directory traversal with tree-style output (using ├── and └── connectors) + +### 2.3 Tool: `read` (File Reading) + +**Purpose:** Read file contents with pagination and binary detection + +**Parameters:** +- `file_path` (string, required): Path to file +- `offset` (integer, optional): Line offset (0-based, default: 0) +- `limit` (integer, optional): Max lines (default: 2000) + +**Implementation:** +- Check file size (<50MB) +- Binary detection (check for null bytes in first 8KB) +- Line-numbered output with pagination +- Truncation indicator if limit exceeded + +### 2.4 Tool: `write` (File Creation) + +**Purpose:** Create or overwrite files + +**Parameters:** +- `file_path` (string, required): Path to file +- `content` (string, required): Content to write + +**Implementation:** +- Create parent directories if needed +- Atomic write (write to temp file, then rename) +- Permission checks (block .env files by default) + +### 2.5 Tool: `bash` (Shell Execution) + +**Purpose:** Execute shell commands with security + +**Parameters:** +- `command` (string, required): Command to execute +- `timeout` (integer, optional): Timeout in seconds (default: 120) +- `workdir` (string, optional): Working directory +- `description` (string, optional): Human-readable description + +**Implementation:** +- Use `tokio::process::Command` +- Stream stdout/stderr +- Timeout handling with process kill +- Basic command validation (block dangerous commands) + +### 2.6 Tool: `edit` (Text Replacement) + +**Purpose:** Replace text in files with smart diffing + +**Parameters:** +- `file_path` (string, required): Path to file +- `old_string` (string, required): Text to replace +- `new_string` (string, required): Replacement text +- `replace_all` (boolean, optional): Replace all occurrences (default: false) + +**Implementation:** +- Exact match first +- Fuzzy matching with Levenshtein distance as fallback +- Line-trimmed matching +- Block anchor fallback for multi-line edits +- Generate diff output showing changes + +**Dependencies:** `strsim = "0.11"` (for Levenshtein distance) + +## Phase 3: Integration (Week 4) + +### 3.1 Agent Integration + +Update `src/agent/manager.rs` to: +- Initialize tool registry on startup +- Pass tool definitions to LLM context +- Parse tool calls from LLM responses +- Execute tools and return results + +### 3.2 LLM Context Format + +Tools should be exposed to LLM in OpenAI-compatible format: + +```json +{ + "type": "function", + "function": { + "name": "read", + "description": "Read file contents...", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "offset": {"type": "integer"}, + "limit": {"type": "integer"} + }, + "required": ["file_path"] + } + } +} +``` + +### 3.3 Response Handling + +Parse tool calls from LLM responses: +- OpenAI: `tool_calls` in assistant message +- Generic: Parse function call syntax from text + +Execute tools and format results back to LLM. + +## Phase 4: Testing & Polish (Week 5) + +### 4.1 Unit Tests + +Each tool needs tests for: +- Happy path +- Error cases (file not found, permission denied) +- Edge cases (empty files, binary files, large files) +- Parameter validation + +### 4.2 Integration Tests + +- End-to-end agent workflows +- Tool chaining (read -> edit -> read) +- Concurrent tool execution +- Error recovery + +### 4.3 Documentation + +- Tool usage examples +- Parameter reference +- Error message guide + +## File Structure + +``` +src/ + tools/ + mod.rs # Tool trait and exports + types.rs # Core types (Tool, ToolResult, ToolError) + context.rs # ToolContext + registry.rs # ToolRegistry + fs/ # File system tools + mod.rs + glob.rs + list.rs + read.rs + write.rs + edit.rs + bash.rs # Shell execution +``` + +## Dependencies to Add + +```toml +[dependencies] +glob = "0.3" +strsim = "0.11" # For fuzzy string matching in edit tool +tempfile = "3.0" # For atomic file writes +``` + +## Implementation Order + +1. **Week 1:** Core infrastructure (types, context, registry, trait) +2. **Week 2:** Simple tools (glob, list, read) +3. **Week 3:** Complex tools (write, bash, edit) +4. **Week 4:** Agent integration and LLM context +5. **Week 5:** Testing and documentation + +## Success Criteria + +- All 6 core tools implemented and tested +- Tools can be called from agent context +- LLM can discover and invoke tools +- Proper error handling and user feedback +- Binary file detection works +- Large file handling (truncation) +- Concurrent tool execution support + +## Future Enhancements (Post-MVP) + +- Search tools (grep, codesearch, websearch) +- External data tools (webfetch) +- Task coordination (subagent spawning) +- TODO management +- LSP integration +- Custom skill system +- Plugin architecture for user-defined tools From 200e2747474a81fbc1bcb12a74976e9b36748368 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 30 Jan 2026 21:37:19 +0800 Subject: [PATCH 6/9] feat: added AGENTS.md and CLAUDE.md discovery. --- src/command/handlers.rs | 92 +++----- src/llm/client.rs | 5 + src/persistence/prefs.rs | 6 +- src/prompt/mod.rs | 8 +- src/prompt/rules.rs | 358 ++++++++++++++++++++++++++++++ src/ui/components/chat.rs | 2 +- src/ui/components/wave_spinner.rs | 2 +- 7 files changed, 403 insertions(+), 70 deletions(-) create mode 100644 src/prompt/rules.rs diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 39caa3e..d6868cc 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -482,8 +482,6 @@ mod tests { raw: "/exit".to_string(), prefs_dao: None, active_model_id: None, - name: "exit".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = handle_exit(&parsed, &mut session_manager).await; @@ -493,13 +491,11 @@ mod tests { #[tokio::test] async fn test_handle_sessions() { let parsed = ParsedCommand { - name: "exit".to_string(), + name: "sessions".to_string(), args: vec![], - raw: "/exit".to_string(), + raw: "/sessions".to_string(), prefs_dao: None, active_model_id: None, - name: "sessions".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = handle_sessions(&parsed, &mut session_manager).await; @@ -519,13 +515,11 @@ mod tests { session_manager.create_session(Some("session-2".to_string())); let parsed = ParsedCommand { - name: "exit".to_string(), + name: "sessions".to_string(), args: vec![], - raw: "/exit".to_string(), + raw: "/sessions".to_string(), prefs_dao: None, active_model_id: None, - name: "sessions".to_string(), - args: vec![], }; let result = handle_sessions(&parsed, &mut session_manager).await; match result { @@ -546,13 +540,11 @@ mod tests { #[tokio::test] async fn test_handle_new_no_args() { let parsed = ParsedCommand { - name: "exit".to_string(), + name: "new".to_string(), args: vec![], - raw: "/exit".to_string(), + raw: "/new".to_string(), prefs_dao: None, active_model_id: None, - name: "new".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = handle_new(&parsed, &mut session_manager).await; @@ -567,13 +559,11 @@ mod tests { #[tokio::test] async fn test_handle_new_with_name() { let parsed = ParsedCommand { - name: "exit".to_string(), - args: vec![], - raw: "/exit".to_string(), - prefs_dao: None, - active_model_id: None, name: "new".to_string(), args: vec!["my-session".to_string()], + raw: "/new my-session".to_string(), + prefs_dao: None, + active_model_id: None, }; let mut session_manager = SessionManager::new(); let result = handle_new(&parsed, &mut session_manager).await; @@ -588,13 +578,11 @@ mod tests { #[tokio::test] async fn test_handle_home() { let parsed = ParsedCommand { - name: "exit".to_string(), + name: "home".to_string(), args: vec![], - raw: "/exit".to_string(), + raw: "/home".to_string(), prefs_dao: None, active_model_id: None, - name: "home".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = handle_new(&parsed, &mut session_manager).await; @@ -612,13 +600,11 @@ mod tests { let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { - name: "exit".to_string(), + name: "connect".to_string(), args: vec![], - raw: "/exit".to_string(), + raw: "/connect".to_string(), prefs_dao: None, active_model_id: None, - name: "connect".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = handle_connect(&parsed, &mut session_manager).await; @@ -645,13 +631,11 @@ mod tests { let _ = crate::config::ApiKeyConfig::cleanup_test(); let parsed = ParsedCommand { - name: "exit".to_string(), - args: vec![], - raw: "/exit".to_string(), - prefs_dao: None, - active_model_id: None, name: "connect".to_string(), args: vec!["nano-gpt".to_string()], + raw: "/connect nano-gpt".to_string(), + prefs_dao: None, + active_model_id: None, }; let mut session_manager = SessionManager::new(); let result = handle_connect(&parsed, &mut session_manager).await; @@ -670,13 +654,11 @@ mod tests { let _ = crate::config::ApiKeyConfig::cleanup_test(); let parsed = ParsedCommand { - name: "exit".to_string(), - args: vec![], - raw: "/exit".to_string(), - prefs_dao: None, - active_model_id: None, name: "connect".to_string(), args: vec!["nano-gpt".to_string(), "sk-test-key".to_string()], + raw: "/connect nano-gpt sk-test-key".to_string(), + prefs_dao: None, + active_model_id: None, }; let mut session_manager = SessionManager::new(); let result = handle_connect(&parsed, &mut session_manager).await; @@ -697,13 +679,11 @@ mod tests { let mut session_manager = SessionManager::new(); let parsed1 = ParsedCommand { - name: "exit".to_string(), - args: vec![], - raw: "/exit".to_string(), - prefs_dao: None, - active_model_id: None, name: "connect".to_string(), args: vec!["nano-gpt".to_string(), "sk-test-key".to_string()], + raw: "/connect nano-gpt sk-test-key".to_string(), + prefs_dao: None, + active_model_id: None, }; let result1 = handle_connect(&parsed1, &mut session_manager).await; match result1 { @@ -725,13 +705,11 @@ mod tests { async fn test_handle_models() { let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { - name: "exit".to_string(), + name: "models".to_string(), args: vec![], - raw: "/exit".to_string(), + raw: "/models".to_string(), prefs_dao: None, active_model_id: None, - name: "models".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = handle_models(&parsed, &mut session_manager).await; @@ -750,13 +728,11 @@ mod tests { async fn test_handle_models_with_filter() { let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { - name: "exit".to_string(), - args: vec![], - raw: "/exit".to_string(), - prefs_dao: None, - active_model_id: None, name: "models".to_string(), args: vec!["open".to_string()], + raw: "/models open".to_string(), + prefs_dao: None, + active_model_id: None, }; let mut session_manager = SessionManager::new(); let result = handle_models(&parsed, &mut session_manager).await; @@ -776,13 +752,11 @@ mod tests { let _ = crate::config::ApiKeyConfig::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { - name: "exit".to_string(), + name: "models".to_string(), args: vec![], - raw: "/exit".to_string(), + raw: "/models".to_string(), prefs_dao: None, active_model_id: None, - name: "models".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = handle_models(&parsed, &mut session_manager).await; @@ -820,8 +794,6 @@ mod tests { raw: "/exit".to_string(), prefs_dao: None, active_model_id: None, - name: "exit".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = registry.execute(&parsed, &mut session_manager).await; @@ -832,13 +804,11 @@ mod tests { async fn test_execute_unknown_command() { let registry = create_registry(); let parsed = ParsedCommand { - name: "exit".to_string(), + name: "unknown".to_string(), args: vec![], - raw: "/exit".to_string(), + raw: "/unknown".to_string(), prefs_dao: None, active_model_id: None, - name: "unknown".to_string(), - args: vec![], }; let mut session_manager = SessionManager::new(); let result = registry.execute(&parsed, &mut session_manager).await; diff --git a/src/llm/client.rs b/src/llm/client.rs index 26101bf..9c88b30 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -205,6 +205,11 @@ pub async fn stream_llm_with_cancellation( let response = match provider_kind { ProviderKind::OpenAICompatible => { + log(&format!( + "USING OPENAICOMPAT | {:?} | {:?}", + &base_url, &model + )); + let provider_config = OpenAICompatible::::builder() .base_url(&base_url) .api_key(&api_key) diff --git a/src/persistence/prefs.rs b/src/persistence/prefs.rs index fceb341..f37335e 100644 --- a/src/persistence/prefs.rs +++ b/src/persistence/prefs.rs @@ -171,10 +171,8 @@ mod tests { use super::*; fn setup_test_dao() -> PrefsDAO { - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("test.db"); - let conn = Connection::open(&db_path).unwrap(); - super::super::migrations::run_migrations(&conn).unwrap(); + let mut conn = Connection::open_in_memory().unwrap(); + super::super::migrations::run_migrations(&mut conn).unwrap(); PrefsDAO { conn } } diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 212c616..135f3bc 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -1,5 +1,7 @@ use crate::tools::ToolRegistry; +mod rules; + #[derive(Debug, Clone, PartialEq)] pub enum ProviderType { OpenAI, @@ -68,7 +70,7 @@ impl SystemPromptComposer { parts.push(self.get_tools_context(registry).await); } - parts.push(self.get_custom_instructions()); + parts.push(self.get_custom_instructions().await); parts .into_iter() @@ -261,8 +263,8 @@ Tool use: ) } - fn get_custom_instructions(&self) -> String { - String::new() + async fn get_custom_instructions(&self) -> String { + rules::get_custom_instructions(&self.working_directory).await } } diff --git a/src/prompt/rules.rs b/src/prompt/rules.rs new file mode 100644 index 0000000..73dd088 --- /dev/null +++ b/src/prompt/rules.rs @@ -0,0 +1,358 @@ +use std::path::{Path, PathBuf}; + +const DEFAULT_MAX_RULE_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone)] +struct RuleFile { + path: PathBuf, + contents: String, + truncated: bool, +} + +#[derive(Debug, Clone, Default)] +struct ResolvedRules { + local: Option, + global: Option, +} + +#[derive(Debug, Clone)] +struct ResolveOptions { + config_dir: Option, + home_dir: Option, + disable_claude_code: bool, + disable_claude_code_prompt: bool, + max_bytes: usize, +} + +impl Default for ResolveOptions { + fn default() -> Self { + Self { + config_dir: dirs::config_dir(), + home_dir: dirs::home_dir(), + disable_claude_code: env_truthy("CRABCODE_DISABLE_CLAUDE_CODE"), + disable_claude_code_prompt: env_truthy("CRABCODE_DISABLE_CLAUDE_CODE_PROMPT"), + max_bytes: DEFAULT_MAX_RULE_BYTES, + } + } +} + +pub async fn get_custom_instructions(working_directory: &str) -> String { + let rules = resolve_rules(Path::new(working_directory), ResolveOptions::default()).await; + format_rules_for_prompt(&rules) +} + +async fn resolve_rules(start_dir: &Path, opts: ResolveOptions) -> ResolvedRules { + let local = resolve_local_rules(start_dir, &opts).await; + let global = resolve_global_rules(&opts).await; + ResolvedRules { local, global } +} + +async fn resolve_local_rules(start_dir: &Path, opts: &ResolveOptions) -> Option { + let allow_claude_local = !opts.disable_claude_code; + + let mut dir = start_dir.to_path_buf(); + if !dir.is_dir() { + dir.pop(); + } + + loop { + let agents = dir.join("AGENTS.md"); + if file_exists(&agents).await { + if let Some(rule) = read_rule_file(&agents, opts.max_bytes).await { + return Some(rule); + } + } + + if allow_claude_local { + let claudemd = dir.join("CLAUDE.md"); + if file_exists(&claudemd).await { + if let Some(rule) = read_rule_file(&claudemd, opts.max_bytes).await { + return Some(rule); + } + } + } + + if !dir.pop() { + break; + } + } + + None +} + +async fn resolve_global_rules(opts: &ResolveOptions) -> Option { + if let Some(config_dir) = &opts.config_dir { + let global_agents = config_dir.join("crabcode").join("AGENTS.md"); + if file_exists(&global_agents).await { + if let Some(rule) = read_rule_file(&global_agents, opts.max_bytes).await { + return Some(rule); + } + } + } + + let allow_claude_global = !opts.disable_claude_code && !opts.disable_claude_code_prompt; + if allow_claude_global { + if let Some(home_dir) = &opts.home_dir { + let claude_global = home_dir.join(".claude").join("CLAUDE.md"); + if file_exists(&claude_global).await { + if let Some(rule) = read_rule_file(&claude_global, opts.max_bytes).await { + return Some(rule); + } + } + } + } + + None +} + +fn format_rules_for_prompt(rules: &ResolvedRules) -> String { + let mut out = String::new(); + + if let Some(local) = &rules.local { + push_rule_section(&mut out, local); + } + + if let Some(global) = &rules.global { + if !out.is_empty() { + out.push_str("\n\n---\n\n"); + } + push_rule_section(&mut out, global); + } + + out +} + +fn push_rule_section(out: &mut String, rule: &RuleFile) { + let path_str = display_path_best_effort(&rule.path); + out.push_str("Instructions from: "); + out.push_str(&path_str); + out.push('\n'); + out.push_str(&rule.contents); + + if rule.truncated { + if !out.ends_with('\n') { + out.push('\n'); + } + out.push_str("\n[crabcode] Note: instructions truncated due to size limit.\n"); + } else if !out.ends_with('\n') { + out.push('\n'); + } +} + +fn display_path_best_effort(path: &Path) -> String { + // Best-effort canonicalization; never fail prompt creation. + std::fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .display() + .to_string() +} + +async fn read_rule_file(path: &Path, max_bytes: usize) -> Option { + let bytes = tokio::fs::read(path).await.ok()?; + + let (slice, truncated) = if bytes.len() > max_bytes { + (&bytes[..max_bytes], true) + } else { + (&bytes[..], false) + }; + + let contents = String::from_utf8_lossy(slice).to_string(); + Some(RuleFile { + path: path.to_path_buf(), + contents, + truncated, + }) +} + +async fn file_exists(path: &Path) -> bool { + match tokio::fs::metadata(path).await { + Ok(m) => m.is_file(), + Err(_) => false, + } +} + +fn env_truthy(key: &str) -> bool { + let v = std::env::var(key).unwrap_or_default(); + if v.is_empty() { + return false; + } + matches!( + v.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" | "on" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("crabcode_{prefix}_{nanos}")) + } + + fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, contents).unwrap(); + } + + #[tokio::test] + async fn local_prefers_agents_over_claude_same_dir() { + let root = unique_temp_dir("rules1"); + fs::create_dir_all(&root).unwrap(); + write_file(&root.join("AGENTS.md"), "agents"); + write_file(&root.join("CLAUDE.md"), "claude"); + + let opts = ResolveOptions { + config_dir: None, + home_dir: None, + disable_claude_code: false, + disable_claude_code_prompt: false, + max_bytes: 1024, + }; + let rules = resolve_rules(&root, opts).await; + assert!(rules.local.is_some()); + assert_eq!(rules.local.unwrap().contents, "agents"); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn upward_traversal_finds_parent_rules() { + let root = unique_temp_dir("rules2"); + let child = root.join("a").join("b"); + fs::create_dir_all(&child).unwrap(); + write_file(&root.join("AGENTS.md"), "root agents"); + + let opts = ResolveOptions { + config_dir: None, + home_dir: None, + disable_claude_code: false, + disable_claude_code_prompt: false, + max_bytes: 1024, + }; + let rules = resolve_rules(&child, opts).await; + assert!(rules.local.is_some()); + assert_eq!(rules.local.unwrap().contents, "root agents"); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn first_match_wins_child_claude_beats_parent_agents() { + let root = unique_temp_dir("rules3"); + let child = root.join("child"); + fs::create_dir_all(&child).unwrap(); + write_file(&root.join("AGENTS.md"), "parent agents"); + write_file(&child.join("CLAUDE.md"), "child claude"); + + let opts = ResolveOptions { + config_dir: None, + home_dir: None, + disable_claude_code: false, + disable_claude_code_prompt: false, + max_bytes: 1024, + }; + let rules = resolve_rules(&child, opts).await; + assert!(rules.local.is_some()); + assert_eq!(rules.local.unwrap().contents, "child claude"); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn global_prefers_config_agents_over_claude() { + let root = unique_temp_dir("rules4"); + let config_dir = root.join("config"); + let home_dir = root.join("home"); + fs::create_dir_all(&config_dir).unwrap(); + fs::create_dir_all(&home_dir).unwrap(); + + write_file( + &config_dir.join("crabcode").join("AGENTS.md"), + "global agents", + ); + write_file(&home_dir.join(".claude").join("CLAUDE.md"), "global claude"); + + let opts = ResolveOptions { + config_dir: Some(config_dir), + home_dir: Some(home_dir), + disable_claude_code: false, + disable_claude_code_prompt: false, + max_bytes: 1024, + }; + let rules = resolve_rules(&root, opts).await; + assert!(rules.global.is_some()); + assert_eq!(rules.global.unwrap().contents, "global agents"); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn global_claude_disabled_by_prompt_flag() { + let root = unique_temp_dir("rules5"); + let home_dir = root.join("home"); + fs::create_dir_all(&home_dir).unwrap(); + write_file(&home_dir.join(".claude").join("CLAUDE.md"), "global claude"); + + let opts = ResolveOptions { + config_dir: None, + home_dir: Some(home_dir), + disable_claude_code: false, + disable_claude_code_prompt: true, + max_bytes: 1024, + }; + let rules = resolve_rules(&root, opts).await; + assert!(rules.global.is_none()); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn local_claude_disabled_by_global_flag() { + let root = unique_temp_dir("rules6"); + fs::create_dir_all(&root).unwrap(); + write_file(&root.join("CLAUDE.md"), "claude"); + + let opts = ResolveOptions { + config_dir: None, + home_dir: None, + disable_claude_code: true, + disable_claude_code_prompt: false, + max_bytes: 1024, + }; + let rules = resolve_rules(&root, opts).await; + assert!(rules.local.is_none()); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn truncates_large_files() { + let root = unique_temp_dir("rules7"); + fs::create_dir_all(&root).unwrap(); + let big = "a".repeat(2048); + write_file(&root.join("AGENTS.md"), &big); + + let opts = ResolveOptions { + config_dir: None, + home_dir: None, + disable_claude_code: false, + disable_claude_code_prompt: false, + max_bytes: 64, + }; + let rules = resolve_rules(&root, opts).await; + let rf = rules.local.unwrap(); + assert!(rf.truncated); + assert_eq!(rf.contents.len(), 64); + + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 2a2f5d6..1940cf9 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -92,7 +92,7 @@ impl Chat { } fn should_autoscroll(&self) -> bool { - self.autoscroll_enabled + self.autoscroll_enabled && !self.user_scrolled_up } pub fn add_user_message(&mut self, content: impl Into) { diff --git a/src/ui/components/wave_spinner.rs b/src/ui/components/wave_spinner.rs index a58264d..3ecbbc3 100644 --- a/src/ui/components/wave_spinner.rs +++ b/src/ui/components/wave_spinner.rs @@ -228,7 +228,7 @@ mod tests { #[test] fn test_wave_spinner_new() { let spinner = WaveSpinner::new(Color::Rgb(255, 165, 0)); - assert_eq!(spinner.frames.len(), 34); + assert_eq!(spinner.frames.len(), 32); assert_eq!(spinner.current_frame, 0); } From 975a7e9691fe0d4f4584965ca2ed6889c0b8afde Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 30 Jan 2026 21:47:58 +0800 Subject: [PATCH 7/9] feat: added /refreshmodels --- src/app.rs | 6 ++-- src/command/handlers.rs | 72 +++++++++++++++++++++++++++++++++++++++-- src/model/discovery.rs | 58 ++++++++++++++++++++------------- 3 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index 942cfb1..d9284a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -788,7 +788,9 @@ impl App { self.chat_state.chat.clear(); self.base_focus = BaseFocus::Home; self.session_manager.clear_current_session(); - } else if self.base_focus == BaseFocus::Home { + } else if self.base_focus == BaseFocus::Home + && parsed.name != "refreshmodels" + { self.base_focus = BaseFocus::Chat; } // Only add non-empty messages to the chat, and don't add exit message @@ -902,7 +904,7 @@ impl App { self.chat_state.chat.clear(); self.base_focus = BaseFocus::Home; self.session_manager.clear_current_session(); - } else if self.base_focus == BaseFocus::Home { + } else if self.base_focus == BaseFocus::Home && parsed.name != "refreshmodels" { self.base_focus = BaseFocus::Chat; } // Don't add exit message to chat diff --git a/src/command/handlers.rs b/src/command/handlers.rs index d6868cc..726f863 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -1,6 +1,6 @@ use crate::command::parser::ParsedCommand; use crate::command::registry::{Command, CommandResult, Registry}; -use crate::logging::log; +use crate::push_toast; use crate::session::manager::SessionManager; use chrono::{DateTime, Local, Utc}; use std::pin::Pin; @@ -425,6 +425,51 @@ pub fn handle_models<'a>( }) } +pub fn handle_refreshmodels<'a>( + _parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + Box::pin(async move { + let discovery = match crate::model::discovery::Discovery::new() { + Ok(d) => d, + Err(e) => { + push_toast(ratatui_toolkit::Toast::new( + format!("Failed to initialize model discovery: {}", e), + ratatui_toolkit::ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return CommandResult::Success(String::new()); + } + }; + + let providers = match discovery.refresh_cache().await { + Ok(p) => p, + Err(e) => { + push_toast(ratatui_toolkit::Toast::new( + format!("Failed to refresh models cache: {}", e), + ratatui_toolkit::ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return CommandResult::Success(String::new()); + } + }; + + let provider_count = providers.len(); + let model_count: usize = providers.values().map(|p| p.models.len()).sum(); + + push_toast(ratatui_toolkit::Toast::new( + format!( + "Models cache refreshed: {} providers, {} models", + provider_count, model_count + ), + ratatui_toolkit::ToastLevel::Info, + Some(std::time::Duration::from_secs(3)), + )); + + CommandResult::Success(String::new()) + }) +} + pub fn register_all_commands(registry: &mut Registry) { registry.register(Command { name: "exit".to_string(), @@ -461,6 +506,12 @@ pub fn register_all_commands(registry: &mut Registry) { description: "List available models".to_string(), handler: handle_models, }); + + registry.register(Command { + name: "refreshmodels".to_string(), + description: "Refresh the models.dev cache".to_string(), + handler: handle_refreshmodels, + }); } #[cfg(test)] @@ -772,17 +823,34 @@ mod tests { let _ = crate::model::discovery::Discovery::cleanup_test(); } + #[tokio::test] + async fn test_handle_refreshmodels() { + let _ = crate::model::discovery::Discovery::cleanup_test(); + let parsed = ParsedCommand { + name: "refreshmodels".to_string(), + args: vec![], + raw: "/refreshmodels".to_string(), + prefs_dao: None, + active_model_id: None, + }; + let mut session_manager = SessionManager::new(); + let result = handle_refreshmodels(&parsed, &mut session_manager).await; + assert_eq!(result, CommandResult::Success(String::new())); + let _ = crate::model::discovery::Discovery::cleanup_test(); + } + #[tokio::test] async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 6); + assert_eq!(names.len(), 7); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); assert!(names.contains(&"connect".to_string())); assert!(names.contains(&"models".to_string())); assert!(names.contains(&"home".to_string())); + assert!(names.contains(&"refreshmodels".to_string())); } #[tokio::test] diff --git a/src/model/discovery.rs b/src/model/discovery.rs index dd71799..b9edeb8 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -113,12 +113,8 @@ impl Discovery { cache_path, }) } else { - let cache_dir = dirs::home_dir() - .context("Could not find home directory")? - .join(".cache") - .join("crabcode"); - - fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?; + crate::persistence::ensure_cache_dir().context("Failed to create cache directory")?; + let cache_dir = crate::persistence::get_cache_dir(); let cache_path = cache_dir.join("models_dev_cache.json"); @@ -132,10 +128,37 @@ impl Discovery { } } + pub fn cache_path(&self) -> &PathBuf { + &self.cache_path + } + fn get_cache_path(&self) -> &PathBuf { &self.cache_path } + async fn fetch_from_api(&self) -> Result> { + let response = self + .client + .get(MODELS_DEV_API_URL) + .send() + .await + .context("Failed to fetch from models.dev API")?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Models.dev API returned error status: {}", + response.status() + )); + } + + let providers: HashMap = response + .json() + .await + .context("Failed to parse models.dev API response")?; + + Ok(providers) + } + fn load_from_cache(&self) -> Result>> { let cache_path = self.get_cache_path(); @@ -186,27 +209,16 @@ impl Discovery { return Ok(cached); } - let response = self - .client - .get(MODELS_DEV_API_URL) - .send() - .await - .context("Failed to fetch from models.dev API")?; + let providers = self.fetch_from_api().await?; - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Models.dev API returned error status: {}", - response.status() - )); - } + self.save_to_cache(&providers)?; - let providers: HashMap = response - .json() - .await - .context("Failed to parse models.dev API response")?; + Ok(providers) + } + pub async fn refresh_cache(&self) -> Result> { + let providers = self.fetch_from_api().await?; self.save_to_cache(&providers)?; - Ok(providers) } From b79b8311b6eddb86dfe0957b90f2e26d12d7ab36 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 30 Jan 2026 22:06:15 +0800 Subject: [PATCH 8/9] feat: added proper metrics (tps, ttft, total latency). --- _plans/metrics-plan.md | 98 +++++++++++++++++++ src/app.rs | 8 ++ src/persistence/conversions.rs | 16 ++++ src/persistence/db.rs | 29 ++++++ src/persistence/history.rs | 22 ++++- src/persistence/migrations.rs | 35 +++---- src/persistence/mod.rs | 2 + src/persistence/prompt_history.rs | 20 +--- src/session/types.rs | 14 +++ src/ui/components/chat.rs | 150 +++++++++++++++++++++++++----- 10 files changed, 326 insertions(+), 68 deletions(-) create mode 100644 _plans/metrics-plan.md create mode 100644 src/persistence/db.rs diff --git a/_plans/metrics-plan.md b/_plans/metrics-plan.md new file mode 100644 index 0000000..a70aa94 --- /dev/null +++ b/_plans/metrics-plan.md @@ -0,0 +1,98 @@ +# Metrics Plan (TTFT + TPS + Full Latency) + +Goal: store timing primitives (T0/T1/Tn) + output token count (N) so we can compute TTFT, TPS (decode-only), and full latency reliably and display them in message metadata. + +This repo currently stores only `duration_ms` and `token_count` on messages, where `duration_ms` effectively measures time since first token (Tn - T1). That yields a decode-phase TPS already, but we do not persist TTFT or the underlying primitives. + +## Definitions + +Primitives: + +- T0: timestamp when the API request is sent +- T1: timestamp when the first output token arrives +- Tn: timestamp when the last output token arrives +- N: total number of output tokens generated + +Derived metrics: + +- TTFT: T1 - T0 +- Decode duration: Tn - T1 +- Full latency (total time): Tn - T0 +- TPS (decode-only): N / (Tn - T1) + +## Data Model + +Persist primitives (do not persist derived metrics): + +- `t0_ms` (INTEGER, epoch milliseconds) +- `t1_ms` (INTEGER, epoch milliseconds) +- `tn_ms` (INTEGER, epoch milliseconds) +- `output_tokens` (INTEGER) + +Notes: + +- Persist as epoch milliseconds so values survive across process restarts. +- Derive TTFT/TPS/latency when rendering UI or exporting data. + +## Schema Changes + +Greenfield preference: update the baseline schema so a new DB includes these columns. + +- Add columns to `messages` table (assistant rows will use them; others may be NULL/0): + - `t0_ms INTEGER` + - `t1_ms INTEGER` + - `tn_ms INTEGER` + - `output_tokens INTEGER` + +Migration for existing installs (keeps codebase consistent even if we treat DB as new): + +- Add a new migration version that `ALTER TABLE messages` to add the above columns. + +Optional cleanup: + +- The `responses` table appears unused by current reads/writes; consider removing it from the baseline schema and/or formalizing its purpose. + +## Capture Points (Streaming Lifecycle) + +Capture these during a single model generation: + +- Record T0 when we start the provider request (right before creating/awaiting the stream). +- Record T1 when the first text/reasoning chunk is received (only once). +- Record Tn when the end-of-stream signal is received. +- Track N as output token count: + - Prefer provider-reported usage if available at end-of-stream. + - Otherwise fallback to the existing heuristic (chars/4) to keep metrics available. + +Implementation notes: + +- Use `Instant` for high-resolution runtime timing, but persist epoch millis for storage. +- Guard against edge cases: + - If T1 missing, TTFT/TPS should be absent. + - If (Tn - T1) <= 0, TPS should be absent or 0. + +## Wiring Through the App + +- Extend `crate::session::types::Message` to carry the primitives (or a `GenerationMetrics` struct): + - `t0_ms: Option` + - `t1_ms: Option` + - `tn_ms: Option` + - `output_tokens: Option` + +- Extend persistence structs + SQL: + - `src/persistence/history.rs` insert/select must include these fields. + - `src/persistence/conversions.rs` must map session message <-> persistence message. + +## UI / Metadata Display + +Update chat metadata to show explicit, spec-aligned metrics (for completed assistant messages): + +- TTFT: `(t1_ms - t0_ms)` +- TPS: `output_tokens / ((tn_ms - t1_ms) / 1000.0)` +- Total: `(tn_ms - t0_ms)` + +Keep the existing `duration_ms` display only for backwards compatibility if needed; otherwise prefer displaying total + TTFT + TPS. + +## Tests + +- Unit tests for metric calculations (normal case + missing fields + decode duration zero). +- UI formatting test for metadata (ensures stable ordering/precision). diff --git a/src/app.rs b/src/app.rs index d9284a6..ead3ba9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1267,6 +1267,9 @@ impl App { .append_reasoning_to_last_assistant(&reasoning); } crate::llm::ChunkMessage::End => { + // Capture end timestamp for TTFT/TPS/latency calculations. + self.chat_state.chat.mark_streaming_end(); + // Finalize streaming metrics from the chat's tracked values self.chat_state.chat.finalize_streaming_metrics(); @@ -1295,6 +1298,7 @@ impl App { } crate::llm::ChunkMessage::Failed(error) => { self.is_streaming = false; + self.chat_state.chat.mark_streaming_end(); self.chat_state.chat.finalize_streaming_metrics(); push_toast(ratatui_toolkit::Toast::new( format!("LLM error: {}", error), @@ -1309,6 +1313,7 @@ impl App { } crate::llm::ChunkMessage::Cancelled => { self.is_streaming = false; + self.chat_state.chat.mark_streaming_end(); self.chat_state.chat.finalize_streaming_metrics(); push_toast(ratatui_toolkit::Toast::new( "Streaming cancelled", @@ -1462,6 +1467,9 @@ impl App { last_msg.is_complete = false; } + // Initialize per-turn streaming timing primitives (T0). + self.chat_state.chat.begin_streaming_turn(); + let provider_name = self.provider_name.clone(); let model = self.model.clone(); let cwd = self.cwd.clone(); diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index f79d753..b2abe5c 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -38,6 +38,10 @@ impl From for Message { provider: msg.provider.clone(), agent_mode: msg.agent_mode.clone(), duration_ms: msg.duration_ms.map(|d| d as i64).unwrap_or(0), + t0_ms: msg.t0_ms.map(|v| v as i64), + t1_ms: msg.t1_ms.map(|v| v as i64), + tn_ms: msg.tn_ms.map(|v| v as i64), + output_tokens: msg.output_tokens.map(|v| v as i64), } } } @@ -93,6 +97,18 @@ impl TryFrom for SessionMessage { } else { None }, + t0_ms: msg + .t0_ms + .and_then(|v| if v > 0 { Some(v as u64) } else { None }), + t1_ms: msg + .t1_ms + .and_then(|v| if v > 0 { Some(v as u64) } else { None }), + tn_ms: msg + .tn_ms + .and_then(|v| if v > 0 { Some(v as u64) } else { None }), + output_tokens: msg + .output_tokens + .and_then(|v| if v > 0 { Some(v as usize) } else { None }), model: msg.model.clone(), provider: msg.provider.clone(), }) diff --git a/src/persistence/db.rs b/src/persistence/db.rs new file mode 100644 index 0000000..ba08a5a --- /dev/null +++ b/src/persistence/db.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use rusqlite::Connection; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::{ensure_data_dir, get_data_dir, migrations::run_migrations}; + +pub type DbConn = Arc>; + +fn init_db_conn() -> Result { + ensure_data_dir()?; + let db_path = get_data_dir().join("data.db"); + + let mut conn = Connection::open(&db_path)?; + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + run_migrations(&mut conn)?; + + Ok(Arc::new(Mutex::new(conn))) +} + +pub fn get_db_conn() -> Result { + static DB: OnceLock = OnceLock::new(); + if let Some(conn) = DB.get() { + return Ok(conn.clone()); + } + + let conn = init_db_conn()?; + let _ = DB.set(conn.clone()); + Ok(conn) +} diff --git a/src/persistence/history.rs b/src/persistence/history.rs index b4acd25..3125874 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -36,6 +36,10 @@ pub struct Message { pub provider: Option, pub agent_mode: Option, pub duration_ms: i64, + pub t0_ms: Option, + pub t1_ms: Option, + pub tn_ms: Option, + pub output_tokens: Option, } pub struct HistoryDAO { @@ -110,8 +114,11 @@ impl HistoryDAO { let parts_json = serde_json::to_string(&msg.parts)?; self.conn.execute( - "INSERT INTO messages (id, session_id, role, parts, tokens_used, model, provider, agent_mode, duration_ms) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO messages ( + id, session_id, role, parts, tokens_used, model, provider, agent_mode, duration_ms, + t0_ms, t1_ms, tn_ms, output_tokens + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ &msg.id, msg.session_id, @@ -122,6 +129,10 @@ impl HistoryDAO { msg.provider.as_deref(), msg.agent_mode.as_deref(), msg.duration_ms, + msg.t0_ms, + msg.t1_ms, + msg.tn_ms, + msg.output_tokens, ], )?; @@ -131,7 +142,8 @@ impl HistoryDAO { pub fn get_messages(&self, session_id: i64) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms + "SELECT id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, + t0_ms, t1_ms, tn_ms, output_tokens FROM messages WHERE session_id = ?1 ORDER BY timestamp ASC", )?; @@ -150,6 +162,10 @@ impl HistoryDAO { provider: row.get(7)?, agent_mode: row.get(8)?, duration_ms: row.get(9)?, + t0_ms: row.get(10)?, + t1_ms: row.get(11)?, + tn_ms: row.get(12)?, + output_tokens: row.get(13)?, }) })?; diff --git a/src/persistence/migrations.rs b/src/persistence/migrations.rs index bac1565..aacd922 100644 --- a/src/persistence/migrations.rs +++ b/src/persistence/migrations.rs @@ -8,10 +8,6 @@ pub fn run_migrations(db: &mut Connection) -> Result<()> { migrate_to_v1(db)?; } - if current_version < 2 { - migrate_to_v2(db)?; - } - Ok(()) } @@ -51,6 +47,11 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { model TEXT, provider TEXT, agent_mode TEXT, + duration_ms INTEGER DEFAULT 0, + t0_ms INTEGER, + t1_ms INTEGER, + tn_ms INTEGER, + output_tokens INTEGER, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); @@ -73,10 +74,17 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); + CREATE TABLE IF NOT EXISTS prompt_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt TEXT NOT NULL, + timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); CREATE INDEX IF NOT EXISTS idx_prefs_updated ON prefs(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_prompt_history_timestamp ON prompt_history(timestamp DESC); "#, )?; @@ -88,22 +96,3 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { tx.commit()?; Ok(()) } - -fn migrate_to_v2(db: &mut Connection) -> Result<()> { - let tx = db.transaction()?; - - // Add duration_ms column to messages table - tx.execute_batch( - r#" - ALTER TABLE messages ADD COLUMN duration_ms INTEGER DEFAULT 0; - "#, - )?; - - tx.execute( - "INSERT INTO migrations (version, applied_at) VALUES (2, strftime('%s', 'now'))", - params![], - )?; - - tx.commit()?; - Ok(()) -} diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index e123a03..7a309fd 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; pub mod auth; pub mod conversions; +pub mod db; pub mod history; pub mod migrations; pub mod prefs; @@ -11,6 +12,7 @@ pub mod providers; pub use auth::{AuthConfig, AuthDAO}; pub use conversions::persistence_to_session; +pub use db::{get_db_conn, DbConn}; pub use history::{HistoryDAO, Message, MessagePart, Session}; pub use prefs::PrefsDAO; pub use prompt_history::PromptHistoryCache; diff --git a/src/persistence/prompt_history.rs b/src/persistence/prompt_history.rs index 44372ed..c7d7bfa 100644 --- a/src/persistence/prompt_history.rs +++ b/src/persistence/prompt_history.rs @@ -3,7 +3,7 @@ use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; -use super::{ensure_data_dir, get_data_dir}; +use super::{ensure_data_dir, get_data_dir, migrations::run_migrations}; const MAX_HISTORY_SIZE: usize = 100; @@ -24,26 +24,12 @@ impl PromptHistoryDAO { ensure_data_dir()?; let db_path = data_dir.join("data.db"); - let conn = Connection::open(&db_path)?; - Self::ensure_table(&conn)?; + let mut conn = Connection::open(&db_path)?; + run_migrations(&mut conn)?; Ok(Self { conn }) } - fn ensure_table(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS prompt_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - prompt TEXT NOT NULL, - timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) - ); - CREATE INDEX IF NOT EXISTS idx_prompt_history_timestamp ON prompt_history(timestamp DESC); - "#, - )?; - Ok(()) - } - pub fn add_prompt(&self, prompt: &str) -> Result<()> { if prompt.trim().is_empty() { return Ok(()); diff --git a/src/session/types.rs b/src/session/types.rs index dfdaba2..4b81eda 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -18,6 +18,12 @@ pub struct Message { pub agent_mode: Option, pub token_count: Option, pub duration_ms: Option, + // Streaming timing primitives (epoch milliseconds) + // Used to derive TTFT/TPS/full latency. + pub t0_ms: Option, + pub t1_ms: Option, + pub tn_ms: Option, + pub output_tokens: Option, pub model: Option, pub provider: Option, } @@ -33,6 +39,10 @@ impl Message { agent_mode: None, token_count: None, duration_ms: None, + t0_ms: None, + t1_ms: None, + tn_ms: None, + output_tokens: None, model: None, provider: None, } @@ -64,6 +74,10 @@ impl Message { agent_mode: None, token_count: None, duration_ms: None, + t0_ms: None, + t1_ms: None, + tn_ms: None, + output_tokens: None, model: None, provider: None, } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 1940cf9..0ce9734 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -19,8 +19,13 @@ pub struct Chat { pub is_dragging_scrollbar: bool, pub content_height: usize, pub viewport_height: usize, + // Streaming metrics tracking (per streaming turn) pub streaming_start_time: Option, pub streaming_first_token_time: Option, + pub streaming_end_time: Option, + pub streaming_t0_ms: Option, + pub streaming_t1_ms: Option, + pub streaming_tn_ms: Option, pub streaming_token_count: usize, /// Whether to autoscroll to bottom when new content arrives /// Only autoscrolls if user is already near the bottom @@ -40,6 +45,14 @@ pub struct Chat { // Minimum elapsed time before showing tokens/s (250ms) const MIN_TOKENS_PER_SECOND_ELAPSED_MS: u128 = 250; +fn now_epoch_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + impl Chat { pub fn new() -> Self { Self { @@ -51,6 +64,10 @@ impl Chat { viewport_height: 0, streaming_start_time: None, streaming_first_token_time: None, + streaming_end_time: None, + streaming_t0_ms: None, + streaming_t1_ms: None, + streaming_tn_ms: None, streaming_token_count: 0, autoscroll_enabled: true, user_scrolled_up: false, @@ -71,6 +88,10 @@ impl Chat { viewport_height: 0, streaming_start_time: None, streaming_first_token_time: None, + streaming_end_time: None, + streaming_t0_ms: None, + streaming_t1_ms: None, + streaming_tn_ms: None, streaming_token_count: 0, autoscroll_enabled: true, user_scrolled_up: false, @@ -138,10 +159,13 @@ impl Chat { let now = std::time::Instant::now(); if self.streaming_start_time.is_none() { + // Fallback: streaming should normally be initialized by begin_streaming_turn(). self.streaming_start_time = Some(now); + self.streaming_t0_ms = Some(now_epoch_ms()); } if self.streaming_first_token_time.is_none() { self.streaming_first_token_time = Some(now); + self.streaming_t1_ms = Some(now_epoch_ms()); } // Estimate tokens: ~4 characters per token on average @@ -172,9 +196,11 @@ impl Chat { let now = std::time::Instant::now(); if self.streaming_start_time.is_none() { self.streaming_start_time = Some(now); + self.streaming_t0_ms = Some(now_epoch_ms()); } if self.streaming_first_token_time.is_none() { self.streaming_first_token_time = Some(now); + self.streaming_t1_ms = Some(now_epoch_ms()); } self.streaming_token_count += chunk_str.chars().count().max(1) / 4; if self.should_autoscroll() { @@ -190,9 +216,42 @@ impl Chat { self.content_height = 0; self.streaming_start_time = None; self.streaming_first_token_time = None; + self.streaming_end_time = None; + self.streaming_t0_ms = None; + self.streaming_t1_ms = None; + self.streaming_tn_ms = None; self.streaming_token_count = 0; } + pub fn begin_streaming_turn(&mut self) { + let now = std::time::Instant::now(); + let t0_ms = now_epoch_ms(); + + self.streaming_start_time = Some(now); + self.streaming_first_token_time = None; + self.streaming_end_time = None; + self.streaming_t0_ms = Some(t0_ms); + self.streaming_t1_ms = None; + self.streaming_tn_ms = None; + self.streaming_token_count = 0; + self.cached_tokens_per_sec = None; + self.last_tps_calculated = None; + + if let Some(msg) = self + .messages + .last_mut() + .filter(|m| m.role == MessageRole::Assistant && !m.is_complete) + { + msg.t0_ms = Some(t0_ms); + } + } + + pub fn mark_streaming_end(&mut self) { + let now = std::time::Instant::now(); + self.streaming_end_time = Some(now); + self.streaming_tn_ms = Some(now_epoch_ms()); + } + pub fn get_streaming_tokens_per_sec(&mut self) -> Option { // Throttle token calculation to prevent excessive updates during high-frequency renders // caused by mouse movement. Only recalculate every 100ms. @@ -237,25 +296,47 @@ impl Chat { } pub fn finalize_streaming_metrics(&mut self) { - if let Some(first_token_time) = self.streaming_first_token_time { - let duration_ms = first_token_time.elapsed().as_millis() as u64; - let token_count = self.streaming_token_count; - - if let Some(idx) = self - .messages - .iter() - .rposition(|m| m.role == MessageRole::Assistant) - { - if let Some(msg) = self.messages.get_mut(idx) { - msg.token_count = Some(token_count); - msg.duration_ms = Some(duration_ms); - } + let token_count = self.streaming_token_count; + + let t0_ms = self.streaming_t0_ms; + let t1_ms = self.streaming_t1_ms; + let tn_ms = self.streaming_tn_ms.or_else(|| { + // Fallback: if caller didn't mark end, compute an end timestamp now. + Some(now_epoch_ms()) + }); + + let decode_duration_ms = if let (Some(t1), Some(tn)) = + (self.streaming_first_token_time, self.streaming_end_time) + { + tn.duration_since(t1).as_millis() as u64 + } else if let Some(t1) = self.streaming_first_token_time { + t1.elapsed().as_millis() as u64 + } else { + 0 + }; + + if let Some(idx) = self + .messages + .iter() + .rposition(|m| m.role == MessageRole::Assistant) + { + if let Some(msg) = self.messages.get_mut(idx) { + msg.output_tokens = Some(token_count); + msg.token_count = Some(token_count); + msg.duration_ms = Some(decode_duration_ms); + msg.t0_ms = t0_ms; + msg.t1_ms = t1_ms; + msg.tn_ms = tn_ms; } } // Reset streaming state self.streaming_start_time = None; self.streaming_first_token_time = None; + self.streaming_end_time = None; + self.streaming_t0_ms = None; + self.streaming_t1_ms = None; + self.streaming_tn_ms = None; self.streaming_token_count = 0; self.streaming_renderer = None; self.streaming_message_idx = None; @@ -879,19 +960,45 @@ impl Chat { Style::default().fg(colors.text_weak), )); - // Duration and tokens/second if available (only show for completed messages) + // Timing + throughput metrics (only show for completed messages) if message.is_complete { - if let (Some(token_count), Some(duration_ms)) = + if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) { + let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0); + + let total_ms = tn.saturating_sub(t0); + let ttft_ms = t1.saturating_sub(t0); + let decode_ms = tn.saturating_sub(t1); + + let total_sec = total_ms as f64 / 1000.0; + let ttft_sec = ttft_ms as f64 / 1000.0; + + spans.push(Span::styled( + format!(" • {:.1}s", total_sec), + Style::default().fg(colors.text_weak), + )); + spans.push(Span::styled( + format!(" • ttft {:.1}s", ttft_sec), + Style::default().fg(colors.text_weak), + )); + + let tokens_per_sec = if decode_ms > 0 && output_tokens > 0 { + (output_tokens as f64) / (decode_ms as f64 / 1000.0) + } else { + 0.0 + }; + spans.push(Span::styled( + format!(" • {:.0}t/s", tokens_per_sec), + Style::default().fg(colors.text_weak), + )); + } else if let (Some(token_count), Some(duration_ms)) = (message.token_count, message.duration_ms) { - // Duration + // Backward-compatible fallback: duration_ms reflects decode time. let duration_sec = duration_ms as f64 / 1000.0; spans.push(Span::styled( format!(" • {:.1}s", duration_sec), Style::default().fg(colors.text_weak), )); - - // Tokens/second let tokens_per_sec = if duration_ms > 0 { (token_count as f64) / (duration_ms as f64 / 1000.0) } else { @@ -901,13 +1008,6 @@ impl Chat { format!(" • {:.0}t/s", tokens_per_sec), Style::default().fg(colors.text_weak), )); - } else { - // Show 0s and 0t/s when metrics not yet available - spans.push(Span::styled(" • 0s", Style::default().fg(colors.text_weak))); - spans.push(Span::styled( - " • 0t/s", - Style::default().fg(colors.text_weak), - )); } } From ab7d3da6af1e6225a213bbc3bd5c1032aa44e488 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 30 Jan 2026 22:06:24 +0800 Subject: [PATCH 9/9] chore: other plans added. --- _plans/AGENTS_AND_CLAUDE_RULES_LOADING.md | 103 ++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 _plans/AGENTS_AND_CLAUDE_RULES_LOADING.md diff --git a/_plans/AGENTS_AND_CLAUDE_RULES_LOADING.md b/_plans/AGENTS_AND_CLAUDE_RULES_LOADING.md new file mode 100644 index 0000000..8a2880c --- /dev/null +++ b/_plans/AGENTS_AND_CLAUDE_RULES_LOADING.md @@ -0,0 +1,103 @@ +# Plan: Read AGENTS.md / CLAUDE.md As Default Rules + +Goal: crabcode should automatically include project rule files in the system prompt, matching OpenCode/Claude Code conventions. + +## Desired Behavior (Compatibility) + +### Local (project) rules +- Starting from the current working directory, traverse upward to the filesystem root. +- At each directory, look for rule files in this order: + 1. `AGENTS.md` + 2. `CLAUDE.md` +- The first match wins (exactly one local rules file is loaded). + +### Global (user) rules +- Load a global rules file from `~/.config/crabcode/AGENTS.md`. +- If that does not exist, fall back to `~/.claude/CLAUDE.md`. +- The first match wins (exactly one global rules file is loaded). + +### Precedence +- Local rules and global rules are independent categories. +- If both exist, include both in the system prompt (local first, then global), each with a clear source label. +- If neither exists, omit the custom instructions section entirely. + +### Disabling Claude Code compatibility +- Mirror OpenCode-style env toggles for `.claude` fallbacks: + - `CRABCODE_DISABLE_CLAUDE_CODE=1` disables all `~/.claude` usage. + - `CRABCODE_DISABLE_CLAUDE_CODE_PROMPT=1` disables only `~/.claude/CLAUDE.md`. +- (Optional later) Also support `CRABCODE_DISABLE_CLAUDE_CODE_PROJECT=1` to disable local `CLAUDE.md` fallback while still allowing `AGENTS.md`. + +## System Prompt Output Format + +Insert rule content as a dedicated section so it is obvious and stable: + +```text +Instructions from: /abs/path/to/AGENTS.md + + +--- + +Instructions from: /abs/path/to/global/AGENTS.md + +``` + +Notes: +- Use absolute paths when possible. +- If canonicalization fails, print the best-effort path. +- Keep the raw markdown; do not reflow or rewrite. + +## Implementation Sketch (Rust) + +### 1) Add a small resolver module +- New file: `src/prompt/rules.rs` (or `src/rules.rs`). +- Responsibilities: + - Determine local rule file by upward traversal from `working_directory`. + - Determine global rule file by checking config directory, then `.claude` fallback (unless disabled). + - Read file contents with size limits. + - Return a struct describing sources. + +Proposed types: +- `struct RuleFile { path: PathBuf, contents: String }` +- `struct ResolvedRules { local: Option, global: Option }` + +### 2) Wire into the system prompt composer +- Update `src/prompt/mod.rs`: + - Make `get_custom_instructions` async (or call into an async helper) so it can use `tokio::fs`. + - Append rule sections after the tools context (or after env context) so they reliably influence behavior. + +Likely touch points: +- `src/prompt/mod.rs`: + - Replace `parts.push(self.get_custom_instructions());` with `parts.push(self.get_custom_instructions().await);` + - Implement `get_custom_instructions` to call the resolver with `self.working_directory`. + +### 3) Size limits and guardrails +- Enforce a max bytes limit per rule file (suggested: 64 KiB). +- If the file is larger: + - Read only the first N bytes; append a short truncation note. +- If the file is unreadable: + - Omit it (do not fail composing the system prompt). + +### 4) Tests +- Unit tests in `src/prompt/rules.rs` (or a `tests/` integration test) using temp dirs: + 1. Local precedence: `AGENTS.md` overrides `CLAUDE.md` in same directory. + 2. Upward traversal: rule found in parent directory is used. + 3. First match wins: if child has `CLAUDE.md` and parent has `AGENTS.md`, child `CLAUDE.md` wins (because it is found first in traversal). + 4. Global precedence: `~/.config/crabcode/AGENTS.md` overrides `~/.claude/CLAUDE.md`. + 5. Disable flags: `.claude` ignored when env var set. + 6. Truncation behavior for large files. + +Testability notes: +- Make the resolver accept injected “home/config dir” paths (or an override env var) to avoid writing into the real home directory during tests. + +## Acceptance Criteria + +- When a project has `AGENTS.md`, crabcode includes it in the system prompt automatically. +- When a project has only `CLAUDE.md`, crabcode includes it automatically. +- Local traversal and precedence match OpenCode behavior. +- Global fallback to `.claude` works and can be disabled. +- Missing/unreadable rules never crash crabcode. + +## Follow-ups (Optional) + +- Add `crabcode.json` support for `instructions: []` (like OpenCode) to include additional files/URLs. +- Expose a `/rules` or `/debug prompt` command to show which rule files were loaded.