import type { CanonicalMessage, CanonicalContentBlock } from "../../model/index.js";
export type MessageProjectorInput = {
messages: CanonicalMessage[];
maxMessages?: number;
};
export type MessageProjectorResult = {
messages: CanonicalMessage[];
droppedCount: number;
warnings: Array<{
code: "context_truncated" | "tool_result_orphaned" | "tool_call_unmatched" | "tool_result_injected";
message: string;
}>;
};
* Project canonical messages so the output is safe to send to a model:
* 1. Apply a tool-pair-safe sliding window when `maxMessages` is set.
* 2. Ensure every assistant `tool_call` has a matching `tool_result`
* immediately following — inject placeholder results for any that are
* missing so the OpenAI API never rejects the payload.
* 3. Strip orphaned `tool_result` blocks whose `tool_call` was dropped.
*/
export class MessageProjector {
project(input: MessageProjectorInput): MessageProjectorResult {
const warnings: MessageProjectorResult["warnings"] = [];
let projected = input.messages;
let droppedCount = 0;
if (input.maxMessages !== undefined && projected.length > input.maxMessages) {
const result = toolPairSafeTruncate(projected, input.maxMessages);
droppedCount = result.droppedCount;
projected = result.messages;
if (droppedCount > 0) {
warnings.push({
code: "context_truncated",
message: `Truncated ${droppedCount} message(s) to respect maxMessages=${input.maxMessages}.`,
});
}
}
projected = repairToolResultPairing(projected, warnings);
return { messages: projected, droppedCount, warnings };
}
}
* Truncate messages to at most `max` while never cutting between an
* assistant `tool_calls` message and its subsequent `tool_result` messages.
* The cut point is pushed earlier until it lands on a safe turn boundary.
*/
function toolPairSafeTruncate(
messages: CanonicalMessage[],
max: number,
): { messages: CanonicalMessage[]; droppedCount: number } {
if (messages.length <= max) return { messages, droppedCount: 0 };
let cutIndex = messages.length - max;
while (cutIndex < messages.length && isToolResultOnly(messages[cutIndex])) {
cutIndex++;
}
if (cutIndex < messages.length && messages[cutIndex].role === "assistant" && hasToolCalls(messages[cutIndex])) {
}
const sliced = messages.slice(cutIndex);
return { messages: sliced, droppedCount: cutIndex };
}
* Walk through the conversation and:
* - Inject a placeholder `tool_result` user message for any assistant
* `tool_call` that has no matching result (fixes the OpenAI API error).
* - Strip orphaned `tool_result` blocks whose `tool_call` was dropped.
*/
function repairToolResultPairing(
messages: CanonicalMessage[],
warnings: MessageProjectorResult["warnings"],
): CanonicalMessage[] {
const output: CanonicalMessage[] = [];
let pendingToolCallIds: string[] = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (message.role === "assistant") {
if (pendingToolCallIds.length > 0) {
injectPlaceholderResults(pendingToolCallIds, output, warnings);
pendingToolCallIds = [];
}
pendingToolCallIds = collectToolCallIds(message);
output.push(message);
continue;
}
const pendingSet = new Set(pendingToolCallIds);
const seen = new Set<string>();
for (const block of message.content) {
if (block.type === "tool_result" || block.type === "tool_result_reference") {
seen.add(block.toolCallId);
}
}
const hasOrphans = [...seen].some((id) => !pendingSet.has(id));
let cleanedMessage = message;
if (hasOrphans) {
const kept: CanonicalContentBlock[] = [];
for (const block of message.content) {
if (
(block.type === "tool_result" || block.type === "tool_result_reference") &&
!pendingSet.has(block.toolCallId)
) {
warnings.push({
code: "tool_result_orphaned",
message: `tool_result ${block.toolCallId} has no matching tool_call — removed.`,
});
} else {
kept.push(block);
}
}
if (kept.length === 0) {
for (const id of seen) pendingToolCallIds = pendingToolCallIds.filter((pid) => pid !== id);
continue;
}
cleanedMessage = { ...message, content: kept };
}
const matched = [...seen].filter((id) => pendingSet.has(id));
pendingToolCallIds = pendingToolCallIds.filter((id) => !matched.includes(id));
output.push(cleanedMessage);
}
if (pendingToolCallIds.length > 0) {
injectPlaceholderResults(pendingToolCallIds, output, warnings);
}
return output;
}
function injectPlaceholderResults(
toolCallIds: string[],
output: CanonicalMessage[],
warnings: MessageProjectorResult["warnings"],
): void {
const blocks: CanonicalContentBlock[] = toolCallIds.map((id) => ({
type: "tool_result" as const,
toolCallId: id,
content: [{ type: "text" as const, text: "[result truncated]" }],
}));
output.push({ role: "user", content: blocks });
for (const id of toolCallIds) {
warnings.push({
code: "tool_result_injected",
message: `Injected placeholder tool_result for unmatched tool_call ${id}.`,
});
}
}
function collectToolCallIds(message: CanonicalMessage): string[] {
return message.content
.filter((block): block is { type: "tool_call"; id: string; name: string; input: unknown } =>
block.type === "tool_call",
)
.map((block) => block.id);
}
function hasToolCalls(message: CanonicalMessage): boolean {
return message.content.some((block) => block.type === "tool_call");
}
function isToolResultOnly(message: CanonicalMessage): boolean {
return message.content.length > 0 && message.content.every(
(block) => block.type === "tool_result" || block.type === "tool_result_reference",
);
}