Skip to content

harness(openai): prompt-guided (text-mode) tool calling for non-native models#55

Merged
senamakel merged 1 commit into
mainfrom
harness/openai-prompt-guided-tools
Jul 11, 2026
Merged

harness(openai): prompt-guided (text-mode) tool calling for non-native models#55
senamakel merged 1 commit into
mainfrom
harness/openai-prompt-guided-tools

Conversation

@senamakel

@senamakel senamakel commented Jul 11, 2026

Copy link
Copy Markdown
Member

What

Adds prompt-guided (text-mode) tool calling to OpenAiModel for models
without native tool support (with_native_tool_calling(false) — many self-hosted
/ local OpenAI-compatible runtimes that reject the OpenAI tools param with a
400).

Previously the crate was native-tools-only: a model with profile.tool_calling == false still had request.tools sent as native tools (which those runtimes
reject), and there was no path to embed tools in the prompt or parse tool calls
from text — the doc comments referenced a "harness embeds tool specs in the
prompt" behavior that wasn't actually implemented.

Now, when profile.tool_calling is false and a request carries tools:

  • translate_request embeds a ## Tool Use Protocol block on the system
    prompt (<tool_call>{"name":..,"arguments":..}</tool_call> convention + each
    tool's name/description/JSON-Schema params) and sends no native tools.
  • invoke and stream recover the model's <tool_call> blocks from the
    completed response into message.tool_calls (unary + terminal stream
    Completed), stripping them from the prose — so the agent loop sees tool calls
    identically to the native path, with no harness change.

New prompt_tools module holds the pure builders/parser (tool_instructions,
with_tool_instructions, parse_tool_calls_from_text, apply_to_response).

Why

Unblocks OpenHuman's inference cutover of local runtimes to crate-native
models (issue #4249 Phase 3): they relied on the host's prompt-guided tool
parsing (TextModeProvider + pformat), which the crate lacked. The <tool_call>
convention matches the long-standing OpenHuman host format, so models already
prompted for it behave identically after the cutover.

Testing

  • 10 new unit tests (instruction embedding, single/multi/malformed/unterminated
    block parsing, missing-args default, system-message insertion).
  • Full providers::openai suite (75) green — native tool path unchanged.
  • cargo fmt --check, cargo clippy --lib -- -D warnings clean.

Follow-up

Streamed text deltas still carry the raw <tool_call> markup (tool calls are
recovered from the terminal Completed); cleaning the live deltas mid-stream is
a follow-up.

https://claude.ai/code/session_01U8NDGbt9tKj443VquzycLB

Summary by CodeRabbit

  • New Features

    • Added support for tool calling with OpenAI-compatible models that do not provide native tool support.
    • Tool instructions are automatically included in prompts when needed.
    • Tool calls returned in text are recognized and converted into structured tool requests.
    • Works for both standard and streaming responses.
  • Bug Fixes

    • Malformed tool-call content is handled safely without disrupting surrounding response text.

…e 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
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OpenAI provider adds prompt-guided tool calling for models without native tool support. It injects tool schemas into messages, omits wire-level tools, parses tagged tool calls from responses, and applies recovery to invoke and streaming completion paths.

Changes

Prompt-guided tool calling

Layer / File(s) Summary
Tool protocol and text parsing
src/harness/providers/openai/mod.rs, src/harness/providers/openai/prompt_tools.rs
Defines the text-mode tool protocol, message instruction generation, response parsing, response adaptation, and unit tests.
Prompt-guided request translation
src/harness/providers/openai/transport.rs
Injects tool instructions when native tool calling is disabled and removes tools from the wire request.
Invoke and stream response recovery
src/harness/providers/openai/transport.rs
Applies text tool-call extraction to completed invoke responses and terminal streaming responses.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Poem

I’m a rabbit with tools in a prompt-built row,
Tagging each call where the model winds go.
JSON tucked neatly, prose brushed clean,
Streams bloom calls at their ending scene.
Hop, hop—OpenAI now knows what they mean!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: prompt-guided text-mode tool calling for OpenAI models without native tool support.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65eabc4ffc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// 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 👍 / 👎.

Comment on lines +716 to +717
let tools: Vec<ToolWire> = if prompt_guided_tools {
Vec::new()

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 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/harness/providers/openai/transport.rs (1)

688-700: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the prompt-guided predicate to avoid drift across the three call sites.

!self.profile.tool_calling && !request.tools.is_empty() is duplicated here (as prompt_guided_tools), in invoke (Line 1108), and in stream (Line 1184). Request translation omits wire tools based on this, and both response paths recover the tags based on it — if one copy is later edited (e.g. adding a config flag), the request and recovery paths silently disagree and prompt-guided calls stop being recovered. A single fn prompt_guided_tools(&self, request: &ModelRequest) -> bool keeps all three in lockstep.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/harness/providers/openai/transport.rs` around lines 688 - 700, Extract
the duplicated prompt-guided condition into a shared
Self::prompt_guided_tools(&self, request: &ModelRequest) -> bool helper. Replace
the local predicate in request translation and the corresponding checks in
invoke and stream, preserving the current behavior while ensuring wire-tool
omission and response tag recovery use the same decision.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/harness/providers/openai/transport.rs`:
- Around line 688-700: Extract the duplicated prompt-guided condition into a
shared Self::prompt_guided_tools(&self, request: &ModelRequest) -> bool helper.
Replace the local predicate in request translation and the corresponding checks
in invoke and stream, preserving the current behavior while ensuring wire-tool
omission and response tag recovery use the same decision.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 446e6e83-23c8-4b85-8972-62bcee0d38ab

📥 Commits

Reviewing files that changed from the base of the PR and between e19b858 and 65eabc4.

📒 Files selected for processing (3)
  • src/harness/providers/openai/mod.rs
  • src/harness/providers/openai/prompt_tools.rs
  • src/harness/providers/openai/transport.rs

@senamakel senamakel merged commit 40cd29b into main Jul 11, 2026
3 checks passed
@senamakel senamakel deleted the harness/openai-prompt-guided-tools branch July 11, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant