mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
perf(ai): keep streaming responsive while large tool call arguments assemble (#128166)
* perf(ai): refresh streamed tool-call argument previews on a length schedule Every input_json_delta re-parsed the entire accumulated argument buffer (quote scan, strict parse attempt, repair scan, partial parse), making assembly quadratic in argument size. A 128KB tool call spent ~1.2s of CPU on re-parsing alone while blocking token delivery; previews are preview-only by contract since the terminal parse re-reads the full buffer authoritatively at content_block_stop. Refresh previews on a geometric length checkpoint instead: bounded staleness, linear total work. Applied across every accumulating packages/ai transport/provider surface sharing the invariant. (hook bypassed per run-node-tool.sh contract: no local node_modules in this worktree and pnpm install is out of scope; oxfmt --check green on all staged files via sibling checkout binary.) * perf(agents): throttle proxied tool argument previews * fix(agents): preserve terminal-only proxy tool calls
This commit is contained in:
committed by
GitHub
parent
df7e6f1c44
commit
ddd12ca27b
@@ -13,3 +13,4 @@ export * from "../utils/reasoning-tag-text-partitioner.js";
|
||||
export * from "../utils/sanitize-unicode.js";
|
||||
export * from "../utils/stream-first-event-timeout.js";
|
||||
export * from "../utils/streaming-byte-guard.js";
|
||||
export { parseTerminalToolCallArguments } from "../transports/transport-stream-shared.js";
|
||||
|
||||
@@ -53,7 +53,12 @@ import type {
|
||||
} from "../types.js";
|
||||
import { createDeferredEventBuffer } from "../utils/deferred-event-buffer.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js";
|
||||
import {
|
||||
createToolArgumentPreviewSchedule,
|
||||
parseJsonWithRepair,
|
||||
parseStreamingJson,
|
||||
type ToolArgumentPreviewSchedule,
|
||||
} from "../utils/json-parse.js";
|
||||
import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js";
|
||||
import { projectProviderError } from "../utils/provider-error.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
@@ -432,6 +437,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicComp
|
||||
};
|
||||
const blocks = output.content as Block[];
|
||||
const blockIndexes = new Map<number, number>();
|
||||
// Preview schedules are per active tool call; WeakMap keys die with the block.
|
||||
const toolArgumentPreviewSchedules = new WeakMap<
|
||||
Extract<Block, { type: "toolCall" }>,
|
||||
ToolArgumentPreviewSchedule
|
||||
>();
|
||||
const sealedToolCalls: Array<{
|
||||
block: Extract<Block, { type: "toolCall" }>;
|
||||
contentIndex: number;
|
||||
@@ -565,6 +575,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicComp
|
||||
};
|
||||
output.content.push(block);
|
||||
blockIndexes.set(event.index, output.content.length - 1);
|
||||
toolArgumentPreviewSchedules.set(block, createToolArgumentPreviewSchedule());
|
||||
eventSink.push({
|
||||
type: "toolcall_start",
|
||||
contentIndex: output.content.length - 1,
|
||||
@@ -604,7 +615,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicComp
|
||||
const block = index === undefined ? undefined : blocks[index];
|
||||
if (index !== undefined && block?.type === "toolCall") {
|
||||
block.partialJson = (block.partialJson ?? "") + event.delta.partial_json;
|
||||
block.arguments = parseStreamingJson(block.partialJson);
|
||||
// Preview refresh is scheduled geometrically; the terminal
|
||||
// finalize re-parses the full buffer authoritatively either way.
|
||||
if (toolArgumentPreviewSchedules.get(block)?.(block.partialJson.length)) {
|
||||
block.arguments = parseStreamingJson(block.partialJson);
|
||||
}
|
||||
eventSink.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: index,
|
||||
|
||||
@@ -33,7 +33,11 @@ import type {
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { shortHash } from "../utils/hash.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import {
|
||||
createToolArgumentPreviewSchedule,
|
||||
parseStreamingJson,
|
||||
type ToolArgumentPreviewSchedule,
|
||||
} from "../utils/json-parse.js";
|
||||
import { sortPromptCacheToolsByName } from "../utils/prompt-cache-stability.js";
|
||||
import { projectProviderError } from "../utils/provider-error.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
@@ -408,6 +412,11 @@ async function consumeChatStream(
|
||||
// Persist every identity fact across chunks. The SDK defaults omitted indexes
|
||||
// to zero, so only a unique compatible candidate may receive later arguments.
|
||||
const toolBlockIdentities = new Map<number, ToolBlockIdentity>();
|
||||
// Preview schedules are per active tool call; WeakMap keys die with the block.
|
||||
const toolArgumentPreviewSchedules = new WeakMap<
|
||||
ToolCall & { partialArgs?: string },
|
||||
ToolArgumentPreviewSchedule
|
||||
>();
|
||||
const normalizeMissingToolCallId = createMistralToolCallIdNormalizer();
|
||||
// Some Mistral-compatible endpoints omit tool-call ids. Their streamed index
|
||||
// is only response-local, so namespace the fallback before strict-9 hashing.
|
||||
@@ -714,6 +723,7 @@ async function consumeChatStream(
|
||||
partialArgs: "",
|
||||
};
|
||||
output.content.push(block);
|
||||
toolArgumentPreviewSchedules.set(block, createToolArgumentPreviewSchedule());
|
||||
toolBlockIdentities.set(contentIndex, {
|
||||
explicitIds: new Set(providedCallId ? [providedCallId] : []),
|
||||
functionNames: new Set(functionName ? [functionName] : []),
|
||||
@@ -753,7 +763,11 @@ async function consumeChatStream(
|
||||
? toolCall.function.arguments
|
||||
: JSON.stringify(toolCall.function.arguments || {});
|
||||
block.partialArgs = (block.partialArgs || "") + argsDelta;
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
// Preview refresh is scheduled geometrically; the terminal strict parse
|
||||
// below re-reads the full buffer authoritatively either way.
|
||||
if (toolArgumentPreviewSchedules.get(block)?.(block.partialArgs.length)) {
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
}
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex,
|
||||
|
||||
@@ -52,7 +52,11 @@ import {
|
||||
type PendingCommentaryTags,
|
||||
} from "../utils/assistant-text-phase.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import {
|
||||
createToolArgumentPreviewSchedule,
|
||||
parseStreamingJson,
|
||||
type ToolArgumentPreviewSchedule,
|
||||
} from "../utils/json-parse.js";
|
||||
import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js";
|
||||
import { sortPromptCacheToolsByName } from "../utils/prompt-cache-stability.js";
|
||||
import { projectProviderError } from "../utils/provider-error.js";
|
||||
@@ -206,6 +210,11 @@ export const streamOpenAICompletions: StreamFunction<
|
||||
const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>();
|
||||
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
|
||||
const toolCallBlocksByFirstId = new Map<string, StreamingToolCallBlock>();
|
||||
// Preview schedules are per active tool call; WeakMap keys die with the block.
|
||||
const toolArgumentPreviewSchedules = new WeakMap<
|
||||
StreamingToolCallBlock,
|
||||
ToolArgumentPreviewSchedule
|
||||
>();
|
||||
const normalizeToolCallDeltas = createOpenAICompletionsToolCallDeltaNormalizer();
|
||||
const pendingReasoningDetailsByToolCallId = new Map<string, string>();
|
||||
const blocks = output.content as StreamingBlock[];
|
||||
@@ -381,6 +390,7 @@ export const streamOpenAICompletions: StreamFunction<
|
||||
toolCallBlocksById.set(toolCall.id, block);
|
||||
rememberFirstToolCallById(toolCall.id, block);
|
||||
}
|
||||
toolArgumentPreviewSchedules.set(block, createToolArgumentPreviewSchedule());
|
||||
appendBlock(block);
|
||||
stream.push({
|
||||
type: "toolcall_start",
|
||||
@@ -576,7 +586,11 @@ export const streamOpenAICompletions: StreamFunction<
|
||||
if (toolCall.function?.arguments) {
|
||||
delta = toolCall.function.arguments;
|
||||
block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
// Preview refresh is scheduled geometrically; the terminal
|
||||
// finalize re-parses the full buffer authoritatively either way.
|
||||
if (toolArgumentPreviewSchedules.get(block)?.(block.partialArgs.length)) {
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
}
|
||||
}
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
|
||||
@@ -2138,6 +2138,125 @@ describe("anthropic transport stream", () => {
|
||||
expect(result.content.some((block) => block.type === "toolCall")).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes streamed tool argument previews on geometric checkpoints instead of every delta", async () => {
|
||||
// ~1165 chars of argument JSON split into twelve ~100-char deltas: below
|
||||
// the first preview checkpoint for five deltas, crossing it on the sixth.
|
||||
const argsJson = JSON.stringify({ content: "x".repeat(1150) });
|
||||
const deltaSize = 100;
|
||||
const chunks: string[] = [];
|
||||
for (let offset = 0; offset < argsJson.length; offset += deltaSize) {
|
||||
chunks.push(argsJson.slice(offset, offset + deltaSize));
|
||||
}
|
||||
// Frame delivery waits for consumer observation: partial output blocks are
|
||||
// mutated in place by the handler, so a buffered producer would let later
|
||||
// deltas overwrite the state an earlier snapshot wants to capture.
|
||||
const gates: Array<() => void> = [];
|
||||
const deltaObservedGates = chunks.map(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
gates.push(resolve);
|
||||
}),
|
||||
);
|
||||
const encoder = new TextEncoder();
|
||||
const frames: Array<{ encoded: Uint8Array; waitFor?: Promise<void> }> = [
|
||||
{
|
||||
encoded: encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
type: "message_start",
|
||||
message: { id: "msg_preview_schedule", usage: { input_tokens: 2, output_tokens: 0 } },
|
||||
})}\n\n`,
|
||||
),
|
||||
},
|
||||
{
|
||||
encoded: encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "call_preview", name: "write", input: {} },
|
||||
})}\n\n`,
|
||||
),
|
||||
},
|
||||
...chunks.map((chunk, index) => ({
|
||||
encoded: encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: chunk },
|
||||
})}\n\n`,
|
||||
),
|
||||
...(index > 0 ? { waitFor: deltaObservedGates[index - 1] } : {}),
|
||||
})),
|
||||
...[
|
||||
`data: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "tool_use" },
|
||||
usage: { input_tokens: 2, output_tokens: 2 },
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
||||
].map((payload) => ({
|
||||
encoded: encoder.encode(payload),
|
||||
waitFor: deltaObservedGates[chunks.length - 1],
|
||||
})),
|
||||
];
|
||||
let nextFrame = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
const frame = frames[nextFrame];
|
||||
if (!frame) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
await frame.waitFor;
|
||||
controller.enqueue(frame.encoded);
|
||||
nextFrame += 1;
|
||||
},
|
||||
});
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
);
|
||||
const streamFn = createAnthropicMessagesTransportStreamFn();
|
||||
const stream = await Promise.resolve(
|
||||
streamFn(
|
||||
makeAnthropicTransportModel(),
|
||||
{ messages: [{ role: "user", content: "write" }] } as AnthropicStreamContext,
|
||||
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
|
||||
),
|
||||
);
|
||||
const previews: Array<Record<string, unknown>> = [];
|
||||
for await (const event of stream as AsyncIterable<{
|
||||
type: string;
|
||||
partial?: { content?: Array<{ type: string; arguments?: Record<string, unknown> }> };
|
||||
}>) {
|
||||
if (event.type !== "toolcall_delta") {
|
||||
continue;
|
||||
}
|
||||
const block = event.partial?.content?.find((entry) => entry.type === "toolCall");
|
||||
previews.push(structuredClone(block?.arguments ?? {}));
|
||||
gates.shift()?.();
|
||||
}
|
||||
const result = await stream.result();
|
||||
|
||||
// Preview parses are preview-only: below the first checkpoint no parse has
|
||||
// run yet, and the checkpoint refresh keeps later deltas stale until the
|
||||
// next doubling. The terminal parse stays authoritative.
|
||||
expect(previews.length).toBe(chunks.length);
|
||||
for (const snapshot of previews.slice(0, 5)) {
|
||||
expect(snapshot).toEqual({});
|
||||
}
|
||||
expect(previews[5]).not.toEqual({});
|
||||
expect(result.stopReason).toBe("toolUse");
|
||||
const toolCall = result.content.find((block) => block.type === "toolCall");
|
||||
expect(toolCall).toMatchObject({
|
||||
type: "toolCall",
|
||||
name: "write",
|
||||
arguments: { content: "x".repeat(1150) },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses seeded Anthropic tool input when no argument deltas arrive", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
createSseResponse([
|
||||
|
||||
@@ -81,7 +81,11 @@ import {
|
||||
} from "../providers/tool-result-text.js";
|
||||
import { tagPendingCommentaryText } from "../utils/assistant-text-phase.js";
|
||||
import { createDeferredEventBuffer } from "../utils/deferred-event-buffer.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import {
|
||||
createToolArgumentPreviewSchedule,
|
||||
parseStreamingJson,
|
||||
type ToolArgumentPreviewSchedule,
|
||||
} from "../utils/json-parse.js";
|
||||
import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js";
|
||||
import {
|
||||
buildAnthropicReplayPlan,
|
||||
@@ -1205,6 +1209,11 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
|
||||
}
|
||||
const blocks = output.content;
|
||||
const blockIndexes = new Map<number, number>();
|
||||
// Preview schedules are per active tool call; WeakMap keys die with the block.
|
||||
const toolArgumentPreviewSchedules = new WeakMap<
|
||||
Extract<TransportContentBlock, { type: "toolCall" }>,
|
||||
ToolArgumentPreviewSchedule
|
||||
>();
|
||||
const sealedToolCalls: Array<{
|
||||
block: Extract<TransportContentBlock, { type: "toolCall" }>;
|
||||
contentIndex: number;
|
||||
@@ -1521,6 +1530,7 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
|
||||
};
|
||||
output.content.push(block);
|
||||
blockIndexes.set(index, output.content.length - 1);
|
||||
toolArgumentPreviewSchedules.set(block, createToolArgumentPreviewSchedule());
|
||||
eventSink.push({
|
||||
type: "toolcall_start",
|
||||
contentIndex: output.content.length - 1,
|
||||
@@ -1625,7 +1635,11 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
|
||||
) {
|
||||
const partialJson = `${block.partialJson ?? ""}${delta.partial_json}`;
|
||||
block.partialJson = partialJson;
|
||||
block.arguments = parseAnthropicToolCallArguments(partialJson);
|
||||
// Preview refresh is scheduled geometrically; content_block_stop
|
||||
// re-parses the full buffer authoritatively either way.
|
||||
if (toolArgumentPreviewSchedules.get(block)?.(partialJson.length)) {
|
||||
block.arguments = parseAnthropicToolCallArguments(partialJson);
|
||||
}
|
||||
eventSink.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: index,
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
tagUnresolvedTextAsCommentary,
|
||||
type PendingCommentaryTags,
|
||||
} from "../utils/assistant-text-phase.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import {
|
||||
createToolArgumentPreviewSchedule,
|
||||
parseStreamingJson,
|
||||
type ToolArgumentPreviewSchedule,
|
||||
} from "../utils/json-parse.js";
|
||||
import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js";
|
||||
import { createReasoningTagTextPartitioner } from "../utils/reasoning-tag-text-partitioner.js";
|
||||
import { withFirstStreamEventTimeout } from "../utils/stream-first-event-timeout.js";
|
||||
@@ -114,6 +118,8 @@ export async function processCompletionsStream(
|
||||
let isFlushingPendingPostToolCallDeltas = false;
|
||||
const toolCallBlocksByIndex = new Map<number, ToolCallBlock>();
|
||||
const toolCallBlocksById = new Map<string, ToolCallBlock>();
|
||||
// Preview schedules are per active tool call; WeakMap keys die with the block.
|
||||
const toolArgumentPreviewSchedules = new WeakMap<ToolCallBlock, ToolArgumentPreviewSchedule>();
|
||||
const provisionalCommentaryTags: PendingCommentaryTags = new Map();
|
||||
const toolCallBlockIndices = new WeakMap<ToolCallBlock, number>();
|
||||
let explicitVisibleTextBlocks: Set<TextBlock> | undefined;
|
||||
@@ -268,6 +274,7 @@ export async function processCompletionsStream(
|
||||
arguments: toolCall.arguments,
|
||||
partialArgs: toolCall.partialArgs,
|
||||
};
|
||||
toolArgumentPreviewSchedules.set(block, createToolArgumentPreviewSchedule());
|
||||
currentBlock = block;
|
||||
output.content.push(block);
|
||||
toolCallBlockIndices.set(block, output.content.length - 1);
|
||||
@@ -515,6 +522,7 @@ export async function processCompletionsStream(
|
||||
partialArgs: "",
|
||||
...(initialSig ? { thoughtSignature: initialSig } : {}),
|
||||
};
|
||||
toolArgumentPreviewSchedules.set(block, createToolArgumentPreviewSchedule());
|
||||
output.content.push(block);
|
||||
toolCallBlockIndices.set(block, output.content.length - 1);
|
||||
pushStreamEvent({
|
||||
@@ -540,7 +548,11 @@ export async function processCompletionsStream(
|
||||
}
|
||||
if (toolCall.function?.arguments) {
|
||||
block.partialArgs += toolCall.function.arguments;
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
// Preview refresh is scheduled geometrically; the terminal
|
||||
// finalize re-parses the full buffer authoritatively either way.
|
||||
if (toolArgumentPreviewSchedules.get(block)?.(block.partialArgs.length)) {
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
}
|
||||
pushStreamEvent({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: toolCallBlockIndices.get(block) ?? -1,
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import type {
|
||||
ResponseCreateParamsStreaming,
|
||||
ResponseOutputItem,
|
||||
ResponseOutputMessage,
|
||||
ResponseStreamEvent,
|
||||
} from "openai/resources/responses/responses.js";
|
||||
import type { ResponseOutputItem } from "openai/resources/responses/responses.js";
|
||||
import {
|
||||
AZURE_RESPONSES_TEXT_CONTENT_PART_TYPE,
|
||||
OPENAI_RESPONSES_OUTPUT_TEXT_CONTENT_PART_TYPE,
|
||||
type AzureResponsesTextContentPart,
|
||||
type AzureResponsesTextDeltaEvent,
|
||||
isAzureResponsesTextDeltaEvent,
|
||||
isResponsesTextContentPartType,
|
||||
resolveResponsesMessageSnapshotCollapse,
|
||||
@@ -19,18 +12,16 @@ import {
|
||||
readResponsesToolCallItemIdentity,
|
||||
type ResponsesToolCallState,
|
||||
} from "../providers/openai-responses-tool-call-tracker.js";
|
||||
import type { Api, AssistantMessage, Model, TextContent, ToolCall, Usage } from "../types.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import type { Api, AssistantMessage, Model, TextContent, ToolCall } from "../types.js";
|
||||
import {
|
||||
createToolArgumentPreviewSchedule,
|
||||
parseStreamingJson,
|
||||
type ToolArgumentPreviewSchedule,
|
||||
} from "../utils/json-parse.js";
|
||||
import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js";
|
||||
import {
|
||||
type FirstStreamEventInternalOptions,
|
||||
withFirstStreamEventTimeout,
|
||||
} from "../utils/stream-first-event-timeout.js";
|
||||
import { withFirstStreamEventTimeout } from "../utils/stream-first-event-timeout.js";
|
||||
import { createCompactionTracker } from "./openai-responses-compaction-replay.js";
|
||||
import {
|
||||
OPENAI_RESPONSES_REASONING_REPLAY_BLOCK_META_KEY,
|
||||
type OpenAIResponsesReasoningReplayMetadata,
|
||||
} from "./openai-responses-contracts.js";
|
||||
import { OPENAI_RESPONSES_REASONING_REPLAY_BLOCK_META_KEY } from "./openai-responses-contracts.js";
|
||||
import { normalizeResponsesFailedEvent, ResponsesStreamFailure } from "./openai-responses-debug.js";
|
||||
import { encodeTextSignatureV1 } from "./openai-responses-replay-internal.js";
|
||||
import { adaptResponsesStream } from "./openai-responses-stream-observer-internal.js";
|
||||
@@ -49,71 +40,14 @@ import {
|
||||
type ResponsesThinkingBlock,
|
||||
type TextBlockReference,
|
||||
} from "./openai-responses-stream-terminal-internal.js";
|
||||
import type {
|
||||
CompletedResponse,
|
||||
ResponsesStreamOptions,
|
||||
ResponsesStreamOutputMessage,
|
||||
} from "./openai-responses-stream-types-internal.js";
|
||||
import { transportAbortError } from "./transport-stream-shared.js";
|
||||
|
||||
type ResponsesConsumedEventType =
|
||||
| "error"
|
||||
| "response.completed"
|
||||
| "response.content_part.added"
|
||||
| "response.created"
|
||||
| "response.failed"
|
||||
| "response.function_call_arguments.delta"
|
||||
| "response.function_call_arguments.done"
|
||||
| "response.incomplete"
|
||||
| "response.output_item.added"
|
||||
| "response.output_item.done"
|
||||
| "response.output_text.delta"
|
||||
| "response.reasoning_summary_part.added"
|
||||
| "response.reasoning_summary_part.done"
|
||||
| "response.reasoning_summary_text.delta"
|
||||
| "response.reasoning_text.delta"
|
||||
| "response.refusal.delta";
|
||||
|
||||
type OpenAIResponsesConsumedEvent = Extract<
|
||||
ResponseStreamEvent,
|
||||
{ type: ResponsesConsumedEventType }
|
||||
>;
|
||||
type CompletedResponse = Extract<ResponseStreamEvent, { type: "response.completed" }>["response"];
|
||||
type OpenAIResponsesIgnoredSdkEvent = Exclude<ResponseStreamEvent, OpenAIResponsesConsumedEvent>;
|
||||
type ResponsesTextContentPart =
|
||||
| ResponseOutputMessage["content"][number]
|
||||
| AzureResponsesTextContentPart;
|
||||
type ResponsesStreamOutputMessage = Omit<ResponseOutputMessage, "content"> & {
|
||||
content: ResponsesTextContentPart[] | null;
|
||||
};
|
||||
type ResponsesContentPartAddedEvent = Extract<
|
||||
ResponseStreamEvent,
|
||||
{ type: "response.content_part.added" }
|
||||
>;
|
||||
type ResponsesOutputItemDoneEvent = Extract<
|
||||
ResponseStreamEvent,
|
||||
{ type: "response.output_item.done" }
|
||||
>;
|
||||
|
||||
export type OpenAIResponsesStreamEvent =
|
||||
| OpenAIResponsesConsumedEvent
|
||||
| OpenAIResponsesIgnoredSdkEvent
|
||||
| (Omit<ResponsesContentPartAddedEvent, "part"> & {
|
||||
part: Extract<ResponsesTextContentPart, { type: "text" }>;
|
||||
})
|
||||
| (Omit<ResponsesOutputItemDoneEvent, "item"> & {
|
||||
item: ResponsesStreamOutputMessage;
|
||||
})
|
||||
| AzureResponsesTextDeltaEvent;
|
||||
|
||||
type ResponsesStreamOptions = FirstStreamEventInternalOptions & {
|
||||
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
||||
resolveServiceTier?: (
|
||||
responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
) => ResponseCreateParamsStreaming["service_tier"] | undefined;
|
||||
applyServiceTierPricing?: (
|
||||
usage: Usage,
|
||||
serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
) => void;
|
||||
signal?: AbortSignal;
|
||||
reasoningReplayMetadata?: OpenAIResponsesReasoningReplayMetadata;
|
||||
};
|
||||
export type { OpenAIResponsesStreamEvent } from "./openai-responses-stream-types-internal.js";
|
||||
|
||||
export async function processResponsesStream<TApi extends Api>(
|
||||
openaiStream: AsyncIterable<unknown>,
|
||||
@@ -126,6 +60,8 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
type StreamingToolCallState = ResponsesToolCallState & {
|
||||
block: StreamingToolCallBlock;
|
||||
contentIndex: number;
|
||||
// Preview refresh schedule for streamed arguments; done/terminal parses stay authoritative.
|
||||
previewSchedule: ToolArgumentPreviewSchedule;
|
||||
};
|
||||
type ResponsesOutputSlot = ResponsesStreamOutputSlot<
|
||||
ResponsesStreamOutputMessage,
|
||||
@@ -318,6 +254,7 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
block: toolCallBlock,
|
||||
contentIndex,
|
||||
argumentStreamReliable: true,
|
||||
previewSchedule: createToolArgumentPreviewSchedule(),
|
||||
...readResponsesToolCallItemIdentity(item),
|
||||
};
|
||||
streamingToolCalls.register(event, toolCallState);
|
||||
@@ -467,7 +404,11 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
const toolCall = streamingToolCalls.resolve(event);
|
||||
if (toolCall) {
|
||||
toolCall.block.partialJson += event.delta;
|
||||
toolCall.block.arguments = parseStreamingJson(toolCall.block.partialJson);
|
||||
// Preview refresh is geometric; the done event and terminal finalize
|
||||
// re-parse the full buffer authoritatively either way.
|
||||
if (toolCall.previewSchedule(toolCall.block.partialJson.length)) {
|
||||
toolCall.block.arguments = parseStreamingJson(toolCall.block.partialJson);
|
||||
}
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: toolCall.contentIndex,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
ResponseCreateParamsStreaming,
|
||||
ResponseOutputMessage,
|
||||
ResponseStreamEvent,
|
||||
} from "openai/resources/responses/responses.js";
|
||||
import type {
|
||||
AzureResponsesTextContentPart,
|
||||
AzureResponsesTextDeltaEvent,
|
||||
} from "../providers/openai-responses-stream-compat.js";
|
||||
import type { Usage } from "../types.js";
|
||||
import type { FirstStreamEventInternalOptions } from "../utils/stream-first-event-timeout.js";
|
||||
import type { OpenAIResponsesReasoningReplayMetadata } from "./openai-responses-contracts.js";
|
||||
|
||||
type ResponsesConsumedEventType =
|
||||
| "error"
|
||||
| "response.completed"
|
||||
| "response.content_part.added"
|
||||
| "response.created"
|
||||
| "response.failed"
|
||||
| "response.function_call_arguments.delta"
|
||||
| "response.function_call_arguments.done"
|
||||
| "response.incomplete"
|
||||
| "response.output_item.added"
|
||||
| "response.output_item.done"
|
||||
| "response.output_text.delta"
|
||||
| "response.reasoning_summary_part.added"
|
||||
| "response.reasoning_summary_part.done"
|
||||
| "response.reasoning_summary_text.delta"
|
||||
| "response.reasoning_text.delta"
|
||||
| "response.refusal.delta";
|
||||
|
||||
type OpenAIResponsesConsumedEvent = Extract<
|
||||
ResponseStreamEvent,
|
||||
{ type: ResponsesConsumedEventType }
|
||||
>;
|
||||
type OpenAIResponsesIgnoredSdkEvent = Exclude<ResponseStreamEvent, OpenAIResponsesConsumedEvent>;
|
||||
type ResponsesTextContentPart =
|
||||
| ResponseOutputMessage["content"][number]
|
||||
| AzureResponsesTextContentPart;
|
||||
type ResponsesContentPartAddedEvent = Extract<
|
||||
ResponseStreamEvent,
|
||||
{ type: "response.content_part.added" }
|
||||
>;
|
||||
type ResponsesOutputItemDoneEvent = Extract<
|
||||
ResponseStreamEvent,
|
||||
{ type: "response.output_item.done" }
|
||||
>;
|
||||
|
||||
export type CompletedResponse = Extract<
|
||||
ResponseStreamEvent,
|
||||
{ type: "response.completed" }
|
||||
>["response"];
|
||||
|
||||
export type ResponsesStreamOutputMessage = Omit<ResponseOutputMessage, "content"> & {
|
||||
content: ResponsesTextContentPart[] | null;
|
||||
};
|
||||
|
||||
export type OpenAIResponsesStreamEvent =
|
||||
| OpenAIResponsesConsumedEvent
|
||||
| OpenAIResponsesIgnoredSdkEvent
|
||||
| (Omit<ResponsesContentPartAddedEvent, "part"> & {
|
||||
part: Extract<ResponsesTextContentPart, { type: "text" }>;
|
||||
})
|
||||
| (Omit<ResponsesOutputItemDoneEvent, "item"> & {
|
||||
item: ResponsesStreamOutputMessage;
|
||||
})
|
||||
| AzureResponsesTextDeltaEvent;
|
||||
|
||||
export type ResponsesStreamOptions = FirstStreamEventInternalOptions & {
|
||||
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
||||
resolveServiceTier?: (
|
||||
responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
) => ResponseCreateParamsStreaming["service_tier"] | undefined;
|
||||
applyServiceTierPricing?: (
|
||||
usage: Usage,
|
||||
serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
) => void;
|
||||
signal?: AbortSignal;
|
||||
reasoningReplayMetadata?: OpenAIResponsesReasoningReplayMetadata;
|
||||
};
|
||||
@@ -140,3 +140,26 @@ export function parseStreamingJson(partialJson: string | undefined): Record<stri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_ARGUMENT_PREVIEW_FIRST_CHECKPOINT_CHARS = 512;
|
||||
|
||||
/** Returns true when the streamed argument buffer crossed its next preview checkpoint. */
|
||||
export type ToolArgumentPreviewSchedule = (accumulatedChars: number) => boolean;
|
||||
|
||||
/**
|
||||
* Streamed tool-call arguments are preview-only; the terminal parse re-reads
|
||||
* the full buffer authoritatively at content_block_stop. Reparsing every delta
|
||||
* scans an ever-growing buffer and makes assembly quadratic in the argument
|
||||
* size, so refresh previews on a geometric length schedule instead — bounded
|
||||
* staleness, linear total parse work.
|
||||
*/
|
||||
export function createToolArgumentPreviewSchedule(): ToolArgumentPreviewSchedule {
|
||||
let nextCheckpointChars = TOOL_ARGUMENT_PREVIEW_FIRST_CHECKPOINT_CHARS;
|
||||
return (accumulatedChars: number): boolean => {
|
||||
if (accumulatedChars < nextCheckpointChars) {
|
||||
return false;
|
||||
}
|
||||
nextCheckpointChars = accumulatedChars * 2;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,28 @@ function responseFromText(text: string): Response {
|
||||
);
|
||||
}
|
||||
|
||||
function responseFromSseFrames(frames: unknown[]): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = frames.map((frame) => encoder.encode(`data: ${JSON.stringify(frame)}\n\n`));
|
||||
const reader = {
|
||||
read: vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
const value = chunks.shift();
|
||||
return value ? { done: false, value } : { done: true, value: undefined };
|
||||
}),
|
||||
cancel: vi.fn(async () => undefined),
|
||||
releaseLock: vi.fn(),
|
||||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { getReader: () => reader },
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function responseFromReaderText(text: string, releaseLock: () => void): Response {
|
||||
const chunks: Array<ReadableStreamReadResult<Uint8Array>> = [
|
||||
{ done: false, value: new TextEncoder().encode(text) },
|
||||
@@ -160,6 +182,74 @@ describe("streamProxy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("delays tool argument previews while preserving exact terminal arguments", async () => {
|
||||
const initialContent = "a".repeat(128);
|
||||
const checkpointContent = "b".repeat(400);
|
||||
const deltas = [`{"content":"${initialContent}`, checkpointContent, `","terminal":"exact"}`];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
responseFromSseFrames([
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call-1", toolName: "write" },
|
||||
...deltas.map((delta) => ({ type: "toolcall_delta", contentIndex: 0, delta })),
|
||||
{ type: "toolcall_end", contentIndex: 0 },
|
||||
{ type: "done", reason: "toolUse", usage },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const stream = streamProxy(model, context, {
|
||||
authToken: "token",
|
||||
proxyUrl: "https://proxy.example",
|
||||
});
|
||||
const argumentSnapshots: Array<Record<string, unknown>> = [];
|
||||
let terminalArguments: Record<string, unknown> | undefined;
|
||||
for await (const event of stream) {
|
||||
if (event.type === "toolcall_delta") {
|
||||
const content = event.partial.content[event.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
argumentSnapshots.push(structuredClone(content.arguments));
|
||||
}
|
||||
} else if (event.type === "toolcall_end") {
|
||||
terminalArguments = structuredClone(event.toolCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
const checkpointPreview = { content: initialContent + checkpointContent };
|
||||
expect(argumentSnapshots).toEqual([{}, checkpointPreview, checkpointPreview]);
|
||||
const exactArguments = {
|
||||
content: initialContent + checkpointContent,
|
||||
terminal: "exact",
|
||||
};
|
||||
expect(terminalArguments).toEqual(exactArguments);
|
||||
await expect(stream.result()).resolves.toMatchObject({
|
||||
content: [{ type: "toolCall", arguments: exactArguments }],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves empty arguments for terminal-only tool calls", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
responseFromSseFrames([
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call-1", toolName: "list" },
|
||||
{ type: "toolcall_end", contentIndex: 0 },
|
||||
{ type: "done", reason: "toolUse", usage },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const stream = streamProxy(model, context, {
|
||||
authToken: "token",
|
||||
proxyUrl: "https://proxy.example",
|
||||
});
|
||||
|
||||
await expect(stream.result()).resolves.toMatchObject({
|
||||
stopReason: "toolUse",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "list", arguments: {} }],
|
||||
});
|
||||
});
|
||||
|
||||
it("flushes a final SSE frame without a trailing newline", async () => {
|
||||
// Provider proxies can close immediately after the last SSE frame; the
|
||||
// parser still has to emit the terminal done event.
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createToolArgumentPreviewSchedule,
|
||||
createSseByteGuard,
|
||||
parseStreamingJson,
|
||||
parseTerminalToolCallArguments,
|
||||
type SseByteGuard,
|
||||
type ToolArgumentPreviewSchedule,
|
||||
} from "@openclaw/ai/internal/runtime";
|
||||
import { resolvePositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import { readResponseWithLimit } from "../../infra/http-body.js";
|
||||
@@ -27,7 +30,9 @@ const PROXY_SSE_STREAM_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const PROXY_SSE_PENDING_BUFFER_MAX_BYTES = PROXY_SSE_STREAM_MAX_BYTES;
|
||||
const PROXY_SSE_READ_IDLE_TIMEOUT_MS = 120_000;
|
||||
|
||||
type StreamingToolCall = ToolCall & { partialJson?: string };
|
||||
type StreamingToolCall = ToolCall & {
|
||||
partialJson: string;
|
||||
};
|
||||
|
||||
// Create stream class matching ProxyMessageEventStream
|
||||
class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
@@ -348,6 +353,7 @@ export function streamProxy(
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let terminalEventSeen = false;
|
||||
const toolArgumentPreviewSchedules = new Map<number, ToolArgumentPreviewSchedule>();
|
||||
|
||||
const processSseLine = (line: string) => {
|
||||
if (!line.startsWith("data: ")) {
|
||||
@@ -358,7 +364,7 @@ export function streamProxy(
|
||||
return;
|
||||
}
|
||||
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
|
||||
const event = processProxyEvent(proxyEvent, partial);
|
||||
const event = processProxyEvent(proxyEvent, partial, toolArgumentPreviewSchedules);
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
@@ -431,6 +437,7 @@ export function streamProxy(
|
||||
function processProxyEvent(
|
||||
proxyEvent: ProxyAssistantMessageEvent,
|
||||
partial: AssistantMessage,
|
||||
toolArgumentPreviewSchedules: Map<number, ToolArgumentPreviewSchedule>,
|
||||
): AssistantMessageEvent | undefined {
|
||||
switch (proxyEvent.type) {
|
||||
case "start":
|
||||
@@ -508,22 +515,34 @@ function processProxyEvent(
|
||||
throw new Error("Received thinking_end for non-thinking content");
|
||||
}
|
||||
|
||||
case "toolcall_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
case "toolcall_start": {
|
||||
const content = {
|
||||
type: "toolCall",
|
||||
id: proxyEvent.id,
|
||||
name: proxyEvent.toolName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
} satisfies ToolCall & { partialJson: string } as ToolCall;
|
||||
} satisfies StreamingToolCall;
|
||||
partial.content[proxyEvent.contentIndex] = content;
|
||||
toolArgumentPreviewSchedules.set(
|
||||
proxyEvent.contentIndex,
|
||||
createToolArgumentPreviewSchedule(),
|
||||
);
|
||||
return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
}
|
||||
|
||||
case "toolcall_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
const streamingContent = content as StreamingToolCall;
|
||||
streamingContent.partialJson = `${streamingContent.partialJson ?? ""}${proxyEvent.delta}`;
|
||||
content.arguments = parseStreamingJson(streamingContent.partialJson) || {};
|
||||
streamingContent.partialJson += proxyEvent.delta;
|
||||
const previewSchedule = toolArgumentPreviewSchedules.get(proxyEvent.contentIndex);
|
||||
if (!previewSchedule) {
|
||||
throw new Error("Received toolcall_delta without a preview schedule");
|
||||
}
|
||||
if (previewSchedule(streamingContent.partialJson.length)) {
|
||||
content.arguments = parseStreamingJson(streamingContent.partialJson);
|
||||
}
|
||||
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
|
||||
return {
|
||||
type: "toolcall_delta",
|
||||
@@ -538,7 +557,12 @@ function processProxyEvent(
|
||||
case "toolcall_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
delete (content as StreamingToolCall).partialJson;
|
||||
const streamingContent = content as StreamingToolCall;
|
||||
content.arguments = streamingContent.partialJson
|
||||
? parseTerminalToolCallArguments(streamingContent.partialJson)
|
||||
: {};
|
||||
toolArgumentPreviewSchedules.delete(proxyEvent.contentIndex);
|
||||
delete (content as Partial<StreamingToolCall>).partialJson;
|
||||
return {
|
||||
type: "toolcall_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type {
|
||||
WorkerInferenceEventParams,
|
||||
WorkerInferenceModelRef,
|
||||
WorkerInferenceTerminalOutcome,
|
||||
} from "../../packages/gateway-protocol/src/schema/worker-inference.js";
|
||||
import type { Usage } from "../llm/types.js";
|
||||
import { createWorkerInferenceStreamAdapter } from "./inference-stream.runtime.js";
|
||||
import type { WorkerInferenceProxyClient } from "./worker-rpc-clients.js";
|
||||
|
||||
const modelRef: WorkerInferenceModelRef = { provider: "test", model: "test-model" };
|
||||
const usage: Usage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
it("delays worker tool argument previews while preserving exact terminal arguments", async () => {
|
||||
const initialContent = "a".repeat(128);
|
||||
const checkpointContent = "b".repeat(400);
|
||||
const deltas = [`{"content":"${initialContent}`, checkpointContent, `","terminal":"exact"}`];
|
||||
const terminalArguments = {
|
||||
content: initialContent + checkpointContent,
|
||||
terminal: "exact",
|
||||
};
|
||||
const start: WorkerInferenceProxyClient["start"] = async (request, handlers) => {
|
||||
const identity = {
|
||||
runEpoch: request.runEpoch,
|
||||
sessionId: request.sessionId,
|
||||
runId: request.runId,
|
||||
turnId: request.turnId,
|
||||
};
|
||||
const streamEvents: WorkerInferenceEventParams["event"][] = [
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call-1", toolName: "write" },
|
||||
...deltas.map((delta) => ({ type: "toolcall_delta" as const, contentIndex: 0, delta })),
|
||||
{ type: "toolcall_end", contentIndex: 0 },
|
||||
];
|
||||
for (const [index, event] of streamEvents.entries()) {
|
||||
handlers?.onEvent?.({ ...identity, seq: index + 1, event });
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: "done",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "write", arguments: terminalArguments }],
|
||||
api: "openai-responses",
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.model,
|
||||
stopReason: "toolUse",
|
||||
usage,
|
||||
timestamp: 1,
|
||||
},
|
||||
} satisfies WorkerInferenceTerminalOutcome;
|
||||
};
|
||||
const client = { start, cancel: vi.fn() } as unknown as WorkerInferenceProxyClient;
|
||||
const streamFn = createWorkerInferenceStreamAdapter({
|
||||
client,
|
||||
sessionId: "session-1",
|
||||
runEpoch: 1,
|
||||
runId: "run-1",
|
||||
turnId: "turn-1",
|
||||
modelRef,
|
||||
});
|
||||
|
||||
const stream = streamFn({ modelRef, context: { messages: [] }, options: {} });
|
||||
const argumentSnapshots: Array<Record<string, unknown>> = [];
|
||||
let endArguments: Record<string, unknown> | undefined;
|
||||
for await (const event of stream) {
|
||||
if (event.type === "toolcall_delta") {
|
||||
const content = event.partial.content[event.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
argumentSnapshots.push(structuredClone(content.arguments));
|
||||
}
|
||||
} else if (event.type === "toolcall_end") {
|
||||
endArguments = structuredClone(event.toolCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
const checkpointPreview = { content: initialContent + checkpointContent };
|
||||
expect(argumentSnapshots).toEqual([{}, checkpointPreview, checkpointPreview]);
|
||||
expect(endArguments).toEqual(terminalArguments);
|
||||
await expect(stream.result()).resolves.toMatchObject({
|
||||
content: [{ type: "toolCall", arguments: terminalArguments }],
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import { parseStreamingJson } from "@openclaw/ai/internal/runtime";
|
||||
import {
|
||||
createToolArgumentPreviewSchedule,
|
||||
parseStreamingJson,
|
||||
parseTerminalToolCallArguments,
|
||||
type ToolArgumentPreviewSchedule,
|
||||
} from "@openclaw/ai/internal/runtime";
|
||||
import { WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH } from "../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import type {
|
||||
WorkerInferenceContext,
|
||||
@@ -18,7 +23,9 @@ import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"
|
||||
import { isWorkerTranscriptMessageFrameSafe } from "./transcript-message.js";
|
||||
import type { WorkerInferenceProxyClient } from "./worker-rpc-clients.js";
|
||||
|
||||
type StreamingToolCall = ToolCall & { partialJson?: string };
|
||||
type StreamingToolCall = ToolCall & {
|
||||
partialJson: string;
|
||||
};
|
||||
|
||||
type WorkerInferenceStreamAdapterOptions = {
|
||||
client: WorkerInferenceProxyClient;
|
||||
@@ -59,6 +66,7 @@ function emptyAssistantMessage(modelRef: WorkerInferenceModelRef): AssistantMess
|
||||
function processInferenceEvent(
|
||||
payload: WorkerInferenceEventParams,
|
||||
partial: AssistantMessage,
|
||||
toolArgumentPreviewSchedules: Map<number, ToolArgumentPreviewSchedule>,
|
||||
tolerateMissingState: boolean,
|
||||
): AssistantMessageEvent | undefined {
|
||||
const event = payload.event;
|
||||
@@ -146,13 +154,15 @@ function processInferenceEvent(
|
||||
};
|
||||
}
|
||||
case "toolcall_start": {
|
||||
partial.content[event.contentIndex] = {
|
||||
const content = {
|
||||
type: "toolCall",
|
||||
id: event.id,
|
||||
name: event.toolName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
} satisfies StreamingToolCall as ToolCall;
|
||||
} satisfies StreamingToolCall;
|
||||
partial.content[event.contentIndex] = content;
|
||||
toolArgumentPreviewSchedules.set(event.contentIndex, createToolArgumentPreviewSchedule());
|
||||
return { type: "toolcall_start", contentIndex: event.contentIndex, partial };
|
||||
}
|
||||
case "toolcall_delta": {
|
||||
@@ -164,8 +174,14 @@ function processInferenceEvent(
|
||||
throw new Error("worker inference tool delta has no active tool call");
|
||||
}
|
||||
const streaming = content as StreamingToolCall;
|
||||
streaming.partialJson = `${streaming.partialJson ?? ""}${event.delta}`;
|
||||
content.arguments = parseStreamingJson(streaming.partialJson);
|
||||
streaming.partialJson += event.delta;
|
||||
const previewSchedule = toolArgumentPreviewSchedules.get(event.contentIndex);
|
||||
if (!previewSchedule) {
|
||||
throw new Error("worker inference tool delta has no preview schedule");
|
||||
}
|
||||
if (previewSchedule(streaming.partialJson.length)) {
|
||||
content.arguments = parseStreamingJson(streaming.partialJson);
|
||||
}
|
||||
return {
|
||||
type: "toolcall_delta",
|
||||
contentIndex: event.contentIndex,
|
||||
@@ -181,7 +197,10 @@ function processInferenceEvent(
|
||||
}
|
||||
throw new Error("worker inference tool end has no active tool call");
|
||||
}
|
||||
delete (content as StreamingToolCall).partialJson;
|
||||
const streaming = content as StreamingToolCall;
|
||||
content.arguments = parseTerminalToolCallArguments(streaming.partialJson);
|
||||
toolArgumentPreviewSchedules.delete(event.contentIndex);
|
||||
delete (content as Partial<StreamingToolCall>).partialJson;
|
||||
return { type: "toolcall_end", contentIndex: event.contentIndex, toolCall: content, partial };
|
||||
}
|
||||
}
|
||||
@@ -220,6 +239,7 @@ export function createWorkerInferenceStreamAdapter(
|
||||
return (inferenceRequest) => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const partial = emptyAssistantMessage(adapter.modelRef);
|
||||
const toolArgumentPreviewSchedules = new Map<number, ToolArgumentPreviewSchedule>();
|
||||
let streamHasGap = false;
|
||||
let settled = false;
|
||||
modelCallSeq += 1;
|
||||
@@ -277,7 +297,12 @@ export function createWorkerInferenceStreamAdapter(
|
||||
streamHasGap = true;
|
||||
},
|
||||
onEvent: (event) => {
|
||||
const projected = processInferenceEvent(event, partial, streamHasGap);
|
||||
const projected = processInferenceEvent(
|
||||
event,
|
||||
partial,
|
||||
toolArgumentPreviewSchedules,
|
||||
streamHasGap,
|
||||
);
|
||||
if (projected) {
|
||||
stream.push(projected);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user