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
1 change: 1 addition & 0 deletions src/harness/providers/openai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 600;

mod convert;
mod prompt_tools;
mod responses;
mod sse;
mod transport;
Expand Down
250 changes: 250 additions & 0 deletions src/harness/providers/openai/prompt_tools.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
//! Prompt-guided (text-mode) tool calling for models without native tool support.
//!
//! Many self-hosted / local OpenAI-compatible runtimes reject the OpenAI `tools`
//! parameter (HTTP 400) or simply ignore it. For those models
//! ([`OpenAiModel`](super::OpenAiModel) built [`with_native_tool_calling(false)`](super::OpenAiModel::with_native_tool_calling)),
//! the wire client embeds the tool specs **in the system prompt** as a small
//! protocol and parses the model's `<tool_call>…</tool_call>` blocks back into
//! [`ToolCall`]s — so the agent loop sees tool calls identically to the native
//! path, without any harness change.
//!
//! The `<tool_call>{"name":…,"arguments":…}</tool_call>` convention matches the
//! long-standing OpenHuman host format so models already prompted for it behave
//! identically after the crate cutover.

use std::fmt::Write as _;

use serde_json::{Map, Value};

use crate::harness::message::{ContentBlock, Message};
use crate::harness::model::ModelResponse;
use crate::harness::tool::{ToolCall, ToolSchema};

/// Opening / closing delimiters for a text-mode tool call.
const OPEN_TAG: &str = "<tool_call>";
const CLOSE_TAG: &str = "</tool_call>";

/// Build the tool-use protocol block appended to the system prompt when native
/// tool calling is unavailable. Describes the `<tool_call>` convention and lists
/// each tool's name, description, and JSON-Schema parameters.
pub(super) fn tool_instructions(tools: &[ToolSchema]) -> String {
let mut out = String::new();
out.push_str("## Tool Use Protocol\n\n");
out.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
out.push_str(OPEN_TAG);
out.push('\n');
out.push_str(r#"{"name": "tool_name", "arguments": {"param": "value"}}"#);
out.push('\n');
out.push_str(CLOSE_TAG);
out.push_str("\n\n");
out.push_str("You may emit multiple tool calls in a single response. ");
out.push_str("After execution, results appear in <tool_result> tags. ");
out.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
out.push_str("### Available Tools\n\n");
for tool in tools {
let params = serde_json::to_string(&tool.parameters).unwrap_or_else(|_| "{}".to_string());
// Infallible: writing to a String never errors.
let _ = writeln!(out, "**{}**: {}", tool.name, tool.description);
let _ = writeln!(out, "Parameters: `{params}`\n");
}
out
}

/// Return `messages` with the tool-use protocol appended to the system prompt:
/// the instructions are added as a trailing block on the first system message, or
/// a new leading system message when the request carries none. `tools` empty →
/// `messages` is returned unchanged (cloned).
pub(super) fn with_tool_instructions(messages: &[Message], tools: &[ToolSchema]) -> Vec<Message> {
if tools.is_empty() {
return messages.to_vec();
}
let block = tool_instructions(tools);
let mut out = messages.to_vec();
if let Some(Message::System(system)) = out.iter_mut().find(|m| matches!(m, Message::System(_)))
{
// Append as a distinct text block so the original system prompt is intact.
system
.content
.push(ContentBlock::Text(format!("\n\n{block}")));
} else {
out.insert(0, Message::system(block));
}
out
}

/// Extract `<tool_call>…</tool_call>` blocks from `text`, parsing each inner JSON
/// object (`{"name":…,"arguments":…}`) into a [`ToolCall`]. Returns the text with
/// the blocks removed (trimmed) plus the parsed calls, in order.
///
/// Robust to noise: a block whose inner text is not a JSON object with a string
/// `name` is dropped; a dangling `<tool_call>` with no close is left verbatim in
/// the returned text.
pub(super) fn parse_tool_calls_from_text(text: &str) -> (String, Vec<ToolCall>) {
let mut calls = Vec::new();
let mut cleaned = String::new();
let mut rest = text;

while let Some(start) = rest.find(OPEN_TAG) {
cleaned.push_str(&rest[..start]);
let after_open = &rest[start + OPEN_TAG.len()..];
let Some(end) = after_open.find(CLOSE_TAG) else {
// Unterminated block: keep it (and everything after) as plain text.
cleaned.push_str(&rest[start..]);
return (cleaned.trim().to_string(), calls);
};
let inner = after_open[..end].trim();
if let Some(call) = parse_one(inner, calls.len() + 1) {
calls.push(call);
}
rest = &after_open[end + CLOSE_TAG.len()..];
}
cleaned.push_str(rest);
(cleaned.trim().to_string(), calls)
}

/// Extract prompt-guided `<tool_call>` blocks from a completed [`ModelResponse`]'s
/// text into `message.tool_calls`, replacing the message content with the cleaned
/// prose. No-op when the text carries no blocks — so a plain text answer is
/// untouched. Applied by [`OpenAiModel`](super::OpenAiModel) after a non-native
/// tool call completes (unary + terminal stream item).
pub(super) fn apply_to_response(mut response: ModelResponse) -> ModelResponse {
let text = response.text();
let (cleaned, calls) = parse_tool_calls_from_text(&text);
if calls.is_empty() {
return response;
}
response.message.tool_calls.extend(calls);
response.message.content = if cleaned.is_empty() {
Vec::new()
} else {
vec![ContentBlock::Text(cleaned)]
};
response
}

/// Parse a single tool-call body into a [`ToolCall`] with a synthetic 1-based id.
fn parse_one(inner: &str, index: usize) -> Option<ToolCall> {
let value: Value = serde_json::from_str(inner).ok()?;
let name = value.get("name")?.as_str()?.to_string();
let arguments = value
.get("arguments")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
Some(ToolCall {
id: format!("call_{index}"),
name,
arguments,
})
}

#[cfg(test)]
mod test {
use super::*;

fn schema(name: &str) -> ToolSchema {
ToolSchema {
name: name.to_string(),
description: format!("{name} description"),
parameters: serde_json::json!({"type": "object"}),
format: Default::default(),
}
}

#[test]
fn instructions_list_each_tool() {
let text = tool_instructions(&[schema("read_file"), schema("write_file")]);
assert!(text.contains("## Tool Use Protocol"));
assert!(text.contains("<tool_call>"));
assert!(text.contains("**read_file**"));
assert!(text.contains("**write_file**"));
}

#[test]
fn with_tool_instructions_appends_to_system() {
let msgs = vec![Message::system("You are helpful."), Message::user("hi")];
let out = with_tool_instructions(&msgs, &[schema("read_file")]);
assert_eq!(out.len(), 2);
let Message::System(sys) = &out[0] else {
panic!("first message should stay system")
};
// Original prompt preserved + protocol appended.
let joined: String = sys
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert!(joined.contains("You are helpful."));
assert!(joined.contains("Tool Use Protocol"));
}

#[test]
fn with_tool_instructions_inserts_system_when_absent() {
let msgs = vec![Message::user("hi")];
let out = with_tool_instructions(&msgs, &[schema("read_file")]);
assert_eq!(out.len(), 2);
assert!(matches!(out[0], Message::System(_)));
}

#[test]
fn empty_tools_leaves_messages_unchanged() {
let msgs = vec![Message::user("hi")];
assert_eq!(with_tool_instructions(&msgs, &[]), msgs);
}

#[test]
fn parses_single_tool_call() {
let text = r#"Let me read it.
<tool_call>
{"name": "read_file", "arguments": {"path": "a.txt"}}
</tool_call>"#;
let (cleaned, calls) = parse_tool_calls_from_text(text);
assert_eq!(cleaned, "Let me read it.");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "read_file");
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].arguments, serde_json::json!({"path": "a.txt"}));
}

#[test]
fn parses_multiple_calls_and_keeps_prose() {
let text = r#"a<tool_call>{"name":"one","arguments":{}}</tool_call>b<tool_call>{"name":"two","arguments":{"x":1}}</tool_call>c"#;
let (cleaned, calls) = parse_tool_calls_from_text(text);
assert_eq!(cleaned, "abc");
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "one");
assert_eq!(calls[1].name, "two");
assert_eq!(calls[1].id, "call_2");
}

#[test]
fn missing_arguments_defaults_to_empty_object() {
let (_, calls) = parse_tool_calls_from_text(r#"<tool_call>{"name":"noargs"}</tool_call>"#);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].arguments, serde_json::json!({}));
}

#[test]
fn malformed_block_is_dropped() {
let (cleaned, calls) = parse_tool_calls_from_text("<tool_call>not json</tool_call>done");
assert!(calls.is_empty());
assert_eq!(cleaned, "done");
}

#[test]
fn unterminated_block_kept_as_text() {
let text = "text <tool_call>{\"name\":\"x\"}";
let (cleaned, calls) = parse_tool_calls_from_text(text);
assert!(calls.is_empty());
assert_eq!(cleaned, "text <tool_call>{\"name\":\"x\"}");
}

#[test]
fn no_blocks_returns_text_verbatim() {
let (cleaned, calls) = parse_tool_calls_from_text("just a normal answer");
assert!(calls.is_empty());
assert_eq!(cleaned, "just a normal answer");
}
}
68 changes: 52 additions & 16 deletions src/harness/providers/openai/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -685,32 +685,50 @@ impl OpenAiModel {
&self,
request: &ModelRequest,
) -> Result<ChatCompletionRequest> {
// Prompt-guided tools: a model without native tool calling that is still
// handed tools gets the tool protocol embedded in its system prompt and no
// native `tools` on the wire (many local runtimes 400 on `tools`). The
// model's `<tool_call>` blocks are parsed back in [`Self::invoke`]/stream.
let prompt_guided_tools = !self.profile.tool_calling && !request.tools.is_empty();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor ToolChoice::None before enabling text tools

ToolChoice::None means the model must not call any tool, but this condition ignores request.tool_choice: a request that carries tools for later use but disables them for this turn still gets tool instructions injected and invoke/stream will parse <tool_call> blocks, so the agent loop can execute a tool the caller explicitly forbade. Keep prompt-guided tooling disabled (and skip parsing) when request.tool_choice is None, or render an explicit no-tool instruction.

Useful? React with 👍 / 👎.

let instructed_messages;
let base_messages: &[Message] = if prompt_guided_tools {
instructed_messages =
prompt_tools::with_tool_instructions(&request.messages, &request.tools);
&instructed_messages
} else {
&request.messages
};

// Optionally fold system messages into the first user turn (for endpoints
// that reject a `system` role) before wire translation.
let merged_messages;
let source_messages: &[Message] = if self.merge_system_into_user {
merged_messages = merge_system_into_user(&request.messages);
merged_messages = merge_system_into_user(base_messages);
&merged_messages
} else {
&request.messages
base_messages
};
let messages = source_messages
.iter()
.map(translate_message)
.collect::<Result<Vec<_>>>()?;

let tools: Vec<ToolWire> = request
.tools
.iter()
.map(|schema| ToolWire {
kind: "function".to_string(),
function: FunctionSchemaWire {
name: schema.name.clone(),
description: schema.description.clone(),
parameters: schema.parameters.clone(),
},
})
.collect();
let tools: Vec<ToolWire> = if prompt_guided_tools {
Vec::new()
Comment on lines +716 to +717

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite tool-call history in text-mode requests

After a text-mode call is parsed, the agent loop appends the assistant tool_calls and Message::Tool result before the next model turn; this branch only drops the top-level tools, while the same history is still passed through translate_message, which serializes native assistant.tool_calls and role: "tool" instead of the <tool_result> protocol promised in the prompt. For the non-native/local endpoints this path targets, the second request after any tool call can be rejected or the model never sees results in the format it was instructed to use. Convert prior assistant/tool messages into text-mode tags when prompt_guided_tools is active.

Useful? React with 👍 / 👎.

} else {
request
.tools
.iter()
.map(|schema| ToolWire {
kind: "function".to_string(),
function: FunctionSchemaWire {
name: schema.name.clone(),
description: schema.description.clone(),
parameters: schema.parameters.clone(),
},
})
.collect()
};

// tool_choice is only meaningful when tools are declared.
let tool_choice = if tools.is_empty() {
Expand Down Expand Up @@ -1084,7 +1102,13 @@ impl<State: Send + Sync> ChatModel<State> for OpenAiModel {
})?;

let value: Value = serde_json::from_str(&text)?;
parse_response(value)
let response = parse_response(value)?;
// Prompt-guided tools: recover the model's `<tool_call>` blocks into
// `message.tool_calls` when native tool calling was suppressed.
if !self.profile.tool_calling && !request.tools.is_empty() {
return Ok(prompt_tools::apply_to_response(response));
}
Ok(response)
}

/// Streams the OpenAI Chat Completions response as a real [`ModelStream`].
Expand Down Expand Up @@ -1153,6 +1177,18 @@ impl<State: Send + Sync> ChatModel<State> for OpenAiModel {
terminal_emitted: false,
};

Ok(Box::pin(futures::stream::unfold(state, sse_next)))
let stream = futures::stream::unfold(state, sse_next);
// Prompt-guided tools: recover `<tool_call>` blocks from the terminal
// `Completed` response into `message.tool_calls` (the streamed text deltas
// still carry the raw markup — cleaning them mid-stream is a follow-up).
if !self.profile.tool_calling && !request.tools.is_empty() {
return Ok(Box::pin(stream.map(|item| match item {
ModelStreamItem::Completed(response) => {
ModelStreamItem::Completed(prompt_tools::apply_to_response(response))
}
other => other,
})));
}
Ok(Box::pin(stream))
}
}