Files
tkmind_go/crates/goose/src/providers/toolshim.rs
T
2026-01-23 14:52:43 -05:00

448 lines
17 KiB
Rust

//! # ToolShim Module
//!
//! The ToolShim module provides a reusable component for interpreting and augmenting LLM outputs with tool calls,
//! regardless of whether the underlying model natively supports tool/function calling.
//!
//! ## Overview
//!
//! ToolShim addresses the challenge of working with models that don't natively support tools by:
//!
//! 1. Taking the text output from any LLM
//! 2. Sending it to a separate "interpreter" model (which can be the same or different model)
//! 3. Using a model to extract tool call intentions into the appropriate format
//! 4. Converting the outputs of the interpreter model into proper tool call structs
//! 5. Augmenting the original message with the extracted tool calls
//!
//! ## Key Components
//!
//! ### ToolInterpreter Trait
//!
//! The core of ToolShim is the `ToolInterpreter` trait, which defines the interface for any model that can interpret text and extract tool calls.
//!
//! ### Implementations
//!
//! The module provides an implementation for Ollama:
//!
//! - `OllamaInterpreter`: Uses Ollama's structured output API to interpret tool calls
//!
//! ### Helper Functions
//!
//! - `augment_message_with_tool_calls`: A utility function that takes any message, extracts text content, sends it to an interpreter, and adds any detected tool calls back to the message.
//!
use super::errors::ProviderError;
use super::ollama::OLLAMA_DEFAULT_PORT;
use super::ollama::OLLAMA_HOST;
use crate::conversation::message::{Message, MessageContent};
use crate::conversation::Conversation;
use crate::model::ModelConfig;
use crate::providers::formats::openai::create_request;
use anyhow::Result;
use reqwest::Client;
use rmcp::model::{object, CallToolRequestParams, RawContent, Tool};
use serde_json::{json, Value};
use std::ops::Deref;
use std::time::Duration;
use uuid::Uuid;
/// Default model to use for tool interpretation
pub const DEFAULT_INTERPRETER_MODEL_OLLAMA: &str = "mistral-nemo";
/// Environment variables that affect behavior:
/// - GOOSE_TOOLSHIM: When set to "true" or "1", enables using the tool shim in the standard OllamaProvider (default: false)
/// - GOOSE_TOOLSHIM_OLLAMA_MODEL: Ollama model to use as the tool interpreter (default: DEFAULT_INTERPRETER_MODEL)
/// A trait for models that can interpret text into structured tool call JSON format
#[async_trait::async_trait]
pub trait ToolInterpreter {
/// Interpret potential tool calls from text and convert them to proper tool call JSON format
async fn interpret_to_tool_calls(
&self,
content: &str,
tools: &[Tool],
) -> Result<Vec<CallToolRequestParams>, ProviderError>;
}
/// Ollama-specific implementation of the ToolInterpreter trait
pub struct OllamaInterpreter {
client: Client,
base_url: String,
}
impl OllamaInterpreter {
pub fn new() -> Result<Self, ProviderError> {
let client = Client::builder()
.timeout(Duration::from_secs(600))
.build()
.expect("Failed to create HTTP client");
let base_url = Self::get_ollama_base_url()?;
Ok(Self { client, base_url })
}
/// Get the Ollama base URL from existing config or use default values
fn get_ollama_base_url() -> Result<String, ProviderError> {
let config = crate::config::Config::global();
let host: String = config
.get_param("OLLAMA_HOST")
.unwrap_or_else(|_| OLLAMA_HOST.to_string());
// Format the URL correctly with http:// prefix if needed
let base = if host.starts_with("http://") || host.starts_with("https://") {
&host
} else {
&format!("http://{}", host)
};
let mut base_url = url::Url::parse(base)
.map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?;
// Set the default port if missing
// Don't add default port if:
// 1. URL explicitly ends with standard ports (:80 or :443)
// 2. URL uses HTTPS (which implicitly uses port 443)
let explicit_default_port = host.ends_with(":80") || host.ends_with(":443");
let is_https = base_url.scheme() == "https";
if base_url.port().is_none() && !explicit_default_port && !is_https {
base_url.set_port(Some(OLLAMA_DEFAULT_PORT)).map_err(|_| {
ProviderError::RequestFailed("Failed to set default port".to_string())
})?;
}
Ok(base_url.to_string())
}
fn tool_structured_ouput_format_schema() -> Value {
json!({
"type": "object",
"properties": {
"tool_calls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the tool to call"
},
"arguments": {
"type": "object",
"description": "The arguments to pass to the tool"
}
},
"required": ["name", "arguments"]
}
}
},
"required": ["tool_calls"]
})
}
async fn post_structured(
&self,
system_prompt: &str,
format_instruction: &str,
format_schema: Value,
model: &str,
) -> Result<Value, ProviderError> {
let base_url = self.base_url.trim_end_matches('/');
let url = format!("{}/api/chat", base_url);
let mut messages = Vec::new();
let user_message = Message::user().with_text(format_instruction);
messages.push(user_message);
let model_config = ModelConfig::new(model)
.map_err(|e| ProviderError::RequestFailed(format!("Model config error: {e}")))?;
let mut payload = create_request(
&model_config,
system_prompt,
&messages,
&[], // No tools
&super::utils::ImageFormat::OpenAi,
false,
)?;
payload["stream"] = json!(false); // needed for the /api/chat endpoint to work
payload["format"] = format_schema;
tracing::info!(
"Tool interpreter payload: {}",
serde_json::to_string_pretty(&payload).unwrap_or_default()
);
let response = self.client.post(&url).json(&payload).send().await?;
if !response.status().is_success() {
let status = response.status();
let error_text = match response.text().await {
Ok(text) => text,
Err(_) => "Could not read error response".to_string(),
};
return Err(ProviderError::RequestFailed(format!(
"Ollama structured API returned error status {}: {}",
status, error_text
)));
}
let response_json: Value = response.json().await.map_err(|e| {
ProviderError::RequestFailed(format!(
"Failed to parse Ollama structured API response: {e}"
))
})?;
Ok(response_json)
}
fn process_interpreter_response(
response: &Value,
) -> Result<Vec<CallToolRequestParams>, ProviderError> {
let mut tool_calls = Vec::new();
tracing::info!(
"Tool interpreter response is {}",
serde_json::to_string_pretty(&response).unwrap_or_default()
);
// Extract tool_calls array from the response
if response.get("message").is_some() && response["message"].get("content").is_some() {
let content = response["message"]["content"].as_str().unwrap_or_default();
// Try to parse the content as JSON
if let Ok(content_json) = serde_json::from_str::<Value>(content) {
// Check for the format with tool_calls array inside an object
if content_json.is_object() && content_json.get("tool_calls").is_some() {
// Process each tool call in the array
if let Some(tool_calls_array) = content_json["tool_calls"].as_array() {
for item in tool_calls_array {
if item.is_object()
&& item.get("name").is_some()
&& item.get("arguments").is_some()
{
let name = item["name"].as_str().unwrap_or_default().to_string();
let arguments = item["arguments"].clone();
// Add the tool call to our result vector
tool_calls.push(CallToolRequestParams {
meta: None,
task: None,
name: name.into(),
arguments: Some(object(arguments)),
});
}
}
}
}
}
}
Ok(tool_calls)
}
}
#[async_trait::async_trait]
impl ToolInterpreter for OllamaInterpreter {
async fn interpret_to_tool_calls(
&self,
last_assistant_msg: &str,
tools: &[Tool],
) -> Result<Vec<CallToolRequestParams>, ProviderError> {
if tools.is_empty() {
return Ok(vec![]);
}
// Create the system prompt
let system_prompt = "If there is detectable JSON-formatted tool requests, write them into valid JSON tool calls in the following format:
{{
\"tool_calls\": [
{{
\"name\": \"tool_name\",
\"arguments\": {{
\"param1\": \"value1\",
\"param2\": \"value2\"
}}
}}
]
}}
Otherwise, if no JSON tool requests are provided, use the no-op tool:
{{
\"tool_calls\": [
{{
\"name\": \"noop\",
\"arguments\": {{
}}
}}]
}}
";
// Create enhanced content with instruction to output tool calls as JSON
let format_instruction = format!("{}\nRequest: {}\n\n", system_prompt, last_assistant_msg);
// Define the JSON schema for tool call format
let format_schema = OllamaInterpreter::tool_structured_ouput_format_schema();
// Determine which model to use for interpretation (from env var or default)
let interpreter_model = std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL")
.unwrap_or_else(|_| DEFAULT_INTERPRETER_MODEL_OLLAMA.to_string());
// Make a call to ollama with structured output
let interpreter_response = self
.post_structured("", &format_instruction, format_schema, &interpreter_model)
.await?;
// Process the interpreter response to get tool calls directly
let tool_calls = OllamaInterpreter::process_interpreter_response(&interpreter_response)?;
Ok(tool_calls)
}
}
/// Creates a string containing formatted tool information
pub fn format_tool_info(tools: &[Tool]) -> String {
let mut tool_info = String::new();
for tool in tools {
tool_info.push_str(&format!(
"Tool Name: {}\nSchema: {}\nDescription: {:?}\n\n",
tool.name,
serde_json::to_string_pretty(&tool.input_schema).unwrap_or_default(),
tool.description
));
}
tool_info
}
/// Convert messages containing ToolRequest/ToolResponse to text messages for toolshim mode
/// This is necessary because some providers (like Bedrock) validate that tool_use/tool_result
/// blocks can only exist when tools are defined, but in toolshim mode we pass empty tools
pub fn convert_tool_messages_to_text(messages: &[Message]) -> Conversation {
let converted_messages: Vec<Message> = messages
.iter()
.map(|message| {
let mut new_content = Vec::new();
let mut has_tool_content = false;
for content in &message.content {
match content {
MessageContent::ToolRequest(req) => {
has_tool_content = true;
// Convert tool request to text format
let text = if let Ok(tool_call) = &req.tool_call {
format!(
"Using tool: {}\n{{\n \"name\": \"{}\",\n \"arguments\": {}\n}}",
tool_call.name,
tool_call.name,
serde_json::to_string_pretty(&tool_call.arguments)
.unwrap_or_default()
)
} else {
"Tool request failed".to_string()
};
new_content.push(MessageContent::text(text));
}
MessageContent::ToolResponse(res) => {
has_tool_content = true;
// Convert tool response to text format
let text = match &res.tool_result {
Ok(result) => {
let text_contents: Vec<String> = result
.content
.iter()
.filter_map(|c| match c.deref() {
RawContent::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect();
format!("Tool result:\n{}", text_contents.join("\n"))
}
Err(e) => format!("Tool error: {}", e),
};
new_content.push(MessageContent::text(text));
}
_ => {
// Keep other content types as-is
new_content.push(content.clone());
}
}
}
if has_tool_content {
Message::new(message.role.clone(), message.created, new_content)
} else {
message.clone()
}
})
.collect();
Conversation::new_unvalidated(converted_messages)
}
/// Modifies the system prompt to include tool usage instructions when tool interpretation is enabled
pub fn modify_system_prompt_for_tool_json(system_prompt: &str, tools: &[Tool]) -> String {
let tool_info = format_tool_info(tools);
format!(
"{}\n\n{}\n\nBreak down your task into smaller steps and do one step and tool call at a time. Do not try to use multiple tools at once. If you want to use a tool, tell the user what tool to use by specifying the tool in this JSON format\n{{\n \"name\": \"tool_name\",\n \"arguments\": {{\n \"parameter1\": \"value1\",\n \"parameter2\": \"value2\"\n }}\n}}. After you get the tool result back, consider the result and then proceed to do the next step and tool call if required.",
system_prompt,
tool_info
)
}
/// Helper function to augment a message with tool calls if any are detected
pub async fn augment_message_with_tool_calls<T: ToolInterpreter>(
interpreter: &T,
message: Message,
tools: &[Tool],
) -> Result<Message, ProviderError> {
// If there are no tools or the message is empty, return the original message
if tools.is_empty() {
return Ok(message);
}
// Extract content from the message
let content_opt = message.content.iter().find_map(|content| {
if let MessageContent::Text(text) = content {
Some(text.text.as_str())
} else {
None
}
});
// If there's no text content or it's already a tool request, return the original message
let content = match content_opt {
Some(text) => text,
None => return Ok(message),
};
// Check if there's already a tool request
if message
.content
.iter()
.any(|content| matches!(content, MessageContent::ToolRequest(_)))
{
return Ok(message);
}
// Use the interpreter to convert the content to tool calls
let tool_calls = interpreter.interpret_to_tool_calls(content, tools).await?;
// If no tool calls were detected, return the original message
if tool_calls.is_empty() {
return Ok(message);
}
// Add each tool call to the message
let mut final_message = message;
for tool_call in tool_calls {
if tool_call.name != "noop" {
// do not actually execute noop tool
let id = Uuid::new_v4().to_string();
final_message = final_message.with_tool_request(id, Ok(tool_call));
}
}
Ok(final_message)
}