diff --git a/src/harness/providers/openai/mod.rs b/src/harness/providers/openai/mod.rs
index 04568c7..9e0a697 100644
--- a/src/harness/providers/openai/mod.rs
+++ b/src/harness/providers/openai/mod.rs
@@ -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;
diff --git a/src/harness/providers/openai/prompt_tools.rs b/src/harness/providers/openai/prompt_tools.rs
new file mode 100644
index 0000000..a830e61
--- /dev/null
+++ b/src/harness/providers/openai/prompt_tools.rs
@@ -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 `…` blocks back into
+//! [`ToolCall`]s — so the agent loop sees tool calls identically to the native
+//! path, without any harness change.
+//!
+//! The `{"name":…,"arguments":…}` 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 = "";
+const CLOSE_TAG: &str = "";
+
+/// Build the tool-use protocol block appended to the system prompt when native
+/// tool calling is unavailable. Describes the `` 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 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 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 {
+ 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 `…` 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 `` with no close is left verbatim in
+/// the returned text.
+pub(super) fn parse_tool_calls_from_text(text: &str) -> (String, Vec) {
+ 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 `` 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 {
+ 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(""));
+ 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.
+
+{"name": "read_file", "arguments": {"path": "a.txt"}}
+"#;
+ 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{"name":"one","arguments":{}}b{"name":"two","arguments":{"x":1}}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#"{"name":"noargs"}"#);
+ 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("not jsondone");
+ assert!(calls.is_empty());
+ assert_eq!(cleaned, "done");
+ }
+
+ #[test]
+ fn unterminated_block_kept_as_text() {
+ let text = "text {\"name\":\"x\"}";
+ let (cleaned, calls) = parse_tool_calls_from_text(text);
+ assert!(calls.is_empty());
+ assert_eq!(cleaned, "text {\"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");
+ }
+}
diff --git a/src/harness/providers/openai/transport.rs b/src/harness/providers/openai/transport.rs
index 92a569f..9e8d71a 100644
--- a/src/harness/providers/openai/transport.rs
+++ b/src/harness/providers/openai/transport.rs
@@ -685,32 +685,50 @@ impl OpenAiModel {
&self,
request: &ModelRequest,
) -> Result {
+ // 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 `` 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::>>()?;
- let tools: Vec = 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 = if prompt_guided_tools {
+ Vec::new()
+ } 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 ChatModel 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 `` 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 ChatModel 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 `` 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))
}
}