Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ jobs:
- name: Build and Test
run: |
gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar'
source ../bin/activate-hermit && cargo test
source ../bin/activate-hermit
export CARGO_INCREMENTAL=0
cargo test --jobs 2
working-directory: crates

- name: Check disk space before cleanup
Expand Down Expand Up @@ -125,8 +127,11 @@ jobs:
run: df -h

- name: Lint
run: source ./bin/activate-hermit && cargo clippy -- -D warnings

run: |
source ./bin/activate-hermit
export CARGO_INCREMENTAL=0
cargo clippy --jobs 2 -- -D warnings

desktop-lint:
name: Lint Electron Desktop App
runs-on: macos-latest
Expand Down
186 changes: 127 additions & 59 deletions crates/goose/src/providers/formats/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,64 @@ use mcp_core::tool::{Tool, ToolCall};
use serde_json::{json, Value};
use std::collections::HashSet;

// Constants for frequently used strings in Anthropic API format
const TYPE_FIELD: &str = "type";
const CONTENT_FIELD: &str = "content";
const TEXT_TYPE: &str = "text";
const ROLE_FIELD: &str = "role";
const USER_ROLE: &str = "user";
const ASSISTANT_ROLE: &str = "assistant";
const TOOL_USE_TYPE: &str = "tool_use";
const TOOL_RESULT_TYPE: &str = "tool_result";
const THINKING_TYPE: &str = "thinking";
const REDACTED_THINKING_TYPE: &str = "redacted_thinking";
const CACHE_CONTROL_FIELD: &str = "cache_control";
const ID_FIELD: &str = "id";
const NAME_FIELD: &str = "name";
const INPUT_FIELD: &str = "input";
const TOOL_USE_ID_FIELD: &str = "tool_use_id";
const IS_ERROR_FIELD: &str = "is_error";
const SIGNATURE_FIELD: &str = "signature";
const DATA_FIELD: &str = "data";

/// Convert internal Message format to Anthropic's API message specification
pub fn format_messages(messages: &[Message]) -> Vec<Value> {
let mut anthropic_messages = Vec::new();

// Convert messages to Anthropic format
for message in messages {
let role = match message.role {
Role::User => "user",
Role::Assistant => "assistant",
Role::User => USER_ROLE,
Role::Assistant => ASSISTANT_ROLE,
};

let mut content = Vec::new();
for msg_content in &message.content {
match msg_content {
MessageContent::Text(text) => {
content.push(json!({
"type": "text",
"text": text.text
TYPE_FIELD: TEXT_TYPE,
TEXT_TYPE: text.text
}));
}
MessageContent::ToolRequest(tool_request) => {
if let Ok(tool_call) = &tool_request.tool_call {
content.push(json!({
"type": "tool_use",
"id": tool_request.id,
"name": tool_call.name,
"input": tool_call.arguments
}));
match &tool_request.tool_call {
Ok(tool_call) => {
content.push(json!({
TYPE_FIELD: TOOL_USE_TYPE,
ID_FIELD: tool_request.id,
NAME_FIELD: tool_call.name,
INPUT_FIELD: tool_call.arguments
}));
}
Err(_tool_error) => {
// Skip malformed tool requests - they shouldn't be sent to Anthropic
// This maintains the existing behavior for ToolRequest errors
}
}
}
MessageContent::ToolResponse(tool_response) => {
if let Ok(result) = &tool_response.tool_result {
MessageContent::ToolResponse(tool_response) => match &tool_response.tool_result {
Ok(result) => {
let text = result
.iter()
.filter_map(|c| match c {
Expand All @@ -51,12 +77,20 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
.join("\n");

content.push(json!({
"type": "tool_result",
"tool_use_id": tool_response.id,
"content": text
TYPE_FIELD: TOOL_RESULT_TYPE,
TOOL_USE_ID_FIELD: tool_response.id,
CONTENT_FIELD: text
}));
}
}
Err(tool_error) => {
content.push(json!({
TYPE_FIELD: TOOL_RESULT_TYPE,
TOOL_USE_ID_FIELD: tool_response.id,
CONTENT_FIELD: format!("Error: {}", tool_error),
IS_ERROR_FIELD: true
}));
}
},
MessageContent::ToolConfirmationRequest(_tool_confirmation_request) => {
// Skip tool confirmation requests
}
Expand All @@ -68,25 +102,25 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
}
MessageContent::Thinking(thinking) => {
content.push(json!({
"type": "thinking",
"thinking": thinking.thinking,
"signature": thinking.signature
TYPE_FIELD: THINKING_TYPE,
THINKING_TYPE: thinking.thinking,
SIGNATURE_FIELD: thinking.signature
}));
}
MessageContent::RedactedThinking(redacted) => {
content.push(json!({
"type": "redacted_thinking",
"data": redacted.data
TYPE_FIELD: REDACTED_THINKING_TYPE,
DATA_FIELD: redacted.data
}));
}
MessageContent::Image(_) => continue, // Anthropic doesn't support image content yet
MessageContent::FrontendToolRequest(tool_request) => {
if let Ok(tool_call) = &tool_request.tool_call {
content.push(json!({
"type": "tool_use",
"id": tool_request.id,
"name": tool_call.name,
"input": tool_call.arguments
TYPE_FIELD: TOOL_USE_TYPE,
ID_FIELD: tool_request.id,
NAME_FIELD: tool_call.name,
INPUT_FIELD: tool_call.arguments
}));
}
}
Expand All @@ -96,19 +130,19 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
// Skip messages with empty content
if !content.is_empty() {
anthropic_messages.push(json!({
"role": role,
"content": content
ROLE_FIELD: role,
CONTENT_FIELD: content
}));
}
}

// If no messages, add a default one
if anthropic_messages.is_empty() {
anthropic_messages.push(json!({
"role": "user",
"content": [{
"type": "text",
"text": "Ignore"
ROLE_FIELD: USER_ROLE,
CONTENT_FIELD: [{
TYPE_FIELD: TEXT_TYPE,
TEXT_TYPE: "Ignore"
}]
}));
}
Expand All @@ -119,14 +153,14 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
// cache_control parameter, so that this checkpoint can read from the previous cache.
let mut user_count = 0;
for message in anthropic_messages.iter_mut().rev() {
if message.get("role") == Some(&json!("user")) {
if let Some(content) = message.get_mut("content") {
if message.get(ROLE_FIELD) == Some(&json!(USER_ROLE)) {
if let Some(content) = message.get_mut(CONTENT_FIELD) {
if let Some(content_array) = content.as_array_mut() {
if let Some(last_content) = content_array.last_mut() {
last_content
.as_object_mut()
.unwrap()
.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
last_content.as_object_mut().unwrap().insert(
CACHE_CONTROL_FIELD.to_string(),
json!({ TYPE_FIELD: "ephemeral" }),
);
}
}
}
Expand All @@ -148,7 +182,7 @@ pub fn format_tools(tools: &[Tool]) -> Vec<Value> {
for tool in tools {
if unique_tools.insert(tool.name.clone()) {
tool_specs.push(json!({
"name": tool.name,
NAME_FIELD: tool.name,
"description": tool.description,
"input_schema": tool.input_schema
}));
Expand All @@ -158,10 +192,10 @@ pub fn format_tools(tools: &[Tool]) -> Vec<Value> {
// Add "cache_control" to the last tool spec, if any. This means that all tool definitions,
// will be cached as a single prefix.
if let Some(last_tool) = tool_specs.last_mut() {
last_tool
.as_object_mut()
.unwrap()
.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
last_tool.as_object_mut().unwrap().insert(
CACHE_CONTROL_FIELD.to_string(),
json!({ TYPE_FIELD: "ephemeral" }),
);
}

tool_specs
Expand All @@ -170,58 +204,58 @@ pub fn format_tools(tools: &[Tool]) -> Vec<Value> {
/// Convert system message to Anthropic's API system specification
pub fn format_system(system: &str) -> Value {
json!([{
"type": "text",
"text": system,
"cache_control": { "type": "ephemeral" }
TYPE_FIELD: TEXT_TYPE,
TEXT_TYPE: system,
CACHE_CONTROL_FIELD: { TYPE_FIELD: "ephemeral" }
}])
}

/// Convert Anthropic's API response to internal Message format
pub fn response_to_message(response: Value) -> Result<Message> {
let content_blocks = response
.get("content")
.get(CONTENT_FIELD)
.and_then(|c| c.as_array())
.ok_or_else(|| anyhow!("Invalid response format: missing content array"))?;

let mut message = Message::assistant();

for block in content_blocks {
match block.get("type").and_then(|t| t.as_str()) {
Some("text") => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
match block.get(TYPE_FIELD).and_then(|t| t.as_str()) {
Some(TEXT_TYPE) => {
if let Some(text) = block.get(TEXT_TYPE).and_then(|t| t.as_str()) {
message = message.with_text(text.to_string());
}
}
Some("tool_use") => {
Some(TOOL_USE_TYPE) => {
let id = block
.get("id")
.get(ID_FIELD)
.and_then(|i| i.as_str())
.ok_or_else(|| anyhow!("Missing tool_use id"))?;
let name = block
.get("name")
.get(NAME_FIELD)
.and_then(|n| n.as_str())
.ok_or_else(|| anyhow!("Missing tool_use name"))?;
let input = block
.get("input")
.get(INPUT_FIELD)
.ok_or_else(|| anyhow!("Missing tool_use input"))?;

let tool_call = ToolCall::new(name, input.clone());
message = message.with_tool_request(id, Ok(tool_call));
}
Some("thinking") => {
Some(THINKING_TYPE) => {
let thinking = block
.get("thinking")
.get(THINKING_TYPE)
.and_then(|t| t.as_str())
.ok_or_else(|| anyhow!("Missing thinking content"))?;
let signature = block
.get("signature")
.get(SIGNATURE_FIELD)
.and_then(|s| s.as_str())
.ok_or_else(|| anyhow!("Missing thinking signature"))?;
message = message.with_thinking(thinking, signature);
}
Some("redacted_thinking") => {
Some(REDACTED_THINKING_TYPE) => {
let data = block
.get("data")
.get(DATA_FIELD)
.and_then(|d| d.as_str())
.ok_or_else(|| anyhow!("Missing redacted_thinking data"))?;
message = message.with_redacted_thinking(data);
Expand Down Expand Up @@ -692,4 +726,38 @@ mod tests {

Ok(())
}

#[test]
fn test_tool_error_handling_maintains_pairing() {
use mcp_core::handler::ToolError;

let messages = vec![
Message::assistant().with_tool_request(
"tool_1",
Ok(ToolCall::new("calculator", json!({"expression": "2 + 2"}))),
),
Message::user().with_tool_response(
"tool_1",
Err(ToolError::ExecutionError("Tool failed".to_string())),
),
];

let spec = format_messages(&messages);

assert_eq!(spec.len(), 2);

assert_eq!(spec[0]["role"], "assistant");
assert_eq!(spec[0]["content"][0]["type"], "tool_use");
assert_eq!(spec[0]["content"][0]["id"], "tool_1");
assert_eq!(spec[0]["content"][0]["name"], "calculator");

assert_eq!(spec[1]["role"], "user");
assert_eq!(spec[1]["content"][0]["type"], "tool_result");
assert_eq!(spec[1]["content"][0]["tool_use_id"], "tool_1");
assert_eq!(
spec[1]["content"][0]["content"],
"Error: Execution failed: Tool failed"
);
assert_eq!(spec[1]["content"][0]["is_error"], true);
}
}
Loading