-
Notifications
You must be signed in to change notification settings - Fork 3
harness(openai): prompt-guided (text-mode) tool calling for non-native models #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After a text-mode call is parsed, the agent loop appends the assistant 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() { | ||
|
|
@@ -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`]. | ||
|
|
@@ -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)) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ToolChoice::Nonemeans the model must not call any tool, but this condition ignoresrequest.tool_choice: a request that carries tools for later use but disables them for this turn still gets tool instructions injected andinvoke/streamwill parse<tool_call>blocks, so the agent loop can execute a tool the caller explicitly forbade. Keep prompt-guided tooling disabled (and skip parsing) whenrequest.tool_choiceisNone, or render an explicit no-tool instruction.Useful? React with 👍 / 👎.