返回源码地图

packages/ai/src/utils/transcript.ts

v1.0.0 · a13d35a742c6 · 04:system 与工具声明重放

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import type { Context, Message, SystemMessage, Tool, ToolReference, TranscriptContext } from "../types.ts";
2import { contentText, getSystemMessageText } from "./text.ts";
3
4export type { TranscriptContext } from "../types.ts";
5
6/**
7 * Build the leading system message for a prompt and tool set. Returns undefined when
8 * both are empty, so an empty transcript stays empty.
9 */
10export function createInitialSystemMessage(
11 systemPrompt: string | undefined,
12 tools: Tool[] | undefined,
13): SystemMessage | undefined {
14 const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;
15 const hasTools = tools !== undefined && tools.length > 0;
16 if (!hasSystemPrompt && !hasTools) return undefined;
17 return {
18 role: "system",
19 content: systemPrompt ?? "",
20 ...(hasTools ? { toolsAdded: tools } : {}),
21 timestamp: 0,
22 };
23}
24
25/**
26 * Fold `Context.systemPrompt` and `Context.tools` into a leading system message.
27 * This is the only entry point that produces a {@link TranscriptContext}; every
28 * provider-facing function expects the result.
29 */
30export function normalizeContext(context: Context): TranscriptContext {
31 const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);
32 const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages;
33 return { messages } as TranscriptContext;
34}
35
36/**
37 * Any message list. The replay helpers only read entries whose role is `"system"`, so
38 * agent transcripts that carry custom message roles can be passed without filtering.
39 */
40export type TranscriptMessages = readonly { role: string }[];
41
42function isSystemMessage(message: { role: string }): message is SystemMessage {
43 return message.role === "system";
44}
45
46/** Return the leading system message, if the transcript starts with one. */
47export function getInitialSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
48 const first = messages[0];
49 return first && isSystemMessage(first) ? first : undefined;
50}
51
52/** Drop the leading system message for APIs that carry the prompt outside the message list. */
53export function withoutInitialSystemMessage(messages: Message[]): Message[] {
54 return getInitialSystemMessage(messages) ? messages.slice(1) : messages;
55}
56
57/** Resolve the tools available after applying every transcript delta in order. */
58export function getCurrentTools(messages: TranscriptMessages): Tool[] {
59 const tools = new Map<string, Tool>();
60 for (const message of messages) {
61 if (!isSystemMessage(message)) continue;
62 for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name);
63 for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool);
64 }
65 return [...tools.values()];
66}
67
68/**
69 * Replay every system message into one leading system message holding the current
70 * prompt and tools. Later `content` is appended to the base prompt, `sections` are
71 * patched by name, and tools are resolved with {@link getCurrentTools}.
72 */
73export function getCurrentSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
74 const content: string[] = [];
75 const sections = new Map<string, string>();
76 let timestamp: number | undefined;
77 for (const message of messages) {
78 if (!isSystemMessage(message)) continue;
79 timestamp ??= message.timestamp;
80 const text = contentText(message.content);
81 if (text.length > 0) content.push(text);
82 for (const [name, value] of Object.entries(message.sections ?? {})) {
83 if (value === null) sections.delete(name);
84 else sections.set(name, value);
85 }
86 }
87 const tools = getCurrentTools(messages);
88 if (timestamp === undefined && tools.length === 0) return undefined;
89 return {
90 role: "system",
91 content: content.join("\n\n"),
92 ...(sections.size > 0 ? { sections: Object.fromEntries(sections) } : {}),
93 ...(tools.length > 0 ? { toolsAdded: tools } : {}),
94 timestamp: timestamp ?? 0,
95 };
96}
97
98/** Render the current system prompt text after replaying every system message. */
99export function getCurrentSystemPrompt(messages: TranscriptMessages): string {
100 const message = getCurrentSystemMessage(messages);
101 return message ? getSystemMessageText(message) : "";
102}
103
104/**
105 * Rebuild the transcript for APIs without mid-conversation system messages: the replayed
106 * system message leads, and every later system message is dropped.
107 */
108export function collapseSystemMessages(context: TranscriptContext): TranscriptContext {
109 const head = getCurrentSystemMessage(context.messages);
110 const messages = context.messages.filter((message) => message.role !== "system");
111 return { messages: head ? [head, ...messages] : messages } as TranscriptContext;
112}
113
114/** Keep later system messages in place when the model accepts them; otherwise collapse them. */
115export function resolveTranscript(
116 context: TranscriptContext,
117 supportsMidConvoSystemMessages: boolean | undefined,
118): TranscriptContext {
119 return supportsMidConvoSystemMessages ? context : collapseSystemMessages(context);
120}
121
122/** Strip executable and display-only fields from a tool before transcript comparison or persistence. */
123export function toToolDeclaration(tool: Tool): Tool {
124 return {
125 name: tool.name,
126 description: tool.description,
127 parameters: JSON.parse(JSON.stringify(tool.parameters)) as Tool["parameters"],
128 ...(tool.constrainedSampling === undefined ? {} : { constrainedSampling: tool.constrainedSampling }),
129 };
130}
131
132/**
133 * Whether two tools declare the same interface to the model.
134 *
135 * Both sides go through {@link toToolDeclaration} first: its JSON round-trip drops the
136 * typebox symbol keys and `undefined` fields that a structural comparison would see, and
137 * builds both objects with the same key order, so comparing the serialized declarations
138 * is exact. This avoids a deep-equal dependency in a browser-safe package.
139 */
140export function declarationsEqual(left: Tool, right: Tool): boolean {
141 return JSON.stringify(toToolDeclaration(left)) === JSON.stringify(toToolDeclaration(right));
142}
143
144export interface ToolStateChanges {
145 toolsAdded: Tool[];
146 toolsRemoved: ToolReference[];
147}
148
149/** Compare two complete tool states. A changed definition is a removal followed by an addition. */
150export function getToolStateChanges(previous: readonly Tool[], current: readonly Tool[]): ToolStateChanges {
151 const previousTools = new Map(previous.map((tool) => [tool.name, tool]));
152 const currentTools = new Map(current.map((tool) => [tool.name, tool]));
153 return {
154 toolsAdded: current
155 .filter((tool) => {
156 const previousTool = previousTools.get(tool.name);
157 return previousTool === undefined || !declarationsEqual(previousTool, tool);
158 })
159 .map(toToolDeclaration),
160 toolsRemoved: previous
161 .filter((tool) => {
162 const currentTool = currentTools.get(tool.name);
163 return currentTool === undefined || !declarationsEqual(tool, currentTool);
164 })
165 .map((tool) => ({ name: tool.name })),
166 };
167}
168
169/** Every definition referenced by transcript tool state, in first-declaration order. */
170export function getDeclaredTools(messages: TranscriptMessages): Tool[] {
171 const definitions = new Map<string, Tool>();
172 for (const message of messages) {
173 if (!isSystemMessage(message)) continue;
174 for (const tool of message.toolsAdded ?? []) definitions.set(tool.name, tool);
175 }
176 return [...definitions.values()];
177}
178
179/**
180 * Whether a tool name was declared twice with different definitions. Transports that
181 * reference tools by name (Anthropic `tool_addition`/`tool_removal`) cannot express that.
182 */
183export function hasToolRedefinitions(messages: TranscriptMessages): boolean {
184 const declared = new Map<string, Tool>();
185 for (const message of messages) {
186 if (!isSystemMessage(message)) continue;
187 for (const tool of message.toolsAdded ?? []) {
188 const previous = declared.get(tool.name);
189 if (previous !== undefined && !declarationsEqual(previous, tool)) return true;
190 declared.set(tool.name, tool);
191 }
192 }
193 return false;
194}
195
196/** Whether tool history contains a removal or same-name redeclaration that an addition-only transport cannot replay. */
197export function hasNonAdditiveToolChanges(messages: TranscriptMessages): boolean {
198 const declared = new Set<string>();
199 for (const message of messages) {
200 if (!isSystemMessage(message)) continue;
201 if ((message.toolsRemoved?.length ?? 0) > 0) return true;
202 for (const tool of message.toolsAdded ?? []) {
203 if (declared.has(tool.name)) return true;
204 declared.add(tool.name);
205 }
206 }
207 return false;
208}
209
210export interface TranscriptTools {
211 /** Tools sent in the top-level request field. */
212 requestTools: Tool[];
213 /**
214 * Whether later system messages carry their own `toolsAdded` as in-place additions.
215 * When false, `requestTools` already holds the complete current tool set.
216 */
217 anchorsAdditions: boolean;
218}
219
220/**
221 * Split tool declarations between the top-level request field and in-place additions.
222 * Transports that can anchor additions at a system message keep the initial tools at the
223 * top and load later ones where they appear; that only works when no tool was removed or
224 * redeclared, so everything else sends the current tool list.
225 */
226export function resolveTranscriptTools(messages: TranscriptMessages, supportsToolAdditions: boolean): TranscriptTools {
227 const anchorsAdditions = supportsToolAdditions && !hasNonAdditiveToolChanges(messages);
228 return {
229 requestTools: anchorsAdditions
230 ? (getInitialSystemMessage(messages)?.toolsAdded ?? [])
231 : getCurrentTools(messages),
232 anchorsAdditions,
233 };
234}