From 90e784cab8e34e28b10e32787224c2fb1fa17598 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 10 Apr 2026 13:12:38 +0300 Subject: [PATCH] fix(btw): omit empty tool arrays for side questions (#64219) (thanks @ngutman) (#64219) --- CHANGELOG.md | 1 + src/agents/btw.test.ts | 15 +++++++++++++++ src/agents/btw.ts | 11 ++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 066395a0e9e..fbb1fdcb59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai - iMessage (imsg): strip an accidental protobuf length-delimited UTF-8 field wrapper from inbound `text` and `reply_to_text` when it fully consumes the field, fixing leading garbage before the real message. (#63868) Thanks @neeravmakwana. - Gateway/pairing: fail closed for paired device records that have no device tokens, and reject pairing approvals whose requested scopes do not match the requested device roles. - ACP/gateway chat: classify lifecycle errors before forwarding them to ACP clients so refusals use ACP's refusal stop reason while transient backend errors continue to finish as normal turns. +- Commands/btw: keep tool-less side questions from sending injected empty `tools` arrays on strict OpenAI-compatible providers, so `/btw` continues working after prior tool-call history. (#64219) Thanks @ngutman. ## 2026.4.9 diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 0c17a5b665e..3204b200bf4 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -297,6 +297,21 @@ describe("runBtwSideQuestion", () => { expect(result).toEqual({ text: "Final answer." }); }); + it("strips injected empty tools arrays from BTW payloads before sending", async () => { + mockDoneAnswer("Final answer."); + + await runSideQuestion(); + + const [, , options] = streamSimpleMock.mock.calls[0] ?? []; + const onPayload = (options as { onPayload?: (payload: unknown) => void })?.onPayload; + const payloadWithEmptyTools = { messages: [], tools: [] as unknown[] }; + + const result = onPayload?.(payloadWithEmptyTools); + + expect(payloadWithEmptyTools).not.toHaveProperty("tools"); + expect(result).toBeUndefined(); + }); + it("forces provider reasoning off even when the session think level is adaptive", async () => { streamSimpleMock.mockImplementation((_model, _input, options?: { reasoning?: unknown }) => { return options?.reasoning === undefined diff --git a/src/agents/btw.ts b/src/agents/btw.ts index b2c3b2766b2..972c3fbbe67 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -21,6 +21,7 @@ import { ensureOpenClawModelsJson } from "./models-config.js"; import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js"; import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js"; import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js"; +import { streamWithPayloadPatch } from "./pi-embedded-runner/stream-payload-utils.js"; import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; @@ -283,7 +284,8 @@ export async function runBtwSideQuestion( await blockEmitChain; }; - const stream = streamSimple( + const stream = await streamWithPayloadPatch( + streamSimple, model, { systemPrompt: buildBtwSystemPrompt(), @@ -308,6 +310,13 @@ export async function runBtwSideQuestion( reasoning: undefined, signal: params.opts?.abortSignal, }, + (payloadObj) => { + // BTW is intentionally tool-less. Some OpenAI-compatible providers reject + // the empty tools arrays injected for generic tool-history replay. + if (Array.isArray(payloadObj.tools) && payloadObj.tools.length === 0) { + delete payloadObj.tools; + } + }, ); let finalEvent: