Skip to content

Commit 65eabc4

Browse files
committed
harness(openai): prompt-guided (text-mode) tool calling for non-native models
Models without native tool calling (OpenAiModel built with_native_tool_calling(false) — many self-hosted / local OpenAI-compatible runtimes that 400 on the `tools` param) can now still call tools: the wire client embeds the tool specs in the system prompt as a small `<tool_call>{"name":..,"arguments":..}</tool_call>` protocol and parses those blocks back into ToolCalls, so the agent loop sees tool calls identically to the native path — no harness change. - new prompt_tools module: tool_instructions / with_tool_instructions (embed the protocol on the system prompt) + parse_tool_calls_from_text / apply_to_response (recover <tool_call> blocks into message.tool_calls, strip them from the prose). - transport::translate_request: when profile.tool_calling is false and the request carries tools, embed the protocol and send NO native `tools` on the wire. - OpenAiModel::invoke + stream: recover tool calls from the completed response (unary + terminal stream Completed item). Streamed text deltas still carry the raw markup — cleaning them mid-stream is a follow-up. The `<tool_call>` convention matches the long-standing OpenHuman host format so models already prompted for it behave identically. 10 unit tests; full providers::openai suite (75) green; fmt + clippy clean. Unblocks the OpenHuman inference cutover of LOCAL runtimes to crate-native models (issue #4249 Phase 3) — they relied on the host's prompt-guided tool parsing. Claude-Session: https://claude.ai/code/session_01U8NDGbt9tKj443VquzycLB
1 parent 4fc8cd8 commit 65eabc4

3 files changed

Lines changed: 303 additions & 16 deletions

File tree

src/harness/providers/openai/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;
6666
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 600;
6767

6868
mod convert;
69+
mod prompt_tools;
6970
mod responses;
7071
mod sse;
7172
mod transport;
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
//! Prompt-guided (text-mode) tool calling for models without native tool support.
2+
//!
3+
//! Many self-hosted / local OpenAI-compatible runtimes reject the OpenAI `tools`
4+
//! parameter (HTTP 400) or simply ignore it. For those models
5+
//! ([`OpenAiModel`](super::OpenAiModel) built [`with_native_tool_calling(false)`](super::OpenAiModel::with_native_tool_calling)),
6+
//! the wire client embeds the tool specs **in the system prompt** as a small
7+
//! protocol and parses the model's `<tool_call>…</tool_call>` blocks back into
8+
//! [`ToolCall`]s — so the agent loop sees tool calls identically to the native
9+
//! path, without any harness change.
10+
//!
11+
//! The `<tool_call>{"name":…,"arguments":…}</tool_call>` convention matches the
12+
//! long-standing OpenHuman host format so models already prompted for it behave
13+
//! identically after the crate cutover.
14+
15+
use std::fmt::Write as _;
16+
17+
use serde_json::{Map, Value};
18+
19+
use crate::harness::message::{ContentBlock, Message};
20+
use crate::harness::model::ModelResponse;
21+
use crate::harness::tool::{ToolCall, ToolSchema};
22+
23+
/// Opening / closing delimiters for a text-mode tool call.
24+
const OPEN_TAG: &str = "<tool_call>";
25+
const CLOSE_TAG: &str = "</tool_call>";
26+
27+
/// Build the tool-use protocol block appended to the system prompt when native
28+
/// tool calling is unavailable. Describes the `<tool_call>` convention and lists
29+
/// each tool's name, description, and JSON-Schema parameters.
30+
pub(super) fn tool_instructions(tools: &[ToolSchema]) -> String {
31+
let mut out = String::new();
32+
out.push_str("## Tool Use Protocol\n\n");
33+
out.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
34+
out.push_str(OPEN_TAG);
35+
out.push('\n');
36+
out.push_str(r#"{"name": "tool_name", "arguments": {"param": "value"}}"#);
37+
out.push('\n');
38+
out.push_str(CLOSE_TAG);
39+
out.push_str("\n\n");
40+
out.push_str("You may emit multiple tool calls in a single response. ");
41+
out.push_str("After execution, results appear in <tool_result> tags. ");
42+
out.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
43+
out.push_str("### Available Tools\n\n");
44+
for tool in tools {
45+
let params = serde_json::to_string(&tool.parameters).unwrap_or_else(|_| "{}".to_string());
46+
// Infallible: writing to a String never errors.
47+
let _ = writeln!(out, "**{}**: {}", tool.name, tool.description);
48+
let _ = writeln!(out, "Parameters: `{params}`\n");
49+
}
50+
out
51+
}
52+
53+
/// Return `messages` with the tool-use protocol appended to the system prompt:
54+
/// the instructions are added as a trailing block on the first system message, or
55+
/// a new leading system message when the request carries none. `tools` empty →
56+
/// `messages` is returned unchanged (cloned).
57+
pub(super) fn with_tool_instructions(messages: &[Message], tools: &[ToolSchema]) -> Vec<Message> {
58+
if tools.is_empty() {
59+
return messages.to_vec();
60+
}
61+
let block = tool_instructions(tools);
62+
let mut out = messages.to_vec();
63+
if let Some(Message::System(system)) = out.iter_mut().find(|m| matches!(m, Message::System(_)))
64+
{
65+
// Append as a distinct text block so the original system prompt is intact.
66+
system
67+
.content
68+
.push(ContentBlock::Text(format!("\n\n{block}")));
69+
} else {
70+
out.insert(0, Message::system(block));
71+
}
72+
out
73+
}
74+
75+
/// Extract `<tool_call>…</tool_call>` blocks from `text`, parsing each inner JSON
76+
/// object (`{"name":…,"arguments":…}`) into a [`ToolCall`]. Returns the text with
77+
/// the blocks removed (trimmed) plus the parsed calls, in order.
78+
///
79+
/// Robust to noise: a block whose inner text is not a JSON object with a string
80+
/// `name` is dropped; a dangling `<tool_call>` with no close is left verbatim in
81+
/// the returned text.
82+
pub(super) fn parse_tool_calls_from_text(text: &str) -> (String, Vec<ToolCall>) {
83+
let mut calls = Vec::new();
84+
let mut cleaned = String::new();
85+
let mut rest = text;
86+
87+
while let Some(start) = rest.find(OPEN_TAG) {
88+
cleaned.push_str(&rest[..start]);
89+
let after_open = &rest[start + OPEN_TAG.len()..];
90+
let Some(end) = after_open.find(CLOSE_TAG) else {
91+
// Unterminated block: keep it (and everything after) as plain text.
92+
cleaned.push_str(&rest[start..]);
93+
return (cleaned.trim().to_string(), calls);
94+
};
95+
let inner = after_open[..end].trim();
96+
if let Some(call) = parse_one(inner, calls.len() + 1) {
97+
calls.push(call);
98+
}
99+
rest = &after_open[end + CLOSE_TAG.len()..];
100+
}
101+
cleaned.push_str(rest);
102+
(cleaned.trim().to_string(), calls)
103+
}
104+
105+
/// Extract prompt-guided `<tool_call>` blocks from a completed [`ModelResponse`]'s
106+
/// text into `message.tool_calls`, replacing the message content with the cleaned
107+
/// prose. No-op when the text carries no blocks — so a plain text answer is
108+
/// untouched. Applied by [`OpenAiModel`](super::OpenAiModel) after a non-native
109+
/// tool call completes (unary + terminal stream item).
110+
pub(super) fn apply_to_response(mut response: ModelResponse) -> ModelResponse {
111+
let text = response.text();
112+
let (cleaned, calls) = parse_tool_calls_from_text(&text);
113+
if calls.is_empty() {
114+
return response;
115+
}
116+
response.message.tool_calls.extend(calls);
117+
response.message.content = if cleaned.is_empty() {
118+
Vec::new()
119+
} else {
120+
vec![ContentBlock::Text(cleaned)]
121+
};
122+
response
123+
}
124+
125+
/// Parse a single tool-call body into a [`ToolCall`] with a synthetic 1-based id.
126+
fn parse_one(inner: &str, index: usize) -> Option<ToolCall> {
127+
let value: Value = serde_json::from_str(inner).ok()?;
128+
let name = value.get("name")?.as_str()?.to_string();
129+
let arguments = value
130+
.get("arguments")
131+
.cloned()
132+
.unwrap_or_else(|| Value::Object(Map::new()));
133+
Some(ToolCall {
134+
id: format!("call_{index}"),
135+
name,
136+
arguments,
137+
})
138+
}
139+
140+
#[cfg(test)]
141+
mod test {
142+
use super::*;
143+
144+
fn schema(name: &str) -> ToolSchema {
145+
ToolSchema {
146+
name: name.to_string(),
147+
description: format!("{name} description"),
148+
parameters: serde_json::json!({"type": "object"}),
149+
format: Default::default(),
150+
}
151+
}
152+
153+
#[test]
154+
fn instructions_list_each_tool() {
155+
let text = tool_instructions(&[schema("read_file"), schema("write_file")]);
156+
assert!(text.contains("## Tool Use Protocol"));
157+
assert!(text.contains("<tool_call>"));
158+
assert!(text.contains("**read_file**"));
159+
assert!(text.contains("**write_file**"));
160+
}
161+
162+
#[test]
163+
fn with_tool_instructions_appends_to_system() {
164+
let msgs = vec![Message::system("You are helpful."), Message::user("hi")];
165+
let out = with_tool_instructions(&msgs, &[schema("read_file")]);
166+
assert_eq!(out.len(), 2);
167+
let Message::System(sys) = &out[0] else {
168+
panic!("first message should stay system")
169+
};
170+
// Original prompt preserved + protocol appended.
171+
let joined: String = sys
172+
.content
173+
.iter()
174+
.filter_map(|b| match b {
175+
ContentBlock::Text(t) => Some(t.as_str()),
176+
_ => None,
177+
})
178+
.collect();
179+
assert!(joined.contains("You are helpful."));
180+
assert!(joined.contains("Tool Use Protocol"));
181+
}
182+
183+
#[test]
184+
fn with_tool_instructions_inserts_system_when_absent() {
185+
let msgs = vec![Message::user("hi")];
186+
let out = with_tool_instructions(&msgs, &[schema("read_file")]);
187+
assert_eq!(out.len(), 2);
188+
assert!(matches!(out[0], Message::System(_)));
189+
}
190+
191+
#[test]
192+
fn empty_tools_leaves_messages_unchanged() {
193+
let msgs = vec![Message::user("hi")];
194+
assert_eq!(with_tool_instructions(&msgs, &[]), msgs);
195+
}
196+
197+
#[test]
198+
fn parses_single_tool_call() {
199+
let text = r#"Let me read it.
200+
<tool_call>
201+
{"name": "read_file", "arguments": {"path": "a.txt"}}
202+
</tool_call>"#;
203+
let (cleaned, calls) = parse_tool_calls_from_text(text);
204+
assert_eq!(cleaned, "Let me read it.");
205+
assert_eq!(calls.len(), 1);
206+
assert_eq!(calls[0].name, "read_file");
207+
assert_eq!(calls[0].id, "call_1");
208+
assert_eq!(calls[0].arguments, serde_json::json!({"path": "a.txt"}));
209+
}
210+
211+
#[test]
212+
fn parses_multiple_calls_and_keeps_prose() {
213+
let text = r#"a<tool_call>{"name":"one","arguments":{}}</tool_call>b<tool_call>{"name":"two","arguments":{"x":1}}</tool_call>c"#;
214+
let (cleaned, calls) = parse_tool_calls_from_text(text);
215+
assert_eq!(cleaned, "abc");
216+
assert_eq!(calls.len(), 2);
217+
assert_eq!(calls[0].name, "one");
218+
assert_eq!(calls[1].name, "two");
219+
assert_eq!(calls[1].id, "call_2");
220+
}
221+
222+
#[test]
223+
fn missing_arguments_defaults_to_empty_object() {
224+
let (_, calls) = parse_tool_calls_from_text(r#"<tool_call>{"name":"noargs"}</tool_call>"#);
225+
assert_eq!(calls.len(), 1);
226+
assert_eq!(calls[0].arguments, serde_json::json!({}));
227+
}
228+
229+
#[test]
230+
fn malformed_block_is_dropped() {
231+
let (cleaned, calls) = parse_tool_calls_from_text("<tool_call>not json</tool_call>done");
232+
assert!(calls.is_empty());
233+
assert_eq!(cleaned, "done");
234+
}
235+
236+
#[test]
237+
fn unterminated_block_kept_as_text() {
238+
let text = "text <tool_call>{\"name\":\"x\"}";
239+
let (cleaned, calls) = parse_tool_calls_from_text(text);
240+
assert!(calls.is_empty());
241+
assert_eq!(cleaned, "text <tool_call>{\"name\":\"x\"}");
242+
}
243+
244+
#[test]
245+
fn no_blocks_returns_text_verbatim() {
246+
let (cleaned, calls) = parse_tool_calls_from_text("just a normal answer");
247+
assert!(calls.is_empty());
248+
assert_eq!(cleaned, "just a normal answer");
249+
}
250+
}

src/harness/providers/openai/transport.rs

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -685,32 +685,50 @@ impl OpenAiModel {
685685
&self,
686686
request: &ModelRequest,
687687
) -> Result<ChatCompletionRequest> {
688+
// Prompt-guided tools: a model without native tool calling that is still
689+
// handed tools gets the tool protocol embedded in its system prompt and no
690+
// native `tools` on the wire (many local runtimes 400 on `tools`). The
691+
// model's `<tool_call>` blocks are parsed back in [`Self::invoke`]/stream.
692+
let prompt_guided_tools = !self.profile.tool_calling && !request.tools.is_empty();
693+
let instructed_messages;
694+
let base_messages: &[Message] = if prompt_guided_tools {
695+
instructed_messages =
696+
prompt_tools::with_tool_instructions(&request.messages, &request.tools);
697+
&instructed_messages
698+
} else {
699+
&request.messages
700+
};
701+
688702
// Optionally fold system messages into the first user turn (for endpoints
689703
// that reject a `system` role) before wire translation.
690704
let merged_messages;
691705
let source_messages: &[Message] = if self.merge_system_into_user {
692-
merged_messages = merge_system_into_user(&request.messages);
706+
merged_messages = merge_system_into_user(base_messages);
693707
&merged_messages
694708
} else {
695-
&request.messages
709+
base_messages
696710
};
697711
let messages = source_messages
698712
.iter()
699713
.map(translate_message)
700714
.collect::<Result<Vec<_>>>()?;
701715

702-
let tools: Vec<ToolWire> = request
703-
.tools
704-
.iter()
705-
.map(|schema| ToolWire {
706-
kind: "function".to_string(),
707-
function: FunctionSchemaWire {
708-
name: schema.name.clone(),
709-
description: schema.description.clone(),
710-
parameters: schema.parameters.clone(),
711-
},
712-
})
713-
.collect();
716+
let tools: Vec<ToolWire> = if prompt_guided_tools {
717+
Vec::new()
718+
} else {
719+
request
720+
.tools
721+
.iter()
722+
.map(|schema| ToolWire {
723+
kind: "function".to_string(),
724+
function: FunctionSchemaWire {
725+
name: schema.name.clone(),
726+
description: schema.description.clone(),
727+
parameters: schema.parameters.clone(),
728+
},
729+
})
730+
.collect()
731+
};
714732

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

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

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

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

0 commit comments

Comments
 (0)