chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
237
third_party/pi/packages/ai/src/utils/transcript.ts
vendored
Normal file
237
third_party/pi/packages/ai/src/utils/transcript.ts
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { Context, Message, SystemMessage, Tool, ToolReference, TranscriptContext } from "../types.ts";
|
||||
import { contentText, getSystemMessageText } from "./text.ts";
|
||||
|
||||
export type { TranscriptContext } from "../types.ts";
|
||||
|
||||
/**
|
||||
* Build the leading system message for a prompt and tool set. Returns undefined when
|
||||
* both are empty, so an empty transcript stays empty.
|
||||
*/
|
||||
export function createInitialSystemMessage(
|
||||
systemPrompt: string | undefined,
|
||||
tools: Tool[] | undefined,
|
||||
): SystemMessage | undefined {
|
||||
const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;
|
||||
const hasTools = tools !== undefined && tools.length > 0;
|
||||
if (!hasSystemPrompt && !hasTools) return undefined;
|
||||
return {
|
||||
role: "system",
|
||||
content: systemPrompt ?? "",
|
||||
...(hasTools ? { toolsAdded: tools } : {}),
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `Context.systemPrompt` and `Context.tools` into a leading system message.
|
||||
* This is the only entry point that produces a {@link TranscriptContext}; every
|
||||
* provider-facing function expects the result.
|
||||
*/
|
||||
export function normalizeContext(context: Context): TranscriptContext {
|
||||
const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);
|
||||
const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages;
|
||||
return { messages } as TranscriptContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any message list. The replay helpers only read entries whose role is `"system"`, so
|
||||
* agent transcripts that carry custom message roles can be passed without filtering.
|
||||
*/
|
||||
export type TranscriptMessages = readonly { role: string }[];
|
||||
|
||||
function isSystemMessage(message: { role: string }): message is SystemMessage {
|
||||
return message.role === "system";
|
||||
}
|
||||
|
||||
/** Return the leading system message, if the transcript starts with one. */
|
||||
export function getInitialSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
|
||||
const first = messages[0];
|
||||
return first && isSystemMessage(first) ? first : undefined;
|
||||
}
|
||||
|
||||
/** Drop the leading system message for APIs that carry the prompt outside the message list. */
|
||||
export function withoutInitialSystemMessage(messages: Message[]): Message[] {
|
||||
return getInitialSystemMessage(messages) ? messages.slice(1) : messages;
|
||||
}
|
||||
|
||||
/** Resolve the tools available after applying every transcript delta in order. */
|
||||
export function getCurrentTools(messages: TranscriptMessages): Tool[] {
|
||||
const tools = new Map<string, Tool>();
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name);
|
||||
for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool);
|
||||
}
|
||||
return [...tools.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay every system message into one leading system message holding the current
|
||||
* prompt and tools. Later `content` is appended to the base prompt, `sections` are
|
||||
* patched by name, and tools are resolved with {@link getCurrentTools}.
|
||||
*/
|
||||
export function getCurrentSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
|
||||
const content: string[] = [];
|
||||
const sections = new Map<string, string>();
|
||||
let timestamp: number | undefined;
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
timestamp ??= message.timestamp;
|
||||
const text = contentText(message.content);
|
||||
if (text.length > 0) content.push(text);
|
||||
for (const [name, value] of Object.entries(message.sections ?? {})) {
|
||||
if (value === null) sections.delete(name);
|
||||
else sections.set(name, value);
|
||||
}
|
||||
}
|
||||
const tools = getCurrentTools(messages);
|
||||
if (timestamp === undefined && tools.length === 0) return undefined;
|
||||
return {
|
||||
role: "system",
|
||||
content: content.join("\n\n"),
|
||||
...(sections.size > 0 ? { sections: Object.fromEntries(sections) } : {}),
|
||||
...(tools.length > 0 ? { toolsAdded: tools } : {}),
|
||||
timestamp: timestamp ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the current system prompt text after replaying every system message. */
|
||||
export function getCurrentSystemPrompt(messages: TranscriptMessages): string {
|
||||
const message = getCurrentSystemMessage(messages);
|
||||
return message ? getSystemMessageText(message) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the transcript for APIs without mid-conversation system messages: the replayed
|
||||
* system message leads, and every later system message is dropped.
|
||||
*/
|
||||
export function collapseSystemMessages(context: TranscriptContext): TranscriptContext {
|
||||
const head = getCurrentSystemMessage(context.messages);
|
||||
const messages = context.messages.filter((message) => message.role !== "system");
|
||||
return { messages: head ? [head, ...messages] : messages } as TranscriptContext;
|
||||
}
|
||||
|
||||
/** Keep later system messages in place when the model accepts them; otherwise collapse them. */
|
||||
export function resolveTranscript(
|
||||
context: TranscriptContext,
|
||||
supportsMidConvoSystemMessages: boolean | undefined,
|
||||
): TranscriptContext {
|
||||
return supportsMidConvoSystemMessages ? context : collapseSystemMessages(context);
|
||||
}
|
||||
|
||||
/** Strip executable and display-only fields from a tool before transcript comparison or persistence. */
|
||||
export function toToolDeclaration(tool: Tool): Tool {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: JSON.parse(JSON.stringify(tool.parameters)) as Tool["parameters"],
|
||||
...(tool.constrainedSampling === undefined ? {} : { constrainedSampling: tool.constrainedSampling }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two tools declare the same interface to the model.
|
||||
*
|
||||
* Both sides go through {@link toToolDeclaration} first: its JSON round-trip drops the
|
||||
* typebox symbol keys and `undefined` fields that a structural comparison would see, and
|
||||
* builds both objects with the same key order, so comparing the serialized declarations
|
||||
* is exact. This avoids a deep-equal dependency in a browser-safe package.
|
||||
*/
|
||||
export function declarationsEqual(left: Tool, right: Tool): boolean {
|
||||
return JSON.stringify(toToolDeclaration(left)) === JSON.stringify(toToolDeclaration(right));
|
||||
}
|
||||
|
||||
export interface ToolStateChanges {
|
||||
toolsAdded: Tool[];
|
||||
toolsRemoved: ToolReference[];
|
||||
}
|
||||
|
||||
/** Compare two complete tool states. A changed definition is a removal followed by an addition. */
|
||||
export function getToolStateChanges(previous: readonly Tool[], current: readonly Tool[]): ToolStateChanges {
|
||||
const previousTools = new Map(previous.map((tool) => [tool.name, tool]));
|
||||
const currentTools = new Map(current.map((tool) => [tool.name, tool]));
|
||||
return {
|
||||
toolsAdded: current
|
||||
.filter((tool) => {
|
||||
const previousTool = previousTools.get(tool.name);
|
||||
return previousTool === undefined || !declarationsEqual(previousTool, tool);
|
||||
})
|
||||
.map(toToolDeclaration),
|
||||
toolsRemoved: previous
|
||||
.filter((tool) => {
|
||||
const currentTool = currentTools.get(tool.name);
|
||||
return currentTool === undefined || !declarationsEqual(tool, currentTool);
|
||||
})
|
||||
.map((tool) => ({ name: tool.name })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every definition referenced by transcript tool state, in first-declaration order. */
|
||||
export function getDeclaredTools(messages: TranscriptMessages): Tool[] {
|
||||
const definitions = new Map<string, Tool>();
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
for (const tool of message.toolsAdded ?? []) definitions.set(tool.name, tool);
|
||||
}
|
||||
return [...definitions.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tool name was declared twice with different definitions. A transport that can
|
||||
* only reference previously declared tools by name cannot replay such a history.
|
||||
*
|
||||
* @deprecated No built-in transport needs this anymore: Anthropic expresses redefinitions with
|
||||
* inline `tool_definition` blocks. Kept for API compatibility and will be removed in a future release.
|
||||
*/
|
||||
export function hasToolRedefinitions(messages: TranscriptMessages): boolean {
|
||||
const declared = new Map<string, Tool>();
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
for (const tool of message.toolsAdded ?? []) {
|
||||
const previous = declared.get(tool.name);
|
||||
if (previous !== undefined && !declarationsEqual(previous, tool)) return true;
|
||||
declared.set(tool.name, tool);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether tool history contains a removal or same-name redeclaration that an addition-only transport cannot replay. */
|
||||
export function hasNonAdditiveToolChanges(messages: TranscriptMessages): boolean {
|
||||
const declared = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
if ((message.toolsRemoved?.length ?? 0) > 0) return true;
|
||||
for (const tool of message.toolsAdded ?? []) {
|
||||
if (declared.has(tool.name)) return true;
|
||||
declared.add(tool.name);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface TranscriptTools {
|
||||
/** Tools sent in the top-level request field. */
|
||||
requestTools: Tool[];
|
||||
/**
|
||||
* Whether later system messages carry their own `toolsAdded` as in-place additions.
|
||||
* When false, `requestTools` already holds the complete current tool set.
|
||||
*/
|
||||
anchorsAdditions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split tool declarations between the top-level request field and in-place additions.
|
||||
* Transports that can anchor additions at a system message keep the initial tools at the
|
||||
* top and load later ones where they appear; that only works when no tool was removed or
|
||||
* redeclared, so everything else sends the current tool list.
|
||||
*/
|
||||
export function resolveTranscriptTools(messages: TranscriptMessages, supportsToolAdditions: boolean): TranscriptTools {
|
||||
const anchorsAdditions = supportsToolAdditions && !hasNonAdditiveToolChanges(messages);
|
||||
return {
|
||||
requestTools: anchorsAdditions
|
||||
? (getInitialSystemMessage(messages)?.toolsAdded ?? [])
|
||||
: getCurrentTools(messages),
|
||||
anchorsAdditions,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user