From 8ff36fa9001666c40e79d3e1689a66653caf6184 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 20 Feb 2026 15:22:10 -0400 Subject: [PATCH] Close spec compliance gaps in coding-agent-loop Fix all gaps identified by spec review against docs/specs/coding-agent-loop-spec.md: - Replace placeholder system prompts with substantial provider-aligned prompts for OpenAI (codex-rs style), Anthropic (Claude Code style), and Gemini (gemini-cli style) covering identity, tool usage, and coding best practices - Fix ExecutionEnvironment trait: add offset/limit to read_file, depth to list_directory, path to glob, remove separate args from exec_command - LocalEnv: use /bin/bash -c, spawn process groups with setsid, SIGTERM to -pid, use ripgrep with grep fallback, add env var safelist - Add environment context block with XML tags including git branch, date, model, and knowledge cutoff fields - Capture git context snapshot (branch, status, recent commits) on init - Add user_instructions to SessionConfig, appended as final prompt layer - Emit AssistantTextStart before LLM calls, SESSION_END on abort path - Fix truncation messages to match spec wording exactly - Implement provider_options() for all profiles (reasoning, beta headers, safety settings) - Add Gemini-specific tools: read_many_files, list_dir, web_search, web_fetch - Wire spawn_agent max_turns parameter - Fall back to working_dir for project doc discovery outside git repos All 188 tests pass. Co-Authored-By: Claude Opus 4.6 --- crates/coding-agent-loop/src/execution_env.rs | 20 +- crates/coding-agent-loop/src/local_env.rs | 226 ++++++++++------- .../src/profiles/anthropic.rs | 124 +++++++--- .../coding-agent-loop/src/profiles/gemini.rs | 217 +++++++++++------ crates/coding-agent-loop/src/profiles/mod.rs | 53 ++-- .../coding-agent-loop/src/profiles/openai.rs | 124 +++++++--- crates/coding-agent-loop/src/project_docs.rs | 10 +- .../coding-agent-loop/src/provider_profile.rs | 7 +- crates/coding-agent-loop/src/session.rs | 73 ++++-- crates/coding-agent-loop/src/subagent.rs | 20 +- crates/coding-agent-loop/src/tool_registry.rs | 7 +- crates/coding-agent-loop/src/tools.rs | 229 +++++++++++++----- crates/coding-agent-loop/src/truncation.rs | 29 ++- 13 files changed, 778 insertions(+), 361 deletions(-) diff --git a/crates/coding-agent-loop/src/execution_env.rs b/crates/coding-agent-loop/src/execution_env.rs index 29b3c7d63..504ba82a1 100644 --- a/crates/coding-agent-loop/src/execution_env.rs +++ b/crates/coding-agent-loop/src/execution_env.rs @@ -25,14 +25,13 @@ pub struct GrepOptions { #[async_trait] pub trait ExecutionEnvironment: Send + Sync { - async fn read_file(&self, path: &str) -> Result; + async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result; async fn write_file(&self, path: &str, content: &str) -> Result<(), String>; async fn file_exists(&self, path: &str) -> Result; - async fn list_directory(&self, path: &str) -> Result, String>; + async fn list_directory(&self, path: &str, depth: Option) -> Result, String>; async fn exec_command( &self, command: &str, - args: &[String], timeout_ms: u64, working_dir: Option<&str>, env_vars: Option<&std::collections::HashMap>, @@ -43,7 +42,7 @@ pub trait ExecutionEnvironment: Send + Sync { path: &str, options: &GrepOptions, ) -> Result, String>; - async fn glob(&self, pattern: &str) -> Result, String>; + async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String>; async fn initialize(&self) -> Result<(), String>; async fn cleanup(&self) -> Result<(), String>; fn working_directory(&self) -> &str; @@ -60,7 +59,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for MockEnv { - async fn read_file(&self, _path: &str) -> Result { + async fn read_file(&self, _path: &str, _offset: Option, _limit: Option) -> Result { Ok("hello".into()) } async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> { @@ -69,7 +68,7 @@ mod tests { async fn file_exists(&self, _path: &str) -> Result { Ok(true) } - async fn list_directory(&self, _path: &str) -> Result, String> { + async fn list_directory(&self, _path: &str, _depth: Option) -> Result, String> { Ok(vec![DirEntry { name: "test.rs".into(), is_dir: false, @@ -79,7 +78,6 @@ mod tests { async fn exec_command( &self, _command: &str, - _args: &[String], _timeout_ms: u64, _working_dir: Option<&str>, _env_vars: Option<&std::collections::HashMap>, @@ -100,7 +98,7 @@ mod tests { ) -> Result, String> { Ok(vec!["match".into()]) } - async fn glob(&self, _pattern: &str) -> Result, String> { + async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result, String> { Ok(vec!["file.rs".into()]) } async fn initialize(&self) -> Result<(), String> { @@ -123,14 +121,14 @@ mod tests { #[tokio::test] async fn mock_env_read_file() { let env: Arc = Arc::new(MockEnv); - let result = env.read_file("test.rs").await.unwrap(); + let result = env.read_file("test.rs", None, None).await.unwrap(); assert_eq!(result, "hello"); } #[tokio::test] async fn mock_env_exec_command() { let env: Arc = Arc::new(MockEnv); - let result = env.exec_command("echo", &[], 5000, None, None).await.unwrap(); + let result = env.exec_command("echo", 5000, None, None).await.unwrap(); assert_eq!(result.exit_code, 0); assert!(!result.timed_out); } @@ -138,7 +136,7 @@ mod tests { #[tokio::test] async fn mock_env_list_directory() { let env: Arc = Arc::new(MockEnv); - let entries = env.list_directory("/tmp").await.unwrap(); + let entries = env.list_directory("/tmp", None).await.unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "test.rs"); assert!(!entries[0].is_dir); diff --git a/crates/coding-agent-loop/src/local_env.rs b/crates/coding-agent-loop/src/local_env.rs index c3436b55d..bd387a1c6 100644 --- a/crates/coding-agent-loop/src/local_env.rs +++ b/crates/coding-agent-loop/src/local_env.rs @@ -15,20 +15,15 @@ impl LocalExecutionEnvironment { Self { working_directory } } - fn format_line_numbered(content: &str) -> String { - use std::fmt::Write; - let lines: Vec<&str> = content.lines().collect(); - let width = lines.len().to_string().len().max(1); - let mut result = String::new(); - let mut line_num = 1; - for line in &lines { - let _ = writeln!(result, "{line_num:>width$} | {line}"); - line_num += 1; - } - result - } + const ENV_SAFELIST: &'static [&'static str] = &[ + "PATH", "HOME", "USER", "SHELL", "LANG", "TERM", "TMPDIR", + "GOPATH", "CARGO_HOME", "NVM_DIR", + ]; fn should_filter_env_var(key: &str) -> bool { + if Self::ENV_SAFELIST.contains(&key) { + return false; + } let lower = key.to_lowercase(); lower.ends_with("_api_key") || lower.ends_with("_secret") @@ -49,12 +44,25 @@ impl LocalExecutionEnvironment { #[async_trait] impl ExecutionEnvironment for LocalExecutionEnvironment { - async fn read_file(&self, path: &str) -> Result { + async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result { let full_path = self.resolve_path(path); let content = tokio::fs::read_to_string(&full_path) .await .map_err(|e| format!("Failed to read {}: {e}", full_path.display()))?; - Ok(Self::format_line_numbered(&content)) + + let all_lines: Vec<&str> = content.lines().collect(); + let skip = offset.unwrap_or(0); + let take = limit.unwrap_or(all_lines.len()); + let selected: Vec<&str> = all_lines.into_iter().skip(skip).take(take).collect(); + + use std::fmt::Write; + let width = (skip + selected.len()).to_string().len().max(1); + let mut result = String::new(); + for (i, line) in selected.iter().enumerate() { + let line_num = skip + i + 1; + let _ = writeln!(result, "{line_num:>width$} | {line}"); + } + Ok(result) } async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { @@ -74,40 +82,57 @@ impl ExecutionEnvironment for LocalExecutionEnvironment { Ok(full_path.exists()) } - async fn list_directory(&self, path: &str) -> Result, String> { + async fn list_directory(&self, path: &str, depth: Option) -> Result, String> { let full_path = self.resolve_path(path); - let mut entries = Vec::new(); - let mut read_dir = tokio::fs::read_dir(&full_path) - .await - .map_err(|e| format!("Failed to read directory {}: {e}", full_path.display()))?; + let max_depth = depth.unwrap_or(1); - while let Some(entry) = read_dir - .next_entry() - .await - .map_err(|e| format!("Failed to read entry: {e}"))? - { - let metadata = entry - .metadata() - .await - .map_err(|e| format!("Failed to read metadata: {e}"))?; - entries.push(DirEntry { - name: entry.file_name().to_string_lossy().into_owned(), - is_dir: metadata.is_dir(), - size: if metadata.is_file() { - Some(metadata.len()) + fn list_recursive( + base: &std::path::Path, + prefix: &str, + current_depth: usize, + max_depth: usize, + entries: &mut Vec, + ) -> Result<(), String> { + let mut dir_entries: Vec = std::fs::read_dir(base) + .map_err(|e| format!("Failed to read directory {}: {e}", base.display()))? + .filter_map(|e| e.ok()) + .collect(); + dir_entries.sort_by_key(|e| e.file_name()); + + for entry in dir_entries { + let metadata = entry + .metadata() + .map_err(|e| format!("Failed to read metadata: {e}"))?; + let name = if prefix.is_empty() { + entry.file_name().to_string_lossy().into_owned() } else { - None - }, - }); + format!("{prefix}/{}", entry.file_name().to_string_lossy()) + }; + let is_dir = metadata.is_dir(); + entries.push(DirEntry { + name: name.clone(), + is_dir, + size: if metadata.is_file() { + Some(metadata.len()) + } else { + None + }, + }); + if is_dir && current_depth + 1 < max_depth { + list_recursive(&entry.path(), &name, current_depth + 1, max_depth, entries)?; + } + } + Ok(()) } - entries.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut entries = Vec::new(); + list_recursive(&full_path, "", 0, max_depth, &mut entries)?; Ok(entries) } async fn exec_command( &self, command: &str, - args: &[String], timeout_ms: u64, working_dir: Option<&str>, env_vars: Option<&std::collections::HashMap>, @@ -129,17 +154,26 @@ impl ExecutionEnvironment for LocalExecutionEnvironment { std::path::PathBuf::from, ); - let mut cmd = Command::new(command); - cmd.args(args) + let mut cmd = Command::new("/bin/bash"); + cmd.arg("-c") + .arg(command) .current_dir(&effective_dir) .env_clear() .envs(filtered_env) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + libc::setpgid(0, 0); + Ok(()) + }); + } + let mut child = cmd .spawn() - .map_err(|e| format!("Failed to spawn {command}: {e}"))?; + .map_err(|e| format!("Failed to spawn command: {e}"))?; let timeout_duration = std::time::Duration::from_millis(timeout_ms); @@ -149,12 +183,13 @@ impl ExecutionEnvironment for LocalExecutionEnvironment { status_result.map_err(|e| format!("Failed to wait for process: {e}"))?; (false, status.code().unwrap_or(-1)) } else { - // SIGTERM first, then SIGKILL after 2 seconds + // SIGTERM the process group first, then SIGKILL after 2 seconds #[cfg(unix)] if let Some(pid) = child.id() { #[allow(clippy::cast_possible_wrap)] unsafe { - libc::kill(pid as i32, libc::SIGTERM); + // Negative pid sends signal to the entire process group + libc::kill(-(pid as i32), libc::SIGTERM); } // Wait 2 seconds for graceful shutdown if tokio::time::timeout( @@ -207,37 +242,71 @@ impl ExecutionEnvironment for LocalExecutionEnvironment { ) -> Result, String> { let full_path = self.resolve_path(path); - let mut args = vec!["-rn".to_string()]; - if options.case_insensitive { - args.push("-i".into()); - } - if let Some(ref glob_filter) = options.glob_filter { - args.push("--include".into()); - args.push(glob_filter.clone()); - } - if let Some(max) = options.max_results { - args.push("-m".into()); - args.push(max.to_string()); - } - args.push(pattern.into()); - args.push(full_path.to_string_lossy().into_owned()); + // Try rg (ripgrep) first, fall back to grep + let use_rg = std::process::Command::new("rg") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok(); - let output = std::process::Command::new("grep") - .args(&args) - .output() - .map_err(|e| format!("Failed to run grep: {e}"))?; + let output = if use_rg { + let mut args = vec!["-n".to_string()]; + if options.case_insensitive { + args.push("-i".into()); + } + if let Some(ref glob_filter) = options.glob_filter { + args.push("--glob".into()); + args.push(glob_filter.clone()); + } + if let Some(max) = options.max_results { + args.push("-m".into()); + args.push(max.to_string()); + } + args.push(pattern.into()); + args.push(full_path.to_string_lossy().into_owned()); + + std::process::Command::new("rg") + .args(&args) + .output() + .map_err(|e| format!("Failed to run rg: {e}"))? + } else { + let mut args = vec!["-rn".to_string()]; + if options.case_insensitive { + args.push("-i".into()); + } + if let Some(ref glob_filter) = options.glob_filter { + args.push("--include".into()); + args.push(glob_filter.clone()); + } + if let Some(max) = options.max_results { + args.push("-m".into()); + args.push(max.to_string()); + } + args.push(pattern.into()); + args.push(full_path.to_string_lossy().into_owned()); + + std::process::Command::new("grep") + .args(&args) + .output() + .map_err(|e| format!("Failed to run grep: {e}"))? + }; let stdout = String::from_utf8_lossy(&output.stdout); let results: Vec = stdout.lines().map(String::from).filter(|l| !l.is_empty()).collect(); Ok(results) } - async fn glob(&self, pattern: &str) -> Result, String> { - // Use find + fnmatch-style pattern via shell glob expansion + async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { + let base_dir = path.map_or_else( + || self.working_directory.clone(), + std::path::PathBuf::from, + ); + let full_pattern = if Path::new(pattern).is_absolute() { pattern.to_string() } else { - format!("{}/{pattern}", self.working_directory.display()) + format!("{}/{pattern}", base_dir.display()) }; // Use shell globbing via ls @@ -327,7 +396,7 @@ mod tests { std::fs::write(dir.join("test.txt"), "hello\nworld\nfoo").unwrap(); let env = LocalExecutionEnvironment::new(dir.clone()); - let result = env.read_file("test.txt").await.unwrap(); + let result = env.read_file("test.txt", None, None).await.unwrap(); assert_eq!(result, "1 | hello\n2 | world\n3 | foo\n"); std::fs::remove_dir_all(&dir).unwrap(); @@ -340,7 +409,7 @@ mod tests { std::fs::write(dir.join("padded.txt"), content.trim_end()).unwrap(); let env = LocalExecutionEnvironment::new(dir.clone()); - let result = env.read_file("padded.txt").await.unwrap(); + let result = env.read_file("padded.txt", None, None).await.unwrap(); assert!(result.starts_with(" 1 | line 1\n")); assert!(result.contains("12 | line 12\n")); @@ -351,7 +420,7 @@ mod tests { async fn read_file_not_found() { let dir = temp_dir(); let env = LocalExecutionEnvironment::new(dir.clone()); - let result = env.read_file("nonexistent.txt").await; + let result = env.read_file("nonexistent.txt", None, None).await; assert!(result.is_err()); std::fs::remove_dir_all(&dir).unwrap(); } @@ -393,7 +462,7 @@ mod tests { std::fs::create_dir(dir.join("c_dir")).unwrap(); let env = LocalExecutionEnvironment::new(dir.clone()); - let entries = env.list_directory(".").await.unwrap(); + let entries = env.list_directory(".", None).await.unwrap(); assert_eq!(entries.len(), 3); assert_eq!(entries[0].name, "a.txt"); @@ -411,7 +480,7 @@ mod tests { let dir = temp_dir(); let env = LocalExecutionEnvironment::new(dir.clone()); let result = env - .exec_command("echo", &["hello".into()], 5000, None, None) + .exec_command("echo hello", 5000, None, None) .await .unwrap(); @@ -427,7 +496,7 @@ mod tests { let dir = temp_dir(); let env = LocalExecutionEnvironment::new(dir.clone()); let result = env - .exec_command("sh", &["-c".into(), "exit 42".into()], 5000, None, None) + .exec_command("exit 42", 5000, None, None) .await .unwrap(); @@ -441,7 +510,7 @@ mod tests { let dir = temp_dir(); let env = LocalExecutionEnvironment::new(dir.clone()); let result = env - .exec_command("sleep", &["10".into()], 200, None, None) + .exec_command("sleep 10", 200, None, None) .await .unwrap(); @@ -455,7 +524,7 @@ mod tests { let dir = temp_dir(); let env = LocalExecutionEnvironment::new(dir.clone()); let result = env - .exec_command("sh", &["-c".into(), "echo err >&2".into()], 5000, None, None) + .exec_command("echo err >&2", 5000, None, None) .await .unwrap(); @@ -602,21 +671,10 @@ mod tests { std::fs::write(dir.join("c.txt"), "").unwrap(); let env = LocalExecutionEnvironment::new(dir.clone()); - let results = env.glob("*.rs").await.unwrap(); + let results = env.glob("*.rs", None).await.unwrap(); assert_eq!(results.len(), 2); std::fs::remove_dir_all(&dir).unwrap(); } - #[test] - fn format_line_numbered_empty() { - let result = LocalExecutionEnvironment::format_line_numbered(""); - assert_eq!(result, ""); - } - - #[test] - fn format_line_numbered_single_line() { - let result = LocalExecutionEnvironment::format_line_numbered("hello"); - assert_eq!(result, "1 | hello\n"); - } } diff --git a/crates/coding-agent-loop/src/profiles/anthropic.rs b/crates/coding-agent-loop/src/profiles/anthropic.rs index c8a3d2f2d..e3321a7c6 100644 --- a/crates/coding-agent-loop/src/profiles/anthropic.rs +++ b/crates/coding-agent-loop/src/profiles/anthropic.rs @@ -96,31 +96,76 @@ impl ProviderProfile for AnthropicProfile { }; format!( - "You are Claude, an AI coding assistant by Anthropic. \ - You help users with software engineering tasks including solving bugs, \ - adding new functionality, refactoring code, and explaining code.\n\n\ - {env_block}\n\n\ - # Tools\n\ - Use the provided tools to interact with the codebase and environment.\n\n\ - ## read_file\n\ - Read files before editing them. Use offset/limit for large files.\n\n\ - ## edit_file\n\ - The old_string must be an exact match of existing text and must be unique in the file. \ - If old_string matches multiple locations, provide more surrounding context to make it unique. \ - Prefer editing existing files over creating new ones.\n\n\ - ## write_file\n\ - Use write_file only when creating new files. Prefer edit_file for modifying existing files.\n\n\ - ## shell\n\ - Use for running commands, tests, and builds. Default timeout is 120 seconds.\n\n\ - ## grep\n\ - Search file contents with regex patterns. Supports output modes: content, files_with_matches, count.\n\n\ - ## glob\n\ - Find files by name pattern. Results sorted by modification time (newest first).\n\n\ - # Coding Best Practices\n\ - Write clean, maintainable code. Handle errors appropriately. \ - Follow existing code conventions in the project.\ - {docs_section}\ - {user_section}" + "\ +You are Claude, an AI coding assistant made by Anthropic. You help users with software \ +engineering tasks including solving bugs, adding new functionality, refactoring code, \ +explaining code, and more. + +You are an interactive agent that helps users with software engineering tasks. Use the \ +instructions below and the tools available to you to assist the user. + +{env_block} + +# Doing Tasks + +- The user will primarily request you to perform software engineering tasks. These may include \ +solving bugs, adding new functionality, refactoring code, explaining code, and more. +- In general, do not propose changes to code you have not read. If a user asks about or wants \ +you to modify a file, read it first. Understand existing code before suggesting modifications. +- Do not create files unless they are absolutely necessary for achieving your goal. Generally \ +prefer editing an existing file to creating a new one, as this prevents file bloat and builds \ +on existing work more effectively. +- If your approach is blocked, do not attempt to brute force your way to the outcome. Consider \ +alternative approaches or other ways you might unblock yourself. +- Avoid over-engineering. Only make changes that are directly requested or clearly necessary. \ +Keep solutions simple and focused. +- Do not add features, refactor code, or make improvements beyond what was asked. +- Do not add error handling, fallbacks, or validation for scenarios that cannot happen. Trust \ +internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). +- Avoid backwards-compatibility hacks. If you are certain something is unused, delete it completely. + +# Tools + +Use the provided tools to interact with the codebase and environment. Do NOT use the shell \ +tool to run commands when a relevant dedicated tool is provided: +- To read files use read_file instead of cat, head, tail, or sed. +- To edit files use edit_file instead of sed or awk. +- To create files use write_file instead of cat with heredoc or echo redirection. +- To search for files use glob instead of find or ls. +- To search the content of files use grep instead of grep or rg. + +## read_file +Read files before editing them. Always read a file before attempting to edit it. Use \ +offset/limit for large files. Reading a file you have not read before is always appropriate. + +## edit_file +Performs exact string replacements in files. The old_string must be an exact match of \ +existing text and must be unique in the file. If old_string matches multiple locations, provide \ +more surrounding context to make it unique. Prefer editing existing files over creating new ones. \ +When editing text, ensure you preserve the exact indentation as it appears in the file. + +## write_file +Use write_file only when creating new files. Prefer edit_file for modifying existing files. \ +Always prefer editing existing files in the codebase over creating new ones. + +## shell +Use for running commands, tests, and builds. Default timeout is 120 seconds. Use timeout_ms \ +parameter for longer-running commands. + +## grep +Search file contents with regex patterns. Supports output modes: content, files_with_matches, count. \ +Use this for searching the content of files rather than using shell grep or rg. + +## glob +Find files by name pattern. Results sorted by modification time (newest first). Use this for \ +finding files rather than using shell find or ls commands. + +# Coding Best Practices + +Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \ +in the project. Keep changes minimal and focused on the task.\ +{docs_section}\ +{user_section}" ) } @@ -131,7 +176,7 @@ impl ProviderProfile for AnthropicProfile { fn provider_options(&self) -> Option { Some(serde_json::json!({ "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] + "beta_headers": ["interleaved-thinking-2025-05-14", "extended-thinking-2025-04-14", "max-tokens-3-5-sonnet-2025-04-14"] } })) } @@ -163,7 +208,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for TestEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _: Option, _: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -172,13 +217,12 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _: &str, - _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, @@ -199,7 +243,7 @@ mod tests { ) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -240,8 +284,8 @@ mod tests { let profile = AnthropicProfile::new("claude-sonnet-4-20250514"); let env = TestEnv; let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); - assert!(prompt.contains("You are Claude, an AI coding assistant by Anthropic")); - assert!(prompt.contains("# Environment")); + assert!(prompt.contains("You are Claude, an AI coding assistant made by Anthropic")); + assert!(prompt.contains("")); assert!(prompt.contains("linux")); assert!(prompt.contains("/home/test")); assert!(prompt.contains("# Tools")); @@ -287,12 +331,16 @@ mod tests { is_git_repo: true, date: "2026-02-20".into(), model_name: "claude-opus-4-6".into(), + knowledge_cutoff: "May 2025".into(), + git_status_short: None, + git_recent_commits: None, }; let prompt = profile.build_system_prompt(&env, &ctx, &[], None); assert!(prompt.contains("Git branch: feature-branch")); - assert!(prompt.contains("Is a git repository: true")); - assert!(prompt.contains("Date: 2026-02-20")); + assert!(prompt.contains("Is git repository: true")); + assert!(prompt.contains("Today's date: 2026-02-20")); assert!(prompt.contains("Model: claude-opus-4-6")); + assert!(prompt.contains("Knowledge cutoff: May 2025")); } #[test] @@ -336,6 +384,14 @@ mod tests { headers.contains(&"interleaved-thinking-2025-05-14"), "beta_headers should contain interleaved-thinking header" ); + assert!( + headers.contains(&"extended-thinking-2025-04-14"), + "beta_headers should contain extended-thinking header" + ); + assert!( + headers.contains(&"max-tokens-3-5-sonnet-2025-04-14"), + "beta_headers should contain max-tokens header" + ); } #[test] diff --git a/crates/coding-agent-loop/src/profiles/gemini.rs b/crates/coding-agent-loop/src/profiles/gemini.rs index 0fd930f34..64f369ae4 100644 --- a/crates/coding-agent-loop/src/profiles/gemini.rs +++ b/crates/coding-agent-loop/src/profiles/gemini.rs @@ -4,53 +4,17 @@ use crate::subagent::{ make_close_agent_tool, make_send_input_tool, make_spawn_agent_tool, make_wait_tool, SessionFactory, SubAgentManager, }; -use crate::tool_registry::{RegisteredTool, ToolRegistry}; +use crate::tool_registry::ToolRegistry; use crate::tools::{ - make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool, make_shell_tool, - make_write_file_tool, + make_edit_file_tool, make_glob_tool, make_grep_tool, make_list_dir_tool, + make_read_file_tool, make_read_many_files_tool, make_shell_tool, make_web_fetch_tool, + make_web_search_tool, make_write_file_tool, }; use std::sync::Arc; use unified_llm::types::ToolDefinition; use super::{build_env_context_block_with, EnvContext}; -fn make_list_dir_tool() -> RegisteredTool { - RegisteredTool { - definition: ToolDefinition { - name: "list_dir".into(), - description: "List directory contents with depth control".into(), - parameters: serde_json::json!({ - "type": "object", - "properties": { - "path": {"type": "string", "description": "Directory path to list"}, - "depth": {"type": "integer", "description": "Depth of listing (default 1)"} - }, - "required": ["path"] - }), - }, - executor: Arc::new(|args, env| { - Box::pin(async move { - let path = args["path"] - .as_str() - .ok_or_else(|| "path is required".to_string())?; - - let entries = env.list_directory(path).await?; - let lines: Vec = entries - .iter() - .map(|e| { - if e.is_dir { - format!("{}/", e.name) - } else { - e.name.clone() - } - }) - .collect(); - Ok(lines.join("\n")) - }) - }), - } -} - pub struct GeminiProfile { model: String, registry: ToolRegistry, @@ -62,12 +26,15 @@ impl GeminiProfile { let mut registry = ToolRegistry::new(); registry.register(make_read_file_tool()); + registry.register(make_read_many_files_tool()); registry.register(make_write_file_tool()); registry.register(make_edit_file_tool()); registry.register(make_shell_tool()); registry.register(make_grep_tool()); registry.register(make_glob_tool()); registry.register(make_list_dir_tool()); + registry.register(make_web_search_tool()); + registry.register(make_web_fetch_tool()); Self { model: model.into(), @@ -129,22 +96,121 @@ impl ProviderProfile for GeminiProfile { }; format!( - "You are a coding assistant powered by Gemini. You help users with software engineering tasks \ -including solving bugs, adding new functionality, refactoring code, and explaining code.\n\n\ -{env_block}\n\n\ -# Tools\n\ -Use the provided tools to interact with the codebase and environment.\n\n\ -- read_file: Read files to understand code before modifying. Use offset/limit for large files.\n\ -- edit_file: Use search-and-replace editing. The old_string must exactly match existing text. Prefer editing existing files over creating new ones.\n\ -- write_file: Use for creating new files or completely rewriting files.\n\ -- shell: Execute shell commands. Default timeout is 10 seconds.\n\ -- grep: Search file contents with regex patterns.\n\ -- glob: Find files by name pattern. Results sorted by modification time.\n\ -- list_dir: List directory contents with depth control.\n\n\ -# Project Docs\n\ -Look for GEMINI.md and AGENTS.md files in the project for project-specific instructions.\n\n\ -# Coding Best Practices\n\ -Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions in the project.\ + "\ +You are Gemini CLI, an interactive CLI agent specializing in software engineering tasks \ +including solving bugs, adding new functionality, refactoring code, and explaining code. \ +Your primary goal is to help users safely and effectively. + +# Core Mandates + +## Security and System Integrity +- Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \ +`.env` files, `.git`, and system configuration folders. +- Do not stage or commit changes unless specifically requested by the user. + +## Engineering Standards +- Instructions found in GEMINI.md and AGENTS.md files are foundational mandates. They take \ +absolute precedence over the general workflows and tool defaults described in this system prompt. +- Rigorously adhere to existing workspace conventions, architectural patterns, and style. \ +Analyze surrounding files, tests, and configuration to ensure your changes are seamless, \ +idiomatic, and consistent with the local context. +- NEVER assume a library/framework is available. Verify its established usage within the \ +project before employing it. +- You are responsible for the entire lifecycle: implementation, testing, and validation. \ +A task is only complete when the behavioral correctness of the change has been verified. +- ALWAYS search for and update related tests after making a code change. + +## Context Efficiency +Be strategic in your use of the available tools to minimize unnecessary context usage while \ +still providing the best answer you can. +- Combine turns whenever possible by utilizing parallel searching and reading. +- Prefer using tools like `grep` to identify points of interest instead of reading lots of \ +files individually. +- If you need to read multiple ranges in a file, do so in parallel. + +{env_block} + +# Development Lifecycle + +Operate using a Research -> Strategy -> Execution lifecycle. + +1. **Research:** Systematically map the codebase and validate assumptions. Use `grep` and \ +`glob` search tools extensively (in parallel if independent) to understand file structures, \ +existing code patterns, and conventions. Use `read_file` to validate all assumptions. \ +Prioritize empirical reproduction of reported issues. +2. **Strategy:** Formulate a grounded plan based on your research. +3. **Execution:** For each sub-task: + - **Plan:** Define the specific implementation approach and the testing strategy. + - **Act:** Apply targeted, surgical changes. Use the available tools (edit_file, \ +write_file, shell). Include necessary automated tests. + - **Validate:** Run tests and workspace standards to confirm success and ensure no \ +regressions were introduced. + +Validation is the only path to finality. Never assume success or settle for unverified changes. + +# Tools + +Use the provided tools to interact with the codebase and environment. + +## read_file +Read files to understand code before modifying. Use offset/limit for large files. Minimize \ +unnecessarily large file reads when doing so does not result in extra turns. + +## read_many_files +Read multiple files at once by providing an array of paths. Useful for reading small files in \ +their entirety or gathering context from multiple locations efficiently. + +## edit_file +Use search-and-replace editing. The old_string must exactly match existing text and be unique \ +in the file. Prefer editing existing files over creating new ones. Before making manual code \ +changes, check if an ecosystem tool (like `eslint --fix`, `prettier --write`, `cargo fmt`) is \ +available in the project. + +## write_file +Use for creating new files or completely rewriting files. + +## shell +Execute shell commands. Default timeout is 10 seconds. Use timeout_ms parameter for \ +longer-running commands. Always prefer non-interactive commands (e.g., using CI flags for \ +test runners to avoid persistent watch modes or `git --no-pager`). + +## grep +Search file contents with regex patterns. Use conservative result counts and narrow scope \ +(include/exclude parameters). Use context/before/after to request enough context to avoid \ +needing to read the file before editing matches. + +## glob +Find files by name pattern. Results sorted by modification time. + +## list_dir +List directory contents with depth control. + +## web_search +Search the web for information. + +## web_fetch +Fetch content from a URL. + +# Project Docs + +Look for GEMINI.md and AGENTS.md files in the project for project-specific instructions. \ +These are foundational mandates that take precedence over defaults in this prompt. + +# Operational Guidelines + +## Tone and Style +- Act as a senior software engineer and collaborative peer programmer. +- Be concise and direct. Adopt a professional tone suitable for a CLI environment. +- Use tools for actions, text output only for communication. + +## Tool Usage +- Execute multiple independent tool calls in parallel when feasible. +- Use the shell tool for running commands, remembering to explain modifying commands first. + +# Coding Best Practices + +Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \ +in the project.\ {docs_section}\ {user_section}" ) @@ -157,12 +223,10 @@ Write clean, maintainable code. Handle errors appropriately. Follow existing cod fn provider_options(&self) -> Option { Some(serde_json::json!({ "gemini": { - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_NONE" - } - ] + "safety_settings": { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } } })) } @@ -195,7 +259,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for TestEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _: Option, _: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -204,13 +268,12 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _: &str, - _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, @@ -231,7 +294,7 @@ mod tests { ) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -272,7 +335,7 @@ mod tests { let profile = GeminiProfile::new("gemini-2.0-flash"); let env = TestEnv; let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); - assert!(prompt.contains("You are a coding assistant powered by Gemini")); + assert!(prompt.contains("You are Gemini CLI")); assert!(prompt.contains("solving bugs")); assert!(prompt.contains("adding new functionality")); assert!(prompt.contains("refactoring code")); @@ -285,12 +348,15 @@ mod tests { let env = TestEnv; let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); assert!(prompt.contains("read_file")); + assert!(prompt.contains("read_many_files")); assert!(prompt.contains("edit_file")); assert!(prompt.contains("write_file")); assert!(prompt.contains("shell")); assert!(prompt.contains("grep")); assert!(prompt.contains("glob")); assert!(prompt.contains("list_dir")); + assert!(prompt.contains("web_search")); + assert!(prompt.contains("web_fetch")); assert!(prompt.contains("Default timeout is 10 seconds")); } @@ -318,7 +384,7 @@ mod tests { let profile = GeminiProfile::new("gemini-2.0-flash"); let env = TestEnv; let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); - assert!(prompt.contains("# Environment")); + assert!(prompt.contains("")); assert!(prompt.contains("linux")); } @@ -329,25 +395,26 @@ mod tests { assert!(options.is_some()); let options = options.unwrap(); let safety = &options["gemini"]["safety_settings"]; - assert!(safety.is_array()); - let settings = safety.as_array().unwrap(); - assert_eq!(settings.len(), 1); - assert_eq!(settings[0]["category"], "HARM_CATEGORY_DANGEROUS_CONTENT"); - assert_eq!(settings[0]["threshold"], "BLOCK_NONE"); + assert!(safety.is_object()); + assert_eq!(safety["category"], "HARM_CATEGORY_DANGEROUS_CONTENT"); + assert_eq!(safety["threshold"], "BLOCK_ONLY_HIGH"); } #[test] fn gemini_tools_registered() { let profile = GeminiProfile::new("gemini-2.0-flash"); let names = profile.tool_registry().names(); - assert_eq!(names.len(), 7); + assert_eq!(names.len(), 10); assert!(names.contains(&"read_file".to_string())); + assert!(names.contains(&"read_many_files".to_string())); assert!(names.contains(&"write_file".to_string())); assert!(names.contains(&"edit_file".to_string())); assert!(names.contains(&"shell".to_string())); assert!(names.contains(&"grep".to_string())); assert!(names.contains(&"glob".to_string())); assert!(names.contains(&"list_dir".to_string())); + assert!(names.contains(&"web_search".to_string())); + assert!(names.contains(&"web_fetch".to_string())); } #[test] @@ -361,7 +428,7 @@ mod tests { }); profile.register_subagent_tools(manager, factory, 0); let names = profile.tool_registry().names(); - assert_eq!(names.len(), 11); + assert_eq!(names.len(), 14); assert!(names.contains(&"spawn_agent".to_string())); assert!(names.contains(&"send_input".to_string())); assert!(names.contains(&"wait".to_string())); diff --git a/crates/coding-agent-loop/src/profiles/mod.rs b/crates/coding-agent-loop/src/profiles/mod.rs index d977b7f27..3148fe4f5 100644 --- a/crates/coding-agent-loop/src/profiles/mod.rs +++ b/crates/coding-agent-loop/src/profiles/mod.rs @@ -15,6 +15,9 @@ pub struct EnvContext { pub is_git_repo: bool, pub date: String, pub model_name: String, + pub knowledge_cutoff: String, + pub git_status_short: Option, + pub git_recent_commits: Option, } #[must_use] @@ -25,25 +28,29 @@ pub fn build_env_context_block(env: &dyn ExecutionEnvironment) -> String { #[must_use] pub fn build_env_context_block_with(env: &dyn ExecutionEnvironment, ctx: &EnvContext) -> String { let mut lines = vec![ - "# Environment".to_string(), - format!("- Working directory: {}", env.working_directory()), - format!("- Platform: {}", env.platform()), - format!("- OS: {}", env.os_version()), + "".to_string(), + format!("Working directory: {}", env.working_directory()), + format!("Is git repository: {}", ctx.is_git_repo), ]; - if ctx.is_git_repo { - lines.push(format!("- Is a git repository: {}", ctx.is_git_repo)); - } if let Some(ref branch) = ctx.git_branch { - lines.push(format!("- Git branch: {branch}")); - } - if !ctx.date.is_empty() { - lines.push(format!("- Date: {}", ctx.date)); - } - if !ctx.model_name.is_empty() { - lines.push(format!("- Model: {}", ctx.model_name)); + lines.push(format!("Git branch: {branch}")); } + lines.push(format!("Platform: {}", env.platform())); + lines.push(format!("OS version: {}", env.os_version())); + + if !ctx.date.is_empty() { + lines.push(format!("Today's date: {}", ctx.date)); + } + if !ctx.model_name.is_empty() { + lines.push(format!("Model: {}", ctx.model_name)); + } + if !ctx.knowledge_cutoff.is_empty() { + lines.push(format!("Knowledge cutoff: {}", ctx.knowledge_cutoff)); + } + + lines.push("".to_string()); lines.join("\n") } @@ -57,7 +64,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for TestEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _: Option, _: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -66,13 +73,12 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _: &str, - _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, @@ -93,7 +99,7 @@ mod tests { ) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -117,7 +123,8 @@ mod tests { fn env_context_block_contains_platform() { let env = TestEnv; let block = build_env_context_block(&env); - assert!(block.contains("# Environment")); + assert!(block.contains("")); + assert!(block.contains("")); assert!(block.contains("linux")); assert!(block.contains("/home/test")); assert!(block.contains("Linux 6.1.0")); @@ -131,11 +138,15 @@ mod tests { is_git_repo: true, date: "2026-02-20".into(), model_name: "claude-opus-4-6".into(), + knowledge_cutoff: "May 2025".into(), + git_status_short: None, + git_recent_commits: None, }; let block = build_env_context_block_with(&env, &ctx); assert!(block.contains("Git branch: main")); - assert!(block.contains("Is a git repository: true")); - assert!(block.contains("Date: 2026-02-20")); + assert!(block.contains("Is git repository: true")); + assert!(block.contains("Today's date: 2026-02-20")); assert!(block.contains("Model: claude-opus-4-6")); + assert!(block.contains("Knowledge cutoff: May 2025")); } } diff --git a/crates/coding-agent-loop/src/profiles/openai.rs b/crates/coding-agent-loop/src/profiles/openai.rs index 2dec77e7c..6552fe888 100644 --- a/crates/coding-agent-loop/src/profiles/openai.rs +++ b/crates/coding-agent-loop/src/profiles/openai.rs @@ -96,25 +96,87 @@ impl ProviderProfile for OpenAiProfile { }; format!( - "You are a coding assistant powered by OpenAI. You help users with software engineering tasks \ - including solving bugs, adding new functionality, refactoring code, and explaining code.\n\n\ - {env_block}\n\n\ - # Tools\n\ - Use the provided tools to interact with the codebase and environment.\n\n\ - - read_file: Read files to understand code before modifying. Use offset/limit for large files.\n\ - - apply_patch: Use the v4a patch format for all file modifications. The format uses \ - `*** Begin Patch` / `*** End Patch` delimiters with `*** Add File:`, `*** Delete File:`, \ - `*** Update File:` operations. Update operations use `@@` context hints and +/- prefixes \ - for changes. Show 3 lines of context around each change.\n\ - - write_file: Use for creating new files. For modifications, prefer apply_patch.\n\ - - shell: Execute shell commands. Default timeout is 10 seconds. Use timeout_ms parameter \ - for longer-running commands.\n\ - - grep: Search file contents with regex. Use glob_filter to narrow results.\n\ - - glob: Find files by name pattern.\n\n\ - # Coding Best Practices\n\ - Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions in the project.\ - {docs_section}\ - {user_section}" + "\ +You are a coding agent powered by OpenAI, running in a terminal-based agentic coding assistant. \ +You are expected to be precise, safe, and helpful. + +You can receive user prompts and context such as files in the workspace, communicate with the \ +user by streaming thinking and responses, and emit function calls to run terminal commands and \ +apply patches. + +# Personality + +Be concise, direct, and friendly. Communicate efficiently, keeping the user clearly informed \ +about ongoing actions without unnecessary detail. Prioritize actionable guidance, clearly \ +stating assumptions, environment prerequisites, and next steps. + +{env_block} + +# AGENTS.md + +Repos may contain AGENTS.md files with instructions for the agent. These files can appear \ +anywhere in the repository. Instructions in AGENTS.md files whose scope includes a file you \ +touch must be obeyed. More-deeply-nested AGENTS.md files take precedence in case of conflict. \ +Direct system/developer/user instructions take precedence over AGENTS.md instructions. + +# Task Execution + +Keep going until the task is completely resolved before ending your turn. Autonomously resolve \ +the query to the best of your ability using the tools available. Do NOT guess or make up an answer. + +Working on repos in the current environment is allowed, even if they are proprietary. + +If completing the task requires writing or modifying files: +- Fix the problem at the root cause rather than applying surface-level patches, when possible. +- Avoid unneeded complexity in your solution. +- Do not attempt to fix unrelated bugs or broken tests. +- Keep changes consistent with the style of the existing codebase. Changes should be minimal \ +and focused on the task. +- Use `git log` and `git blame` to search the history of the codebase if additional context is needed. +- NEVER add copyright or license headers unless specifically requested. +- Do not waste tokens re-reading files after calling apply_patch on them. The tool call will \ +fail if it did not work. +- Do not `git commit` your changes or create new git branches unless explicitly requested. + +# Validating Your Work + +If the codebase has tests or the ability to build or run, consider using them to verify your \ +work. Start as specific as possible to the code you changed to catch issues efficiently, then \ +make your way to broader tests as you build confidence. + +# Tools + +Use the provided tools to interact with the codebase and environment. + +## read_file +Read files to understand code before modifying. Use offset/limit for large files. + +## apply_patch +Use the v4a patch format for all file modifications. The format uses `*** Begin Patch` / \ +`*** End Patch` delimiters with `*** Add File:`, `*** Delete File:`, `*** Update File:` \ +operations. Update operations use `@@` context hints and +/- prefixes for changes. Show 3 \ +lines of context around each change. NEVER use `applypatch` or `apply-patch`, only `apply_patch`. + +## write_file +Use for creating new files. For modifications, prefer apply_patch. + +## shell +Execute shell commands. Default timeout is 10 seconds. Use timeout_ms parameter for \ +longer-running commands. When searching for text or files, prefer `rg` (ripgrep) because \ +it is much faster than alternatives like `grep`. + +## grep +Search file contents with regex. Use glob_filter to narrow results. + +## glob +Find files by name pattern. + +# Coding Best Practices + +Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \ +in the project.\ +{docs_section}\ +{user_section}" ) } @@ -295,7 +357,7 @@ pub async fn apply_patch_operations( results.push(format!("Deleted file: {path}")); } PatchOperation::Update { path, hunks } => { - let original = env.read_file(path).await?; + let original = env.read_file(path, None, None).await?; let updated = apply_hunks(&original, hunks)?; env.write_file(path, &updated).await?; results.push(format!("Updated file: {path}")); @@ -403,7 +465,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for TestEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _: Option, _: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -412,13 +474,12 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _: &str, - _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, @@ -439,7 +500,7 @@ mod tests { ) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -473,7 +534,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for MockFileEnv { - async fn read_file(&self, path: &str) -> Result { + async fn read_file(&self, path: &str, _: Option, _: Option) -> Result { self.files .lock() .unwrap() @@ -491,13 +552,12 @@ mod tests { async fn file_exists(&self, path: &str) -> Result { Ok(self.files.lock().unwrap().contains_key(path)) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _: &str, - _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, @@ -518,7 +578,7 @@ mod tests { ) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -559,8 +619,8 @@ mod tests { let profile = OpenAiProfile::new("o3-mini"); let env = TestEnv; let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); - assert!(prompt.contains("You are a coding assistant powered by OpenAI")); - assert!(prompt.contains("# Environment")); + assert!(prompt.contains("You are a coding agent powered by OpenAI")); + assert!(prompt.contains("")); assert!(prompt.contains("linux")); assert!(prompt.contains("v4a patch format")); assert!(prompt.contains("*** Begin Patch")); @@ -769,7 +829,7 @@ mod tests { let result = apply_patch_operations(&ops, &env).await.unwrap(); assert!(result.contains("Added file: src/new.rs")); - let content = env.read_file("src/new.rs").await.unwrap(); + let content = env.read_file("src/new.rs", None, None).await.unwrap(); assert_eq!(content, "fn new() {}"); } @@ -796,7 +856,7 @@ mod tests { let result = apply_patch_operations(&ops, &env).await.unwrap(); assert!(result.contains("Updated file: src/lib.rs")); - let content = env.read_file("src/lib.rs").await.unwrap(); + let content = env.read_file("src/lib.rs", None, None).await.unwrap(); assert!(content.contains("println!(\"new\")")); assert!(!content.contains("println!(\"old\")")); } diff --git a/crates/coding-agent-loop/src/project_docs.rs b/crates/coding-agent-loop/src/project_docs.rs index ba5aa0fdf..4b9ed8d40 100644 --- a/crates/coding-agent-loop/src/project_docs.rs +++ b/crates/coding-agent-loop/src/project_docs.rs @@ -23,7 +23,7 @@ pub async fn discover_project_docs( for dir in &directories { for filename in &candidate_filenames { let path = format!("{dir}/{filename}"); - if let Ok(content) = env.read_file(&path).await { + if let Ok(content) = env.read_file(&path, None, None).await { if content.is_empty() { continue; } @@ -97,7 +97,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for DocEnv { - async fn read_file(&self, path: &str) -> Result { + async fn read_file(&self, path: &str, _: Option, _: Option) -> Result { self.files .get(path) .cloned() @@ -109,10 +109,10 @@ mod tests { async fn file_exists(&self, path: &str) -> Result { Ok(self.files.contains_key(path)) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } - async fn exec_command(&self, _: &str, _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { + async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { Ok(ExecResult { stdout: String::new(), stderr: String::new(), @@ -124,7 +124,7 @@ mod tests { async fn grep(&self, _: &str, _: &str, _: &GrepOptions) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { diff --git a/crates/coding-agent-loop/src/provider_profile.rs b/crates/coding-agent-loop/src/provider_profile.rs index 9099f48ad..059bf9bec 100644 --- a/crates/coding-agent-loop/src/provider_profile.rs +++ b/crates/coding-agent-loop/src/provider_profile.rs @@ -33,7 +33,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for TestEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _: Option, _: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -42,13 +42,12 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _: &str, - _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, @@ -69,7 +68,7 @@ mod tests { ) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { diff --git a/crates/coding-agent-loop/src/session.rs b/crates/coding-agent-loop/src/session.rs index a73a44e1b..b35a2f7fa 100644 --- a/crates/coding-agent-loop/src/session.rs +++ b/crates/coding-agent-loop/src/session.rs @@ -62,15 +62,18 @@ impl Session { /// Initialize session by discovering project docs and capturing environment context. /// Call before `process_input`. pub async fn initialize(&mut self) { - if let Some(ref git_root) = self.config.git_root { - self.project_docs = discover_project_docs( - self.execution_env.as_ref(), - git_root, - self.execution_env.working_directory(), - &self.provider_profile.id(), - ) - .await; - } + let doc_root = self + .config + .git_root + .clone() + .unwrap_or_else(|| self.execution_env.working_directory().to_string()); + self.project_docs = discover_project_docs( + self.execution_env.as_ref(), + &doc_root, + self.execution_env.working_directory(), + &self.provider_profile.id(), + ) + .await; // Populate environment context self.env_context = self.build_env_context().await; @@ -83,7 +86,7 @@ impl Session { // Detect git info via execution environment let git_branch = self .execution_env - .exec_command("git", &["rev-parse".into(), "--abbrev-ref".into(), "HEAD".into()], 5000, None, None) + .exec_command("git rev-parse --abbrev-ref HEAD", 5000, None, None) .await .ok() .filter(|r| r.exit_code == 0) @@ -91,11 +94,38 @@ impl Session { let is_git_repo = git_branch.is_some(); + let git_status_short = if is_git_repo { + self.execution_env + .exec_command("git status --short", 5000, None, None) + .await + .ok() + .filter(|r| r.exit_code == 0) + .map(|r| r.stdout.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + }; + + let git_recent_commits = if is_git_repo { + self.execution_env + .exec_command("git log --oneline -10", 5000, None, None) + .await + .ok() + .filter(|r| r.exit_code == 0) + .map(|r| r.stdout.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + }; + EnvContext { git_branch, is_git_repo, date: today, model_name, + knowledge_cutoff: String::new(), + git_status_short, + git_recent_commits, } } @@ -141,6 +171,10 @@ impl Session { self.config.reasoning_effort = effort; } + pub fn set_max_turns(&mut self, max_turns: usize) { + self.config.max_turns = max_turns; + } + pub fn history(&self) -> &History { &self.history } @@ -233,12 +267,24 @@ impl Session { // Check abort flag if self.abort_flag.load(Ordering::SeqCst) { self.state = SessionState::Closed; + self.event_emitter.emit( + EventKind::SessionEnd, + self.id.clone(), + HashMap::new(), + ); return Err(AgentError::Aborted); } // Build request let request = self.build_request(); + // Emit AssistantTextStart before LLM call + self.event_emitter.emit( + EventKind::AssistantTextStart, + self.id.clone(), + HashMap::new(), + ); + // Call LLM let response = match self.llm_client.complete(&request).await { Ok(resp) => resp, @@ -818,7 +864,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for MemoryExecutionEnvironment { - async fn read_file(&self, path: &str) -> Result { + async fn read_file(&self, path: &str, _offset: Option, _limit: Option) -> Result { self.files .get(path) .cloned() @@ -833,14 +879,13 @@ mod tests { Ok(self.files.contains_key(path)) } - async fn list_directory(&self, _path: &str) -> Result, String> { + async fn list_directory(&self, _path: &str, _depth: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _command: &str, - _args: &[String], _timeout_ms: u64, _working_dir: Option<&str>, _env_vars: Option<&std::collections::HashMap>, @@ -863,7 +908,7 @@ mod tests { Ok(vec![]) } - async fn glob(&self, _pattern: &str) -> Result, String> { + async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result, String> { Ok(vec![]) } diff --git a/crates/coding-agent-loop/src/subagent.rs b/crates/coding-agent-loop/src/subagent.rs index 653affab6..5ee0f1c7d 100644 --- a/crates/coding-agent-loop/src/subagent.rs +++ b/crates/coding-agent-loop/src/subagent.rs @@ -191,7 +191,18 @@ pub fn make_spawn_agent_tool( .and_then(|v| v.as_str()) .ok_or_else(|| "Missing required parameter: task".to_string())?; - let session = session_factory(); + // Extract optional max_turns parameter + #[allow(clippy::cast_possible_truncation)] + let max_turns = args + .get("max_turns") + .and_then(|v| v.as_u64()) + .map(|v| v as usize); + + // Note: working_dir and model require session factory changes to wire through + let mut session = session_factory(); + if let Some(turns) = max_turns { + session.set_max_turns(turns); + } let mut mgr = manager.lock().await; mgr.spawn(session, task.to_string(), current_depth) }) @@ -376,7 +387,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for MemoryExecutionEnvironment { - async fn read_file(&self, _path: &str) -> Result { + async fn read_file(&self, _path: &str, _offset: Option, _limit: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> { @@ -385,13 +396,12 @@ mod tests { async fn file_exists(&self, _path: &str) -> Result { Ok(false) } - async fn list_directory(&self, _path: &str) -> Result, String> { + async fn list_directory(&self, _path: &str, _depth: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _command: &str, - _args: &[String], _timeout_ms: u64, _working_dir: Option<&str>, _env_vars: Option<&std::collections::HashMap>, @@ -412,7 +422,7 @@ mod tests { ) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _pattern: &str) -> Result, String> { + async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { diff --git a/crates/coding-agent-loop/src/tool_registry.rs b/crates/coding-agent-loop/src/tool_registry.rs index d2485a317..84eed4b5c 100644 --- a/crates/coding-agent-loop/src/tool_registry.rs +++ b/crates/coding-agent-loop/src/tool_registry.rs @@ -170,7 +170,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for DummyEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _: Option, _: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -179,13 +179,12 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _: &str, - _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, @@ -206,7 +205,7 @@ mod tests { ) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { diff --git a/crates/coding-agent-loop/src/tools.rs b/crates/coding-agent-loop/src/tools.rs index aa7e1152d..b076fbc24 100644 --- a/crates/coding-agent-loop/src/tools.rs +++ b/crates/coding-agent-loop/src/tools.rs @@ -29,22 +29,13 @@ pub fn make_read_file_tool() -> RegisteredTool { let offset = args.get("offset").and_then(serde_json::Value::as_u64); let limit = args.get("limit").and_then(serde_json::Value::as_u64); - let content = env.read_file(file_path).await?; - - // Default limit of 2000 lines when no limit param provided - let effective_limit = limit.unwrap_or(2000); - #[allow(clippy::cast_possible_truncation)] - let offset_val = offset.unwrap_or(1) as usize; - let lines: Vec<&str> = content.lines().collect(); - let start = if offset_val > 0 { offset_val - 1 } else { 0 }; + let offset_usize = offset.map(|v| v as usize); #[allow(clippy::cast_possible_truncation)] - let selected: Vec<&str> = lines - .into_iter() - .skip(start) - .take(effective_limit as usize) - .collect(); - Ok(selected.join("\n")) + let limit_usize = limit.map(|v| v as usize); + + let content = env.read_file(file_path, offset_usize, limit_usize).await?; + Ok(content) }) }), } @@ -114,7 +105,7 @@ pub fn make_edit_file_tool() -> RegisteredTool { .and_then(serde_json::Value::as_bool) .unwrap_or(false); - let numbered_content = env.read_file(file_path).await?; + let numbered_content = env.read_file(file_path, None, None).await?; // Strip line numbers: each line looks like " 1 | content" or " 10 | content" let raw_lines: Vec<&str> = numbered_content @@ -184,13 +175,7 @@ pub fn make_shell_tool_with_config(config: &SessionConfig) -> RegisteredTool { .min(max_timeout); let result = env - .exec_command( - "/bin/bash", - &["-c".into(), command.into()], - timeout_ms, - None, - None, - ) + .exec_command(command, timeout_ms, None, None) .await?; let mut output = String::new(); @@ -283,18 +268,145 @@ pub fn make_glob_tool() -> RegisteredTool { .get("path") .and_then(serde_json::Value::as_str); - let full_pattern = match path { - Some(dir) => format!("{dir}/{pattern}"), - None => pattern.to_string(), - }; - - let results = env.glob(&full_pattern).await?; + let results = env.glob(pattern, path).await?; Ok(results.join("\n")) }) }), } } +#[must_use] +pub fn make_read_many_files_tool() -> RegisteredTool { + RegisteredTool { + definition: ToolDefinition { + name: "read_many_files".into(), + description: "Read multiple files at once".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of absolute file paths to read" + } + }, + "required": ["paths"] + }), + }, + executor: Arc::new(|args, env| { + Box::pin(async move { + let paths = args["paths"] + .as_array() + .ok_or_else(|| "paths must be an array".to_string())?; + + let mut output = String::new(); + for path_val in paths { + let path = path_val + .as_str() + .ok_or_else(|| "each path must be a string".to_string())?; + match env.read_file(path, None, None).await { + Ok(content) => { + let _ = write!(output, "=== {path} ===\n{content}\n\n"); + } + Err(err) => { + let _ = write!(output, "=== {path} ===\nError: {err}\n\n"); + } + } + } + Ok(output) + }) + }), + } +} + +#[must_use] +pub fn make_list_dir_tool() -> RegisteredTool { + RegisteredTool { + definition: ToolDefinition { + name: "list_dir".into(), + description: "List directory contents with depth control".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Directory path to list"}, + "depth": {"type": "integer", "description": "Depth of listing (default 1)"} + }, + "required": ["path"] + }), + }, + executor: Arc::new(|args, env| { + Box::pin(async move { + let path = args["path"] + .as_str() + .ok_or_else(|| "path is required".to_string())?; + #[allow(clippy::cast_possible_truncation)] + let depth = args + .get("depth") + .and_then(serde_json::Value::as_u64) + .map(|v| v as usize); + + let entries = env.list_directory(path, depth).await?; + let lines: Vec = entries + .iter() + .map(|e| { + if e.is_dir { + format!("{}/", e.name) + } else { + e.name.clone() + } + }) + .collect(); + Ok(lines.join("\n")) + }) + }), + } +} + +#[must_use] +pub fn make_web_search_tool() -> RegisteredTool { + RegisteredTool { + definition: ToolDefinition { + name: "web_search".into(), + description: "Search the web".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "max_results": {"type": "integer", "description": "Maximum number of results"} + }, + "required": ["query"] + }), + }, + executor: Arc::new(|_args, _env| { + Box::pin(async move { + Ok("Web search is not configured. This is a placeholder tool.".to_string()) + }) + }), + } +} + +#[must_use] +pub fn make_web_fetch_tool() -> RegisteredTool { + RegisteredTool { + definition: ToolDefinition { + name: "web_fetch".into(), + description: "Fetch content from a URL".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to fetch"} + }, + "required": ["url"] + }), + }, + executor: Arc::new(|_args, _env| { + Box::pin(async move { + Ok("Web fetch is not configured. This is a placeholder tool.".to_string()) + }) + }), + } +} + #[cfg(test)] mod tests { use super::*; @@ -308,8 +420,12 @@ mod tests { #[async_trait] impl ExecutionEnvironment for ReadFileEnv { - async fn read_file(&self, _path: &str) -> Result { - Ok(self.content.clone()) + async fn read_file(&self, _path: &str, offset: Option, limit: Option) -> Result { + let lines: Vec<&str> = self.content.lines().collect(); + let start = offset.unwrap_or(1).saturating_sub(1); + let count = limit.unwrap_or(2000); + let selected: Vec<&str> = lines.into_iter().skip(start).take(count).collect(); + Ok(selected.join("\n")) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { Ok(()) @@ -317,10 +433,10 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _depth: Option) -> Result, String> { Ok(vec![]) } - async fn exec_command(&self, _: &str, _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { + async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { Ok(ExecResult { stdout: String::new(), stderr: String::new(), @@ -332,7 +448,7 @@ mod tests { async fn grep(&self, _: &str, _: &str, _: &GrepOptions) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _path: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -358,7 +474,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for WriteFileEnv { - async fn read_file(&self, _path: &str) -> Result { + async fn read_file(&self, _path: &str, _offset: Option, _limit: Option) -> Result { Ok(String::new()) } async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { @@ -368,10 +484,10 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _depth: Option) -> Result, String> { Ok(vec![]) } - async fn exec_command(&self, _: &str, _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { + async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { Ok(ExecResult { stdout: String::new(), stderr: String::new(), @@ -383,7 +499,7 @@ mod tests { async fn grep(&self, _: &str, _: &str, _: &GrepOptions) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _path: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -410,7 +526,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for EditFileEnv { - async fn read_file(&self, _path: &str) -> Result { + async fn read_file(&self, _path: &str, _offset: Option, _limit: Option) -> Result { Ok(self.content.clone()) } async fn write_file(&self, _path: &str, content: &str) -> Result<(), String> { @@ -420,10 +536,10 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _depth: Option) -> Result, String> { Ok(vec![]) } - async fn exec_command(&self, _: &str, _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { + async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { Ok(ExecResult { stdout: String::new(), stderr: String::new(), @@ -435,7 +551,7 @@ mod tests { async fn grep(&self, _: &str, _: &str, _: &GrepOptions) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _path: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -461,7 +577,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for ShellEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _offset: Option, _limit: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -470,16 +586,16 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _depth: Option) -> Result, String> { Ok(vec![]) } - async fn exec_command(&self, _: &str, _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { + async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { Ok(self.result.clone()) } async fn grep(&self, _: &str, _: &str, _: &GrepOptions) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _path: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -505,7 +621,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for ShellCapturingEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _offset: Option, _limit: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -514,13 +630,12 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _depth: Option) -> Result, String> { Ok(vec![]) } async fn exec_command( &self, _: &str, - _: &[String], timeout_ms: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, @@ -537,7 +652,7 @@ mod tests { async fn grep(&self, _: &str, _: &str, _: &GrepOptions) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _path: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -563,7 +678,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for GrepEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _offset: Option, _limit: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -572,10 +687,10 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _depth: Option) -> Result, String> { Ok(vec![]) } - async fn exec_command(&self, _: &str, _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { + async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { Ok(ExecResult { stdout: String::new(), stderr: String::new(), @@ -587,7 +702,7 @@ mod tests { async fn grep(&self, _: &str, _: &str, _: &GrepOptions) -> Result, String> { Ok(self.results.clone()) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _path: Option<&str>) -> Result, String> { Ok(vec![]) } async fn initialize(&self) -> Result<(), String> { @@ -613,7 +728,7 @@ mod tests { #[async_trait] impl ExecutionEnvironment for GlobEnv { - async fn read_file(&self, _: &str) -> Result { + async fn read_file(&self, _: &str, _offset: Option, _limit: Option) -> Result { Ok(String::new()) } async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { @@ -622,10 +737,10 @@ mod tests { async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str) -> Result, String> { + async fn list_directory(&self, _: &str, _depth: Option) -> Result, String> { Ok(vec![]) } - async fn exec_command(&self, _: &str, _: &[String], _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { + async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>) -> Result { Ok(ExecResult { stdout: String::new(), stderr: String::new(), @@ -637,7 +752,7 @@ mod tests { async fn grep(&self, _: &str, _: &str, _: &GrepOptions) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str) -> Result, String> { + async fn glob(&self, _: &str, _path: Option<&str>) -> Result, String> { Ok(self.results.clone()) } async fn initialize(&self) -> Result<(), String> { diff --git a/crates/coding-agent-loop/src/truncation.rs b/crates/coding-agent-loop/src/truncation.rs index c33db9a0b..3114b3f05 100644 --- a/crates/coding-agent-loop/src/truncation.rs +++ b/crates/coding-agent-loop/src/truncation.rs @@ -51,16 +51,16 @@ pub fn truncate_output(output: &str, max_chars: usize, mode: TruncationMode) -> let head = &output[..half]; let tail = &output[output.len() - half..]; format!( - "{head}\n\n[WARNING: Output truncated. {removed} characters removed. \ - Full output available in event stream. Retry with smaller scope if needed.]\n\n{tail}" + "{head}\n\n[WARNING: Tool output was truncated. {removed} characters were removed from the middle. \ + The full output is available in the event stream. \ + If you need to see specific parts, re-run the tool with more targeted parameters.]\n\n{tail}" ) } TruncationMode::Tail => { let tail = &output[output.len() - max_chars..]; format!( - "\n\n[WARNING: Output truncated. {removed} characters removed. \ - Showing last portion only. Full output available in event stream. \ - Retry with smaller scope if needed.]\n\n{tail}" + "[WARNING: Tool output was truncated. First {removed} characters were removed. \ + The full output is available in the event stream.]\n\n{tail}" ) } } @@ -78,8 +78,7 @@ pub fn truncate_lines(output: &str, max_lines: usize) -> String { let omitted = lines.len() - max_lines; format!( - "{}\n\n[WARNING: Output truncated by line count. {omitted} lines omitted. \ - Showing first and last lines.]\n\n{}", + "{}\n\n[... {omitted} lines omitted ...]\n\n{}", head.join("\n"), tail.join("\n") ) @@ -144,16 +143,16 @@ mod tests { let output = "a".repeat(100); let result = truncate_output(&output, 40, TruncationMode::HeadTail); assert!(result.contains(&"a".repeat(20))); - assert!(result.contains("Output truncated")); - assert!(result.contains("60 characters removed")); + assert!(result.contains("Tool output was truncated")); + assert!(result.contains("60 characters were removed from the middle")); } #[test] fn tail_mode() { let output = format!("{}BBB", "A".repeat(100)); let result = truncate_output(&output, 10, TruncationMode::Tail); - assert!(result.contains("Output truncated")); - assert!(result.contains("Showing last portion only")); + assert!(result.contains("Tool output was truncated")); + assert!(result.contains("First 93 characters were removed")); assert!(result.ends_with("AAAAAAABBB")); } @@ -166,7 +165,7 @@ mod tests { assert!(result.contains("line 3")); assert!(result.contains("line 18")); assert!(result.contains("line 20")); - assert!(result.contains("14 lines omitted")); + assert!(result.contains("... 14 lines omitted ...")); } #[test] @@ -182,14 +181,14 @@ mod tests { #[test] fn config_override_char_limit() { - let output = "x".repeat(200); + let output = "x".repeat(5000); let mut config = SessionConfig::default(); config .tool_output_limits - .insert("my_tool".into(), 50); + .insert("my_tool".into(), 100); let result = truncate_tool_output(&output, "my_tool", &config); assert!(result.len() < output.len()); - assert!(result.contains("Output truncated")); + assert!(result.contains("Tool output was truncated")); } #[test]