[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:
870
infrastructure/ai/harness/toolOutputStore.ts
Normal file
870
infrastructure/ai/harness/toolOutputStore.ts
Normal file
@@ -0,0 +1,870 @@
|
||||
export interface ToolOutputHandle {
|
||||
id: string;
|
||||
chatSessionId: string;
|
||||
capabilityId: string;
|
||||
sessionId?: string;
|
||||
totalChars: number;
|
||||
storedChars: number;
|
||||
sourceTruncated: boolean;
|
||||
preview: string;
|
||||
storedAt: number;
|
||||
accessedAt: number;
|
||||
fullContent?: string;
|
||||
filePath?: string;
|
||||
spillPromise?: Promise<void>;
|
||||
evicted?: boolean;
|
||||
}
|
||||
|
||||
export interface PersistedToolOutputRecord {
|
||||
schemaVersion: 1;
|
||||
handleId: string;
|
||||
chatSessionId: string;
|
||||
capabilityId: string;
|
||||
terminalSessionId?: string;
|
||||
totalChars: number;
|
||||
storedChars: number;
|
||||
sourceTruncated: boolean;
|
||||
preview: string;
|
||||
storedAt: number;
|
||||
accessedAt: number;
|
||||
}
|
||||
|
||||
export interface StoreToolOutputInput {
|
||||
chatSessionId: string;
|
||||
capabilityId: string;
|
||||
content: string;
|
||||
sessionId?: string;
|
||||
previewChars?: number;
|
||||
}
|
||||
|
||||
export interface ReadToolOutputInput {
|
||||
handleId: string;
|
||||
mode?: 'head' | 'tail' | 'full' | 'range' | 'search';
|
||||
maxChars?: number;
|
||||
offset?: number;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface ToolOutputReadResult {
|
||||
handleId: string;
|
||||
mode: NonNullable<ReadToolOutputInput['mode']>;
|
||||
content: string;
|
||||
totalChars: number;
|
||||
storedChars: number;
|
||||
sourceTruncated: boolean;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
nextOffset: number;
|
||||
hasMore: boolean;
|
||||
matchOffsets?: number[];
|
||||
}
|
||||
|
||||
export const TOOL_OUTPUT_READ_MAX_CHARS = 12_000;
|
||||
export const TOOL_OUTPUT_MAX_HANDLE_CHARS = 4_000_000;
|
||||
export const TOOL_OUTPUT_MAX_HANDLES_PER_SESSION = 64;
|
||||
export const TOOL_OUTPUT_MAX_CHARS_PER_SESSION = 8_000_000;
|
||||
export const TOOL_OUTPUT_MAX_HANDLES_GLOBAL = 256;
|
||||
export const TOOL_OUTPUT_MAX_CHARS_GLOBAL = 32_000_000;
|
||||
export const TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS = 1_024;
|
||||
export const TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS = 1_024;
|
||||
export const TOOL_OUTPUT_TTL_MS = 30 * 60 * 1_000;
|
||||
export const TOOL_OUTPUT_SPILL_THRESHOLD_CHARS = 0;
|
||||
const TOOL_OUTPUT_SEARCH_CONTEXT_CHARS = 320;
|
||||
const TOOL_OUTPUT_SEARCH_MAX_MATCHES = 20;
|
||||
const TOOL_OUTPUT_LIFECYCLE_BLOOM_BITS = 1 << 22;
|
||||
const TOOL_OUTPUT_LIFECYCLE_BLOOM_HASHES = 4;
|
||||
|
||||
/**
|
||||
* Fixed-memory deny set with no false negatives. Lifecycle ids are UUID-like
|
||||
* and never intentionally reused, so rare false positives are safer than
|
||||
* accepting output for a deleted chat/terminal after exact tombstone churn.
|
||||
*/
|
||||
class FixedStringBloomFilter {
|
||||
private readonly words = new Uint32Array(TOOL_OUTPUT_LIFECYCLE_BLOOM_BITS >>> 5);
|
||||
|
||||
add(value: string): void {
|
||||
for (const bit of this.bitsFor(value)) {
|
||||
this.words[bit >>> 5] |= 1 << (bit & 31);
|
||||
}
|
||||
}
|
||||
|
||||
has(value: string): boolean {
|
||||
for (const bit of this.bitsFor(value)) {
|
||||
if ((this.words[bit >>> 5] & (1 << (bit & 31))) === 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private bitsFor(value: string): number[] {
|
||||
let first = 0x811c9dc5;
|
||||
let second = 0x9e3779b9;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
first = Math.imul(first ^ code, 0x01000193);
|
||||
second = Math.imul(second ^ (code + index), 0x85ebca6b);
|
||||
}
|
||||
second |= 1;
|
||||
const mask = TOOL_OUTPUT_LIFECYCLE_BLOOM_BITS - 1;
|
||||
return Array.from({ length: TOOL_OUTPUT_LIFECYCLE_BLOOM_HASHES }, (_, index) => (
|
||||
(first + Math.imul(index, second)) >>> 0
|
||||
) & mask);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolOutputPersistence {
|
||||
write(record: PersistedToolOutputRecord, content: string): Promise<string>;
|
||||
restore?(
|
||||
handleId: string,
|
||||
chatSessionId: string,
|
||||
): Promise<{ path: string; record: PersistedToolOutputRecord } | null>;
|
||||
read(path: string, input: ReadToolOutputInput): Promise<Omit<ToolOutputReadResult, 'handleId' | 'storedChars' | 'sourceTruncated'> | null>;
|
||||
delete(path: string): Promise<void>;
|
||||
deleteSession?(chatSessionId: string): Promise<void>;
|
||||
deleteTerminalSession?(chatSessionId: string, terminalSessionId: string): Promise<void>;
|
||||
deleteTerminalEverywhere?(terminalSessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ToolOutputStoreOptions {
|
||||
maxHandleChars?: number;
|
||||
maxHandlesPerSession?: number;
|
||||
maxCharsPerSession?: number;
|
||||
maxHandlesGlobal?: number;
|
||||
maxCharsGlobal?: number;
|
||||
ttlMs?: number;
|
||||
spillThresholdChars?: number;
|
||||
now?: () => number;
|
||||
persistence?: ToolOutputPersistence;
|
||||
}
|
||||
|
||||
function nextHandleId(): string {
|
||||
const randomId = globalThis.crypto?.randomUUID?.()
|
||||
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
return `tool-output-${randomId}`;
|
||||
}
|
||||
|
||||
function isHighSurrogate(value: number): boolean {
|
||||
return value >= 0xd800 && value <= 0xdbff;
|
||||
}
|
||||
|
||||
function isLowSurrogate(value: number): boolean {
|
||||
return value >= 0xdc00 && value <= 0xdfff;
|
||||
}
|
||||
|
||||
function safeSliceBounds(content: string, requestedStart: number, requestedEnd: number): [number, number] {
|
||||
let start = Math.min(content.length, Math.max(0, requestedStart));
|
||||
let end = Math.min(content.length, Math.max(start, requestedEnd));
|
||||
if (start > 0 && start < content.length && isLowSurrogate(content.charCodeAt(start))) {
|
||||
start -= 1;
|
||||
}
|
||||
if (end > start && end < content.length && isHighSurrogate(content.charCodeAt(end - 1))) {
|
||||
end -= 1;
|
||||
}
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
export class ToolOutputStore {
|
||||
private readonly bySession = new Map<string, Map<string, ToolOutputHandle>>();
|
||||
private readonly maxHandleChars: number;
|
||||
private readonly maxHandlesPerSession: number;
|
||||
private readonly maxCharsPerSession: number;
|
||||
private readonly maxHandlesGlobal: number;
|
||||
private readonly maxCharsGlobal: number;
|
||||
private readonly ttlMs: number;
|
||||
private readonly spillThresholdChars: number;
|
||||
private readonly now: () => number;
|
||||
private readonly restorePromises = new Map<string, Promise<ToolOutputHandle | undefined>>();
|
||||
private readonly sessionGenerations = new Map<string, number>();
|
||||
private readonly sessionDeletionPromises = new Map<string, Promise<void>>();
|
||||
private readonly failedSessionDeletions = new Set<string>();
|
||||
private readonly terminalMutationGenerations = new Map<string, number>();
|
||||
private readonly terminalDeletionPromises = new Map<string, Promise<void>>();
|
||||
private readonly failedTerminalDeletions = new Set<string>();
|
||||
private readonly deletedTerminalSessions = new Map<string, string>();
|
||||
private readonly closedTerminalSessions = new Set<string>();
|
||||
private readonly lifecycleDenyFilter = new FixedStringBloomFilter();
|
||||
private persistence?: ToolOutputPersistence;
|
||||
|
||||
constructor(options: ToolOutputStoreOptions = {}) {
|
||||
this.maxHandleChars = options.maxHandleChars ?? TOOL_OUTPUT_MAX_HANDLE_CHARS;
|
||||
this.maxHandlesPerSession = options.maxHandlesPerSession ?? TOOL_OUTPUT_MAX_HANDLES_PER_SESSION;
|
||||
this.maxCharsPerSession = options.maxCharsPerSession ?? TOOL_OUTPUT_MAX_CHARS_PER_SESSION;
|
||||
this.maxHandlesGlobal = options.maxHandlesGlobal ?? TOOL_OUTPUT_MAX_HANDLES_GLOBAL;
|
||||
this.maxCharsGlobal = options.maxCharsGlobal ?? TOOL_OUTPUT_MAX_CHARS_GLOBAL;
|
||||
this.ttlMs = options.ttlMs ?? TOOL_OUTPUT_TTL_MS;
|
||||
this.spillThresholdChars = options.spillThresholdChars ?? TOOL_OUTPUT_SPILL_THRESHOLD_CHARS;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.persistence = options.persistence;
|
||||
}
|
||||
|
||||
setPersistence(persistence: ToolOutputPersistence | undefined): void {
|
||||
this.persistence = persistence;
|
||||
}
|
||||
|
||||
resolveRestartPersistenceNotices<T>(value: T, chatSessionId: string): T {
|
||||
return this.resolveRestartPersistenceNoticesValue(value, chatSessionId) as T;
|
||||
}
|
||||
|
||||
getLifecycleMetadataStatsForTests(): {
|
||||
sessionGenerations: number;
|
||||
failedSessionDeletions: number;
|
||||
terminalMutationGenerations: number;
|
||||
failedTerminalDeletions: number;
|
||||
deletedTerminalSessions: number;
|
||||
closedTerminalSessions: number;
|
||||
} {
|
||||
return {
|
||||
sessionGenerations: this.sessionGenerations.size,
|
||||
failedSessionDeletions: this.failedSessionDeletions.size,
|
||||
terminalMutationGenerations: this.terminalMutationGenerations.size,
|
||||
failedTerminalDeletions: this.failedTerminalDeletions.size,
|
||||
deletedTerminalSessions: this.deletedTerminalSessions.size,
|
||||
closedTerminalSessions: this.closedTerminalSessions.size,
|
||||
};
|
||||
}
|
||||
|
||||
store(input: StoreToolOutputInput): ToolOutputHandle {
|
||||
const previewChars = input.previewChars ?? 240;
|
||||
const now = this.now();
|
||||
const lifecycleDenied = this.lifecycleDenyFilter.has(`chat:${input.chatSessionId}`)
|
||||
|| Boolean(
|
||||
input.sessionId
|
||||
&& (
|
||||
this.closedTerminalSessions.has(input.sessionId)
|
||||
|| this.lifecycleDenyFilter.has(`closed-terminal:${input.sessionId}`)
|
||||
)
|
||||
);
|
||||
if (lifecycleDenied) {
|
||||
return {
|
||||
id: nextHandleId(),
|
||||
chatSessionId: input.chatSessionId,
|
||||
capabilityId: input.capabilityId,
|
||||
sessionId: input.sessionId,
|
||||
totalChars: input.content.length,
|
||||
storedChars: 0,
|
||||
sourceTruncated: input.content.length > 0,
|
||||
preview: "",
|
||||
storedAt: now,
|
||||
accessedAt: now,
|
||||
evicted: true,
|
||||
};
|
||||
}
|
||||
const retainedContent = retainBoundedContent(input.content, this.maxHandleChars);
|
||||
const handle: ToolOutputHandle = {
|
||||
id: nextHandleId(),
|
||||
chatSessionId: input.chatSessionId,
|
||||
capabilityId: input.capabilityId,
|
||||
sessionId: input.sessionId,
|
||||
totalChars: input.content.length,
|
||||
storedChars: retainedContent.length,
|
||||
sourceTruncated: retainedContent.length < input.content.length,
|
||||
preview: retainedContent.slice(0, previewChars),
|
||||
storedAt: now,
|
||||
accessedAt: now,
|
||||
fullContent: retainedContent,
|
||||
};
|
||||
const sessionMap = this.bySession.get(input.chatSessionId) ?? new Map<string, ToolOutputHandle>();
|
||||
sessionMap.set(handle.id, handle);
|
||||
this.bySession.set(input.chatSessionId, sessionMap);
|
||||
this.enforceSessionLimits(input.chatSessionId, sessionMap);
|
||||
this.enforceGlobalLimits();
|
||||
if (sessionMap.has(handle.id)) this.startSpill(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
get(handleId: string, chatSessionId?: string): ToolOutputHandle | undefined {
|
||||
this.pruneExpired();
|
||||
if (chatSessionId) {
|
||||
const handle = this.bySession.get(chatSessionId)?.get(handleId);
|
||||
if (handle) handle.accessedAt = this.now();
|
||||
return handle;
|
||||
}
|
||||
for (const sessionMap of this.bySession.values()) {
|
||||
const handle = sessionMap.get(handleId);
|
||||
if (handle) {
|
||||
handle.accessedAt = this.now();
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
listPendingHandles(chatSessionId: string): ToolOutputHandle[] {
|
||||
this.pruneExpired();
|
||||
return [...(this.bySession.get(chatSessionId)?.values() ?? [])];
|
||||
}
|
||||
|
||||
async flush(chatSessionId: string): Promise<void> {
|
||||
const handles = [...(this.bySession.get(chatSessionId)?.values() ?? [])];
|
||||
await Promise.allSettled(handles.map(handle => handle.spillPromise));
|
||||
}
|
||||
|
||||
read(input: ReadToolOutputInput, chatSessionId?: string): string | null {
|
||||
return this.readChunk(input, chatSessionId)?.content ?? null;
|
||||
}
|
||||
|
||||
readChunk(input: ReadToolOutputInput, chatSessionId?: string): ToolOutputReadResult | null {
|
||||
const handle = this.get(input.handleId, chatSessionId);
|
||||
if (!handle) return null;
|
||||
if (handle.fullContent == null) return null;
|
||||
return buildReadResult(handle, handle.fullContent, input);
|
||||
}
|
||||
|
||||
async readChunkAsync(input: ReadToolOutputInput, chatSessionId?: string): Promise<ToolOutputReadResult | null> {
|
||||
let handle = this.get(input.handleId, chatSessionId);
|
||||
if (!handle && chatSessionId) {
|
||||
handle = await this.restoreHandle(input.handleId, chatSessionId);
|
||||
}
|
||||
if (!handle) return null;
|
||||
await handle.spillPromise;
|
||||
if (handle.fullContent != null) return buildReadResult(handle, handle.fullContent, input);
|
||||
if (!handle.filePath || !this.persistence) return null;
|
||||
const persisted = await this.persistence.read(handle.filePath, input);
|
||||
if (!persisted) {
|
||||
this.removeHandle(handle);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...persisted,
|
||||
handleId: handle.id,
|
||||
totalChars: handle.totalChars,
|
||||
storedChars: handle.storedChars,
|
||||
sourceTruncated: handle.sourceTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
prune(chatSessionId: string): void {
|
||||
this.lifecycleDenyFilter.add(`chat:${chatSessionId}`);
|
||||
this.failedSessionDeletions.delete(chatSessionId);
|
||||
this.sessionGenerations.set(chatSessionId, (this.sessionGenerations.get(chatSessionId) ?? 0) + 1);
|
||||
for (const key of this.deletedTerminalSessions.keys()) {
|
||||
if (key.startsWith(`${chatSessionId}:`)) {
|
||||
this.deletedTerminalSessions.delete(key);
|
||||
this.terminalMutationGenerations.delete(key);
|
||||
this.failedTerminalDeletions.delete(key);
|
||||
}
|
||||
}
|
||||
const sessionMap = this.bySession.get(chatSessionId);
|
||||
if (sessionMap) {
|
||||
for (const handle of sessionMap.values()) this.evictHandle(handle);
|
||||
}
|
||||
this.bySession.delete(chatSessionId);
|
||||
let deletionSucceeded = false;
|
||||
const deletion = this.persistence?.deleteSession?.(chatSessionId)
|
||||
.then(
|
||||
() => { deletionSucceeded = true; },
|
||||
() => {},
|
||||
);
|
||||
if (deletion) {
|
||||
this.sessionDeletionPromises.set(chatSessionId, deletion);
|
||||
void deletion.finally(() => {
|
||||
if (this.sessionDeletionPromises.get(chatSessionId) !== deletion) {
|
||||
return;
|
||||
}
|
||||
this.sessionDeletionPromises.delete(chatSessionId);
|
||||
if (deletionSucceeded) {
|
||||
this.cleanupSessionGeneration(chatSessionId);
|
||||
} else {
|
||||
this.failedSessionDeletions.add(chatSessionId);
|
||||
this.enforceSessionDeletionMetadataLimit();
|
||||
}
|
||||
});
|
||||
} else if (this.persistence?.restore && !this.persistence.deleteSession) {
|
||||
this.failedSessionDeletions.add(chatSessionId);
|
||||
this.enforceSessionDeletionMetadataLimit();
|
||||
}
|
||||
this.cleanupSessionGeneration(chatSessionId);
|
||||
}
|
||||
|
||||
pruneTerminalSession(chatSessionId: string, terminalSessionId: string): void {
|
||||
const terminalKey = `${chatSessionId}:${terminalSessionId}`;
|
||||
this.lifecycleDenyFilter.add(`terminal-key:${terminalKey}`);
|
||||
this.deletedTerminalSessions.delete(terminalKey);
|
||||
this.deletedTerminalSessions.set(terminalKey, chatSessionId);
|
||||
this.failedTerminalDeletions.delete(terminalKey);
|
||||
this.terminalMutationGenerations.set(
|
||||
terminalKey,
|
||||
(this.terminalMutationGenerations.get(terminalKey) ?? 0) + 1,
|
||||
);
|
||||
const sessionMap = this.bySession.get(chatSessionId);
|
||||
if (sessionMap) {
|
||||
for (const [handleId, handle] of sessionMap) {
|
||||
if (handle.sessionId !== terminalSessionId) continue;
|
||||
sessionMap.delete(handleId);
|
||||
this.evictHandle(handle);
|
||||
}
|
||||
if (sessionMap.size === 0) this.bySession.delete(chatSessionId);
|
||||
}
|
||||
let deletionSucceeded = false;
|
||||
const deletion = this.persistence?.deleteTerminalSession?.(chatSessionId, terminalSessionId)
|
||||
.then(
|
||||
() => { deletionSucceeded = true; },
|
||||
() => {},
|
||||
);
|
||||
if (deletion) {
|
||||
this.terminalDeletionPromises.set(terminalKey, deletion);
|
||||
void deletion.finally(() => {
|
||||
if (this.terminalDeletionPromises.get(terminalKey) !== deletion) {
|
||||
return;
|
||||
}
|
||||
this.terminalDeletionPromises.delete(terminalKey);
|
||||
if (deletionSucceeded) {
|
||||
this.cleanupTerminalMutationMetadata(terminalKey, chatSessionId);
|
||||
} else {
|
||||
this.failedTerminalDeletions.add(terminalKey);
|
||||
this.enforceTerminalMutationMetadataLimit();
|
||||
}
|
||||
});
|
||||
}
|
||||
this.cleanupTerminalMutationMetadata(terminalKey, chatSessionId);
|
||||
this.enforceTerminalMutationMetadataLimit();
|
||||
}
|
||||
|
||||
pruneTerminalSessionEverywhere(terminalSessionId: string): void {
|
||||
this.lifecycleDenyFilter.add(`closed-terminal:${terminalSessionId}`);
|
||||
this.closedTerminalSessions.delete(terminalSessionId);
|
||||
this.closedTerminalSessions.add(terminalSessionId);
|
||||
while (this.closedTerminalSessions.size > TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS) {
|
||||
const oldestTerminalSessionId = this.closedTerminalSessions.values().next().value;
|
||||
if (oldestTerminalSessionId === undefined) break;
|
||||
this.closedTerminalSessions.delete(oldestTerminalSessionId);
|
||||
}
|
||||
const chatSessionIds = [...this.bySession.keys()];
|
||||
for (const chatSessionId of chatSessionIds) {
|
||||
this.pruneTerminalSession(chatSessionId, terminalSessionId);
|
||||
}
|
||||
void this.persistence?.deleteTerminalEverywhere?.(terminalSessionId).catch(() => {});
|
||||
}
|
||||
|
||||
private startSpill(handle: ToolOutputHandle): void {
|
||||
if (!this.persistence || (handle.fullContent?.length ?? 0) < this.spillThresholdChars) return;
|
||||
const persistence = this.persistence;
|
||||
const content = handle.fullContent!;
|
||||
handle.spillPromise = persistence.write(toPersistedRecord(handle), content).then(async path => {
|
||||
if (handle.evicted) {
|
||||
await persistence.delete(path);
|
||||
return;
|
||||
}
|
||||
handle.filePath = path;
|
||||
handle.fullContent = undefined;
|
||||
}).catch(() => {
|
||||
// Keep the in-memory copy if persistence is temporarily unavailable.
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRestartPersistenceNoticesValue(value: unknown, chatSessionId: string): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return this.resolveRestartPersistenceNoticeString(value, chatSessionId);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(entry => this.resolveRestartPersistenceNoticesValue(entry, chatSessionId));
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const handleId = typeof record.handleId === 'string' ? record.handleId : undefined;
|
||||
return Object.fromEntries(Object.entries(record).map(([key, entry]) => {
|
||||
if (typeof entry === 'string' && handleId && this.isHandleRestartPersistent(handleId, chatSessionId)) {
|
||||
return [key, removeRestartPersistenceWarning(entry)];
|
||||
}
|
||||
return [key, this.resolveRestartPersistenceNoticesValue(entry, chatSessionId)];
|
||||
}));
|
||||
}
|
||||
|
||||
private resolveRestartPersistenceNoticeString(value: string, chatSessionId: string): string {
|
||||
const handleIds = [...value.matchAll(/handleId=(tool-output-[A-Za-z0-9-]+)/g)]
|
||||
.map(match => match[1]);
|
||||
if (!handleIds.length) return value;
|
||||
return handleIds.every(handleId => this.isHandleRestartPersistent(handleId, chatSessionId))
|
||||
? removeRestartPersistenceWarning(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
private isHandleRestartPersistent(handleId: string, chatSessionId: string): boolean {
|
||||
return Boolean(this.bySession.get(chatSessionId)?.get(handleId)?.filePath);
|
||||
}
|
||||
|
||||
private enforceSessionLimits(chatSessionId: string, sessionMap: Map<string, ToolOutputHandle>): void {
|
||||
const totalChars = () => [...sessionMap.values()].reduce((sum, item) => sum + item.storedChars, 0);
|
||||
while (
|
||||
sessionMap.size > this.maxHandlesPerSession
|
||||
|| totalChars() > this.maxCharsPerSession
|
||||
) {
|
||||
const oldest = [...sessionMap.values()].sort((a, b) => a.accessedAt - b.accessedAt)[0];
|
||||
if (!oldest) break;
|
||||
sessionMap.delete(oldest.id);
|
||||
this.evictHandle(oldest);
|
||||
}
|
||||
if (sessionMap.size === 0) this.bySession.delete(chatSessionId);
|
||||
}
|
||||
|
||||
private pruneExpired(): void {
|
||||
const cutoff = this.now() - this.ttlMs;
|
||||
for (const [chatSessionId, sessionMap] of this.bySession) {
|
||||
for (const [handleId, handle] of sessionMap) {
|
||||
if (handle.accessedAt > cutoff) continue;
|
||||
sessionMap.delete(handleId);
|
||||
if (!handle.filePath) this.evictHandle(handle);
|
||||
}
|
||||
if (sessionMap.size === 0) this.bySession.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private enforceGlobalLimits(): void {
|
||||
const allHandles = () => [...this.bySession.entries()].flatMap(([chatSessionId, sessionMap]) => (
|
||||
[...sessionMap.values()].map(handle => ({ chatSessionId, sessionMap, handle }))
|
||||
));
|
||||
while (true) {
|
||||
const entries = allHandles();
|
||||
const totalChars = entries.reduce((sum, entry) => sum + entry.handle.storedChars, 0);
|
||||
if (entries.length <= this.maxHandlesGlobal && totalChars <= this.maxCharsGlobal) break;
|
||||
const oldest = entries.sort((a, b) => a.handle.accessedAt - b.handle.accessedAt)[0];
|
||||
if (!oldest) break;
|
||||
oldest.sessionMap.delete(oldest.handle.id);
|
||||
this.evictHandle(oldest.handle);
|
||||
if (oldest.sessionMap.size === 0) this.bySession.delete(oldest.chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private evictHandle(handle: ToolOutputHandle): void {
|
||||
handle.evicted = true;
|
||||
if (handle.filePath && this.persistence) {
|
||||
void this.persistence.delete(handle.filePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreHandle(handleId: string, chatSessionId: string): Promise<ToolOutputHandle | undefined> {
|
||||
if (!this.persistence?.restore) return undefined;
|
||||
const pendingDeletion = this.sessionDeletionPromises.get(chatSessionId);
|
||||
if (pendingDeletion) await pendingDeletion;
|
||||
const key = `${chatSessionId}:${handleId}`;
|
||||
const pending = this.restorePromises.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const generation = this.sessionGenerations.get(chatSessionId) ?? 0;
|
||||
const terminalMutationGenerations = new Map(this.terminalMutationGenerations);
|
||||
const restorePromise = this.restoreHandleImpl(
|
||||
handleId,
|
||||
chatSessionId,
|
||||
generation,
|
||||
terminalMutationGenerations,
|
||||
).finally(() => {
|
||||
this.restorePromises.delete(key);
|
||||
this.cleanupSessionGeneration(chatSessionId);
|
||||
this.enforceSessionDeletionMetadataLimit();
|
||||
this.cleanupTerminalMutationMetadataForChat(chatSessionId);
|
||||
});
|
||||
this.restorePromises.set(key, restorePromise);
|
||||
return restorePromise;
|
||||
}
|
||||
|
||||
private async restoreHandleImpl(
|
||||
handleId: string,
|
||||
chatSessionId: string,
|
||||
generation: number,
|
||||
terminalMutationGenerations: Map<string, number>,
|
||||
): Promise<ToolOutputHandle | undefined> {
|
||||
const restored = await this.persistence?.restore?.(handleId, chatSessionId);
|
||||
if (!restored || !isValidPersistedRecord(restored.record, handleId, chatSessionId)) return undefined;
|
||||
const restoredTerminalKey = restored.record.terminalSessionId
|
||||
? `${chatSessionId}:${restored.record.terminalSessionId}`
|
||||
: undefined;
|
||||
if (
|
||||
this.failedSessionDeletions.has(chatSessionId)
|
||||
|| this.lifecycleDenyFilter.has(`chat:${chatSessionId}`)
|
||||
|| (this.sessionGenerations.get(chatSessionId) ?? 0) !== generation
|
||||
|| (
|
||||
restoredTerminalKey
|
||||
&& (this.terminalMutationGenerations.get(restoredTerminalKey) ?? 0)
|
||||
!== (terminalMutationGenerations.get(restoredTerminalKey) ?? 0)
|
||||
)
|
||||
|| (
|
||||
restoredTerminalKey
|
||||
&& (
|
||||
this.deletedTerminalSessions.has(restoredTerminalKey)
|
||||
|| this.lifecycleDenyFilter.has(`terminal-key:${restoredTerminalKey}`)
|
||||
)
|
||||
)
|
||||
|| (
|
||||
restored.record.terminalSessionId
|
||||
&& (
|
||||
this.closedTerminalSessions.has(restored.record.terminalSessionId)
|
||||
|| this.lifecycleDenyFilter.has(`closed-terminal:${restored.record.terminalSessionId}`)
|
||||
)
|
||||
)
|
||||
) {
|
||||
void this.persistence?.delete(restored.path).catch(() => {});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = restored.record;
|
||||
const handle: ToolOutputHandle = {
|
||||
id: record.handleId,
|
||||
chatSessionId: record.chatSessionId,
|
||||
capabilityId: record.capabilityId,
|
||||
sessionId: record.terminalSessionId,
|
||||
totalChars: record.totalChars,
|
||||
storedChars: record.storedChars,
|
||||
sourceTruncated: record.sourceTruncated,
|
||||
preview: record.preview,
|
||||
storedAt: record.storedAt,
|
||||
accessedAt: this.now(),
|
||||
filePath: restored.path,
|
||||
};
|
||||
const sessionMap = this.bySession.get(chatSessionId) ?? new Map<string, ToolOutputHandle>();
|
||||
const existing = sessionMap.get(handleId);
|
||||
if (existing) return existing;
|
||||
sessionMap.set(handleId, handle);
|
||||
this.bySession.set(chatSessionId, sessionMap);
|
||||
this.enforceSessionLimits(chatSessionId, sessionMap);
|
||||
this.enforceGlobalLimits();
|
||||
return sessionMap.get(handleId);
|
||||
}
|
||||
|
||||
private removeHandle(handle: ToolOutputHandle): void {
|
||||
const sessionMap = this.bySession.get(handle.chatSessionId);
|
||||
sessionMap?.delete(handle.id);
|
||||
if (sessionMap?.size === 0) this.bySession.delete(handle.chatSessionId);
|
||||
this.evictHandle(handle);
|
||||
}
|
||||
|
||||
private cleanupSessionGeneration(chatSessionId: string): void {
|
||||
if (this.sessionDeletionPromises.has(chatSessionId)) return;
|
||||
if (this.failedSessionDeletions.has(chatSessionId)) return;
|
||||
if (this.hasPendingRestoreForChat(chatSessionId)) return;
|
||||
this.sessionGenerations.delete(chatSessionId);
|
||||
}
|
||||
|
||||
private enforceSessionDeletionMetadataLimit(): void {
|
||||
while (this.failedSessionDeletions.size > TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS) {
|
||||
let removed = false;
|
||||
for (const chatSessionId of this.failedSessionDeletions) {
|
||||
if (
|
||||
this.sessionDeletionPromises.has(chatSessionId)
|
||||
|| this.hasPendingRestoreForChat(chatSessionId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.failedSessionDeletions.delete(chatSessionId);
|
||||
this.sessionGenerations.delete(chatSessionId);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
if (!removed) break;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupTerminalMutationMetadata(terminalKey: string, chatSessionId: string): void {
|
||||
if (this.terminalDeletionPromises.has(terminalKey)) return;
|
||||
if (this.failedTerminalDeletions.has(terminalKey)) return;
|
||||
if (this.hasPendingRestoreForChat(chatSessionId)) return;
|
||||
if (this.persistence?.restore && !this.persistence.deleteTerminalSession) return;
|
||||
this.terminalMutationGenerations.delete(terminalKey);
|
||||
this.deletedTerminalSessions.delete(terminalKey);
|
||||
}
|
||||
|
||||
private cleanupTerminalMutationMetadataForChat(chatSessionId: string): void {
|
||||
const terminalPrefix = `${chatSessionId}:`;
|
||||
for (const terminalKey of this.terminalMutationGenerations.keys()) {
|
||||
if (terminalKey.startsWith(terminalPrefix)) {
|
||||
this.cleanupTerminalMutationMetadata(terminalKey, chatSessionId);
|
||||
}
|
||||
}
|
||||
this.enforceTerminalMutationMetadataLimit();
|
||||
}
|
||||
|
||||
private hasPendingRestoreForChat(chatSessionId: string): boolean {
|
||||
const restorePrefix = `${chatSessionId}:`;
|
||||
return [...this.restorePromises.keys()].some(key => key.startsWith(restorePrefix));
|
||||
}
|
||||
|
||||
private enforceTerminalMutationMetadataLimit(): void {
|
||||
while (this.deletedTerminalSessions.size > TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS) {
|
||||
let removed = false;
|
||||
for (const [terminalKey, chatSessionId] of this.deletedTerminalSessions) {
|
||||
if (
|
||||
this.terminalDeletionPromises.has(terminalKey)
|
||||
|| this.hasPendingRestoreForChat(chatSessionId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.deletedTerminalSessions.delete(terminalKey);
|
||||
this.terminalMutationGenerations.delete(terminalKey);
|
||||
this.failedTerminalDeletions.delete(terminalKey);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
if (!removed) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeRestartPersistenceWarning(value: string): string {
|
||||
return value
|
||||
.replace(' restartPersistence=unavailable (read before closing the app)', '')
|
||||
.replace('This saved output is available only until the app closes. Read this handle before closing the app.', '')
|
||||
.replace(
|
||||
'Full file content is available only until the app closes. Use tool_output_read now.',
|
||||
'Full file content stored. Use tool_output_read with this handleId to read more.',
|
||||
)
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trimEnd();
|
||||
}
|
||||
|
||||
function toPersistedRecord(handle: ToolOutputHandle): PersistedToolOutputRecord {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
handleId: handle.id,
|
||||
chatSessionId: handle.chatSessionId,
|
||||
capabilityId: handle.capabilityId,
|
||||
terminalSessionId: handle.sessionId,
|
||||
totalChars: handle.totalChars,
|
||||
storedChars: handle.storedChars,
|
||||
sourceTruncated: handle.sourceTruncated,
|
||||
preview: handle.preview,
|
||||
storedAt: handle.storedAt,
|
||||
accessedAt: handle.accessedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidPersistedRecord(
|
||||
record: PersistedToolOutputRecord,
|
||||
handleId: string,
|
||||
chatSessionId: string,
|
||||
): boolean {
|
||||
return record?.schemaVersion === 1
|
||||
&& record.handleId === handleId
|
||||
&& record.chatSessionId === chatSessionId
|
||||
&& typeof record.capabilityId === 'string'
|
||||
&& record.capabilityId.length > 0
|
||||
&& Number.isFinite(record.totalChars)
|
||||
&& record.totalChars >= 0
|
||||
&& Number.isFinite(record.storedChars)
|
||||
&& record.storedChars >= 0
|
||||
&& record.storedChars <= TOOL_OUTPUT_MAX_HANDLE_CHARS
|
||||
&& typeof record.sourceTruncated === 'boolean'
|
||||
&& typeof record.preview === 'string'
|
||||
&& Number.isFinite(record.storedAt)
|
||||
&& Number.isFinite(record.accessedAt);
|
||||
}
|
||||
|
||||
function retainBoundedContent(content: string, maxChars: number): string {
|
||||
if (content.length <= maxChars) return content;
|
||||
const marker = `\n\n[... source output exceeded local handle limit; ${content.length - maxChars} chars omitted ...]\n\n`;
|
||||
if (marker.length >= maxChars) return content.slice(0, maxChars);
|
||||
const budget = Math.max(0, maxChars - marker.length);
|
||||
const head = Math.floor(budget / 2);
|
||||
const tail = budget - head;
|
||||
return `${content.slice(0, head)}${marker}${content.slice(-tail)}`;
|
||||
}
|
||||
|
||||
function buildReadResult(
|
||||
handle: ToolOutputHandle,
|
||||
content: string,
|
||||
input: ReadToolOutputInput,
|
||||
): ToolOutputReadResult {
|
||||
const requestedMax = Number.isFinite(input.maxChars)
|
||||
? Math.floor(input.maxChars!)
|
||||
: TOOL_OUTPUT_READ_MAX_CHARS;
|
||||
const maxChars = Math.min(TOOL_OUTPUT_READ_MAX_CHARS, Math.max(1, requestedMax));
|
||||
const mode = input.mode ?? 'head';
|
||||
|
||||
if (mode === 'search') {
|
||||
const query = input.query ?? '';
|
||||
if (!query) {
|
||||
return {
|
||||
handleId: handle.id,
|
||||
mode,
|
||||
content: 'Search query is required.',
|
||||
totalChars: handle.totalChars,
|
||||
storedChars: handle.storedChars,
|
||||
sourceTruncated: handle.sourceTruncated,
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
nextOffset: 0,
|
||||
hasMore: false,
|
||||
matchOffsets: [],
|
||||
};
|
||||
}
|
||||
const haystack = content.toLocaleLowerCase();
|
||||
const needle = query.toLocaleLowerCase();
|
||||
const offsets: number[] = [];
|
||||
let cursor = Math.max(0, Math.floor(input.offset ?? 0));
|
||||
while (offsets.length < TOOL_OUTPUT_SEARCH_MAX_MATCHES) {
|
||||
const match = haystack.indexOf(needle, cursor);
|
||||
if (match < 0) break;
|
||||
offsets.push(match);
|
||||
cursor = match + Math.max(1, needle.length);
|
||||
}
|
||||
const excerpts: string[] = [];
|
||||
const renderedOffsets: number[] = [];
|
||||
let renderedChars = 0;
|
||||
for (const match of offsets) {
|
||||
const [start, end] = safeSliceBounds(
|
||||
content,
|
||||
match - TOOL_OUTPUT_SEARCH_CONTEXT_CHARS,
|
||||
match + query.length + TOOL_OUTPUT_SEARCH_CONTEXT_CHARS,
|
||||
);
|
||||
const excerpt = `[match offset=${match}]\n${content.slice(start, end)}`;
|
||||
const separator = excerpts.length > 0 ? '\n\n' : '';
|
||||
const available = maxChars - renderedChars - separator.length;
|
||||
if (available <= 0) break;
|
||||
if (excerpt.length > available) {
|
||||
if (excerpts.length > 0) break;
|
||||
const [, safeEnd] = safeSliceBounds(excerpt, 0, available);
|
||||
excerpts.push(excerpt.slice(0, safeEnd));
|
||||
renderedOffsets.push(match);
|
||||
renderedChars += safeEnd;
|
||||
break;
|
||||
}
|
||||
excerpts.push(excerpt);
|
||||
renderedOffsets.push(match);
|
||||
renderedChars += separator.length + excerpt.length;
|
||||
}
|
||||
const rendered = excerpts.join('\n\n');
|
||||
const nextOffset = renderedOffsets.length > 0
|
||||
? renderedOffsets[renderedOffsets.length - 1] + Math.max(1, query.length)
|
||||
: content.length;
|
||||
return {
|
||||
handleId: handle.id,
|
||||
mode,
|
||||
content: rendered || `No matches found for "${query}".`,
|
||||
totalChars: handle.totalChars,
|
||||
storedChars: handle.storedChars,
|
||||
sourceTruncated: handle.sourceTruncated,
|
||||
startOffset: Math.max(0, Math.floor(input.offset ?? 0)),
|
||||
endOffset: nextOffset,
|
||||
nextOffset,
|
||||
hasMore: haystack.indexOf(needle, nextOffset) >= 0,
|
||||
matchOffsets: renderedOffsets,
|
||||
};
|
||||
}
|
||||
|
||||
let startOffset = 0;
|
||||
if (mode === 'tail') {
|
||||
startOffset = Math.max(0, content.length - maxChars);
|
||||
} else if (mode === 'range') {
|
||||
startOffset = Math.min(content.length, Math.max(0, Math.floor(input.offset ?? 0)));
|
||||
}
|
||||
const [safeStartOffset, safeEndOffset] = safeSliceBounds(
|
||||
content,
|
||||
startOffset,
|
||||
startOffset + maxChars,
|
||||
);
|
||||
startOffset = safeStartOffset;
|
||||
const selected = content.slice(startOffset, safeEndOffset);
|
||||
const endOffset = safeEndOffset;
|
||||
return {
|
||||
handleId: handle.id,
|
||||
mode,
|
||||
content: selected,
|
||||
totalChars: handle.totalChars,
|
||||
storedChars: handle.storedChars,
|
||||
sourceTruncated: handle.sourceTruncated,
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextOffset: endOffset,
|
||||
hasMore: endOffset < content.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const globalToolOutputStore = new ToolOutputStore();
|
||||
Reference in New Issue
Block a user