diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index 8d87ca6f0..6ed30b462 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -205,7 +205,7 @@ service-integrations = [ "dep:x25519-dalek", "bitfun-services-integrations/product-full", ] -tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full"] +tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full", "dep:image"] tauri-support = ["tauri"] # Optional tauri support ssh-remote = [ "dep:aes-gcm", @@ -217,5 +217,8 @@ ssh-remote = [ "ssh_config", ] # russh-keys pure-Rust crypto backend (no openssl) +[dev-dependencies] +tempfile = { workspace = true } + [build-dependencies] sha2 = { workspace = true } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/core/src/agentic/agents/definitions/modes/claw.rs index 77d1bab26..a638c3bad 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/claw.rs @@ -18,6 +18,7 @@ impl ClawMode { default_tools: vec![ "Task".to_string(), "Read".to_string(), + "view_image".to_string(), "Write".to_string(), "Edit".to_string(), "Delete".to_string(), diff --git a/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs index b1c77a1e6..766e4f714 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs @@ -27,6 +27,7 @@ impl CoworkMode { // Discovery + editing "LS".to_string(), "Read".to_string(), + "view_image".to_string(), "Grep".to_string(), "Glob".to_string(), "Write".to_string(), diff --git a/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs index abfcc1dbb..c0803c306 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -24,6 +24,7 @@ impl DeepResearchMode { "WebSearch".to_string(), "WebFetch".to_string(), "Read".to_string(), + "view_image".to_string(), "Grep".to_string(), "Glob".to_string(), "LS".to_string(), diff --git a/src/crates/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/core/src/agentic/agents/definitions/modes/team.rs index 033438f8f..8717eb3b7 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/team.rs @@ -23,6 +23,7 @@ impl TeamMode { "Skill".to_string(), "Task".to_string(), "Read".to_string(), + "view_image".to_string(), "Write".to_string(), "Edit".to_string(), "Delete".to_string(), diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs b/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs index 04fb10afc..ec4fe2d29 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs @@ -27,6 +27,7 @@ impl ComputerUseMode { "AskUserQuestion".to_string(), "TodoWrite".to_string(), "Skill".to_string(), + "view_image".to_string(), "Bash".to_string(), "TerminalControl".to_string(), "ControlHub".to_string(), diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs index f278e0b0d..d490a0aad 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs @@ -17,6 +17,7 @@ impl GeneralPurposeAgent { default_tools: vec![ "Bash".to_string(), "Read".to_string(), + "view_image".to_string(), "Glob".to_string(), "Grep".to_string(), "Write".to_string(), diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index b43807fd4..0f3004434 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -65,6 +65,7 @@ pub fn shared_coding_mode_tools() -> Vec { vec![ "Task".to_string(), "Read".to_string(), + "view_image".to_string(), "Write".to_string(), "Edit".to_string(), "Delete".to_string(), diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 9cb45a3ca..0b45c3305 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -40,6 +40,7 @@ pub mod terminal_control_tool; pub mod thread_goal_tools; pub mod todo_write_tool; pub mod util; +pub mod view_image_tool; pub mod web_tools; #[deprecated(note = "GetToolSpecTool is owned by the product tool runtime boundary")] @@ -79,4 +80,5 @@ pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; pub use thread_goal_tools::{CreateGoalTool, GetGoalTool, UpdateGoalTool}; pub use todo_write_tool::TodoWriteTool; +pub use view_image_tool::ViewImageTool; pub use web_tools::{WebFetchTool, WebSearchTool}; diff --git a/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs b/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs new file mode 100644 index 000000000..368464986 --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs @@ -0,0 +1,621 @@ +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use crate::util::types::ToolImageAttachment; +use async_trait::async_trait; +use base64::Engine as _; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +pub struct ViewImageTool; + +#[derive(Debug, Clone)] +enum ResolvedImagePath { + Local(PathBuf), + RemoteWorkspace { logical_path: String, path: String }, +} + +impl ResolvedImagePath { + fn display_path(&self) -> &str { + match self { + Self::Local(path) => path.to_str().unwrap_or(""), + Self::RemoteWorkspace { logical_path, .. } => logical_path, + } + } +} + +impl Default for ViewImageTool { + fn default() -> Self { + Self::new() + } +} + +impl ViewImageTool { + pub fn new() -> Self { + Self + } + + fn primary_api_format(ctx: &ToolUseContext) -> String { + ctx.custom_data + .get("primary_model_provider") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase() + } + + fn require_multimodal_tool_output(ctx: &ToolUseContext) -> BitFunResult<()> { + if !ctx.primary_model_supports_image_understanding() { + return Err(BitFunError::tool( + "view_image is not allowed because the primary model does not accept image inputs" + .to_string(), + )); + } + + let format = Self::primary_api_format(ctx); + if matches!( + format.as_str(), + "anthropic" | "openai" | "response" | "responses" + ) { + return Ok(()); + } + + Err(BitFunError::tool( + "view_image returns images in tool results; set the primary model to Anthropic (Claude) or OpenAI-compatible API format. Other providers are not supported for view_image yet." + .to_string(), + )) + } + + fn mime_type_for_image(bytes: &[u8]) -> BitFunResult<&'static str> { + let format = image::guess_format(bytes).map_err(|_| { + BitFunError::tool( + "view_image can only attach supported image files: png, jpeg, gif, webp, or bmp" + .to_string(), + ) + })?; + + match format { + image::ImageFormat::Png => Ok("image/png"), + image::ImageFormat::Jpeg => Ok("image/jpeg"), + image::ImageFormat::Gif => Ok("image/gif"), + image::ImageFormat::WebP => Ok("image/webp"), + image::ImageFormat::Bmp => Ok("image/bmp"), + other => Err(BitFunError::tool(format!( + "view_image does not support image format {:?}; supported formats are png, jpeg, gif, webp, and bmp", + other + ))), + } + } + + fn path_from_input(input: &Value) -> BitFunResult<&str> { + input + .get("path") + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| BitFunError::tool("path is required".to_string())) + } + + fn validate_detail(input: &Value) -> BitFunResult<()> { + match input.get("detail") { + None | Some(Value::Null) => Ok(()), + Some(Value::String(value)) if value == "original" => Ok(()), + Some(Value::String(value)) => Err(BitFunError::tool(format!( + "view_image.detail only supports `original`; omit `detail` for default behavior, got `{}`", + value + ))), + Some(_) => Err(BitFunError::tool( + "view_image.detail must be the string `original` when provided".to_string(), + )), + } + } + + fn resolve_path( + input_path: &str, + context: Option<&ToolUseContext>, + ) -> BitFunResult { + let local_path = Path::new(input_path); + if local_path.is_absolute() + && !crate::agentic::tools::workspace_paths::is_bitfun_runtime_uri(input_path) + { + return Ok(ResolvedImagePath::Local(local_path.to_path_buf())); + } + + match context.map(|ctx| ctx.resolve_tool_path(input_path)) { + Some(Ok(resolved)) => { + if resolved.uses_remote_workspace_backend() { + return Ok(ResolvedImagePath::RemoteWorkspace { + logical_path: resolved.logical_path, + path: resolved.resolved_path, + }); + } + Ok(ResolvedImagePath::Local(PathBuf::from( + resolved.resolved_path, + ))) + } + Some(Err(err)) => Err(err), + None => { + let path = Path::new(input_path); + if !path.is_absolute() { + return Err(BitFunError::tool(format!( + "path must be an absolute path when no tool context is available, got: {}", + input_path + ))); + } + Ok(ResolvedImagePath::Local(path.to_path_buf())) + } + } + } + + async fn read_image_bytes( + resolved: &ResolvedImagePath, + context: Option<&ToolUseContext>, + ) -> BitFunResult> { + match resolved { + ResolvedImagePath::Local(path) => { + let metadata = fs::metadata(path).await.map_err(|err| { + BitFunError::tool(format!( + "unable to locate image at {}: {}", + path.display(), + err + )) + })?; + if !metadata.is_file() { + return Err(BitFunError::tool(format!( + "image path is not a file: {}", + path.display() + ))); + } + + fs::read(path).await.map_err(|err| { + BitFunError::tool(format!( + "unable to read image at {}: {}", + path.display(), + err + )) + }) + } + ResolvedImagePath::RemoteWorkspace { path, logical_path } => { + let fs = context.and_then(|ctx| ctx.ws_fs()).ok_or_else(|| { + BitFunError::tool( + "view_image cannot read remote workspace images because workspace filesystem services are unavailable" + .to_string(), + ) + })?; + let is_file = fs.is_file(path).await.map_err(|err| { + BitFunError::tool(format!( + "unable to inspect remote image at {}: {}", + logical_path, err + )) + })?; + if !is_file { + return Err(BitFunError::tool(format!( + "image path is not a file: {}", + logical_path + ))); + } + + fs.read_file(path).await.map_err(|err| { + BitFunError::tool(format!( + "unable to read remote image at {}: {}", + logical_path, err + )) + }) + } + } + } +} + +#[async_trait] +impl Tool for ViewImageTool { + fn name(&self) -> &str { + "view_image" + } + + async fn description(&self) -> BitFunResult { + Ok( + "View a local image from the filesystem. Use only when given a local image path and the image is not already attached to the conversation." + .to_string(), + ) + } + + fn short_description(&self) -> String { + "Attach a local image file for model vision.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Expanded + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Local filesystem path to an image file. Use an absolute path, a workspace-relative path, or an absolute path inside the current workspace." + }, + "detail": { + "type": "string", + "enum": ["original"], + "description": "Optional detail override. Supported value: original. The current BitFun tool attaches the original local image bytes." + } + }, + "required": ["path"], + "additionalProperties": false + }) + } + + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { + context + .map(|ctx| ctx.primary_model_supports_image_understanding()) + .unwrap_or(true) + } + + fn is_readonly(&self) -> bool { + true + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + if let Err(err) = Self::validate_detail(input) { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + }; + } + + let input_path = match Self::path_from_input(input) { + Ok(path) => path, + Err(err) => { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + } + } + }; + + let path = match Self::resolve_path(input_path, context) { + Ok(path) => path, + Err(err) => { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + } + } + }; + + match &path { + ResolvedImagePath::Local(local_path) => match std::fs::metadata(local_path) { + Ok(metadata) if metadata.is_file() => ValidationResult::default(), + Ok(_) => ValidationResult { + result: false, + message: Some(format!("image path is not a file: {}", path.display_path())), + error_code: Some(400), + meta: None, + }, + Err(err) => ValidationResult { + result: false, + message: Some(format!( + "unable to locate image at {}: {}", + path.display_path(), + err + )), + error_code: Some(404), + meta: None, + }, + }, + ResolvedImagePath::RemoteWorkspace { + path: remote_path, .. + } => { + let Some(fs) = context.and_then(|ctx| ctx.ws_fs()) else { + return ValidationResult { + result: false, + message: Some( + "Workspace filesystem services are required to validate remote image paths" + .to_string(), + ), + error_code: Some(400), + meta: None, + }; + }; + match fs.is_file(remote_path).await { + Ok(true) => ValidationResult::default(), + Ok(false) => ValidationResult { + result: false, + message: Some(format!("image path is not a file: {}", path.display_path())), + error_code: Some(400), + meta: None, + }, + Err(err) => ValidationResult { + result: false, + message: Some(format!( + "unable to locate image at {}: {}", + path.display_path(), + err + )), + error_code: Some(404), + meta: None, + }, + } + } + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let path = input.get("path").and_then(Value::as_str).unwrap_or(""); + if path.is_empty() { + "Viewing local image".to_string() + } else { + format!("Viewing local image: {}", path) + } + } + + fn render_tool_result_message(&self, output: &Value) -> String { + output + .get("summary") + .and_then(Value::as_str) + .unwrap_or("Image attached") + .to_string() + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + Self::require_multimodal_tool_output(context)?; + Self::validate_detail(input)?; + + let input_path = Self::path_from_input(input)?; + let path = Self::resolve_path(input_path, Some(context))?; + let bytes = Self::read_image_bytes(&path, Some(context)).await?; + let mime_type = Self::mime_type_for_image(&bytes)?; + let data_base64 = base64::engine::general_purpose::STANDARD.encode(bytes); + let summary = format!("Attached image: {}", path.display_path()); + let data = json!({ + "path": path.display_path(), + "mime_type": mime_type, + "summary": summary, + }); + + Ok(vec![ToolResult::ok_with_images( + data, + Some("Image attached for model vision.".to_string()), + vec![ToolImageAttachment { + mime_type: mime_type.to_string(), + data_base64, + }], + )]) + } +} + +#[cfg(test)] +mod tests { + use super::ViewImageTool; + use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::workspace::{ + WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, WorkspaceFileSystem, + WorkspaceServices, WorkspaceShell, + }; + use crate::agentic::WorkspaceBinding; + use crate::service::remote_ssh::workspace_state::workspace_session_identity; + use async_trait::async_trait; + use serde_json::json; + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + use std::sync::Arc; + + const ONE_BY_ONE_PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, + 0x1c, 0x0c, 0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0x01, 0x63, 0xfc, + 0xff, 0x1f, 0x00, 0x03, 0x03, 0x02, 0x00, 0xf7, 0x7f, 0x2d, 0xa4, 0x00, 0x00, 0x00, 0x00, + 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + + struct FakeRemoteFs; + + #[async_trait] + impl WorkspaceFileSystem for FakeRemoteFs { + async fn read_file(&self, path: &str) -> anyhow::Result> { + if path == "/remote/workspace/screenshots/pixel.png" { + return Ok(ONE_BY_ONE_PNG.to_vec()); + } + anyhow::bail!("not found: {}", path) + } + + async fn read_file_text(&self, path: &str) -> anyhow::Result { + anyhow::bail!("not text: {}", path) + } + + async fn write_file(&self, _path: &str, _contents: &[u8]) -> anyhow::Result<()> { + Ok(()) + } + + async fn exists(&self, path: &str) -> anyhow::Result { + Ok(path == "/remote/workspace/screenshots/pixel.png") + } + + async fn is_file(&self, path: &str) -> anyhow::Result { + Ok(path == "/remote/workspace/screenshots/pixel.png") + } + + async fn is_dir(&self, _path: &str) -> anyhow::Result { + Ok(false) + } + + async fn read_dir(&self, _path: &str) -> anyhow::Result> { + Ok(Vec::new()) + } + } + + struct FakeShell; + + #[async_trait] + impl WorkspaceShell for FakeShell { + async fn exec_with_options( + &self, + _command: &str, + _options: WorkspaceCommandOptions, + ) -> anyhow::Result { + Ok(WorkspaceCommandResult { + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + interrupted: false, + timed_out: false, + }) + } + } + + fn context(provider: &str, supports_images: bool) -> ToolUseContext { + let mut custom_data = HashMap::new(); + custom_data.insert("primary_model_provider".to_string(), json!(provider)); + custom_data.insert( + "primary_model_supports_image_understanding".to_string(), + json!(supports_images), + ); + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data, + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + fn remote_context(provider: &str, supports_images: bool) -> ToolUseContext { + let mut context = context(provider, supports_images); + let root = "/remote/workspace"; + let session_identity = workspace_session_identity(root, Some("conn-1"), Some("host")) + .expect("remote identity"); + context.workspace = Some(WorkspaceBinding::new_remote( + Some("workspace-remote".to_string()), + PathBuf::from(root), + "conn-1".to_string(), + "remote-session".to_string(), + session_identity, + )); + context.runtime_handles = bitfun_runtime_ports::ToolRuntimeHandles::new( + Some(WorkspaceServices { + fs: Arc::new(FakeRemoteFs), + shell: Arc::new(FakeShell), + }), + None, + ); + context + } + + #[tokio::test] + async fn view_image_attaches_local_image() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("pixel.png"); + fs::write(&path, ONE_BY_ONE_PNG).expect("write png"); + + let results = ViewImageTool::new() + .call_impl(&json!({ "path": path }), &context("openai", true)) + .await + .expect("view image result"); + + let ToolResult::Result { + data, + image_attachments, + .. + } = &results[0] + else { + panic!("expected result"); + }; + assert_eq!(data["mime_type"], "image/png"); + let attachments = image_attachments.as_ref().expect("image attachments"); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].mime_type, "image/png"); + assert!(!attachments[0].data_base64.is_empty()); + } + + #[tokio::test] + async fn view_image_rejects_text_only_model() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("pixel.png"); + fs::write(&path, ONE_BY_ONE_PNG).expect("write png"); + + let error = ViewImageTool::new() + .call_impl(&json!({ "path": path }), &context("openai", false)) + .await + .expect_err("text-only model should be rejected"); + + assert!(error.to_string().contains("does not accept image inputs")); + } + + #[tokio::test] + async fn view_image_rejects_non_image_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("note.txt"); + fs::write(&path, "not an image").expect("write text"); + + let error = ViewImageTool::new() + .call_impl(&json!({ "path": path }), &context("openai", true)) + .await + .expect_err("non-image should be rejected"); + + assert!(error.to_string().contains("supported image files")); + } + + #[tokio::test] + async fn view_image_is_visible_in_remote_sessions_for_local_absolute_paths() { + assert!( + ViewImageTool::new() + .is_available_in_context(Some(&remote_context("openai", true))) + .await + ); + } + + #[tokio::test] + async fn view_image_reads_remote_workspace_relative_image() { + let results = ViewImageTool::new() + .call_impl( + &json!({ "path": "screenshots/pixel.png" }), + &remote_context("openai", true), + ) + .await + .expect("remote image result"); + + let ToolResult::Result { + data, + image_attachments, + .. + } = &results[0] + else { + panic!("expected result"); + }; + assert_eq!(data["path"], "/remote/workspace/screenshots/pixel.png"); + assert_eq!(data["mime_type"], "image/png"); + let attachments = image_attachments.as_ref().expect("image attachments"); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].mime_type, "image/png"); + } +} diff --git a/src/crates/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/core/src/agentic/tools/product_runtime/materialization.rs index 93c982d14..cb1455ffe 100644 --- a/src/crates/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/core/src/agentic/tools/product_runtime/materialization.rs @@ -18,6 +18,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { match tool_name { "LS" => Some(Arc::new(LSTool::new())), "Read" => Some(Arc::new(FileReadTool::new())), + "view_image" => Some(Arc::new(ViewImageTool::new())), "Glob" => Some(Arc::new(GlobTool::new())), "Grep" => Some(Arc::new(GrepTool::new())), "Write" => Some(Arc::new(FileWriteTool::new())), diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index a82a4dd7b..9454ed423 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -338,6 +338,7 @@ mod tests { let expected_names = vec![ "LS", "Read", + "view_image", "Glob", "Grep", "Write", @@ -569,6 +570,7 @@ mod tests { vec![ "LS", "Read", + "view_image", "Glob", "Grep", "Skill", diff --git a/src/crates/tool-packs/src/lib.rs b/src/crates/tool-packs/src/lib.rs index fffb530df..ea3a96cfb 100644 --- a/src/crates/tool-packs/src/lib.rs +++ b/src/crates/tool-packs/src/lib.rs @@ -115,7 +115,15 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ provider_id: "core.basic", feature_groups: CORE_BASIC_FEATURE_GROUPS, tool_names: &[ - "LS", "Read", "Glob", "Grep", "Write", "Edit", "Delete", "Bash", + "LS", + "Read", + "view_image", + "Glob", + "Grep", + "Write", + "Edit", + "Delete", + "Bash", ], }, ToolProviderGroupPlan { @@ -324,6 +332,7 @@ mod tests { vec![ "LS", "Read", + "view_image", "Glob", "Grep", "Write",