Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isCyberPolicyCode } from "../lib/errors";
import { redactSecretString } from "../lib/redact";
import { contentPartsToText } from "./image";
import { identifyRoutedModel } from "./identity";
import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
import {
Expand Down Expand Up @@ -195,6 +196,9 @@ function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[]
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
const out: unknown[] = [];
const { context, options } = parsed;
// Mirror the bridge's replay-cache scope (issue #950): provider call ids are
// not globally unique, so reasoning must not cross conversation boundaries.
const replayCacheScope = parsed._clientThreadId ?? "global";

// 260718 dangling tool_calls hardening (devlog/_plan/260718_dangling_toolcall_hardening):
// strict chat providers (Kimi/Moonshot) 400 when an assistant tool_call is not answered
Expand Down Expand Up @@ -324,7 +328,26 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
if (textParts.length > 0) {
chatMsg.content = textParts.map(p => p.text).join("");
}
const reasoningContent = thinkingParts.map(p => p.thinking).join("");
let reasoningContent = thinkingParts.map(p => p.thinking).join("");
// History transformations (compaction, lost assistant turn, resumed
// threads) can strip the reasoning item while the tool round survives.
// Re-attach the reasoning the bridge recorded for these call ids so
// preserveReasoningContentModels providers (DeepSeek thinking mode)
// never receive a bare tool-call continuation (issue #950).
if (
reasoningContent.length === 0
&& toolCalls.length > 0
&& modelInList(provider.preserveReasoningContentModels, parsed.modelId)
) {
const cached = toolCalls
.map(tc => (tc.id ? peekReasoningForCall(tc.id, replayCacheScope) : undefined))
.filter((text): text is string => typeof text === "string" && text.length > 0);
// Parallel calls share one preceding reasoning block, which is
// recorded under every call id — join unique texts only.
if (cached.length > 0) {
reasoningContent = [...new Set(cached)].join("\n");
}
}
if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
chatMsg.reasoning_content = reasoningContent;
}
Expand Down Expand Up @@ -382,9 +405,17 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
// role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
flushPendingToolCalls();
const name = safeToolName(msg.toolName);
// The orphan repair synthesizes an assistant tool call for a result
// whose assistant turn was lost; carry the recorded reasoning so the
// replayed round stays valid for thinking-mode providers (#950).
const cachedReasoning =
toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
? peekReasoningForCall(toolCallId, replayCacheScope)
: undefined;
out.push({
role: "assistant",
content: emptyAssistantContent(provider),
...(cachedReasoning ? { reasoning_content: cachedReasoning } : {}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
Wibias marked this conversation as resolved.
tool_calls: [{
id: toolCallId,
type: "function",
Expand Down
42 changes: 42 additions & 0 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUs
import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
import { encodeCompactionSummary } from "./responses/compaction";
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
import { resolveStallTimeoutSec } from "./stall-timeout";
import { usageDisplayTotalTokens } from "./usage/totals";
import {
Expand Down Expand Up @@ -180,6 +181,12 @@ export function bridgeToResponsesSSE(
*/
onUsage?: (usage: OcxUsage | undefined) => void;
translatorBudget?: TranslatorBudget;
/**
* Conversation identity for the reasoning replay cache (issue #950).
* Provider call ids are not globally unique; scoping by thread keeps one
* conversation's reasoning out of another's continuations.
*/
replayCacheScope?: string;
/**
* Test seam for the wire/stall beat loop. Production omits this and uses the
* global timers; injecting here must not change scheduling semantics.
Expand All @@ -190,6 +197,7 @@ export function bridgeToResponsesSSE(
};
},
): ReadableStream<Uint8Array> {
const replayCacheScope = options?.replayCacheScope ?? "global";
const setBeatInterval = options?.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms));
const clearBeatInterval = options?.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType<typeof setInterval>));
// Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
Expand Down Expand Up @@ -426,8 +434,15 @@ export function bridgeToResponsesSSE(
// encodeReasoningEnvelope: takeReasoningEnvelope's sig/red guard would drop txt-only.
let hiddenRawReasoningText = "";
let hiddenRawReasoningBytes = 0;
// Raw reasoning text flushed most recently, waiting for the tool call it
// preceded. Recorded into the replay cache on tool_call_start so a later
// continuation can re-attach it when history lost the reasoning item
// (issue #950). Kept until new reasoning/text arrives: parallel tool
// calls share the same preceding reasoning block.
let rawReasoningForNextToolCall = "";
const flushHiddenRawReasoning = () => {
if (!hiddenRawReasoningText) return;
rawReasoningForNextToolCall = hiddenRawReasoningText;
const previousBytes = hiddenRawReasoningBytes;
const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText });
const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
Expand Down Expand Up @@ -519,6 +534,7 @@ export function bridgeToResponsesSSE(

const closeCurrentRawReasoning = () => {
if (!currentRawReasoning) return;
rawReasoningForNextToolCall = currentRawReasoning.text;
const item = {
type: "reasoning", id: currentRawReasoning.itemId, summary: [],
content: [{ type: "reasoning_text", text: currentRawReasoning.text }],
Expand Down Expand Up @@ -779,6 +795,7 @@ export function bridgeToResponsesSSE(
if (currentReasoning) closeCurrentReasoning();
if (currentRawReasoning) closeCurrentRawReasoning();
flushHiddenRawReasoning();
rawReasoningForNextToolCall = "";
if (currentToolCall) closeCurrentToolCall();
flushHiddenReasoningEnvelope();
break;
Expand All @@ -787,6 +804,8 @@ export function bridgeToResponsesSSE(
if (currentReasoning) closeCurrentReasoning();
if (currentRawReasoning) closeCurrentRawReasoning();
flushHiddenRawReasoning();
// Reasoning consumed by a text turn, not a tool call: no cache target.
rawReasoningForNextToolCall = "";
if (currentToolCall) closeCurrentToolCall();
// Only flush on an explicit phase change. A later delta that omits `phase` must
// keep appending to the current message rather than wiping the earlier phase.
Expand Down Expand Up @@ -821,6 +840,12 @@ export function bridgeToResponsesSSE(
}
case "thinking_delta": {
if (options?.hideThinkingSummary) {
// The hidden branch returns early, so flush any raw reasoning
// that preceded the thinking block and clear the replay-cache
// candidate — otherwise a stale reasoning_raw_delta would be
// recorded for a LATER tool call (CodeRabbit on #971).
flushHiddenRawReasoning();
rawReasoningForNextToolCall = "";
({ value: hiddenThinkingText, bytes: hiddenThinkingBytes } = appendString(
hiddenThinkingText,
hiddenThinkingBytes,
Expand All @@ -832,6 +857,7 @@ export function bridgeToResponsesSSE(
if (currentMsg) closeCurrentMessage("commentary");
if (currentRawReasoning) closeCurrentRawReasoning();
flushHiddenRawReasoning();
rawReasoningForNextToolCall = "";
if (currentToolCall) closeCurrentToolCall();
if (!currentReasoning) {
const itemId = `rs_${uuid()}`;
Expand Down Expand Up @@ -905,6 +931,9 @@ export function bridgeToResponsesSSE(
if (currentReasoning) closeCurrentReasoning();
if (currentRawReasoning) closeCurrentRawReasoning();
flushHiddenRawReasoning();
if (rawReasoningForNextToolCall) {
rememberReasoningForCall(event.id, rawReasoningForNextToolCall, replayCacheScope);
}
if (currentToolCall) closeCurrentToolCall();
const mapped = toolNsMap?.get(event.name);
const realName = mapped?.name ?? event.name;
Expand Down Expand Up @@ -1298,9 +1327,12 @@ function buildResponseJSONWithBudget(
/** Raw adapter-reported usage before wire normalization (see bridgeToResponsesSSE onUsage). */
onUsage?: (usage: OcxUsage | undefined) => void;
translatorBudget?: TranslatorBudget;
/** Conversation identity for the reasoning replay cache (issue #950). */
replayCacheScope?: string;
},
): Record<string, unknown> {
const responseId = `resp_${uuid()}`;
const replayCacheScope = options?.replayCacheScope ?? "global";
const output: OutputItem[] = [];
const budget = options?.translatorBudget;
const encoder = new TextEncoder();
Expand Down Expand Up @@ -1356,6 +1388,9 @@ function buildResponseJSONWithBudget(
let currentSummaryReasoningBytes = 0;
let currentRawReasoning = "";
let currentRawReasoningBytes = 0;
// Same replay-cache handoff as the streaming path (issue #950): the most
// recently flushed raw reasoning waits for the tool call it preceded.
let rawReasoningForNextToolCall = "";
// Anthropic extended-thinking round-trip (batch): see bridgeToResponsesSSE counterpart.
let batchSignature: string | undefined;
let batchSignatureBytes = 0;
Expand Down Expand Up @@ -1423,6 +1458,7 @@ function buildResponseJSONWithBudget(
};
const flushRawReasoning = () => {
if (!currentRawReasoning) return;
rawReasoningForNextToolCall = currentRawReasoning;
if (options?.hideThinkingSummary === true) {
// Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip.
pushOutput({
Expand Down Expand Up @@ -1480,6 +1516,7 @@ function buildResponseJSONWithBudget(
flushText("commentary");
flushSummaryReasoning();
flushRawReasoning();
rawReasoningForNextToolCall = "";
flushToolCall();
break;
case "text_delta":
Expand All @@ -1488,6 +1525,7 @@ function buildResponseJSONWithBudget(
if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
if (currentSummaryReasoning) flushSummaryReasoning();
if (currentRawReasoning) flushRawReasoning();
rawReasoningForNextToolCall = "";
if (currentToolCallId) flushToolCall();
// Compaction turns keep the summary out of normal message output (replay dedup — see
// bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
Expand All @@ -1506,6 +1544,7 @@ function buildResponseJSONWithBudget(
case "thinking_delta":
if (currentText) flushText("commentary");
if (currentRawReasoning) flushRawReasoning();
rawReasoningForNextToolCall = "";
if (currentToolCallId) flushToolCall();
{
({ value: currentSummaryReasoning, bytes: currentSummaryReasoningBytes } = appendBatchString(
Expand Down Expand Up @@ -1542,6 +1581,9 @@ function buildResponseJSONWithBudget(
if (currentText) flushText("commentary");
if (currentSummaryReasoning) flushSummaryReasoning();
if (currentRawReasoning) flushRawReasoning();
if (rawReasoningForNextToolCall) {
rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope);
}
flushToolCall();
currentToolCallId = e.id;
budget?.openCall(e.id);
Expand Down
16 changes: 16 additions & 0 deletions src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
const parts: OcxThinkingContent[] = [];
let thinking = "";
let signature: string | undefined;
let rawReasoning = "";

const flushVisible = () => {
if (!thinking && !signature) return;
Expand All @@ -170,19 +171,33 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
thinking = "";
signature = undefined;
};
const flushRaw = () => {
if (!rawReasoning) return;
parts.push({ type: "thinking", thinking: rawReasoning });
rawReasoning = "";
};

for (const e of events) {
if (e.type === "thinking_delta") {
flushRaw();
thinking += e.thinking;
} else if (e.type === "reasoning_raw_delta") {
// OpenAI-compatible providers emit raw reasoning instead of signed
// thinking; DeepSeek thinking mode requires it back alongside replayed
// tool_calls (mirrors src/web-search/loop.ts, issue #950).
flushVisible();
rawReasoning += e.text;
} else if (e.type === "thinking_signature") {
signature = e.signature;
flushVisible();
} else if (e.type === "redacted_thinking") {
flushVisible();
flushRaw();
parts.push({ type: "thinking", thinking: "", redacted: [e.data] });
}
}
flushVisible();
flushRaw();
return parts;
}

Expand Down Expand Up @@ -813,6 +828,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
}, 2_000,
{
translatorBudget,
replayCacheScope: parsed._clientThreadId ?? "global",
...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
hideThinkingSummary: parsed.options.hideThinkingSummary,
stallTimeoutSec: deps.stallTimeoutSec,
Expand Down
34 changes: 32 additions & 2 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,34 @@ function findToolById(messages: OcxMessage[], callId: string): { name: string; n
return { name: "" };
}

/**
* Attach pending reasoning to the assistant turn that owns the given call id.
* Reconstructed histories (resume/retry/synthetic) can order a `reasoning`
* item AFTER the `function_call` it belongs to; without this, the pending
* buffer is cleared at the tool output and the turn serializes without
* `reasoning_content`, which DeepSeek thinking mode rejects with HTTP 400
* (issue #950).
*/
function attachPendingReasoningToCallOwner(
messages: OcxMessage[],
callId: string,
pendingReasoning: Array<{ part: OcxThinkingContent; envelopeSigned: boolean }>,
): void {
if (pendingReasoning.length === 0 || !callId) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== "assistant") continue;
for (const part of m.content) {
if (part.type === "toolCall" && part.id === callId) {
// Prepend so thinking still precedes tool_use for adapters that require
// that ordering (Anthropic-style replay).
m.content = [...pendingReasoning.map(entry => entry.part), ...m.content];
return;
}
}
}
}

const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);

export function parseRequest(body: unknown): OcxParsedRequest {
Expand Down Expand Up @@ -545,8 +573,9 @@ export function parseRequest(body: unknown): OcxParsedRequest {
}

if (effectiveType === "function_call_output") {
pendingReasoning.length = 0;
const output = item as { call_id: string; output?: string | unknown[] };
attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning);
pendingReasoning.length = 0;
const toolInfo = findToolById(messages, output.call_id);
messages.push({
role: "toolResult", toolCallId: output.call_id,
Expand All @@ -558,8 +587,9 @@ export function parseRequest(body: unknown): OcxParsedRequest {
}

if (effectiveType === "custom_tool_call_output") {
pendingReasoning.length = 0;
const output = item as { call_id: string; output: string | unknown[] };
attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning);
pendingReasoning.length = 0;
const toolInfo = findToolById(messages, output.call_id);
messages.push({
role: "toolResult", toolCallId: output.call_id,
Expand Down
Loading
Loading