Improve local chat tool call flow (#5962)
Unify the Studio local tool-call loop (GGUF + safetensors) behind a shared ToolLoopController: ordered preface-then-tool-card rendering, duplicate-call de-looping with a forced final answer, XML-leak containment, and a parser fix that accepts closed <function=...> calls followed by trailing prose. Includes backend tests for the controller, strict parser, and GGUF route cursor reset.
This commit is contained in:
parent
0d6d7dd4b3
commit
ccb471f5bf
15 changed files with 3647 additions and 900 deletions
|
|
@ -252,8 +252,7 @@ function documentCitationToSource(
|
|||
title: string;
|
||||
metadata?: { description: string };
|
||||
} | null {
|
||||
const source =
|
||||
typeof cit.source === "string" && cit.source ? cit.source : "";
|
||||
const source = typeof cit.source === "string" && cit.source ? cit.source : "";
|
||||
const docTitle =
|
||||
(typeof cit.document_title === "string" && cit.document_title) ||
|
||||
(typeof cit.title === "string" && cit.title) ||
|
||||
|
|
@ -264,13 +263,12 @@ function documentCitationToSource(
|
|||
// search_result_location can carry a free-form id (e.g. ``kb-doc-42``)
|
||||
// or a hostile scheme. Fall back to a stable doc anchor otherwise.
|
||||
const url =
|
||||
isSafeNavigableSourceUrl(source) || `#anthropic-doc-${docIndex ?? fallbackIdx}`;
|
||||
isSafeNavigableSourceUrl(source) ||
|
||||
`#anthropic-doc-${docIndex ?? fallbackIdx}`;
|
||||
const title = docTitle || source || `Document ${fallbackIdx + 1}`;
|
||||
const cited =
|
||||
typeof cit.cited_text === "string" ? cit.cited_text.trim() : "";
|
||||
const cited = typeof cit.cited_text === "string" ? cit.cited_text.trim() : "";
|
||||
// Trim the cited snippet so the Sources panel stays scannable.
|
||||
const description =
|
||||
cited.length > 240 ? `${cited.slice(0, 240)}...` : cited;
|
||||
const description = cited.length > 240 ? `${cited.slice(0, 240)}...` : cited;
|
||||
// Anthropic numbers inline [N] per citation, not per source URL.
|
||||
// Fold citation type + position-bearing fields into the id so distinct
|
||||
// citations on the same source keep separate Sources entries.
|
||||
|
|
@ -565,154 +563,6 @@ function isAnthropicRefusalMessage(message: RunMessage): boolean {
|
|||
return metadata?.custom?.anthropicRefusal === true;
|
||||
}
|
||||
|
||||
function collectAssistantToolCalls(
|
||||
message: RunMessage,
|
||||
): Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
extra_content?: unknown;
|
||||
}> {
|
||||
const out: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
extra_content?: unknown;
|
||||
}> = [];
|
||||
for (const part of message.content ?? []) {
|
||||
if (part.type !== "tool-call") continue;
|
||||
const tc = part as ToolCallMessagePart & {
|
||||
argsText?: string;
|
||||
extra_content?: unknown;
|
||||
};
|
||||
const toolNameLower = (tc.toolName ?? "").toLowerCase();
|
||||
const argsObj =
|
||||
tc.args && typeof tc.args === "object"
|
||||
? (tc.args as Record<string, unknown>)
|
||||
: null;
|
||||
const argsGoogle =
|
||||
argsObj && typeof argsObj.google === "object" && argsObj.google !== null
|
||||
? (argsObj.google as Record<string, unknown>)
|
||||
: null;
|
||||
const hasNativePart = Boolean(
|
||||
argsGoogle &&
|
||||
typeof argsGoogle.native_part === "object" &&
|
||||
argsGoogle.native_part !== null,
|
||||
);
|
||||
const hasServerToolMarker = Boolean(
|
||||
argsObj && (argsObj as Record<string, unknown>)._server_tool === true,
|
||||
);
|
||||
const isServerSideBuiltin = isServerSideBuiltinToolPart(
|
||||
toolNameLower,
|
||||
argsObj,
|
||||
hasServerToolMarker,
|
||||
hasNativePart,
|
||||
);
|
||||
if (isServerSideBuiltin) {
|
||||
// Gemini code_execution / image_generation must round-trip the
|
||||
// native_part payload for native replay; drop the rest.
|
||||
if (!hasNativePart) continue;
|
||||
}
|
||||
const argumentsStr =
|
||||
typeof tc.argsText === "string" && tc.argsText.length > 0
|
||||
? tc.argsText
|
||||
: JSON.stringify(tc.args ?? {});
|
||||
const entry: {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
extra_content?: unknown;
|
||||
} = {
|
||||
id: tc.toolCallId,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tc.toolName ?? "",
|
||||
arguments: argumentsStr,
|
||||
},
|
||||
};
|
||||
// Promote args.google to extra_content.google so the backend
|
||||
// native_part replay branch finds it (it only inspects
|
||||
// extra_content, not function.arguments).
|
||||
if (tc.extra_content !== undefined) {
|
||||
entry.extra_content = tc.extra_content;
|
||||
} else if (argsGoogle) {
|
||||
entry.extra_content = { google: argsGoogle };
|
||||
}
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectToolResultMessages(
|
||||
message: RunMessage,
|
||||
): Array<{
|
||||
role: "tool";
|
||||
content: string;
|
||||
tool_call_id: string;
|
||||
name?: string;
|
||||
}> {
|
||||
const out: Array<{
|
||||
role: "tool";
|
||||
content: string;
|
||||
tool_call_id: string;
|
||||
name?: string;
|
||||
}> = [];
|
||||
for (const part of message.content ?? []) {
|
||||
if (part.type !== "tool-call") continue;
|
||||
const tc = part as ToolCallMessagePart;
|
||||
const result = (tc as { result?: unknown }).result;
|
||||
// Skip provider-side builtins; see isServerSideBuiltinToolPart().
|
||||
const argsObj =
|
||||
tc.args && typeof tc.args === "object"
|
||||
? (tc.args as Record<string, unknown>)
|
||||
: null;
|
||||
const argsGoogle =
|
||||
argsObj && typeof argsObj.google === "object" && argsObj.google !== null
|
||||
? (argsObj.google as Record<string, unknown>)
|
||||
: null;
|
||||
const toolNameLower = (tc.toolName ?? "").toLowerCase();
|
||||
const hasServerToolMarker = Boolean(
|
||||
argsObj && argsObj._server_tool === true,
|
||||
);
|
||||
const hasNativePart = Boolean(
|
||||
argsGoogle &&
|
||||
typeof argsGoogle.native_part === "object" &&
|
||||
argsGoogle.native_part !== null,
|
||||
);
|
||||
if (
|
||||
isServerSideBuiltinToolPart(
|
||||
toolNameLower,
|
||||
argsObj,
|
||||
hasServerToolMarker,
|
||||
hasNativePart,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (result === undefined || result === null) continue;
|
||||
let content: string;
|
||||
if (typeof result === "string") {
|
||||
// Backend ChatMessage validator rejects role="tool" with empty
|
||||
// content; serialise sentinel JSON so legitimately empty tool
|
||||
// outputs still round-trip to the provider.
|
||||
content = result.length > 0 ? result : JSON.stringify({ result: "" });
|
||||
} else {
|
||||
try {
|
||||
content = JSON.stringify(result);
|
||||
} catch {
|
||||
content = String(result);
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
role: "tool",
|
||||
content,
|
||||
tool_call_id: tc.toolCallId,
|
||||
...(tc.toolName ? { name: tc.toolName } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type SerializedMessage = {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: OpenAIMessageContent | null;
|
||||
|
|
@ -733,6 +583,185 @@ type SerializedMessage = {
|
|||
extra_content?: unknown;
|
||||
};
|
||||
|
||||
type SerializedToolCall = NonNullable<SerializedMessage["tool_calls"]>[number];
|
||||
type SerializedToolResult = {
|
||||
role: "tool";
|
||||
content: string;
|
||||
tool_call_id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type ToolPartReplayMetadata = {
|
||||
argsObj: Record<string, unknown> | null;
|
||||
argsGoogle: Record<string, unknown> | null;
|
||||
hasNativePart: boolean;
|
||||
isServerSideBuiltin: boolean;
|
||||
};
|
||||
|
||||
function getToolPartReplayMetadata(
|
||||
tc: ToolCallMessagePart,
|
||||
): ToolPartReplayMetadata {
|
||||
const toolNameLower = (tc.toolName ?? "").toLowerCase();
|
||||
const argsObj =
|
||||
tc.args && typeof tc.args === "object"
|
||||
? (tc.args as Record<string, unknown>)
|
||||
: null;
|
||||
const argsGoogle =
|
||||
argsObj && typeof argsObj.google === "object" && argsObj.google !== null
|
||||
? (argsObj.google as Record<string, unknown>)
|
||||
: null;
|
||||
const hasNativePart = Boolean(
|
||||
argsGoogle &&
|
||||
typeof argsGoogle.native_part === "object" &&
|
||||
argsGoogle.native_part !== null,
|
||||
);
|
||||
const hasServerToolMarker = Boolean(
|
||||
argsObj && (argsObj as Record<string, unknown>)._server_tool === true,
|
||||
);
|
||||
return {
|
||||
argsObj,
|
||||
argsGoogle,
|
||||
hasNativePart,
|
||||
isServerSideBuiltin: isServerSideBuiltinToolPart(
|
||||
toolNameLower,
|
||||
argsObj,
|
||||
hasServerToolMarker,
|
||||
hasNativePart,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
type ToolReplayProvenance = {
|
||||
source?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function getToolReplayProvenance(
|
||||
part: ToolCallMessagePart,
|
||||
): ToolReplayProvenance | null {
|
||||
const provenance = (part as { provenance?: unknown }).provenance;
|
||||
if (
|
||||
!provenance ||
|
||||
typeof provenance !== "object" ||
|
||||
Array.isArray(provenance)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return provenance as ToolReplayProvenance;
|
||||
}
|
||||
|
||||
function hasToolReplayResult(part: ToolCallMessagePart): boolean {
|
||||
const result = (part as { result?: unknown }).result;
|
||||
return result !== undefined && result !== null;
|
||||
}
|
||||
|
||||
function shouldFlushCompletedLocalToolPair(part: ToolCallMessagePart): boolean {
|
||||
const provenance = getToolReplayProvenance(part);
|
||||
if (provenance?.source !== "local") {
|
||||
return false;
|
||||
}
|
||||
if (getToolPartReplayMetadata(part).isServerSideBuiltin) {
|
||||
return false;
|
||||
}
|
||||
return hasToolReplayResult(part);
|
||||
}
|
||||
|
||||
function serializeAssistantToolCallPart(
|
||||
part: ToolCallMessagePart,
|
||||
): SerializedToolCall | null {
|
||||
const tc = part as ToolCallMessagePart & {
|
||||
argsText?: string;
|
||||
extra_content?: unknown;
|
||||
};
|
||||
const { argsGoogle, hasNativePart, isServerSideBuiltin } =
|
||||
getToolPartReplayMetadata(tc);
|
||||
|
||||
if (isServerSideBuiltin && !hasNativePart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const argumentsStr =
|
||||
typeof tc.argsText === "string" && tc.argsText.length > 0
|
||||
? tc.argsText
|
||||
: JSON.stringify(tc.args ?? {});
|
||||
const entry: SerializedToolCall = {
|
||||
id: tc.toolCallId,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tc.toolName ?? "",
|
||||
arguments: argumentsStr,
|
||||
},
|
||||
};
|
||||
// Promote args.google to extra_content.google so the backend
|
||||
// native_part replay branch can find it. The backend only inspects
|
||||
// extra_content, not function.arguments.
|
||||
if (tc.extra_content !== undefined) {
|
||||
entry.extra_content = tc.extra_content;
|
||||
} else if (argsGoogle) {
|
||||
entry.extra_content = { google: argsGoogle };
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function serializeToolResultPart(
|
||||
part: ToolCallMessagePart,
|
||||
): SerializedToolResult | null {
|
||||
const tc = part as ToolCallMessagePart;
|
||||
const result = (tc as { result?: unknown }).result;
|
||||
const { isServerSideBuiltin } = getToolPartReplayMetadata(tc);
|
||||
|
||||
// Skip provider-side builtins; see isServerSideBuiltinToolPart.
|
||||
if (isServerSideBuiltin) {
|
||||
return null;
|
||||
}
|
||||
if (result === undefined || result === null) return null;
|
||||
|
||||
let content: string;
|
||||
if (typeof result === "string") {
|
||||
// Backend ChatMessage validator rejects role="tool" with empty
|
||||
// content; serialise a sentinel JSON so legitimately empty tool
|
||||
// outputs still round-trip the follow-up turn to the provider.
|
||||
content = result.length > 0 ? result : JSON.stringify({ result: "" });
|
||||
} else {
|
||||
try {
|
||||
content = JSON.stringify(result);
|
||||
} catch {
|
||||
content = String(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
role: "tool" as const,
|
||||
content,
|
||||
tool_call_id: tc.toolCallId,
|
||||
...(tc.toolName ? { name: tc.toolName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function canReplayToolCallWithoutRoleTool(part: ToolCallMessagePart): boolean {
|
||||
// Gemini/OpenAI provider-native builtin cards replay through
|
||||
// extra_content/native parts and intentionally do not produce role="tool"
|
||||
// messages. Local/user tool calls must have a concrete tool result before
|
||||
// they are replayed ahead of later assistant text.
|
||||
return getToolPartReplayMetadata(part).isServerSideBuiltin;
|
||||
}
|
||||
|
||||
function sanitizeAssistantReplayText(text: string): string {
|
||||
return text.replace(
|
||||
/data:audio\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g,
|
||||
"[audio]",
|
||||
);
|
||||
}
|
||||
|
||||
function buildReplayContent(
|
||||
textContent: string,
|
||||
imageParts: Array<{ type: "image_url"; image_url: { url: string } }>,
|
||||
): OpenAIMessageContent {
|
||||
return imageParts.length > 0
|
||||
? [{ type: "text", text: textContent }, ...imageParts]
|
||||
: textContent;
|
||||
}
|
||||
|
||||
function collectAssistantTextThoughtSignature(
|
||||
message: RunMessage,
|
||||
): string | undefined {
|
||||
|
|
@ -749,6 +778,132 @@ function collectAssistantTextThoughtSignature(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function attachAssistantThoughtSignature(
|
||||
messages: SerializedMessage[],
|
||||
thoughtSignature: string | undefined,
|
||||
): void {
|
||||
if (!thoughtSignature) return;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message.role !== "assistant") continue;
|
||||
const extra =
|
||||
message.extra_content &&
|
||||
typeof message.extra_content === "object" &&
|
||||
!Array.isArray(message.extra_content)
|
||||
? (message.extra_content as Record<string, unknown>)
|
||||
: {};
|
||||
const google =
|
||||
extra.google &&
|
||||
typeof extra.google === "object" &&
|
||||
!Array.isArray(extra.google)
|
||||
? (extra.google as Record<string, unknown>)
|
||||
: {};
|
||||
message.extra_content = {
|
||||
...extra,
|
||||
google: { ...google, thought_signature: thoughtSignature },
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAssistantReplayMessages(
|
||||
message: RunMessage,
|
||||
): SerializedMessage[] {
|
||||
if (isAnthropicRefusalMessage(message)) {
|
||||
// Prune refused assistant turn from outbound history; the
|
||||
// rendered transcript still shows the user-visible notice.
|
||||
return [];
|
||||
}
|
||||
|
||||
const imageParts = collectImageParts(message);
|
||||
const messages: SerializedMessage[] = [];
|
||||
const pendingTextParts: string[] = [];
|
||||
let pendingToolCalls: SerializedToolCall[] = [];
|
||||
let pendingToolResults: SerializedToolResult[] = [];
|
||||
let imagePartsPending = imageParts.length > 0;
|
||||
|
||||
const flushAssistantAndToolResults = (force = false): void => {
|
||||
const textContent = sanitizeAssistantReplayText(
|
||||
pendingTextParts.join("\n"),
|
||||
);
|
||||
const includeImageParts = imagePartsPending ? imageParts : [];
|
||||
const hasContent = textContent.length > 0 || includeImageParts.length > 0;
|
||||
const hasToolCalls = pendingToolCalls.length > 0;
|
||||
|
||||
if (!force && !hasContent && !hasToolCalls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assistantMessage: SerializedMessage = {
|
||||
role: "assistant",
|
||||
content: hasContent
|
||||
? buildReplayContent(textContent, includeImageParts)
|
||||
: "",
|
||||
};
|
||||
if (hasToolCalls) {
|
||||
assistantMessage.tool_calls = pendingToolCalls;
|
||||
// OpenAI requires content === null on assistant turns whose
|
||||
// payload is entirely tool_calls (matches the wire shape Gemini
|
||||
// expects for the next functionCall replay).
|
||||
if (!hasContent) {
|
||||
assistantMessage.content = null;
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(assistantMessage);
|
||||
if (pendingToolResults.length > 0) {
|
||||
messages.push(...pendingToolResults);
|
||||
}
|
||||
|
||||
pendingTextParts.length = 0;
|
||||
pendingToolCalls = [];
|
||||
pendingToolResults = [];
|
||||
imagePartsPending = false;
|
||||
};
|
||||
|
||||
for (const part of message.content ?? []) {
|
||||
if (part.type === "text") {
|
||||
if (pendingToolCalls.length > 0) {
|
||||
flushAssistantAndToolResults();
|
||||
}
|
||||
pendingTextParts.push(part.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === "tool-call") {
|
||||
const toolPart = part as ToolCallMessagePart;
|
||||
const toolCall = serializeAssistantToolCallPart(toolPart);
|
||||
if (!toolCall) continue;
|
||||
|
||||
const toolResult = serializeToolResultPart(toolPart);
|
||||
if (!toolResult && !canReplayToolCallWithoutRoleTool(toolPart)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flushLocalPair = shouldFlushCompletedLocalToolPair(toolPart);
|
||||
if (flushLocalPair && pendingToolCalls.length > 0) {
|
||||
flushAssistantAndToolResults();
|
||||
}
|
||||
|
||||
pendingToolCalls.push(toolCall);
|
||||
if (toolResult) {
|
||||
pendingToolResults.push(toolResult);
|
||||
}
|
||||
|
||||
if (flushLocalPair) {
|
||||
flushAssistantAndToolResults();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushAssistantAndToolResults(messages.length === 0);
|
||||
attachAssistantThoughtSignature(
|
||||
messages,
|
||||
collectAssistantTextThoughtSignature(message),
|
||||
);
|
||||
return messages;
|
||||
}
|
||||
|
||||
function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
|
||||
if (
|
||||
message.role !== "system" &&
|
||||
|
|
@ -758,49 +913,18 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
|
|||
return [];
|
||||
}
|
||||
|
||||
let textContent = collectTextParts(message).join("\n");
|
||||
if (message.role === "assistant") {
|
||||
textContent = textContent.replace(
|
||||
/data:audio\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g,
|
||||
"[audio]",
|
||||
);
|
||||
if (isAnthropicRefusalMessage(message)) {
|
||||
// Prune refused assistant turn from outbound history; the rendered
|
||||
// transcript still shows the user-visible notice.
|
||||
return [];
|
||||
}
|
||||
return serializeAssistantReplayMessages(message);
|
||||
}
|
||||
|
||||
const textContent = collectTextParts(message).join("\n");
|
||||
const imageParts = collectImageParts(message);
|
||||
const toolCalls =
|
||||
message.role === "assistant" ? collectAssistantToolCalls(message) : [];
|
||||
const toolResults =
|
||||
message.role === "assistant" ? collectToolResultMessages(message) : [];
|
||||
|
||||
const base: SerializedMessage = {
|
||||
role: message.role,
|
||||
content:
|
||||
imageParts.length > 0
|
||||
? [{ type: "text", text: textContent }, ...imageParts]
|
||||
: textContent,
|
||||
};
|
||||
if (toolCalls.length > 0) {
|
||||
base.tool_calls = toolCalls;
|
||||
// OpenAI requires content === null on assistant turns that are
|
||||
// entirely tool_calls (matches the wire shape Gemini expects for
|
||||
// the next functionCall replay).
|
||||
if (!textContent && imageParts.length === 0) {
|
||||
base.content = null;
|
||||
}
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const sig = collectAssistantTextThoughtSignature(message);
|
||||
if (sig) {
|
||||
base.extra_content = { google: { thought_signature: sig } };
|
||||
}
|
||||
}
|
||||
|
||||
return toolResults.length > 0 ? [base, ...toolResults] : [base];
|
||||
return [
|
||||
{
|
||||
role: message.role,
|
||||
content: buildReplayContent(textContent, imageParts),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractImageBase64(input: string): string | undefined {
|
||||
|
|
@ -1310,7 +1434,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
let loaded: boolean;
|
||||
let blockedByTrustRemoteCode: boolean;
|
||||
try {
|
||||
({ loaded, blockedByTrustRemoteCode } = await autoLoadSmallestModel());
|
||||
({ loaded, blockedByTrustRemoteCode } =
|
||||
await autoLoadSmallestModel());
|
||||
} catch (error) {
|
||||
clearSelectedImageEditReference();
|
||||
throw error;
|
||||
|
|
@ -1772,8 +1897,23 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
// <think>...</think> for parseAssistantContent. Lives outside the
|
||||
// SSE loop because the close tag fires when content arrives.
|
||||
let reasoningContentOpen = false;
|
||||
type ToolCallProvenance = {
|
||||
source?: string;
|
||||
healed?: boolean;
|
||||
forced?: boolean;
|
||||
provisional?: boolean;
|
||||
duplicate?: boolean;
|
||||
reason?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type PositionedToolCallPart = ToolCallMessagePart & {
|
||||
textCursor?: number;
|
||||
_delta_index?: number;
|
||||
extra_content?: unknown;
|
||||
provenance?: ToolCallProvenance;
|
||||
};
|
||||
// Tool call parts, cumulative; result lands on tool_end.
|
||||
const toolCallParts: ToolCallMessagePart[] = [];
|
||||
const toolCallParts: PositionedToolCallPart[] = [];
|
||||
// Latest Gemini text-part thoughtSignature; pinned onto the final
|
||||
// text MessagePart so next-turn replay carries it.
|
||||
let latestTextThoughtSignature: string | undefined;
|
||||
|
|
@ -1792,16 +1932,81 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
return parts;
|
||||
};
|
||||
const orderAssistantContent = (
|
||||
textParts: ReturnType<typeof parseAssistantContent>,
|
||||
) => {
|
||||
const imageToolParts = toolCallParts.filter(
|
||||
(part) => part.toolName === "image_generation",
|
||||
);
|
||||
const otherToolParts = toolCallParts.filter(
|
||||
(part) => part.toolName !== "image_generation",
|
||||
);
|
||||
return [...otherToolParts, ...textParts, ...imageToolParts];
|
||||
const buildAssistantContent = (rawText: string) => {
|
||||
const positionedTools = toolCallParts
|
||||
.map((part, index) => {
|
||||
const cursor = (part as PositionedToolCallPart).textCursor;
|
||||
return {
|
||||
part,
|
||||
index,
|
||||
cursor:
|
||||
typeof cursor === "number" && Number.isFinite(cursor)
|
||||
? Math.min(Math.max(cursor, 0), rawText.length)
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.cursor - b.cursor || a.index - b.index);
|
||||
|
||||
const assembled: Array<
|
||||
ReturnType<typeof parseAssistantContent>[number] | ToolCallMessagePart
|
||||
> = [];
|
||||
let textCursor = 0;
|
||||
let toolIndex = 0;
|
||||
|
||||
const appendTextThrough = (nextCursor: number) => {
|
||||
if (nextCursor <= textCursor) return;
|
||||
assembled.push(
|
||||
...parseAssistantContent(rawText.slice(textCursor, nextCursor)),
|
||||
);
|
||||
textCursor = nextCursor;
|
||||
};
|
||||
|
||||
while (toolIndex < positionedTools.length) {
|
||||
const cursor = positionedTools[toolIndex].cursor;
|
||||
appendTextThrough(cursor);
|
||||
while (
|
||||
toolIndex < positionedTools.length &&
|
||||
positionedTools[toolIndex].cursor === cursor
|
||||
) {
|
||||
assembled.push(positionedTools[toolIndex].part);
|
||||
toolIndex += 1;
|
||||
}
|
||||
}
|
||||
appendTextThrough(rawText.length);
|
||||
|
||||
return pinTextThoughtSignature(assembled);
|
||||
};
|
||||
const parseToolProvenance = (
|
||||
value: unknown,
|
||||
): ToolCallProvenance | undefined => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...(value as Record<string, unknown>) } as ToolCallProvenance;
|
||||
};
|
||||
const mergeToolProvenance = (
|
||||
existing: ToolCallProvenance | undefined,
|
||||
incoming: ToolCallProvenance | undefined,
|
||||
): ToolCallProvenance | undefined => {
|
||||
if (!incoming) return existing;
|
||||
if (!existing) return incoming;
|
||||
const merged: ToolCallProvenance = { ...existing, ...incoming };
|
||||
for (const key of [
|
||||
"healed",
|
||||
"forced",
|
||||
"provisional",
|
||||
"duplicate",
|
||||
] as const) {
|
||||
if (existing[key] === true || incoming[key] === true) {
|
||||
merged[key] = true;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
const closeReasoningContent = () => {
|
||||
if (!reasoningContentOpen) return;
|
||||
cumulativeText += "</think>";
|
||||
reasoningContentOpen = false;
|
||||
};
|
||||
// Anthropic document_citations payload, converted to Sources-panel
|
||||
// parts at end-of-stream so inline [N] markers have matching entries.
|
||||
|
|
@ -2189,7 +2394,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
try {
|
||||
let requestPayload: OpenAIChatCompletionsRequest;
|
||||
try {
|
||||
requestPayload = await buildRequestPayload(retriedWithRefreshedKey);
|
||||
requestPayload = await buildRequestPayload(
|
||||
retriedWithRefreshedKey,
|
||||
);
|
||||
} catch (error) {
|
||||
clearSelectedImageEditReference();
|
||||
throw error;
|
||||
|
|
@ -2270,6 +2477,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
anthropicRefusalSeen = true;
|
||||
continue;
|
||||
}
|
||||
closeReasoningContent();
|
||||
const toolProvenance = parseToolProvenance(
|
||||
toolEvent.provenance,
|
||||
);
|
||||
if (toolEvent.type === "tool_start") {
|
||||
const id =
|
||||
(toolEvent.tool_call_id as string) ||
|
||||
|
|
@ -2280,11 +2491,18 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
(p) => p.toolCallId === id,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
const existing = toolCallParts[
|
||||
idx
|
||||
] as PositionedToolCallPart;
|
||||
toolCallParts[idx] = {
|
||||
...toolCallParts[idx],
|
||||
...existing,
|
||||
toolName: toolEvent.tool_name as string,
|
||||
argsText: JSON.stringify(toolArgs),
|
||||
args: toolArgs,
|
||||
provenance: mergeToolProvenance(
|
||||
existing.provenance,
|
||||
toolProvenance,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
toolCallParts.push({
|
||||
|
|
@ -2293,7 +2511,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
toolName: toolEvent.tool_name as string,
|
||||
argsText: JSON.stringify(toolArgs),
|
||||
args: toolArgs,
|
||||
});
|
||||
textCursor: cumulativeText.length,
|
||||
...(toolProvenance ? { provenance: toolProvenance } : {}),
|
||||
} as PositionedToolCallPart);
|
||||
}
|
||||
} else if (toolEvent.type === "tool_end") {
|
||||
const id =
|
||||
|
|
@ -2437,21 +2657,23 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
native_part: { parts: mergedParts },
|
||||
};
|
||||
}
|
||||
const existing = toolCallParts[
|
||||
idx
|
||||
] as PositionedToolCallPart;
|
||||
toolCallParts[idx] = {
|
||||
...toolCallParts[idx],
|
||||
...existing,
|
||||
args: mergedArgs,
|
||||
argsText: JSON.stringify(mergedArgs ?? {}),
|
||||
result: parsedResult,
|
||||
provenance: mergeToolProvenance(
|
||||
existing.provenance,
|
||||
toolProvenance,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Cumulative yield; orderAssistantContent puts search/code
|
||||
// before text and generated images after.
|
||||
const textParts = pinTextThoughtSignature(
|
||||
parseAssistantContent(cumulativeText),
|
||||
);
|
||||
yield {
|
||||
content: orderAssistantContent(textParts),
|
||||
content: buildAssistantContent(cumulativeText),
|
||||
metadata: {
|
||||
timing: buildTiming(
|
||||
streamStartTime,
|
||||
|
|
@ -2504,10 +2726,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
| { extra_content?: unknown }
|
||||
| undefined
|
||||
)?.extra_content;
|
||||
if (
|
||||
deltaExtraContent &&
|
||||
typeof deltaExtraContent === "object"
|
||||
) {
|
||||
if (deltaExtraContent && typeof deltaExtraContent === "object") {
|
||||
const eGoogle = (deltaExtraContent as Record<string, unknown>)
|
||||
.google;
|
||||
if (eGoogle && typeof eGoogle === "object") {
|
||||
|
|
@ -2556,6 +2775,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
Array.isArray(rawDeltaToolCalls) &&
|
||||
rawDeltaToolCalls.length > 0
|
||||
) {
|
||||
closeReasoningContent();
|
||||
for (const tc of rawDeltaToolCalls) {
|
||||
if (!tc || typeof tc !== "object") continue;
|
||||
const call = tc as {
|
||||
|
|
@ -2575,20 +2795,16 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
: undefined;
|
||||
if (!existing && idx !== undefined) {
|
||||
existing = toolCallParts.find(
|
||||
(p) =>
|
||||
(
|
||||
p as ToolCallMessagePart & { _delta_index?: number }
|
||||
)._delta_index === idx,
|
||||
(p) => (p as PositionedToolCallPart)._delta_index === idx,
|
||||
);
|
||||
}
|
||||
const argsFragment = call.function?.arguments ?? "";
|
||||
if (existing) {
|
||||
const prevName = existing.toolName ?? "";
|
||||
const nextName = call.function?.name ?? prevName;
|
||||
const merged =
|
||||
(existing.argsText ?? "") + argsFragment;
|
||||
let parsedArgs:
|
||||
ToolCallMessagePart["args"] = existing.args ?? {};
|
||||
const merged = (existing.argsText ?? "") + argsFragment;
|
||||
let parsedArgs: ToolCallMessagePart["args"] =
|
||||
existing.args ?? {};
|
||||
if (merged) {
|
||||
try {
|
||||
parsedArgs = JSON.parse(
|
||||
|
|
@ -2600,24 +2816,18 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
} as ToolCallMessagePart["args"];
|
||||
}
|
||||
}
|
||||
const prevExtra = (
|
||||
existing as ToolCallMessagePart & {
|
||||
extra_content?: unknown;
|
||||
}
|
||||
).extra_content;
|
||||
const updated: ToolCallMessagePart & {
|
||||
_delta_index?: number;
|
||||
extra_content?: unknown;
|
||||
} = {
|
||||
...(existing as ToolCallMessagePart),
|
||||
const prevExtra = (existing as PositionedToolCallPart)
|
||||
.extra_content;
|
||||
const updated: PositionedToolCallPart = {
|
||||
...(existing as PositionedToolCallPart),
|
||||
toolName: nextName,
|
||||
argsText: merged,
|
||||
args: parsedArgs,
|
||||
...(call.extra_content !== undefined
|
||||
? { extra_content: call.extra_content }
|
||||
: prevExtra !== undefined
|
||||
? { extra_content: prevExtra }
|
||||
: {}),
|
||||
? { extra_content: prevExtra }
|
||||
: {}),
|
||||
...(idx !== undefined ? { _delta_index: idx } : {}),
|
||||
};
|
||||
const replaceIdx = toolCallParts.indexOf(existing);
|
||||
|
|
@ -2626,8 +2836,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
} else {
|
||||
const callId =
|
||||
stableId ||
|
||||
`tool_call_${idx ?? toolCallParts.length}`;
|
||||
stableId || `tool_call_${idx ?? toolCallParts.length}`;
|
||||
const argsText = argsFragment;
|
||||
let parsedArgs: ToolCallMessagePart["args"] = {};
|
||||
if (argsText) {
|
||||
|
|
@ -2641,15 +2850,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
} as ToolCallMessagePart["args"];
|
||||
}
|
||||
}
|
||||
const fresh: ToolCallMessagePart & {
|
||||
_delta_index?: number;
|
||||
extra_content?: unknown;
|
||||
} = {
|
||||
const fresh: PositionedToolCallPart = {
|
||||
type: "tool-call" as const,
|
||||
toolCallId: callId,
|
||||
toolName: call.function?.name ?? "",
|
||||
argsText,
|
||||
args: parsedArgs,
|
||||
textCursor: cumulativeText.length,
|
||||
...(call.extra_content !== undefined
|
||||
? { extra_content: call.extra_content }
|
||||
: {}),
|
||||
|
|
@ -2659,12 +2866,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
}
|
||||
yield {
|
||||
content: [
|
||||
...toolCallParts,
|
||||
...pinTextThoughtSignature(
|
||||
parseAssistantContent(cumulativeText),
|
||||
),
|
||||
],
|
||||
content: buildAssistantContent(cumulativeText),
|
||||
metadata: {
|
||||
timing: buildTiming(
|
||||
streamStartTime,
|
||||
|
|
@ -2695,10 +2897,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
}
|
||||
if (delta) {
|
||||
if (reasoningContentOpen) {
|
||||
cumulativeText += "</think>";
|
||||
reasoningContentOpen = false;
|
||||
}
|
||||
closeReasoningContent();
|
||||
cumulativeText += delta;
|
||||
}
|
||||
// Strip a trailing ${...} template-literal artifact from
|
||||
|
|
@ -2709,12 +2908,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
"",
|
||||
);
|
||||
}
|
||||
const parts = pinTextThoughtSignature(
|
||||
parseAssistantContent(cumulativeText),
|
||||
);
|
||||
const textParts = parseAssistantContent(cumulativeText);
|
||||
|
||||
if (
|
||||
parts.some((part) => part.type === "reasoning") &&
|
||||
textParts.some((part) => part.type === "reasoning") &&
|
||||
!reasoningStartAt
|
||||
) {
|
||||
reasoningStartAt = Date.now();
|
||||
|
|
@ -2729,9 +2926,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
);
|
||||
}
|
||||
|
||||
if (parts.length > 0 || toolCallParts.length > 0) {
|
||||
if (textParts.length > 0 || toolCallParts.length > 0) {
|
||||
yield {
|
||||
content: orderAssistantContent(parts),
|
||||
content: buildAssistantContent(cumulativeText),
|
||||
metadata: {
|
||||
timing: buildTiming(
|
||||
streamStartTime,
|
||||
|
|
@ -2756,13 +2953,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
throw streamError;
|
||||
}
|
||||
}
|
||||
// If the stream ended inside a delta.reasoning_content block
|
||||
// (Kimi / DeepSeek), close the open <think> tag so the reasoning
|
||||
// panel parses cleanly.
|
||||
if (reasoningContentOpen) {
|
||||
cumulativeText += "</think>";
|
||||
reasoningContentOpen = false;
|
||||
}
|
||||
// If the stream ended while we were still inside a
|
||||
// delta.reasoning_content block (Kimi / DeepSeek path), close
|
||||
// the open <think> tag so the reasoning panel parses cleanly.
|
||||
closeReasoningContent();
|
||||
settleFirstTokenOk();
|
||||
|
||||
// Extract source parts from completed web_search and web_fetch
|
||||
|
|
@ -2826,9 +3020,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
|
||||
yield {
|
||||
content: [
|
||||
...orderAssistantContent(
|
||||
pinTextThoughtSignature(parseAssistantContent(cumulativeText)),
|
||||
),
|
||||
...buildAssistantContent(cumulativeText),
|
||||
...sourceParts,
|
||||
...documentCitationParts,
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue