From 32c7a3df95efbf34e54a538ba61996e4ef0e40fa Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Wed, 8 Jan 2025 19:25:51 -0800 Subject: [PATCH] feat: Implement a simplified reference agent and dev toolkit (#564) --- crates/goose-cli/src/commands/mcp.rs | 3 +- crates/goose-cli/src/commands/session.rs | 4 +- crates/goose-mcp/src/developer2/lang.rs | 34 ++ crates/goose-mcp/src/developer2/mod.rs | 530 +++++++++++++++++++++ crates/goose-mcp/src/lib.rs | 2 + crates/goose/src/agents/mod.rs | 1 + crates/goose/src/agents/reference.rs | 147 ++++++ crates/goose/src/message.rs | 4 + crates/goose/src/providers/openai_utils.rs | 3 + crates/mcp-core/src/content.rs | 45 ++ 10 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 crates/goose-mcp/src/developer2/lang.rs create mode 100644 crates/goose-mcp/src/developer2/mod.rs create mode 100644 crates/goose/src/agents/reference.rs diff --git a/crates/goose-cli/src/commands/mcp.rs b/crates/goose-cli/src/commands/mcp.rs index 42f17aa413..291e4d620a 100644 --- a/crates/goose-cli/src/commands/mcp.rs +++ b/crates/goose-cli/src/commands/mcp.rs @@ -1,6 +1,6 @@ use anyhow::Result; use goose_mcp::NonDeveloperRouter; -use goose_mcp::{DeveloperRouter, JetBrainsRouter}; +use goose_mcp::{Developer2Router, DeveloperRouter, JetBrainsRouter}; use mcp_server::router::RouterService; use mcp_server::{BoundedService, ByteTransport, Server}; use tokio::io::{stdin, stdout}; @@ -10,6 +10,7 @@ pub async fn run_server(name: &str) -> Result<()> { let router: Option> = match name { "developer" => Some(Box::new(RouterService(DeveloperRouter::new()))), + "developer2" => Some(Box::new(RouterService(Developer2Router::new()))), "nondeveloper" => Some(Box::new(RouterService(NonDeveloperRouter::new()))), "jetbrains" => Some(Box::new(RouterService(JetBrainsRouter::new()))), _ => None, diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index c3dd7373d4..099adacff4 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -55,13 +55,15 @@ pub async fn build_session<'a>( // TODO use systems from the profile // TODO once the client/server for MCP has stabilized, we should probably add InProcess transport to each // and avoid spawning here. But it is at least included in the CLI for portability + + let system = std::env::var("GOOSE_SYSTEM").unwrap_or("developer".to_string()); let config = SystemConfig::stdio( std::env::current_exe() .expect("should find the current executable") .to_str() .expect("should resolve executable to string path"), ) - .with_args(vec!["mcp", "developer"]); + .with_args(vec!["mcp", &system]); agent .add_system(config) .await diff --git a/crates/goose-mcp/src/developer2/lang.rs b/crates/goose-mcp/src/developer2/lang.rs new file mode 100644 index 0000000000..4d5609de25 --- /dev/null +++ b/crates/goose-mcp/src/developer2/lang.rs @@ -0,0 +1,34 @@ +use std::path::Path; + +/// Get the markdown language identifier for a file extension +pub fn get_language_identifier(path: &Path) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()) { + Some("rs") => "rust", + Some("py") => "python", + Some("js") => "javascript", + Some("ts") => "typescript", + Some("json") => "json", + Some("toml") => "toml", + Some("yaml") | Some("yml") => "yaml", + Some("sh") => "bash", + Some("go") => "go", + Some("md") => "markdown", + Some("html") => "html", + Some("css") => "css", + Some("sql") => "sql", + Some("java") => "java", + Some("cpp") | Some("cc") | Some("cxx") => "cpp", + Some("c") => "c", + Some("h") | Some("hpp") => "cpp", + Some("rb") => "ruby", + Some("php") => "php", + Some("swift") => "swift", + Some("kt") | Some("kts") => "kotlin", + Some("scala") => "scala", + Some("r") => "r", + Some("m") => "matlab", + Some("pl") => "perl", + Some("dockerfile") => "dockerfile", + _ => "", + } +} diff --git a/crates/goose-mcp/src/developer2/mod.rs b/crates/goose-mcp/src/developer2/mod.rs new file mode 100644 index 0000000000..451305aa4e --- /dev/null +++ b/crates/goose-mcp/src/developer2/mod.rs @@ -0,0 +1,530 @@ +mod lang; + +use anyhow::Result; +use indoc::formatdoc; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + future::Future, + path::{Path, PathBuf}, + pin::Pin, +}; +use tokio::process::Command; +use url::Url; + +use mcp_core::{ + handler::{ResourceError, ToolError}, + protocol::ServerCapabilities, + resource::Resource, + tool::Tool, +}; +use mcp_server::router::CapabilitiesBuilder; +use mcp_server::Router; + +use mcp_core::content::Content; +use mcp_core::role::Role; + +use indoc::indoc; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; + +pub struct Developer2Router { + tools: Vec, + file_history: Arc>>>, + instructions: String, +} + +impl Default for Developer2Router { + fn default() -> Self { + Self::new() + } +} + +impl Developer2Router { + pub fn new() -> Self { + // TODO consider rust native search tools, we could use + // https://docs.rs/ignore/latest/ignore/ + + let bash_tool = Tool::new( + "shell".to_string(), + indoc! {r#" + Execute a command in the shell. + + This will return the output and error concatenated into a single string, as + you would see from running on the command line. There will also be an indication + of if the command succeeded or failed. + + Avoid commands that produce a large amount of ouput, and consider piping those outputs to files. + If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that + this tool does not run indefinitely. + + **Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions + may show ignored or hidden files. For example *do not* use `find` or `ls -r` + - To locate a file by name: `rg --files | rg example.py` + - To locate consent inside files: `rg 'class Example'` + "#}.to_string(), + json!({ + "type": "object", + "required": ["command"], + "properties": { + "command": {"type": "string"} + } + }), + ); + + let text_editor_tool = Tool::new( + "text_editor".to_string(), + indoc! {r#" + Perform text editing operations on files. + + The `command` parameter specifies the operation to perform. Allowed options are: + - `view`: View the content of a file. + - `create`: Create a new file with the given content (it will fail if the file already exists) + - `str_replace`: Replace a string in a file with a new string. + - `undo_edit`: Undo the last edit made to a file. + + To use the create command, you must specify `file_text` which will become the content of the new file. + + To use the str_replace command, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one + unique section of the original file, including any whitespace. Make sure to include enough context that the match is not + ambiguous. The entire original string will be replaced with `new_str`. + "#}.to_string(), + json!({ + "type": "object", + "required": ["command", "path"], + "properties": { + "path": { + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.", + "type": "string" + }, + "command": { + "type": "string", + "enum": ["view", "create", "str_replace", "undo_edit"], + "description": "Allowed options are: `view`, `create`, `str_replace`, undo_edit`." + }, + "old_str": {"type": "string"}, + "new_str": {"type": "string"}, + "file_text": {"type": "string"} + } + }), + ); + + let instructions = formatdoc! {r#" + The developer system gives you the capabilities to edit code files and run shell commands, + and can be used to solve a wide range of problems. + + You can use the shell tool to run any command that would work on the relevant operating system. + Use the shell tool as needed to locate files or interact with the project. + + operating system: {os} + current directory: {cwd} + + "#, + os=std::env::consts::OS, + cwd=std::env::current_dir().expect("should have a current working dir").to_string_lossy(), + }; + + Self { + tools: vec![bash_tool, text_editor_tool], + file_history: Arc::new(Mutex::new(HashMap::new())), + instructions, + } + } + + // Helper method to resolve a path relative to cwd + fn resolve_path(&self, path_str: &str) -> Result { + let cwd = std::env::current_dir().expect("should have a current working dir"); + let expanded = shellexpand::tilde(path_str); + let path = Path::new(expanded.as_ref()); + + let suggestion = cwd.join(path); + + match path.is_absolute() { + true => Ok(path.to_path_buf()), + false => Err(ToolError::InvalidParameters(format!( + "The path {} is not an absolute path, did you possibly mean {}?", + path_str, + suggestion.to_string_lossy(), + ))), + } + } + + // Implement bash tool functionality + async fn bash(&self, params: Value) -> Result, ToolError> { + let command = + params + .get("command") + .and_then(|v| v.as_str()) + .ok_or(ToolError::InvalidParameters( + "The command string is required".to_string(), + ))?; + + // TODO consider command suggestions and safety rails + + // TODO be more careful about backgrounding, revisit interleave + // Redirect stderr to stdout to interleave outputs + let cmd_with_redirect = format!("{} 2>&1", command); + + // Execute the command + let child = Command::new("bash") + .stdout(Stdio::piped()) // These two pipes required to capture output later. + .stderr(Stdio::piped()) + .kill_on_drop(true) // Critical so that the command is killed when the agent.reply stream is interrupted. + .arg("-c") + .arg(cmd_with_redirect) + .spawn() + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + // Wait for the command to complete and get output + let output = child + .wait_with_output() + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let output_str = String::from_utf8_lossy(&output.stdout); + Ok(vec![ + Content::text(output_str.clone()).with_audience(vec![Role::Assistant]), + Content::text(output_str) + .with_audience(vec![Role::User]) + .with_priority(0.0), + ]) + } + + async fn text_editor(&self, params: Value) -> Result, ToolError> { + let command = params + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'command' parameter".to_string()) + })?; + + let path_str = params + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'path' parameter".into()))?; + + let path = self.resolve_path(path_str)?; + + match command { + "view" => self.text_editor_view(&path).await, + "create" => { + let file_text = params + .get("file_text") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'file_text' parameter".into()) + })?; + + self.text_editor_create(&path, file_text).await + } + "str_replace" => { + let old_str = params + .get("old_str") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'old_str' parameter".into()) + })?; + let new_str = params + .get("new_str") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'new_str' parameter".into()) + })?; + + self.text_editor_replace(&path, old_str, new_str).await + } + "undo_edit" => self.text_editor_undo(&path).await, + _ => Err(ToolError::InvalidParameters(format!( + "Unknown command '{}'", + command + ))), + } + } + + async fn text_editor_view(&self, path: &PathBuf) -> Result, ToolError> { + if path.is_file() { + // Check file size first (2MB limit) + const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; // 2MB in bytes + const MAX_CHAR_COUNT: usize = 1 << 20; // 2^20 characters (1,048,576) + + let file_size = std::fs::metadata(path) + .map_err(|e| { + ToolError::ExecutionError(format!("Failed to get file metadata: {}", e)) + })? + .len(); + + if file_size > MAX_FILE_SIZE { + return Err(ToolError::ExecutionError(format!( + "File '{}' is too large ({:.2}MB). Maximum size is 2MB to prevent memory issues.", + path.display(), + file_size as f64 / 1024.0 / 1024.0 + ))); + } + + let uri = Url::from_file_path(path) + .map_err(|_| ToolError::ExecutionError("Invalid file path".into()))? + .to_string(); + + let content = std::fs::read_to_string(path) + .map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?; + + let char_count = content.chars().count(); + if char_count > MAX_CHAR_COUNT { + return Err(ToolError::ExecutionError(format!( + "File '{}' has too many characters ({}). Maximum character count is {}.", + path.display(), + char_count, + MAX_CHAR_COUNT + ))); + } + + let language = lang::get_language_identifier(path); + let formatted = formatdoc! {" + ### {path} + ```{language} + {content} + ``` + ", + path=path.display(), + language=language, + content=content, + }; + + // The LLM gets just a quick update as we expect the file to view in the status + // but we send a low priority message for the human + Ok(vec![ + Content::embedded_text(uri, content).with_audience(vec![Role::Assistant]), + Content::text(formatted) + .with_audience(vec![Role::User]) + .with_priority(0.0), + ]) + } else { + Err(ToolError::ExecutionError(format!( + "The path '{}' does not exist or is not a file.", + path.display() + ))) + } + } + + async fn text_editor_create( + &self, + path: &PathBuf, + file_text: &str, + ) -> Result, ToolError> { + // Check if file already exists + if path.exists() { + return Err(ToolError::InvalidParameters(format!( + "File '{}' already exists - you will need to edit it with the `str_replace` command", + path.display() + ))); + } + + // Write to the file + std::fs::write(path, file_text) + .map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?; + + // Try to detect the language from the file extension + let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + + // The assistant output does not show the file again because the content is already in the tool request + // but we do show it to the user here + Ok(vec![ + Content::text(format!("Successfully wrote to {}", path.display())) + .with_audience(vec![Role::Assistant]), + Content::text(formatdoc! {r#" + ### {path} + ```{language} + {content} + ``` + "#, + path=path.display(), + language=language, + content=file_text, + }) + .with_audience(vec![Role::User]) + .with_priority(0.2), + ]) + } + + async fn text_editor_replace( + &self, + path: &PathBuf, + old_str: &str, + new_str: &str, + ) -> Result, ToolError> { + // Check if file exists and is active + if !path.exists() { + return Err(ToolError::InvalidParameters(format!( + "File '{}' does not exist, you can write a new file with the `create` command", + path.display() + ))); + } + + // Read content + let content = std::fs::read_to_string(path) + .map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?; + + // Ensure 'old_str' appears exactly once + if content.matches(old_str).count() > 1 { + return Err(ToolError::InvalidParameters( + "'old_str' must appear exactly once in the file, but it appears multiple times" + .into(), + )); + } + if content.matches(old_str).count() == 0 { + return Err(ToolError::InvalidParameters( + "'old_str' must appear exactly once in the file, but it does not appear in the file. Make sure the string exactly matches existing file content, including whitespace!".into(), + )); + } + + // Save history for undo + self.save_file_history(path)?; + + // Replace and write back + let new_content = content.replace(old_str, new_str); + std::fs::write(path, &new_content) + .map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?; + + // Try to detect the language from the file extension + let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + + // Show a snippet of the changed content with context + const SNIPPET_LINES: usize = 4; + + // Count newlines before the replacement to find the line number + let replacement_line = content + .split(old_str) + .next() + .expect("should split on already matched content") + .matches('\n') + .count(); + + // Calculate start and end lines for the snippet + let start_line = replacement_line.saturating_sub(SNIPPET_LINES); + let end_line = replacement_line + SNIPPET_LINES + new_str.matches('\n').count(); + + // Get the relevant lines for our snippet + let lines: Vec<&str> = new_content.lines().collect(); + let snippet = lines + .iter() + .skip(start_line) + .take(end_line - start_line + 1) + .cloned() + .collect::>() + .join("\n"); + + let output = formatdoc! {r#" + ```{language} + {snippet} + ``` + "#, + language=language, + snippet=snippet + }; + + let success_message = formatdoc! {r#" + The file {} has been edited, and the section now reads: + {} + Review the changes above for errors. Undo and edit the file again if necessary! + "#, + path.display(), + output + }; + + Ok(vec![ + Content::text(success_message).with_audience(vec![Role::Assistant]), + Content::text(output) + .with_audience(vec![Role::User]) + .with_priority(0.2), + ]) + } + + async fn text_editor_undo(&self, path: &PathBuf) -> Result, ToolError> { + let mut history = self.file_history.lock().unwrap(); + if let Some(contents) = history.get_mut(path) { + if let Some(previous_content) = contents.pop() { + // Write previous content back to file + std::fs::write(path, previous_content).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write file: {}", e)) + })?; + Ok(vec![Content::text("Undid the last edit")]) + } else { + Err(ToolError::InvalidParameters( + "No edit history available to undo".into(), + )) + } + } else { + Err(ToolError::InvalidParameters( + "No edit history available to undo".into(), + )) + } + } + + fn save_file_history(&self, path: &PathBuf) -> Result<(), ToolError> { + let mut history = self.file_history.lock().unwrap(); + let content = if path.exists() { + std::fs::read_to_string(path) + .map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))? + } else { + String::new() + }; + history.entry(path.clone()).or_default().push(content); + Ok(()) + } +} + +impl Router for Developer2Router { + fn name(&self) -> String { + "developer".to_string() + } + + fn instructions(&self) -> String { + self.instructions.clone() + } + + fn capabilities(&self) -> ServerCapabilities { + CapabilitiesBuilder::new().with_tools(true).build() + } + + fn list_tools(&self) -> Vec { + self.tools.clone() + } + + fn call_tool( + &self, + tool_name: &str, + arguments: Value, + ) -> Pin, ToolError>> + Send + 'static>> { + let this = self.clone(); + let tool_name = tool_name.to_string(); + Box::pin(async move { + match tool_name.as_str() { + "shell" => this.bash(arguments).await, + "text_editor" => this.text_editor(arguments).await, + _ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))), + } + }) + } + + // TODO see if we can make it easy to skip implementing these + fn list_resources(&self) -> Vec { + Vec::new() + } + + fn read_resource( + &self, + _uri: &str, + ) -> Pin> + Send + 'static>> { + Box::pin(async move { Ok("".to_string()) }) + } +} + +impl Clone for Developer2Router { + fn clone(&self) -> Self { + Self { + tools: self.tools.clone(), + file_history: Arc::clone(&self.file_history), + instructions: self.instructions.clone(), + } + } +} diff --git a/crates/goose-mcp/src/lib.rs b/crates/goose-mcp/src/lib.rs index 9af0889935..ed1f397fe3 100644 --- a/crates/goose-mcp/src/lib.rs +++ b/crates/goose-mcp/src/lib.rs @@ -1,7 +1,9 @@ mod developer; +mod developer2; mod jetbrains; mod nondeveloper; pub use developer::DeveloperRouter; +pub use developer2::Developer2Router; pub use jetbrains::JetBrainsRouter; pub use nondeveloper::NonDeveloperRouter; diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index b7d41da66e..f487cf8bfe 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -3,6 +3,7 @@ mod capabilities; mod default; mod factory; mod system; +mod reference; pub use agent::Agent; pub use capabilities::Capabilities; diff --git a/crates/goose/src/agents/reference.rs b/crates/goose/src/agents/reference.rs new file mode 100644 index 0000000000..b5e7c97a54 --- /dev/null +++ b/crates/goose/src/agents/reference.rs @@ -0,0 +1,147 @@ +/// A simplified agent implementation used as a reference +/// It makes no attempt to handle context limits, and cannot read resources +use async_trait::async_trait; +use futures::stream::BoxStream; +use tokio::sync::Mutex; +use tracing::{debug, instrument}; + +use super::Agent; +use crate::agents::capabilities::Capabilities; +use crate::agents::system::{SystemConfig, SystemResult}; +use crate::message::{Message, ToolRequest}; +use crate::providers::base::Provider; +use crate::providers::base::ProviderUsage; +use crate::register_agent; +use crate::token_counter::TokenCounter; +use serde_json::Value; +/// Reference implementation of an Agent +pub struct ReferenceAgent { + capabilities: Mutex, + _token_counter: TokenCounter, +} + +impl ReferenceAgent { + pub fn new(provider: Box) -> Self { + Self { + capabilities: Mutex::new(Capabilities::new(provider)), + _token_counter: TokenCounter::new(), + } + } +} + +#[async_trait] +impl Agent for ReferenceAgent { + async fn add_system(&mut self, system: SystemConfig) -> SystemResult<()> { + let mut capabilities = self.capabilities.lock().await; + capabilities.add_system(system).await + } + + async fn remove_system(&mut self, name: &str) { + let mut capabilities = self.capabilities.lock().await; + capabilities + .remove_system(name) + .await + .expect("Failed to remove system"); + } + + async fn list_systems(&self) -> Vec { + let capabilities = self.capabilities.lock().await; + capabilities + .list_systems() + .await + .expect("Failed to list systems") + } + + async fn passthrough(&self, _system: &str, _request: Value) -> SystemResult { + // TODO implement + Ok(Value::Null) + } + + #[instrument(skip(self, messages), fields(user_message))] + async fn reply( + &self, + messages: &[Message], + ) -> anyhow::Result>> { + let mut messages = messages.to_vec(); + let reply_span = tracing::Span::current(); + let mut capabilities = self.capabilities.lock().await; + let tools = capabilities.get_prefixed_tools().await?; + let system_prompt = capabilities.get_system_prompt().await; + let _estimated_limit = capabilities + .provider() + .get_model_config() + .get_estimated_limit(); + + // Set the user_message field in the span instead of creating a new event + if let Some(content) = messages + .last() + .and_then(|msg| msg.content.first()) + .and_then(|c| c.as_text()) + { + debug!("user_message" = &content); + } + + // Update conversation history for the start of the reply + let _resources = capabilities.get_resources().await?; + + Ok(Box::pin(async_stream::try_stream! { + let _reply_guard = reply_span.enter(); + loop { + // Get completion from provider + let (response, usage) = capabilities.provider().complete( + &system_prompt, + &messages, + &tools, + ).await?; + capabilities.record_usage(usage).await; + + // Yield the assistant's response + yield response.clone(); + + tokio::task::yield_now().await; + + // First collect any tool requests + let tool_requests: Vec<&ToolRequest> = response.content + .iter() + .filter_map(|content| content.as_tool_request()) + .collect(); + + if tool_requests.is_empty() { + break; + } + + // Then dispatch each in parallel + let futures: Vec<_> = tool_requests + .iter() + .filter_map(|request| request.tool_call.clone().ok()) + .map(|tool_call| capabilities.dispatch_tool_call(tool_call)) + .collect(); + + // Process all the futures in parallel but wait until all are finished + let outputs = futures::future::join_all(futures).await; + + // Create a message with the responses + let mut message_tool_response = Message::user(); + // Now combine these into MessageContent::ToolResponse using the original ID + for (request, output) in tool_requests.iter().zip(outputs.into_iter()) { + message_tool_response = message_tool_response.with_tool_response( + request.id.clone(), + output, + ); + } + + yield message_tool_response.clone(); + + messages.push(response); + messages.push(message_tool_response); + } + })) + } + + async fn usage(&self) -> Vec { + let capabilities = self.capabilities.lock().await; + capabilities.get_usage().await + } +} + +register_agent!("reference", ReferenceAgent); diff --git a/crates/goose/src/message.rs b/crates/goose/src/message.rs index 694e319d2f..f7f3708cd3 100644 --- a/crates/goose/src/message.rs +++ b/crates/goose/src/message.rs @@ -107,6 +107,10 @@ impl From for MessageContent { match content { Content::Text(text) => MessageContent::Text(text), Content::Image(image) => MessageContent::Image(image), + Content::Resource(resource) => MessageContent::Text(TextContent { + text: resource.get_text(), + annotations: None, + }), } } } diff --git a/crates/goose/src/providers/openai_utils.rs b/crates/goose/src/providers/openai_utils.rs index c004930c59..a5c6a916d0 100644 --- a/crates/goose/src/providers/openai_utils.rs +++ b/crates/goose/src/providers/openai_utils.rs @@ -89,6 +89,9 @@ pub fn messages_to_openai_spec( "content": [convert_image(&image, image_format)] })); } + Content::Resource(resource) => { + tool_content.push(Content::text(resource.get_text())); + } _ => { tool_content.push(content); } diff --git a/crates/mcp-core/src/content.rs b/crates/mcp-core/src/content.rs index fc1fa9cb1d..c5567a9ebe 100644 --- a/crates/mcp-core/src/content.rs +++ b/crates/mcp-core/src/content.rs @@ -2,6 +2,7 @@ /// The various content types can be display to humans but also understood by models /// They include optional annotations used to help inform agent usage use super::role::Role; +use crate::resource::ResourceContents; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -49,11 +50,29 @@ pub struct ImageContent { pub annotations: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EmbeddedResource { + resource: ResourceContents, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl EmbeddedResource { + pub fn get_text(&self) -> String { + match &self.resource { + ResourceContents::TextResourceContents { text, .. } => text.clone(), + _ => String::new(), + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Content { Text(TextContent), Image(ImageContent), + Resource(EmbeddedResource), } impl Content { @@ -72,6 +91,24 @@ impl Content { }) } + pub fn resource(resource: ResourceContents) -> Self { + Content::Resource(EmbeddedResource { + resource, + annotations: None, + }) + } + + pub fn embedded_text, T: Into>(uri: S, content: T) -> Self { + Content::Resource(EmbeddedResource { + resource: ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("text".to_string()), + text: content.into(), + }, + annotations: None, + }) + } + /// Get the text content if this is a TextContent variant pub fn as_text(&self) -> Option<&str> { match self { @@ -93,6 +130,7 @@ impl Content { let annotations = match &mut self { Content::Text(text) => &mut text.annotations, Content::Image(image) => &mut image.annotations, + Content::Resource(resource) => &mut resource.annotations, }; *annotations = Some(match annotations.take() { Some(mut a) => { @@ -118,6 +156,7 @@ impl Content { let annotations = match &mut self { Content::Text(text) => &mut text.annotations, Content::Image(image) => &mut image.annotations, + Content::Resource(resource) => &mut resource.annotations, }; *annotations = Some(match annotations.take() { Some(mut a) => { @@ -138,6 +177,10 @@ impl Content { match self { Content::Text(text) => text.annotations.as_ref().and_then(|a| a.audience.as_ref()), Content::Image(image) => image.annotations.as_ref().and_then(|a| a.audience.as_ref()), + Content::Resource(resource) => resource + .annotations + .as_ref() + .and_then(|a| a.audience.as_ref()), } } @@ -146,6 +189,7 @@ impl Content { match self { Content::Text(text) => text.annotations.as_ref().and_then(|a| a.priority), Content::Image(image) => image.annotations.as_ref().and_then(|a| a.priority), + Content::Resource(resource) => resource.annotations.as_ref().and_then(|a| a.priority), } } @@ -153,6 +197,7 @@ impl Content { match self { Content::Text(text) => Content::text(text.text.clone()), Content::Image(image) => Content::image(image.data.clone(), image.mime_type.clone()), + Content::Resource(resource) => Content::resource(resource.resource.clone()), } } }