Files
NetMesh/infrastructure/ai/harness/cattyRuntime.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

282 lines
10 KiB
TypeScript

import type { ModelMessage } from 'ai';
import { generateText, pruneMessages } from 'ai';
import {
CONTEXT_COMPACTION_SYSTEM_PROMPT,
DEFAULT_PROTECT_RECENT_MESSAGES,
formatMessagesForCompaction,
resolveContextWindow,
} from '../contextCompaction';
import type { ProviderConfig } from '../types';
import {
extractLatestUserGoal,
prepareTurnContext,
} from './contextManager';
import type { CompactionTrace } from './types';
import { buildCattyCompactionTimeout } from './streamTimeouts';
import {
COMPACTION_PROMPT_RESERVE,
COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
DEFAULT_MAX_OUTPUT_TOKENS,
resolveEffectiveMaxOutputTokens,
computeCompactionThreshold,
computeTotalInputTokens,
} from './contextBudget';
import { pruneUntilFitsCompaction } from './compactionPruner';
import type { ToolOutputStore } from './toolOutputStore';
import { storeCompactionArchive, storeCompactionArtifact } from './compactionArtifacts';
import { globalTwoPassCompactionCache } from './twoPassCompaction';
export interface CompactCattyMessagesInput {
messages: ModelMessage[];
sessionId: string;
chatSessionId?: string;
provider?: Pick<ProviderConfig, 'contextWindow' | 'modelContextWindows' | 'providerId' | 'advancedParams'> | null;
modelId?: string | null;
reservedTokens?: () => number;
model: Parameters<typeof generateText>[0]['model'];
abortSignal: AbortSignal;
trigger?: 'pre-turn' | '413-retry' | 'force';
force?: boolean;
compressForRequestTooLargeRetry?: boolean;
/** Override protect-recent window (default: DEFAULT_PROTECT_RECENT_MESSAGES). */
protectRecentMessages?: number;
maxOutputTokens?: number;
onCompactionStart?: (trigger: 'pre-turn' | '413-retry' | 'force') => void;
onCompaction?: (trace: CompactionTrace) => void;
toolOutputStore?: ToolOutputStore;
reinjection?: {
permissionMode?: import('../types').AIPermissionMode;
sessionScopeSummary?: string;
sessionStateText?: string;
};
}
export interface CompactCattyMessagesResult {
messages: ModelMessage[];
trace?: CompactionTrace;
summary?: string;
}
export function buildCompactionFailureArchiveNotice(
archiveHandleId: string | undefined,
sourceTruncated: boolean,
): string | undefined {
return archiveHandleId
? `Compaction summary failed. Earlier ${sourceTruncated ? 'bounded' : 'exact'} conversation remains available at tool output handle ${archiveHandleId}; search/read it before guessing missing details.`
: undefined;
}
export async function compactCattyMessages(
input: CompactCattyMessagesInput,
): Promise<CompactCattyMessagesResult> {
const contextWindow = resolveContextWindow({
provider: input.provider,
modelId: input.modelId,
});
const maxOutputTokens = input.maxOutputTokens
?? input.provider?.advancedParams?.maxTokens
?? DEFAULT_MAX_OUTPUT_TOKENS;
const providerId = input.provider?.providerId;
let archiveHandleId: string | undefined;
let archiveSourceTruncated = false;
let artifactHandleId: string | undefined;
let archiveChars: number | undefined;
let twoPassCacheHit = false;
let twoPassPrefixMessages: number | undefined;
let compactionSummary: string | undefined;
const reservedTokens = input.reservedTokens?.() ?? 0;
const threshold = computeCompactionThreshold({ contextWindow, maxOutputTokens });
const estimatedInput = computeTotalInputTokens({
messages: input.messages,
providerId,
reservedTokens,
});
const prewarmThreshold = Math.max(1, threshold - Math.ceil(contextWindow * 0.1));
if (
!input.force
&& input.trigger !== '413-retry'
&& estimatedInput >= prewarmThreshold
&& estimatedInput < threshold
&& input.chatSessionId
&& input.modelId
) {
globalTwoPassCompactionCache.start(
input.chatSessionId,
input.modelId,
input.messages,
async prefix => {
const result = await generateText({
model: input.model,
instructions: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Create the first-pass note for this stable earlier prefix. Preserve exact decisions, paths, commands, errors, and unfinished work:\n\n${formatMessagesForCompaction(prefix)}`,
}],
abortSignal: input.abortSignal,
maxOutputTokens: COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
temperature: 0,
timeout: buildCattyCompactionTimeout(),
});
return result.text;
},
);
}
const summarize = async (messagesToSummarize: ModelMessage[]) => {
const summarizeTrigger = input.trigger === '413-retry' || input.compressForRequestTooLargeRetry
? '413-retry'
: input.trigger === 'force' || input.force
? 'force'
: 'pre-turn';
input.onCompactionStart?.(summarizeTrigger);
const reserved = input.reservedTokens?.() ?? 0;
const compactionOutputTokens = resolveEffectiveMaxOutputTokens(
contextWindow,
COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
);
const availableForInput = Math.max(
1,
contextWindow - compactionOutputTokens - COMPACTION_PROMPT_RESERVE - reserved,
);
const pruned = pruneUntilFitsCompaction({
messages: messagesToSummarize,
availableForInput: Math.max(1, availableForInput),
providerId,
});
const cached = input.chatSessionId && input.modelId
? await globalTwoPassCompactionCache.consume(input.chatSessionId, input.modelId, pruned)
: undefined;
twoPassCacheHit = Boolean(cached);
twoPassPrefixMessages = cached?.prefixLength;
const formattedHistory = cached
? [
`[FIRST-PASS NOTE FOR ${cached.prefixLength} EARLIER MESSAGES]\n${cached.note}`,
`[REMAINING EXACT MESSAGES]\n${formatMessagesForCompaction(pruned.slice(cached.prefixLength))}`,
].join('\n\n')
: formatMessagesForCompaction(pruned);
// Archive the untouched turn input. messagesToSummarize has already passed
// through integrity repair and stale-result pruning, so it is not an exact
// recovery source even though it is the right input for the summary model.
const exactFormattedHistory = formatMessagesForCompaction(input.messages);
archiveChars = exactFormattedHistory.length;
if (input.toolOutputStore && input.chatSessionId) {
const archive = storeCompactionArchive(
input.toolOutputStore,
input.chatSessionId,
exactFormattedHistory,
);
archiveHandleId = archive.id;
archiveSourceTruncated = archive.sourceTruncated;
}
const result = await generateText({
model: input.model,
instructions: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Summarize this earlier conversation context for the next model turn:\n\n${formattedHistory}`,
}],
abortSignal: input.abortSignal,
maxOutputTokens: COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
temperature: 0,
timeout: buildCattyCompactionTimeout(),
});
if (input.toolOutputStore && input.chatSessionId) {
artifactHandleId = storeCompactionArtifact(
input.toolOutputStore,
input.chatSessionId,
{
trigger: summarizeTrigger,
modelId: input.modelId,
archiveHandleId,
formattedHistory,
summary: result.text,
},
).id;
}
const archiveNotice = archiveHandleId
? `\n\n[${archiveSourceTruncated ? 'Bounded conversation snapshot (source exceeded the local archive cap)' : 'Exact conversation snapshot'} archived locally: handleId=${archiveHandleId}. Use tool_output_read search/range only when the summary lacks a needed exact detail.]`
: '';
compactionSummary = `${result.text}${archiveNotice}`;
return compactionSummary;
};
const trigger = input.trigger ?? (input.force ? 'force' : 'pre-turn');
try {
const prepared = await prepareTurnContext({
messages: input.messages,
backend: 'catty',
contextWindow,
reservedTokens: input.reservedTokens?.() ?? 0,
maxOutputTokens,
trigger,
force: input.force,
compressForRequestTooLargeRetry: input.compressForRequestTooLargeRetry,
protectRecentMessages: input.protectRecentMessages ?? DEFAULT_PROTECT_RECENT_MESSAGES,
summarize,
sessionId: input.sessionId,
chatSessionId: input.chatSessionId,
onEvent: undefined,
reinjection: {
...input.reinjection,
userGoal: extractLatestUserGoal(input.messages),
},
providerId,
});
const trace = prepared.trace ? {
...prepared.trace,
archiveHandleId,
artifactHandleId,
archiveChars,
twoPassCacheHit,
twoPassPrefixMessages,
} : undefined;
if (trace) input.onCompaction?.(trace);
return { messages: prepared.messages, trace, summary: compactionSummary };
} catch (err) {
if (input.abortSignal.aborted) throw err;
console.warn('[Harness] Context compaction failed; falling back to recent messages only:', err);
const fallback = await prepareTurnContext({
messages: input.messages,
backend: 'catty',
contextWindow,
trigger: 'force',
force: true,
compressForRequestTooLargeRetry: input.compressForRequestTooLargeRetry,
protectRecentMessages: input.protectRecentMessages ?? DEFAULT_PROTECT_RECENT_MESSAGES,
sessionId: input.sessionId,
chatSessionId: input.chatSessionId,
onEvent: undefined,
reinjection: {
...input.reinjection,
sessionScopeSummary: [
input.reinjection?.sessionScopeSummary,
buildCompactionFailureArchiveNotice(archiveHandleId, archiveSourceTruncated),
].filter(Boolean).join('\n') || undefined,
userGoal: extractLatestUserGoal(input.messages),
},
});
const trace = fallback.trace ? {
...fallback.trace,
archiveHandleId,
artifactHandleId,
archiveChars,
twoPassCacheHit,
twoPassPrefixMessages,
} : undefined;
if (trace) input.onCompaction?.(trace);
return { messages: fallback.messages, trace, summary: compactionSummary };
}
}
export function prepareCattyMessagesForStream(
messages: ModelMessage[],
options: { preserveReasoning?: boolean } = {},
): ModelMessage[] {
return pruneMessages({
messages,
reasoning: options.preserveReasoning ? 'none' : 'all',
emptyMessages: 'remove',
});
}