fix: store tool calls structurally for proper API roundtripping

The agent would stop mid-task because native tool calls were stored as
inline JSON text in Message.content. When sent back to the Anthropic API
via convert_messages(), they went as plain text instead of structured
tool_use/tool_result blocks. The model would occasionally get confused
and emit text describing what it wanted to do instead of invoking the
tool mechanism.

Changes:
- Add MessageToolCall struct and tool_calls/tool_result_id fields to Message
- Add id field to core ToolCall struct to preserve provider tool call IDs
- Update Anthropic convert_messages() to emit tool_use and tool_result blocks
- Add ToolResult variant to AnthropicContent enum
- Store tool calls structurally in tool message construction (not inline JSON)
- Fix add_message() to preserve empty-content messages with tool_calls
- Fix check_duplicate_in_previous_message() to check structured tool_calls
- Generate valid IDs for JSON fallback tool calls (Anthropic pattern requirement)
- Update planner create_tool_message() to use structured tool calls
This commit is contained in:
Dhanji R. Prasanna
2026-02-11 08:48:07 +11:00
parent 2a4cd1f4d6
commit d3f0112f46
15 changed files with 355 additions and 53 deletions

View File

@@ -295,14 +295,26 @@ impl AnthropicProvider {
});
}
// Add text content
content_blocks.push(AnthropicContent::Text {
text: message.content.clone(),
cache_control: message
.cache_control
.as_ref()
.map(Self::convert_cache_control),
});
// Check if this is a tool result message
if let Some(ref tool_use_id) = message.tool_result_id {
content_blocks.push(AnthropicContent::ToolResult {
tool_use_id: tool_use_id.clone(),
content: message.content.clone(),
cache_control: message
.cache_control
.as_ref()
.map(Self::convert_cache_control),
});
} else {
// Regular text content
content_blocks.push(AnthropicContent::Text {
text: message.content.clone(),
cache_control: message
.cache_control
.as_ref()
.map(Self::convert_cache_control),
});
}
anthropic_messages.push(AnthropicMessage {
role: "user".to_string(),
@@ -310,15 +322,39 @@ impl AnthropicProvider {
});
}
MessageRole::Assistant => {
anthropic_messages.push(AnthropicMessage {
role: "assistant".to_string(),
content: vec![AnthropicContent::Text {
let mut content_blocks: Vec<AnthropicContent> = Vec::new();
// Add text content if non-empty
if !message.content.trim().is_empty() {
content_blocks.push(AnthropicContent::Text {
text: message.content.clone(),
cache_control: message
.cache_control
.as_ref()
.map(Self::convert_cache_control),
}],
});
}
// Add tool_use blocks for any structured tool calls
for tc in &message.tool_calls {
content_blocks.push(AnthropicContent::ToolUse {
id: tc.id.clone(),
name: tc.name.clone(),
input: tc.input.clone(),
});
}
// Ensure we have at least one content block
if content_blocks.is_empty() {
content_blocks.push(AnthropicContent::Text {
text: message.content.clone(),
cache_control: None,
});
}
anthropic_messages.push(AnthropicMessage {
role: "assistant".to_string(),
content: content_blocks,
});
}
}
@@ -929,6 +965,13 @@ enum AnthropicContent {
},
#[serde(rename = "image")]
Image { source: AnthropicImageSource },
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<crate::CacheControl>,
},
}
/// Image source for Anthropic API

View File

@@ -95,6 +95,16 @@ impl CacheControl {
}
}
/// A tool call stored in an assistant message for proper API roundtripping.
/// When the model makes a native tool call, we store it structurally so that
/// convert_messages() can send it as a proper tool_use block (not inline JSON text).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageToolCall {
pub id: String,
pub name: String,
pub input: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: MessageRole,
@@ -107,6 +117,16 @@ pub struct Message {
pub kind: MessageKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_control: Option<CacheControl>,
/// Structured tool calls made by the assistant in this message.
/// When non-empty, convert_messages() should emit tool_use content blocks
/// instead of (or in addition to) plain text.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<MessageToolCall>,
/// If this is a tool result message, the ID of the tool_use it responds to.
/// When set, convert_messages() should emit a tool_result content block
/// instead of plain text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_result_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -279,6 +299,8 @@ impl Message {
id: Self::generate_id(),
kind: MessageKind::Regular,
cache_control: None,
tool_calls: Vec::new(),
tool_result_id: None,
}
}
@@ -295,6 +317,8 @@ impl Message {
id: Self::generate_id(),
kind: MessageKind::Regular,
cache_control: Some(cache_control),
tool_calls: Vec::new(),
tool_result_id: None,
}
}
@@ -307,6 +331,8 @@ impl Message {
id: Self::generate_id(),
kind,
cache_control: None,
tool_calls: Vec::new(),
tool_result_id: None,
}
}