diff --git a/packages/tool-call-repair/package.json b/packages/tool-call-repair/package.json
new file mode 100644
index 00000000000..011f4b51b4f
--- /dev/null
+++ b/packages/tool-call-repair/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@openclaw/tool-call-repair",
+ "version": "0.0.0-private",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ }
+}
diff --git a/packages/tool-call-repair/src/grammar.ts b/packages/tool-call-repair/src/grammar.ts
new file mode 100644
index 00000000000..37b177bed4e
--- /dev/null
+++ b/packages/tool-call-repair/src/grammar.ts
@@ -0,0 +1,218 @@
+export const END_TOOL_REQUEST = "[END_TOOL_REQUEST]";
+export const HARMONY_CHANNEL_MARKER = "<|channel|>";
+export const HARMONY_MESSAGE_MARKER = "<|message|>";
+export const HARMONY_CALL_MARKER = "<|call|>";
+
+export function matchesLiteralPrefix(text: string, literal: string): boolean {
+ return literal.startsWith(text) || text.startsWith(literal);
+}
+
+export function isPlainTextToolNameChar(char: string | undefined): boolean {
+ return Boolean(char && /[A-Za-z0-9_-]/.test(char));
+}
+
+export function isXmlishNameChar(char: string | undefined): boolean {
+ return Boolean(char && /[A-Za-z0-9_.:-]/.test(char));
+}
+
+export function skipHorizontalWhitespace(text: string, start: number): number {
+ let index = start;
+ while (index < text.length && (text[index] === " " || text[index] === "\t")) {
+ index += 1;
+ }
+ return index;
+}
+
+export function skipWhitespace(text: string, start: number): number {
+ let index = start;
+ while (index < text.length && /\s/.test(text[index] ?? "")) {
+ index += 1;
+ }
+ return index;
+}
+
+export function consumeLineBreak(text: string, start: number): number | null {
+ if (text[start] === "\r") {
+ return text[start + 1] === "\n" ? start + 2 : start + 1;
+ }
+ if (text[start] === "\n") {
+ return start + 1;
+ }
+ return null;
+}
+
+export function findJsonObjectEnd(
+ text: string,
+ start: number,
+ maxPayloadBytes?: number,
+): number | null {
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+ for (let index = start; index < text.length; index += 1) {
+ if (maxPayloadBytes !== undefined && index + 1 - start > maxPayloadBytes) {
+ return null;
+ }
+ const char = text[index];
+ if (inString) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === '"') {
+ inString = false;
+ }
+ continue;
+ }
+ if (char === '"') {
+ inString = true;
+ continue;
+ }
+ if (char === "{") {
+ depth += 1;
+ continue;
+ }
+ if (char === "}") {
+ depth -= 1;
+ if (depth === 0) {
+ return index + 1;
+ }
+ }
+ }
+ return null;
+}
+
+export function skipSerializedToolCallTrailingLineBreak(text: string, cursor: number): number {
+ const afterLineBreak = consumeLineBreak(text, cursor);
+ return afterLineBreak ?? cursor;
+}
+
+export function consumeJsonToolClosingMarker(text: string, cursor: number): number {
+ let markerStart = cursor;
+ while (markerStart < text.length && /\s/.test(text[markerStart] ?? "")) {
+ markerStart += 1;
+ }
+ const rest = text.slice(markerStart);
+ if (rest.startsWith(END_TOOL_REQUEST)) {
+ return skipSerializedToolCallTrailingLineBreak(text, markerStart + END_TOOL_REQUEST.length);
+ }
+ const bracketClose = /^\[\/[A-Za-z0-9_-]+\]/.exec(rest);
+ if (bracketClose) {
+ return skipSerializedToolCallTrailingLineBreak(text, markerStart + bracketClose[0].length);
+ }
+ if (rest.startsWith(HARMONY_CALL_MARKER)) {
+ return skipSerializedToolCallTrailingLineBreak(text, markerStart + HARMONY_CALL_MARKER.length);
+ }
+ return skipSerializedToolCallTrailingLineBreak(text, cursor);
+}
+
+export function findBracketedJsonPayloadStart(text: string): number | null {
+ if (!text.startsWith("[")) {
+ return null;
+ }
+ const close = text.indexOf("]");
+ if (close === -1) {
+ return null;
+ }
+ let cursor = close + 1;
+ cursor = skipHorizontalWhitespace(text, cursor);
+ cursor = skipSerializedToolCallTrailingLineBreak(text, cursor);
+ cursor = skipHorizontalWhitespace(text, cursor);
+ return text[cursor] === "{" ? cursor : null;
+}
+
+export function findHarmonyJsonPayloadStart(text: string): number | null {
+ let cursor = 0;
+ if (text.startsWith(HARMONY_CHANNEL_MARKER)) {
+ cursor = HARMONY_CHANNEL_MARKER.length;
+ }
+ const rest = text.slice(cursor);
+ const channel = ["commentary", "analysis", "final"].find((candidate) =>
+ rest.startsWith(candidate),
+ );
+ if (!channel) {
+ return null;
+ }
+ cursor += channel.length;
+ cursor = skipHorizontalWhitespace(text, cursor);
+ if (!text.slice(cursor).startsWith("to=")) {
+ return null;
+ }
+ cursor += "to=".length;
+ const nameStart = cursor;
+ while (isPlainTextToolNameChar(text[cursor])) {
+ cursor += 1;
+ }
+ if (cursor === nameStart) {
+ return null;
+ }
+ cursor = skipHorizontalWhitespace(text, cursor);
+ if (!text.slice(cursor).startsWith("code")) {
+ return null;
+ }
+ cursor += "code".length;
+ cursor = skipWhitespace(text, cursor);
+ if (text.slice(cursor).startsWith(HARMONY_MESSAGE_MARKER)) {
+ cursor = skipWhitespace(text, cursor + HARMONY_MESSAGE_MARKER.length);
+ }
+ return text[cursor] === "{" ? cursor : null;
+}
+
+export function startsWithAsciiMarkerIgnoreCase(
+ text: string,
+ cursor: number,
+ marker: string,
+): boolean {
+ return text.slice(cursor, cursor + marker.length).toLowerCase() === marker;
+}
+
+export function indexOfAsciiMarkerIgnoreCase(text: string, marker: string, start: number): number {
+ let cursor = start;
+ while (cursor < text.length) {
+ const next = text.indexOf(marker[0] ?? "", cursor);
+ if (next === -1) {
+ return -1;
+ }
+ if (startsWithAsciiMarkerIgnoreCase(text, next, marker)) {
+ return next;
+ }
+ cursor = next + 1;
+ }
+ return -1;
+}
+
+export function findXmlishToolCallEnd(text: string): number | null {
+ let cursor: number;
+ const xmlFunction = /^/i.exec(text);
+ if (xmlFunction) {
+ cursor = xmlFunction[0].length;
+ } else {
+ const bracketed = /^\[(?:tool:)?[A-Za-z0-9_-]+\]/.exec(text);
+ if (!bracketed) {
+ return null;
+ }
+ cursor = bracketed[0].length;
+ cursor = skipHorizontalWhitespace(text, cursor);
+ cursor = skipSerializedToolCallTrailingLineBreak(text, cursor);
+ }
+
+ cursor = skipWhitespace(text, cursor);
+ if (!startsWithAsciiMarkerIgnoreCase(text, cursor, "", cursor);
+ if (parameterClose === -1) {
+ return null;
+ }
+ cursor = skipWhitespace(text, parameterClose + "".length);
+ if (startsWithAsciiMarkerIgnoreCase(text, cursor, "")) {
+ return skipSerializedToolCallTrailingLineBreak(text, cursor + "".length);
+ }
+ if (!startsWithAsciiMarkerIgnoreCase(text, cursor, ";
+ end: number;
+ name: string;
+ raw: string;
+ start: number;
+};
+
+export type PlainTextToolCallParseOptions = {
+ allowedToolNames?: Iterable;
+ maxPayloadBytes?: number;
+};
+
+const DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES = 256_000;
+
+type PlainTextToolCallOpening = {
+ allowsOptionalXmlishClose?: boolean;
+ end: number;
+ name: string;
+ requiresClosing: boolean;
+};
+
+function parseBracketOpening(text: string, start: number): PlainTextToolCallOpening | null {
+ if (text[start] !== "[") {
+ return null;
+ }
+ let cursor = start + 1;
+ if (text.startsWith("tool:", cursor)) {
+ cursor += "tool:".length;
+ const nameStart = cursor;
+ while (isPlainTextToolNameChar(text[cursor])) {
+ cursor += 1;
+ }
+ if (cursor === nameStart || text[cursor] !== "]") {
+ return null;
+ }
+ return {
+ allowsOptionalXmlishClose: true,
+ end: cursor + 1,
+ name: text.slice(nameStart, cursor),
+ requiresClosing: false,
+ };
+ }
+ const nameStart = cursor;
+ while (isPlainTextToolNameChar(text[cursor])) {
+ cursor += 1;
+ }
+ if (cursor === nameStart || text[cursor] !== "]") {
+ return null;
+ }
+ const name = text.slice(nameStart, cursor);
+ cursor += 1;
+ cursor = skipHorizontalWhitespace(text, cursor);
+ const afterLineBreak = consumeLineBreak(text, cursor);
+ if (afterLineBreak === null) {
+ return null;
+ }
+ return { end: afterLineBreak, name, requiresClosing: true };
+}
+
+function parseHarmonyOpening(text: string, start: number): PlainTextToolCallOpening | null {
+ let cursor = start;
+ if (text.startsWith(HARMONY_CHANNEL_MARKER, cursor)) {
+ cursor += HARMONY_CHANNEL_MARKER.length;
+ }
+ const channelStart = cursor;
+ while (/[A-Za-z_]/.test(text[cursor] ?? "")) {
+ cursor += 1;
+ }
+ const channel = text.slice(channelStart, cursor);
+ if (channel !== "commentary" && channel !== "analysis" && channel !== "final") {
+ return null;
+ }
+ cursor = skipHorizontalWhitespace(text, cursor);
+ if (!text.startsWith("to=", cursor)) {
+ return null;
+ }
+ cursor += 3;
+ const nameStart = cursor;
+ while (isPlainTextToolNameChar(text[cursor])) {
+ cursor += 1;
+ }
+ if (cursor === nameStart) {
+ return null;
+ }
+ const name = text.slice(nameStart, cursor);
+ cursor = skipHorizontalWhitespace(text, cursor);
+ if (!text.startsWith("code", cursor)) {
+ return null;
+ }
+ cursor += 4;
+ cursor = skipWhitespace(text, cursor);
+ if (text.startsWith(HARMONY_MESSAGE_MARKER, cursor)) {
+ cursor = skipWhitespace(text, cursor + HARMONY_MESSAGE_MARKER.length);
+ }
+ return { end: cursor, name, requiresClosing: false };
+}
+
+function parseXmlishFunctionOpening(text: string, start: number): PlainTextToolCallOpening | null {
+ const match = /^\s*/i.exec(text.slice(start));
+ if (!match?.[1]) {
+ return null;
+ }
+ return { end: start + match[0].length, name: match[1], requiresClosing: false };
+}
+
+function parseOpening(text: string, start: number): PlainTextToolCallOpening | null {
+ return parseBracketOpening(text, start) ?? parseHarmonyOpening(text, start);
+}
+
+function consumeJsonObject(
+ text: string,
+ start: number,
+ maxPayloadBytes: number,
+): { end: number; value: Record } | null {
+ const cursor = skipWhitespace(text, start);
+ if (text[cursor] !== "{") {
+ return null;
+ }
+ const end = findJsonObjectEnd(text, cursor, maxPayloadBytes);
+ if (end === null) {
+ return null;
+ }
+ const rawJson = text.slice(cursor, end);
+ try {
+ const parsed = JSON.parse(rawJson) as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return null;
+ }
+ return { end, value: parsed as Record };
+ } catch {
+ return null;
+ }
+}
+
+function parseClosing(text: string, start: number, name: string): number | null {
+ const cursor = skipWhitespace(text, start);
+ if (text.startsWith(END_TOOL_REQUEST, cursor)) {
+ return cursor + END_TOOL_REQUEST.length;
+ }
+ const namedClosing = `[/${name}]`;
+ if (text.startsWith(namedClosing, cursor)) {
+ return cursor + namedClosing.length;
+ }
+ return null;
+}
+
+function parseOptionalHarmonyClosing(text: string, start: number): number {
+ const cursor = skipWhitespace(text, start);
+ if (text.startsWith(HARMONY_CALL_MARKER, cursor)) {
+ return cursor + HARMONY_CALL_MARKER.length;
+ }
+ return start;
+}
+
+function parsePlainTextToolCallBlockAt(
+ text: string,
+ start: number,
+ options?: PlainTextToolCallParseOptions,
+): PlainTextToolCallBlock | null {
+ const opening = parseOpening(text, start);
+ if (!opening) {
+ return null;
+ }
+ const allowedToolNames = options?.allowedToolNames
+ ? new Set(options.allowedToolNames)
+ : undefined;
+ if (allowedToolNames && !allowedToolNames.has(opening.name)) {
+ return null;
+ }
+ const payload = consumeJsonObject(
+ text,
+ opening.end,
+ options?.maxPayloadBytes ?? DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES,
+ );
+ if (!payload) {
+ return null;
+ }
+ const closingEnd = opening.requiresClosing
+ ? parseClosing(text, payload.end, opening.name)
+ : parseOptionalHarmonyClosing(text, payload.end);
+ if (closingEnd === null) {
+ return null;
+ }
+ return {
+ arguments: payload.value,
+ end: closingEnd,
+ name: opening.name,
+ raw: text.slice(start, closingEnd),
+ start,
+ };
+}
+
+type XmlishParameterBlockBounds = {
+ closeStart: number;
+ end: number;
+ name: string;
+ payloadStart: number;
+ start: number;
+};
+
+function findXmlishParameterBlock(text: string, start: number): XmlishParameterBlockBounds | null {
+ const cursor = skipWhitespace(text, start);
+ const openMatch = /^/i.exec(text.slice(cursor));
+ if (!openMatch?.[1]) {
+ return null;
+ }
+ const payloadStart = cursor + openMatch[0].length;
+ const closeMatch = /<\/parameter>/i.exec(text.slice(payloadStart));
+ if (!closeMatch) {
+ return null;
+ }
+ const closeStart = payloadStart + closeMatch.index;
+ const closeEnd = closeStart + closeMatch[0].length;
+ return {
+ closeStart,
+ end: closeEnd,
+ name: openMatch[1],
+ payloadStart,
+ start: cursor,
+ };
+}
+
+function consumeXmlishParameterBlock(
+ text: string,
+ start: number,
+ maxPayloadBytes: number,
+): { end: number; name: string; value: string } | null {
+ const bounds = findXmlishParameterBlock(text, start);
+ if (!bounds) {
+ return null;
+ }
+ if (bounds.end - bounds.start > maxPayloadBytes) {
+ return null;
+ }
+ return {
+ end: bounds.end,
+ name: bounds.name,
+ value: extractXmlishParameterValue(text, bounds.payloadStart, bounds.closeStart),
+ };
+}
+
+function extractXmlishParameterValue(text: string, start: number, end: number): string {
+ let payloadStart = start;
+ let payloadEnd = end;
+ const afterOpeningLineBreak = consumeLineBreak(text, payloadStart);
+ if (afterOpeningLineBreak !== null) {
+ payloadStart = afterOpeningLineBreak;
+ if (payloadEnd > payloadStart && text[payloadEnd - 1] === "\n") {
+ payloadEnd -= 1;
+ if (payloadEnd > payloadStart && text[payloadEnd - 1] === "\r") {
+ payloadEnd -= 1;
+ }
+ } else if (payloadEnd > payloadStart && text[payloadEnd - 1] === "\r") {
+ payloadEnd -= 1;
+ }
+ }
+ return text.slice(payloadStart, payloadEnd);
+}
+
+function consumeXmlishFunctionClose(text: string, start: number): number | null {
+ const cursor = skipWhitespace(text, start);
+ return text.slice(cursor).toLowerCase().startsWith("")
+ ? cursor + "".length
+ : null;
+}
+
+function consumeOptionalXmlishFunctionClose(text: string, start: number): number {
+ return consumeXmlishFunctionClose(text, start) ?? start;
+}
+
+function parseXmlishPlainTextToolCallBlockEndAt(
+ text: string,
+ start: number,
+ options?: PlainTextToolCallParseOptions,
+): number | null {
+ const opening = parseXmlishOpening(text, start);
+ if (!opening) {
+ return null;
+ }
+ const allowedToolNames = options?.allowedToolNames
+ ? new Set(options.allowedToolNames)
+ : undefined;
+ if (allowedToolNames && !allowedToolNames.has(opening.name)) {
+ return null;
+ }
+
+ let cursor = opening.end;
+ let parameterCount = 0;
+ while (true) {
+ const parameter = findXmlishParameterBlock(text, cursor);
+ if (!parameter) {
+ break;
+ }
+ parameterCount += 1;
+ cursor = parameter.end;
+ }
+ if (parameterCount === 0) {
+ return null;
+ }
+ return opening.allowsOptionalXmlishClose
+ ? consumeOptionalXmlishFunctionClose(text, cursor)
+ : consumeXmlishFunctionClose(text, cursor);
+}
+
+function parseXmlishOpening(text: string, start: number): PlainTextToolCallOpening | null {
+ return parseBracketOpening(text, start) ?? parseXmlishFunctionOpening(text, start);
+}
+
+function parseXmlishPlainTextToolCallBlockAt(
+ text: string,
+ start: number,
+ options?: PlainTextToolCallParseOptions,
+): PlainTextToolCallBlock | null {
+ const opening = parseXmlishOpening(text, start);
+ if (!opening) {
+ return null;
+ }
+ const allowedToolNames = options?.allowedToolNames
+ ? new Set(options.allowedToolNames)
+ : undefined;
+ if (allowedToolNames && !allowedToolNames.has(opening.name)) {
+ return null;
+ }
+
+ const maxPayloadBytes = options?.maxPayloadBytes ?? DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES;
+ const args: Record = {};
+ let cursor = opening.end;
+ let parameterCount = 0;
+ while (true) {
+ const parameter = consumeXmlishParameterBlock(text, cursor, maxPayloadBytes);
+ if (!parameter) {
+ break;
+ }
+ if (parameter.end - opening.end > maxPayloadBytes) {
+ return null;
+ }
+ args[parameter.name] = parameter.value;
+ parameterCount += 1;
+ cursor = parameter.end;
+ }
+ if (parameterCount === 0) {
+ return null;
+ }
+
+ const end = opening.allowsOptionalXmlishClose
+ ? consumeOptionalXmlishFunctionClose(text, cursor)
+ : consumeXmlishFunctionClose(text, cursor);
+ if (end === null) {
+ return null;
+ }
+ return {
+ arguments: args,
+ end,
+ name: opening.name,
+ raw: text.slice(start, end),
+ start,
+ };
+}
+
+export function parseStandalonePlainTextToolCallBlocks(
+ text: string,
+ options?: PlainTextToolCallParseOptions,
+): PlainTextToolCallBlock[] | null {
+ const blocks: PlainTextToolCallBlock[] = [];
+ let cursor = skipWhitespace(text, 0);
+ while (cursor < text.length) {
+ const block =
+ parsePlainTextToolCallBlockAt(text, cursor, options) ??
+ parseXmlishPlainTextToolCallBlockAt(text, cursor, options);
+ if (!block) {
+ return null;
+ }
+ blocks.push(block);
+ cursor = skipWhitespace(text, block.end);
+ }
+ return blocks.length > 0 ? blocks : null;
+}
+
+export function stripPlainTextToolCallBlocks(text: string): string {
+ if (
+ !text ||
+ (!/\[(?:tool:)?[A-Za-z0-9_-]+\]/.test(text) &&
+ !/(?:^|\n)\s*(?:<\|channel\|>)?(?:commentary|analysis|final)\s+to=/.test(text) &&
+ !/(?:^|\n)\s*/i.test(text))
+ ) {
+ return text;
+ }
+ let result = "";
+ let cursor = 0;
+ let index = 0;
+ while (index < text.length) {
+ const lineStart = index === 0 || text[index - 1] === "\n";
+ if (!lineStart) {
+ index += 1;
+ continue;
+ }
+ const blockStart = skipHorizontalWhitespace(text, index);
+ const block = parsePlainTextToolCallBlockAt(text, blockStart);
+ const blockEnd = block?.end ?? parseXmlishPlainTextToolCallBlockEndAt(text, blockStart);
+ if (blockEnd === null) {
+ index += 1;
+ continue;
+ }
+ result += text.slice(cursor, index);
+ cursor = blockEnd;
+ const afterBlockLineBreak = consumeLineBreak(text, cursor);
+ if (afterBlockLineBreak !== null) {
+ cursor = afterBlockLineBreak;
+ }
+ index = cursor;
+ }
+ result += text.slice(cursor);
+ return result;
+}
diff --git a/packages/tool-call-repair/src/promote.ts b/packages/tool-call-repair/src/promote.ts
new file mode 100644
index 00000000000..7469895cc5f
--- /dev/null
+++ b/packages/tool-call-repair/src/promote.ts
@@ -0,0 +1,257 @@
+import { parseStandalonePlainTextToolCallBlocks, type PlainTextToolCallBlock } from "./payload.js";
+
+export type ToolCallRepairNameResolver = (
+ rawName: string,
+ allowedToolNames: Set,
+) => string | null;
+
+export type PromotedPlainTextToolCallBlockFactory = (
+ block: PlainTextToolCallBlock,
+ resolvedName: string,
+) => Record;
+
+export type PlainTextToolCallPromotionOptions = {
+ allowedStopReasons?: ReadonlySet;
+ allowedToolNames: Set;
+ createToolCallBlock: PromotedPlainTextToolCallBlockFactory;
+ isRetainableNonTextBlock?: (block: Record) => boolean;
+ message: unknown;
+ requireAssistantRole?: boolean;
+ resolveToolName?: ToolCallRepairNameResolver;
+};
+
+function asRecord(value: unknown): Record | undefined {
+ return value && typeof value === "object" ? (value as Record) : undefined;
+}
+
+function resolveExactToolName(rawName: string, allowedToolNames: Set): string | null {
+ return allowedToolNames.has(rawName) ? rawName : null;
+}
+
+function createPromotedToolCallBlocks(
+ text: string,
+ options: PlainTextToolCallPromotionOptions,
+): Record[] | undefined {
+ const parsedBlocks = parseStandalonePlainTextToolCallBlocks(text);
+ if (!parsedBlocks) {
+ return undefined;
+ }
+
+ const resolveToolName = options.resolveToolName ?? resolveExactToolName;
+ const toolCalls: Record[] = [];
+ for (const block of parsedBlocks) {
+ const resolvedName = resolveToolName(block.name, options.allowedToolNames);
+ if (!resolvedName) {
+ return undefined;
+ }
+ toolCalls.push(options.createToolCallBlock(block, resolvedName));
+ }
+ return toolCalls;
+}
+
+function createPromotedToolCallBlocksFromTextParts(
+ textParts: readonly string[],
+ options: PlainTextToolCallPromotionOptions,
+): Record[] | undefined {
+ const exactText = textParts.join("").trim();
+ if (!exactText) {
+ return [];
+ }
+ for (const text of createTextPartPromotionCandidates(textParts, exactText)) {
+ const toolCalls = createPromotedToolCallBlocks(text, options);
+ if (toolCalls) {
+ return toolCalls;
+ }
+ }
+ return undefined;
+}
+
+function createTextPartPromotionCandidates(
+ textParts: readonly string[],
+ exactText: string,
+): string[] {
+ const repairedText = joinTextPartsWithStructuralLineBreaks(textParts).trim();
+ const newlineJoinedText = textParts.join("\n").trim();
+ return [...new Set([repairedText, exactText, newlineJoinedText].filter(Boolean))];
+}
+
+function joinTextPartsWithStructuralLineBreaks(textParts: readonly string[]): string {
+ let text = "";
+ for (const part of textParts) {
+ if (text && shouldInsertStructuralLineBreak(text, part)) {
+ text += "\n";
+ }
+ text += part;
+ }
+ return text;
+}
+
+function shouldInsertStructuralLineBreak(left: string, right: string): boolean {
+ if (!left || !right || /[\r\n]$/u.test(left) || /^\s/u.test(right)) {
+ return false;
+ }
+ const trimmedLeft = left.trimEnd();
+ return (
+ /$/iu.test(trimmedLeft) ||
+ /^\[[A-Za-z0-9_-]+\]$/u.test(trimmedLeft)
+ );
+}
+
+function shouldPromoteMessage(options: PlainTextToolCallPromotionOptions): boolean {
+ if (options.allowedToolNames.size === 0) {
+ return false;
+ }
+ const messageRecord = asRecord(options.message);
+ if (!messageRecord) {
+ return false;
+ }
+ if (options.requireAssistantRole && messageRecord.role !== "assistant") {
+ return false;
+ }
+ return !options.allowedStopReasons || options.allowedStopReasons.has(messageRecord.stopReason);
+}
+
+export function extractStandalonePlainTextToolCallText(params: {
+ allowOtherNonTextBlocks?: boolean;
+ allowedStopReasons?: ReadonlySet;
+ isRetainableNonTextBlock?: (block: Record) => boolean;
+ message: unknown;
+ requireAssistantRole?: boolean;
+}): string | undefined {
+ const record = asRecord(params.message);
+ if (!record) {
+ return undefined;
+ }
+ if (params.requireAssistantRole && record.role !== "assistant") {
+ return undefined;
+ }
+ if (params.allowedStopReasons && !params.allowedStopReasons.has(record.stopReason)) {
+ return undefined;
+ }
+
+ const content = record.content;
+ if (typeof content === "string") {
+ const text = content.trim();
+ return text || undefined;
+ }
+ if (!Array.isArray(content)) {
+ return undefined;
+ }
+
+ const textParts: string[] = [];
+ for (const block of content) {
+ const blockRecord = asRecord(block);
+ if (!blockRecord) {
+ return undefined;
+ }
+ if (blockRecord.type === "text") {
+ if (typeof blockRecord.text !== "string") {
+ return undefined;
+ }
+ if (blockRecord.text.trim()) {
+ textParts.push(blockRecord.text);
+ }
+ continue;
+ }
+ if (params.isRetainableNonTextBlock?.(blockRecord) || params.allowOtherNonTextBlocks) {
+ continue;
+ }
+ return undefined;
+ }
+
+ const text = textParts.join("").trim();
+ return text || undefined;
+}
+
+export function promoteStandalonePlainTextToolCallMessage(
+ options: PlainTextToolCallPromotionOptions,
+): Record | undefined {
+ if (!shouldPromoteMessage(options)) {
+ return undefined;
+ }
+ const messageRecord = asRecord(options.message);
+ if (!messageRecord) {
+ return undefined;
+ }
+
+ const originalContent = messageRecord.content;
+ if (typeof originalContent === "string") {
+ const text = originalContent.trim();
+ if (!text) {
+ return undefined;
+ }
+ const toolCalls = createPromotedToolCallBlocks(text, options);
+ if (!toolCalls) {
+ return undefined;
+ }
+ return {
+ ...messageRecord,
+ content: toolCalls,
+ stopReason: "toolUse",
+ };
+ }
+
+ if (!Array.isArray(originalContent)) {
+ return undefined;
+ }
+
+ const content: Array> = [];
+ let promotedTextBlock = false;
+ let textParts: string[] = [];
+ const flushTextParts = (): boolean | undefined => {
+ if (textParts.length === 0) {
+ return false;
+ }
+ const toolCalls = createPromotedToolCallBlocksFromTextParts(textParts, options);
+ textParts = [];
+ if (toolCalls?.length === 0) {
+ return false;
+ }
+ if (!toolCalls) {
+ return undefined;
+ }
+ content.push(...toolCalls);
+ return true;
+ };
+
+ for (const block of originalContent) {
+ const blockRecord = asRecord(block);
+ if (!blockRecord) {
+ return undefined;
+ }
+ if (blockRecord.type === "text") {
+ if (typeof blockRecord.text !== "string") {
+ return undefined;
+ }
+ if (blockRecord.text.trim()) {
+ textParts.push(blockRecord.text);
+ }
+ continue;
+ }
+ const promotedTextRun = flushTextParts();
+ if (promotedTextRun === undefined) {
+ return undefined;
+ }
+ promotedTextBlock ||= promotedTextRun;
+ if (options.isRetainableNonTextBlock?.(blockRecord)) {
+ content.push(blockRecord);
+ continue;
+ }
+ return undefined;
+ }
+
+ const promotedTrailingTextRun = flushTextParts();
+ if (promotedTrailingTextRun === undefined) {
+ return undefined;
+ }
+ promotedTextBlock ||= promotedTrailingTextRun;
+ if (!promotedTextBlock) {
+ return undefined;
+ }
+
+ return {
+ ...messageRecord,
+ content,
+ stopReason: "toolUse",
+ };
+}
diff --git a/packages/tool-call-repair/src/stream-normalizer.ts b/packages/tool-call-repair/src/stream-normalizer.ts
new file mode 100644
index 00000000000..ea94bc80b3d
--- /dev/null
+++ b/packages/tool-call-repair/src/stream-normalizer.ts
@@ -0,0 +1,1353 @@
+import {
+ consumeJsonToolClosingMarker,
+ END_TOOL_REQUEST,
+ findBracketedJsonPayloadStart,
+ findHarmonyJsonPayloadStart,
+ findJsonObjectEnd,
+ findXmlishToolCallEnd,
+ isPlainTextToolNameChar,
+ isXmlishNameChar,
+ matchesLiteralPrefix,
+} from "./grammar.js";
+
+export type PlainTextToolCallNameMatcher = {
+ hasExactName(name: string): boolean;
+ hasNamePrefix(prefix: string): boolean;
+};
+
+export type PlainTextToolCallMessageNormalization =
+ | { kind: "promoted" | "scrubbed"; message: Record }
+ | undefined;
+
+export type PlainTextToolCallStreamNormalizerOptions = {
+ createPromotedToolCallEvents(message: Record): Iterable;
+ matcher: PlainTextToolCallNameMatcher;
+ normalizeDoneMessage(params: {
+ message: unknown;
+ reason: unknown;
+ }): PlainTextToolCallMessageNormalization;
+ stopAfterDone?: boolean;
+};
+
+const TEXT_TOOL_CALL_BUFFER_MAX_CHARS = 256_000;
+
+const TEXT_TOOL_CALL_SUPPRESSED_SCAN_MAX_CHARS = TEXT_TOOL_CALL_BUFFER_MAX_CHARS + 64_000;
+const TEXT_TOOL_CALL_SUPPRESSED_TAIL_CHARS =
+ TEXT_TOOL_CALL_SUPPRESSED_SCAN_MAX_CHARS - TEXT_TOOL_CALL_BUFFER_MAX_CHARS;
+const TEXT_TOOL_CALL_SUPPRESSED_MARKER_SCAN_CHARS = 2_048;
+
+type PlainTextToolCallBufferState = "possible" | "impossible" | "over-cap";
+
+function asRecord(value: unknown): Record | undefined {
+ return value && typeof value === "object" ? (value as Record) : undefined;
+}
+
+function couldStillBeJsonPayload(text: string, start: number): boolean {
+ let cursor = start;
+ while (cursor < text.length && /\s/.test(text[cursor] ?? "")) {
+ cursor += 1;
+ }
+ return cursor >= text.length || text[cursor] === "{";
+}
+
+function couldStillBeXmlishParameterPayload(text: string, start: number): boolean {
+ let cursor = start;
+ while (cursor < text.length && /\s/.test(text[cursor] ?? "")) {
+ cursor += 1;
+ }
+ if (cursor >= text.length) {
+ return true;
+ }
+ return matchesLiteralPrefix(text.slice(cursor).toLowerCase(), "= text.length) {
+ return true;
+ }
+ if (text[cursor] !== "]") {
+ return false;
+ }
+ if (!matcher.hasExactName(name)) {
+ return false;
+ }
+ return (
+ couldStillBeJsonPayload(text, cursor + 1) ||
+ couldStillBeXmlishParameterPayload(text, cursor + 1)
+ );
+ }
+
+ let cursor = 1;
+ while (isPlainTextToolNameChar(text[cursor])) {
+ cursor += 1;
+ }
+ const name = text.slice(1, cursor);
+ if (!name || !matcher.hasNamePrefix(name)) {
+ return false;
+ }
+ if (cursor >= text.length) {
+ return true;
+ }
+ if (text[cursor] !== "]") {
+ return false;
+ }
+ if (!matcher.hasExactName(name)) {
+ return false;
+ }
+
+ cursor += 1;
+ while (text[cursor] === " " || text[cursor] === "\t") {
+ cursor += 1;
+ }
+ if (cursor >= text.length) {
+ return true;
+ }
+ if (text[cursor] === "\r") {
+ if (cursor + 1 >= text.length) {
+ return true;
+ }
+ const payloadStart = text[cursor + 1] === "\n" ? cursor + 2 : cursor + 1;
+ return (
+ couldStillBeJsonPayload(text, payloadStart) ||
+ couldStillBeXmlishParameterPayload(text, payloadStart)
+ );
+ }
+ if (text[cursor] !== "\n") {
+ return false;
+ }
+ return (
+ couldStillBeJsonPayload(text, cursor + 1) ||
+ couldStillBeXmlishParameterPayload(text, cursor + 1)
+ );
+}
+
+function couldStillBeXmlishFunctionToolCall(
+ text: string,
+ matcher: PlainTextToolCallNameMatcher,
+): boolean {
+ const marker = "= text.length) {
+ return true;
+ }
+ if (text[cursor] !== ">") {
+ return false;
+ }
+ if (!matcher.hasExactName(name)) {
+ return false;
+ }
+ return couldStillBeXmlishParameterPayload(text, cursor + 1);
+}
+
+function couldStillBeHarmonyStandaloneToolCall(
+ text: string,
+ matcher: PlainTextToolCallNameMatcher,
+): boolean {
+ const channelMarker = "<|channel|>";
+ let cursor = 0;
+ if (matchesLiteralPrefix(text, channelMarker)) {
+ if (text.length <= channelMarker.length) {
+ return true;
+ }
+ cursor = channelMarker.length;
+ }
+
+ const rest = text.slice(cursor);
+ const channel = ["commentary", "analysis", "final"].find((candidate) =>
+ matchesLiteralPrefix(rest, candidate),
+ );
+ if (!channel) {
+ return false;
+ }
+ if (rest.length <= channel.length) {
+ return true;
+ }
+
+ cursor += channel.length;
+ while (text[cursor] === " " || text[cursor] === "\t") {
+ cursor += 1;
+ }
+ if (cursor >= text.length) {
+ return true;
+ }
+
+ const toMarker = "to=";
+ const toRest = text.slice(cursor);
+ if (!matchesLiteralPrefix(toRest, toMarker)) {
+ return false;
+ }
+ if (toRest.length <= toMarker.length) {
+ return true;
+ }
+
+ cursor += toMarker.length;
+ const nameStart = cursor;
+ while (isPlainTextToolNameChar(text[cursor])) {
+ cursor += 1;
+ }
+ const name = text.slice(nameStart, cursor);
+ if (!name || !matcher.hasNamePrefix(name)) {
+ return false;
+ }
+ if (cursor >= text.length) {
+ return true;
+ }
+
+ while (text[cursor] === " " || text[cursor] === "\t") {
+ cursor += 1;
+ }
+ if (cursor >= text.length) {
+ return true;
+ }
+ if (!matcher.hasExactName(name)) {
+ return false;
+ }
+
+ const codeMarker = "code";
+ const codeRest = text.slice(cursor);
+ if (!matchesLiteralPrefix(codeRest, codeMarker)) {
+ return false;
+ }
+ if (codeRest.length <= codeMarker.length) {
+ return true;
+ }
+
+ cursor += codeMarker.length;
+ while (cursor < text.length && /\s/.test(text[cursor] ?? "")) {
+ cursor += 1;
+ }
+ if (cursor >= text.length) {
+ return true;
+ }
+
+ const messageMarker = "<|message|>";
+ const messageRest = text.slice(cursor);
+ if (matchesLiteralPrefix(messageRest, messageMarker)) {
+ return true;
+ }
+ return text[cursor] === "{";
+}
+
+function hasExactSerializedToolCallPrefix(
+ text: string,
+ matcher: PlainTextToolCallNameMatcher,
+): boolean {
+ const bracketed = /^\[(?:tool:)?([A-Za-z0-9_-]+)\]/.exec(text);
+ if (bracketed?.[1]) {
+ return matcher.hasExactName(bracketed[1]);
+ }
+ const xmlish = /^/i.exec(text);
+ if (xmlish?.[1]) {
+ return matcher.hasExactName(xmlish[1]);
+ }
+ const harmony =
+ /^(?:<\|channel\|>)?(?:commentary|analysis|final)\s+to=([A-Za-z0-9_-]+)\s+code\b/.exec(text);
+ return Boolean(harmony?.[1] && matcher.hasExactName(harmony[1]));
+}
+
+function stripCompleteSerializedToolCallPrefix(
+ text: string,
+ matcher?: PlainTextToolCallNameMatcher,
+): string | null {
+ if (matcher && !hasExactSerializedToolCallPrefix(text, matcher)) {
+ return null;
+ }
+ const xmlishEnd = findXmlishToolCallEnd(text);
+ if (xmlishEnd !== null) {
+ return text.slice(xmlishEnd);
+ }
+ const jsonStart = findBracketedJsonPayloadStart(text) ?? findHarmonyJsonPayloadStart(text);
+ if (jsonStart === null) {
+ return null;
+ }
+ const jsonEnd = findJsonObjectEnd(text, jsonStart);
+ if (jsonEnd === null) {
+ return null;
+ }
+ return text.slice(consumeJsonToolClosingMarker(text, jsonEnd));
+}
+
+function stripSerializedToolCallPrefixes(
+ text: string,
+ matcher: PlainTextToolCallNameMatcher,
+): string | null {
+ let current = text;
+ let changed = false;
+ for (let count = 0; count < 32; count += 1) {
+ const next = stripCompleteSerializedToolCallPrefix(current.trimStart(), matcher);
+ if (next === null) {
+ if (changed && hasExactSerializedToolCallPrefix(current.trimStart(), matcher)) {
+ return "";
+ }
+ return changed ? current : null;
+ }
+ changed = true;
+ current = next;
+ if (!current.trim()) {
+ return current;
+ }
+ }
+ return hasExactSerializedToolCallPrefix(current.trimStart(), matcher) ? "" : current;
+}
+
+function getPlainTextToolCallBufferState(
+ text: string,
+ matcher: PlainTextToolCallNameMatcher,
+): PlainTextToolCallBufferState {
+ const trimmed = text.trimStart();
+ if (trimmed.length === 0) {
+ return text.length > TEXT_TOOL_CALL_BUFFER_MAX_CHARS ? "impossible" : "possible";
+ }
+ const toolCallLike =
+ couldStillBeBracketedStandaloneToolCall(trimmed, matcher) ||
+ couldStillBeXmlishFunctionToolCall(trimmed, matcher) ||
+ couldStillBeHarmonyStandaloneToolCall(trimmed, matcher);
+ if (!toolCallLike) {
+ return "impossible";
+ }
+ if (text.length <= TEXT_TOOL_CALL_BUFFER_MAX_CHARS) {
+ return "possible";
+ }
+ const textAfterCompleteToolBlocks = stripSerializedToolCallPrefixes(trimmed, matcher);
+ return textAfterCompleteToolBlocks !== null && textAfterCompleteToolBlocks.trim()
+ ? "impossible"
+ : "over-cap";
+}
+
+function getTextToolCallEventText(event: Record): string | undefined {
+ if (typeof event.delta === "string") {
+ return event.delta;
+ }
+ return typeof event.content === "string" ? event.content : undefined;
+}
+
+function appendTextToolCallBuffer(bufferedText: string, event: Record): string {
+ const text = getTextToolCallEventText(event);
+ if (text === undefined) {
+ return bufferedText;
+ }
+ if (typeof event.content === "string" && !bufferedText) {
+ return text;
+ }
+ return typeof event.delta === "string" ? bufferedText + text : bufferedText;
+}
+
+function hasSuppressedToolCallClosingMarker(text: string): boolean {
+ if (!text) {
+ return false;
+ }
+ const lowerText = text.toLowerCase();
+ return (
+ lowerText.includes("") ||
+ lowerText.includes("") ||
+ text.includes(END_TOOL_REQUEST) ||
+ text.includes("<|call|>") ||
+ text.includes("}") ||
+ /\[\/[A-Za-z0-9_.:-]+\]/.test(text)
+ );
+}
+
+function shouldRescanSuppressedTextToolCallBuffer(
+ previousBufferedText: string,
+ event: Record,
+): boolean {
+ const eventText = getTextToolCallEventText(event);
+ if (!eventText) {
+ return false;
+ }
+ return hasSuppressedToolCallClosingMarker(
+ previousBufferedText.slice(-TEXT_TOOL_CALL_SUPPRESSED_MARKER_SCAN_CHARS) + eventText,
+ );
+}
+
+function truncateSuppressedTextToolCallBuffer(text: string): string {
+ if (text.length <= TEXT_TOOL_CALL_SUPPRESSED_SCAN_MAX_CHARS) {
+ return text;
+ }
+ return (
+ text.slice(0, TEXT_TOOL_CALL_BUFFER_MAX_CHARS) +
+ text.slice(-TEXT_TOOL_CALL_SUPPRESSED_TAIL_CHARS)
+ );
+}
+
+function appendSuppressedTextToolCallBuffer(
+ bufferedText: string,
+ event: Record,
+): { changed: boolean; scanText: string; text: string } {
+ const nextText = appendTextToolCallBuffer(bufferedText, event);
+ if (nextText === bufferedText) {
+ return { changed: false, scanText: bufferedText, text: bufferedText };
+ }
+ return {
+ changed: true,
+ scanText: nextText,
+ text: truncateSuppressedTextToolCallBuffer(nextText),
+ };
+}
+
+function shouldSuppressBufferedTextBlock(blockText: string, bufferedText: string): boolean {
+ const normalizedBlock = blockText.trim();
+ const normalizedBuffer = bufferedText.trim();
+ const normalizedSuppressedPrefix = bufferedText.slice(0, TEXT_TOOL_CALL_BUFFER_MAX_CHARS).trim();
+ return (
+ Boolean(normalizedBlock && normalizedBuffer) &&
+ (normalizedBuffer.startsWith(normalizedBlock) ||
+ normalizedBlock.startsWith(normalizedBuffer) ||
+ (bufferedText.length >= TEXT_TOOL_CALL_SUPPRESSED_SCAN_MAX_CHARS &&
+ Boolean(normalizedSuppressedPrefix) &&
+ normalizedBlock.startsWith(normalizedSuppressedPrefix)))
+ );
+}
+
+function scrubBufferedTextFromContent(
+ content: unknown,
+ bufferedText: string,
+ matcher: PlainTextToolCallNameMatcher,
+ options?: { onlyTextIndex?: unknown; preserveEmptyTextBlocks?: boolean },
+): { changed: boolean; content: unknown } {
+ if (Array.isArray(content)) {
+ if (typeof options?.onlyTextIndex === "number") {
+ const block = content[options.onlyTextIndex];
+ const record = asRecord(block);
+ if (
+ record?.type !== "text" ||
+ typeof record.text !== "string" ||
+ !shouldSuppressBufferedTextBlock(record.text, bufferedText)
+ ) {
+ return { changed: false, content };
+ }
+ const nextContent = [...content];
+ if (options.preserveEmptyTextBlocks) {
+ nextContent[options.onlyTextIndex] = { ...record, text: "" };
+ } else {
+ nextContent.splice(options.onlyTextIndex, 1);
+ }
+ return { changed: true, content: nextContent };
+ }
+
+ const overCapPrefix = scrubOverCapTextPrefixFromContent(content, matcher, options);
+ if (overCapPrefix.changed) {
+ return overCapPrefix;
+ }
+
+ let changed = false;
+ const nextContent = content.flatMap((block) => {
+ const record = asRecord(block);
+ if (
+ record?.type === "text" &&
+ typeof record.text === "string" &&
+ shouldSuppressBufferedTextBlock(record.text, bufferedText)
+ ) {
+ changed = true;
+ return options?.preserveEmptyTextBlocks ? [{ ...record, text: "" }] : [];
+ }
+ return [block];
+ });
+ return changed ? { changed, content: nextContent } : { changed: false, content };
+ }
+ if (typeof content === "string" && shouldSuppressBufferedTextBlock(content, bufferedText)) {
+ return { changed: true, content: "" };
+ }
+ return { changed: false, content };
+}
+
+function scrubOverCapTextPrefixFromContent(
+ content: readonly unknown[],
+ matcher: PlainTextToolCallNameMatcher,
+ options?: { preserveEmptyTextBlocks?: boolean },
+): { changed: boolean; content: unknown } {
+ let currentContent: readonly unknown[] = content;
+ let changed = false;
+ for (let count = 0; count < 32; count += 1) {
+ const scrubbed = scrubFirstOverCapTextPrefixFromContent(currentContent, matcher, options);
+ if (!scrubbed.changed || !Array.isArray(scrubbed.content)) {
+ return changed ? { changed: true, content: currentContent } : scrubbed;
+ }
+ currentContent = scrubbed.content;
+ changed = true;
+ }
+ return { changed, content: currentContent };
+}
+
+function scrubFirstOverCapTextPrefixFromContent(
+ content: readonly unknown[],
+ matcher: PlainTextToolCallNameMatcher,
+ options?: { preserveEmptyTextBlocks?: boolean },
+): { changed: boolean; content: unknown } {
+ const suppressedTextIndexes = new Set();
+ let accumulated = "";
+ let reachedOverCap = false;
+ for (let index = 0; index < content.length; index += 1) {
+ const record = asRecord(content[index]);
+ if (record?.type !== "text" || typeof record.text !== "string") {
+ continue;
+ }
+ if (!record.text.trim()) {
+ continue;
+ }
+ if (!accumulated && !hasExactSerializedToolCallPrefix(record.text.trimStart(), matcher)) {
+ continue;
+ }
+ if (reachedOverCap && hasExactSerializedToolCallPrefix(record.text.trimStart(), matcher)) {
+ break;
+ }
+ if (
+ reachedOverCap &&
+ suppressedTextIndexes.size === 1 &&
+ !hasSuppressedToolCallClosingMarker(record.text)
+ ) {
+ break;
+ }
+
+ accumulated = accumulated ? `${accumulated}\n${record.text}` : record.text;
+ suppressedTextIndexes.add(index);
+
+ const state = getPlainTextToolCallBufferState(accumulated, matcher);
+ if (state === "over-cap") {
+ reachedOverCap = true;
+ const strippedSuffix = stripSerializedToolCallPrefixes(accumulated, matcher);
+ if (strippedSuffix !== null) {
+ return scrubSuppressedTextIndexesFromContent(
+ content,
+ suppressedTextIndexes,
+ options,
+ strippedSuffix,
+ index,
+ );
+ }
+ continue;
+ }
+ if (state === "impossible") {
+ if (reachedOverCap) {
+ const strippedSuffix = stripSerializedToolCallPrefixes(accumulated, matcher);
+ if (strippedSuffix !== null) {
+ return scrubSuppressedTextIndexesFromContent(
+ content,
+ suppressedTextIndexes,
+ options,
+ strippedSuffix,
+ index,
+ );
+ }
+ return scrubSuppressedTextIndexesFromContent(content, suppressedTextIndexes, options);
+ }
+ accumulated = "";
+ suppressedTextIndexes.clear();
+ reachedOverCap = false;
+ }
+ }
+ if (reachedOverCap) {
+ return scrubSuppressedTextIndexesFromContent(content, suppressedTextIndexes, options);
+ }
+ return { changed: false, content };
+}
+
+function scrubSuppressedTextIndexesFromContent(
+ content: readonly unknown[],
+ suppressedTextIndexes: ReadonlySet,
+ options?: { preserveEmptyTextBlocks?: boolean },
+ visibleSuffix?: string,
+ visibleSuffixIndex?: number,
+): { changed: boolean; content: unknown } {
+ const nextContent = content.flatMap((block, blockIndex) => {
+ if (!suppressedTextIndexes.has(blockIndex)) {
+ return [block];
+ }
+ const blockRecord = asRecord(block);
+ if (
+ visibleSuffixIndex === blockIndex &&
+ visibleSuffix !== undefined &&
+ visibleSuffix.trim() &&
+ blockRecord
+ ) {
+ return [{ ...blockRecord, text: visibleSuffix }];
+ }
+ return options?.preserveEmptyTextBlocks && blockRecord ? [{ ...blockRecord, text: "" }] : [];
+ });
+ return { changed: true, content: nextContent };
+}
+
+function stripPlainTextToolCallsFromContent(
+ content: unknown,
+ matcher: PlainTextToolCallNameMatcher,
+ options?: { preserveEmptyTextBlocks?: boolean },
+): { changed: boolean; content: unknown } {
+ if (Array.isArray(content)) {
+ const textBlocks = content
+ .map((block, index) => ({ index, record: asRecord(block) }))
+ .filter(
+ (entry): entry is { index: number; record: Record } =>
+ entry.record?.type === "text" && typeof entry.record.text === "string",
+ );
+ const joinedText = textBlocks.map((entry) => String(entry.record.text)).join("\n");
+ if (joinedText.trim()) {
+ const strippedJoined = stripSerializedToolCallPrefixes(joinedText.trim(), matcher);
+ if (strippedJoined !== null && strippedJoined !== joinedText) {
+ const firstTextIndex = textBlocks[0]?.index;
+ const nextContent = content.flatMap((block, index) => {
+ const record = asRecord(block);
+ if (record?.type !== "text" || typeof record.text !== "string") {
+ return [block];
+ }
+ if (options?.preserveEmptyTextBlocks) {
+ return [
+ {
+ ...record,
+ text: index === firstTextIndex && strippedJoined.trim() ? strippedJoined : "",
+ },
+ ];
+ }
+ return index === firstTextIndex && strippedJoined.trim()
+ ? [{ ...record, text: strippedJoined }]
+ : [];
+ });
+ return { changed: true, content: nextContent };
+ }
+ }
+
+ let changed = false;
+ const nextContent: unknown[] = [];
+ for (const block of content) {
+ const record = asRecord(block);
+ if (record?.type !== "text" || typeof record.text !== "string") {
+ nextContent.push(block);
+ continue;
+ }
+ const strippedText = stripSerializedToolCallPrefixes(record.text, matcher);
+ if (strippedText === null || strippedText === record.text) {
+ nextContent.push(block);
+ continue;
+ }
+ changed = true;
+ if (strippedText.trim()) {
+ nextContent.push({ ...record, text: strippedText });
+ } else if (options?.preserveEmptyTextBlocks) {
+ nextContent.push({ ...record, text: "" });
+ }
+ }
+ return changed ? { changed, content: nextContent } : { changed: false, content };
+ }
+ if (typeof content === "string") {
+ const strippedText = stripSerializedToolCallPrefixes(content, matcher);
+ if (strippedText !== null && strippedText !== content) {
+ return { changed: true, content: strippedText };
+ }
+ }
+ return { changed: false, content };
+}
+
+function stripOverCapPlainTextToolCallsFromContent(
+ content: unknown,
+ matcher: PlainTextToolCallNameMatcher,
+ options?: { preserveEmptyTextBlocks?: boolean },
+): { changed: boolean; content: unknown } {
+ if (Array.isArray(content)) {
+ let changed = false;
+ const nextContent: unknown[] = [];
+ for (const block of content) {
+ const record = asRecord(block);
+ if (
+ record?.type !== "text" ||
+ typeof record.text !== "string" ||
+ record.text.length <= TEXT_TOOL_CALL_BUFFER_MAX_CHARS
+ ) {
+ nextContent.push(block);
+ continue;
+ }
+ const strippedText = stripSerializedToolCallPrefixes(record.text, matcher);
+ if (strippedText === null || strippedText === record.text) {
+ nextContent.push(block);
+ continue;
+ }
+ changed = true;
+ if (strippedText.trim()) {
+ nextContent.push({ ...record, text: strippedText });
+ } else if (options?.preserveEmptyTextBlocks) {
+ nextContent.push({ ...record, text: "" });
+ }
+ }
+ return changed ? { changed, content: nextContent } : { changed: false, content };
+ }
+ if (typeof content === "string" && content.length > TEXT_TOOL_CALL_BUFFER_MAX_CHARS) {
+ const strippedText = stripSerializedToolCallPrefixes(content, matcher);
+ if (strippedText !== null && strippedText !== content) {
+ return { changed: true, content: strippedText };
+ }
+ }
+ return { changed: false, content };
+}
+
+function scrubPlainTextToolCallContent(
+ content: unknown,
+ bufferedText: string,
+ matcher: PlainTextToolCallNameMatcher,
+ options?: { onlyTextIndex?: unknown; preserveEmptyTextBlocks?: boolean },
+): { changed: boolean; content: unknown } {
+ const scrubbed = scrubBufferedTextFromContent(content, bufferedText, matcher, options);
+ const stripped =
+ options?.onlyTextIndex === undefined
+ ? stripPlainTextToolCallsFromContent(scrubbed.content, matcher, options)
+ : { changed: false, content: scrubbed.content };
+ return stripped.changed ? stripped : scrubbed;
+}
+
+function shouldPreserveEmptyTextBlocksForEventIndex(
+ content: unknown,
+ bufferedText: string,
+ matcher: PlainTextToolCallNameMatcher,
+ eventContentIndex: unknown,
+): boolean {
+ if (
+ typeof eventContentIndex !== "number" ||
+ !Number.isInteger(eventContentIndex) ||
+ eventContentIndex < 0 ||
+ !Array.isArray(content)
+ ) {
+ return false;
+ }
+ const currentBlock = content[eventContentIndex];
+ if (currentBlock === undefined) {
+ return false;
+ }
+ const scrubbed = scrubPlainTextToolCallContent(content, bufferedText, matcher);
+ return (
+ scrubbed.changed &&
+ Array.isArray(scrubbed.content) &&
+ scrubbed.content[eventContentIndex] !== currentBlock
+ );
+}
+
+function scrubBufferedTextFromPartial(
+ event: Record,
+ bufferedText: string,
+ matcher: PlainTextToolCallNameMatcher,
+ contentIndex?: unknown,
+ options?: { preserveEmptyTextBlocks?: boolean },
+): Record {
+ const partial = asRecord(event.partial);
+ if (!partial) {
+ return event;
+ }
+ const preserveEmptyTextBlocks =
+ options?.preserveEmptyTextBlocks === true ||
+ shouldPreserveEmptyTextBlocksForEventIndex(
+ partial.content,
+ bufferedText,
+ matcher,
+ event.contentIndex,
+ );
+ const scrubbed = scrubPlainTextToolCallContent(partial.content, bufferedText, matcher, {
+ onlyTextIndex: contentIndex,
+ preserveEmptyTextBlocks,
+ });
+ if (!scrubbed.changed) {
+ return event;
+ }
+ return {
+ ...event,
+ partial: {
+ ...partial,
+ content: scrubbed.content,
+ },
+ };
+}
+
+function scrubBufferedTextFromMessage(
+ event: Record,
+ bufferedText: string,
+ matcher: PlainTextToolCallNameMatcher,
+ contentIndex?: unknown,
+): Record {
+ const message = asRecord(event.message);
+ if (!message) {
+ return event;
+ }
+ const scrubbed = scrubPlainTextToolCallContent(message.content, bufferedText, matcher, {
+ onlyTextIndex: contentIndex,
+ });
+ if (!scrubbed.changed) {
+ return event;
+ }
+ return {
+ ...event,
+ message: {
+ ...message,
+ content: scrubbed.content,
+ },
+ };
+}
+
+function scrubBufferedTextFromError(
+ event: Record,
+ bufferedText: string,
+ matcher: PlainTextToolCallNameMatcher,
+ contentIndex?: unknown,
+): Record {
+ const error = asRecord(event.error);
+ if (!error) {
+ return event;
+ }
+ const scrubbed = scrubPlainTextToolCallContent(error.content, bufferedText, matcher, {
+ onlyTextIndex: contentIndex,
+ });
+ if (!scrubbed.changed) {
+ return event;
+ }
+ return {
+ ...event,
+ error: {
+ ...error,
+ content: scrubbed.content,
+ },
+ };
+}
+
+function replaceTextContentWithVisibleSuffix(
+ record: Record,
+ visibleText: string,
+ contentIndex?: unknown,
+ matcher?: PlainTextToolCallNameMatcher,
+): Record {
+ if (typeof record.content === "string") {
+ return { ...record, content: visibleText };
+ }
+ if (!Array.isArray(record.content)) {
+ return record;
+ }
+ const originalContent = record.content;
+ if (typeof contentIndex === "number") {
+ const content = originalContent.flatMap((block, index) => {
+ if (index !== contentIndex) {
+ return [block];
+ }
+ const blockRecord = asRecord(block);
+ if (blockRecord?.type !== "text" || typeof blockRecord.text !== "string") {
+ return [block];
+ }
+ if (matcher && !hasExactSerializedToolCallPrefix(blockRecord.text.trimStart(), matcher)) {
+ return [block];
+ }
+ return visibleText.trim() ? [{ ...blockRecord, text: visibleText }] : [];
+ });
+ if (matcher && content.every((block, index) => block === originalContent[index])) {
+ return replaceTextContentWithVisibleSuffix(record, visibleText, undefined, matcher);
+ }
+ return { ...record, content };
+ }
+ const textBlockCount = originalContent.filter((block) => {
+ const blockRecord = asRecord(block);
+ return blockRecord?.type === "text" && typeof blockRecord.text === "string";
+ }).length;
+ if (textBlockCount !== 1) {
+ if (!matcher) {
+ return record;
+ }
+ let replaced = false;
+ const content = originalContent.flatMap((block) => {
+ const blockRecord = asRecord(block);
+ if (blockRecord?.type !== "text" || typeof blockRecord.text !== "string") {
+ return [block];
+ }
+ if (replaced) {
+ return [block];
+ }
+ if (!hasExactSerializedToolCallPrefix(blockRecord.text.trimStart(), matcher)) {
+ return [block];
+ }
+ replaced = true;
+ return visibleText.trim() ? [{ ...blockRecord, text: visibleText }] : [];
+ });
+ return replaced ? { ...record, content } : record;
+ }
+ let replaced = false;
+ const content = originalContent.flatMap((block) => {
+ const blockRecord = asRecord(block);
+ if (blockRecord?.type !== "text" || typeof blockRecord.text !== "string") {
+ return [block];
+ }
+ if (replaced) {
+ return [];
+ }
+ replaced = true;
+ return visibleText.trim() ? [{ ...blockRecord, text: visibleText }] : [];
+ });
+ return { ...record, content };
+}
+
+function scrubReclassifiedMixedTextFromPartial(
+ event: Record,
+ visibleText: string,
+ contentIndex?: unknown,
+ matcher?: PlainTextToolCallNameMatcher,
+): Record {
+ const partial = asRecord(event.partial);
+ if (!partial) {
+ return event;
+ }
+ return {
+ ...event,
+ partial: replaceTextContentWithVisibleSuffix(partial, visibleText, contentIndex, matcher),
+ };
+}
+
+function scrubReclassifiedMixedTextFromError(
+ event: Record,
+ visibleText: string,
+ contentIndex?: unknown,
+ matcher?: PlainTextToolCallNameMatcher,
+): Record {
+ const error = asRecord(event.error);
+ if (!error) {
+ return event;
+ }
+ return {
+ ...event,
+ error: replaceTextContentWithVisibleSuffix(error, visibleText, contentIndex, matcher),
+ };
+}
+
+export function scrubOverCapPlainTextToolCallMessage(params: {
+ candidateText: string | undefined;
+ matcher: PlainTextToolCallNameMatcher;
+ message: unknown;
+}): Record | undefined {
+ const record = asRecord(params.message);
+ const candidateText = params.candidateText;
+ if (!record || !candidateText) {
+ return undefined;
+ }
+ const bufferState = getPlainTextToolCallBufferState(candidateText, params.matcher);
+ if (bufferState === "impossible") {
+ if (candidateText.length <= TEXT_TOOL_CALL_BUFFER_MAX_CHARS) {
+ return undefined;
+ }
+ const visibleText = stripSerializedToolCallPrefixes(candidateText, params.matcher);
+ if (visibleText?.trim() && !Array.isArray(record.content)) {
+ const replaced = replaceTextContentWithVisibleSuffix(
+ record,
+ visibleText,
+ undefined,
+ params.matcher,
+ );
+ if (replaced !== record) {
+ return replaced;
+ }
+ }
+ if (Array.isArray(record.content)) {
+ const overCap = scrubOverCapTextPrefixFromContent(record.content, params.matcher);
+ const stripped = stripOverCapPlainTextToolCallsFromContent(overCap.content, params.matcher);
+ if (!overCap.changed && !stripped.changed) {
+ return undefined;
+ }
+ return {
+ ...record,
+ content: stripped.changed ? stripped.content : overCap.content,
+ };
+ }
+ return undefined;
+ }
+ if (bufferState !== "over-cap") {
+ return undefined;
+ }
+ const scrubbed = scrubPlainTextToolCallContent(record.content, candidateText, params.matcher);
+ return {
+ ...record,
+ content: scrubbed.content,
+ };
+}
+
+function createScrubbedTextDeltaEvent(
+ event: Record,
+ text: string,
+): Record {
+ const partial = asRecord(event.partial);
+ const syntheticContent =
+ typeof event.contentIndex === "number"
+ ? Array.from({ length: event.contentIndex + 1 }, (_, index) => ({
+ type: "text",
+ text: index === event.contentIndex ? text : "",
+ }))
+ : [{ type: "text", text }];
+ const scrubbedPartial = partial
+ ? replaceTextContentWithVisibleSuffix(partial, text, event.contentIndex)
+ : { role: "assistant", content: syntheticContent };
+ const eventWithoutTextEndContent = { ...event };
+ delete eventWithoutTextEndContent.content;
+ return {
+ ...eventWithoutTextEndContent,
+ type: "text_delta",
+ delta: text,
+ partial: scrubbedPartial,
+ };
+}
+
+function appendReclassifiedVisibleDelta(
+ visibleText: string,
+ event: Record,
+): string {
+ return typeof event.delta === "string" ? `${visibleText}${event.delta}` : visibleText;
+}
+
+function isAllowedTextToolCallLikeEvent(
+ event: Record,
+ matcher: PlainTextToolCallNameMatcher,
+): boolean {
+ const text = getTextToolCallEventText(event);
+ return Boolean(text?.trim() && getPlainTextToolCallBufferState(text, matcher) !== "impossible");
+}
+
+function isBufferedTextEvent(bufferedEvent: unknown): boolean {
+ const bufferedRecord = asRecord(bufferedEvent);
+ const bufferedType = typeof bufferedRecord?.type === "string" ? bufferedRecord.type : "";
+ return (
+ bufferedType === "text_start" || bufferedType === "text_delta" || bufferedType === "text_end"
+ );
+}
+
+export async function* normalizePlainTextToolCallStreamEvents(
+ source: AsyncIterable,
+ options: PlainTextToolCallStreamNormalizerOptions,
+): AsyncGenerator {
+ const bufferedEvents: unknown[] = [];
+ let bufferedText = "";
+ let suppressingOverCapTextToolCall = false;
+ let suppressedTextContentIndex: unknown;
+ let hasSuppressedTextContentIndex = false;
+ let reclassifiedMixedTextContentIndex: unknown;
+ let hasReclassifiedMixedTextContentIndex = false;
+ let scrubReclassifiedMixedTextFromDone = false;
+ let reclassifiedMixedVisibleText: string | undefined;
+
+ const flushBufferedEvents = () => {
+ const events = bufferedEvents.splice(0);
+ bufferedText = "";
+ return events;
+ };
+
+ function* flushScrubbedBufferedNonTextEvents(resetBufferedText: boolean) {
+ const events = bufferedEvents.splice(0);
+ const textToScrub = bufferedText;
+ if (resetBufferedText) {
+ bufferedText = "";
+ }
+ for (const bufferedEvent of events) {
+ if (isBufferedTextEvent(bufferedEvent)) {
+ continue;
+ }
+ const bufferedRecord = asRecord(bufferedEvent);
+ yield bufferedRecord
+ ? scrubBufferedTextFromPartial(
+ bufferedRecord,
+ textToScrub,
+ options.matcher,
+ hasSuppressedTextContentIndex ? suppressedTextContentIndex : undefined,
+ { preserveEmptyTextBlocks: suppressingOverCapTextToolCall },
+ )
+ : bufferedEvent;
+ }
+ }
+
+ function* suppressBufferedTextEvents() {
+ suppressingOverCapTextToolCall = true;
+ yield* flushScrubbedBufferedNonTextEvents(false);
+ }
+
+ for await (const event of source) {
+ const record = asRecord(event);
+ if (!record) {
+ yield event;
+ continue;
+ }
+ const type = typeof record.type === "string" ? record.type : "";
+ if (type === "text_start" || type === "text_delta" || type === "text_end") {
+ if (
+ type === "text_end" &&
+ hasReclassifiedMixedTextContentIndex &&
+ record.contentIndex === reclassifiedMixedTextContentIndex
+ ) {
+ continue;
+ }
+ if (
+ scrubReclassifiedMixedTextFromDone &&
+ reclassifiedMixedVisibleText !== undefined &&
+ hasReclassifiedMixedTextContentIndex &&
+ record.contentIndex === reclassifiedMixedTextContentIndex
+ ) {
+ reclassifiedMixedVisibleText = appendReclassifiedVisibleDelta(
+ reclassifiedMixedVisibleText,
+ record,
+ );
+ yield scrubReclassifiedMixedTextFromPartial(
+ record,
+ reclassifiedMixedVisibleText,
+ reclassifiedMixedTextContentIndex,
+ options.matcher,
+ );
+ continue;
+ }
+ if (suppressingOverCapTextToolCall) {
+ if (hasSuppressedTextContentIndex && record.contentIndex !== suppressedTextContentIndex) {
+ if (isAllowedTextToolCallLikeEvent(record, options.matcher)) {
+ continue;
+ }
+ yield scrubBufferedTextFromPartial(
+ record,
+ bufferedText,
+ options.matcher,
+ suppressedTextContentIndex,
+ { preserveEmptyTextBlocks: true },
+ );
+ continue;
+ }
+ const previousBufferedText = bufferedText;
+ const appended = appendSuppressedTextToolCallBuffer(bufferedText, record);
+ bufferedText = appended.text;
+ const shouldRescan =
+ appended.changed &&
+ shouldRescanSuppressedTextToolCallBuffer(previousBufferedText, record);
+ const bufferState = shouldRescan
+ ? getPlainTextToolCallBufferState(appended.scanText, options.matcher)
+ : "over-cap";
+ if (bufferState === "impossible") {
+ const visibleText =
+ stripSerializedToolCallPrefixes(appended.scanText, options.matcher) ?? "";
+ yield* flushScrubbedBufferedNonTextEvents(true);
+ suppressingOverCapTextToolCall = false;
+ suppressedTextContentIndex = undefined;
+ hasSuppressedTextContentIndex = false;
+ reclassifiedMixedTextContentIndex = record.contentIndex;
+ hasReclassifiedMixedTextContentIndex = true;
+ scrubReclassifiedMixedTextFromDone = true;
+ reclassifiedMixedVisibleText = visibleText;
+ if (visibleText.trim()) {
+ yield createScrubbedTextDeltaEvent(record, visibleText);
+ }
+ }
+ continue;
+ }
+ bufferedEvents.push(event);
+ bufferedText = appendTextToolCallBuffer(bufferedText, record);
+ const scanBufferedText = truncateSuppressedTextToolCallBuffer(bufferedText);
+ const scanWasTruncated = scanBufferedText.length !== bufferedText.length;
+ const bufferState = getPlainTextToolCallBufferState(scanBufferedText, options.matcher);
+ if (bufferState === "impossible") {
+ const visibleText =
+ !scanWasTruncated && bufferedText.length > TEXT_TOOL_CALL_BUFFER_MAX_CHARS
+ ? stripSerializedToolCallPrefixes(bufferedText.trimStart(), options.matcher)
+ : null;
+ if (visibleText?.trim()) {
+ yield* flushScrubbedBufferedNonTextEvents(true);
+ reclassifiedMixedTextContentIndex = record.contentIndex;
+ hasReclassifiedMixedTextContentIndex = true;
+ scrubReclassifiedMixedTextFromDone = true;
+ reclassifiedMixedVisibleText = visibleText;
+ yield createScrubbedTextDeltaEvent(record, visibleText);
+ } else if (
+ scanWasTruncated &&
+ stripSerializedToolCallPrefixes(scanBufferedText.trimStart(), options.matcher) !== null
+ ) {
+ bufferedText = scanBufferedText;
+ suppressedTextContentIndex = record.contentIndex;
+ hasSuppressedTextContentIndex = true;
+ yield* suppressBufferedTextEvents();
+ } else {
+ yield* flushBufferedEvents();
+ }
+ } else if (bufferState === "over-cap") {
+ bufferedText = scanBufferedText;
+ suppressedTextContentIndex = record.contentIndex;
+ hasSuppressedTextContentIndex = true;
+ yield* suppressBufferedTextEvents();
+ }
+ continue;
+ }
+
+ if (type === "done") {
+ const normalizedMessage = options.normalizeDoneMessage({
+ message: record.message,
+ reason: record.reason,
+ });
+ if (normalizedMessage?.kind === "promoted") {
+ yield* flushScrubbedBufferedNonTextEvents(true);
+ suppressingOverCapTextToolCall = false;
+ suppressedTextContentIndex = undefined;
+ hasSuppressedTextContentIndex = false;
+ scrubReclassifiedMixedTextFromDone = false;
+ reclassifiedMixedTextContentIndex = undefined;
+ hasReclassifiedMixedTextContentIndex = false;
+ reclassifiedMixedVisibleText = undefined;
+ yield* options.createPromotedToolCallEvents(normalizedMessage.message);
+ yield { ...record, reason: "toolUse", message: normalizedMessage.message };
+ if (options.stopAfterDone) {
+ return;
+ }
+ continue;
+ }
+ if (normalizedMessage?.kind === "scrubbed") {
+ yield* flushScrubbedBufferedNonTextEvents(true);
+ suppressingOverCapTextToolCall = false;
+ suppressedTextContentIndex = undefined;
+ hasSuppressedTextContentIndex = false;
+ scrubReclassifiedMixedTextFromDone = false;
+ reclassifiedMixedTextContentIndex = undefined;
+ hasReclassifiedMixedTextContentIndex = false;
+ reclassifiedMixedVisibleText = undefined;
+ yield { ...record, message: normalizedMessage.message };
+ if (options.stopAfterDone) {
+ return;
+ }
+ continue;
+ }
+ const mixedMessageRecord = scrubReclassifiedMixedTextFromDone
+ ? asRecord(record.message)
+ : undefined;
+ const strippedMixedMessage =
+ mixedMessageRecord && reclassifiedMixedVisibleText !== undefined
+ ? replaceTextContentWithVisibleSuffix(
+ mixedMessageRecord,
+ reclassifiedMixedVisibleText,
+ hasReclassifiedMixedTextContentIndex ? reclassifiedMixedTextContentIndex : undefined,
+ options.matcher,
+ )
+ : undefined;
+ if (strippedMixedMessage) {
+ yield* flushScrubbedBufferedNonTextEvents(true);
+ scrubReclassifiedMixedTextFromDone = false;
+ reclassifiedMixedTextContentIndex = undefined;
+ hasReclassifiedMixedTextContentIndex = false;
+ reclassifiedMixedVisibleText = undefined;
+ yield { ...record, message: strippedMixedMessage };
+ if (options.stopAfterDone) {
+ return;
+ }
+ continue;
+ }
+ if (suppressingOverCapTextToolCall) {
+ const scrubbedDoneEvent = scrubBufferedTextFromMessage(
+ record,
+ bufferedText,
+ options.matcher,
+ hasSuppressedTextContentIndex ? suppressedTextContentIndex : undefined,
+ );
+ yield* flushScrubbedBufferedNonTextEvents(true);
+ suppressingOverCapTextToolCall = false;
+ suppressedTextContentIndex = undefined;
+ hasSuppressedTextContentIndex = false;
+ scrubReclassifiedMixedTextFromDone = false;
+ reclassifiedMixedTextContentIndex = undefined;
+ hasReclassifiedMixedTextContentIndex = false;
+ reclassifiedMixedVisibleText = undefined;
+ yield scrubbedDoneEvent;
+ if (options.stopAfterDone) {
+ return;
+ }
+ continue;
+ }
+ yield* flushBufferedEvents();
+ yield event;
+ if (options.stopAfterDone) {
+ return;
+ }
+ continue;
+ }
+
+ if (type === "error") {
+ if (!suppressingOverCapTextToolCall) {
+ yield* flushBufferedEvents();
+ }
+ yield suppressingOverCapTextToolCall
+ ? scrubBufferedTextFromError(
+ scrubBufferedTextFromPartial(
+ record,
+ bufferedText,
+ options.matcher,
+ hasSuppressedTextContentIndex ? suppressedTextContentIndex : undefined,
+ { preserveEmptyTextBlocks: true },
+ ),
+ bufferedText,
+ options.matcher,
+ hasSuppressedTextContentIndex ? suppressedTextContentIndex : undefined,
+ )
+ : scrubReclassifiedMixedTextFromDone && reclassifiedMixedVisibleText !== undefined
+ ? scrubReclassifiedMixedTextFromError(
+ scrubReclassifiedMixedTextFromPartial(
+ record,
+ reclassifiedMixedVisibleText,
+ hasReclassifiedMixedTextContentIndex
+ ? reclassifiedMixedTextContentIndex
+ : undefined,
+ options.matcher,
+ ),
+ reclassifiedMixedVisibleText,
+ hasReclassifiedMixedTextContentIndex ? reclassifiedMixedTextContentIndex : undefined,
+ options.matcher,
+ )
+ : event;
+ return;
+ }
+
+ if (scrubReclassifiedMixedTextFromDone && reclassifiedMixedVisibleText !== undefined) {
+ yield scrubReclassifiedMixedTextFromPartial(
+ record,
+ reclassifiedMixedVisibleText,
+ hasReclassifiedMixedTextContentIndex ? reclassifiedMixedTextContentIndex : undefined,
+ options.matcher,
+ );
+ continue;
+ }
+
+ if (bufferedEvents.length > 0 && !suppressingOverCapTextToolCall) {
+ bufferedEvents.push(event);
+ continue;
+ }
+
+ yield suppressingOverCapTextToolCall
+ ? scrubBufferedTextFromPartial(
+ record,
+ bufferedText,
+ options.matcher,
+ hasSuppressedTextContentIndex ? suppressedTextContentIndex : undefined,
+ { preserveEmptyTextBlocks: suppressingOverCapTextToolCall },
+ )
+ : event;
+ }
+
+ if (!suppressingOverCapTextToolCall) {
+ yield* flushBufferedEvents();
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d024217ffa4..5a95660c753 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1855,6 +1855,8 @@ importers:
packages/web-content-core: {}
+ packages/tool-call-repair: {}
+
ui:
dependencies:
'@create-markdown/preview':
diff --git a/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts
index 31e50629c33..963d4b7dfe9 100644
--- a/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts
+++ b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts
@@ -1,5 +1,5 @@
+import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js";
import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js";
-import { stripPlainTextToolCallBlocks } from "../../plugin-sdk/tool-payload.js";
import {
extractLeadingHttpStatus,
formatRawAssistantErrorForUi,
diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts
index 165af7fd38e..cbdacb254e2 100644
--- a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts
+++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts
@@ -1,13 +1,51 @@
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
sanitizeOpenAIResponsesReplayForStream,
sanitizeReplayToolCallIdsForStream,
shouldApplyReplayToolCallIdSanitizer,
+ wrapStreamFnPromoteStandaloneTextToolCalls,
} from "./attempt.tool-call-normalization.js";
type AssistantMessage = Extract;
type ToolResultMessage = Extract;
+type FakeWrappedStream = {
+ result: () => Promise;
+ [Symbol.asyncIterator]: () => AsyncIterator;
+};
+
+function createFakeStream(params: {
+ events: unknown[];
+ resultMessage: unknown;
+}): FakeWrappedStream {
+ return {
+ async result() {
+ return params.resultMessage;
+ },
+ [Symbol.asyncIterator]() {
+ return (async function* () {
+ for (const event of params.events) {
+ yield event;
+ }
+ })();
+ },
+ };
+}
+
+async function collectStreamEvents(stream: AsyncIterable): Promise {
+ const events: unknown[] = [];
+ for await (const event of stream) {
+ events.push(event);
+ }
+ return events;
+}
+
+function requireRecord(value: unknown, label: string): Record {
+ if (!value || typeof value !== "object") {
+ throw new Error(`expected ${label}`);
+ }
+ return value as Record;
+}
function requireAssistantMessage(message: AgentMessage | undefined): AssistantMessage {
if (!message || message.role !== "assistant") {
@@ -50,6 +88,731 @@ function toolResultSummary(message: AgentMessage | undefined) {
};
}
+describe("wrapStreamFnPromoteStandaloneTextToolCalls", () => {
+ it("promotes standalone serialized parameter XML text to structured tool calls", async () => {
+ const rawToolText = [
+ "[tool:exec]",
+ "",
+ "cat /proc/mounts 2>/dev/null | head -20",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "find / -maxdepth 4 -type d 2>/dev/null | head -20",
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "Need to audit the mount." },
+ { type: "text", text: rawToolText },
+ ],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "start", partial: { content: [] } },
+ {
+ type: "text_start",
+ contentIndex: 1,
+ partial: { content: [{ type: "text", text: "" }] },
+ },
+ { type: "text_delta", contentIndex: 1, delta: rawToolText },
+ { type: "text_end", contentIndex: 1, content: rawToolText },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+ const result = requireRecord(await stream.result(), "result message");
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "start",
+ "toolcall_start",
+ "toolcall_delta",
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ expect(requireRecord(events.at(-1), "done").reason).toBe("toolUse");
+ expect(result.stopReason).toBe("toolUse");
+ const content = result.content as Array>;
+ expect(content).toHaveLength(3);
+ expect(content[0]).toEqual({ type: "thinking", thinking: "Need to audit the mount." });
+ expect(content[1]).toMatchObject({
+ type: "toolCall",
+ name: "exec",
+ arguments: { command: "cat /proc/mounts 2>/dev/null | head -20" },
+ partialArgs: '{"command":"cat /proc/mounts 2>/dev/null | head -20"}',
+ });
+ expect(String(content[1].id)).toMatch(/^call_[a-f0-9]{24}$/);
+ expect(content[2]).toMatchObject({
+ type: "toolCall",
+ name: "exec",
+ arguments: { command: "find / -maxdepth 4 -type d 2>/dev/null | head -20" },
+ });
+ });
+
+ it("preserves content indexes when promoting text before thinking", async () => {
+ const rawToolText = [
+ "[tool:exec]",
+ "",
+ "pwd",
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [
+ { type: "text", text: rawToolText },
+ { type: "thinking", thinking: "Need the current directory." },
+ ],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: rawToolText },
+ {
+ type: "thinking_delta",
+ contentIndex: 1,
+ delta: "Need the current directory.",
+ partial: {
+ content: [
+ { type: "text", text: rawToolText },
+ { type: "thinking", thinking: "Need the current directory." },
+ ],
+ },
+ },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+ const result = requireRecord(await stream.result(), "result message");
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "thinking_delta",
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ expect(requireRecord(events[0], "thinking event").contentIndex).toBe(1);
+ expect(requireRecord(events[1], "toolcall start").contentIndex).toBe(0);
+ expect((result.content as Array>).map((block) => block.type)).toEqual([
+ "toolCall",
+ "thinking",
+ ]);
+ });
+
+ it("preserves intervening thinking when promoting multiple text blocks", async () => {
+ const firstRawToolText = [
+ "[tool:exec]",
+ "",
+ "pwd",
+ "",
+ "",
+ ].join("\n");
+ const secondRawToolText = [
+ "[tool:exec]",
+ "",
+ "whoami",
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [
+ { type: "text", text: firstRawToolText },
+ { type: "thinking", thinking: "Need one more check." },
+ { type: "text", text: secondRawToolText },
+ ],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: firstRawToolText },
+ {
+ type: "thinking_delta",
+ contentIndex: 1,
+ delta: "Need one more check.",
+ partial: {
+ content: [
+ { type: "text", text: firstRawToolText },
+ { type: "thinking", thinking: "Need one more check." },
+ { type: "text", text: secondRawToolText },
+ ],
+ },
+ },
+ { type: "text_delta", contentIndex: 2, delta: secondRawToolText },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+ const result = requireRecord(await stream.result(), "result message");
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "thinking_delta",
+ "toolcall_start",
+ "toolcall_delta",
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ expect(requireRecord(events[0], "thinking event").contentIndex).toBe(1);
+ expect(requireRecord(events[1], "first toolcall start").contentIndex).toBe(0);
+ expect(requireRecord(events[3], "second toolcall start").contentIndex).toBe(2);
+ expect((result.content as Array>).map((block) => block.type)).toEqual([
+ "toolCall",
+ "thinking",
+ "toolCall",
+ ]);
+ expect(requireRecord((result.content as unknown[])[0], "first tool call")).toMatchObject({
+ name: "exec",
+ arguments: { command: "pwd" },
+ });
+ expect(requireRecord((result.content as unknown[])[2], "second tool call")).toMatchObject({
+ name: "exec",
+ arguments: { command: "whoami" },
+ });
+ });
+
+ it("promotes serialized tool calls split across adjacent text blocks", async () => {
+ const resultMessage = {
+ role: "assistant",
+ content: [
+ { type: "text", text: "[tool:exec]\n\n" },
+ { type: "text", text: "pwd\n\n" },
+ { type: "thinking", thinking: "Checking location." },
+ ],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: "[tool:exec]\n\n" },
+ { type: "text_delta", contentIndex: 1, delta: "pwd\n\n" },
+ {
+ type: "thinking_delta",
+ contentIndex: 2,
+ delta: "Checking location.",
+ partial: { content: resultMessage.content },
+ },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+ const result = requireRecord(await stream.result(), "result message");
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "thinking_delta",
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ expect(requireRecord(events[0], "thinking event").contentIndex).toBe(2);
+ expect(requireRecord(events[1], "toolcall start").contentIndex).toBe(0);
+ expect((result.content as Array>).map((block) => block.type)).toEqual([
+ "toolCall",
+ "thinking",
+ ]);
+ expect(requireRecord((result.content as unknown[])[0], "tool call")).toMatchObject({
+ name: "exec",
+ arguments: { command: "pwd" },
+ });
+ });
+
+ it("buffers case-insensitive tool-name prefixes until final promotion", async () => {
+ const rawToolText = [
+ "[tool:read]",
+ "",
+ "src/index.ts",
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [{ type: "text", text: rawToolText }],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: "[tool:rea" },
+ { type: "text_delta", contentIndex: 0, delta: rawToolText.slice("[tool:rea".length) },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["Read"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+ const result = requireRecord(await stream.result(), "result message");
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ expect(result.stopReason).toBe("toolUse");
+ expect(requireRecord((result.content as unknown[])[0], "tool call")).toMatchObject({
+ type: "toolCall",
+ name: "Read",
+ arguments: { path: "src/index.ts" },
+ });
+ });
+
+ it("buffers normalized alias tool-name prefixes until final promotion", async () => {
+ const rawToolText = [
+ "[tool:bash]",
+ "",
+ "pwd",
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [{ type: "text", text: rawToolText }],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: "[tool:ba" },
+ { type: "text_delta", contentIndex: 0, delta: rawToolText.slice("[tool:ba".length) },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+ const result = requireRecord(await stream.result(), "result message");
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ expect(requireRecord((result.content as unknown[])[0], "tool call")).toMatchObject({
+ type: "toolCall",
+ name: "exec",
+ arguments: { command: "pwd" },
+ });
+ });
+
+ it("keeps possible tool-call text buffered across interleaved non-text events", async () => {
+ const rawToolText = [
+ "[tool:exec]",
+ "",
+ "pwd",
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "Need shell state." },
+ { type: "text", text: rawToolText },
+ ],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 1, delta: rawToolText },
+ {
+ type: "thinking_delta",
+ contentIndex: 0,
+ delta: "Need shell state.",
+ partial: {
+ content: [
+ { type: "thinking", thinking: "Need shell state." },
+ { type: "text", text: rawToolText },
+ ],
+ },
+ },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "thinking_delta",
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ const thinkingEvent = requireRecord(events[0], "thinking event");
+ expect(requireRecord(thinkingEvent.partial, "thinking partial").content).toEqual([
+ { type: "thinking", thinking: "Need shell state." },
+ ]);
+ expect(JSON.stringify(events)).not.toContain(rawToolText);
+ });
+
+ it("preserves interleaved event content indexes when buffered text is scrubbed first", async () => {
+ const rawToolText = [
+ "[tool:exec]",
+ "",
+ "pwd",
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [
+ { type: "text", text: rawToolText },
+ { type: "thinking", thinking: "Need shell state." },
+ ],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: rawToolText },
+ {
+ type: "thinking_delta",
+ contentIndex: 1,
+ delta: "Need shell state.",
+ partial: {
+ content: [
+ { type: "text", text: rawToolText },
+ { type: "thinking", thinking: "Need shell state." },
+ ],
+ },
+ },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "thinking_delta",
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ const thinkingEvent = requireRecord(events[0], "thinking event");
+ expect(thinkingEvent.contentIndex).toBe(1);
+ expect(requireRecord(thinkingEvent.partial, "thinking partial").content).toEqual([
+ { type: "text", text: "" },
+ { type: "thinking", thinking: "Need shell state." },
+ ]);
+ expect(JSON.stringify(events)).not.toContain(rawToolText);
+ });
+
+ it("closes the underlying stream iterator when consumers stop early", async () => {
+ const returnIterator = vi.fn(async () => ({ done: true, value: undefined }));
+ const nextIterator = vi
+ .fn()
+ .mockResolvedValueOnce({ done: false, value: { type: "start", partial: { content: [] } } })
+ .mockResolvedValue({ done: true, value: undefined });
+ const baseFn = vi.fn(() => ({
+ async result() {
+ return { role: "assistant", content: [], stopReason: "stop" };
+ },
+ [Symbol.asyncIterator]() {
+ return {
+ next: nextIterator,
+ return: returnIterator,
+ };
+ },
+ }));
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+ const iterator = stream[Symbol.asyncIterator]();
+
+ expect(await iterator.next()).toEqual({
+ done: false,
+ value: { type: "start", partial: { content: [] } },
+ });
+ await iterator.return?.();
+
+ expect(returnIterator).toHaveBeenCalledTimes(1);
+ });
+
+ it("flushes buffered text before terminal error events", async () => {
+ const rawToolText = "[tool:exec]";
+ const errorEvent = { type: "error", error: new Error("stream failed") };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [{ type: "text_delta", contentIndex: 0, delta: rawToolText }, errorEvent],
+ resultMessage: { role: "assistant", content: [], stopReason: "stop" },
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+
+ expect(events).toEqual([
+ { type: "text_delta", contentIndex: 0, delta: rawToolText },
+ errorEvent,
+ ]);
+ });
+
+ it("buffers split XML function markers until final promotion", async () => {
+ const rawToolText = [
+ "",
+ "",
+ "pwd",
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [{ type: "text", text: rawToolText }],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: "<" },
+ { type: "text_delta", contentIndex: 0, delta: rawToolText.slice(1) },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "toolcall_start",
+ "toolcall_delta",
+ "done",
+ ]);
+ });
+
+ it("suppresses over-cap serialized XMLish text instead of flushing it", async () => {
+ const rawToolText = [
+ "[tool:exec]",
+ "",
+ "x".repeat(256_001),
+ "",
+ "",
+ ].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [{ type: "text", text: rawToolText }],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "start", partial: { content: [] } },
+ {
+ type: "text_start",
+ contentIndex: 0,
+ partial: { content: [{ type: "text", text: "" }] },
+ },
+ { type: "text_delta", contentIndex: 0, delta: rawToolText },
+ {
+ type: "thinking_delta",
+ contentIndex: 1,
+ delta: "still thinking",
+ partial: {
+ content: [
+ { type: "text", text: rawToolText },
+ { type: "thinking", thinking: "still thinking" },
+ ],
+ },
+ },
+ { type: "text_end", contentIndex: 0, content: rawToolText },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+ const result = requireRecord(await stream.result(), "result message");
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "start",
+ "thinking_delta",
+ "done",
+ ]);
+ const thinkingEvent = requireRecord(events[1], "thinking event");
+ expect(requireRecord(thinkingEvent.partial, "thinking partial").content).toEqual([
+ { type: "text", text: "" },
+ { type: "thinking", thinking: "still thinking" },
+ ]);
+ const doneEvent = requireRecord(events[2], "done event");
+ expect(doneEvent.reason).toBe("stop");
+ expect(doneEvent.message).toMatchObject({
+ role: "assistant",
+ content: [],
+ stopReason: "stop",
+ });
+ expect(result).toMatchObject({ role: "assistant", content: [], stopReason: "stop" });
+ expect(JSON.stringify(events)).not.toContain("[tool:exec]");
+ expect(JSON.stringify(result)).not.toContain("[tool:exec]");
+ });
+
+ it("scrubs split over-cap serialized XMLish text blocks from done messages", async () => {
+ const rawToolTextParts = [
+ "[tool:exec]\n",
+ ["x".repeat(256_001), "", ""].join("\n"),
+ ];
+ const resultMessage = {
+ role: "assistant",
+ content: rawToolTextParts.map((text) => ({ type: "text", text })),
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [{ type: "done", reason: "stop", message: resultMessage }],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+ const result = requireRecord(await stream.result(), "result message");
+
+ expect(requireRecord(events[0], "done event").message).toMatchObject({
+ role: "assistant",
+ content: [],
+ stopReason: "stop",
+ });
+ expect(result).toMatchObject({ role: "assistant", content: [], stopReason: "stop" });
+ expect(JSON.stringify(events)).not.toContain("[tool:exec]");
+ expect(JSON.stringify(result)).not.toContain("");
+ });
+
+ it("preserves visible suffix text after an over-cap JSON tool payload", async () => {
+ const visibleSuffix = "Visible answer after oversized JSON.";
+ const rawText = [`[tool:exec] {"command":"${"x".repeat(256_001)}"}`, visibleSuffix].join("\n");
+ const resultMessage = {
+ role: "assistant",
+ content: [{ type: "text", text: rawText }],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: rawText },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+
+ expect(events.map((event) => requireRecord(event, "event").type)).toEqual([
+ "text_delta",
+ "done",
+ ]);
+ const textEvent = requireRecord(events[0], "text event");
+ expect(String(textEvent.delta)).toBe(visibleSuffix);
+ expect(requireRecord(textEvent.partial, "text partial").content).toEqual([
+ { type: "text", text: visibleSuffix },
+ ]);
+ expect(JSON.stringify(events)).not.toContain("[tool:exec]");
+ });
+
+ it("does not buffer normal prose that starts like a final answer", async () => {
+ const resultMessage = {
+ role: "assistant",
+ content: [{ type: "text", text: "Finally, the audit is done." }],
+ stopReason: "stop",
+ };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ { type: "text_delta", contentIndex: 0, delta: "Finally, the audit is done." },
+ { type: "done", reason: "stop", message: resultMessage },
+ ],
+ resultMessage,
+ }),
+ );
+ const wrapped = wrapStreamFnPromoteStandaloneTextToolCalls(baseFn as never, new Set(["exec"]));
+ const stream = (await Promise.resolve(
+ wrapped({} as never, {} as never, {} as never),
+ )) as FakeWrappedStream;
+
+ const events = await collectStreamEvents(stream);
+
+ expect(events).toEqual([
+ { type: "text_delta", contentIndex: 0, delta: "Finally, the audit is done." },
+ { type: "done", reason: "stop", message: resultMessage },
+ ]);
+ });
+});
+
describe("sanitizeReplayToolCallIdsForStream", () => {
it("skips strict stream id sanitization when provider policy opts out", () => {
expect(
diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts
index ef00e5e71cd..f69421afa3a 100644
--- a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts
+++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts
@@ -1,5 +1,15 @@
+import { randomUUID } from "node:crypto";
+import {
+ extractStandalonePlainTextToolCallText,
+ normalizePlainTextToolCallStreamEvents,
+ promoteStandalonePlainTextToolCallMessage as promotePlainTextToolCallMessage,
+ scrubOverCapPlainTextToolCallMessage,
+ type PlainTextToolCallBlock,
+ type PlainTextToolCallNameMatcher,
+} from "../../../../packages/tool-call-repair/src/index.js";
import { visitObjectContentBlocks } from "../../../shared/message-content-blocks.js";
import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js";
+import { normalizeStringEntries } from "../../../shared/string-normalization.js";
import {
downgradeOpenAIFunctionCallReasoningPairs,
downgradeOpenAIReasoningBlocks,
@@ -9,14 +19,13 @@ import {
} from "../../embedded-agent-helpers.js";
import type { AgentMessage, StreamFn } from "../../runtime/index.js";
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
-import type { MutableAssistantMessageEventStream } from "../../stream-compat.js";
import {
extractToolCallsFromAssistant,
extractToolResultIds,
sanitizeToolCallIdsForCloudCodeAssist,
type ToolCallIdMode,
} from "../../tool-call-id.js";
-import { normalizeToolName } from "../../tool-policy.js";
+import { couldNormalizeToolNamePrefixToAllowedTool, normalizeToolName } from "../../tool-policy.js";
import { shouldAllowProviderOwnedThinkingReplay } from "../../transcript-policy.js";
import type { TranscriptPolicy } from "../../transcript-policy.js";
import { wrapStreamObjectEvents } from "./stream-wrapper.js";
@@ -28,6 +37,7 @@ type UnknownToolLoopGuardState = {
count: number;
countedMessages: WeakSet