SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
68d7816 verified
Raw History Blame Contribute Delete
7.01 kB
import { estimateMessageTokens, estimateUsedContextTokens } from '#/agent/context-usage';
import { inputSubmitted, messageAppended, turnEnded, turnStarted } from '#/agent/events';
import { createUserEntry, type HistoryMessage, type UserEntry } from '#/agent/turn';
import type { ExternalEvent } from '#/eventStore/events';
import { createUserMessage, type UserMessage } from '#/llm/message';
import summaryPrefixTemplate from './compaction-summary-prefix.md?raw';
const COMPACTION_SUMMARY_PREFIX = summaryPrefixTemplate.trimEnd();
const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000;
const COMPACT_USER_MESSAGE_HEAD_TOKENS = 2_000;
export interface CompactionSeed {
events: ExternalEvent[];
tokensAfter: number;
keptUserMessageCount: number;
keptHeadUserMessageCount?: number;
}
interface CompactionUserSelection {
head: UserEntry[];
tail: UserEntry[];
elided: boolean;
omittedTokens: number;
}
export function buildCompactionSeed(input: {
turnId: number;
history: readonly HistoryMessage[];
summary: string;
queue: readonly UserEntry[];
}): CompactionSeed {
const compactable = input.history.filter(isKeptUserEntry);
const selection = selectCompactionUserMessages(
compactable,
COMPACT_USER_MESSAGE_MAX_TOKENS,
COMPACT_USER_MESSAGE_HEAD_TOKENS,
);
const elision = selection.elided
? createUserEntry(createUserMessage(elisionText(selection.omittedTokens)), {
source: 'compaction',
key: 'elision',
})
: undefined;
const summaryEntry = createUserEntry(createUserMessage(summaryText(input.summary)), {
source: 'compaction',
key: 'summary',
});
const kept: HistoryMessage[] = [
...selection.head,
...(elision === undefined ? [] : [elision]),
...selection.tail,
];
const seeded = [...kept, summaryEntry];
const events: ExternalEvent[] = [
turnStarted({ turnId: input.turnId }),
...seeded.map((message) => messageAppended({ message })),
turnEnded({ turnId: input.turnId, outcome: 'done' }),
...input.queue.map((item) => inputSubmitted({ entry: item })),
];
return {
events,
tokensAfter: estimateUsedContextTokens(seeded),
keptUserMessageCount: selection.head.length + selection.tail.length,
keptHeadUserMessageCount: selection.elided ? selection.head.length : undefined,
};
}
export function compactionContinuationMessage(): UserMessage {
return createUserMessage(
wrapSystemReminder(
'Context compaction is complete — continue the work that was in progress when it began.',
),
);
}
function summaryText(summary: string): string {
const trimmed = summary.trim();
return `${COMPACTION_SUMMARY_PREFIX}\n${trimmed.length > 0 ? trimmed : '(no summary available)'}`;
}
function elisionText(omittedTokens: number): string {
return wrapSystemReminder(
`Some of this conversation's user messages were omitted here during compaction: the messages above this note are the oldest user input, the messages below are the most recent, and roughly ${String(omittedTokens)} tokens in between were dropped. The omitted content is covered by the compaction summary at the end of the conversation.`,
);
}
function wrapSystemReminder(content: string): string {
return `<system-reminder>\n${content.trim()}\n</system-reminder>`;
}
function isKeptUserEntry(entry: HistoryMessage): entry is UserEntry {
if (entry.message.role !== 'user') return false;
if (entry.meta?.source === 'compaction') return false;
return entry.meta?.source === undefined || entry.meta?.source === 'input';
}
function selectCompactionUserMessages(
messages: readonly UserEntry[],
maxTokens: number,
headTokens: number,
): CompactionUserSelection {
let totalTokens = 0;
for (const entry of messages) {
totalTokens += estimateMessageTokens(entry.message);
}
if (totalTokens <= maxTokens) {
return { head: [], tail: [...messages], elided: false, omittedTokens: 0 };
}
const headBudget = Math.min(Math.max(headTokens, 0), maxTokens);
const tail: UserEntry[] = [];
let tailRemaining = maxTokens - headBudget;
let headEndExclusive = messages.length;
let tailBoundaryDroppedPrefix: UserEntry | null = null;
for (let i = messages.length - 1; i >= 0 && tailRemaining > 0; i--) {
const entry = messages[i] as UserEntry;
const tokens = estimateMessageTokens(entry.message);
if (tokens <= tailRemaining) {
tail.push(entry);
tailRemaining -= tokens;
headEndExclusive = i;
continue;
}
const fullText = textOf(entry.message);
const keptSuffix = truncateTextToTokensFromEnd(fullText, tailRemaining);
tail.push(replaceEntryText(entry, keptSuffix));
headEndExclusive = i;
const droppedPrefix = fullText.slice(0, fullText.length - keptSuffix.length);
if (droppedPrefix.length > 0) {
tailBoundaryDroppedPrefix = replaceEntryText(entry, droppedPrefix);
}
break;
}
tail.reverse();
const headCandidates = messages.slice(0, headEndExclusive);
if (tailBoundaryDroppedPrefix !== null) {
headCandidates.push(tailBoundaryDroppedPrefix);
}
const head: UserEntry[] = [];
let headRemaining = headBudget;
for (const entry of headCandidates) {
if (headRemaining <= 0) break;
const tokens = estimateMessageTokens(entry.message);
if (tokens <= headRemaining) {
head.push(entry);
headRemaining -= tokens;
continue;
}
head.push(replaceEntryText(entry, truncateTextToTokens(textOf(entry.message), headRemaining)));
break;
}
let keptTokens = 0;
for (const entry of head) keptTokens += estimateMessageTokens(entry.message);
for (const entry of tail) keptTokens += estimateMessageTokens(entry.message);
return { head, tail, elided: true, omittedTokens: Math.max(0, totalTokens - keptTokens) };
}
function textOf(message: UserMessage): string {
let text = '';
for (const part of message.content) {
if (part.type === 'text') {
text += part.text;
}
}
return text;
}
function replaceEntryText(entry: UserEntry, text: string): UserEntry {
return { ...entry, message: { ...entry.message, content: [{ type: 'text', text }] } };
}
function truncateTextToTokens(text: string, maxTokens: number): string {
if (maxTokens <= 0) return '';
let asciiCount = 0;
let nonAsciiCount = 0;
let end = 0;
for (const char of text) {
if ((char.codePointAt(0) as number) <= 127) {
asciiCount++;
} else {
nonAsciiCount++;
}
if (Math.ceil(asciiCount / 4) + nonAsciiCount > maxTokens) break;
end += char.length;
}
return text.slice(0, end);
}
function truncateTextToTokensFromEnd(text: string, maxTokens: number): string {
if (maxTokens <= 0) return '';
const chars = Array.from(text);
let tokens = 0;
let start = chars.length;
for (let i = chars.length - 1; i >= 0; i--) {
const code = chars[i]?.codePointAt(0) ?? 0;
tokens += code <= 127 ? 0.25 : 1;
if (Math.ceil(tokens) > maxTokens) break;
start = i;
}
return chars.slice(start).join('');
}