[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
351
infrastructure/ai/contextCompaction.ts
Normal file
351
infrastructure/ai/contextCompaction.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import type { ModelMessage } from "ai";
|
||||
import type { ProviderConfig } from "./types";
|
||||
import {
|
||||
computeCompactionThreshold,
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
} from "./harness/contextBudget";
|
||||
import {
|
||||
estimateModelMessagesTokensWithKind,
|
||||
estimateUnknownTokens,
|
||||
} from "./harness/tokenEstimator";
|
||||
import { redactSecretsForModel } from "./harness/modelSecretRedaction";
|
||||
|
||||
const REDACTED_PAYLOAD_PREVIEW_CHARS = 80;
|
||||
|
||||
export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000;
|
||||
export const DEFAULT_PROTECT_RECENT_MESSAGES = 10;
|
||||
|
||||
export const CONTEXT_COMPACTION_SYSTEM_PROMPT = `You are summarizing a long Netcatty agent conversation so it can continue without exceeding the model context window.
|
||||
|
||||
Create a concise but complete summary that preserves:
|
||||
- the user's current goal and requirements
|
||||
- important decisions and constraints
|
||||
- terminal hosts, paths, commands, files, errors, and results that still matter
|
||||
- what has already been tried
|
||||
- unresolved tasks or blockers
|
||||
|
||||
Do not add new advice. Only summarize what happened.`;
|
||||
|
||||
export interface ShouldCompactContextInput {
|
||||
promptTokens: number;
|
||||
contextWindow: number;
|
||||
thresholdRatio?: number;
|
||||
maxOutputTokens?: number;
|
||||
}
|
||||
|
||||
export interface PrepareContextCompactionInput {
|
||||
messages: ModelMessage[];
|
||||
contextWindow?: number;
|
||||
reservedTokens?: number;
|
||||
thresholdRatio?: number;
|
||||
maxOutputTokens?: number;
|
||||
providerId?: string | null;
|
||||
protectRecentMessages?: number;
|
||||
summarize: (messagesToSummarize: ModelMessage[]) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface PrepareContextCompactionResult {
|
||||
messages: ModelMessage[];
|
||||
summary?: string;
|
||||
didCompact: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveContextWindowInput {
|
||||
provider?: Pick<ProviderConfig, "contextWindow" | "modelContextWindows"> | null;
|
||||
modelId?: string | null;
|
||||
defaultContextWindow?: number;
|
||||
}
|
||||
|
||||
export function shouldCompactContext({
|
||||
promptTokens,
|
||||
contextWindow,
|
||||
thresholdRatio,
|
||||
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
}: ShouldCompactContextInput): boolean {
|
||||
if (contextWindow <= 0) return false;
|
||||
const threshold = thresholdRatio != null
|
||||
? contextWindow * thresholdRatio
|
||||
: computeCompactionThreshold({ contextWindow, maxOutputTokens });
|
||||
return promptTokens >= threshold;
|
||||
}
|
||||
|
||||
export function resolveContextWindow({
|
||||
provider,
|
||||
modelId,
|
||||
defaultContextWindow = DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
}: ResolveContextWindowInput): number {
|
||||
const manual = sanitizeContextWindow(provider?.contextWindow);
|
||||
if (manual != null) return manual;
|
||||
|
||||
const discovered = modelId ? sanitizeContextWindow(provider?.modelContextWindows?.[modelId]) : null;
|
||||
if (discovered != null) return discovered;
|
||||
|
||||
return defaultContextWindow;
|
||||
}
|
||||
|
||||
export function sanitizeContextWindow(value: unknown): number | undefined {
|
||||
const num = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) return undefined;
|
||||
return Math.max(1, Math.round(num));
|
||||
}
|
||||
|
||||
export function estimateModelMessagesTokens(
|
||||
messages: ModelMessage[],
|
||||
providerId?: string | null,
|
||||
): number {
|
||||
return estimateModelMessagesTokensWithKind({ messages, providerId }).tokens;
|
||||
}
|
||||
|
||||
export { estimateUnknownTokens };
|
||||
|
||||
export function findSafeCompactionSplitIndex(
|
||||
messages: ModelMessage[],
|
||||
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
): number {
|
||||
let splitAt = Math.max(0, messages.length - protectRecentMessages);
|
||||
|
||||
while (splitAt > 0 && startsWithToolResult(messages[splitAt])) {
|
||||
splitAt -= 1;
|
||||
}
|
||||
|
||||
while (splitAt > 0 && endsWithToolCall(messages[splitAt - 1])) {
|
||||
splitAt -= 1;
|
||||
}
|
||||
|
||||
return splitAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-message equivalent of findSafeCompactionSplitIndex.
|
||||
* Persisted force-compact boundaries use ChatMessage indices; never cut between
|
||||
* an assistant toolCalls message and its following tool result messages.
|
||||
*/
|
||||
export function findSafeChatMessageCompactionSplitIndex(
|
||||
messages: Array<{ role: string; toolCalls?: readonly unknown[] | null }>,
|
||||
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
): number {
|
||||
let splitAt = Math.max(0, messages.length - protectRecentMessages);
|
||||
|
||||
while (splitAt > 0 && messages[splitAt]?.role === "tool") {
|
||||
splitAt -= 1;
|
||||
}
|
||||
|
||||
while (
|
||||
splitAt > 0
|
||||
&& messages[splitAt - 1]?.role === "assistant"
|
||||
&& Array.isArray(messages[splitAt - 1]?.toolCalls)
|
||||
&& (messages[splitAt - 1]?.toolCalls?.length ?? 0) > 0
|
||||
) {
|
||||
splitAt -= 1;
|
||||
}
|
||||
|
||||
return splitAt;
|
||||
}
|
||||
|
||||
export function buildCompactedMessages({
|
||||
summary,
|
||||
recentMessages,
|
||||
}: {
|
||||
summary: string;
|
||||
recentMessages: ModelMessage[];
|
||||
}): ModelMessage[] {
|
||||
return [
|
||||
{
|
||||
role: "user",
|
||||
content: `[Previous conversation summary]\n\n${summary.trim()}\n\n[Continue with the recent messages below.]`,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I understand the previous conversation summary and will continue from the recent messages.",
|
||||
},
|
||||
...recentMessages,
|
||||
];
|
||||
}
|
||||
|
||||
export async function prepareContextCompaction({
|
||||
messages,
|
||||
contextWindow = DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
reservedTokens = 0,
|
||||
thresholdRatio,
|
||||
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
providerId,
|
||||
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
summarize,
|
||||
}: PrepareContextCompactionInput): Promise<PrepareContextCompactionResult> {
|
||||
const promptTokens = estimateModelMessagesTokens(messages, providerId)
|
||||
+ Math.max(0, Math.ceil(reservedTokens));
|
||||
if (!shouldCompactContext({
|
||||
promptTokens,
|
||||
contextWindow,
|
||||
thresholdRatio,
|
||||
maxOutputTokens,
|
||||
})) {
|
||||
return { messages, didCompact: false };
|
||||
}
|
||||
|
||||
const splitAt = findSafeCompactionSplitIndex(messages, protectRecentMessages);
|
||||
const oldMessages = messages.slice(0, splitAt);
|
||||
const recentMessages = messages.slice(splitAt);
|
||||
if (oldMessages.length === 0) {
|
||||
return { messages, didCompact: false };
|
||||
}
|
||||
|
||||
const summary = (await summarize(oldMessages)).trim();
|
||||
if (!summary) {
|
||||
return { messages, didCompact: false };
|
||||
}
|
||||
|
||||
return {
|
||||
messages: buildCompactedMessages({ summary, recentMessages }),
|
||||
summary,
|
||||
didCompact: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMessagesForCompaction(messages: ModelMessage[]): string {
|
||||
return messages
|
||||
.map((message, index) => {
|
||||
return `<message index="${index + 1}" role="${escapeXml(String(message.role))}">\n${escapeXml(formatMessageContent(message.content))}\n</message>`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function keepRecentContextMessages(
|
||||
messages: ModelMessage[],
|
||||
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
): ModelMessage[] {
|
||||
const splitAt = findSafeCompactionSplitIndex(messages, protectRecentMessages);
|
||||
return messages.slice(splitAt);
|
||||
}
|
||||
|
||||
function startsWithToolResult(message: ModelMessage | undefined): boolean {
|
||||
if (!message || message.role !== "tool") return false;
|
||||
if (!Array.isArray(message.content)) return true;
|
||||
return message.content.some((part) => {
|
||||
return part && typeof part === "object" && (part as { type?: string }).type === "tool-result";
|
||||
});
|
||||
}
|
||||
|
||||
function endsWithToolCall(message: ModelMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return false;
|
||||
return message.content.some((part) => {
|
||||
return part && typeof part === "object" && (part as { type?: string }).type === "tool-call";
|
||||
});
|
||||
}
|
||||
|
||||
function formatMessageContent(content: ModelMessage["content"]): string {
|
||||
if (typeof content === "string") return redactSecretsForModel(content);
|
||||
return redactSecretsForModel(JSON.stringify(sanitizeContentForCompaction(content), null, 2));
|
||||
}
|
||||
|
||||
function sanitizeContentForCompaction(content: Exclude<ModelMessage["content"], string>): unknown {
|
||||
if (!Array.isArray(content)) return sanitizeUnknownForCompaction(content);
|
||||
return content.map((part) => sanitizeContentPartForCompaction(part));
|
||||
}
|
||||
|
||||
function sanitizeContentPartForCompaction(part: unknown): unknown {
|
||||
if (!isRecord(part)) return sanitizeUnknownForCompaction(part);
|
||||
|
||||
const sanitized = sanitizeRecordForCompaction(part);
|
||||
// Provider options are replay metadata for the actual model request, not
|
||||
// conversation content. In particular, Responses encrypted reasoning can
|
||||
// be tens of KB per turn and must never become literal summarization text.
|
||||
delete sanitized.providerOptions;
|
||||
|
||||
if (part.type === "image") {
|
||||
return {
|
||||
...sanitized,
|
||||
image: describeRedactedPayload(part.image, {
|
||||
label: "image",
|
||||
mediaType: typeof part.mediaType === "string" ? part.mediaType : undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
return {
|
||||
...sanitized,
|
||||
data: describeRedactedPayload(part.data, {
|
||||
label: "file",
|
||||
mediaType: typeof part.mediaType === "string" ? part.mediaType : undefined,
|
||||
filename: typeof part.filename === "string" ? part.filename : undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function sanitizeRecordForCompaction(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [entryKey, entryValue] of Object.entries(value)) {
|
||||
sanitized[entryKey] = sanitizeUnknownForCompaction(entryValue, entryKey);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function sanitizeUnknownForCompaction(value: unknown, key?: string): unknown {
|
||||
if (value == null) return value;
|
||||
if (typeof value === "string") {
|
||||
if (key === "base64Data" || key === "dataUrl" || key === "file_data") {
|
||||
return describeRedactedPayload(value, { label: key });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (value instanceof URL) return value.toString();
|
||||
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
||||
return describeRedactedPayload(value, { label: key ?? "binary" });
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((part) => sanitizeUnknownForCompaction(part));
|
||||
if (isRecord(value)) return sanitizeRecordForCompaction(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function describeRedactedPayload(
|
||||
value: unknown,
|
||||
{
|
||||
label,
|
||||
filename,
|
||||
mediaType,
|
||||
}: {
|
||||
label: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
},
|
||||
): string {
|
||||
const details = [
|
||||
filename ? `filename=${filename}` : undefined,
|
||||
mediaType ? `mediaType=${mediaType}` : undefined,
|
||||
describePayloadSize(value),
|
||||
typeof value === "string" ? describeStringPreview(value) : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
return `[redacted ${label} payload${details.length ? `: ${details.join(", ")}` : ""}]`;
|
||||
}
|
||||
|
||||
function describePayloadSize(value: unknown): string {
|
||||
if (typeof value === "string") return `${value.length} chars`;
|
||||
if (value instanceof ArrayBuffer) return `${value.byteLength} bytes`;
|
||||
if (ArrayBuffer.isView(value)) return `${value.byteLength} bytes`;
|
||||
if (value instanceof URL) return "url";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function describeStringPreview(value: string): string | undefined {
|
||||
if (!value.startsWith("data:")) return undefined;
|
||||
const commaIndex = value.indexOf(",");
|
||||
const header = commaIndex >= 0 ? value.slice(0, commaIndex) : value.slice(0, REDACTED_PAYLOAD_PREVIEW_CHARS);
|
||||
return `source=${header.slice(0, REDACTED_PAYLOAD_PREVIEW_CHARS)}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
Reference in New Issue
Block a user