




















@@ -0,0 +1,247 @@
1+import { randomUUID } from "node:crypto";
2+import type { StreamFn } from "@earendil-works/pi-agent-core";
3+import { createAssistantMessageEventStream, streamSimple } from "@earendil-works/pi-ai";
4+import { parseStandalonePlainTextToolCallBlocks } from "./tool-payload.js";
5+6+function toRecord(value: unknown): Record<string, unknown> | undefined {
7+return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
8+}
9+10+function resolveContextToolNames(context: Parameters<StreamFn>[1]): Set<string> {
11+const tools = (context as { tools?: unknown }).tools;
12+if (!Array.isArray(tools)) {
13+return new Set();
14+}
15+const names = tools
16+.map((tool) => {
17+const record = toRecord(tool);
18+return typeof record?.name === "string" && record.name.trim() ? record.name : undefined;
19+})
20+.filter((name): name is string => Boolean(name));
21+return new Set(names);
22+}
23+24+function couldStillBePlainTextToolCall(text: string): boolean {
25+if (text.length > 256_000) {
26+return false;
27+}
28+const trimmed = text.trimStart();
29+return (
30+trimmed.length === 0 ||
31+trimmed.startsWith("[") ||
32+trimmed.startsWith("<|channel|>") ||
33+trimmed.startsWith("commentary") ||
34+trimmed.startsWith("analysis") ||
35+trimmed.startsWith("final")
36+);
37+}
38+39+function createSyntheticToolCallId(): string {
40+return `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
41+}
42+43+function createPlainTextToolCallBlock(parsed: {
44+arguments: Record<string, unknown>;
45+name: string;
46+}): Record<string, unknown> {
47+return {
48+type: "toolCall",
49+id: createSyntheticToolCallId(),
50+name: parsed.name,
51+arguments: parsed.arguments,
52+partialArgs: JSON.stringify(parsed.arguments),
53+};
54+}
55+56+function promotePlainTextToolCalls(
57+message: unknown,
58+toolNames: Set<string>,
59+): Record<string, unknown> | undefined {
60+const messageRecord = toRecord(message);
61+if (!messageRecord) {
62+return undefined;
63+}
64+if (!Array.isArray(messageRecord.content)) {
65+if (typeof messageRecord.content !== "string" || !messageRecord.content.trim()) {
66+return undefined;
67+}
68+const parsed = parseStandalonePlainTextToolCallBlocks(messageRecord.content, {
69+allowedToolNames: toolNames,
70+});
71+if (!parsed) {
72+return undefined;
73+}
74+return {
75+ ...messageRecord,
76+content: parsed.map(createPlainTextToolCallBlock),
77+stopReason: "toolUse",
78+};
79+}
80+if (
81+messageRecord.content.some((block) => toRecord(block)?.type === "toolCall") ||
82+messageRecord.content.length === 0
83+) {
84+return undefined;
85+}
86+87+let promoted = false;
88+const nextContent: Array<Record<string, unknown>> = [];
89+for (const block of messageRecord.content) {
90+const blockRecord = toRecord(block);
91+if (!blockRecord) {
92+return undefined;
93+}
94+if (blockRecord.type !== "text") {
95+nextContent.push(blockRecord);
96+continue;
97+}
98+const text = typeof blockRecord.text === "string" ? blockRecord.text : "";
99+if (!text.trim()) {
100+continue;
101+}
102+const parsed = parseStandalonePlainTextToolCallBlocks(text, {
103+allowedToolNames: toolNames,
104+});
105+if (!parsed) {
106+return undefined;
107+}
108+nextContent.push(...parsed.map(createPlainTextToolCallBlock));
109+promoted = true;
110+}
111+112+if (!promoted) {
113+return undefined;
114+}
115+return {
116+ ...messageRecord,
117+content: nextContent,
118+stopReason: "toolUse",
119+};
120+}
121+122+function emitPromotedToolCallEvents(
123+stream: { push(event: unknown): void },
124+message: Record<string, unknown>,
125+): void {
126+const content = Array.isArray(message.content) ? message.content : [];
127+content.forEach((block, contentIndex) => {
128+const record = toRecord(block);
129+if (record?.type !== "toolCall") {
130+return;
131+}
132+stream.push({ type: "toolcall_start", contentIndex, partial: message });
133+stream.push({
134+type: "toolcall_delta",
135+ contentIndex,
136+delta: typeof record.partialArgs === "string" ? record.partialArgs : "{}",
137+partial: message,
138+});
139+});
140+}
141+142+function wrapPlainTextToolCallStream(
143+source: ReturnType<StreamFn>,
144+context: Parameters<StreamFn>[1],
145+): ReturnType<StreamFn> {
146+const toolNames = resolveContextToolNames(context);
147+if (toolNames.size === 0) {
148+return source;
149+}
150+const output = createAssistantMessageEventStream();
151+const stream = output as unknown as { push(event: unknown): void; end(): void };
152+153+void (async () => {
154+const bufferedTextEvents: unknown[] = [];
155+let bufferedText = "";
156+let ended = false;
157+const endStream = () => {
158+if (!ended) {
159+ended = true;
160+stream.end();
161+}
162+};
163+const flushBufferedTextEvents = () => {
164+for (const event of bufferedTextEvents.splice(0)) {
165+stream.push(event);
166+}
167+bufferedText = "";
168+};
169+170+try {
171+for await (const event of source as AsyncIterable<unknown>) {
172+const record = toRecord(event);
173+const type = typeof record?.type === "string" ? record.type : "";
174+175+if (type === "text_start" || type === "text_delta" || type === "text_end") {
176+bufferedTextEvents.push(event);
177+if (typeof record?.delta === "string") {
178+bufferedText += record.delta;
179+} else if (typeof record?.content === "string" && !bufferedText) {
180+bufferedText = record.content;
181+}
182+if (!couldStillBePlainTextToolCall(bufferedText)) {
183+flushBufferedTextEvents();
184+}
185+continue;
186+}
187+188+if (type === "done") {
189+const promotedMessage = promotePlainTextToolCalls(record?.message, toolNames);
190+if (promotedMessage) {
191+bufferedTextEvents.splice(0);
192+bufferedText = "";
193+emitPromotedToolCallEvents(stream, promotedMessage);
194+stream.push({ ...record, reason: "toolUse", message: promotedMessage });
195+} else {
196+flushBufferedTextEvents();
197+stream.push(event);
198+}
199+endStream();
200+return;
201+}
202+203+flushBufferedTextEvents();
204+stream.push(event);
205+if (type === "error") {
206+endStream();
207+return;
208+}
209+}
210+flushBufferedTextEvents();
211+} catch (error) {
212+stream.push({
213+type: "error",
214+reason: "error",
215+error: {
216+role: "assistant",
217+content: [],
218+stopReason: "error",
219+errorMessage: error instanceof Error ? error.message : String(error),
220+},
221+});
222+} finally {
223+endStream();
224+}
225+})();
226+227+return output as ReturnType<StreamFn>;
228+}
229+230+/**
231+ * Bundled-provider runtime hygiene for providers that can leak tool-use syntax
232+ * as assistant text even when native tool calling is enabled.
233+ */
234+export function createPlainTextToolCallPromotionWrapper(
235+baseStreamFn: StreamFn | undefined,
236+): StreamFn {
237+const underlying = baseStreamFn ?? streamSimple;
238+return (model, context, options) => {
239+const maybeStream = underlying(model, context, options);
240+if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
241+return Promise.resolve(maybeStream).then((stream) =>
242+wrapPlainTextToolCallStream(stream, context),
243+) as ReturnType<StreamFn>;
244+}
245+return wrapPlainTextToolCallStream(maybeStream, context);
246+};
247+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。