diff --git a/Cargo.lock b/Cargo.lock index 89359f4558d7..4a56d84d63c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,6 +4790,7 @@ dependencies = [ name = "goose-types" version = "1.37.0" dependencies = [ + "base64 0.22.1", "regex", "rmcp", "serde", diff --git a/crates/goose-types/Cargo.toml b/crates/goose-types/Cargo.toml index 18f47864acdc..6fd80891b66e 100644 --- a/crates/goose-types/Cargo.toml +++ b/crates/goose-types/Cargo.toml @@ -12,6 +12,7 @@ description.workspace = true workspace = true [dependencies] +base64.workspace = true regex = { workspace = true } rmcp = { workspace = true, features = ["server"] } serde = { workspace = true } diff --git a/crates/goose-types/src/lib.rs b/crates/goose-types/src/lib.rs index e435628293eb..194b50cc8cc3 100644 --- a/crates/goose-types/src/lib.rs +++ b/crates/goose-types/src/lib.rs @@ -1,7 +1,11 @@ mod tool_result_serde; +use base64::Engine; use regex::{Regex, RegexBuilder}; -use rmcp::model::{CallToolRequestParams, CallToolResult, ErrorData, JsonObject}; +use rmcp::model::{ + AnnotateAble, CallToolRequestParams, CallToolResult, Content, ErrorData, ImageContent, + JsonObject, RawContent, RawImageContent, RawTextContent, Role, TextContent, +}; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -258,6 +262,396 @@ pub struct ActionRequired { pub data: ActionRequiredData, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +/// Content passed inside a message, which can be both simple content and tool content +#[serde(tag = "type", rename_all = "camelCase")] +pub enum MessageContent { + Text(TextContent), + Image(ImageContent), + ToolRequest(ToolRequest), + ToolResponse(ToolResponse), + ToolConfirmationRequest(ToolConfirmationRequest), + ActionRequired(ActionRequired), + FrontendToolRequest(FrontendToolRequest), + Thinking(ThinkingContent), + RedactedThinking(RedactedThinkingContent), + SystemNotification(SystemNotificationContent), +} + +impl fmt::Display for MessageContent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MessageContent::Text(t) => write!(f, "{}", t.text), + MessageContent::Image(i) => write!(f, "[Image: {}]", i.mime_type), + MessageContent::ToolRequest(r) => { + write!(f, "[ToolRequest: {}]", r.to_readable_string()) + } + MessageContent::ToolResponse(r) => write!( + f, + "[ToolResponse: {}]", + match &r.tool_result { + Ok(result) => format!("{} content item(s)", result.content.len()), + Err(e) => format!("Error: {e}"), + } + ), + MessageContent::ToolConfirmationRequest(r) => { + write!(f, "[ToolConfirmationRequest: {}]", r.tool_name) + } + MessageContent::ActionRequired(a) => match &a.data { + ActionRequiredData::ToolConfirmation { tool_name, .. } => { + write!(f, "[ActionRequired: ToolConfirmation for {}]", tool_name) + } + ActionRequiredData::Elicitation { message, .. } => { + write!(f, "[ActionRequired: Elicitation - {}]", message) + } + ActionRequiredData::ElicitationResponse { id, .. } => { + write!(f, "[ActionRequired: ElicitationResponse for {}]", id) + } + }, + MessageContent::FrontendToolRequest(r) => match &r.tool_call { + Ok(tool_call) => write!(f, "[FrontendToolRequest: {}]", tool_call.name), + Err(e) => write!(f, "[FrontendToolRequest: Error: {}]", e), + }, + MessageContent::Thinking(t) => write!(f, "[Thinking: {}]", t.thinking), + MessageContent::RedactedThinking(_r) => write!(f, "[RedactedThinking]"), + MessageContent::SystemNotification(r) => { + write!(f, "[SystemNotification: {}]", r.msg) + } + } + } +} + +impl MessageContent { + pub fn text>(text: S) -> Self { + MessageContent::Text( + RawTextContent { + text: text.into(), + meta: None, + } + .no_annotation(), + ) + } + + pub fn filter_for_audience(&self, audience: Role) -> Option { + match self { + MessageContent::Text(text) => { + if text + .audience() + .map(|roles| roles.contains(&audience)) + .unwrap_or(true) + { + Some(self.clone()) + } else { + None + } + } + MessageContent::Image(img) => { + if img + .audience() + .map(|roles| roles.contains(&audience)) + .unwrap_or(true) + { + Some(self.clone()) + } else { + None + } + } + MessageContent::ToolResponse(res) => { + let Ok(result) = &res.tool_result else { + return Some(self.clone()); + }; + + let filtered_content: Vec = result + .content + .iter() + .filter(|c| { + c.audience() + .map(|roles| roles.contains(&audience)) + .unwrap_or(true) + }) + .cloned() + .collect(); + + // Preserve ToolResponse even when content is empty - some providers + // (like Google) need to handle empty tool responses specially + let mut tool_result = result.clone(); + tool_result.content = filtered_content; + Some(MessageContent::ToolResponse(ToolResponse { + id: res.id.clone(), + tool_result: Ok(tool_result), + metadata: res.metadata.clone(), + })) + } + MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) => { + if audience == Role::Assistant { + Some(self.clone()) + } else { + None + } + } + _ => Some(self.clone()), + } + } + + pub fn image, T: Into>(data: S, mime_type: T) -> Self { + MessageContent::Image( + RawImageContent { + data: data.into(), + mime_type: mime_type.into(), + meta: None, + } + .no_annotation(), + ) + } + + pub fn tool_request>( + id: S, + tool_call: ToolResult, + ) -> Self { + MessageContent::ToolRequest(ToolRequest { + id: id.into(), + tool_call, + metadata: None, + tool_meta: None, + }) + } + + pub fn tool_request_with_metadata>( + id: S, + tool_call: ToolResult, + metadata: Option<&MessageProviderMetadata>, + ) -> Self { + MessageContent::ToolRequest(ToolRequest { + id: id.into(), + tool_call, + metadata: metadata.cloned(), + tool_meta: None, + }) + } + + pub fn tool_response>(id: S, tool_result: ToolResult) -> Self { + MessageContent::ToolResponse(ToolResponse { + id: id.into(), + tool_result, + metadata: None, + }) + } + + pub fn tool_response_with_metadata>( + id: S, + tool_result: ToolResult, + metadata: Option<&MessageProviderMetadata>, + ) -> Self { + MessageContent::ToolResponse(ToolResponse { + id: id.into(), + tool_result, + metadata: metadata.cloned(), + }) + } + + pub fn action_required>( + id: S, + tool_name: String, + arguments: JsonObject, + prompt: Option, + ) -> Self { + MessageContent::ActionRequired(ActionRequired { + data: ActionRequiredData::ToolConfirmation { + id: id.into(), + tool_name, + arguments, + prompt, + }, + }) + } + + pub fn action_required_elicitation>( + id: S, + message: String, + requested_schema: serde_json::Value, + ) -> Self { + MessageContent::ActionRequired(ActionRequired { + data: ActionRequiredData::Elicitation { + id: id.into(), + message, + requested_schema, + }, + }) + } + + pub fn action_required_elicitation_response>( + id: S, + user_data: serde_json::Value, + ) -> Self { + MessageContent::ActionRequired(ActionRequired { + data: ActionRequiredData::ElicitationResponse { + id: id.into(), + user_data, + }, + }) + } + + pub fn thinking, S2: Into>(thinking: S1, signature: S2) -> Self { + MessageContent::Thinking(ThinkingContent { + thinking: thinking.into(), + signature: signature.into(), + }) + } + + pub fn redacted_thinking>(data: S) -> Self { + MessageContent::RedactedThinking(RedactedThinkingContent { data: data.into() }) + } + + pub fn frontend_tool_request>( + id: S, + tool_call: ToolResult, + ) -> Self { + MessageContent::FrontendToolRequest(FrontendToolRequest { + id: id.into(), + tool_call, + }) + } + + pub fn system_notification>( + notification_type: SystemNotificationType, + msg: S, + ) -> Self { + MessageContent::SystemNotification(SystemNotificationContent { + notification_type, + msg: msg.into(), + data: None, + }) + } + + pub fn system_notification_with_data>( + notification_type: SystemNotificationType, + msg: S, + data: serde_json::Value, + ) -> Self { + MessageContent::SystemNotification(SystemNotificationContent { + notification_type, + msg: msg.into(), + data: Some(data), + }) + } + + pub fn as_system_notification(&self) -> Option<&SystemNotificationContent> { + if let MessageContent::SystemNotification(ref notification) = self { + Some(notification) + } else { + None + } + } + + pub fn as_tool_request(&self) -> Option<&ToolRequest> { + if let MessageContent::ToolRequest(ref tool_request) = self { + Some(tool_request) + } else { + None + } + } + + pub fn as_tool_response(&self) -> Option<&ToolResponse> { + if let MessageContent::ToolResponse(ref tool_response) = self { + Some(tool_response) + } else { + None + } + } + + pub fn as_action_required(&self) -> Option<&ActionRequired> { + if let MessageContent::ActionRequired(ref action_required) = self { + Some(action_required) + } else { + None + } + } + + pub fn as_tool_response_text(&self) -> Option { + if let Some(tool_response) = self.as_tool_response() { + if let Ok(result) = &tool_response.tool_result { + let texts: Vec = result + .content + .iter() + .filter_map(|content| content.as_text().map(|t| t.text.to_string())) + .collect(); + if !texts.is_empty() { + return Some(texts.join("\n")); + } + } + } + None + } + + /// Get the text content if this is a TextContent variant + pub fn as_text(&self) -> Option<&str> { + match self { + MessageContent::Text(text) => Some(&text.text), + _ => None, + } + } + + /// Get the thinking content if this is a ThinkingContent variant + pub fn as_thinking(&self) -> Option<&ThinkingContent> { + match self { + MessageContent::Thinking(thinking) => Some(thinking), + _ => None, + } + } + + /// Get the redacted thinking content if this is a RedactedThinkingContent variant + pub fn as_redacted_thinking(&self) -> Option<&RedactedThinkingContent> { + match self { + MessageContent::RedactedThinking(redacted) => Some(redacted), + _ => None, + } + } +} + +impl From for MessageContent { + fn from(content: Content) -> Self { + match content.raw { + RawContent::Text(text) => { + MessageContent::Text(text.optional_annotate(content.annotations)) + } + RawContent::Image(image) => { + MessageContent::Image(image.optional_annotate(content.annotations)) + } + RawContent::ResourceLink(_link) => MessageContent::text("[Resource link]"), + RawContent::Resource(resource) => { + MessageContent::text(extract_text_from_resource(&resource.resource)) + } + RawContent::Audio(_) => { + MessageContent::text("[Audio content: not supported]".to_string()) + } + } + } +} + +pub fn extract_text_from_resource(resource: &rmcp::model::ResourceContents) -> String { + match resource { + rmcp::model::ResourceContents::TextResourceContents { text, .. } => text.clone(), + rmcp::model::ResourceContents::BlobResourceContents { + blob, mime_type, .. + } => match base64::engine::general_purpose::STANDARD.decode(blob) { + Ok(bytes) => { + let byte_len = bytes.len(); + match String::from_utf8(bytes) { + Ok(text) => text, + Err(_) => { + let mime = mime_type + .as_ref() + .map(|m| m.as_str()) + .unwrap_or("application/octet-stream"); + format!("[Binary content ({}) - {} bytes]", mime, byte_len) + } + } + } + Err(_) => blob.clone(), + }, + } +} + impl MessageMetadata { pub fn agent_only() -> Self { Self { @@ -947,6 +1341,88 @@ mod tests { assert!(req_no_summary.persisted_chain_summary().is_none()); } + #[test] + fn message_content_builders_create_text_image_and_thinking() { + let text = MessageContent::text("hello"); + assert_eq!(text.as_text(), Some("hello")); + + let image = MessageContent::image("image-bytes", "image/png"); + match image { + MessageContent::Image(image) => { + assert_eq!(image.data, "image-bytes"); + assert_eq!(image.mime_type, "image/png"); + } + other => panic!("expected image content, got {other:?}"), + } + + let thinking = MessageContent::thinking("reasoning", "sig"); + assert_eq!(thinking.as_thinking().unwrap().thinking, "reasoning"); + assert_eq!(thinking.as_thinking().unwrap().signature, "sig"); + + let redacted = MessageContent::redacted_thinking("redacted"); + assert_eq!(redacted.as_redacted_thinking().unwrap().data, "redacted"); + } + + #[test] + fn message_content_filters_for_audience() { + let assistant_text = MessageContent::Text( + RawTextContent { + text: "assistant only".to_string(), + meta: None, + } + .with_audience(vec![Role::Assistant]), + ); + assert!(assistant_text + .filter_for_audience(Role::Assistant) + .is_some()); + assert!(assistant_text.filter_for_audience(Role::User).is_none()); + + let thinking = MessageContent::thinking("provider reasoning", "sig"); + assert!(thinking.filter_for_audience(Role::Assistant).is_some()); + assert!(thinking.filter_for_audience(Role::User).is_none()); + + let response = MessageContent::tool_response( + "tool_1", + Ok(CallToolResult::success(vec![ + Content::text("assistant visible").with_audience(vec![Role::Assistant]), + Content::text("user visible").with_audience(vec![Role::User]), + ])), + ); + let filtered = response.filter_for_audience(Role::Assistant).unwrap(); + let text = filtered.as_tool_response_text().unwrap(); + assert_eq!(text, "assistant visible"); + } + + #[test] + fn message_content_converts_rmcp_content() { + let text = MessageContent::from(Content::text("hello")); + assert_eq!(text.as_text(), Some("hello")); + + let resource = rmcp::model::ResourceContents::TextResourceContents { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: "resource text".to_string(), + meta: None, + }; + assert_eq!(extract_text_from_resource(&resource), "resource text"); + } + + #[test] + fn extract_text_from_resource_handles_binary_blob() { + let blob = base64::engine::general_purpose::STANDARD.encode([0xFF, 0xFE]); + let resource = rmcp::model::ResourceContents::BlobResourceContents { + uri: "file:///image.png".to_string(), + mime_type: Some("image/png".to_string()), + blob, + meta: None, + }; + + assert_eq!( + extract_text_from_resource(&resource), + "[Binary content (image/png) - 2 bytes]" + ); + } + #[test] fn action_required_round_trips_tool_confirmation_payload() { let mut arguments = JsonObject::new(); diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index 40027218b497..b0ec9395b431 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -1,21 +1,18 @@ -use crate::mcp_utils::extract_text_from_resource; use crate::utils::sanitize_unicode_tags; use chrono::Utc; pub use goose_types::{ - ActionRequired, ActionRequiredData, FrontendToolRequest, InferenceMetadata, MessageMetadata, - MessageProviderMetadata as ProviderMetadata, PersistedChainSummary, RedactedThinkingContent, - SystemNotificationContent, SystemNotificationType, ThinkingContent, TokenState, ToolCallResult, - ToolConfirmationRequest, ToolRequest, ToolResponse, ToolResult, TOOL_META_CHAIN_SUMMARY_KEY, - TOOL_META_EXTERNAL_DISPATCH_KEY, TOOL_META_TITLE_KEY, + ActionRequired, ActionRequiredData, FrontendToolRequest, InferenceMetadata, MessageContent, + MessageMetadata, MessageProviderMetadata as ProviderMetadata, PersistedChainSummary, + RedactedThinkingContent, SystemNotificationContent, SystemNotificationType, ThinkingContent, + TokenState, ToolCallResult, ToolConfirmationRequest, ToolRequest, ToolResponse, ToolResult, + TOOL_META_CHAIN_SUMMARY_KEY, TOOL_META_EXTERNAL_DISPATCH_KEY, TOOL_META_TITLE_KEY, }; use rmcp::model::{ - AnnotateAble, CallToolRequestParams, CallToolResult, Content, ImageContent, JsonObject, - PromptMessage, PromptMessageContent, PromptMessageRole, RawContent, RawImageContent, - RawTextContent, Role, TextContent, + AnnotateAble, CallToolRequestParams, CallToolResult, JsonObject, PromptMessage, + PromptMessageContent, PromptMessageRole, RawTextContent, Role, }; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashSet; -use std::fmt; use utoipa::ToSchema; use uuid::Uuid; @@ -71,372 +68,6 @@ where Ok(content) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] -/// Content passed inside a message, which can be both simple content and tool content -#[serde(tag = "type", rename_all = "camelCase")] -pub enum MessageContent { - Text(TextContent), - Image(ImageContent), - ToolRequest(ToolRequest), - ToolResponse(ToolResponse), - ToolConfirmationRequest(ToolConfirmationRequest), - ActionRequired(ActionRequired), - FrontendToolRequest(FrontendToolRequest), - Thinking(ThinkingContent), - RedactedThinking(RedactedThinkingContent), - SystemNotification(SystemNotificationContent), -} - -impl fmt::Display for MessageContent { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MessageContent::Text(t) => write!(f, "{}", t.text), - MessageContent::Image(i) => write!(f, "[Image: {}]", i.mime_type), - MessageContent::ToolRequest(r) => { - write!(f, "[ToolRequest: {}]", r.to_readable_string()) - } - MessageContent::ToolResponse(r) => write!( - f, - "[ToolResponse: {}]", - match &r.tool_result { - Ok(result) => format!("{} content item(s)", result.content.len()), - Err(e) => format!("Error: {e}"), - } - ), - MessageContent::ToolConfirmationRequest(r) => { - write!(f, "[ToolConfirmationRequest: {}]", r.tool_name) - } - MessageContent::ActionRequired(a) => match &a.data { - ActionRequiredData::ToolConfirmation { tool_name, .. } => { - write!(f, "[ActionRequired: ToolConfirmation for {}]", tool_name) - } - ActionRequiredData::Elicitation { message, .. } => { - write!(f, "[ActionRequired: Elicitation - {}]", message) - } - ActionRequiredData::ElicitationResponse { id, .. } => { - write!(f, "[ActionRequired: ElicitationResponse for {}]", id) - } - }, - MessageContent::FrontendToolRequest(r) => match &r.tool_call { - Ok(tool_call) => write!(f, "[FrontendToolRequest: {}]", tool_call.name), - Err(e) => write!(f, "[FrontendToolRequest: Error: {}]", e), - }, - MessageContent::Thinking(t) => write!(f, "[Thinking: {}]", t.thinking), - MessageContent::RedactedThinking(_r) => write!(f, "[RedactedThinking]"), - MessageContent::SystemNotification(r) => { - write!(f, "[SystemNotification: {}]", r.msg) - } - } - } -} - -impl MessageContent { - pub fn text>(text: S) -> Self { - MessageContent::Text( - RawTextContent { - text: text.into(), - meta: None, - } - .no_annotation(), - ) - } - - pub fn filter_for_audience(&self, audience: Role) -> Option { - match self { - MessageContent::Text(text) => { - if text - .audience() - .map(|roles| roles.contains(&audience)) - .unwrap_or(true) - { - Some(self.clone()) - } else { - None - } - } - MessageContent::Image(img) => { - if img - .audience() - .map(|roles| roles.contains(&audience)) - .unwrap_or(true) - { - Some(self.clone()) - } else { - None - } - } - MessageContent::ToolResponse(res) => { - let Ok(result) = &res.tool_result else { - return Some(self.clone()); - }; - - let filtered_content: Vec = result - .content - .iter() - .filter(|c| { - c.audience() - .map(|roles| roles.contains(&audience)) - .unwrap_or(true) - }) - .cloned() - .collect(); - - // Preserve ToolResponse even when content is empty - some providers - // (like Google) need to handle empty tool responses specially - let mut tool_result = result.clone(); - tool_result.content = filtered_content; - Some(MessageContent::ToolResponse(ToolResponse { - id: res.id.clone(), - tool_result: Ok(tool_result), - metadata: res.metadata.clone(), - })) - } - MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) => { - if audience == Role::Assistant { - Some(self.clone()) - } else { - None - } - } - _ => Some(self.clone()), - } - } - - pub fn image, T: Into>(data: S, mime_type: T) -> Self { - MessageContent::Image( - RawImageContent { - data: data.into(), - mime_type: mime_type.into(), - meta: None, - } - .no_annotation(), - ) - } - - pub fn tool_request>( - id: S, - tool_call: ToolResult, - ) -> Self { - MessageContent::ToolRequest(ToolRequest { - id: id.into(), - tool_call, - metadata: None, - tool_meta: None, - }) - } - - pub fn tool_request_with_metadata>( - id: S, - tool_call: ToolResult, - metadata: Option<&ProviderMetadata>, - ) -> Self { - MessageContent::ToolRequest(ToolRequest { - id: id.into(), - tool_call, - metadata: metadata.cloned(), - tool_meta: None, - }) - } - - pub fn tool_response>(id: S, tool_result: ToolResult) -> Self { - MessageContent::ToolResponse(ToolResponse { - id: id.into(), - tool_result, - metadata: None, - }) - } - - pub fn tool_response_with_metadata>( - id: S, - tool_result: ToolResult, - metadata: Option<&ProviderMetadata>, - ) -> Self { - MessageContent::ToolResponse(ToolResponse { - id: id.into(), - tool_result, - metadata: metadata.cloned(), - }) - } - - pub fn action_required>( - id: S, - tool_name: String, - arguments: JsonObject, - prompt: Option, - ) -> Self { - MessageContent::ActionRequired(ActionRequired { - data: ActionRequiredData::ToolConfirmation { - id: id.into(), - tool_name, - arguments, - prompt, - }, - }) - } - - pub fn action_required_elicitation>( - id: S, - message: String, - requested_schema: serde_json::Value, - ) -> Self { - MessageContent::ActionRequired(ActionRequired { - data: ActionRequiredData::Elicitation { - id: id.into(), - message, - requested_schema, - }, - }) - } - - pub fn action_required_elicitation_response>( - id: S, - user_data: serde_json::Value, - ) -> Self { - MessageContent::ActionRequired(ActionRequired { - data: ActionRequiredData::ElicitationResponse { - id: id.into(), - user_data, - }, - }) - } - - pub fn thinking, S2: Into>(thinking: S1, signature: S2) -> Self { - MessageContent::Thinking(ThinkingContent { - thinking: thinking.into(), - signature: signature.into(), - }) - } - - pub fn redacted_thinking>(data: S) -> Self { - MessageContent::RedactedThinking(RedactedThinkingContent { data: data.into() }) - } - - pub fn frontend_tool_request>( - id: S, - tool_call: ToolResult, - ) -> Self { - MessageContent::FrontendToolRequest(FrontendToolRequest { - id: id.into(), - tool_call, - }) - } - - pub fn system_notification>( - notification_type: SystemNotificationType, - msg: S, - ) -> Self { - MessageContent::SystemNotification(SystemNotificationContent { - notification_type, - msg: msg.into(), - data: None, - }) - } - - pub fn system_notification_with_data>( - notification_type: SystemNotificationType, - msg: S, - data: serde_json::Value, - ) -> Self { - MessageContent::SystemNotification(SystemNotificationContent { - notification_type, - msg: msg.into(), - data: Some(data), - }) - } - - pub fn as_system_notification(&self) -> Option<&SystemNotificationContent> { - if let MessageContent::SystemNotification(ref notification) = self { - Some(notification) - } else { - None - } - } - - pub fn as_tool_request(&self) -> Option<&ToolRequest> { - if let MessageContent::ToolRequest(ref tool_request) = self { - Some(tool_request) - } else { - None - } - } - - pub fn as_tool_response(&self) -> Option<&ToolResponse> { - if let MessageContent::ToolResponse(ref tool_response) = self { - Some(tool_response) - } else { - None - } - } - - pub fn as_action_required(&self) -> Option<&ActionRequired> { - if let MessageContent::ActionRequired(ref action_required) = self { - Some(action_required) - } else { - None - } - } - - pub fn as_tool_response_text(&self) -> Option { - if let Some(tool_response) = self.as_tool_response() { - if let Ok(result) = &tool_response.tool_result { - let texts: Vec = result - .content - .iter() - .filter_map(|content| content.as_text().map(|t| t.text.to_string())) - .collect(); - if !texts.is_empty() { - return Some(texts.join("\n")); - } - } - } - None - } - - /// Get the text content if this is a TextContent variant - pub fn as_text(&self) -> Option<&str> { - match self { - MessageContent::Text(text) => Some(&text.text), - _ => None, - } - } - - /// Get the thinking content if this is a ThinkingContent variant - pub fn as_thinking(&self) -> Option<&ThinkingContent> { - match self { - MessageContent::Thinking(thinking) => Some(thinking), - _ => None, - } - } - - /// Get the redacted thinking content if this is a RedactedThinkingContent variant - pub fn as_redacted_thinking(&self) -> Option<&RedactedThinkingContent> { - match self { - MessageContent::RedactedThinking(redacted) => Some(redacted), - _ => None, - } - } -} - -impl From for MessageContent { - fn from(content: Content) -> Self { - match content.raw { - RawContent::Text(text) => { - MessageContent::Text(text.optional_annotate(content.annotations)) - } - RawContent::Image(image) => { - MessageContent::Image(image.optional_annotate(content.annotations)) - } - RawContent::ResourceLink(_link) => MessageContent::text("[Resource link]"), - RawContent::Resource(resource) => { - MessageContent::text(extract_text_from_resource(&resource.resource)) - } - RawContent::Audio(_) => { - MessageContent::text("[Audio content: not supported]".to_string()) - } - } - } -} - impl From for Message { fn from(prompt_message: PromptMessage) -> Self { // Create a new message with the appropriate role @@ -453,7 +84,7 @@ impl From for Message { } PromptMessageContent::ResourceLink { .. } => MessageContent::text("[Resource link]"), PromptMessageContent::Resource { resource } => { - MessageContent::text(extract_text_from_resource(&resource.resource)) + MessageContent::text(goose_types::extract_text_from_resource(&resource.resource)) } }; diff --git a/crates/goose/src/mcp_utils.rs b/crates/goose/src/mcp_utils.rs index 238d236c91a4..d094ced108a5 100644 --- a/crates/goose/src/mcp_utils.rs +++ b/crates/goose/src/mcp_utils.rs @@ -1,107 +1,2 @@ -use base64::Engine; -pub use goose_types::ToolResult; +pub use goose_types::{extract_text_from_resource, ToolResult}; pub use rmcp::model::ErrorData; -use rmcp::model::ResourceContents; - -pub fn extract_text_from_resource(resource: &ResourceContents) -> String { - match resource { - ResourceContents::TextResourceContents { text, .. } => text.clone(), - ResourceContents::BlobResourceContents { - blob, mime_type, .. - } => match base64::engine::general_purpose::STANDARD.decode(blob) { - Ok(bytes) => { - let byte_len = bytes.len(); - match String::from_utf8(bytes) { - Ok(text) => text, - Err(_) => { - let mime = mime_type - .as_ref() - .map(|m| m.as_str()) - .unwrap_or("application/octet-stream"); - format!("[Binary content ({}) - {} bytes]", mime, byte_len) - } - } - } - Err(_) => blob.clone(), - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use test_case::test_case; - - #[test_case("Hello, World!", "Hello, World!" ; "simple text")] - #[test_case("Hello from GitHub!", "Hello from GitHub!" ; "github content")] - #[test_case("", "" ; "empty text")] - fn test_extract_text_from_text_resource(input: &str, expected: &str) { - let resource = ResourceContents::TextResourceContents { - uri: "file:///test.txt".to_string(), - mime_type: Some("text/plain".to_string()), - text: input.to_string(), - meta: None, - }; - assert_eq!(extract_text_from_resource(&resource), expected); - } - - #[test_case("Hello from GitHub!", "Hello from GitHub!" ; "utf8 markdown")] - #[test_case("Simple text", "Simple text" ; "utf8 plain")] - fn test_extract_text_from_blob_utf8(input: &str, expected: &str) { - let blob = base64::engine::general_purpose::STANDARD.encode(input.as_bytes()); - let resource = ResourceContents::BlobResourceContents { - uri: "github://repo/file.md".to_string(), - mime_type: Some("text/markdown".to_string()), - blob, - meta: None, - }; - assert_eq!(extract_text_from_resource(&resource), expected); - } - - #[test] - fn test_extract_text_from_blob_binary() { - let binary_data: Vec = vec![0xFF, 0xFE, 0x00, 0x01, 0x89, 0x50, 0x4E, 0x47]; - let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data); - - let resource = ResourceContents::BlobResourceContents { - uri: "file:///image.png".to_string(), - mime_type: Some("image/png".to_string()), - blob, - meta: None, - }; - - assert_eq!( - extract_text_from_resource(&resource), - "[Binary content (image/png) - 8 bytes]" - ); - } - - #[test] - fn test_extract_text_from_blob_binary_no_mime_type() { - let binary_data: Vec = vec![0xFF, 0xFE]; - let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data); - - let resource = ResourceContents::BlobResourceContents { - uri: "file:///unknown".to_string(), - mime_type: None, - blob, - meta: None, - }; - - assert_eq!( - extract_text_from_resource(&resource), - "[Binary content (application/octet-stream) - 2 bytes]" - ); - } - - #[test] - fn test_extract_text_from_blob_invalid_base64() { - let resource = ResourceContents::BlobResourceContents { - uri: "file:///test.txt".to_string(), - mime_type: Some("text/plain".to_string()), - blob: "not valid base64!!!".to_string(), - meta: None, - }; - assert_eq!(extract_text_from_resource(&resource), "not valid base64!!!"); - } -}