1
import type { Context, Message, SystemMessage, Tool, ToolReference, TranscriptContext } from "../types.ts";2
import { contentText, getSystemMessageText } from "./text.ts";4
export type { TranscriptContext } from "../types.ts";6
/**7
* Build the leading system message for a prompt and tool set. Returns undefined when8
* both are empty, so an empty transcript stays empty.9
*/10
export 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
}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}; every28
* provider-facing function expects the result.29
*/30
export 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
}36
/**37
* Any message list. The replay helpers only read entries whose role is `"system"`, so38
* agent transcripts that carry custom message roles can be passed without filtering.39
*/40
export type TranscriptMessages = readonly { role: string }[];42
function isSystemMessage(message: { role: string }): message is SystemMessage {43
return message.role === "system";44
}46
/** Return the leading system message, if the transcript starts with one. */47
export function getInitialSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {48
const first = messages[0];49
return first && isSystemMessage(first) ? first : undefined;50
}52
/** Drop the leading system message for APIs that carry the prompt outside the message list. */53
export function withoutInitialSystemMessage(messages: Message[]): Message[] {54
return getInitialSystemMessage(messages) ? messages.slice(1) : messages;55
}57
/** Resolve the tools available after applying every transcript delta in order. */58
export 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
}68
/**69
* Replay every system message into one leading system message holding the current70
* prompt and tools. Later `content` is appended to the base prompt, `sections` are71
* patched by name, and tools are resolved with {@link getCurrentTools}.72
*/73
export 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
}98
/** Render the current system prompt text after replaying every system message. */99
export function getCurrentSystemPrompt(messages: TranscriptMessages): string {100
const message = getCurrentSystemMessage(messages);101
return message ? getSystemMessageText(message) : "";102
}104
/**105
* Rebuild the transcript for APIs without mid-conversation system messages: the replayed106
* system message leads, and every later system message is dropped.107
*/108
export 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
}114
/** Keep later system messages in place when the model accepts them; otherwise collapse them. */115
export function resolveTranscript(116
context: TranscriptContext,117
supportsMidConvoSystemMessages: boolean | undefined,118
): TranscriptContext {119
return supportsMidConvoSystemMessages ? context : collapseSystemMessages(context);120
}122
/** Strip executable and display-only fields from a tool before transcript comparison or persistence. */123
export 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
}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 the136
* typebox symbol keys and `undefined` fields that a structural comparison would see, and137
* builds both objects with the same key order, so comparing the serialized declarations138
* is exact. This avoids a deep-equal dependency in a browser-safe package.139
*/140
export function declarationsEqual(left: Tool, right: Tool): boolean {141
return JSON.stringify(toToolDeclaration(left)) === JSON.stringify(toToolDeclaration(right));142
}144
export interface ToolStateChanges {145
toolsAdded: Tool[];146
toolsRemoved: ToolReference[];147
}149
/** Compare two complete tool states. A changed definition is a removal followed by an addition. */150
export 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: current155
.filter((tool) => {156
const previousTool = previousTools.get(tool.name);157
return previousTool === undefined || !declarationsEqual(previousTool, tool);158
})159
.map(toToolDeclaration),160
toolsRemoved: previous161
.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
}169
/** Every definition referenced by transcript tool state, in first-declaration order. */170
export 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
}179
/**180
* Whether a tool name was declared twice with different definitions. Transports that181
* reference tools by name (Anthropic `tool_addition`/`tool_removal`) cannot express that.182
*/183
export 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
}196
/** Whether tool history contains a removal or same-name redeclaration that an addition-only transport cannot replay. */197
export 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
}210
export 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
}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 the223
* top and load later ones where they appear; that only works when no tool was removed or224
* redeclared, so everything else sends the current tool list.225
*/226
export function resolveTranscriptTools(messages: TranscriptMessages, supportsToolAdditions: boolean): TranscriptTools {227
const anchorsAdditions = supportsToolAdditions && !hasNonAdditiveToolChanges(messages);228
return {229
requestTools: anchorsAdditions230
? (getInitialSystemMessage(messages)?.toolsAdded ?? [])231
: getCurrentTools(messages),232
anchorsAdditions,233
};234
}