[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:
125
infrastructure/ai/harness/agentEventAdapter.test.ts
Normal file
125
infrastructure/ai/harness/agentEventAdapter.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isStepHandleNoticeMessage,
|
||||
mapCattyStreamChunkToAgentEvents,
|
||||
mapSdkStreamEventToAgentEvents,
|
||||
} from './agentEventAdapter';
|
||||
|
||||
describe('agentEventAdapter', () => {
|
||||
it('maps tool-output-denied chunks to approval_resolved denied and tool_result', () => {
|
||||
const events = mapCattyStreamChunkToAgentEvents(
|
||||
{
|
||||
type: 'tool-output-denied',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'sftp_write_file',
|
||||
},
|
||||
{ sessionId: 'chat-1', turnId: 'turn-1' },
|
||||
);
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events[0]?.type, 'approval_resolved');
|
||||
assert.equal((events[0] as { outcome?: string }).outcome, 'denied');
|
||||
assert.equal(events[1]?.type, 'tool_result');
|
||||
assert.equal((events[1] as { isError?: boolean }).isError, true);
|
||||
});
|
||||
|
||||
it('maps tool-error chunks to tool_result with isError', () => {
|
||||
const events = mapCattyStreamChunkToAgentEvents(
|
||||
{
|
||||
type: 'tool-error',
|
||||
toolCallId: 'call-2',
|
||||
toolName: 'terminal_execute',
|
||||
error: new Error('timeout'),
|
||||
},
|
||||
{ sessionId: 'chat-1', turnId: 'turn-1' },
|
||||
);
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0]?.type, 'tool_result');
|
||||
assert.equal((events[0] as { isError?: boolean }).isError, true);
|
||||
assert.match(String((events[0] as { result?: string }).result), /timeout/);
|
||||
});
|
||||
|
||||
it('maps denied tool-approval-response with nested toolCall to tool_result', () => {
|
||||
const events = mapCattyStreamChunkToAgentEvents(
|
||||
{
|
||||
type: 'tool-approval-response',
|
||||
approvalId: 'approval-1',
|
||||
approved: false,
|
||||
reason: 'Observer mode blocks write operations.',
|
||||
toolCall: {
|
||||
toolCallId: 'call-3',
|
||||
toolName: 'sftp_write_file',
|
||||
input: { path: '/tmp/x' },
|
||||
},
|
||||
},
|
||||
{ sessionId: 'chat-1', turnId: 'turn-1' },
|
||||
);
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events[0]?.type, 'approval_resolved');
|
||||
assert.equal((events[0] as { outcome?: string }).outcome, 'denied');
|
||||
assert.equal(events[1]?.type, 'tool_result');
|
||||
assert.match(String((events[1] as { result?: string }).result), /Observer mode/);
|
||||
});
|
||||
|
||||
it('detects step handle notice messages for prepareStep dedup', () => {
|
||||
assert.equal(
|
||||
isStepHandleNoticeMessage('[step 2] Tool output handles available: tool-output-abc'),
|
||||
true,
|
||||
);
|
||||
assert.equal(isStepHandleNoticeMessage('regular user message'), false);
|
||||
});
|
||||
|
||||
it('maps SDK activity events into the unified trace protocol', () => {
|
||||
const context = { sessionId: 'chat-1', turnId: 'turn-1' };
|
||||
const fileChange = mapSdkStreamEventToAgentEvents({
|
||||
type: 'file-change',
|
||||
itemId: 'patch-1',
|
||||
status: 'completed',
|
||||
changes: [{ path: 'src/app.ts', kind: 'update' }],
|
||||
}, context);
|
||||
const webSearch = mapSdkStreamEventToAgentEvents({
|
||||
type: 'web-search', itemId: 'search-1', query: 'Codex events', status: 'running',
|
||||
}, context);
|
||||
const plan = mapSdkStreamEventToAgentEvents({
|
||||
type: 'plan-update', itemId: 'plan-1', status: 'completed',
|
||||
items: [{ text: 'Map events', completed: true }],
|
||||
}, context);
|
||||
const warning = mapSdkStreamEventToAgentEvents({
|
||||
type: 'warning', itemId: 'warning-1', message: 'recoverable',
|
||||
}, context);
|
||||
|
||||
assert.equal(fileChange[0]?.type, 'file_change');
|
||||
assert.equal(webSearch[0]?.type, 'web_search');
|
||||
assert.equal(plan[0]?.type, 'plan_update');
|
||||
assert.equal(warning[0]?.type, 'error');
|
||||
assert.equal((warning[0] as { recoverable?: boolean }).recoverable, true);
|
||||
});
|
||||
|
||||
it('maps actual SDK usage including cached and reasoning tokens', () => {
|
||||
const events = mapSdkStreamEventToAgentEvents({
|
||||
type: 'usage',
|
||||
inputTokens: 100,
|
||||
cachedInputTokens: 40,
|
||||
outputTokens: 25,
|
||||
reasoningTokens: 10,
|
||||
totalTokens: 125,
|
||||
}, { sessionId: 'chat-1', turnId: 'turn-1' });
|
||||
assert.deepEqual(events[0] && {
|
||||
type: events[0].type,
|
||||
promptTokens: 'promptTokens' in events[0] ? events[0].promptTokens : undefined,
|
||||
cachedPromptTokens: 'cachedPromptTokens' in events[0] ? events[0].cachedPromptTokens : undefined,
|
||||
completionTokens: 'completionTokens' in events[0] ? events[0].completionTokens : undefined,
|
||||
reasoningTokens: 'reasoningTokens' in events[0] ? events[0].reasoningTokens : undefined,
|
||||
totalTokens: 'totalTokens' in events[0] ? events[0].totalTokens : undefined,
|
||||
estimated: 'estimated' in events[0] ? events[0].estimated : undefined,
|
||||
}, {
|
||||
type: 'usage',
|
||||
promptTokens: 100,
|
||||
cachedPromptTokens: 40,
|
||||
completionTokens: 25,
|
||||
reasoningTokens: 10,
|
||||
totalTokens: 125,
|
||||
estimated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
317
infrastructure/ai/harness/agentEventAdapter.ts
Normal file
317
infrastructure/ai/harness/agentEventAdapter.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import type { AgentEvent, AgentEventListener } from './types';
|
||||
import { resolveStreamChunkToolCallId } from '../aiChatStreamingSupport';
|
||||
|
||||
let eventCounter = 0;
|
||||
|
||||
function nextEventId(prefix: string): string {
|
||||
eventCounter += 1;
|
||||
return `${prefix}-${Date.now()}-${eventCounter}`;
|
||||
}
|
||||
|
||||
export interface StreamEventContext {
|
||||
sessionId: string;
|
||||
chatSessionId?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
export interface CattyStreamChunk {
|
||||
type: string;
|
||||
text?: string;
|
||||
textDelta?: string;
|
||||
delta?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
args?: unknown;
|
||||
output?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
approved?: boolean;
|
||||
stepNumber?: number;
|
||||
}
|
||||
|
||||
const STEP_HANDLE_NOTICE_RE = /^\[step \d+\] Tool output handles available:/;
|
||||
|
||||
function isStepHandleNoticeMessage(content: unknown): boolean {
|
||||
return typeof content === 'string' && STEP_HANDLE_NOTICE_RE.test(content);
|
||||
}
|
||||
|
||||
function isToolResultError(output: unknown): boolean {
|
||||
if (output == null) return false;
|
||||
if (typeof output === 'object') {
|
||||
const obj = output as Record<string, unknown>;
|
||||
if ('error' in obj && typeof obj.error === 'string') return true;
|
||||
if ('ok' in obj && obj.ok === false) return true;
|
||||
}
|
||||
if (typeof output === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(output) as Record<string, unknown>;
|
||||
if ('error' in parsed && typeof parsed.error === 'string') return true;
|
||||
if ('ok' in parsed && parsed.ok === false) return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mapSdkStreamEventToAgentEvents(
|
||||
event: Record<string, unknown>,
|
||||
ctx: StreamEventContext,
|
||||
): AgentEvent[] {
|
||||
const base = {
|
||||
sessionId: ctx.sessionId,
|
||||
chatSessionId: ctx.chatSessionId,
|
||||
backend: 'external-sdk' as const,
|
||||
timestamp: Date.now(),
|
||||
turnId: ctx.turnId,
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case 'text-delta':
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('model-delta'),
|
||||
type: 'model_delta',
|
||||
text: String(event.text ?? event.textDelta ?? event.delta ?? ''),
|
||||
}];
|
||||
case 'thinking-delta':
|
||||
case 'reasoning-delta':
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('reasoning-delta'),
|
||||
type: 'reasoning_delta',
|
||||
text: String(event.text ?? event.textDelta ?? event.delta ?? ''),
|
||||
}];
|
||||
case 'tool-call':
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('tool-call'),
|
||||
type: 'tool_call',
|
||||
toolCallId: String(event.toolCallId ?? event.id ?? ''),
|
||||
toolName: String(event.toolName ?? event.name ?? 'unknown'),
|
||||
args: (event.args ?? event.input ?? {}) as Record<string, unknown>,
|
||||
}];
|
||||
case 'tool-result':
|
||||
{
|
||||
const output = event.result ?? event.output ?? '';
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('tool-result'),
|
||||
type: 'tool_result',
|
||||
toolCallId: String(event.toolCallId ?? ''),
|
||||
toolName: typeof event.toolName === 'string' ? event.toolName : undefined,
|
||||
result: typeof output === 'string' ? output : JSON.stringify(output),
|
||||
isError: Boolean(event.isError) || isToolResultError(output),
|
||||
}];
|
||||
}
|
||||
case 'file-change':
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('file-change'),
|
||||
type: 'file_change',
|
||||
itemId: String(event.itemId ?? ''),
|
||||
status: event.status === 'failed' ? 'failed' : 'completed',
|
||||
changes: Array.isArray(event.changes)
|
||||
? event.changes as Array<{ path: string; kind: 'add' | 'delete' | 'update' }>
|
||||
: [],
|
||||
}];
|
||||
case 'web-search':
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('web-search'),
|
||||
type: 'web_search',
|
||||
itemId: String(event.itemId ?? ''),
|
||||
query: String(event.query ?? ''),
|
||||
status: event.status === 'completed' ? 'completed' : 'running',
|
||||
}];
|
||||
case 'plan-update':
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('plan-update'),
|
||||
type: 'plan_update',
|
||||
itemId: String(event.itemId ?? ''),
|
||||
status: event.status === 'completed' ? 'completed' : 'running',
|
||||
items: Array.isArray(event.items)
|
||||
? event.items as Array<{ text: string; completed: boolean }>
|
||||
: [],
|
||||
}];
|
||||
case 'warning':
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('warning'),
|
||||
type: 'error',
|
||||
message: String(event.message ?? 'Unknown SDK warning'),
|
||||
recoverable: true,
|
||||
}];
|
||||
case 'usage': {
|
||||
const promptTokens = Number(event.inputTokens) || 0;
|
||||
const completionTokens = Number(event.outputTokens) || 0;
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('usage'),
|
||||
type: 'usage',
|
||||
promptTokens,
|
||||
cachedPromptTokens: Number(event.cachedInputTokens) || 0,
|
||||
completionTokens,
|
||||
reasoningTokens: Number(event.reasoningTokens) || 0,
|
||||
totalTokens: Number(event.totalTokens) || promptTokens + completionTokens,
|
||||
estimated: false,
|
||||
}];
|
||||
}
|
||||
case 'error':
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('error'),
|
||||
type: 'error',
|
||||
message: String(event.error ?? event.message ?? 'Unknown SDK error'),
|
||||
recoverable: false,
|
||||
}];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCattyStreamChunkToAgentEvents(
|
||||
chunk: CattyStreamChunk,
|
||||
ctx: StreamEventContext,
|
||||
): AgentEvent[] {
|
||||
const base = {
|
||||
sessionId: ctx.sessionId,
|
||||
chatSessionId: ctx.chatSessionId,
|
||||
backend: 'catty' as const,
|
||||
timestamp: Date.now(),
|
||||
turnId: ctx.turnId,
|
||||
};
|
||||
|
||||
if (chunk.type === 'text' || chunk.type === 'text-delta') {
|
||||
const text = chunk.text ?? chunk.textDelta ?? '';
|
||||
if (!text) return [];
|
||||
return [{ ...base, id: nextEventId('model-delta'), type: 'model_delta', text }];
|
||||
}
|
||||
|
||||
if (chunk.type === 'reasoning' || chunk.type === 'reasoning-start' || chunk.type === 'reasoning-delta') {
|
||||
const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? '';
|
||||
if (!text) return [];
|
||||
return [{ ...base, id: nextEventId('reasoning-delta'), type: 'reasoning_delta', text }];
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool-call' && chunk.toolCallId && chunk.toolName) {
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('tool-call'),
|
||||
type: 'tool_call',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
args: (chunk.input ?? chunk.args ?? {}) as Record<string, unknown>,
|
||||
}];
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool-result' && chunk.toolCallId) {
|
||||
const output = chunk.output ?? chunk.result;
|
||||
const resultText = typeof output === 'string' ? output : JSON.stringify(output ?? '');
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('tool-result'),
|
||||
type: 'tool_result',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
|
||||
result: resultText,
|
||||
isError: isToolResultError(output),
|
||||
}];
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool-error' && chunk.toolCallId) {
|
||||
const resultText = chunk.error instanceof Error
|
||||
? JSON.stringify({ error: chunk.error.message })
|
||||
: typeof chunk.error === 'string'
|
||||
? JSON.stringify({ error: chunk.error })
|
||||
: JSON.stringify({ error: String(chunk.error ?? 'Tool execution failed.') });
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('tool-result'),
|
||||
type: 'tool_result',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
|
||||
result: resultText,
|
||||
isError: true,
|
||||
}];
|
||||
}
|
||||
|
||||
if (chunk.type === 'error') {
|
||||
const message = chunk.error instanceof Error
|
||||
? chunk.error.message
|
||||
: String(chunk.error ?? 'Unknown stream error');
|
||||
return [{ ...base, id: nextEventId('error'), type: 'error', message }];
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool-approval-request') {
|
||||
const toolCallId = resolveStreamChunkToolCallId(chunk);
|
||||
const toolName = chunk.toolName ?? chunk.toolCall?.toolName;
|
||||
if (!toolCallId || !toolName) return [];
|
||||
return [{
|
||||
...base,
|
||||
id: nextEventId('approval-requested'),
|
||||
type: 'approval_requested',
|
||||
toolCallId,
|
||||
toolName,
|
||||
args: (chunk.input ?? chunk.args ?? chunk.toolCall?.input ?? {}) as Record<string, unknown>,
|
||||
}];
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool-approval-response') {
|
||||
const toolCallId = resolveStreamChunkToolCallId(chunk);
|
||||
if (!toolCallId) return [];
|
||||
const approved = chunk.approved === true;
|
||||
const events: AgentEvent[] = [{
|
||||
...base,
|
||||
id: nextEventId('approval-resolved'),
|
||||
type: 'approval_resolved',
|
||||
toolCallId,
|
||||
toolName: String(chunk.toolName ?? chunk.toolCall?.toolName ?? 'unknown'),
|
||||
outcome: approved ? 'approved' : 'denied',
|
||||
}];
|
||||
if (!approved) {
|
||||
events.push({
|
||||
...base,
|
||||
id: nextEventId('tool-result'),
|
||||
type: 'tool_result',
|
||||
toolCallId,
|
||||
result: JSON.stringify({ error: chunk.reason ?? 'Tool execution denied.' }),
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool-output-denied' && chunk.toolCallId) {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
id: nextEventId('approval-resolved'),
|
||||
type: 'approval_resolved',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: String(chunk.toolName ?? 'unknown'),
|
||||
outcome: 'denied',
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: nextEventId('tool-result'),
|
||||
type: 'tool_result',
|
||||
toolCallId: chunk.toolCallId,
|
||||
result: JSON.stringify({ error: 'Tool execution denied.' }),
|
||||
isError: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export { isStepHandleNoticeMessage };
|
||||
|
||||
export function createHarnessEventSink(
|
||||
listener: AgentEventListener,
|
||||
): AgentEventListener {
|
||||
return (event) => listener(event);
|
||||
}
|
||||
375
infrastructure/ai/harness/agentRuntime.test.ts
Normal file
375
infrastructure/ai/harness/agentRuntime.test.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { AgentRuntime } from './agentRuntime';
|
||||
import { TraceStore } from './traceStore';
|
||||
import { SessionStateStore } from './sessionState';
|
||||
import type { TurnDriver, TurnDriverContext, TurnInput } from './turnDrivers/types';
|
||||
|
||||
class MockTurnDriver implements TurnDriver {
|
||||
readonly backend = 'catty' as const;
|
||||
readonly runs: TurnInput[] = [];
|
||||
|
||||
async run(input: TurnInput, ctx: TurnDriverContext): Promise<void> {
|
||||
this.runs.push(input);
|
||||
ctx.emit({
|
||||
id: 'model-delta-1',
|
||||
type: 'model_delta',
|
||||
text: 'hello',
|
||||
} as import('./types').AgentEvent);
|
||||
if (input.signal.aborted) return;
|
||||
}
|
||||
|
||||
abort(): void {}
|
||||
}
|
||||
|
||||
test('AgentRuntime clearChatSession releases its trace history', () => {
|
||||
const traceStore = new TraceStore();
|
||||
const runtime = new AgentRuntime({ drivers: [new MockTurnDriver()], traceStore });
|
||||
traceStore.append({
|
||||
id: 'trace-clear-1',
|
||||
type: 'turn_start',
|
||||
sessionId: 'chat-clear',
|
||||
turnId: 'turn-clear',
|
||||
startedAt: Date.now(),
|
||||
} as import('./types').AgentEvent);
|
||||
|
||||
runtime.clearChatSession('chat-clear');
|
||||
assert.equal(traceStore.getEvents('chat-clear').length, 0);
|
||||
assert.equal(traceStore.getCompactions('chat-clear').length, 0);
|
||||
});
|
||||
|
||||
test('AgentRuntime runTurn emits turn lifecycle and records trace', async () => {
|
||||
const traceStore = new TraceStore();
|
||||
const driver = new MockTurnDriver();
|
||||
const runtime = new AgentRuntime({ drivers: [driver], traceStore });
|
||||
const events: string[] = [];
|
||||
runtime.subscribe(event => events.push(event.type));
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-1',
|
||||
sendScopeKey: 'chat-1',
|
||||
userText: 'hi',
|
||||
signal: controller.signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'assistant-1',
|
||||
context: {
|
||||
activeProvider: undefined,
|
||||
activeModelId: '',
|
||||
scopeType: 'terminal',
|
||||
globalPermissionMode: 'confirm',
|
||||
terminalSessions: [],
|
||||
autoTitleSession: () => {},
|
||||
},
|
||||
maxIterations: 5,
|
||||
ui: {
|
||||
addMessageToSession: () => {},
|
||||
updateLastMessage: () => {},
|
||||
updateMessageById: () => {},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.reason, 'completed');
|
||||
assert.equal(driver.runs.length, 1);
|
||||
assert.deepEqual(events, ['turn_start', 'model_delta', 'turn_end']);
|
||||
assert.equal(traceStore.getEvents('chat-1').length, 3);
|
||||
});
|
||||
|
||||
test('AgentRuntime records session state from tool call and result events', async () => {
|
||||
class ToolTurnDriver implements TurnDriver {
|
||||
readonly backend = 'catty' as const;
|
||||
async run(_input: TurnInput, ctx: TurnDriverContext): Promise<void> {
|
||||
ctx.emit({
|
||||
id: 'secret-call',
|
||||
type: 'tool_call',
|
||||
toolCallId: 'call-secret',
|
||||
toolName: 'terminal_execute',
|
||||
args: { sessionId: 'sess-1', command: 'curl -H "Authorization: Bearer secret_token_123456" https://example.test --password swordfish' },
|
||||
} as import('./types').AgentEvent);
|
||||
ctx.emit({
|
||||
id: 'tool-call-1',
|
||||
type: 'tool_call',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'terminal_execute',
|
||||
args: { sessionId: 'sess-1', command: 'uptime' },
|
||||
} as import('./types').AgentEvent);
|
||||
ctx.emit({
|
||||
id: 'tool-result-1',
|
||||
type: 'tool_result',
|
||||
toolCallId: 'call-1',
|
||||
result: 'ok',
|
||||
isError: false,
|
||||
} as import('./types').AgentEvent);
|
||||
}
|
||||
abort(): void {}
|
||||
}
|
||||
|
||||
const sessionStateStore = new SessionStateStore();
|
||||
const runtime = new AgentRuntime({ drivers: [new ToolTurnDriver()], sessionStateStore });
|
||||
await runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-tool',
|
||||
sendScopeKey: 'chat-tool',
|
||||
userText: 'check uptime',
|
||||
signal: new AbortController().signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'assistant-1',
|
||||
context: {
|
||||
activeProvider: undefined,
|
||||
activeModelId: '',
|
||||
scopeType: 'terminal',
|
||||
globalPermissionMode: 'confirm',
|
||||
terminalSessions: [],
|
||||
autoTitleSession: () => {},
|
||||
},
|
||||
maxIterations: 5,
|
||||
ui: {
|
||||
addMessageToSession: () => {},
|
||||
updateLastMessage: () => {},
|
||||
updateMessageById: () => {},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const text = sessionStateStore.toReinjectionText('chat-tool');
|
||||
assert.ok(text?.includes('uptime'));
|
||||
assert.ok(text?.includes('check uptime'));
|
||||
});
|
||||
|
||||
test('AgentRuntime keeps terminal output when session close reports failure', async () => {
|
||||
class FailedCloseDriver implements TurnDriver {
|
||||
readonly backend = 'external-sdk' as const;
|
||||
async run(_input: TurnInput, ctx: TurnDriverContext): Promise<void> {
|
||||
ctx.emit({
|
||||
id: 'close-call',
|
||||
type: 'tool_call',
|
||||
toolCallId: 'close-1',
|
||||
toolName: 'session_close',
|
||||
args: { sessionId: 'terminal-still-open' },
|
||||
} as import('./types').AgentEvent);
|
||||
ctx.emit({
|
||||
id: 'close-result',
|
||||
type: 'tool_result',
|
||||
toolCallId: 'close-1',
|
||||
result: JSON.stringify({ ok: false, error: 'close rejected' }),
|
||||
isError: false,
|
||||
} as import('./types').AgentEvent);
|
||||
}
|
||||
abort(): void {}
|
||||
}
|
||||
|
||||
const runtime = new AgentRuntime({ drivers: [new FailedCloseDriver()] });
|
||||
const store = runtime.getToolOutputStore('chat-close-failed');
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-close-failed',
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'terminal-still-open',
|
||||
content: 'keep me',
|
||||
});
|
||||
await runtime.runTurn({
|
||||
backend: 'external-sdk',
|
||||
chatSessionId: 'chat-close-failed',
|
||||
sendScopeKey: 'chat-close-failed',
|
||||
userText: 'close it',
|
||||
signal: new AbortController().signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'assistant-close-failed',
|
||||
context: {
|
||||
activeProvider: undefined,
|
||||
activeModelId: '',
|
||||
scopeType: 'terminal',
|
||||
globalPermissionMode: 'confirm',
|
||||
terminalSessions: [],
|
||||
autoTitleSession: () => {},
|
||||
},
|
||||
maxIterations: 5,
|
||||
ui: {
|
||||
addMessageToSession: () => {},
|
||||
updateLastMessage: () => {},
|
||||
updateMessageById: () => {},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(store.get(handle.id, 'chat-close-failed'));
|
||||
});
|
||||
|
||||
test('AgentRuntime redacts secrets before trace and listener fan-out', async () => {
|
||||
class SecretDriver implements TurnDriver {
|
||||
readonly backend = 'catty' as const;
|
||||
async run(_input: TurnInput, ctx: TurnDriverContext): Promise<void> {
|
||||
ctx.emit({
|
||||
id: 'secret-result',
|
||||
type: 'tool_result',
|
||||
toolCallId: 'call-secret',
|
||||
toolName: 'terminal_execute',
|
||||
result: 'API_TOKEN=tok_live_1234567890',
|
||||
} as import('./types').AgentEvent);
|
||||
}
|
||||
}
|
||||
const traceStore = new TraceStore();
|
||||
const runtime = new AgentRuntime({ drivers: [new SecretDriver()], traceStore });
|
||||
const heard: string[] = [];
|
||||
runtime.subscribe(event => {
|
||||
if (event.type === 'tool_result') heard.push(event.result);
|
||||
});
|
||||
await runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-secret',
|
||||
sendScopeKey: 'chat-secret',
|
||||
userText: 'check',
|
||||
signal: new AbortController().signal,
|
||||
assistantMsgId: 'assistant-1',
|
||||
context: {
|
||||
activeProvider: undefined,
|
||||
activeModelId: '',
|
||||
scopeType: 'terminal',
|
||||
globalPermissionMode: 'confirm',
|
||||
terminalSessions: [],
|
||||
autoTitleSession: () => {},
|
||||
},
|
||||
maxIterations: 1,
|
||||
ui: {
|
||||
addMessageToSession: () => {}, updateLastMessage: () => {}, updateMessageById: () => {},
|
||||
reportStreamError: () => {}, setStreamingForScope: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.doesNotMatch(JSON.stringify(traceStore.exportTrace('chat-secret')), /tok_live/);
|
||||
assert.doesNotMatch(JSON.stringify(traceStore.exportTrace('chat-secret')), /secret_token|swordfish/);
|
||||
assert.deepEqual(heard, ['API_TOKEN=[REDACTED]']);
|
||||
});
|
||||
|
||||
test('AgentRuntime stopTurn delegates to active driver', async () => {
|
||||
const driver = new MockTurnDriver();
|
||||
const runtime = new AgentRuntime({ drivers: [driver] });
|
||||
await runtime.stopTurn('chat-2');
|
||||
});
|
||||
|
||||
test('AgentRuntime delegates steering only while a compatible turn is active', async () => {
|
||||
let releaseRun: (() => void) | undefined;
|
||||
class SteeringDriver implements TurnDriver {
|
||||
readonly backend = 'catty' as const;
|
||||
async run(): Promise<void> {
|
||||
await new Promise<void>(resolve => { releaseRun = resolve; });
|
||||
}
|
||||
async steer() {
|
||||
return { status: 'accepted' as const, assistantMessageId: 'assistant-next' };
|
||||
}
|
||||
}
|
||||
const runtime = new AgentRuntime({ drivers: [new SteeringDriver()] });
|
||||
const run = runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-steer',
|
||||
sendScopeKey: 'chat-steer',
|
||||
userText: 'initial',
|
||||
signal: new AbortController().signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'assistant-1',
|
||||
context: {
|
||||
activeProvider: undefined,
|
||||
activeModelId: '',
|
||||
scopeType: 'terminal',
|
||||
globalPermissionMode: 'confirm',
|
||||
terminalSessions: [],
|
||||
autoTitleSession: () => {},
|
||||
},
|
||||
maxIterations: 5,
|
||||
ui: {
|
||||
addMessageToSession: () => {},
|
||||
updateLastMessage: () => {},
|
||||
updateMessageById: () => {},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
},
|
||||
});
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(await runtime.steerTurn({
|
||||
chatSessionId: 'chat-steer',
|
||||
userMessageId: 'user-1',
|
||||
userText: 'change',
|
||||
prompt: 'change',
|
||||
attachedImages: [],
|
||||
}), { status: 'accepted', assistantMessageId: 'assistant-next' });
|
||||
|
||||
releaseRun?.();
|
||||
await run;
|
||||
assert.deepEqual(await runtime.steerTurn({
|
||||
chatSessionId: 'chat-steer',
|
||||
userMessageId: 'user-2',
|
||||
userText: 'too late',
|
||||
prompt: 'too late',
|
||||
attachedImages: [],
|
||||
}), { status: 'inactive' });
|
||||
});
|
||||
|
||||
test('AgentRuntime runTurn rejects a concurrent start for the same session', async () => {
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
|
||||
class BusyMockDriver implements TurnDriver {
|
||||
readonly backend = 'catty' as const;
|
||||
readonly runs: TurnInput[] = [];
|
||||
|
||||
async run(input: TurnInput): Promise<void> {
|
||||
this.runs.push(input);
|
||||
await firstGate;
|
||||
}
|
||||
|
||||
abort(): void {}
|
||||
}
|
||||
|
||||
const driver = new BusyMockDriver();
|
||||
const runtime = new AgentRuntime({ drivers: [driver], traceStore: new TraceStore() });
|
||||
const ui = {
|
||||
addMessageToSession: () => {},
|
||||
updateLastMessage: () => {},
|
||||
updateMessageById: () => {},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
};
|
||||
|
||||
const first = runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-busy',
|
||||
sendScopeKey: 'scope',
|
||||
userText: 'one',
|
||||
signal: new AbortController().signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'a1',
|
||||
context: {} as never,
|
||||
maxIterations: 1,
|
||||
bridge: null,
|
||||
ui,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-busy',
|
||||
sendScopeKey: 'scope',
|
||||
userText: 'two',
|
||||
signal: new AbortController().signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'a2',
|
||||
context: {} as never,
|
||||
maxIterations: 1,
|
||||
bridge: null,
|
||||
ui,
|
||||
}),
|
||||
(err: unknown) => err instanceof Error
|
||||
&& (err as Error & { code?: string }).code === 'AGENT_TURN_BUSY',
|
||||
);
|
||||
|
||||
releaseFirst?.();
|
||||
await first;
|
||||
assert.equal(driver.runs.length, 1);
|
||||
});
|
||||
274
infrastructure/ai/harness/agentRuntime.ts
Normal file
274
infrastructure/ai/harness/agentRuntime.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { globalTraceStore } from './traceStore';
|
||||
import { stopAgentTurn } from './agentStop';
|
||||
import type { AgentBackend, AgentEvent, AgentEventListener } from './types';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
import { ToolResultDedup } from './toolResultDedup';
|
||||
import { SessionStateStore } from './sessionState';
|
||||
import type {
|
||||
TurnDriver,
|
||||
TurnInput,
|
||||
TurnResult,
|
||||
TurnSteerInput,
|
||||
TurnSteerResult,
|
||||
} from './turnDrivers/types';
|
||||
import { globalTwoPassCompactionCache } from './twoPassCompaction';
|
||||
import { redactSecretsForModel, redactSecretsInValueForModel } from './modelSecretRedaction';
|
||||
import { globalTerminalMonitorGuard } from './terminalMonitorGuard';
|
||||
|
||||
let turnCounter = 0;
|
||||
|
||||
function nextTurnId(): string {
|
||||
turnCounter += 1;
|
||||
return `turn-${Date.now()}-${turnCounter}`;
|
||||
}
|
||||
|
||||
function isFailedToolResult(result: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(result) as Record<string, unknown>;
|
||||
return parsed?.ok === false || typeof parsed?.error === 'string';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveTurn {
|
||||
turnId: string;
|
||||
backend: AgentBackend;
|
||||
driver: TurnDriver;
|
||||
}
|
||||
|
||||
export interface AgentRuntimeOptions {
|
||||
drivers: TurnDriver[];
|
||||
traceStore?: typeof globalTraceStore;
|
||||
sessionStateStore?: SessionStateStore;
|
||||
}
|
||||
|
||||
export class AgentRuntime {
|
||||
private readonly drivers = new Map<AgentBackend, TurnDriver>();
|
||||
private readonly listeners = new Set<AgentEventListener>();
|
||||
private readonly activeTurns = new Map<string, ActiveTurn>();
|
||||
private readonly activeTurnPromises = new Map<string, Promise<TurnResult>>();
|
||||
private readonly toolOutputStore = new ToolOutputStore();
|
||||
private readonly sessionStateStore: SessionStateStore;
|
||||
private readonly traceStore: typeof globalTraceStore;
|
||||
|
||||
constructor(options: AgentRuntimeOptions) {
|
||||
for (const driver of options.drivers) {
|
||||
this.drivers.set(driver.backend, driver);
|
||||
}
|
||||
this.traceStore = options.traceStore ?? globalTraceStore;
|
||||
this.sessionStateStore = options.sessionStateStore ?? new SessionStateStore();
|
||||
}
|
||||
|
||||
getToolOutputStore(chatSessionId: string): ToolOutputStore {
|
||||
void chatSessionId;
|
||||
return this.toolOutputStore;
|
||||
}
|
||||
|
||||
getSessionStateStore(): SessionStateStore {
|
||||
return this.sessionStateStore;
|
||||
}
|
||||
|
||||
clearChatSession(chatSessionId: string): void {
|
||||
this.toolOutputStore.prune(chatSessionId);
|
||||
this.sessionStateStore.clear(chatSessionId);
|
||||
this.traceStore.clear(chatSessionId);
|
||||
globalTwoPassCompactionCache.clear(chatSessionId);
|
||||
globalTerminalMonitorGuard.clearPrefix(`${chatSessionId}:`);
|
||||
}
|
||||
|
||||
clearTerminalSession(terminalSessionId: string): void {
|
||||
this.toolOutputStore.pruneTerminalSessionEverywhere(terminalSessionId);
|
||||
}
|
||||
|
||||
async waitForActiveTurn(chatSessionId: string): Promise<void> {
|
||||
await this.activeTurnPromises.get(chatSessionId)?.catch(() => {});
|
||||
}
|
||||
|
||||
subscribe(listener: AgentEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async runTurn(input: TurnInput): Promise<TurnResult> {
|
||||
const driver = this.drivers.get(input.backend);
|
||||
if (!driver) {
|
||||
throw new Error(`No TurnDriver registered for backend "${input.backend}"`);
|
||||
}
|
||||
|
||||
const chatSessionId = input.chatSessionId;
|
||||
if (this.activeTurnPromises.has(chatSessionId)) {
|
||||
const error = new Error(`Agent turn already in progress for session ${chatSessionId}`);
|
||||
(error as Error & { code?: string }).code = 'AGENT_TURN_BUSY';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const turnPromise = this.runTurnInternal(input, driver);
|
||||
this.activeTurnPromises.set(chatSessionId, turnPromise);
|
||||
try {
|
||||
return await turnPromise;
|
||||
} finally {
|
||||
if (this.activeTurnPromises.get(chatSessionId) === turnPromise) {
|
||||
this.activeTurnPromises.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runTurnInternal(input: TurnInput, driver: TurnDriver): Promise<TurnResult> {
|
||||
const turnId = nextTurnId();
|
||||
const chatSessionId = input.chatSessionId;
|
||||
const toolOutputStore = this.getToolOutputStore(chatSessionId);
|
||||
const toolResultDedup = new ToolResultDedup();
|
||||
toolResultDedup.beginTurn();
|
||||
const sessionStateStore = this.sessionStateStore;
|
||||
|
||||
if (input.backend === 'catty') {
|
||||
sessionStateStore.mergeFromUserGoal(chatSessionId, input.userText);
|
||||
}
|
||||
|
||||
this.activeTurns.set(chatSessionId, {
|
||||
turnId,
|
||||
backend: input.backend,
|
||||
driver,
|
||||
});
|
||||
|
||||
const toolCallMeta = new Map<string, { toolName: string; args: Record<string, unknown> }>();
|
||||
|
||||
const emit = (
|
||||
partial: Omit<AgentEvent, 'turnId' | 'sessionId' | 'chatSessionId' | 'backend' | 'timestamp'>
|
||||
& Partial<Pick<AgentEvent, 'turnId' | 'sessionId' | 'chatSessionId' | 'backend' | 'timestamp'>>,
|
||||
) => {
|
||||
let event = {
|
||||
id: partial.id,
|
||||
type: partial.type,
|
||||
sessionId: partial.sessionId ?? chatSessionId,
|
||||
chatSessionId: partial.chatSessionId ?? chatSessionId,
|
||||
backend: partial.backend ?? input.backend,
|
||||
timestamp: partial.timestamp ?? Date.now(),
|
||||
turnId: partial.turnId ?? turnId,
|
||||
...partial,
|
||||
} as AgentEvent;
|
||||
|
||||
if (event.type === 'tool_result') {
|
||||
event = {
|
||||
...event,
|
||||
result: redactSecretsForModel(event.result),
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === 'tool_call') {
|
||||
event = {
|
||||
...event,
|
||||
args: redactSecretsInValueForModel(event.args),
|
||||
};
|
||||
toolCallMeta.set(event.toolCallId, {
|
||||
toolName: event.toolName,
|
||||
args: event.args,
|
||||
});
|
||||
}
|
||||
if (event.type === 'tool_result') {
|
||||
const toolResultIsError = event.isError || isFailedToolResult(event.result);
|
||||
const meta = toolCallMeta.get(event.toolCallId);
|
||||
const toolName = event.toolName ?? meta?.toolName;
|
||||
if (toolName) {
|
||||
sessionStateStore.updateFromToolResult(
|
||||
chatSessionId,
|
||||
toolName,
|
||||
meta?.args,
|
||||
event.result,
|
||||
toolResultIsError,
|
||||
);
|
||||
if (
|
||||
!toolResultIsError
|
||||
&& (toolName === 'session_close' || toolName === 'session.close')
|
||||
&& typeof meta?.args.sessionId === 'string'
|
||||
) {
|
||||
toolOutputStore.pruneTerminalSessionEverywhere(meta.args.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.type === 'model_delta' && event.text) {
|
||||
sessionStateStore.mergeFromAssistantContent(chatSessionId, event.text);
|
||||
}
|
||||
if (event.type === 'file_change' && event.status === 'completed') {
|
||||
sessionStateStore.mergeFileChanges(
|
||||
chatSessionId,
|
||||
event.changes.map(change => change.path),
|
||||
);
|
||||
}
|
||||
if (event.type === 'plan_update') {
|
||||
sessionStateStore.mergePlan(chatSessionId, event.items);
|
||||
}
|
||||
|
||||
this.traceStore.append(event);
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
};
|
||||
|
||||
emit({
|
||||
id: `turn-start-${turnId}`,
|
||||
type: 'turn_start',
|
||||
backendLabel: input.backend === 'external-sdk'
|
||||
? ('agentConfig' in input ? input.agentConfig.name : undefined)
|
||||
: 'catty',
|
||||
} as AgentEvent);
|
||||
|
||||
let reason: TurnResult['reason'] = 'completed';
|
||||
|
||||
try {
|
||||
await driver.run(input, {
|
||||
turnId,
|
||||
chatSessionId,
|
||||
sessionId: chatSessionId,
|
||||
backend: input.backend,
|
||||
signal: input.signal,
|
||||
emit,
|
||||
toolOutputStore,
|
||||
toolResultDedup,
|
||||
sessionStateStore,
|
||||
});
|
||||
if (input.signal.aborted) {
|
||||
reason = 'aborted';
|
||||
}
|
||||
} catch (err) {
|
||||
reason = 'error';
|
||||
emit({
|
||||
id: `turn-error-${turnId}`,
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
recoverable: false,
|
||||
} as AgentEvent);
|
||||
throw err;
|
||||
} finally {
|
||||
emit({
|
||||
id: `turn-end-${turnId}`,
|
||||
type: 'turn_end',
|
||||
reason,
|
||||
} as AgentEvent);
|
||||
this.activeTurns.delete(chatSessionId);
|
||||
}
|
||||
|
||||
return { turnId, reason };
|
||||
}
|
||||
|
||||
async stopTurn(chatSessionId: string, reason: 'user' | 'slash' = 'user'): Promise<void> {
|
||||
const active = this.activeTurns.get(chatSessionId);
|
||||
active?.driver.abort?.(chatSessionId);
|
||||
await stopAgentTurn({
|
||||
chatSessionId,
|
||||
reason,
|
||||
backend: active?.backend ?? 'catty',
|
||||
});
|
||||
await this.waitForActiveTurn(chatSessionId);
|
||||
}
|
||||
|
||||
async steerTurn(input: TurnSteerInput): Promise<TurnSteerResult> {
|
||||
const active = this.activeTurns.get(input.chatSessionId);
|
||||
if (!active) return { status: 'inactive' };
|
||||
if (!active.driver.steer) return { status: 'unsupported' };
|
||||
return active.driver.steer(input);
|
||||
}
|
||||
}
|
||||
40
infrastructure/ai/harness/agentStop.test.ts
Normal file
40
infrastructure/ai/harness/agentStop.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { stopAgentTurn } from './agentStop';
|
||||
import { globalTraceStore } from './traceStore';
|
||||
|
||||
describe('stopAgentTurn', () => {
|
||||
it('aborts, cancels bridge surfaces, and emits turn_end', async () => {
|
||||
const calls: string[] = [];
|
||||
const controller = new AbortController();
|
||||
const sessionId = 'chat-stop-test';
|
||||
|
||||
globalTraceStore.clear(sessionId);
|
||||
|
||||
await stopAgentTurn({
|
||||
chatSessionId: sessionId,
|
||||
abortController: controller,
|
||||
bridge: {
|
||||
aiCattyCancelExec: async (id) => { calls.push(`catty:${id}`); },
|
||||
aiSdkAgentCancel: async (_req, id) => { calls.push(`sdk:${id}`); return { ok: true }; },
|
||||
aiSetChatSessionCancelled: async (id, cancelled) => {
|
||||
calls.push(`cancelled:${id}:${cancelled}`);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
reason: 'slash',
|
||||
backend: 'catty',
|
||||
});
|
||||
|
||||
assert.equal(controller.signal.aborted, true);
|
||||
assert.deepEqual(calls, [
|
||||
`catty:${sessionId}`,
|
||||
`sdk:${sessionId}`,
|
||||
`cancelled:${sessionId}:true`,
|
||||
]);
|
||||
|
||||
const events = globalTraceStore.getEvents(sessionId);
|
||||
assert.equal(events.at(-1)?.type, 'turn_end');
|
||||
assert.equal((events.at(-1) as { reason?: string }).reason, 'aborted');
|
||||
});
|
||||
});
|
||||
73
infrastructure/ai/harness/agentStop.ts
Normal file
73
infrastructure/ai/harness/agentStop.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { clearAllPendingApprovals } from '../shared/approvalGate';
|
||||
import { globalTraceStore } from './traceStore';
|
||||
import type { AgentBackend } from './types';
|
||||
|
||||
export type StopAgentTurnReason = 'user' | 'slash';
|
||||
|
||||
export interface AgentStopBridge {
|
||||
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
|
||||
aiSdkAgentCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export interface StopAgentTurnParams {
|
||||
chatSessionId: string;
|
||||
abortController?: AbortController | null;
|
||||
bridge?: AgentStopBridge | null;
|
||||
reason?: StopAgentTurnReason;
|
||||
backend?: AgentBackend;
|
||||
}
|
||||
|
||||
let stopEventCounter = 0;
|
||||
|
||||
function nextStopEventId(): string {
|
||||
stopEventCounter += 1;
|
||||
return `turn-end-${Date.now()}-${stopEventCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified stop entry for Catty, external SDK, and MCP tool surfaces.
|
||||
*/
|
||||
export async function stopAgentTurn({
|
||||
chatSessionId,
|
||||
abortController,
|
||||
bridge,
|
||||
reason = 'user',
|
||||
backend = 'catty',
|
||||
}: StopAgentTurnParams): Promise<void> {
|
||||
abortController?.abort();
|
||||
clearAllPendingApprovals(chatSessionId);
|
||||
|
||||
const tasks: Array<Promise<unknown>> = [];
|
||||
if (bridge?.aiCattyCancelExec) {
|
||||
tasks.push(bridge.aiCattyCancelExec(chatSessionId).catch(() => {}));
|
||||
}
|
||||
if (bridge?.aiSdkAgentCancel) {
|
||||
tasks.push(bridge.aiSdkAgentCancel('', chatSessionId).catch(() => {}));
|
||||
}
|
||||
if (bridge?.aiSetChatSessionCancelled) {
|
||||
tasks.push(bridge.aiSetChatSessionCancelled(chatSessionId, true).catch(() => {}));
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
|
||||
globalTraceStore.append({
|
||||
id: nextStopEventId(),
|
||||
type: 'turn_end',
|
||||
sessionId: chatSessionId,
|
||||
chatSessionId,
|
||||
backend,
|
||||
timestamp: Date.now(),
|
||||
reason: 'aborted',
|
||||
...(reason === 'slash' ? { backendLabel: 'slash-stop' } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the main-process cancelled flag when a new agent turn begins.
|
||||
*/
|
||||
export async function clearChatSessionCancelled(
|
||||
chatSessionId: string,
|
||||
bridge?: AgentStopBridge | null,
|
||||
): Promise<void> {
|
||||
await bridge?.aiSetChatSessionCancelled?.(chatSessionId, false).catch(() => {});
|
||||
}
|
||||
152
infrastructure/ai/harness/builtinSkillRunner.ts
Normal file
152
infrastructure/ai/harness/builtinSkillRunner.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Runner for built-in diagnostic skills.
|
||||
*
|
||||
* Fetches the skill by id, filters its steps for the target session's shell
|
||||
* family, executes each step sequentially via the bridge, and assembles a
|
||||
* single structured report with labels, stdout/stderr, and exit codes.
|
||||
*/
|
||||
|
||||
import { getBuiltinSkill, filterStepsForShell, type SkillStep } from './builtinSkills';
|
||||
import type { ToolDeps, ToolExecResult } from '../shared/toolExecutors';
|
||||
|
||||
interface RunOptions {
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
command: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
durationMs: number;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export interface SkillRunReport {
|
||||
skill: string;
|
||||
sessionId: string;
|
||||
shellType?: string;
|
||||
os?: string;
|
||||
hostname?: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
steps: StepResult[];
|
||||
}
|
||||
|
||||
export async function executeSkillRun(
|
||||
deps: ToolDeps,
|
||||
args: Record<string, unknown>,
|
||||
options: RunOptions = {},
|
||||
): Promise<ToolExecResult<SkillRunReport>> {
|
||||
const { bridge, context, permissionMode } = deps;
|
||||
const resolveContext = () => (typeof context === 'function' ? context() : context);
|
||||
const ctx = resolveContext();
|
||||
|
||||
const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
|
||||
const skillName = typeof args.skillName === 'string' ? args.skillName.trim() : '';
|
||||
|
||||
if (!sessionId) {
|
||||
return { ok: false, error: 'Missing sessionId.' };
|
||||
}
|
||||
if (!skillName) {
|
||||
return { ok: false, error: 'Missing skillName.' };
|
||||
}
|
||||
|
||||
if (permissionMode === 'observer') {
|
||||
return { ok: false, error: 'Observer mode: skill execution is disabled. Switch to Confirm or Auto mode.' };
|
||||
}
|
||||
|
||||
// Validate session is in scope
|
||||
const session = ctx.sessions.find(s => s.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: `Session "${sessionId}" is not in the current AI scope.` };
|
||||
}
|
||||
|
||||
// Resolve skill
|
||||
const skill = getBuiltinSkill(skillName);
|
||||
if (!skill) {
|
||||
const available = Object.keys((await import('./builtinSkills')).BUILTIN_SKILLS).join(', ');
|
||||
return { ok: false, error: `Unknown skill "${skillName}". Available: ${available}` };
|
||||
}
|
||||
|
||||
// Filter steps for this shell family
|
||||
const steps = filterStepsForShell(skill, session.shellType);
|
||||
if (steps.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Skill "${skill.id}" has no steps for shell family "${session.shellType ?? 'unknown'}".`,
|
||||
};
|
||||
}
|
||||
|
||||
// Run each step sequentially, capture everything
|
||||
const startedAt = new Date();
|
||||
const stepResults: StepResult[] = [];
|
||||
|
||||
for (const step of steps) {
|
||||
const stepResult = await runStep(bridge, sessionId, step, options.chatSessionId);
|
||||
stepResults.push(stepResult);
|
||||
}
|
||||
|
||||
const completedAt = new Date();
|
||||
const report: SkillRunReport = {
|
||||
skill: skill.id,
|
||||
sessionId,
|
||||
shellType: session.shellType,
|
||||
os: session.os,
|
||||
hostname: session.hostname,
|
||||
startedAt: startedAt.toISOString(),
|
||||
completedAt: completedAt.toISOString(),
|
||||
durationMs: completedAt.getTime() - startedAt.getTime(),
|
||||
steps: stepResults,
|
||||
};
|
||||
|
||||
return { ok: true, data: report };
|
||||
}
|
||||
|
||||
async function runStep(
|
||||
bridge: NonNullable<ToolDeps['bridge']>,
|
||||
sessionId: string,
|
||||
step: SkillStep,
|
||||
chatSessionId?: string,
|
||||
): Promise<StepResult> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const result = await bridge.aiExec(sessionId, step.command, chatSessionId);
|
||||
const durationMs = Date.now() - t0;
|
||||
|
||||
if (!result.ok && result.error) {
|
||||
return {
|
||||
label: step.label,
|
||||
command: step.command,
|
||||
stdout: result.stdout || '',
|
||||
stderr: `[exec error] ${result.error}`,
|
||||
exitCode: null,
|
||||
durationMs,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: step.label,
|
||||
command: step.command,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
exitCode: result.exitCode ?? -1,
|
||||
durationMs,
|
||||
ok: (result.exitCode ?? 0) === 0,
|
||||
};
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - t0;
|
||||
return {
|
||||
label: step.label,
|
||||
command: step.command,
|
||||
stdout: '',
|
||||
stderr: `[exception] ${err instanceof Error ? err.message : String(err)}`,
|
||||
exitCode: null,
|
||||
durationMs,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
131
infrastructure/ai/harness/builtinSkills.ts
Normal file
131
infrastructure/ai/harness/builtinSkills.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Built-in diagnostic skills — pre-crafted multi-step shell command bundles
|
||||
* targeting specific OS families. The Catty Agent calls `skill_run` with a
|
||||
* skillName and sessionId; this module expands that into the right commands
|
||||
* for the host's detected OS/shellType.
|
||||
*
|
||||
* Each skill is an ordered list of { label, shell, command } entries. The
|
||||
* executor runs them sequentially and assembles the outputs into a single
|
||||
* structured report the LLM can summarize for the user.
|
||||
*/
|
||||
|
||||
export type SkillShellTarget = 'posix' | 'powershell' | 'cmd' | 'any';
|
||||
|
||||
export interface SkillStep {
|
||||
/** Short human label shown in the assembled report. */
|
||||
label: string;
|
||||
/** Shell family this step is for. 'any' = run regardless. */
|
||||
shell: SkillShellTarget;
|
||||
/** Command to send to the terminal. */
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface BuiltinSkill {
|
||||
id: string;
|
||||
/** Short description the model sees in the tool's docstring. */
|
||||
description: string;
|
||||
/** If true, requires the session to be a remote SSH host. */
|
||||
requiresRemoteHost?: boolean;
|
||||
/** Steps grouped by OS family. */
|
||||
steps: SkillStep[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill registry — keep alphabetical by id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BUILTIN_SKILLS: Record<string, BuiltinSkill> = {
|
||||
// ---- diagnose_linux -----------------------------------------------------
|
||||
diagnose_linux: {
|
||||
id: 'diagnose_linux',
|
||||
description:
|
||||
'Quick Linux health check — CPU, memory, disk, load top processes, listening ports, Docker status, failed systemd services, recent kernel errors.',
|
||||
requiresRemoteHost: true,
|
||||
steps: [
|
||||
{ label: 'OS / kernel / uptime', shell: 'posix', command: 'uname -a; uptime; cat /etc/os-release 2>/dev/null | head -5' },
|
||||
{ label: 'CPU & memory', shell: 'posix', command: "echo '--- free -h ---'; free -h; echo '--- vmstat 1 2 ---'; vmstat 1 2 | tail -1" },
|
||||
{ label: 'Disk usage', shell: 'posix', command: "df -h 2>/dev/null; echo '--- inodes ---'; df -i 2>/dev/null | head -10" },
|
||||
{ label: 'Top processes by CPU', shell: 'posix', command: 'ps aux --sort=-%cpu 2>/dev/null | head -10 || ps aux | sort -k3 -rn | head -10' },
|
||||
{ label: 'Listening ports', shell: 'posix', command: "ss -tlnp 2>/dev/null | head -20 || netstat -tlnp 2>/dev/null | head -20" },
|
||||
{ label: 'Docker status (if present)', shell: 'posix', command: 'command -v docker >/dev/null 2>&1 && (docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>&1 | head -15) || echo "docker not installed"' },
|
||||
{ label: 'Failed systemd services', shell: 'posix', command: "systemctl --failed --no-pager 2>/dev/null | head -20 || echo 'systemd not available'" },
|
||||
{ label: 'Recent kernel errors', shell: 'posix', command: "dmesg --level=err -n 2>/dev/null | tail -15 || journalctl -p err -n 15 --no-pager 2>/dev/null || echo 'no kernel error log available'" },
|
||||
],
|
||||
},
|
||||
|
||||
// ---- diagnose_windows ---------------------------------------------------
|
||||
diagnose_windows: {
|
||||
id: 'diagnose_windows',
|
||||
description:
|
||||
'Quick Windows health check via PowerShell — OS, CPU, memory, disk, top processes, services, network adapters, recent errors.',
|
||||
requiresRemoteHost: true,
|
||||
steps: [
|
||||
{ label: 'OS & uptime', shell: 'powershell', command: '$os = Get-CimInstance Win32_OperatingSystem; "Computer: $($env:COMPUTERNAME)"; "OS: $($os.Caption) $($os.Version)"; "Uptime: $([math]::Round(((Get-Date) - $os.LastBootUpTime).TotalHours, 1)) hours"' },
|
||||
{ label: 'CPU', shell: 'powershell', command: 'Get-CimInstance Win32_Processor | Select-Object Name, NumberOfCores, LoadPercentage | Format-List' },
|
||||
{ label: 'Memory', shell: 'powershell', command: '$cs = Get-CimInstance Win32_ComputerSystem; $os = Get-CimInstance Win32_OperatingSystem; $totalGB = [math]::Round($cs.TotalPhysicalMemory/1GB, 1); $freeGB = [math]::Round($os.FreePhysicalMemory/1MB, 1); "Total: ${totalGB} GB, Free: ${freeGB} GB, Used: $([math]::Round(($totalGB - $freeGB)/$totalGB*100, 1))%"' },
|
||||
{ label: 'Disk', shell: 'powershell', command: 'Get-Volume -DriveLetter * | Where-Object DriveLetter | Select-Object DriveLetter, FileSystemLabel, FileSystem, @{N="TotalGB";E={[math]::Round($_.Size/1GB,1)}}, @{N="FreeGB";E={[math]::Round($_.SizeRemaining/1GB,1)}} | Format-Table -AutoSize' },
|
||||
{ label: 'Top processes (working set)', shell: 'powershell', command: 'Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10 Name, Id, @{N="MB";E={[math]::Round($_.WorkingSet64/1MB,0)}}, CPU | Format-Table -AutoSize' },
|
||||
{ label: 'Services running', shell: 'powershell', command: 'Get-Service | Where-Object Status -eq Running | Measure-Object | Select-Object -ExpandProperty Count | ForEach-Object { "$_ services running" }; Get-Service | Where-Object {$_.Status -ne "Running" -and $_.StartType -ne "Disabled"} | Select-Object -First 15 Name, Status, StartType | Format-Table -AutoSize' },
|
||||
{ label: 'Listening ports', shell: 'powershell', command: 'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | Sort-Object LocalPort | Format-Table -AutoSize' },
|
||||
{ label: 'Recent errors (System log)', shell: 'powershell', command: 'Get-WinEvent -LogName System -MaxEvents 50 -ErrorAction SilentlyContinue | Where-Object {$_.LevelDisplayName -eq "Error"} | Select-Object -First 10 TimeCreated, Id, ProviderName, Message | Format-List' },
|
||||
],
|
||||
},
|
||||
|
||||
// ---- check_ports --------------------------------------------------------
|
||||
check_ports: {
|
||||
id: 'check_ports',
|
||||
description: 'Show all listening TCP/UDP ports with process info (ss/netstat on Linux, netstat/Get-NetTCPConnection on Windows).',
|
||||
steps: [
|
||||
{ label: 'Listening ports (POSIX)', shell: 'posix', command: "echo '=== ss ==='; ss -tulnp 2>/dev/null || echo 'ss not available'; echo '=== netstat ==='; netstat -tulnp 2>/dev/null | head -30 || true" },
|
||||
{ label: 'Listening ports (PowerShell)', shell: 'powershell', command: 'Write-Host "=== TCP Listen ===" ; Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | Sort-Object LocalPort | Format-Table -AutoSize; Write-Host "=== UDP ===" ; Get-NetUDPEndpoint -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | Sort-Object LocalPort | Format-Table -AutoSize' },
|
||||
],
|
||||
},
|
||||
|
||||
// ---- check_docker -------------------------------------------------------
|
||||
check_docker: {
|
||||
id: 'check_docker',
|
||||
description: 'Check Docker daemon health, running containers, disk usage, and recent images.',
|
||||
requiresRemoteHost: true,
|
||||
steps: [
|
||||
{ label: 'Docker version / info', shell: 'any', command: 'docker version 2>&1 | head -15; echo "---"; docker info 2>&1 | head -20' },
|
||||
{ label: 'Containers (all)', shell: 'any', command: 'docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>&1' },
|
||||
{ label: 'Docker disk usage', shell: 'any', command: 'docker system df 2>&1' },
|
||||
{ label: 'Top images', shell: 'any', command: 'docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" 2>&1 | head -15' },
|
||||
],
|
||||
},
|
||||
|
||||
// ---- security_audit -----------------------------------------------------
|
||||
security_audit: {
|
||||
id: 'security_audit',
|
||||
description: 'Basic Linux security posture check — SSH config, firewall, failed SSH logins, world-writable files, listening ports.',
|
||||
requiresRemoteHost: true,
|
||||
steps: [
|
||||
{ label: 'SSHD config quick check', shell: 'posix', command: "echo '=== PermitRootLogin ==='; grep -i 'PermitRootLogin' /etc/ssh/sshd_config 2>/dev/null || echo '(not set = default)'; echo '=== PasswordAuthentication ==='; grep -i 'PasswordAuthentication' /etc/ssh/sshd_config 2>/dev/null || echo '(not set = default)'" },
|
||||
{ label: 'Firewall status', shell: 'posix', command: "echo '=== ufw ==='; command -v ufw >/dev/null 2>&1 && ufw status 2>&1 || echo 'ufw not installed'; echo '=== firewalld ==='; command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state 2>&1 || echo 'firewalld not installed'" },
|
||||
{ label: 'Failed SSH logins (last)', shell: 'posix', command: "echo '=== Recent failed SSH ==='; lastb 2>/dev/null | head -10 || echo 'lastb not available'" },
|
||||
{ label: 'World-writable files in /tmp (non-sticky)', shell: 'posix', command: 'find /tmp -maxdepth 2 -type f ! -sticky -perm -0002 2>/dev/null | head -10 || echo "ok"' },
|
||||
{ label: 'Listening ports', shell: 'posix', command: "ss -tlnp 2>/dev/null | head -20 || netstat -tlnp 2>/dev/null | head -20" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** All skill ids, exported so callers can validate. */
|
||||
export const BUILTIN_SKILL_IDS = Object.keys(BUILTIN_SKILLS);
|
||||
|
||||
/** Resolve a skill by id (case-insensitive). */
|
||||
export function getBuiltinSkill(skillId: string): BuiltinSkill | null {
|
||||
if (!skillId) return null;
|
||||
return BUILTIN_SKILLS[skillId.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/** Return steps matching the host's shell family (run 'any' on every host). */
|
||||
export function filterStepsForShell(skill: BuiltinSkill, shellType?: string): SkillStep[] {
|
||||
const shell = (shellType || 'posix').toLowerCase();
|
||||
return skill.steps.filter(step => {
|
||||
if (step.shell === 'any') return true;
|
||||
if (step.shell === shell) return true;
|
||||
// Treat 'fish' / unknown POSIX-ish shells as posix for diagnostics.
|
||||
if (step.shell === 'posix' && (shell === 'fish' || shell === 'unknown' || shell === 'posix')) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
643
infrastructure/ai/harness/capabilityTools.test.ts
Normal file
643
infrastructure/ai/harness/capabilityTools.test.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCattyToolsFromCatalog,
|
||||
resolveSessionQueueKeyForTests,
|
||||
withCattyToolContext,
|
||||
} from './capabilityTools';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
import { buildTerminalWriteFingerprint, ToolResultDedup } from './toolResultDedup';
|
||||
import { collectPreservedTerminalWriteFingerprints } from './turnDrivers/cattyMessageBuilder';
|
||||
|
||||
describe('capabilityTools session queue keys', () => {
|
||||
it('does not queue read-only harness tools behind terminal session writes', () => {
|
||||
const key = resolveSessionQueueKeyForTests(
|
||||
{
|
||||
capabilityId: 'harness.workspace.get_session_info',
|
||||
toolName: 'workspace_get_session_info',
|
||||
policy: { write: false, bypassesApproval: true },
|
||||
},
|
||||
{ sessionId: 'session-a' },
|
||||
'chat-1',
|
||||
);
|
||||
assert.equal(key, null);
|
||||
});
|
||||
|
||||
it('still serializes terminal.execute on the same session', () => {
|
||||
const key = resolveSessionQueueKeyForTests(
|
||||
{
|
||||
capabilityId: 'terminal.execute',
|
||||
toolName: 'terminal_execute',
|
||||
policy: { write: true, bypassesApproval: false },
|
||||
},
|
||||
{ sessionId: 'session-a', command: 'ls' },
|
||||
'chat-1',
|
||||
);
|
||||
assert.equal(key, 'chat-1:session-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityTools result fitting', () => {
|
||||
it('bounds failed terminal output and stores the original partial output behind a handle', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const partialOutput = `${'build output\n'.repeat(20_000)}FATAL_MID=E_CONN_RESET_7319`;
|
||||
const longError = `API_TOKEN=tok_live_1234567890 ${'diagnostic '.repeat(2_000)}`;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiExec: async () => ({
|
||||
ok: false,
|
||||
error: longError,
|
||||
stdout: partialOutput,
|
||||
stderr: '',
|
||||
exitCode: -1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod.internal',
|
||||
label: 'prod',
|
||||
protocol: 'ssh',
|
||||
connected: true,
|
||||
}],
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
store,
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_execute,
|
||||
toolsContext.terminal_execute,
|
||||
'call-1',
|
||||
).execute({ sessionId: 'session-1', command: 'npm test' }) as {
|
||||
error: string;
|
||||
stdout?: string;
|
||||
};
|
||||
|
||||
assert.ok(result.error.length < 10_000);
|
||||
assert.doesNotMatch(result.error, /tok_live/);
|
||||
assert.match(result.error, /tool output handle/);
|
||||
assert.ok((result.stdout?.length ?? 0) < 30_000);
|
||||
assert.match(result.stdout ?? '', /output handle/);
|
||||
const handleId = result.stdout?.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
assert.match(
|
||||
store.read({ handleId, mode: 'tail', maxChars: 1_000 }, 'chat-1') ?? '',
|
||||
/FATAL_MID=E_CONN_RESET_7319/,
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates large vault note content and stores the full note body behind a handle', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const body = `${'note line\n'.repeat(1000)}important ending`;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async () => ({
|
||||
ok: true,
|
||||
note: {
|
||||
id: 'note-1',
|
||||
title: 'Long note',
|
||||
content: body,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
store,
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.vault_notes_get,
|
||||
toolsContext.vault_notes_get,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ noteId: 'note-1' },
|
||||
) as { note: { content: string } };
|
||||
|
||||
assert.notEqual(result.note.content, body);
|
||||
assert.match(result.note.content, /tool output handle/);
|
||||
const handleId = result.note.content.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
const recovered = store.readChunk({
|
||||
handleId,
|
||||
mode: 'search',
|
||||
query: 'important ending',
|
||||
}, 'chat-1');
|
||||
assert.match(recovered?.content ?? '', /important ending/);
|
||||
});
|
||||
|
||||
it('hard-caps explicit tool output reads and returns a continuation cursor', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const body = `${'full note line\n'.repeat(1000)}important ending`;
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'vault.notes.get',
|
||||
content: body,
|
||||
});
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
store,
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.tool_output_read,
|
||||
toolsContext.tool_output_read,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ handleId: handle.id, mode: 'full', maxChars: body.length + 100 },
|
||||
) as { content: string; nextOffset: number; hasMore: boolean; totalChars: number };
|
||||
|
||||
assert.ok(result.content.length <= 12_000);
|
||||
assert.equal(result.nextOffset, result.content.length);
|
||||
assert.equal(result.hasMore, true);
|
||||
assert.equal(result.totalChars, body.length);
|
||||
});
|
||||
|
||||
it('enforces a shared saved-output read budget across one turn', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
content: 'x'.repeat(50_000),
|
||||
});
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{}, { sessions: [] }, [], 'auto', undefined, 'chat-1', store, dedup,
|
||||
);
|
||||
const reader = withCattyToolContext(tools.tool_output_read, toolsContext.tool_output_read);
|
||||
|
||||
const first = await reader.execute({ handleId: handle.id, mode: 'range', offset: 0, maxChars: 12_000 });
|
||||
const second = await reader.execute({ handleId: handle.id, mode: 'range', offset: 12_000, maxChars: 12_000 });
|
||||
const third = await reader.execute({ handleId: handle.id, mode: 'range', offset: 24_000, maxChars: 12_000 }) as { error?: string };
|
||||
|
||||
assert.equal((first as { content: string }).content.length, 12_000);
|
||||
assert.equal((second as { content: string }).content.length, 12_000);
|
||||
assert.match(third.error ?? '', /read budget/);
|
||||
});
|
||||
|
||||
it('replays a completed terminal command instead of executing it again after retry compaction', async () => {
|
||||
let executions = 0;
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiExec: async () => {
|
||||
executions += 1;
|
||||
return { ok: true, stdout: 'deployed once', stderr: '', exitCode: 0 };
|
||||
},
|
||||
},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
},
|
||||
[], 'auto', undefined, 'chat-1', undefined, dedup,
|
||||
);
|
||||
const execute = withCattyToolContext(tools.terminal_execute, toolsContext.terminal_execute);
|
||||
await execute.execute({ sessionId: 'session-1', command: 'deploy production' });
|
||||
dedup.enableWriteReplay();
|
||||
const replay = await execute.execute({ sessionId: 'session-1', command: 'deploy production' }) as {
|
||||
replayedCompletedResult?: boolean;
|
||||
};
|
||||
|
||||
assert.equal(executions, 1);
|
||||
assert.equal(replay.replayedCompletedResult, true);
|
||||
|
||||
const intentionalRepeat = await execute.execute({ sessionId: 'session-1', command: 'deploy production' }) as {
|
||||
replayedCompletedResult?: boolean;
|
||||
};
|
||||
assert.equal(executions, 2);
|
||||
assert.equal(intentionalRepeat.replayedCompletedResult, undefined);
|
||||
});
|
||||
|
||||
it('executes an intentional repeat when the completed result is already in retry history', async () => {
|
||||
let executions = 0;
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiExec: async () => {
|
||||
executions += 1;
|
||||
return { ok: true, stdout: `run ${executions}`, stderr: '', exitCode: 0 };
|
||||
},
|
||||
},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
},
|
||||
[], 'auto', undefined, 'chat-1', undefined, dedup,
|
||||
);
|
||||
const execute = withCattyToolContext(tools.terminal_execute, toolsContext.terminal_execute);
|
||||
const args = { sessionId: 'session-1', command: 'npm test' };
|
||||
await execute.execute(args);
|
||||
const retryHistory = [
|
||||
{
|
||||
id: 'assistant-progress',
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: args }],
|
||||
},
|
||||
{
|
||||
id: 'tool-progress',
|
||||
role: 'tool' as const,
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'run 1' }],
|
||||
},
|
||||
];
|
||||
dedup.enableWriteReplay(collectPreservedTerminalWriteFingerprints(
|
||||
retryHistory,
|
||||
'assistant-progress',
|
||||
'chat-1',
|
||||
));
|
||||
|
||||
const repeat = await execute.execute(args) as { replayedCompletedResult?: boolean };
|
||||
assert.equal(executions, 2);
|
||||
assert.equal(repeat.replayedCompletedResult, undefined);
|
||||
});
|
||||
|
||||
it('pairs reused tool call IDs with the nearest preceding terminal command', () => {
|
||||
const commandA = { sessionId: 'session-1', command: 'npm test a' };
|
||||
const commandB = { sessionId: 'session-1', command: 'npm test b' };
|
||||
const retryHistory = [
|
||||
{
|
||||
id: 'assistant-a', role: 'assistant' as const, content: '', timestamp: 1,
|
||||
toolCalls: [{ id: 'reused-call', name: 'terminal_execute', arguments: commandA }],
|
||||
},
|
||||
{
|
||||
id: 'tool-a', role: 'tool' as const, content: '', timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'reused-call', content: 'result a' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-b', role: 'assistant' as const, content: '', timestamp: 3,
|
||||
toolCalls: [{ id: 'reused-call', name: 'terminal_execute', arguments: commandB }],
|
||||
},
|
||||
{
|
||||
id: 'tool-b', role: 'tool' as const, content: '', timestamp: 4,
|
||||
toolResults: [{ toolCallId: 'reused-call', content: 'result b' }],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
collectPreservedTerminalWriteFingerprints(retryHistory, 'assistant-a', 'chat-1'),
|
||||
[
|
||||
buildTerminalWriteFingerprint('terminal_execute', 'chat-1', commandA),
|
||||
buildTerminalWriteFingerprint('terminal_execute', 'chat-1', commandB),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('replays a started background job instead of starting it twice after retry compaction', async () => {
|
||||
let starts = 0;
|
||||
const dedup = new ToolResultDedup();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{ aiCapability: async () => ({
|
||||
ok: true,
|
||||
jobId: `job-${++starts}`,
|
||||
status: 'running',
|
||||
command: 'deploy --password swordfish',
|
||||
output: 'x'.repeat(30_000),
|
||||
}) },
|
||||
{ sessions: [] }, [], 'auto', undefined, 'chat-start', undefined, dedup,
|
||||
);
|
||||
const start = withCattyToolContext(tools.terminal_start, toolsContext.terminal_start);
|
||||
await start.execute({ sessionId: 'session-1', command: 'npm run build' });
|
||||
dedup.enableWriteReplay();
|
||||
const replay = await start.execute({ sessionId: 'session-1', command: 'npm run build' }) as {
|
||||
replayedCompletedResult?: boolean;
|
||||
jobId?: string;
|
||||
command?: string;
|
||||
output?: string;
|
||||
};
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(replay.jobId, 'job-1');
|
||||
assert.equal(replay.replayedCompletedResult, true);
|
||||
assert.doesNotMatch(replay.command ?? '', /swordfish/);
|
||||
assert.match(replay.output ?? '', /tool output handle/);
|
||||
|
||||
const intentionalRestart = await start.execute({ sessionId: 'session-1', command: 'npm run build' }) as {
|
||||
replayedCompletedResult?: boolean;
|
||||
jobId?: string;
|
||||
};
|
||||
assert.equal(starts, 2);
|
||||
assert.equal(intentionalRestart.jobId, 'job-2');
|
||||
assert.equal(intentionalRestart.replayedCompletedResult, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityTools terminal context reader', () => {
|
||||
it('reads terminal context from the only scoped terminal when sessionId is omitted', async () => {
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod.internal',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
readTerminalContext: async (request) => ({
|
||||
ok: true,
|
||||
sessionId: request.sessionId,
|
||||
label: 'prod',
|
||||
range: request.range ?? 'viewport',
|
||||
content: 'line-a\nline-b',
|
||||
totalLines: 2,
|
||||
startLine: 0,
|
||||
endLine: 1,
|
||||
returnedLines: 2,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
source: 'live',
|
||||
}),
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_read_context,
|
||||
toolsContext.terminal_read_context,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ range: 'tail', maxLines: 20 },
|
||||
) as { sessionId: string; content: string; range: string };
|
||||
|
||||
assert.equal(result.sessionId, 'session-1');
|
||||
assert.equal(result.range, 'tail');
|
||||
assert.equal(result.content, 'line-a\nline-b');
|
||||
});
|
||||
|
||||
it('fits large terminal context reads through the shared tool output store', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const body = `${'terminal line output '.repeat(900)}important ending`;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod.internal',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
readTerminalContext: async (request) => ({
|
||||
ok: true,
|
||||
sessionId: request.sessionId,
|
||||
label: 'prod',
|
||||
range: request.range ?? 'viewport',
|
||||
content: body,
|
||||
totalLines: 1,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
returnedLines: 1,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
source: 'live',
|
||||
}),
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
store,
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_read_context,
|
||||
toolsContext.terminal_read_context,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ range: 'viewport' },
|
||||
) as { content: string };
|
||||
|
||||
assert.notEqual(result.content, body);
|
||||
assert.match(result.content, /tool output handle/);
|
||||
const handleId = result.content.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
const recovered = store.readChunk({
|
||||
handleId,
|
||||
mode: 'search',
|
||||
query: 'important ending',
|
||||
}, 'chat-1');
|
||||
assert.match(recovered?.content ?? '', /important ending/);
|
||||
});
|
||||
|
||||
it('asks for sessionId when multiple scoped terminals are available', async () => {
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{
|
||||
sessions: [
|
||||
{ sessionId: 'session-1', hostId: 'host-1', hostname: 'a', label: 'a', connected: true },
|
||||
{ sessionId: 'session-2', hostId: 'host-2', hostname: 'b', label: 'b', connected: true },
|
||||
],
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_read_context,
|
||||
toolsContext.terminal_read_context,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ range: 'viewport' },
|
||||
) as { error?: string };
|
||||
|
||||
assert.match(result.error ?? '', /sessionId/);
|
||||
});
|
||||
|
||||
it('returns a small cached notice for an unchanged terminal context range', async () => {
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod.internal',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
readTerminalContext: async () => ({
|
||||
ok: true,
|
||||
sessionId: 'session-1',
|
||||
range: 'tail',
|
||||
content: 'same terminal screen',
|
||||
totalLines: 1,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
returnedLines: 1,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
source: 'live',
|
||||
}),
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
undefined,
|
||||
dedup,
|
||||
);
|
||||
|
||||
const reader = withCattyToolContext(
|
||||
tools.terminal_read_context,
|
||||
toolsContext.terminal_read_context,
|
||||
);
|
||||
const first = await reader.execute({ sessionId: 'session-1', range: 'tail' });
|
||||
const second = await reader.execute({ sessionId: 'session-1', range: 'tail' });
|
||||
|
||||
assert.equal(typeof first, 'object');
|
||||
assert.match(String(second), /^\[cached\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityTools terminal polling', () => {
|
||||
it('tags polled output handles with the owning terminal for close cleanup', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const dedup = new ToolResultDedup();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async (method: string) => method.includes('jobStart')
|
||||
? { ok: true, jobId: 'job-owned', status: 'running', nextOffset: 0 }
|
||||
: { ok: true, jobId: 'job-owned', status: 'running', output: 'x'.repeat(30_000), nextOffset: 30_000 },
|
||||
},
|
||||
{ sessions: [] }, [], 'auto', undefined, 'chat-owned', store, dedup,
|
||||
);
|
||||
await withCattyToolContext(tools.terminal_start, toolsContext.terminal_start)
|
||||
.execute({ sessionId: 'session-owned', command: 'npm run dev' });
|
||||
const result = await withCattyToolContext(tools.terminal_poll, toolsContext.terminal_poll)
|
||||
.execute({ jobId: 'job-owned', offset: 0 }) as { output: string };
|
||||
const handleId = result.output.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
store.pruneTerminalSession('chat-owned', 'session-owned');
|
||||
assert.equal(store.get(handleId, 'chat-owned'), undefined);
|
||||
});
|
||||
|
||||
it('deduplicates an unchanged job output range', async () => {
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async () => ({
|
||||
ok: true,
|
||||
jobId: 'job-1',
|
||||
sessionId: 'session-1',
|
||||
status: 'running',
|
||||
output: 'same build output',
|
||||
outputBaseOffset: 0,
|
||||
nextOffset: 17,
|
||||
totalOutputChars: 17,
|
||||
}),
|
||||
},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
undefined,
|
||||
dedup,
|
||||
);
|
||||
|
||||
const poll = withCattyToolContext(tools.terminal_poll, toolsContext.terminal_poll);
|
||||
const first = await poll.execute({ jobId: 'job-1', offset: 0 });
|
||||
const second = await poll.execute({ jobId: 'job-1', offset: 0 });
|
||||
|
||||
assert.equal(typeof first, 'object');
|
||||
assert.match(String(second), /^\[cached\]/);
|
||||
});
|
||||
|
||||
it('bounds follow-style monitor output before it reaches the model', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const rawOutput = `${'x '.repeat(400)}\n${'log line\n'.repeat(500)}MONITOR_MIDDLE_EVIDENCE_7319\n${'tail line\n'.repeat(500)}`;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async () => ({
|
||||
ok: true,
|
||||
jobId: 'monitor-job-unique',
|
||||
command: 'tail -f /var/log/app.log',
|
||||
status: 'running',
|
||||
output: rawOutput,
|
||||
nextOffset: 9_000,
|
||||
}),
|
||||
},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-monitor',
|
||||
store,
|
||||
);
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_poll,
|
||||
toolsContext.terminal_poll,
|
||||
).execute({ jobId: 'monitor-job-unique', offset: 0 }) as { output: string };
|
||||
|
||||
assert.ok(result.output.length < 3_500);
|
||||
assert.ok(result.output.split('\n')[0].length <= 500);
|
||||
const handleId = result.output.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
assert.match(
|
||||
store.read({ handleId, mode: 'search', query: 'MONITOR_MIDDLE_EVIDENCE_7319' }, 'chat-monitor') ?? '',
|
||||
/MONITOR_MIDDLE_EVIDENCE_7319/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not count empty monitor polls as output bursts', async () => {
|
||||
let polls = 0;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async () => ({
|
||||
ok: true,
|
||||
jobId: 'quiet-monitor-job',
|
||||
command: 'tail -f /var/log/app.log',
|
||||
status: 'running',
|
||||
output: polls++ < 12 ? '' : 'first new line',
|
||||
nextOffset: 0,
|
||||
}),
|
||||
},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-quiet-monitor',
|
||||
);
|
||||
const poll = withCattyToolContext(tools.terminal_poll, toolsContext.terminal_poll);
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
await poll.execute({ jobId: 'quiet-monitor-job', offset: 0 });
|
||||
}
|
||||
const result = await poll.execute({ jobId: 'quiet-monitor-job', offset: 0 }) as { output: string };
|
||||
|
||||
assert.equal(result.output, 'first new line');
|
||||
});
|
||||
});
|
||||
788
infrastructure/ai/harness/capabilityTools.ts
Normal file
788
infrastructure/ai/harness/capabilityTools.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { ExecutorContext, NetcattyBridge } from '../cattyAgent/executor';
|
||||
import type { TerminalContextReadRange } from '../../../domain/terminalContextRead';
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import type { WebSearchConfig } from '../types';
|
||||
import { isWebSearchReady } from '../types';
|
||||
import {
|
||||
executeTerminalExecute,
|
||||
executeWorkspaceGetInfo,
|
||||
executeWorkspaceGetSessionInfo,
|
||||
executeWebSearch,
|
||||
executeUrlFetch,
|
||||
type ToolDeps,
|
||||
type ToolExecResult,
|
||||
} from '../shared/toolExecutors';
|
||||
import { reserveSessionSlot } from '../shared/sessionExecutionQueue';
|
||||
import { fitTerminalExecuteResultForModel } from './terminalCompression';
|
||||
import { fitLargeToolResultForModel } from './toolResultFitting';
|
||||
import { redactSecretsForModel } from './modelSecretRedaction';
|
||||
import {
|
||||
globalTerminalMonitorGuard,
|
||||
isStreamingMonitorCommand,
|
||||
} from './terminalMonitorGuard';
|
||||
import type { ToolOutputStore } from './toolOutputStore';
|
||||
import { TOOL_OUTPUT_READ_MAX_CHARS } from './toolOutputStore';
|
||||
import {
|
||||
buildTerminalWriteFingerprint,
|
||||
hashScopeKey,
|
||||
hashToolResult,
|
||||
previewToolResult,
|
||||
type ToolResultDedup,
|
||||
} from './toolResultDedup';
|
||||
import cattyToolSpecs from './generated/cattyToolSpecs.json';
|
||||
import {
|
||||
cattyToolContextSchema,
|
||||
toolDepsFromContext,
|
||||
type CattyToolContext,
|
||||
} from './cattyRuntimeContext';
|
||||
|
||||
type FieldShape = {
|
||||
type: string;
|
||||
optional?: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type CattyToolSpec = {
|
||||
capabilityId: string;
|
||||
toolName: string;
|
||||
rpcMethod: string | null;
|
||||
localExecution?: boolean;
|
||||
description: string;
|
||||
inputShape: Record<string, FieldShape>;
|
||||
policy: {
|
||||
write: boolean;
|
||||
bypassesApproval: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CattyToolsBundle = {
|
||||
tools: Record<string, ReturnType<typeof tool>>;
|
||||
toolsContext: Record<string, CattyToolContext>;
|
||||
};
|
||||
|
||||
function buildZodObject(shape: Record<string, FieldShape>): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const entries: Record<string, z.ZodTypeAny> = {};
|
||||
for (const [key, field] of Object.entries(shape)) {
|
||||
let schema: z.ZodTypeAny = field.type === 'number' ? z.number() : z.string();
|
||||
if (field.description) {
|
||||
schema = schema.describe(field.description);
|
||||
}
|
||||
entries[key] = field.optional ? schema.optional() : schema;
|
||||
}
|
||||
return z.object(entries);
|
||||
}
|
||||
|
||||
function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
|
||||
if (r.ok === false) return { error: r.error };
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async function invokeCapabilityRpc(
|
||||
bridge: NetcattyBridge,
|
||||
rpcMethod: string,
|
||||
params: Record<string, unknown>,
|
||||
chatSessionId?: string,
|
||||
): Promise<unknown> {
|
||||
if (!bridge.aiCapability) {
|
||||
return { error: 'Capability bridge is unavailable in this environment.' };
|
||||
}
|
||||
const result = await bridge.aiCapability(rpcMethod, params, chatSessionId);
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok: boolean }).ok === false) {
|
||||
return { error: (result as { error?: string }).error || 'Capability call failed.' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function tryFetchHostEnvironment(
|
||||
bridge: NetcattyBridge,
|
||||
chatSessionId?: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (!bridge.aiCapability || !chatSessionId) return null;
|
||||
try {
|
||||
const environment = await invokeCapabilityRpc(
|
||||
bridge,
|
||||
'netcatty/getContext',
|
||||
{},
|
||||
chatSessionId,
|
||||
);
|
||||
if (environment && typeof environment === 'object' && !('error' in environment)) {
|
||||
return environment as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// IPC failures must not block read-only harness tools.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyToolDedup(
|
||||
toolName: string,
|
||||
fingerprint: string,
|
||||
result: unknown,
|
||||
dedup?: ToolResultDedup,
|
||||
): unknown {
|
||||
if (!dedup) return result;
|
||||
const cached = dedup.check(fingerprint);
|
||||
if (cached) {
|
||||
return dedup.buildCachedNotice(cached);
|
||||
}
|
||||
dedup.remember(toolName, fingerprint, previewToolResult(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
function fitCapabilityResultForModel(
|
||||
result: unknown,
|
||||
spec: CattyToolSpec,
|
||||
chatSessionId?: string,
|
||||
toolOutputStore?: ToolOutputStore,
|
||||
args?: Record<string, unknown>,
|
||||
toolResultDedup?: ToolResultDedup,
|
||||
): unknown {
|
||||
if (spec.capabilityId === 'harness.tool_output.read') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const resultRecord = result && typeof result === 'object'
|
||||
? result as Record<string, unknown>
|
||||
: undefined;
|
||||
const jobId = typeof args?.jobId === 'string'
|
||||
? args.jobId
|
||||
: typeof resultRecord?.jobId === 'string' ? resultRecord.jobId : undefined;
|
||||
const terminalSessionId = typeof args?.sessionId === 'string'
|
||||
? args.sessionId
|
||||
: typeof resultRecord?.sessionId === 'string'
|
||||
? resultRecord.sessionId
|
||||
: jobId ? toolResultDedup?.terminalSessionForJob(jobId) : undefined;
|
||||
|
||||
return fitLargeToolResultForModel({
|
||||
result,
|
||||
capabilityId: spec.capabilityId,
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
terminalSessionId,
|
||||
normalizeStrings: spec.capabilityId.startsWith('terminal.')
|
||||
|| spec.capabilityId === 'harness.terminal.read_context',
|
||||
});
|
||||
}
|
||||
|
||||
export function applyMonitorStopResult(
|
||||
poll: Record<string, unknown>,
|
||||
stopResult: unknown,
|
||||
suppressedCount: number,
|
||||
): Record<string, unknown> {
|
||||
const stopFailed = Boolean(
|
||||
stopResult && typeof stopResult === 'object'
|
||||
&& (
|
||||
(stopResult as { ok?: boolean }).ok === false
|
||||
|| (
|
||||
typeof (stopResult as { error?: unknown }).error === 'string'
|
||||
&& (stopResult as { error: string }).error.trim().length > 0
|
||||
)
|
||||
),
|
||||
);
|
||||
return stopFailed
|
||||
? {
|
||||
...poll,
|
||||
output: `[automatic monitor stop failed after sustained overload; ${suppressedCount} batches were suppressed. The job may still be running; poll/stop it explicitly and narrow the command before continuing.]`,
|
||||
}
|
||||
: {
|
||||
...poll,
|
||||
status: 'stopping',
|
||||
output: `[monitor stop requested after sustained overload; ${suppressedCount} batches were suppressed. Narrow the command with grep/awk before restarting it.]`,
|
||||
};
|
||||
}
|
||||
|
||||
interface LocalExecutionContext {
|
||||
deps: ToolDeps;
|
||||
spec: CattyToolSpec;
|
||||
args: Record<string, unknown>;
|
||||
toolOutputStore?: ToolOutputStore;
|
||||
toolResultDedup?: ToolResultDedup;
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
async function executeLocalCattyCapability(ctx: LocalExecutionContext): Promise<unknown> {
|
||||
const { deps, spec, args, toolOutputStore, toolResultDedup, chatSessionId } = ctx;
|
||||
const resolveContext = () => (typeof deps.context === 'function' ? deps.context() : deps.context);
|
||||
|
||||
switch (spec.capabilityId) {
|
||||
case 'harness.tool_output.read': {
|
||||
const { handleId, mode, maxChars, offset, query } = args as {
|
||||
handleId: string;
|
||||
mode?: 'head' | 'tail' | 'full' | 'range' | 'search';
|
||||
maxChars?: number;
|
||||
offset?: number;
|
||||
query?: string;
|
||||
};
|
||||
if (!toolOutputStore || !chatSessionId) {
|
||||
return { error: 'Tool output store is unavailable.' };
|
||||
}
|
||||
const requestedChars = Math.min(
|
||||
TOOL_OUTPUT_READ_MAX_CHARS,
|
||||
typeof maxChars === 'number' && Number.isFinite(maxChars)
|
||||
? Math.max(1, Math.floor(maxChars))
|
||||
: TOOL_OUTPUT_READ_MAX_CHARS,
|
||||
);
|
||||
const grantedChars = toolResultDedup
|
||||
? toolResultDedup.takeBudget('tool-output-read', requestedChars, TOOL_OUTPUT_READ_MAX_CHARS * 2)
|
||||
: requestedChars;
|
||||
if (grantedChars <= 0) {
|
||||
return {
|
||||
error: `This turn has reached its ${TOOL_OUTPUT_READ_MAX_CHARS * 2}-character saved-output read budget. Continue in the next turn or narrow the search.`,
|
||||
};
|
||||
}
|
||||
const result = await toolOutputStore.readChunkAsync(
|
||||
{ handleId, mode, maxChars: grantedChars, offset, query },
|
||||
chatSessionId,
|
||||
);
|
||||
if (result == null) {
|
||||
return { error: `Handle "${handleId}" was not found for this chat session.` };
|
||||
}
|
||||
return { ...result, content: redactSecretsForModel(result.content) };
|
||||
}
|
||||
case 'harness.workspace.get_info': {
|
||||
const scopeCtx = resolveContext();
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([chatSessionId, scopeCtx.workspaceId, String(scopeCtx.sessions?.length ?? 0)]),
|
||||
);
|
||||
const local = executeWorkspaceGetInfo(deps);
|
||||
if (local.ok === false) {
|
||||
return unwrap(local);
|
||||
}
|
||||
let merged: unknown = local.data;
|
||||
const environment = await tryFetchHostEnvironment(deps.bridge, chatSessionId);
|
||||
if (environment) {
|
||||
const hosts = Array.isArray(environment.hosts)
|
||||
? (environment.hosts as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const hostBySessionId = new Map(hosts.map((host) => [String(host.sessionId), host]));
|
||||
merged = {
|
||||
...local.data,
|
||||
sessions: local.data.sessions.map((session) => ({
|
||||
...session,
|
||||
...(hostBySessionId.get(session.sessionId) ?? {}),
|
||||
})),
|
||||
activePortForwardTunnels: environment.activePortForwardTunnels,
|
||||
};
|
||||
}
|
||||
if (fingerprint) {
|
||||
return applyToolDedup(spec.toolName, fingerprint, merged, toolResultDedup);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
case 'harness.workspace.get_session_info': {
|
||||
const { sessionId } = args as { sessionId: string };
|
||||
const local = executeWorkspaceGetSessionInfo(deps, { sessionId });
|
||||
if (local.ok === false) {
|
||||
return unwrap(local);
|
||||
}
|
||||
const environment = await tryFetchHostEnvironment(deps.bridge, chatSessionId);
|
||||
if (environment) {
|
||||
const hosts = Array.isArray(environment.hosts)
|
||||
? (environment.hosts as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const match = hosts.find((host) => String(host.sessionId) === sessionId);
|
||||
if (match) {
|
||||
return { ...local.data, ...match };
|
||||
}
|
||||
}
|
||||
return local.data;
|
||||
}
|
||||
case 'harness.terminal.read_context': {
|
||||
const scopeCtx = resolveContext();
|
||||
const sessions = scopeCtx.sessions ?? [];
|
||||
const requestedSessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
|
||||
? args.sessionId.trim()
|
||||
: undefined;
|
||||
const sessionId = requestedSessionId
|
||||
?? (sessions.length === 1 ? sessions[0].sessionId : undefined);
|
||||
|
||||
if (!sessionId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'sessionId is required because the current AI scope contains multiple terminal sessions.',
|
||||
};
|
||||
}
|
||||
|
||||
const session = sessions.find((entry) => entry.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Terminal session "${sessionId}" is not in the current AI scope.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!scopeCtx.readTerminalContext) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Terminal context reader is unavailable for this AI scope.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await scopeCtx.readTerminalContext({
|
||||
sessionId,
|
||||
range: typeof args.range === 'string' ? args.range as TerminalContextReadRange : undefined,
|
||||
startLine: typeof args.startLine === 'number' ? args.startLine : undefined,
|
||||
maxLines: typeof args.maxLines === 'number' ? args.maxLines : undefined,
|
||||
});
|
||||
if (result.ok === false) return result;
|
||||
const normalizedResult = result;
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([
|
||||
sessionId,
|
||||
String(normalizedResult.startLine),
|
||||
String(normalizedResult.endLine),
|
||||
normalizedResult.range,
|
||||
hashToolResult(normalizedResult.content),
|
||||
]),
|
||||
);
|
||||
return fingerprint
|
||||
? applyToolDedup(spec.toolName, fingerprint, normalizedResult, toolResultDedup)
|
||||
: normalizedResult;
|
||||
}
|
||||
case 'harness.web.search': {
|
||||
const { query, maxResults } = args as { query: string; maxResults?: number };
|
||||
return unwrap(await executeWebSearch(deps, { query, maxResults }));
|
||||
}
|
||||
case 'harness.url.fetch': {
|
||||
const { url, maxLength } = args as { url: string; maxLength?: number };
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(spec.toolName, url);
|
||||
const raw = unwrap(await executeUrlFetch(deps, { url, maxLength }));
|
||||
if (fingerprint) {
|
||||
return applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
case 'harness.skill.run': {
|
||||
const { executeSkillRun } = await import('./builtinSkillRunner');
|
||||
const result = await executeSkillRun(deps, args, { chatSessionId });
|
||||
return unwrap(result);
|
||||
}
|
||||
default:
|
||||
return { error: `No local executor registered for "${spec.capabilityId}".` };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSessionQueueKey(
|
||||
spec: CattyToolSpec,
|
||||
args: Record<string, unknown>,
|
||||
chatSessionId?: string,
|
||||
): string | null {
|
||||
if (spec.capabilityId.startsWith('harness.') && !spec.policy.write) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : undefined;
|
||||
if (sessionId) {
|
||||
return `${chatSessionId ?? 'global'}:${sessionId}`;
|
||||
}
|
||||
return `${chatSessionId ?? 'global'}:${spec.toolName}`;
|
||||
}
|
||||
|
||||
export function resolveSessionQueueKeyForTests(
|
||||
spec: Pick<CattyToolSpec, 'capabilityId' | 'toolName' | 'policy'>,
|
||||
args: Record<string, unknown>,
|
||||
chatSessionId?: string,
|
||||
): string | null {
|
||||
return resolveSessionQueueKey(spec as CattyToolSpec, args, chatSessionId);
|
||||
}
|
||||
|
||||
function createCatalogTool(spec: CattyToolSpec) {
|
||||
const inputSchema = buildZodObject(spec.inputShape);
|
||||
|
||||
return tool({
|
||||
description: spec.description,
|
||||
inputSchema,
|
||||
contextSchema: cattyToolContextSchema,
|
||||
execute: async (args, { toolCallId: _toolCallId, abortSignal, context }) => {
|
||||
const toolContext = context as CattyToolContext;
|
||||
const deps = toolDepsFromContext(toolContext);
|
||||
const { toolOutputStore, toolResultDedup } = toolContext;
|
||||
|
||||
const queueKey = resolveSessionQueueKey(
|
||||
spec,
|
||||
args as Record<string, unknown>,
|
||||
deps.chatSessionId,
|
||||
);
|
||||
const slot = queueKey ? reserveSessionSlot(queueKey) : null;
|
||||
|
||||
try {
|
||||
const result = await (async () => {
|
||||
if (abortSignal?.aborted) {
|
||||
return { error: 'Tool call cancelled before it could start.' };
|
||||
}
|
||||
|
||||
await slot?.ready;
|
||||
|
||||
if (spec.capabilityId === 'terminal.execute') {
|
||||
const { sessionId: sid, command } = args as { sessionId: string; command: string };
|
||||
const writeFingerprint = buildTerminalWriteFingerprint(
|
||||
'terminal_execute',
|
||||
deps.chatSessionId,
|
||||
{ sessionId: sid, command },
|
||||
);
|
||||
const replay = writeFingerprint
|
||||
? toolResultDedup?.replayCompletedWrite(writeFingerprint)
|
||||
: undefined;
|
||||
if (replay !== undefined) {
|
||||
return {
|
||||
...(typeof replay === 'object' && replay !== null ? replay : { result: replay }),
|
||||
replayedCompletedResult: true,
|
||||
note: 'The command already executed before request compaction; its recorded result was replayed and the command was not executed again.',
|
||||
};
|
||||
}
|
||||
const cancelOnAbort = () => {
|
||||
if (deps.chatSessionId) {
|
||||
void deps.bridge.aiCattyCancelExec?.(deps.chatSessionId);
|
||||
}
|
||||
};
|
||||
abortSignal?.addEventListener('abort', cancelOnAbort, { once: true });
|
||||
try {
|
||||
const result = await executeTerminalExecute(deps, { sessionId: sid, command });
|
||||
if (result.ok === false) {
|
||||
if (!result.data) return unwrap(result);
|
||||
const fittedFailure = {
|
||||
error: fitLargeToolResultForModel({
|
||||
result: result.error,
|
||||
capabilityId: 'terminal.execute.error',
|
||||
chatSessionId: deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
terminalSessionId: sid,
|
||||
normalizeStrings: true,
|
||||
}),
|
||||
...fitTerminalExecuteResultForModel({
|
||||
...result.data,
|
||||
command,
|
||||
sessionId: sid,
|
||||
}, {
|
||||
chatSessionId: deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
}),
|
||||
};
|
||||
if (writeFingerprint) toolResultDedup?.rememberCompletedWrite(writeFingerprint, fittedFailure);
|
||||
return fittedFailure;
|
||||
}
|
||||
const fitted = fitTerminalExecuteResultForModel({
|
||||
...result.data,
|
||||
command,
|
||||
sessionId: sid,
|
||||
}, {
|
||||
chatSessionId: deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
});
|
||||
if (writeFingerprint) toolResultDedup?.rememberCompletedWrite(writeFingerprint, fitted);
|
||||
return fitted;
|
||||
} finally {
|
||||
abortSignal?.removeEventListener('abort', cancelOnAbort);
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.localExecution || spec.capabilityId.startsWith('harness.')) {
|
||||
const result = await executeLocalCattyCapability({
|
||||
deps,
|
||||
spec,
|
||||
args: args as Record<string, unknown>,
|
||||
toolOutputStore,
|
||||
toolResultDedup,
|
||||
chatSessionId: deps.chatSessionId,
|
||||
});
|
||||
return fitCapabilityResultForModel(
|
||||
result,
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
}
|
||||
|
||||
if (!spec.rpcMethod) {
|
||||
return { error: `Capability "${spec.capabilityId}" has no RPC binding.` };
|
||||
}
|
||||
|
||||
const terminalStartFingerprint = spec.capabilityId === 'terminal.start'
|
||||
? buildTerminalWriteFingerprint(
|
||||
'terminal_start',
|
||||
deps.chatSessionId,
|
||||
args as { sessionId?: unknown; command?: unknown },
|
||||
)
|
||||
: undefined;
|
||||
const terminalStartReplay = terminalStartFingerprint
|
||||
? toolResultDedup?.replayCompletedWrite(terminalStartFingerprint)
|
||||
: undefined;
|
||||
if (terminalStartReplay !== undefined) {
|
||||
return {
|
||||
...(typeof terminalStartReplay === 'object' && terminalStartReplay !== null
|
||||
? terminalStartReplay
|
||||
: { result: terminalStartReplay }),
|
||||
replayedCompletedResult: true,
|
||||
note: 'The background command already started before request compaction; its recorded job was replayed and no second job was created.',
|
||||
};
|
||||
}
|
||||
|
||||
let raw = await invokeCapabilityRpc(
|
||||
deps.bridge,
|
||||
spec.rpcMethod,
|
||||
args as Record<string, unknown>,
|
||||
deps.chatSessionId,
|
||||
);
|
||||
if (
|
||||
spec.capabilityId === 'terminal.start'
|
||||
&& raw && typeof raw === 'object'
|
||||
&& typeof (raw as { jobId?: unknown }).jobId === 'string'
|
||||
&& typeof (args as { sessionId?: unknown }).sessionId === 'string'
|
||||
) {
|
||||
toolResultDedup?.rememberTerminalJobSession(
|
||||
(raw as { jobId: string }).jobId,
|
||||
(args as { sessionId: string }).sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
spec.capabilityId === 'terminal.poll'
|
||||
&& raw
|
||||
&& typeof raw === 'object'
|
||||
&& (raw as { ok?: boolean }).ok !== false
|
||||
) {
|
||||
let poll = raw as Record<string, unknown>;
|
||||
const monitorKey = `${deps.chatSessionId ?? 'global'}:${String(poll.jobId ?? args.jobId ?? '')}`;
|
||||
if (
|
||||
isStreamingMonitorCommand(poll.command)
|
||||
&& typeof poll.output === 'string'
|
||||
&& poll.output.trim().length > 0
|
||||
) {
|
||||
const guarded = globalTerminalMonitorGuard.process(monitorKey, poll.output);
|
||||
if (guarded.action === 'stop') {
|
||||
const stopResult = await invokeCapabilityRpc(
|
||||
deps.bridge,
|
||||
'netcatty/jobStop',
|
||||
{ jobId: poll.jobId ?? args.jobId },
|
||||
deps.chatSessionId,
|
||||
);
|
||||
poll = applyMonitorStopResult(poll, stopResult, guarded.suppressedCount);
|
||||
} else if (guarded.action === 'suppress') {
|
||||
poll = {
|
||||
...poll,
|
||||
output: `[monitor batch suppressed by rate limit; suppressed=${guarded.suppressedCount}]`,
|
||||
};
|
||||
} else {
|
||||
let output = guarded.content;
|
||||
if (guarded.sourceTruncated && toolOutputStore && deps.chatSessionId) {
|
||||
const jobId = String(poll.jobId ?? args.jobId ?? '');
|
||||
const handle = toolOutputStore.store({
|
||||
chatSessionId: deps.chatSessionId,
|
||||
capabilityId: 'terminal.poll.monitor-batch',
|
||||
content: poll.output,
|
||||
sessionId: jobId ? toolResultDedup?.terminalSessionForJob(jobId) : undefined,
|
||||
});
|
||||
output += [
|
||||
'',
|
||||
`[monitor batch archived before shortening: rawChars=${poll.output.length} handleId=${handle.id}]`,
|
||||
'Use tool_output_read with this handleId to search/read omitted details; the terminal nextOffset already advances past the raw batch.',
|
||||
'This saved output is available only until the app closes. Read this handle before closing the app.',
|
||||
].join('\n');
|
||||
}
|
||||
poll = { ...poll, output };
|
||||
}
|
||||
}
|
||||
if (poll.status !== 'running' && poll.status !== 'stopping') {
|
||||
globalTerminalMonitorGuard.clear(monitorKey);
|
||||
}
|
||||
raw = poll;
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([
|
||||
String(poll.jobId ?? args.jobId ?? ''),
|
||||
String(poll.outputBaseOffset ?? ''),
|
||||
String(poll.nextOffset ?? ''),
|
||||
String(poll.status ?? ''),
|
||||
hashToolResult(poll.output ?? ''),
|
||||
]),
|
||||
);
|
||||
if (fingerprint) {
|
||||
return fitCapabilityResultForModel(
|
||||
applyToolDedup(spec.toolName, fingerprint, poll, toolResultDedup),
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.toolName === 'get_environment' || spec.capabilityId === 'session.environment') {
|
||||
const ctx = typeof deps.context === 'function' ? deps.context() : deps.context;
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([deps.chatSessionId, ctx.workspaceId, String(ctx.sessions?.length ?? 0)]),
|
||||
);
|
||||
if (fingerprint) {
|
||||
return fitCapabilityResultForModel(
|
||||
applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup),
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.capabilityId.includes('sftp') && spec.capabilityId.includes('read')) {
|
||||
const { sessionId: sid, path } = args as { sessionId?: string; path?: string };
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([sid, path]),
|
||||
);
|
||||
if (fingerprint) {
|
||||
return fitCapabilityResultForModel(
|
||||
applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup),
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
raw
|
||||
&& typeof raw === 'object'
|
||||
&& 'content' in raw
|
||||
&& toolOutputStore
|
||||
&& deps.chatSessionId
|
||||
) {
|
||||
const content = String((raw as { content?: string }).content ?? '');
|
||||
const MAX_LIVE_SFTP_READ_CHARS = 24_000;
|
||||
if (content.length > MAX_LIVE_SFTP_READ_CHARS) {
|
||||
const handle = toolOutputStore.store({
|
||||
chatSessionId: deps.chatSessionId,
|
||||
capabilityId: spec.capabilityId,
|
||||
content,
|
||||
sessionId: sid,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
path: (raw as { path?: string }).path ?? path,
|
||||
preview: handle.preview,
|
||||
totalChars: handle.totalChars,
|
||||
handleId: handle.id,
|
||||
note: 'Full file content is available only until the app closes. Use tool_output_read now.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fittedRaw = fitCapabilityResultForModel(
|
||||
raw,
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
if (
|
||||
terminalStartFingerprint
|
||||
&& raw && typeof raw === 'object'
|
||||
&& (raw as { ok?: boolean }).ok !== false
|
||||
&& typeof (raw as { jobId?: unknown }).jobId === 'string'
|
||||
) {
|
||||
toolResultDedup?.rememberCompletedWrite(terminalStartFingerprint, fittedRaw);
|
||||
}
|
||||
return fittedRaw;
|
||||
})();
|
||||
if (toolOutputStore && deps.chatSessionId) {
|
||||
await toolOutputStore.flush(deps.chatSessionId);
|
||||
return toolOutputStore.resolveRestartPersistenceNotices(result, deps.chatSessionId);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
slot?.release();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCattyToolContext(input: {
|
||||
bridge: NetcattyBridge;
|
||||
context: ToolDeps['context'];
|
||||
commandBlocklist?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
webSearchConfig?: WebSearchConfig;
|
||||
chatSessionId?: string;
|
||||
toolOutputStore?: ToolOutputStore;
|
||||
toolResultDedup?: ToolResultDedup;
|
||||
}): CattyToolContext {
|
||||
return {
|
||||
bridge: input.bridge,
|
||||
chatSessionId: input.chatSessionId,
|
||||
permissionMode: input.permissionMode,
|
||||
commandBlocklist: input.commandBlocklist,
|
||||
webSearchConfig: input.webSearchConfig,
|
||||
getExecutorContext: typeof input.context === 'function'
|
||||
? input.context as () => ExecutorContext
|
||||
: () => input.context as ExecutorContext,
|
||||
toolOutputStore: input.toolOutputStore,
|
||||
toolResultDedup: input.toolResultDedup,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCattyToolsFromCatalog(
|
||||
bridge: NetcattyBridge,
|
||||
context: ToolDeps['context'],
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
chatSessionId?: string,
|
||||
toolOutputStore?: ToolOutputStore,
|
||||
toolResultDedup?: ToolResultDedup,
|
||||
): CattyToolsBundle {
|
||||
const sharedContext = buildCattyToolContext({
|
||||
bridge,
|
||||
context,
|
||||
commandBlocklist,
|
||||
permissionMode,
|
||||
webSearchConfig,
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
toolResultDedup,
|
||||
});
|
||||
|
||||
const catalogTools: Record<string, ReturnType<typeof tool>> = {};
|
||||
const toolsContext: Record<string, CattyToolContext> = {};
|
||||
|
||||
for (const rawSpec of cattyToolSpecs as CattyToolSpec[]) {
|
||||
if (rawSpec.capabilityId === 'harness.web.search' && !isWebSearchReady(webSearchConfig)) {
|
||||
continue;
|
||||
}
|
||||
catalogTools[rawSpec.toolName] = createCatalogTool(rawSpec);
|
||||
toolsContext[rawSpec.toolName] = sharedContext;
|
||||
}
|
||||
|
||||
return { tools: catalogTools, toolsContext };
|
||||
}
|
||||
|
||||
/** Test helper: attach shared context when calling tool.execute directly. */
|
||||
export function withCattyToolContext<T extends { execute: (...args: never[]) => unknown }>(
|
||||
toolInstance: T,
|
||||
context: CattyToolContext,
|
||||
toolCallId = 'test-call',
|
||||
): T {
|
||||
const original = toolInstance.execute.bind(toolInstance);
|
||||
return {
|
||||
...toolInstance,
|
||||
execute: (input: Parameters<T['execute']>[0], options?: Partial<Parameters<T['execute']>[1]>) =>
|
||||
original(input, {
|
||||
toolCallId,
|
||||
messages: [],
|
||||
...options,
|
||||
context,
|
||||
} as Parameters<T['execute']>[1]),
|
||||
} as T;
|
||||
}
|
||||
|
||||
export { tryFetchHostEnvironment };
|
||||
281
infrastructure/ai/harness/cattyRuntime.ts
Normal file
281
infrastructure/ai/harness/cattyRuntime.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
76
infrastructure/ai/harness/cattyRuntimeContext.ts
Normal file
76
infrastructure/ai/harness/cattyRuntimeContext.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from 'zod';
|
||||
import type { NetcattyBridge } from '../cattyAgent/executor';
|
||||
import type { ExecutorContext } from '../cattyAgent/executor';
|
||||
import type { AIPermissionMode, WebSearchConfig } from '../types';
|
||||
import type { ToolOutputStore } from './toolOutputStore';
|
||||
import type { ToolResultDedup } from './toolResultDedup';
|
||||
import type { CompactionTrace } from './types';
|
||||
import type { AgentKind } from '../agentKinds';
|
||||
import type { PromptContextSnapshot } from './promptContextSnapshot';
|
||||
|
||||
export const cattyRuntimeContextSchema = z.object({
|
||||
chatSessionId: z.string(),
|
||||
turnId: z.string(),
|
||||
agentKind: z.enum(['sidebar', 'global']),
|
||||
providerId: z.string().optional(),
|
||||
modelId: z.string().optional(),
|
||||
userGoal: z.string().optional(),
|
||||
permissionMode: z.enum(['observer', 'confirm', 'auto']),
|
||||
scopeType: z.enum(['terminal', 'workspace']),
|
||||
scopeLabel: z.string().optional(),
|
||||
lastCompaction: z.custom<CompactionTrace>().optional(),
|
||||
lastStepAdjusted: z.boolean().optional(),
|
||||
promptContext: z.custom<PromptContextSnapshot>().optional(),
|
||||
});
|
||||
|
||||
export type CattyRuntimeContext = z.infer<typeof cattyRuntimeContextSchema>;
|
||||
|
||||
export const cattyToolContextSchema = z.object({
|
||||
bridge: z.custom<NetcattyBridge>(),
|
||||
chatSessionId: z.string().optional(),
|
||||
permissionMode: z.enum(['observer', 'confirm', 'auto']),
|
||||
commandBlocklist: z.array(z.string()).optional(),
|
||||
webSearchConfig: z.custom<WebSearchConfig>().optional(),
|
||||
getExecutorContext: z.custom<() => ExecutorContext>(),
|
||||
toolOutputStore: z.custom<ToolOutputStore>().optional(),
|
||||
toolResultDedup: z.custom<ToolResultDedup>().optional(),
|
||||
});
|
||||
|
||||
export type CattyToolContext = z.infer<typeof cattyToolContextSchema>;
|
||||
|
||||
export function createInitialCattyRuntimeContext(input: {
|
||||
chatSessionId: string;
|
||||
turnId: string;
|
||||
agentKind?: AgentKind;
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
userGoal?: string;
|
||||
permissionMode: AIPermissionMode;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeLabel?: string;
|
||||
promptContext?: PromptContextSnapshot;
|
||||
}): CattyRuntimeContext {
|
||||
return {
|
||||
chatSessionId: input.chatSessionId,
|
||||
turnId: input.turnId,
|
||||
agentKind: input.agentKind ?? 'sidebar',
|
||||
providerId: input.providerId,
|
||||
modelId: input.modelId,
|
||||
userGoal: input.userGoal,
|
||||
permissionMode: input.permissionMode,
|
||||
scopeType: input.scopeType,
|
||||
scopeLabel: input.scopeLabel,
|
||||
promptContext: input.promptContext,
|
||||
};
|
||||
}
|
||||
|
||||
export function toolDepsFromContext(context: CattyToolContext): import('../shared/toolExecutors').ToolDeps {
|
||||
return {
|
||||
bridge: context.bridge,
|
||||
context: context.getExecutorContext,
|
||||
commandBlocklist: context.commandBlocklist,
|
||||
permissionMode: context.permissionMode,
|
||||
webSearchConfig: context.webSearchConfig,
|
||||
chatSessionId: context.chatSessionId,
|
||||
};
|
||||
}
|
||||
113
infrastructure/ai/harness/cattyStreamProcessor.test.ts
Normal file
113
infrastructure/ai/harness/cattyStreamProcessor.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import { processCattyStream, shouldEmitAgentEventsForStreamChunk } from './turnDrivers/cattyStreamProcessor';
|
||||
import { createInitialCattyRuntimeContext } from './cattyRuntimeContext';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
describe('shouldEmitAgentEventsForStreamChunk', () => {
|
||||
it('suppresses trace events for SDK internal stream-state errors', () => {
|
||||
assert.equal(
|
||||
shouldEmitAgentEventsForStreamChunk({
|
||||
type: 'error',
|
||||
error: new Error('reasoning part abc not found'),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('still emits trace events for real stream errors', () => {
|
||||
assert.equal(
|
||||
shouldEmitAgentEventsForStreamChunk({
|
||||
type: 'error',
|
||||
error: new Error('Provider returned HTTP 500'),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldEmitAgentEventsForStreamChunk({ type: 'text-delta', text: 'hi' }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processCattyStream reasoning continuation', () => {
|
||||
it('persists reasoning encrypted content delivered on reasoning-end', async () => {
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'stream-start', warnings: [] },
|
||||
{
|
||||
type: 'reasoning-start',
|
||||
id: 'r1',
|
||||
providerMetadata: { openai: { itemId: 'rs_1' } },
|
||||
},
|
||||
{
|
||||
type: 'reasoning-delta',
|
||||
id: 'r1',
|
||||
delta: 'thinking',
|
||||
providerMetadata: { openai: { itemId: 'rs_1' } },
|
||||
},
|
||||
{
|
||||
type: 'reasoning-end',
|
||||
id: 'r1',
|
||||
providerMetadata: {
|
||||
openai: { itemId: 'rs_1', reasoningEncryptedContent: 'enc-abc' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'finish',
|
||||
finishReason: { unified: 'stop', raw: undefined },
|
||||
usage: {
|
||||
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const messages = new Map<string, ChatMessage>();
|
||||
messages.set('assistant-1', {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 0,
|
||||
});
|
||||
const ui = {
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => {
|
||||
messages.set(message.id, message);
|
||||
},
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
const message = messages.get(messageId);
|
||||
if (message) messages.set(messageId, updater(message));
|
||||
},
|
||||
};
|
||||
|
||||
await processCattyStream({
|
||||
streamSessionId: 'session-1',
|
||||
model,
|
||||
systemPrompt: 'test',
|
||||
toolsBundle: { tools: {}, toolsContext: {} },
|
||||
sdkMessages: [{ role: 'user', content: 'hello' }],
|
||||
signal: new AbortController().signal,
|
||||
currentAssistantMsgId: 'assistant-1',
|
||||
maxIterations: 1,
|
||||
runtimeContext: createInitialCattyRuntimeContext({
|
||||
chatSessionId: 'session-1',
|
||||
turnId: 'turn-1',
|
||||
permissionMode: 'auto',
|
||||
scopeType: 'terminal',
|
||||
}),
|
||||
ui,
|
||||
});
|
||||
|
||||
const continuation = messages.get('assistant-1')?.providerContinuation;
|
||||
const encryptedContent = continuation?.reasoningParts?.at(-1)?.providerOptions?.openai
|
||||
?.reasoningEncryptedContent;
|
||||
assert.equal(encryptedContent, 'enc-abc');
|
||||
assert.match(continuation?.reasoningParts?.map(part => part.text).join('') ?? '', /thinking/);
|
||||
});
|
||||
});
|
||||
98
infrastructure/ai/harness/cattyToolApproval.test.ts
Normal file
98
infrastructure/ai/harness/cattyToolApproval.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildCattyToolApproval } from './cattyToolApproval';
|
||||
|
||||
describe('buildCattyToolApproval', () => {
|
||||
it('auto-approves read-only tools', async () => {
|
||||
const approval = buildCattyToolApproval({ permissionMode: 'confirm', chatSessionId: 'chat-1' });
|
||||
const result = await approval({
|
||||
toolCall: {
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'terminal_read_context',
|
||||
input: { range: 'viewport' },
|
||||
},
|
||||
} as Parameters<typeof approval>[0]);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it('allows observer-bypass write tools in observer mode', async () => {
|
||||
const approval = buildCattyToolApproval({ permissionMode: 'observer', chatSessionId: 'chat-1' });
|
||||
const result = await approval({
|
||||
toolCall: {
|
||||
toolCallId: 'call-stop',
|
||||
toolName: 'terminal_stop',
|
||||
input: { jobId: 'job-1' },
|
||||
},
|
||||
} as Parameters<typeof approval>[0]);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it('denies write tools in observer mode', async () => {
|
||||
const approval = buildCattyToolApproval({ permissionMode: 'observer', chatSessionId: 'chat-1' });
|
||||
const result = await approval({
|
||||
toolCall: {
|
||||
toolCallId: 'call-2',
|
||||
toolName: 'sftp_write_file',
|
||||
input: { path: '/tmp/x', content: 'hi' },
|
||||
},
|
||||
} as Parameters<typeof approval>[0]);
|
||||
assert.deepEqual(result, {
|
||||
type: 'denied',
|
||||
reason: 'Observer mode blocks write operations.',
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-approves write tools in auto mode', async () => {
|
||||
const approval = buildCattyToolApproval({ permissionMode: 'auto', chatSessionId: 'chat-1' });
|
||||
const result = await approval({
|
||||
toolCall: {
|
||||
toolCallId: 'call-3',
|
||||
toolName: 'sftp_write_file',
|
||||
input: { path: '/tmp/x', content: 'hi' },
|
||||
},
|
||||
} as Parameters<typeof approval>[0]);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it('awaits user approval in confirm mode for write tools', async () => {
|
||||
let approvalRequested = false;
|
||||
const approval = buildCattyToolApproval({
|
||||
permissionMode: 'confirm',
|
||||
chatSessionId: 'chat-1',
|
||||
requestApproval: async (toolCallId, toolName) => {
|
||||
approvalRequested = true;
|
||||
assert.equal(toolCallId, 'call-4');
|
||||
assert.equal(toolName, 'sftp_write_file');
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const result = await approval({
|
||||
toolCall: {
|
||||
toolCallId: 'call-4',
|
||||
toolName: 'sftp_write_file',
|
||||
input: { path: '/tmp/x', content: 'hi' },
|
||||
},
|
||||
} as Parameters<typeof approval>[0]);
|
||||
assert.equal(approvalRequested, true);
|
||||
assert.deepEqual(result, { type: 'approved' });
|
||||
});
|
||||
|
||||
it('returns denied when confirm-mode approval is rejected', async () => {
|
||||
const approval = buildCattyToolApproval({
|
||||
permissionMode: 'confirm',
|
||||
chatSessionId: 'chat-1',
|
||||
requestApproval: async () => false,
|
||||
});
|
||||
const result = await approval({
|
||||
toolCall: {
|
||||
toolCallId: 'call-5',
|
||||
toolName: 'sftp_write_file',
|
||||
input: { path: '/tmp/x', content: 'hi' },
|
||||
},
|
||||
} as Parameters<typeof approval>[0]);
|
||||
assert.deepEqual(result, {
|
||||
type: 'denied',
|
||||
reason: 'User denied tool execution.',
|
||||
});
|
||||
});
|
||||
});
|
||||
67
infrastructure/ai/harness/cattyToolApproval.ts
Normal file
67
infrastructure/ai/harness/cattyToolApproval.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ToolApprovalConfiguration } from 'ai';
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import { requestApproval as defaultRequestApproval } from '../shared/approvalGate';
|
||||
import { resolveCapabilityId } from './permissionGrants';
|
||||
import cattyToolSpecs from './generated/cattyToolSpecs.json';
|
||||
|
||||
type CattyToolPolicySpec = {
|
||||
toolName: string;
|
||||
capabilityId: string;
|
||||
policy: {
|
||||
write: boolean;
|
||||
bypassesApproval: boolean;
|
||||
bypassesObserverBlock?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const policyByToolName = new Map<string, CattyToolPolicySpec>(
|
||||
(cattyToolSpecs as CattyToolPolicySpec[]).map((spec) => [spec.toolName, spec]),
|
||||
);
|
||||
|
||||
function needsUserApproval(
|
||||
toolName: string,
|
||||
permissionMode: AIPermissionMode,
|
||||
): boolean {
|
||||
if (permissionMode !== 'confirm') return false;
|
||||
const spec = policyByToolName.get(toolName);
|
||||
if (!spec) return false;
|
||||
return spec.policy.write && !spec.policy.bypassesApproval;
|
||||
}
|
||||
|
||||
export function buildCattyToolApproval(input: {
|
||||
permissionMode: AIPermissionMode;
|
||||
chatSessionId?: string;
|
||||
requestApproval?: typeof defaultRequestApproval;
|
||||
}): ToolApprovalConfiguration<Record<string, never>, import('./cattyRuntimeContext').CattyRuntimeContext> {
|
||||
const { permissionMode, chatSessionId, requestApproval = defaultRequestApproval } = input;
|
||||
|
||||
return async ({ toolCall }) => {
|
||||
const spec = policyByToolName.get(toolCall.toolName);
|
||||
if (!spec?.policy.write) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (permissionMode === 'observer' && !spec.policy.bypassesObserverBlock) {
|
||||
return { type: 'denied' as const, reason: 'Observer mode blocks write operations.' };
|
||||
}
|
||||
|
||||
if (!needsUserApproval(toolCall.toolName, permissionMode)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const args = (toolCall.input ?? {}) as Record<string, unknown>;
|
||||
const approved = await requestApproval(
|
||||
toolCall.toolCallId,
|
||||
toolCall.toolName,
|
||||
args,
|
||||
chatSessionId,
|
||||
undefined,
|
||||
spec.capabilityId ?? resolveCapabilityId(toolCall.toolName),
|
||||
);
|
||||
|
||||
if (approved) {
|
||||
return { type: 'approved' as const };
|
||||
}
|
||||
return { type: 'denied' as const, reason: 'User denied tool execution.' };
|
||||
};
|
||||
}
|
||||
106
infrastructure/ai/harness/cattyTurnDriver.attachments.test.ts
Normal file
106
infrastructure/ai/harness/cattyTurnDriver.attachments.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CattyTurnDriver } from './turnDrivers/cattyTurnDriver';
|
||||
import type { TurnDriverContext, TurnInput } from './turnDrivers/types';
|
||||
|
||||
const mcpServerBridge = await import('../../../electron/bridges/mcpServerBridge.cjs');
|
||||
|
||||
function createTurnContext(): TurnDriverContext {
|
||||
return {
|
||||
turnId: 'turn-1',
|
||||
chatSessionId: 'chat-1',
|
||||
sessionId: 'chat-1',
|
||||
backend: 'catty',
|
||||
signal: new AbortController().signal,
|
||||
emit: () => {},
|
||||
toolOutputStore: {
|
||||
store: () => ({ id: 'handle-1', contentLength: 0 }),
|
||||
read: () => null,
|
||||
clearSession: () => {},
|
||||
},
|
||||
toolResultDedup: {
|
||||
fingerprintFor: () => 'fingerprint',
|
||||
check: () => null,
|
||||
remember: () => {},
|
||||
buildCachedNotice: () => ({}),
|
||||
clearTurn: () => {},
|
||||
},
|
||||
sessionStateStore: {
|
||||
mergeFromUserGoal: () => {},
|
||||
toReinjectionText: () => undefined,
|
||||
get: () => ({ decisions: [], activeHosts: {}, blockers: [], updatedAt: Date.now() }),
|
||||
clear: () => {},
|
||||
updateFromToolResult: () => {},
|
||||
mergeFromAssistantContent: () => {},
|
||||
},
|
||||
} as TurnDriverContext;
|
||||
}
|
||||
|
||||
test('Catty turn registers current message file attachments for attachment tools', async (t) => {
|
||||
mcpServerBridge.cleanup();
|
||||
const csvText = 'label,hostname,username\nprod,prod.example.com,root\n';
|
||||
const bridge = {
|
||||
aiSetChatSessionCancelled: async () => ({ ok: true }),
|
||||
aiMcpUpdateSessions: async () => undefined,
|
||||
aiMcpUpdateAttachments: async (
|
||||
attachments: Array<{ base64Data?: string; mediaType?: string; filename?: string; filePath?: string }>,
|
||||
chatSessionId?: string,
|
||||
) => {
|
||||
mcpServerBridge.updateAttachmentMetadata(attachments, chatSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
t.after(() => mcpServerBridge.cleanup());
|
||||
|
||||
const controller = new AbortController();
|
||||
const input: TurnInput = {
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-1',
|
||||
sendScopeKey: 'chat-1',
|
||||
userText: '把这些主机都导入到 vault',
|
||||
signal: controller.signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'assistant-1',
|
||||
context: {
|
||||
activeProvider: undefined,
|
||||
activeModelId: '',
|
||||
scopeType: 'terminal',
|
||||
globalPermissionMode: 'confirm',
|
||||
terminalSessions: [],
|
||||
autoTitleSession: () => {},
|
||||
},
|
||||
attachments: [{
|
||||
filename: 'hosts_export_2026-06-25.csv',
|
||||
mediaType: 'text/csv',
|
||||
base64Data: Buffer.from(csvText).toString('base64'),
|
||||
filePath: '/tmp/hosts_export_2026-06-25.csv',
|
||||
}],
|
||||
maxIterations: 5,
|
||||
bridge,
|
||||
ui: {
|
||||
addMessageToSession: () => {},
|
||||
updateLastMessage: () => {},
|
||||
updateMessageById: () => {},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
await new CattyTurnDriver().run(input, createTurnContext());
|
||||
|
||||
const listed = mcpServerBridge.handleListAttachments({ chatSessionId: 'chat-1' });
|
||||
assert.equal(listed.ok, true);
|
||||
assert.deepEqual(listed.attachments, [{
|
||||
filename: 'hosts_export_2026-06-25.csv',
|
||||
mediaType: 'text/csv',
|
||||
filePath: '/tmp/hosts_export_2026-06-25.csv',
|
||||
sizeBytes: Buffer.byteLength(csvText),
|
||||
}]);
|
||||
|
||||
const read = mcpServerBridge.handleReadAttachment({
|
||||
chatSessionId: 'chat-1',
|
||||
filename: 'hosts_export_2026-06-25.csv',
|
||||
});
|
||||
assert.equal(read.ok, true);
|
||||
assert.equal(read.text, csvText);
|
||||
});
|
||||
88
infrastructure/ai/harness/compactionArtifacts.test.ts
Normal file
88
infrastructure/ai/harness/compactionArtifacts.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { MockLanguageModelV4 } from 'ai/test';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
import { storeCompactionArchive, storeCompactionArtifact } from './compactionArtifacts';
|
||||
import { buildCompactionFailureArchiveNotice, compactCattyMessages } from './cattyRuntime';
|
||||
|
||||
test('compaction artifacts retain exact searchable history and summary output', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const archive = storeCompactionArchive(store, 'chat-1', 'exact E_CONN_RESET_7319 evidence');
|
||||
const artifact = storeCompactionArtifact(store, 'chat-1', {
|
||||
trigger: '413-retry',
|
||||
modelId: 'model-1',
|
||||
archiveHandleId: archive.id,
|
||||
formattedHistory: 'exact E_CONN_RESET_7319 evidence',
|
||||
summary: 'network failure found',
|
||||
});
|
||||
|
||||
assert.match(store.read({ handleId: archive.id, mode: 'search', query: 'E_CONN_RESET_7319' }, 'chat-1') ?? '', /E_CONN_RESET_7319/);
|
||||
assert.match(store.read({ handleId: artifact.id, mode: 'full' }, 'chat-1') ?? '', /network failure found/);
|
||||
});
|
||||
|
||||
test('compaction failure notice keeps the newly created archive discoverable', () => {
|
||||
assert.match(
|
||||
buildCompactionFailureArchiveNotice('tool-output-archive', false) ?? '',
|
||||
/tool-output-archive/,
|
||||
);
|
||||
assert.equal(buildCompactionFailureArchiveNotice(undefined, false), undefined);
|
||||
});
|
||||
|
||||
test('compaction archive preserves tool evidence from before stale-result pruning', async () => {
|
||||
const evidence = 'UNIQUE_OLD_TOOL_EVIDENCE_7319';
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: 'user', content: 'inspect the old failure' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'old-call',
|
||||
toolName: 'terminal_execute',
|
||||
input: { sessionId: 'session-1', command: 'inspect failure' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'old-call',
|
||||
toolName: 'terminal_execute',
|
||||
output: { type: 'text', value: evidence },
|
||||
}],
|
||||
},
|
||||
...Array.from({ length: 24 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `later message ${index}`,
|
||||
} as ModelMessage)),
|
||||
];
|
||||
const store = new ToolOutputStore();
|
||||
const model = new MockLanguageModelV4({
|
||||
doGenerate: async () => ({
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
finishReason: { unified: 'stop', raw: undefined },
|
||||
usage: {
|
||||
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 2, text: 2, reasoning: undefined },
|
||||
},
|
||||
warnings: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await compactCattyMessages({
|
||||
messages,
|
||||
sessionId: 'chat-archive',
|
||||
chatSessionId: 'chat-archive',
|
||||
model,
|
||||
abortSignal: new AbortController().signal,
|
||||
force: true,
|
||||
trigger: 'force',
|
||||
toolOutputStore: store,
|
||||
});
|
||||
const handleId = result.trace?.archiveHandleId;
|
||||
assert.ok(handleId);
|
||||
assert.match(
|
||||
store.read({ handleId, mode: 'search', query: evidence }, 'chat-archive') ?? '',
|
||||
new RegExp(evidence),
|
||||
);
|
||||
});
|
||||
37
infrastructure/ai/harness/compactionArtifacts.ts
Normal file
37
infrastructure/ai/harness/compactionArtifacts.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ToolOutputStore, ToolOutputHandle } from './toolOutputStore';
|
||||
|
||||
export function storeCompactionArchive(
|
||||
store: ToolOutputStore,
|
||||
chatSessionId: string,
|
||||
formattedHistory: string,
|
||||
): ToolOutputHandle {
|
||||
return store.store({
|
||||
chatSessionId,
|
||||
capabilityId: 'conversation.archive',
|
||||
content: formattedHistory,
|
||||
});
|
||||
}
|
||||
|
||||
export function storeCompactionArtifact(
|
||||
store: ToolOutputStore,
|
||||
chatSessionId: string,
|
||||
input: {
|
||||
trigger: string;
|
||||
modelId?: string | null;
|
||||
archiveHandleId?: string;
|
||||
formattedHistory: string;
|
||||
summary: string;
|
||||
},
|
||||
): ToolOutputHandle {
|
||||
return store.store({
|
||||
chatSessionId,
|
||||
capabilityId: 'compaction.artifact',
|
||||
content: [
|
||||
`trigger: ${input.trigger}`,
|
||||
`model: ${input.modelId ?? 'unknown'}`,
|
||||
`archiveHandleId: ${input.archiveHandleId ?? 'unavailable'}`,
|
||||
`\n[compaction input]\n${input.formattedHistory}`,
|
||||
`\n[compaction output]\n${input.summary}`,
|
||||
].join('\n'),
|
||||
});
|
||||
}
|
||||
98
infrastructure/ai/harness/compactionPruner.test.ts
Normal file
98
infrastructure/ai/harness/compactionPruner.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { pruneFirstModelMessage, pruneLastModelMessage, pruneUntilFitsCompaction } from './compactionPruner.ts';
|
||||
|
||||
test('pruneLastModelMessage removes trailing user and assistant pair', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: 'user', content: 'old' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'terminal_execute',
|
||||
input: { command: 'pwd' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'terminal_execute',
|
||||
output: { type: 'text', value: '/tmp' },
|
||||
}],
|
||||
},
|
||||
{ role: 'user', content: 'recent' },
|
||||
{ role: 'assistant', content: 'acknowledged' },
|
||||
];
|
||||
const pruned = pruneLastModelMessage(messages);
|
||||
assert.equal(pruned.length, 3);
|
||||
assert.equal(pruned[0]?.content, 'old');
|
||||
assert.equal(pruned.at(-1)?.role, 'tool');
|
||||
});
|
||||
|
||||
test('message pruning removes complete parallel tool-result batches', () => {
|
||||
const calls = ['a', 'b', 'c'].map((toolCallId) => ({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId,
|
||||
toolName: 'terminal_poll',
|
||||
input: { jobId: toolCallId },
|
||||
}));
|
||||
const results = calls.map((call) => ({
|
||||
role: 'tool' as const,
|
||||
content: [{
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'text' as const, value: `result ${call.toolCallId}` },
|
||||
}],
|
||||
}));
|
||||
const batch: ModelMessage[] = [
|
||||
{ role: 'assistant', content: calls },
|
||||
...results,
|
||||
];
|
||||
|
||||
assert.deepEqual(pruneFirstModelMessage([...batch, { role: 'user', content: 'next' }]), [
|
||||
{ role: 'user', content: 'next' },
|
||||
]);
|
||||
assert.deepEqual(pruneLastModelMessage([{ role: 'user', content: 'before' }, ...batch]), [
|
||||
{ role: 'user', content: 'before' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('pruneUntilFitsCompaction shrinks history to fit budget', () => {
|
||||
const messages: ModelMessage[] = Array.from({ length: 20 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: 'word '.repeat(500),
|
||||
})) as ModelMessage[];
|
||||
|
||||
const pruned = pruneUntilFitsCompaction({
|
||||
messages,
|
||||
availableForInput: 2_000,
|
||||
providerId: 'openai',
|
||||
});
|
||||
assert.ok(pruned.length < messages.length);
|
||||
});
|
||||
|
||||
test('pruneUntilFitsCompaction drops oldest messages first', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: 'user', content: `oldest ${'x'.repeat(5_000)}` },
|
||||
...Array.from({ length: 16 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? 'assistant' : 'user',
|
||||
content: 'middle context',
|
||||
})) as ModelMessage[],
|
||||
{ role: 'user', content: 'newest goal for current task' },
|
||||
{ role: 'assistant', content: 'latest reply before tail split' },
|
||||
];
|
||||
|
||||
const pruned = pruneUntilFitsCompaction({
|
||||
messages,
|
||||
availableForInput: 800,
|
||||
providerId: 'openai',
|
||||
});
|
||||
const serialized = JSON.stringify(pruned);
|
||||
assert.match(serialized, /newest goal for current task/);
|
||||
assert.doesNotMatch(serialized, /oldest/);
|
||||
});
|
||||
107
infrastructure/ai/harness/compactionPruner.ts
Normal file
107
infrastructure/ai/harness/compactionPruner.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { estimateModelMessagesTokensWithKind } from './tokenEstimator';
|
||||
import { COMPACTION_PROMPT_RESERVE } from './contextBudget';
|
||||
|
||||
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 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 skipToolResultsForward(messages: ModelMessage[], startIndex: number): number {
|
||||
let index = startIndex;
|
||||
while (index < messages.length && startsWithToolResult(messages[index])) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
function findToolResultsStart(messages: ModelMessage[]): number {
|
||||
let index = messages.length;
|
||||
while (index > 0 && startsWithToolResult(messages[index - 1])) index -= 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Prune from the tail while preserving valid tool-call/tool-result pairing. */
|
||||
export function pruneLastModelMessage(messages: ModelMessage[]): ModelMessage[] {
|
||||
if (messages.length === 0) return messages;
|
||||
if (messages.length === 1) return [];
|
||||
|
||||
const trailingToolStart = findToolResultsStart(messages);
|
||||
if (trailingToolStart < messages.length) {
|
||||
const preceding = messages[trailingToolStart - 1];
|
||||
return preceding?.role === 'assistant' && endsWithToolCall(preceding)
|
||||
? messages.slice(0, trailingToolStart - 1)
|
||||
: messages.slice(0, trailingToolStart);
|
||||
}
|
||||
|
||||
const secondToLastIndex = messages.length - 2;
|
||||
const secondToLast = messages[secondToLastIndex];
|
||||
|
||||
if (secondToLast.role === 'assistant' && endsWithToolCall(secondToLast)) {
|
||||
return messages.slice(0, -2);
|
||||
}
|
||||
if (secondToLast.role === 'user') {
|
||||
return messages.slice(0, -2);
|
||||
}
|
||||
return messages.slice(0, -1);
|
||||
}
|
||||
|
||||
/** Prune from the head while preserving valid tool-call/tool-result pairing. */
|
||||
export function pruneFirstModelMessage(messages: ModelMessage[]): ModelMessage[] {
|
||||
if (messages.length === 0) return messages;
|
||||
if (messages.length === 1) return [];
|
||||
|
||||
const first = messages[0];
|
||||
const second = messages[1];
|
||||
|
||||
if (first.role === 'assistant' && endsWithToolCall(first)) {
|
||||
return messages.slice(skipToolResultsForward(messages, 1));
|
||||
}
|
||||
if (first.role === 'user' && second?.role === 'assistant' && endsWithToolCall(second)) {
|
||||
return messages.slice(skipToolResultsForward(messages, 2));
|
||||
}
|
||||
if (first.role === 'user' && second?.role === 'assistant') {
|
||||
return messages.slice(2);
|
||||
}
|
||||
if (startsWithToolResult(first)) {
|
||||
return messages.slice(skipToolResultsForward(messages, 0));
|
||||
}
|
||||
|
||||
return messages.slice(1);
|
||||
}
|
||||
|
||||
export function countMessagesTokens(messages: ModelMessage[], providerId?: string | null): number {
|
||||
return estimateModelMessagesTokensWithKind({ messages, providerId }).tokens;
|
||||
}
|
||||
|
||||
export interface PruneUntilFitsCompactionInput {
|
||||
messages: ModelMessage[];
|
||||
availableForInput: number;
|
||||
providerId?: string | null;
|
||||
compactionPromptTokens?: number;
|
||||
}
|
||||
|
||||
export function pruneUntilFitsCompaction(input: PruneUntilFitsCompactionInput): ModelMessage[] {
|
||||
const reserve = input.compactionPromptTokens ?? COMPACTION_PROMPT_RESERVE;
|
||||
let working = input.messages;
|
||||
|
||||
while (working.length > 0) {
|
||||
const tokens = countMessagesTokens(working, input.providerId) + reserve;
|
||||
if (tokens <= input.availableForInput) {
|
||||
return working;
|
||||
}
|
||||
const pruned = pruneFirstModelMessage(working);
|
||||
if (pruned.length === working.length) break;
|
||||
working = pruned;
|
||||
}
|
||||
|
||||
return working;
|
||||
}
|
||||
9
infrastructure/ai/harness/compactionStatusKeys.ts
Normal file
9
infrastructure/ai/harness/compactionStatusKeys.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/** i18n keys for compaction status — resolved in useAgentCompactionUi. */
|
||||
export const CATTY_COMPACTION_STATUS_KEYS = {
|
||||
preTurn: 'ai.chat.compactingContext',
|
||||
step: 'ai.chat.compactingStep',
|
||||
retry: 'ai.chat.compactionRetry',
|
||||
} as const;
|
||||
|
||||
export type CattyCompactionStatusKey =
|
||||
typeof CATTY_COMPACTION_STATUS_KEYS[keyof typeof CATTY_COMPACTION_STATUS_KEYS];
|
||||
58
infrastructure/ai/harness/contextBudget.test.ts
Normal file
58
infrastructure/ai/harness/contextBudget.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
computeCompactionBuffer,
|
||||
computeCompactionThreshold,
|
||||
computeTotalInputTokens,
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
shouldCompactByBudget,
|
||||
} from './contextBudget.ts';
|
||||
import type { ModelMessage } from 'ai';
|
||||
|
||||
test('computeCompactionThreshold reserves output and buffer', () => {
|
||||
const threshold = computeCompactionThreshold({
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 4096,
|
||||
});
|
||||
const buffer = computeCompactionBuffer(128_000, 4096);
|
||||
assert.equal(threshold, 128_000 - 4096 - buffer - 150);
|
||||
assert.ok(threshold < 128_000 * 0.85);
|
||||
});
|
||||
|
||||
test('shouldCompactByBudget triggers when total input exceeds threshold', () => {
|
||||
const messages: ModelMessage[] = [{ role: 'user', content: 'x'.repeat(400_000) }];
|
||||
assert.equal(shouldCompactByBudget({
|
||||
messages,
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 4096,
|
||||
providerId: 'openai',
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('computeTotalInputTokens includes system and tool names', () => {
|
||||
const messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
const withExtras = computeTotalInputTokens({
|
||||
messages,
|
||||
systemPrompt: 'system prompt',
|
||||
toolNames: ['terminal_execute', 'sftp_read'],
|
||||
providerId: 'anthropic',
|
||||
});
|
||||
const base = computeTotalInputTokens({ messages, providerId: 'anthropic' });
|
||||
assert.ok(withExtras > base);
|
||||
});
|
||||
|
||||
test('computeCompactionThreshold keeps a reasonable threshold for small context windows', () => {
|
||||
const threshold8k = computeCompactionThreshold({
|
||||
contextWindow: 8_192,
|
||||
maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
});
|
||||
assert.ok(threshold8k > 1_000);
|
||||
assert.ok(threshold8k < 8_192 * 0.85);
|
||||
|
||||
const threshold4k = computeCompactionThreshold({
|
||||
contextWindow: 4_096,
|
||||
maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
});
|
||||
assert.ok(threshold4k > 500);
|
||||
assert.ok(threshold4k < 4_096 * 0.85);
|
||||
});
|
||||
98
infrastructure/ai/harness/contextBudget.ts
Normal file
98
infrastructure/ai/harness/contextBudget.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import {
|
||||
estimateModelMessagesTokensWithKind,
|
||||
estimateTextTokens,
|
||||
estimateUnknownTokens,
|
||||
} from './tokenEstimator';
|
||||
|
||||
export const COMPACTION_PROMPT_RESERVE = 150;
|
||||
export const AUTO_COMPACT_BUFFER_CAP = 15_000;
|
||||
export const AUTO_COMPACT_BUFFER_RATIO = 0.8;
|
||||
export const COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS = 1600;
|
||||
export const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
||||
const MIN_OUTPUT_RESERVE = 256;
|
||||
const MAX_OUTPUT_SHARE_OF_WINDOW = 0.25;
|
||||
|
||||
export function resolveEffectiveMaxOutputTokens(
|
||||
contextWindow: number,
|
||||
maxOutputTokens: number = DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
): number {
|
||||
if (contextWindow <= 0) return maxOutputTokens;
|
||||
const cappedByWindow = Math.max(
|
||||
MIN_OUTPUT_RESERVE,
|
||||
Math.floor(contextWindow * MAX_OUTPUT_SHARE_OF_WINDOW),
|
||||
);
|
||||
return Math.min(maxOutputTokens, cappedByWindow);
|
||||
}
|
||||
|
||||
export interface ComputeCompactionThresholdInput {
|
||||
contextWindow: number;
|
||||
maxOutputTokens?: number;
|
||||
compactionPromptTokens?: number;
|
||||
}
|
||||
|
||||
export function computeCompactionBuffer(contextWindow: number, maxOutputTokens: number): number {
|
||||
const remaining = Math.max(0, contextWindow - maxOutputTokens);
|
||||
const ratioCompactionBuffer = Math.ceil((1 - AUTO_COMPACT_BUFFER_RATIO) * remaining);
|
||||
const safeCompactionBuffer = Math.max(maxOutputTokens, ratioCompactionBuffer);
|
||||
return Math.min(safeCompactionBuffer, AUTO_COMPACT_BUFFER_CAP);
|
||||
}
|
||||
|
||||
/** Continue-style threshold: compact before the next turn would exceed the window. */
|
||||
export function computeCompactionThreshold(input: ComputeCompactionThresholdInput): number {
|
||||
const maxOutputTokens = resolveEffectiveMaxOutputTokens(
|
||||
input.contextWindow,
|
||||
input.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
);
|
||||
const compactionPromptTokens = input.compactionPromptTokens ?? COMPACTION_PROMPT_RESERVE;
|
||||
const buffer = computeCompactionBuffer(input.contextWindow, maxOutputTokens);
|
||||
const threshold = input.contextWindow - maxOutputTokens - buffer - compactionPromptTokens;
|
||||
return Math.max(1, threshold);
|
||||
}
|
||||
|
||||
export interface ComputeTotalInputTokensInput {
|
||||
messages: ModelMessage[];
|
||||
providerId?: string | null;
|
||||
systemPrompt?: string;
|
||||
toolNames?: string[];
|
||||
reservedTokens?: number;
|
||||
}
|
||||
|
||||
export function computeTotalInputTokens(input: ComputeTotalInputTokensInput): number {
|
||||
const messageTokens = estimateModelMessagesTokensWithKind({
|
||||
messages: input.messages,
|
||||
providerId: input.providerId,
|
||||
}).tokens;
|
||||
const systemTokens = input.systemPrompt
|
||||
? estimateTextTokens(input.systemPrompt, input.providerId)
|
||||
: 0;
|
||||
const toolTokens = input.toolNames?.length
|
||||
? estimateUnknownTokens({ tools: input.toolNames }, input.providerId)
|
||||
: 0;
|
||||
const reserved = Math.max(0, Math.ceil(input.reservedTokens ?? 0));
|
||||
return messageTokens + systemTokens + toolTokens + reserved;
|
||||
}
|
||||
|
||||
export function shouldCompactByBudget(input: {
|
||||
messages: ModelMessage[];
|
||||
contextWindow: number;
|
||||
maxOutputTokens?: number;
|
||||
providerId?: string | null;
|
||||
systemPrompt?: string;
|
||||
toolNames?: string[];
|
||||
reservedTokens?: number;
|
||||
forceThreshold?: number;
|
||||
}): boolean {
|
||||
const total = computeTotalInputTokens({
|
||||
messages: input.messages,
|
||||
providerId: input.providerId,
|
||||
systemPrompt: input.systemPrompt,
|
||||
toolNames: input.toolNames,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
const threshold = input.forceThreshold ?? computeCompactionThreshold({
|
||||
contextWindow: input.contextWindow,
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
});
|
||||
return total >= threshold;
|
||||
}
|
||||
351
infrastructure/ai/harness/contextManager.test.ts
Normal file
351
infrastructure/ai/harness/contextManager.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { prepareTurnContext, prepareStepContext } from './contextManager.ts';
|
||||
import { TraceStore } from './traceStore.ts';
|
||||
import { ToolOutputStore } from './toolOutputStore.ts';
|
||||
import { createInitialCattyRuntimeContext } from './cattyRuntimeContext.ts';
|
||||
|
||||
test('normal turn and step preparation preserve exact user literals alongside noisy tool output', async () => {
|
||||
const literal = "printf '%s\\n' 'CANONICAL_GUI_" + 'x'.repeat(1200) + "_END'";
|
||||
const content = `Execute exactly: ${literal}\n\n\n\n${'keep this line\n'.repeat(5)}`;
|
||||
for (const userContent of [content, [{ type: 'text' as const, text: content }]]) {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: 'user', content: userContent },
|
||||
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'noise', toolName: 'terminal_execute', input: {} }] },
|
||||
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'noise', toolName: 'terminal_execute', output: { type: 'text', value: 'log '.repeat(10000) } }] },
|
||||
];
|
||||
const turn = await prepareTurnContext({ messages, backend: 'catty', contextWindow: 1_300_000, trigger: 'pre-turn' });
|
||||
const step = await prepareStepContext({ messages: turn.messages, contextWindow: 1_300_000, stepNumber: 0 });
|
||||
for (const prepared of [turn, step]) {
|
||||
assert.deepEqual(prepared.messages.find(message => message.role === 'user')?.content, userContent);
|
||||
assert.ok(JSON.stringify(prepared.messages).length < JSON.stringify(messages).length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('prepareTurnContext applies typed compression before LLM summarize threshold', async () => {
|
||||
const longOutput = 'line\n'.repeat(20_000);
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Check nginx error logs on prod-web-01 and summarize failures.',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'terminal_execute',
|
||||
input: { sessionId: 'sess-1', command: 'tail -n 500 /var/log/nginx/error.log' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'terminal_execute',
|
||||
output: { type: 'text', value: longOutput },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Found repeated upstream timeout errors.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Fix only the upstream timeout issue, do not restart nginx yet.',
|
||||
},
|
||||
];
|
||||
|
||||
const traces: string[] = [];
|
||||
const prepared = await prepareTurnContext({
|
||||
messages,
|
||||
backend: 'catty',
|
||||
contextWindow: 128_000,
|
||||
trigger: 'pre-turn',
|
||||
sessionId: 'chat-1',
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'compaction') traces.push(event.trace.trigger);
|
||||
},
|
||||
reinjection: {
|
||||
permissionMode: 'confirm',
|
||||
userGoal: 'Fix upstream timeout without restarting nginx.',
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(prepared.messages.length >= messages.length);
|
||||
const serialized = JSON.stringify(prepared.messages);
|
||||
assert.match(serialized, /Fix only the upstream timeout issue/);
|
||||
assert.match(serialized, /Permission mode: confirm/);
|
||||
assert.ok(serialized.length < JSON.stringify(messages).length);
|
||||
});
|
||||
|
||||
test('prepareTurnContext skips reinjection when no compaction occurred', async () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: 'user', content: 'List running containers on prod-web-01.' },
|
||||
{ role: 'assistant', content: 'I will check docker ps.' },
|
||||
];
|
||||
|
||||
const events: string[] = [];
|
||||
const prepared = await prepareTurnContext({
|
||||
messages,
|
||||
backend: 'catty',
|
||||
contextWindow: 128_000,
|
||||
trigger: 'pre-turn',
|
||||
sessionId: 'chat-no-compact',
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'compaction') events.push(event.trace.trigger);
|
||||
},
|
||||
reinjection: {
|
||||
permissionMode: 'confirm',
|
||||
userGoal: 'List running containers on prod-web-01.',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prepared.didAdjust, false);
|
||||
assert.equal(events.length, 0);
|
||||
const serialized = JSON.stringify(prepared.messages);
|
||||
assert.doesNotMatch(serialized, /Netcatty session context/);
|
||||
assert.doesNotMatch(serialized, /Permission mode: confirm/);
|
||||
});
|
||||
|
||||
test('prepareTurnContext force trigger retains recent user goal in replay', async () => {
|
||||
const messages: ModelMessage[] = Array.from({ length: 40 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: index === 38
|
||||
? 'SSH into db-01 and inspect /var/log/postgresql/postgresql.log for crash signatures.'
|
||||
: `filler message ${index}`,
|
||||
})) as ModelMessage[];
|
||||
|
||||
const prepared = await prepareTurnContext({
|
||||
messages,
|
||||
backend: 'catty',
|
||||
contextWindow: 128_000,
|
||||
trigger: 'force',
|
||||
force: true,
|
||||
sessionId: 'chat-2',
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(prepared.messages);
|
||||
assert.match(serialized, /SSH into db-01/);
|
||||
assert.match(serialized, /postgresql\.log/);
|
||||
});
|
||||
|
||||
test('TraceStore records compaction events for export', async () => {
|
||||
const store = new TraceStore();
|
||||
await prepareTurnContext({
|
||||
messages: [{ role: 'user', content: 'x'.repeat(500_000) }],
|
||||
backend: 'catty',
|
||||
contextWindow: 1000,
|
||||
trigger: 'force',
|
||||
force: true,
|
||||
sessionId: 'chat-3',
|
||||
onEvent: (event) => store.append(event),
|
||||
});
|
||||
|
||||
const exported = store.exportTrace('chat-3');
|
||||
assert.ok(exported.compactions.length >= 1);
|
||||
assert.equal(exported.compactions[0]?.trigger, 'force');
|
||||
});
|
||||
|
||||
test('TraceStore bounds compaction history independently from events', () => {
|
||||
const store = new TraceStore(20, 3);
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
store.append({
|
||||
id: `compaction-${index}`,
|
||||
type: 'compaction',
|
||||
sessionId: 'chat-bounded-compactions',
|
||||
trace: { trigger: 'force' },
|
||||
} as import('./types').AgentEvent);
|
||||
}
|
||||
assert.equal(store.getCompactions('chat-bounded-compactions').length, 3);
|
||||
});
|
||||
|
||||
test('prepareStepContext replaces prior step handle notices under v7 carry-forward semantics', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
store.store({
|
||||
chatSessionId: 'chat-4',
|
||||
capabilityId: 'sftp.read',
|
||||
content: 'large payload',
|
||||
});
|
||||
const runtimeContext = createInitialCattyRuntimeContext({
|
||||
chatSessionId: 'chat-4',
|
||||
turnId: 'turn-1',
|
||||
permissionMode: 'confirm',
|
||||
scopeType: 'terminal',
|
||||
});
|
||||
const priorNotice: ModelMessage = {
|
||||
role: 'user',
|
||||
content: '[step 1] Tool output handles available: tool-output-old',
|
||||
};
|
||||
const prepared = await prepareStepContext({
|
||||
messages: [priorNotice, { role: 'user', content: 'continue' }],
|
||||
stepNumber: 2,
|
||||
sessionId: 'chat-4',
|
||||
chatSessionId: 'chat-4',
|
||||
toolOutputStore: store,
|
||||
runtimeContext,
|
||||
});
|
||||
|
||||
const notices = prepared.messages.filter(
|
||||
(message) => message.role === 'user'
|
||||
&& typeof message.content === 'string'
|
||||
&& message.content.includes('Tool output handles available'),
|
||||
);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(String(notices[0]?.content), /\[step 2\]/);
|
||||
assert.doesNotMatch(String(notices[0]?.content), /tool-output-old/);
|
||||
});
|
||||
|
||||
test('prepareStepContext emits step compaction trace when over budget', async () => {
|
||||
const messages: ModelMessage[] = Array.from({ length: 30 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: 'payload '.repeat(2_000),
|
||||
})) as ModelMessage[];
|
||||
|
||||
const events: string[] = [];
|
||||
const prepared = await prepareStepContext({
|
||||
messages,
|
||||
stepNumber: 3,
|
||||
sessionId: 'chat-5',
|
||||
chatSessionId: 'chat-5',
|
||||
contextWindow: 4_000,
|
||||
reservedTokens: 500,
|
||||
maxOutputTokens: 512,
|
||||
providerId: 'anthropic',
|
||||
runtimeContext: createInitialCattyRuntimeContext({
|
||||
chatSessionId: 'chat-5',
|
||||
turnId: 'turn-2',
|
||||
permissionMode: 'confirm',
|
||||
scopeType: 'terminal',
|
||||
}),
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'compaction') events.push(event.trace.trigger);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prepared.didAdjust, true);
|
||||
assert.equal(prepared.trace?.trigger, 'step');
|
||||
assert.ok(events.includes('step'));
|
||||
});
|
||||
|
||||
test('prepareStepContext retains handle notice after step budget guard', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
store.store({
|
||||
chatSessionId: 'chat-handle',
|
||||
capabilityId: 'sftp.read',
|
||||
content: 'large payload',
|
||||
});
|
||||
const messages: ModelMessage[] = Array.from({ length: 30 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: 'payload '.repeat(2_000),
|
||||
})) as ModelMessage[];
|
||||
|
||||
const prepared = await prepareStepContext({
|
||||
messages,
|
||||
stepNumber: 2,
|
||||
sessionId: 'chat-handle',
|
||||
chatSessionId: 'chat-handle',
|
||||
contextWindow: 4_000,
|
||||
reservedTokens: 500,
|
||||
maxOutputTokens: 512,
|
||||
toolOutputStore: store,
|
||||
runtimeContext: createInitialCattyRuntimeContext({
|
||||
chatSessionId: 'chat-handle',
|
||||
turnId: 'turn-handle',
|
||||
permissionMode: 'confirm',
|
||||
scopeType: 'terminal',
|
||||
}),
|
||||
});
|
||||
|
||||
const notices = prepared.messages.filter(
|
||||
(message) => message.role === 'user'
|
||||
&& typeof message.content === 'string'
|
||||
&& message.content.includes('Tool output handles available'),
|
||||
);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(String(notices[0]?.content), /\[step 2\]/);
|
||||
});
|
||||
|
||||
test('prepareStepContext never leaves orphan results from a parallel tool batch', async () => {
|
||||
const toolCallIds = ['a', 'b', 'c', 'd', 'e', 'f'];
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: toolCallIds.map((toolCallId) => ({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId,
|
||||
toolName: 'terminal_poll',
|
||||
input: { jobId: toolCallId },
|
||||
})),
|
||||
},
|
||||
...toolCallIds.map((toolCallId) => ({
|
||||
role: 'tool' as const,
|
||||
content: [{
|
||||
type: 'tool-result' as const,
|
||||
toolCallId,
|
||||
toolName: 'terminal_poll',
|
||||
output: { type: 'text' as const, value: `${toolCallId}:${'output '.repeat(4_000)}` },
|
||||
}],
|
||||
})),
|
||||
{ role: 'user', content: 'summarize the completed jobs' },
|
||||
];
|
||||
|
||||
const prepared = await prepareStepContext({
|
||||
messages,
|
||||
stepNumber: 4,
|
||||
sessionId: 'chat-parallel',
|
||||
chatSessionId: 'chat-parallel',
|
||||
contextWindow: 8_000,
|
||||
reservedTokens: 500,
|
||||
maxOutputTokens: 512,
|
||||
runtimeContext: createInitialCattyRuntimeContext({
|
||||
chatSessionId: 'chat-parallel',
|
||||
turnId: 'turn-parallel',
|
||||
permissionMode: 'confirm',
|
||||
scopeType: 'terminal',
|
||||
}),
|
||||
});
|
||||
|
||||
const knownCalls = new Set<string>();
|
||||
for (const message of prepared.messages) {
|
||||
if (message.role === 'assistant' && Array.isArray(message.content)) {
|
||||
for (const part of message.content as Array<{ type?: string; toolCallId?: string }>) {
|
||||
if (part.type === 'tool-call' && part.toolCallId) knownCalls.add(part.toolCallId);
|
||||
}
|
||||
}
|
||||
if (message.role === 'tool' && Array.isArray(message.content)) {
|
||||
for (const part of message.content as Array<{ type?: string; toolCallId?: string }>) {
|
||||
if (part.type === 'tool-result') assert.equal(knownCalls.has(part.toolCallId ?? ''), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('prepareTurnContext calls summarize when over dynamic threshold', async () => {
|
||||
let summarizeCalls = 0;
|
||||
const messages: ModelMessage[] = Array.from({ length: 24 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: 'history '.repeat(3_000),
|
||||
})) as ModelMessage[];
|
||||
|
||||
const prepared = await prepareTurnContext({
|
||||
messages,
|
||||
backend: 'catty',
|
||||
contextWindow: 8_000,
|
||||
maxOutputTokens: 512,
|
||||
trigger: 'pre-turn',
|
||||
sessionId: 'chat-6',
|
||||
providerId: 'openai',
|
||||
summarize: async () => {
|
||||
summarizeCalls += 1;
|
||||
return 'summary of earlier work';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(summarizeCalls, 1);
|
||||
assert.equal(prepared.trace?.didLlmSummarize, true);
|
||||
assert.match(JSON.stringify(prepared.messages), /summary of earlier work/);
|
||||
});
|
||||
524
infrastructure/ai/harness/contextManager.ts
Normal file
524
infrastructure/ai/harness/contextManager.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type { ChatMessage, AIPermissionMode } from '../types';
|
||||
import { isStepHandleNoticeMessage } from './agentEventAdapter';
|
||||
import {
|
||||
DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
findSafeCompactionSplitIndex,
|
||||
keepRecentContextMessages,
|
||||
prepareContextCompaction,
|
||||
} from '../contextCompaction';
|
||||
import { compressMessagesForRequestTooLargeRetry } from '../requestPayloadCompression';
|
||||
import {
|
||||
computeCompactionThreshold,
|
||||
computeTotalInputTokens,
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
shouldCompactByBudget,
|
||||
} from './contextBudget';
|
||||
import { estimateModelMessagesTokensWithKind } from './tokenEstimator';
|
||||
import { pruneStaleToolContext } from './staleContextPruner';
|
||||
import type { PrepareStepContextInput } from './turnDrivers/types';
|
||||
import type {
|
||||
AgentEventListener,
|
||||
CompactionTrace,
|
||||
ContextPrepareResult,
|
||||
ContextPrepareTrigger,
|
||||
ExternalBridgeHistoryMessage,
|
||||
} from './types';
|
||||
import { buildExternalBridgeContextMessages } from './externalBridgeContext';
|
||||
import { repairToolMessageIntegrity } from './toolMessageIntegrity';
|
||||
import { pruneFirstModelMessage } from './compactionPruner';
|
||||
|
||||
export interface PrepareTurnContextInput {
|
||||
messages: ModelMessage[];
|
||||
backend: 'catty' | 'external-bridge';
|
||||
contextWindow?: number;
|
||||
reservedTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
trigger: ContextPrepareTrigger;
|
||||
protectRecentMessages?: number;
|
||||
force?: boolean;
|
||||
compressForRequestTooLargeRetry?: boolean;
|
||||
summarize?: (messagesToSummarize: ModelMessage[]) => Promise<string>;
|
||||
onEvent?: AgentEventListener;
|
||||
sessionId?: string;
|
||||
chatSessionId?: string;
|
||||
providerId?: string | null;
|
||||
reinjection?: PostCompactReinjection;
|
||||
}
|
||||
|
||||
export interface PostCompactReinjection {
|
||||
permissionMode?: AIPermissionMode;
|
||||
sessionScopeSummary?: string;
|
||||
sessionStateText?: string;
|
||||
userGoal?: string;
|
||||
pendingToolHandleIds?: string[];
|
||||
}
|
||||
|
||||
function emitCompactionEvent(
|
||||
onEvent: AgentEventListener | undefined,
|
||||
input: {
|
||||
sessionId?: string;
|
||||
chatSessionId?: string;
|
||||
backend: PrepareTurnContextInput['backend'];
|
||||
},
|
||||
trace: CompactionTrace,
|
||||
): void {
|
||||
if (!onEvent || !input.sessionId) return;
|
||||
onEvent({
|
||||
id: `compaction-${Date.now()}`,
|
||||
type: 'compaction',
|
||||
sessionId: input.sessionId,
|
||||
chatSessionId: input.chatSessionId,
|
||||
backend: input.backend === 'catty' ? 'catty' : 'external-sdk',
|
||||
timestamp: Date.now(),
|
||||
trace,
|
||||
});
|
||||
}
|
||||
|
||||
function emitCompactionStart(
|
||||
onEvent: AgentEventListener | undefined,
|
||||
input: {
|
||||
sessionId?: string;
|
||||
chatSessionId?: string;
|
||||
backend: PrepareTurnContextInput['backend'];
|
||||
},
|
||||
trigger: ContextPrepareTrigger,
|
||||
): void {
|
||||
if (!onEvent || !input.sessionId) return;
|
||||
onEvent({
|
||||
id: `compaction-start-${Date.now()}`,
|
||||
type: 'compaction_start',
|
||||
sessionId: input.sessionId,
|
||||
chatSessionId: input.chatSessionId,
|
||||
backend: input.backend === 'catty' ? 'catty' : 'external-sdk',
|
||||
timestamp: Date.now(),
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function applyTypedMessageCompression(messages: ModelMessage[]): {
|
||||
messages: ModelMessage[];
|
||||
didAdjust: boolean;
|
||||
} {
|
||||
const compressed = compressMessagesForRequestTooLargeRetry(messages);
|
||||
return { messages: compressed.messages, didAdjust: compressed.didAdjust };
|
||||
}
|
||||
|
||||
function buildReinjectionMessages(reinjection?: PostCompactReinjection): ModelMessage[] {
|
||||
if (!reinjection) return [];
|
||||
const lines: string[] = ['[Netcatty session context — preserved after compaction]'];
|
||||
if (reinjection.permissionMode) {
|
||||
lines.push(`Permission mode: ${reinjection.permissionMode}`);
|
||||
}
|
||||
if (reinjection.sessionStateText) {
|
||||
lines.push(reinjection.sessionStateText);
|
||||
}
|
||||
if (reinjection.sessionScopeSummary) {
|
||||
lines.push(reinjection.sessionScopeSummary);
|
||||
}
|
||||
if (reinjection.userGoal) {
|
||||
lines.push(`Current user goal: ${reinjection.userGoal}`);
|
||||
}
|
||||
if (reinjection.pendingToolHandleIds?.length) {
|
||||
lines.push(`Unresolved tool output handles: ${reinjection.pendingToolHandleIds.join(', ')}`);
|
||||
}
|
||||
if (lines.length <= 1) return [];
|
||||
return [{
|
||||
role: 'user',
|
||||
content: lines.join('\n'),
|
||||
}];
|
||||
}
|
||||
|
||||
function buildCompactionTrace(input: {
|
||||
trigger: ContextPrepareTrigger;
|
||||
tokensBefore: number;
|
||||
tokensAfter: number;
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
compressedMessageCount: number;
|
||||
retainedTailCount: number;
|
||||
summaryLength?: number;
|
||||
didTypedCompression: boolean;
|
||||
didLlmSummarize: boolean;
|
||||
did413Fallback: boolean;
|
||||
estimatorKind?: CompactionTrace['estimatorKind'];
|
||||
}): CompactionTrace {
|
||||
return {
|
||||
trigger: input.trigger,
|
||||
estimatedTokensBefore: input.tokensBefore,
|
||||
estimatedTokensAfter: input.tokensAfter,
|
||||
messagesBefore: input.messagesBefore,
|
||||
messagesAfter: input.messagesAfter,
|
||||
compressedMessageCount: input.compressedMessageCount,
|
||||
retainedTailCount: input.retainedTailCount,
|
||||
summaryLength: input.summaryLength,
|
||||
didTypedCompression: input.didTypedCompression,
|
||||
didLlmSummarize: input.didLlmSummarize,
|
||||
did413Fallback: input.did413Fallback,
|
||||
estimatorKind: input.estimatorKind,
|
||||
};
|
||||
}
|
||||
|
||||
function isContextUnderBudgetPressure(input: {
|
||||
messages: ModelMessage[];
|
||||
contextWindow: number;
|
||||
maxOutputTokens: number;
|
||||
providerId?: string | null;
|
||||
reservedTokens?: number;
|
||||
force?: boolean;
|
||||
trigger?: ContextPrepareTrigger;
|
||||
}): boolean {
|
||||
if (input.force || input.trigger === '413-retry' || input.trigger === 'force') {
|
||||
return true;
|
||||
}
|
||||
return shouldCompactByBudget({
|
||||
messages: input.messages,
|
||||
contextWindow: input.contextWindow,
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareTurnContext(
|
||||
input: PrepareTurnContextInput,
|
||||
): Promise<ContextPrepareResult> {
|
||||
const contextWindow = input.contextWindow ?? DEFAULT_CONTEXT_WINDOW_TOKENS;
|
||||
const protectRecent = input.protectRecentMessages ?? DEFAULT_PROTECT_RECENT_MESSAGES;
|
||||
const maxOutputTokens = input.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
const messagesBeforeCount = input.messages.length;
|
||||
const integrity = repairToolMessageIntegrity(input.messages);
|
||||
|
||||
const underBudgetPressure = isContextUnderBudgetPressure({
|
||||
messages: integrity.messages,
|
||||
contextWindow,
|
||||
maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
force: input.force,
|
||||
trigger: input.trigger,
|
||||
});
|
||||
const stale = pruneStaleToolContext(integrity.messages, {
|
||||
underBudgetPressure,
|
||||
});
|
||||
let working = stale.messages;
|
||||
let didAdjust = integrity.didAdjust || stale.didAdjust;
|
||||
|
||||
const tokensBeforeResult = estimateModelMessagesTokensWithKind({
|
||||
messages: working,
|
||||
providerId: input.providerId,
|
||||
});
|
||||
const tokensBefore = tokensBeforeResult.tokens;
|
||||
const estimatorKind = tokensBeforeResult.estimatorKind;
|
||||
|
||||
let didTypedCompression = false;
|
||||
let didLlmSummarize = false;
|
||||
let did413Fallback = false;
|
||||
let summaryLength: number | undefined;
|
||||
let compressedMessageCount = 0;
|
||||
let retainedTailCount = working.length;
|
||||
|
||||
if (input.compressForRequestTooLargeRetry || input.trigger === '413-retry') {
|
||||
const typed = applyTypedMessageCompression(working);
|
||||
working = typed.messages;
|
||||
didTypedCompression = typed.didAdjust;
|
||||
did413Fallback = typed.didAdjust;
|
||||
didAdjust = didAdjust || typed.didAdjust;
|
||||
} else {
|
||||
const typed = applyTypedMessageCompression(working);
|
||||
if (typed.didAdjust) {
|
||||
working = typed.messages;
|
||||
didTypedCompression = true;
|
||||
didAdjust = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.summarize) {
|
||||
const compacted = await prepareContextCompaction({
|
||||
messages: working,
|
||||
contextWindow,
|
||||
reservedTokens: input.reservedTokens ?? 0,
|
||||
thresholdRatio: input.force || input.trigger === 'force' ? 0 : undefined,
|
||||
maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
protectRecentMessages: protectRecent,
|
||||
summarize: input.summarize,
|
||||
});
|
||||
|
||||
if (compacted.didCompact) {
|
||||
working = compacted.messages;
|
||||
didLlmSummarize = true;
|
||||
didAdjust = true;
|
||||
summaryLength = compacted.summary?.length;
|
||||
compressedMessageCount = Math.max(0, messagesBeforeCount - protectRecent);
|
||||
retainedTailCount = protectRecent;
|
||||
} else if (input.force || input.trigger === '413-retry' || input.trigger === 'force') {
|
||||
working = keepRecentContextMessages(working, protectRecent);
|
||||
didAdjust = true;
|
||||
retainedTailCount = working.length;
|
||||
}
|
||||
} else if (input.force || input.trigger === '413-retry' || input.trigger === 'force') {
|
||||
working = keepRecentContextMessages(working, protectRecent);
|
||||
didAdjust = true;
|
||||
retainedTailCount = working.length;
|
||||
}
|
||||
|
||||
const reinjection = buildReinjectionMessages(input.reinjection);
|
||||
if (reinjection.length > 0 && didAdjust) {
|
||||
const reinjectionTokens = estimateModelMessagesTokensWithKind({
|
||||
messages: reinjection,
|
||||
providerId: input.providerId,
|
||||
}).tokens;
|
||||
const finalGuard = applyStepBudgetGuard(working, {
|
||||
contextWindow,
|
||||
reservedTokens: (input.reservedTokens ?? 0) + reinjectionTokens,
|
||||
maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
protectRecentMessages: protectRecent,
|
||||
});
|
||||
working = [...reinjection, ...finalGuard.messages];
|
||||
didAdjust = didAdjust || finalGuard.didAdjust;
|
||||
}
|
||||
|
||||
const tokensAfter = estimateModelMessagesTokensWithKind({
|
||||
messages: working,
|
||||
providerId: input.providerId,
|
||||
}).tokens;
|
||||
|
||||
if (didAdjust) {
|
||||
const trace = buildCompactionTrace({
|
||||
trigger: input.trigger,
|
||||
tokensBefore,
|
||||
tokensAfter,
|
||||
messagesBefore: messagesBeforeCount,
|
||||
messagesAfter: working.length,
|
||||
compressedMessageCount,
|
||||
retainedTailCount,
|
||||
summaryLength,
|
||||
didTypedCompression,
|
||||
didLlmSummarize,
|
||||
did413Fallback,
|
||||
estimatorKind,
|
||||
});
|
||||
emitCompactionEvent(input.onEvent, input, trace);
|
||||
return { messages: working, didAdjust: true, trace };
|
||||
}
|
||||
|
||||
return { messages: working, didAdjust: false };
|
||||
}
|
||||
|
||||
export function buildExternalBridgeContext(
|
||||
messages: ChatMessage[],
|
||||
): ExternalBridgeHistoryMessage[] {
|
||||
return buildExternalBridgeContextMessages(messages);
|
||||
}
|
||||
|
||||
export function extractLatestUserGoal(messages: ModelMessage[] | ChatMessage[]): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message.role !== 'user') continue;
|
||||
const content = typeof message.content === 'string'
|
||||
? message.content.trim()
|
||||
: '';
|
||||
if (content && !content.startsWith('[Netcatty session context')) return content.slice(0, 500);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function applyStepBudgetGuard(
|
||||
messages: ModelMessage[],
|
||||
input: {
|
||||
contextWindow: number;
|
||||
reservedTokens: number;
|
||||
maxOutputTokens: number;
|
||||
providerId?: string | null;
|
||||
protectRecentMessages: number;
|
||||
},
|
||||
): { messages: ModelMessage[]; didAdjust: boolean; didTypedCompression: boolean } {
|
||||
const threshold = computeCompactionThreshold({
|
||||
contextWindow: input.contextWindow,
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
});
|
||||
const total = computeTotalInputTokens({
|
||||
messages,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
if (total < threshold) {
|
||||
return { messages, didAdjust: false, didTypedCompression: false };
|
||||
}
|
||||
|
||||
const splitAt = findSafeCompactionSplitIndex(messages, input.protectRecentMessages);
|
||||
const head = messages.slice(0, splitAt);
|
||||
const tail = messages.slice(splitAt);
|
||||
const compressedHead = applyTypedMessageCompression(head);
|
||||
let next = [...compressedHead.messages, ...tail];
|
||||
let didAdjust = compressedHead.didAdjust;
|
||||
|
||||
let afterTotal = computeTotalInputTokens({
|
||||
messages: next,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
if (afterTotal >= threshold && splitAt > 0) {
|
||||
next = keepRecentContextMessages(next, input.protectRecentMessages);
|
||||
didAdjust = true;
|
||||
afterTotal = computeTotalInputTokens({
|
||||
messages: next,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
}
|
||||
while (afterTotal >= threshold && next.length > 2) {
|
||||
const pruned = pruneFirstModelMessage(next);
|
||||
if (pruned.length === next.length) break;
|
||||
next = pruned;
|
||||
didAdjust = true;
|
||||
afterTotal = computeTotalInputTokens({
|
||||
messages: next,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
messages: next,
|
||||
didAdjust: didAdjust || next.length !== messages.length,
|
||||
didTypedCompression: compressedHead.didAdjust,
|
||||
};
|
||||
}
|
||||
|
||||
/** Step-level typed pruning — no LLM summarize (reserved for pre-turn / 413). */
|
||||
export async function prepareStepContext(
|
||||
input: PrepareStepContextInput,
|
||||
): Promise<ContextPrepareResult & { runtimeContext?: import('./cattyRuntimeContext').CattyRuntimeContext }> {
|
||||
const contextWindow = input.contextWindow ?? DEFAULT_CONTEXT_WINDOW_TOKENS;
|
||||
const maxOutputTokens = input.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
const protectRecent = input.protectRecentMessages ?? DEFAULT_PROTECT_RECENT_MESSAGES;
|
||||
const messagesBeforeCount = input.messages.length;
|
||||
const integrity = repairToolMessageIntegrity(input.messages);
|
||||
|
||||
const underBudgetPressure = isContextUnderBudgetPressure({
|
||||
messages: integrity.messages,
|
||||
contextWindow,
|
||||
maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
const stale = pruneStaleToolContext(integrity.messages, {
|
||||
underBudgetPressure,
|
||||
});
|
||||
let working = stale.messages;
|
||||
|
||||
const typed = compressMessagesForRequestTooLargeRetry(working);
|
||||
working = typed.messages;
|
||||
|
||||
const pendingHandles = input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? [];
|
||||
let didHandleNotice = false;
|
||||
if (pendingHandles.length > 0 && input.stepNumber > 0) {
|
||||
working = working.filter((message) => {
|
||||
if (message.role !== 'user') return true;
|
||||
const content = typeof message.content === 'string' ? message.content : '';
|
||||
return !isStepHandleNoticeMessage(content);
|
||||
});
|
||||
const notice: ModelMessage = {
|
||||
role: 'user',
|
||||
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
|
||||
};
|
||||
working = [notice, ...working];
|
||||
didHandleNotice = true;
|
||||
}
|
||||
|
||||
const budgetGuard = applyStepBudgetGuard(working, {
|
||||
contextWindow,
|
||||
reservedTokens: input.reservedTokens ?? 0,
|
||||
maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
protectRecentMessages: protectRecent,
|
||||
});
|
||||
working = budgetGuard.messages;
|
||||
if (didHandleNotice && pendingHandles.length > 0) {
|
||||
const hasHandleNotice = working.some(
|
||||
(message) => message.role === 'user' && isStepHandleNoticeMessage(message.content),
|
||||
);
|
||||
if (!hasHandleNotice) {
|
||||
working = [{
|
||||
role: 'user',
|
||||
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
|
||||
}, ...working];
|
||||
}
|
||||
}
|
||||
if (didHandleNotice) {
|
||||
const notices = working.filter(
|
||||
message => message.role === 'user' && isStepHandleNoticeMessage(message.content),
|
||||
);
|
||||
const body = working.filter(
|
||||
message => !(message.role === 'user' && isStepHandleNoticeMessage(message.content)),
|
||||
);
|
||||
const noticeTokens = estimateModelMessagesTokensWithKind({
|
||||
messages: notices,
|
||||
providerId: input.providerId,
|
||||
}).tokens;
|
||||
const finalGuard = applyStepBudgetGuard(body, {
|
||||
contextWindow,
|
||||
reservedTokens: (input.reservedTokens ?? 0) + noticeTokens,
|
||||
maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
protectRecentMessages: protectRecent,
|
||||
});
|
||||
working = [...notices, ...finalGuard.messages];
|
||||
budgetGuard.didAdjust = budgetGuard.didAdjust || finalGuard.didAdjust;
|
||||
}
|
||||
const didBudgetAdjust = integrity.didAdjust || stale.didAdjust || typed.didAdjust || budgetGuard.didAdjust;
|
||||
const didAdjust = didBudgetAdjust || didHandleNotice;
|
||||
|
||||
const before = estimateModelMessagesTokensWithKind({
|
||||
messages: input.messages,
|
||||
providerId: input.providerId,
|
||||
});
|
||||
const after = estimateModelMessagesTokensWithKind({
|
||||
messages: working,
|
||||
providerId: input.providerId,
|
||||
});
|
||||
|
||||
const trace = didBudgetAdjust ? buildCompactionTrace({
|
||||
trigger: 'step',
|
||||
tokensBefore: before.tokens,
|
||||
tokensAfter: after.tokens,
|
||||
messagesBefore: messagesBeforeCount,
|
||||
messagesAfter: working.length,
|
||||
compressedMessageCount: Math.max(0, messagesBeforeCount - working.length),
|
||||
retainedTailCount: working.length,
|
||||
didTypedCompression: typed.didAdjust || budgetGuard.didTypedCompression,
|
||||
didLlmSummarize: false,
|
||||
did413Fallback: false,
|
||||
estimatorKind: before.estimatorKind,
|
||||
}) : undefined;
|
||||
|
||||
if (trace && didBudgetAdjust && input.onEvent && input.sessionId) {
|
||||
emitCompactionStart(input.onEvent, {
|
||||
sessionId: input.sessionId,
|
||||
chatSessionId: input.chatSessionId,
|
||||
backend: 'catty',
|
||||
}, 'step');
|
||||
emitCompactionEvent(input.onEvent, {
|
||||
sessionId: input.sessionId,
|
||||
chatSessionId: input.chatSessionId,
|
||||
backend: 'catty',
|
||||
}, trace);
|
||||
}
|
||||
|
||||
const runtimeContext = {
|
||||
...input.runtimeContext,
|
||||
...(trace ? { lastCompaction: trace, lastStepAdjusted: didBudgetAdjust } : {}),
|
||||
...(didAdjust ? { lastStepAdjusted: true } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
messages: working,
|
||||
didAdjust,
|
||||
trace,
|
||||
runtimeContext,
|
||||
};
|
||||
}
|
||||
38
infrastructure/ai/harness/contextReplay.test.ts
Normal file
38
infrastructure/ai/harness/contextReplay.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { prepareTurnContext, extractLatestUserGoal } from './contextManager';
|
||||
|
||||
const fixtureMessages: ModelMessage[] = [
|
||||
{ role: 'user', content: 'Deploy nginx on prod-web-01 and verify port 443.' },
|
||||
{ role: 'assistant', content: 'I will check the server first.' },
|
||||
{ role: 'user', content: 'Command failed: systemctl restart nginx returned exit code 1.' },
|
||||
{ role: 'assistant', content: 'The error log shows missing ssl_certificate path.' },
|
||||
];
|
||||
|
||||
test('context replay compaction retains user goal and recent tail', async () => {
|
||||
const goal = extractLatestUserGoal(fixtureMessages);
|
||||
assert.ok(goal?.includes('Command failed'));
|
||||
|
||||
const prepared = await prepareTurnContext({
|
||||
messages: [...fixtureMessages],
|
||||
backend: 'catty',
|
||||
contextWindow: 500,
|
||||
reservedTokens: 100,
|
||||
trigger: 'force',
|
||||
force: true,
|
||||
protectRecentMessages: 2,
|
||||
providerId: 'anthropic',
|
||||
reinjection: {
|
||||
userGoal: goal,
|
||||
permissionMode: 'confirm',
|
||||
sessionStateText: 'User goal: Deploy nginx\nActive hosts: sess-1 (last: systemctl status nginx)',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prepared.didAdjust, true);
|
||||
assert.ok(prepared.messages.length <= fixtureMessages.length + 1);
|
||||
const joined = JSON.stringify(prepared.messages);
|
||||
assert.match(joined, /Deploy nginx|Command failed|ssl_certificate/);
|
||||
assert.ok(prepared.trace?.estimatorKind);
|
||||
});
|
||||
453
infrastructure/ai/harness/externalBridgeContext.ts
Normal file
453
infrastructure/ai/harness/externalBridgeContext.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import type { ChatMessage } from '../types';
|
||||
import type { ExternalBridgeHistoryMessage } from './types';
|
||||
import { buildHistoricalToolResultReplayText, buildHistoricalUserReplayContent } from '../../../components/ai/cattyHistoryReplay';
|
||||
import { formatVaultNoteReferences, isVaultNoteAttachment } from '../../../application/state/vaultNoteAttachment';
|
||||
|
||||
type ExternalAgentHistoryMessage = ExternalBridgeHistoryMessage;
|
||||
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
|
||||
type DurableUserLine = {
|
||||
line: string;
|
||||
messageIndex: number;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
const MAX_RECENT_RAW_MESSAGES = 6;
|
||||
const MAX_MESSAGES_TO_SCAN = 20;
|
||||
// Bound the scan by user turns, not raw message count: a tool-heavy external agent
|
||||
// chat can produce 5+ messages per logical turn (user + assistant +
|
||||
// several tool_results + follow-up assistant), so a plain
|
||||
// message-count cap ages out early constraints much sooner than intended.
|
||||
const MAX_DURABLE_SCAN_TURNS = 100;
|
||||
const MAX_COMPACT_CONTEXT_CHARS = 3000;
|
||||
const MAX_RAW_MESSAGE_CHARS = 2000;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 500;
|
||||
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
|
||||
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
|
||||
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
|
||||
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
|
||||
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
|
||||
const MAX_TOOL_CALL_LABEL_CHARS = 200;
|
||||
|
||||
type ToolCallInfo = { name: string; arguments: unknown };
|
||||
|
||||
const IMPORTANT_PATTERNS = [
|
||||
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
|
||||
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
|
||||
];
|
||||
const DURABLE_CONSTRAINT_PATTERNS = [
|
||||
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
|
||||
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
|
||||
];
|
||||
const TRIVIAL_USER_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
|
||||
];
|
||||
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
|
||||
];
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isImportantText(value: string): boolean {
|
||||
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isDurableConstraintText(value: string): boolean {
|
||||
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function getUserHistoryContent(message: ChatMessage): string {
|
||||
if (message.role !== "user") return message.content || "";
|
||||
return buildHistoricalUserReplayContent(
|
||||
message.content || "",
|
||||
message.attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
function isTrivialUserMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
|
||||
// Don't blanket-drop short messages — short user turns are often
|
||||
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
|
||||
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
|
||||
// enumerate. The trivial-phrase regex already catches actual filler
|
||||
// ("ok", "yes", "thanks", "继续").
|
||||
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableUserPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isSubstantiveAssistantMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (!normalized) return false;
|
||||
// Mirror the user-side loosening: don't blanket-drop short assistant
|
||||
// messages just because they're under 40 chars or don't match the small
|
||||
// English keyword list. Short but load-bearing decisions ("Use ssh2",
|
||||
// "rebase instead", "中文输出") aren't realistically enumerable and
|
||||
// they're the exact things a later "do what you suggested" references.
|
||||
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
|
||||
// ("ok", "ack", "got it", "明白").
|
||||
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableAssistantPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function appendUniqueLine(
|
||||
target: string[],
|
||||
seen: Set<string>,
|
||||
line: string,
|
||||
maxSectionChars: number,
|
||||
sectionCharsRef: { value: number },
|
||||
): void {
|
||||
const normalized = normalizeWhitespace(line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = sectionCharsRef.value + normalized.length;
|
||||
if (nextChars > maxSectionChars) return;
|
||||
seen.add(normalized);
|
||||
target.push(normalized);
|
||||
sectionCharsRef.value = nextChars;
|
||||
}
|
||||
|
||||
function summarizeToolMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (!message.toolResults?.length) return [];
|
||||
return message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
// Same provenance problem as the raw-window path: once a tool result
|
||||
// lands in the compact section (older than the 6-item raw window),
|
||||
// its paired assistant tool_call is almost always gone. Without the
|
||||
// call label, multiple older results collapse into indistinguishable
|
||||
// "Tool result (callN): ..." lines and follow-ups like "use the
|
||||
// resolv.conf output" can't be resolved. Inline the name+args here
|
||||
// the same way toRawHistoryMessage does.
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
|
||||
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
|
||||
: undefined);
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(normalizeWhitespace(replayContent), MAX_TOOL_SUMMARY_CHARS)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (message.role === "system") return [];
|
||||
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
|
||||
|
||||
const lines: string[] = [];
|
||||
const content = getUserHistoryContent(message);
|
||||
if (content && isImportantText(content)) {
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
lines.push(`${label}: ${truncateText(normalizeWhitespace(content), MAX_TOOL_SUMMARY_CHARS)}`);
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
const args = JSON.stringify(toolCall.arguments ?? {});
|
||||
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
|
||||
if (isImportantText(summary)) lines.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function summarizeDurableUserMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "user") return null;
|
||||
const notes = message.attachments?.filter(isVaultNoteAttachment) ?? [];
|
||||
if (notes.length) {
|
||||
// Budget prose separately: the reference instruction must not consume the request's 280 characters.
|
||||
const nonNotes = message.attachments?.filter((attachment) => !isVaultNoteAttachment(attachment));
|
||||
const request = truncateText(normalizeWhitespace(buildHistoricalUserReplayContent(message.content || '', nonNotes)), MAX_DURABLE_USER_MESSAGE_CHARS);
|
||||
return `User request: ${request}\n${formatVaultNoteReferences(notes)}`;
|
||||
}
|
||||
const content = getUserHistoryContent(message);
|
||||
if (!content) return null;
|
||||
if (isTrivialUserMessage(content)) return null;
|
||||
return `User request: ${truncateText(normalizeWhitespace(content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "assistant" || !message.content) return null;
|
||||
if (!isSubstantiveAssistantMessage(message.content)) return null;
|
||||
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-tool-result provenance index. Keys are
|
||||
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
|
||||
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
|
||||
* cause later calls to overwrite older ones in the lookup — each
|
||||
* tool_result resolves to the most recent assistant tool_call that
|
||||
* preceded it with matching id, which preserves historical correctness
|
||||
* when rebuilding older compact summaries.
|
||||
*/
|
||||
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
|
||||
const provenance = new Map<string, ToolCallInfo>();
|
||||
// Rolling map of the latest tool_call seen (by id) up to the current
|
||||
// point in the message stream.
|
||||
const latestByCallId = new Map<string, ToolCallInfo>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
if (!toolCall.id) continue;
|
||||
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
for (const result of message.toolResults) {
|
||||
const info = latestByCallId.get(result.toolCallId);
|
||||
if (info) {
|
||||
provenance.set(`${message.id}:${result.toolCallId}`, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function lookupToolCallInfo(
|
||||
index: Map<string, ToolCallInfo>,
|
||||
toolMessageId: string,
|
||||
toolCallId: string,
|
||||
): ToolCallInfo | undefined {
|
||||
return index.get(`${toolMessageId}:${toolCallId}`);
|
||||
}
|
||||
|
||||
function toRawHistoryMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): RawHistoryMessage[] {
|
||||
if (message.role === "user") {
|
||||
const content = getUserHistoryContent(message);
|
||||
return content
|
||||
? [{ sourceId: message.id, role: "user", content: truncateText(content, MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
return parts.length
|
||||
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
// Keep recent tool results self-describing while replacing terminal
|
||||
// output with placeholders, so stale-session recovery doesn't replay
|
||||
// bulky command output on every follow-up. External agent replay only
|
||||
// supports user/assistant roles, so we flatten to "assistant" — the
|
||||
// tool results were produced during the assistant's turn.
|
||||
//
|
||||
// Inline the originating tool_call's name+args. Tool calls and their
|
||||
// results live in separate messages; if the last six raw items start
|
||||
// in the middle of a tool interaction, the preceding assistant tool
|
||||
// call can be outside the window. Without the call label the result
|
||||
// is opaque bytes and "use that output" becomes ambiguous.
|
||||
const parts = message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
|
||||
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
|
||||
: undefined);
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${replayContent}`;
|
||||
});
|
||||
return [{
|
||||
sourceId: message.id,
|
||||
role: "assistant",
|
||||
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildCompactContext(
|
||||
messages: ChatMessage[],
|
||||
durableScanStart: number,
|
||||
recentRawSourceIds: Set<string>,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): ExternalAgentHistoryMessage[] {
|
||||
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
|
||||
const summaryLines: string[] = [];
|
||||
const durableUserCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableUserLines: DurableUserLine[] = [];
|
||||
const durableAssistantCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableAssistantLines: DurableUserLine[] = [];
|
||||
const seen = new Set<string>();
|
||||
const durableChars = { value: 0 };
|
||||
const durableAssistantChars = { value: 0 };
|
||||
const summaryChars = { value: 0 };
|
||||
|
||||
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
const durableUserLine = summarizeDurableUserMessage(message);
|
||||
if (durableUserLine) {
|
||||
durableUserCandidates.push({
|
||||
line: durableUserLine,
|
||||
messageIndex,
|
||||
priority: getDurableUserPriority(getUserHistoryContent(message)),
|
||||
});
|
||||
}
|
||||
const durableAssistantLine = summarizeDurableAssistantMessage(message);
|
||||
if (durableAssistantLine) {
|
||||
durableAssistantCandidates.push({
|
||||
line: durableAssistantLine,
|
||||
messageIndex,
|
||||
priority: getDurableAssistantPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
durableUserCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableUserLines.push(candidate);
|
||||
durableChars.value = nextChars;
|
||||
});
|
||||
|
||||
durableAssistantCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableAssistantChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableAssistantLines.push(candidate);
|
||||
durableAssistantChars.value = nextChars;
|
||||
});
|
||||
|
||||
const durableUserLines = selectedDurableUserLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
const durableAssistantLines = selectedDurableAssistantLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
|
||||
for (const line of [...durableUserLines, ...durableAssistantLines]) {
|
||||
seen.add(normalizeWhitespace(line));
|
||||
}
|
||||
|
||||
// Skip messages that are already appended verbatim in the raw window —
|
||||
// otherwise the same last-6 turns get summarized here AND re-sent as
|
||||
// raw, doubling the budget cost of important user turns / large tool
|
||||
// output and crowding out older durable context the replay is meant
|
||||
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
|
||||
for (const message of scanned) {
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
for (const line of summarizeMessage(message, toolCallIndex)) {
|
||||
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
|
||||
}
|
||||
}
|
||||
|
||||
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
|
||||
|
||||
const contentLines = [
|
||||
"[Compact prior Netcatty UI context]",
|
||||
"The external SDK agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
|
||||
];
|
||||
if (durableUserLines.length) {
|
||||
contentLines.push("Earlier user requests that may still apply:");
|
||||
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (durableAssistantLines.length) {
|
||||
contentLines.push("Earlier assistant context that may still matter:");
|
||||
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (summaryLines.length) {
|
||||
contentLines.push("Recent noteworthy context:");
|
||||
contentLines.push(...summaryLines.map((line) => `- ${line}`));
|
||||
}
|
||||
|
||||
return [{
|
||||
role: "user",
|
||||
content: truncateText(
|
||||
contentLines.join("\n"),
|
||||
MAX_COMPACT_CONTEXT_CHARS,
|
||||
),
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first message to include in the scan window,
|
||||
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
|
||||
* Walking backwards stops at the target turn count, so the cost is
|
||||
* bounded even when the transcript is huge.
|
||||
*/
|
||||
function computeDurableScanStart(messages: ChatMessage[]): number {
|
||||
let userTurns = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === "user") {
|
||||
userTurns += 1;
|
||||
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildExternalBridgeContextMessages(messages: ChatMessage[]): ExternalBridgeHistoryMessage[] {
|
||||
// Compute the scan start once, then do all subsequent work over the
|
||||
// already-sliced tail. This avoids O(N) walks over the whole transcript
|
||||
// on every send — previously buildToolCallIndex + the flatMap-to-take-
|
||||
// last-6 raw history both traversed every message in the chat.
|
||||
const durableScanStart = computeDurableScanStart(messages);
|
||||
const scannedTail = messages.slice(durableScanStart);
|
||||
|
||||
// The tool-call provenance index only needs entries for tool_results
|
||||
// that might appear in our output. Building from the scanned tail is
|
||||
// correct for any tool_result whose paired assistant tool_call is
|
||||
// also within the window, which covers >99% of realistic patterns
|
||||
// (tool_calls and tool_results are always adjacent or near-adjacent).
|
||||
// If an ancient tool_call's result stays within the window while the
|
||||
// call itself is outside, that single result loses its [from X(Y)]
|
||||
// label — an acceptable trade for eliminating the per-send O(N) walk.
|
||||
const toolCallIndex = buildToolCallIndex(scannedTail);
|
||||
|
||||
const rawHistory = scannedTail
|
||||
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
|
||||
.slice(-MAX_RECENT_RAW_MESSAGES);
|
||||
const compactContext = buildCompactContext(
|
||||
messages,
|
||||
durableScanStart,
|
||||
new Set(rawHistory.map((message) => message.sourceId)),
|
||||
toolCallIndex,
|
||||
);
|
||||
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
|
||||
|
||||
return [...compactContext, ...recentRaw];
|
||||
}
|
||||
2368
infrastructure/ai/harness/generated/cattyToolSpecs.json
Normal file
2368
infrastructure/ai/harness/generated/cattyToolSpecs.json
Normal file
File diff suppressed because it is too large
Load Diff
2253
infrastructure/ai/harness/generated/globalAgentToolSpecs.json
Normal file
2253
infrastructure/ai/harness/generated/globalAgentToolSpecs.json
Normal file
File diff suppressed because it is too large
Load Diff
11
infrastructure/ai/harness/globalAgentRuntime.ts
Normal file
11
infrastructure/ai/harness/globalAgentRuntime.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AgentRuntime } from './agentRuntime';
|
||||
import { cattyTurnDriver } from './turnDrivers/cattyTurnDriver';
|
||||
import { externalSdkTurnDriver } from './turnDrivers/externalSdkTurnDriver';
|
||||
|
||||
export const globalAgentRuntime = new AgentRuntime({
|
||||
drivers: [cattyTurnDriver, externalSdkTurnDriver],
|
||||
});
|
||||
|
||||
export function getAgentRuntime(): AgentRuntime {
|
||||
return globalAgentRuntime;
|
||||
}
|
||||
147
infrastructure/ai/harness/index.ts
Normal file
147
infrastructure/ai/harness/index.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
export type {
|
||||
AgentBackend,
|
||||
AgentEvent,
|
||||
AgentEventListener,
|
||||
AgentEventType,
|
||||
ApprovalOutcome,
|
||||
CompactionEvent,
|
||||
CompactionTrace,
|
||||
ContextPrepareResult,
|
||||
ContextPrepareTrigger,
|
||||
ExternalBridgeHistoryMessage,
|
||||
TokenEstimatorKind,
|
||||
UsageEvent,
|
||||
PerformanceEvent,
|
||||
ModelCallStartEvent,
|
||||
StepEndEvent,
|
||||
} from './types';
|
||||
export type {
|
||||
TurnSteerFailureReason,
|
||||
TurnSteerInput,
|
||||
TurnSteerResult,
|
||||
} from './turnDrivers/types';
|
||||
|
||||
export { TraceStore, globalTraceStore } from './traceStore';
|
||||
export type { TraceExport } from './traceStore';
|
||||
|
||||
export { stopAgentTurn, clearChatSessionCancelled } from './agentStop';
|
||||
export type { AgentStopBridge, StopAgentTurnParams, StopAgentTurnReason } from './agentStop';
|
||||
|
||||
export { AgentRuntime } from './agentRuntime';
|
||||
export { globalAgentRuntime, getAgentRuntime } from './globalAgentRuntime';
|
||||
|
||||
export {
|
||||
estimateModelMessagesTokens,
|
||||
estimateModelMessagesTokensWithKind,
|
||||
estimateTextTokens,
|
||||
estimateUnknownTokens,
|
||||
} from './tokenEstimator';
|
||||
export type { EstimateModelMessagesTokensInput, EstimateModelMessagesTokensResult } from './tokenEstimator';
|
||||
|
||||
export { ToolOutputStore, globalToolOutputStore } from './toolOutputStore';
|
||||
export type {
|
||||
ToolOutputHandle,
|
||||
StoreToolOutputInput,
|
||||
ReadToolOutputInput,
|
||||
ToolOutputReadResult,
|
||||
ToolOutputPersistence,
|
||||
ToolOutputStoreOptions,
|
||||
} from './toolOutputStore';
|
||||
|
||||
export { ToolResultDedup, hashScopeKey, previewToolResult } from './toolResultDedup';
|
||||
export type { ToolResultDedupEntry } from './toolResultDedup';
|
||||
|
||||
export { cattyTurnDriver } from './turnDrivers/cattyTurnDriver';
|
||||
export { externalSdkTurnDriver } from './turnDrivers/externalSdkTurnDriver';
|
||||
export type {
|
||||
TurnInput,
|
||||
TurnResult,
|
||||
TurnDriver,
|
||||
TurnDriverContext,
|
||||
TurnUiCallbacks,
|
||||
CattyTurnInput,
|
||||
ExternalTurnInput,
|
||||
} from './turnDrivers/types';
|
||||
|
||||
export {
|
||||
prepareTurnContext,
|
||||
prepareStepContext,
|
||||
buildExternalBridgeContext,
|
||||
extractLatestUserGoal,
|
||||
} from './contextManager';
|
||||
export type { PrepareTurnContextInput, PostCompactReinjection } from './contextManager';
|
||||
|
||||
export {
|
||||
computeCompactionThreshold,
|
||||
computeTotalInputTokens,
|
||||
shouldCompactByBudget,
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
} from './contextBudget';
|
||||
|
||||
export { SessionStateStore, globalSessionStateStore } from './sessionState';
|
||||
export type { CattySessionState } from './sessionState';
|
||||
|
||||
export { repairToolMessageIntegrity } from './toolMessageIntegrity';
|
||||
export { redactSecretsForModel } from './modelSecretRedaction';
|
||||
export { buildPromptContextSnapshot } from './promptContextSnapshot';
|
||||
export type { PromptContextSnapshot } from './promptContextSnapshot';
|
||||
|
||||
export { pruneStaleToolContext } from './staleContextPruner';
|
||||
export { pruneUntilFitsCompaction } from './compactionPruner';
|
||||
|
||||
export { CATTY_COMPACTION_STATUS_KEYS } from './compactionStatusKeys';
|
||||
|
||||
export { buildExternalBridgeContextMessages } from './externalBridgeContext';
|
||||
|
||||
export {
|
||||
fitTerminalExecuteResultForModel,
|
||||
MAX_LIVE_TERMINAL_STDOUT_CHARS,
|
||||
MAX_LIVE_TERMINAL_STDERR_CHARS,
|
||||
} from './terminalCompression';
|
||||
export type { TerminalExecuteResult, TerminalOutputHandle } from './terminalCompression';
|
||||
|
||||
export {
|
||||
encodeSdkSessionIdentity,
|
||||
parseSdkSessionIdentity,
|
||||
SDK_SESSION_ID_PREFIX,
|
||||
} from './sdkSessionIdentity';
|
||||
export type { SdkSessionIdentityPayload } from './sdkSessionIdentity';
|
||||
|
||||
export {
|
||||
mapCattyStreamChunkToAgentEvents,
|
||||
mapSdkStreamEventToAgentEvents,
|
||||
createHarnessEventSink,
|
||||
} from './agentEventAdapter';
|
||||
export type { StreamEventContext } from './agentEventAdapter';
|
||||
|
||||
export {
|
||||
compactCattyMessages,
|
||||
prepareCattyMessagesForStream,
|
||||
} from './cattyRuntime';
|
||||
export type { CompactCattyMessagesInput, CompactCattyMessagesResult } from './cattyRuntime';
|
||||
|
||||
export { createCattyToolsFromCatalog } from './capabilityTools';
|
||||
export type { CattyToolsBundle } from './capabilityTools';
|
||||
|
||||
export {
|
||||
createInitialCattyRuntimeContext,
|
||||
cattyRuntimeContextSchema,
|
||||
cattyToolContextSchema,
|
||||
} from './cattyRuntimeContext';
|
||||
export type { CattyRuntimeContext, CattyToolContext } from './cattyRuntimeContext';
|
||||
|
||||
export { buildCattyStreamTimeouts, buildCattyCompactionTimeout } from './streamTimeouts';
|
||||
export { buildCattyToolApproval } from './cattyToolApproval';
|
||||
|
||||
export {
|
||||
PermissionGrantStore,
|
||||
buildGrantFromApproval,
|
||||
createPermissionGrantId,
|
||||
getActivePermissionGrants,
|
||||
matchPermissionGrant,
|
||||
patternMatches,
|
||||
resolveCapabilityId,
|
||||
sanitizePermissionGrants,
|
||||
setActivePermissionGrants,
|
||||
} from './permissionGrants';
|
||||
export type { PermissionGrantMatchContext, PermissionGrantRule } from './permissionGrants';
|
||||
92
infrastructure/ai/harness/largeUserInput.test.ts
Normal file
92
infrastructure/ai/harness/largeUserInput.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
import { fitLargeUserInputForModel } from './largeUserInput';
|
||||
import {
|
||||
buildCattySdkMessages,
|
||||
createContinuationContext,
|
||||
} from './turnDrivers/cattyMessageBuilder';
|
||||
|
||||
test('fitLargeUserInputForModel keeps both ends and stores the full prompt', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const input = `START-${'middle '.repeat(8_000)}-FINAL QUESTION`;
|
||||
const fitted = fitLargeUserInputForModel(input, 'chat-1', store);
|
||||
|
||||
assert.match(fitted, /^START-/);
|
||||
assert.match(fitted, /FINAL QUESTION/);
|
||||
assert.match(fitted, /handleId=tool-output-/);
|
||||
assert.ok(fitted.length < input.length);
|
||||
const handle = store.listPendingHandles('chat-1')[0];
|
||||
assert.equal(handle.fullContent, input);
|
||||
});
|
||||
|
||||
test('fitLargeUserInputForModel reuses one stable handle across history replays', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const input = `START-${'history '.repeat(8_000)}-FINAL QUESTION`;
|
||||
|
||||
const firstReplay = fitLargeUserInputForModel(input, 'chat-1', store);
|
||||
const secondReplay = fitLargeUserInputForModel(input, 'chat-1', store);
|
||||
|
||||
assert.equal(secondReplay, firstReplay);
|
||||
assert.equal(store.listPendingHandles('chat-1').length, 1);
|
||||
});
|
||||
|
||||
test('a large user message remains bounded with the same handle on the next turn', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const input = `START-${'history '.repeat(8_000)}-FINAL QUESTION`;
|
||||
const firstTurnContent = fitLargeUserInputForModel(input, 'chat-1', store);
|
||||
const firstHandleId = firstTurnContent.match(/handleId=(tool-output-[A-Za-z0-9-]+)/)?.[1];
|
||||
assert.ok(firstHandleId);
|
||||
|
||||
const buildHistory = () => buildCattySdkMessages({
|
||||
allMessages: [{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: 1,
|
||||
}],
|
||||
includeCurrentUserMessage: true,
|
||||
trimmed: 'continue',
|
||||
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1'),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: store,
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
|
||||
const secondTurn = buildHistory();
|
||||
const retry = buildHistory();
|
||||
const replayContent = secondTurn[0]?.content;
|
||||
const retryContent = retry[0]?.content;
|
||||
assert.ok(typeof replayContent === 'string');
|
||||
assert.ok(typeof retryContent === 'string');
|
||||
assert.equal(replayContent, retryContent);
|
||||
assert.match(replayContent, new RegExp(`handleId=${firstHandleId}`));
|
||||
assert.ok(replayContent.length < input.length);
|
||||
assert.equal(store.listPendingHandles('chat-1').length, 1);
|
||||
});
|
||||
|
||||
test('a persisted compaction boundary replaces older history with its summary', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const messages = buildCattySdkMessages({
|
||||
allMessages: [
|
||||
{ id: 'old-1', role: 'user', content: 'old secret question', timestamp: 1 },
|
||||
{ id: 'old-2', role: 'assistant', content: 'old answer', timestamp: 2 },
|
||||
{ id: 'recent-1', role: 'user', content: 'recent question', timestamp: 3 },
|
||||
],
|
||||
contextCompaction: {
|
||||
summary: 'The earlier question was answered.',
|
||||
compactedMessageCount: 2,
|
||||
},
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1'),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: store,
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
|
||||
assert.equal(messages.length, 3);
|
||||
assert.match(String(messages[0]?.content), /The earlier question was answered/);
|
||||
assert.doesNotMatch(JSON.stringify(messages), /old secret question|old answer/);
|
||||
assert.match(JSON.stringify(messages), /recent question/);
|
||||
});
|
||||
50
infrastructure/ai/harness/largeUserInput.ts
Normal file
50
infrastructure/ai/harness/largeUserInput.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ToolOutputStore } from './toolOutputStore';
|
||||
|
||||
const LARGE_USER_INPUT_THRESHOLD_CHARS = 25_000;
|
||||
const LARGE_USER_INPUT_HEAD_CHARS = 12_000;
|
||||
const LARGE_USER_INPUT_TAIL_CHARS = 4_000;
|
||||
|
||||
const handlesByStore = new WeakMap<ToolOutputStore, Map<string, string>>();
|
||||
|
||||
function hashInput(input: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function getStableHandleId(
|
||||
input: string,
|
||||
chatSessionId: string,
|
||||
toolOutputStore: ToolOutputStore,
|
||||
): string {
|
||||
const handles = handlesByStore.get(toolOutputStore) ?? new Map<string, string>();
|
||||
handlesByStore.set(toolOutputStore, handles);
|
||||
const key = `${chatSessionId}:${input.length}:${hashInput(input)}`;
|
||||
const existingId = handles.get(key);
|
||||
if (existingId && toolOutputStore.get(existingId, chatSessionId)) return existingId;
|
||||
|
||||
const handleId = toolOutputStore.store({
|
||||
chatSessionId,
|
||||
capabilityId: 'user.input',
|
||||
content: input,
|
||||
}).id;
|
||||
handles.set(key, handleId);
|
||||
return handleId;
|
||||
}
|
||||
|
||||
export function fitLargeUserInputForModel(
|
||||
input: string,
|
||||
chatSessionId: string,
|
||||
toolOutputStore: ToolOutputStore,
|
||||
): string {
|
||||
if (input.length <= LARGE_USER_INPUT_THRESHOLD_CHARS) return input;
|
||||
const handleId = getStableHandleId(input, chatSessionId, toolOutputStore);
|
||||
return [
|
||||
input.slice(0, LARGE_USER_INPUT_HEAD_CHARS),
|
||||
`\n\n[... large user input moved to saved output: ${input.length} chars, handleId=${handleId}. Use tool_output_read with range or search for omitted details ...]\n\n`,
|
||||
input.slice(-LARGE_USER_INPUT_TAIL_CHARS),
|
||||
].join('');
|
||||
}
|
||||
87
infrastructure/ai/harness/modelSecretRedaction.test.ts
Normal file
87
infrastructure/ai/harness/modelSecretRedaction.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { fitTerminalExecuteResultForModel } from './terminalCompression';
|
||||
import { fitLargeToolResultForModel } from './toolResultFitting';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
import { redactSecretsForModel, redactSecretsInValueForModel } from './modelSecretRedaction';
|
||||
|
||||
test('redactSecretsForModel removes common terminal secrets', () => {
|
||||
const input = [
|
||||
'API_TOKEN=tok_live_1234567890',
|
||||
'Authorization: Bearer abc.def.very-secret',
|
||||
'postgres://admin:p4ssw0rd@db.internal/app',
|
||||
'-----BEGIN PRIVATE KEY-----',
|
||||
'super-secret-private-key-body',
|
||||
'-----END PRIVATE KEY-----',
|
||||
].join('\n');
|
||||
const output = redactSecretsForModel(input);
|
||||
|
||||
assert.doesNotMatch(output, /tok_live|abc\.def|p4ssw0rd|private-key-body/);
|
||||
assert.match(output, /\[REDACTED\]/);
|
||||
});
|
||||
|
||||
test('redactSecretsInValueForModel recursively redacts tool arguments', () => {
|
||||
const value = redactSecretsInValueForModel({
|
||||
command: 'curl --password swordfish',
|
||||
nested: [
|
||||
'Authorization: Bearer secret_token_123456',
|
||||
{
|
||||
password: 'short',
|
||||
telnetPassword: 'telnet-secret',
|
||||
passphrase: 'key-passphrase',
|
||||
privateKey: 'not-a-pem-but-still-private',
|
||||
apiKey: 'tiny-api-key',
|
||||
clientSecret: 'client-secret',
|
||||
authToken: 'auth-token',
|
||||
username: 'operator',
|
||||
},
|
||||
],
|
||||
});
|
||||
const serialized = JSON.stringify(value);
|
||||
assert.doesNotMatch(serialized, /swordfish|secret_token|short|telnet-secret|key-passphrase|not-a-pem|tiny-api-key|client-secret|auth-token/);
|
||||
assert.match(serialized, /operator/);
|
||||
});
|
||||
|
||||
test('redactSecretsForModel redacts JSON and quoted shell assignments', () => {
|
||||
const input = [
|
||||
'{"password":"short value","apiKey":"tiny","username":"operator"}',
|
||||
"passphrase='two words'",
|
||||
'client_secret = "quoted secret"',
|
||||
'PRIVATE_KEY=one-line-key',
|
||||
].join('\n');
|
||||
|
||||
const output = redactSecretsForModel(input);
|
||||
assert.doesNotMatch(output, /short value|tiny|two words|quoted secret|one-line-key/);
|
||||
assert.match(output, /"username":"operator"/);
|
||||
assert.equal((output.match(/\[REDACTED\]/g) ?? []).length, 5);
|
||||
});
|
||||
|
||||
test('terminal fitting keeps raw output in the local handle but redacts model-visible text', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const secret = 'API_TOKEN=tok_live_1234567890';
|
||||
const fitted = fitTerminalExecuteResultForModel({
|
||||
stdout: `${secret}\n${'build line\n'.repeat(10_000)}`,
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
command: `deploy --token tok_live_1234567890`,
|
||||
sessionId: 'session-1',
|
||||
}, {
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: store,
|
||||
});
|
||||
|
||||
assert.doesNotMatch(fitted.stdout, /tok_live/);
|
||||
assert.doesNotMatch(fitted.command ?? '', /tok_live/);
|
||||
assert.match(fitted.stdout, /restartPersistence=unavailable \(read before closing the app\)/);
|
||||
const handle = store.listPendingHandles('chat-1')[0];
|
||||
assert.match(handle.fullContent, /tok_live/);
|
||||
});
|
||||
|
||||
test('generic tool fitting redacts short strings even when truncation is unnecessary', () => {
|
||||
const fitted = fitLargeToolResultForModel({
|
||||
result: { message: 'password=hunter2' },
|
||||
capabilityId: 'example.read',
|
||||
}) as { message: string };
|
||||
|
||||
assert.equal(fitted.message, 'password=[REDACTED]');
|
||||
});
|
||||
43
infrastructure/ai/harness/modelSecretRedaction.ts
Normal file
43
infrastructure/ai/harness/modelSecretRedaction.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
const REDACTED = '[REDACTED]';
|
||||
|
||||
const PRIVATE_KEY_PATTERN = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/g;
|
||||
const BEARER_PATTERN = /\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
|
||||
const URL_CREDENTIAL_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@]+)(@)/gi;
|
||||
const QUOTED_SECRET_ASSIGNMENT_PATTERN = /(["'])([A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|token|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|credential)[A-Za-z0-9_.-]*)\1\s*([:=])\s*(["'])((?:\\.|(?!\4)[\s\S])*?)\4/gi;
|
||||
const QUOTED_VALUE_SECRET_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|token|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|credential)[A-Za-z0-9_.-]*)\s*([:=])\s*(["'])((?:\\.|(?!\3)[\s\S])*?)\3/gi;
|
||||
const SECRET_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|token|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|credential)[A-Za-z0-9_.-]*)\s*([:=])\s*(["']?)([^\s"',;]+)\3/gi;
|
||||
const SECRET_CLI_FLAG_PATTERN = /(--(?:password|passwd|passphrase|token|secret|api-key|access-key|secret-key|private-key))\s+(?:["']([^"']+)["']|([^\s]+))/gi;
|
||||
const WELL_KNOWN_TOKEN_PATTERN = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g;
|
||||
|
||||
function isSecretKey(key: string): boolean {
|
||||
const normalized = key.replace(/[^A-Za-z0-9]/g, '').toLocaleLowerCase();
|
||||
return /(?:password|passwd|passphrase|pwd|token|secret|apikey|accesskey|secretkey|privatekey|credential|credentials)$/.test(normalized);
|
||||
}
|
||||
|
||||
/** Redacts likely credentials only on data that is about to become model-visible. */
|
||||
export function redactSecretsForModel(value: string): string {
|
||||
if (!value) return value;
|
||||
return value
|
||||
.replace(PRIVATE_KEY_PATTERN, REDACTED)
|
||||
.replace(BEARER_PATTERN, '$1 [REDACTED]')
|
||||
.replace(URL_CREDENTIAL_PATTERN, `$1${REDACTED}$3`)
|
||||
.replace(QUOTED_SECRET_ASSIGNMENT_PATTERN, '$1$2$1$3$4[REDACTED]$4')
|
||||
.replace(QUOTED_VALUE_SECRET_ASSIGNMENT_PATTERN, '$1$2$3[REDACTED]$3')
|
||||
.replace(SECRET_ASSIGNMENT_PATTERN, '$1$2[REDACTED]')
|
||||
.replace(SECRET_CLI_FLAG_PATTERN, '$1 [REDACTED]')
|
||||
.replace(WELL_KNOWN_TOKEN_PATTERN, REDACTED);
|
||||
}
|
||||
|
||||
/** Recursively redacts model-visible event arguments without mutating the source value. */
|
||||
export function redactSecretsInValueForModel<T>(value: T): T {
|
||||
if (typeof value === 'string') return redactSecretsForModel(value) as T;
|
||||
if (Array.isArray(value)) return value.map(redactSecretsInValueForModel) as T;
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, entry]) => [
|
||||
key,
|
||||
isSecretKey(key) ? REDACTED : redactSecretsInValueForModel(entry),
|
||||
]),
|
||||
) as T;
|
||||
}
|
||||
418
infrastructure/ai/harness/permissionGrants.test.ts
Normal file
418
infrastructure/ai/harness/permissionGrants.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildGrantFromApproval,
|
||||
buildGrantsFromApproval,
|
||||
listGrantableCapabilityIds,
|
||||
matchPermissionGrant,
|
||||
patternMatches,
|
||||
type PermissionGrantRule,
|
||||
} from './permissionGrants';
|
||||
|
||||
const baseRule = (overrides: Partial<PermissionGrantRule>): PermissionGrantRule => ({
|
||||
id: 'grant-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionPattern: 'session-a',
|
||||
createdAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('permissionGrants', () => {
|
||||
it('matches wildcard session and command patterns', () => {
|
||||
const rules = [baseRule({ sessionPattern: '*', commandPattern: 'ls *' })];
|
||||
const matched = matchPermissionGrant(rules, {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'any-session',
|
||||
args: { command: 'ls -la /tmp' },
|
||||
});
|
||||
assert.ok(matched);
|
||||
});
|
||||
|
||||
it('ignores sessionPattern and matches globally by capability and command', () => {
|
||||
const rules = [baseRule({ sessionPattern: 'old-session-uuid', commandPattern: 'ls *' })];
|
||||
const matched = matchPermissionGrant(rules, {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'different-session',
|
||||
args: { command: 'ls -la /tmp' },
|
||||
});
|
||||
assert.ok(matched);
|
||||
});
|
||||
|
||||
it('does not match a different capability', () => {
|
||||
const rules = [baseRule({ sessionPattern: '*' })];
|
||||
const matched = matchPermissionGrant(rules, {
|
||||
capabilityId: 'terminal.start',
|
||||
sessionId: 'session-a',
|
||||
args: { command: 'make' },
|
||||
});
|
||||
assert.equal(matched, null);
|
||||
});
|
||||
|
||||
it('buildGrantFromApproval uses global scope and OpenCode-style command prefix patterns', () => {
|
||||
const grant = buildGrantFromApproval('terminal.execute', {
|
||||
sessionId: 'ssh-1',
|
||||
command: 'systemctl status nginx',
|
||||
}, 'chat-1');
|
||||
assert.ok(grant);
|
||||
assert.equal(grant.sessionPattern, '*');
|
||||
assert.equal(grant.commandPattern, 'systemctl status *');
|
||||
});
|
||||
|
||||
it('buildGrantFromApproval returns null when no command grant is produced', () => {
|
||||
const grant = buildGrantFromApproval('terminal.execute', {
|
||||
sessionId: 'ssh-1',
|
||||
command: 'cd /tmp',
|
||||
}, 'chat-1');
|
||||
|
||||
assert.equal(grant, null);
|
||||
});
|
||||
|
||||
it('buildGrantsFromApproval emits one rule per chained command segment', () => {
|
||||
const grants = buildGrantsFromApproval('terminal.execute', {
|
||||
sessionId: 'ssh-1',
|
||||
command: 'cd /tmp && lscpu',
|
||||
}, 'chat-1');
|
||||
assert.equal(grants.length, 1);
|
||||
assert.equal(grants[0]?.commandPattern, 'lscpu *');
|
||||
});
|
||||
|
||||
it('does not let a comment grant approve a multiline command', () => {
|
||||
const rules = [baseRule({ sessionPattern: '*', commandPattern: '# *' })];
|
||||
const matched = matchPermissionGrant(rules, {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: {
|
||||
command: [
|
||||
'# 1a) clear the kernel_options_post profile field',
|
||||
'cobbler profile edit --name=openEuler-22.03-aarch64 --kernel-options-post=""',
|
||||
].join('\n'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(matched, null);
|
||||
});
|
||||
|
||||
it('requires every grantable command segment to be covered', () => {
|
||||
const rules = [
|
||||
baseRule({ id: 'grant-lscpu', sessionPattern: '*', commandPattern: 'lscpu *' }),
|
||||
baseRule({ id: 'grant-grep', sessionPattern: '*', commandPattern: 'grep *' }),
|
||||
];
|
||||
const matched = matchPermissionGrant(rules, {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command: 'cd /tmp && lscpu | grep CPU' },
|
||||
});
|
||||
|
||||
assert.ok(matched);
|
||||
|
||||
const missingPipeSegment = matchPermissionGrant([rules[0]!], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command: 'cd /tmp && lscpu | grep CPU' },
|
||||
});
|
||||
|
||||
assert.equal(missingPipeSegment, null);
|
||||
});
|
||||
|
||||
it('requires every background command segment to be covered', () => {
|
||||
const command = 'cd /tmp; sleep 1 & rm -rf demo';
|
||||
|
||||
const matchedBySleepOnly = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-sleep', sessionPattern: '*', commandPattern: 'sleep *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedBySleepOnly, null);
|
||||
|
||||
const matchedByBoth = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-sleep', sessionPattern: '*', commandPattern: 'sleep *' }),
|
||||
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByBoth);
|
||||
});
|
||||
|
||||
it('requires cwd segments with shell substitutions to be covered', () => {
|
||||
const command = 'cd "$(pwd)"; ls -la';
|
||||
|
||||
const matchedByLsOnly = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByLsOnly, null);
|
||||
|
||||
const matchedByBoth = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cd', sessionPattern: '*', commandPattern: 'cd *' }),
|
||||
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByBoth);
|
||||
});
|
||||
|
||||
it('does not let a here-doc body grant approve the wrapping command', () => {
|
||||
const command = [
|
||||
"cat <<'EOF'",
|
||||
'rm -rf /tmp/demo',
|
||||
'EOF',
|
||||
].join('\n');
|
||||
|
||||
const matchedByBody = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByBody, null);
|
||||
|
||||
const matchedByWrapper = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByWrapper);
|
||||
});
|
||||
|
||||
it('does not let a piped here-doc body grant approve the command', () => {
|
||||
const command = [
|
||||
'cat <<EOF | grep needle',
|
||||
'rm -rf /tmp/demo',
|
||||
'EOF',
|
||||
].join('\n');
|
||||
|
||||
const matchedByBody = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByBody, null);
|
||||
|
||||
const matchedByPipeline = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
baseRule({ id: 'grant-grep', sessionPattern: '*', commandPattern: 'grep *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByPipeline);
|
||||
});
|
||||
|
||||
it('does not let an fd-prefixed here-doc body grant approve the command', () => {
|
||||
const command = [
|
||||
'cat 0<<EOF',
|
||||
'rm -rf /tmp/demo',
|
||||
'EOF',
|
||||
].join('\n');
|
||||
|
||||
const matchedByBody = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByBody, null);
|
||||
|
||||
const matchedByWrapper = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByWrapper);
|
||||
});
|
||||
|
||||
it('does not let quoted here-doc operator text hide later commands', () => {
|
||||
const command = [
|
||||
"cd /tmp; echo '<<EOF'",
|
||||
'rm -rf demo',
|
||||
'EOF',
|
||||
].join('\n');
|
||||
|
||||
const matchedByEchoOnly = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-echo', sessionPattern: '*', commandPattern: 'echo *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByEchoOnly, null);
|
||||
|
||||
const matchedByAll = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-echo', sessionPattern: '*', commandPattern: 'echo *' }),
|
||||
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
|
||||
baseRule({ id: 'grant-eof', sessionPattern: '*', commandPattern: 'EOF *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByAll);
|
||||
});
|
||||
|
||||
it('keeps commands after mixed-quoted here-doc delimiters grantable', () => {
|
||||
const command = [
|
||||
'cat <<E"OF"',
|
||||
'body text',
|
||||
'EOF',
|
||||
'ls -la',
|
||||
].join('\n');
|
||||
|
||||
const matchedByCatOnly = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByCatOnly, null);
|
||||
|
||||
const matchedByBoth = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByBoth);
|
||||
});
|
||||
|
||||
it('keeps commands after dollar-quoted here-doc delimiters grantable', () => {
|
||||
const command = [
|
||||
"cat <<$'EOF'",
|
||||
'body text',
|
||||
'EOF',
|
||||
'rm -rf demo',
|
||||
].join('\n');
|
||||
|
||||
const matchedByCatOnly = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByCatOnly, null);
|
||||
|
||||
const matchedByBoth = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByBoth);
|
||||
});
|
||||
|
||||
it('keeps commands after ANSI-C quoted here-doc delimiters grantable', () => {
|
||||
const command = [
|
||||
"cat <<$'E\\x4fF'",
|
||||
'body text',
|
||||
'EOF',
|
||||
'rm -rf demo',
|
||||
].join('\n');
|
||||
|
||||
const matchedByCatOnly = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByCatOnly, null);
|
||||
|
||||
const matchedByBoth = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
|
||||
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByBoth);
|
||||
});
|
||||
|
||||
it('does not let arithmetic shifts hide following commands', () => {
|
||||
const command = [
|
||||
'ls $((1 << 2))',
|
||||
'rm -rf demo',
|
||||
].join('\n');
|
||||
|
||||
const matchedByLsOnly = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.equal(matchedByLsOnly, null);
|
||||
|
||||
const matchedByBoth = matchPermissionGrant([
|
||||
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
|
||||
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
|
||||
], {
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'session-a',
|
||||
args: { command },
|
||||
});
|
||||
|
||||
assert.ok(matchedByBoth);
|
||||
});
|
||||
|
||||
it('OpenCode wildcard allows optional args after prefix', () => {
|
||||
assert.equal(patternMatches('lscpu *', 'lscpu'), true);
|
||||
assert.equal(patternMatches('lscpu *', 'lscpu -e'), true);
|
||||
assert.equal(patternMatches('git checkout *', 'git checkout main'), true);
|
||||
assert.equal(patternMatches('git checkout *', 'git commit'), false);
|
||||
});
|
||||
|
||||
it('lists grantable capability ids from catalog policy', () => {
|
||||
const ids = listGrantableCapabilityIds();
|
||||
assert.ok(ids.includes('terminal.execute'));
|
||||
assert.ok(ids.includes('sftp.write'));
|
||||
assert.ok(!ids.includes('terminal.poll'));
|
||||
});
|
||||
|
||||
it('patternMatches supports regex literals', () => {
|
||||
assert.equal(patternMatches('/^ls\\b/', 'ls -la'), true);
|
||||
assert.equal(patternMatches('/^ls\\b/', 'cat file'), false);
|
||||
});
|
||||
});
|
||||
316
infrastructure/ai/harness/permissionGrants.ts
Normal file
316
infrastructure/ai/harness/permissionGrants.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import cattyToolSpecs from './generated/cattyToolSpecs.json';
|
||||
import {
|
||||
buildAlwaysAllowCommandPatterns,
|
||||
extractGrantableShellCommandSegments,
|
||||
} from '../shared/shellCommandGrant';
|
||||
|
||||
export interface PermissionGrantRule {
|
||||
id: string;
|
||||
capabilityId: string;
|
||||
sessionPattern: string;
|
||||
commandPattern?: string;
|
||||
argsPattern?: Record<string, string>;
|
||||
createdAt: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PermissionGrantMatchContext {
|
||||
capabilityId: string;
|
||||
sessionId?: string;
|
||||
chatSessionId?: string;
|
||||
hostname?: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type CattyToolSpecRef = {
|
||||
capabilityId: string;
|
||||
toolName: string;
|
||||
rpcMethod: string | null;
|
||||
policy?: {
|
||||
write?: boolean;
|
||||
bypassesApproval?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const TOOL_NAME_TO_CAPABILITY = new Map<string, string>();
|
||||
const RPC_METHOD_TO_CAPABILITY = new Map<string, string>();
|
||||
|
||||
for (const spec of cattyToolSpecs as CattyToolSpecRef[]) {
|
||||
TOOL_NAME_TO_CAPABILITY.set(spec.toolName, spec.capabilityId);
|
||||
if (spec.rpcMethod) {
|
||||
RPC_METHOD_TO_CAPABILITY.set(spec.rpcMethod, spec.capabilityId);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCapabilityId(toolOrRpcName: string): string {
|
||||
return TOOL_NAME_TO_CAPABILITY.get(toolOrRpcName)
|
||||
?? RPC_METHOD_TO_CAPABILITY.get(toolOrRpcName)
|
||||
?? toolOrRpcName;
|
||||
}
|
||||
|
||||
export function patternMatches(pattern: string, value: string): boolean {
|
||||
if (!pattern) return false;
|
||||
if (pattern === '*') return true;
|
||||
|
||||
if (pattern.startsWith('host:')) {
|
||||
return globOrRegexMatch(pattern.slice('host:'.length), value);
|
||||
}
|
||||
|
||||
return globOrRegexMatch(pattern, value);
|
||||
}
|
||||
|
||||
function globOrRegexMatch(pattern: string, value: string): boolean {
|
||||
if (pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) {
|
||||
const lastSlash = pattern.lastIndexOf('/');
|
||||
const body = pattern.slice(1, lastSlash);
|
||||
const flags = pattern.slice(lastSlash + 1);
|
||||
try {
|
||||
return new RegExp(body, flags).test(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pattern.includes('*') && !pattern.includes('?')) {
|
||||
return value === pattern;
|
||||
}
|
||||
|
||||
// OpenCode Wildcard.match semantics (trailing " *" allows optional args).
|
||||
let escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
if (escaped.endsWith(' .*')) {
|
||||
escaped = `${escaped.slice(0, -3)}( .*)?`;
|
||||
}
|
||||
|
||||
return new RegExp(`^${escaped}$`, 's').test(value);
|
||||
}
|
||||
|
||||
function argsPatternMatches(
|
||||
argsPattern: Record<string, string> | undefined,
|
||||
args: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (!argsPattern) return true;
|
||||
if (!args) return false;
|
||||
|
||||
for (const [key, pattern] of Object.entries(argsPattern)) {
|
||||
const argValue = args[key];
|
||||
if (typeof argValue === 'undefined') return false;
|
||||
if (!patternMatches(pattern, String(argValue))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function matchPermissionGrant(
|
||||
rules: readonly PermissionGrantRule[],
|
||||
ctx: PermissionGrantMatchContext,
|
||||
): PermissionGrantRule | null {
|
||||
if (rules.length === 0) return null;
|
||||
|
||||
const args = ctx.args ?? {};
|
||||
const command = typeof args.command === 'string' ? args.command : '';
|
||||
const commandGrantMatch = command ? matchCommandPatternGrants(rules, ctx, command, args) : null;
|
||||
if (commandGrantMatch) return commandGrantMatch;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.capabilityId !== ctx.capabilityId) continue;
|
||||
if (rule.commandPattern) continue;
|
||||
|
||||
if (!argsPatternMatches(rule.argsPattern, args)) continue;
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchCommandPatternGrants(
|
||||
rules: readonly PermissionGrantRule[],
|
||||
ctx: PermissionGrantMatchContext,
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
): PermissionGrantRule | null {
|
||||
const commandSegments = extractGrantableShellCommandSegments(command);
|
||||
if (commandSegments.length === 0) return null;
|
||||
|
||||
const eligibleRules = rules.filter((rule) => (
|
||||
rule.capabilityId === ctx.capabilityId
|
||||
&& Boolean(rule.commandPattern)
|
||||
&& argsPatternMatches(rule.argsPattern, args)
|
||||
));
|
||||
if (eligibleRules.length === 0) return null;
|
||||
|
||||
let firstMatch: PermissionGrantRule | null = null;
|
||||
for (const segment of commandSegments) {
|
||||
const matched = eligibleRules.find((rule) => patternMatches(rule.commandPattern!, segment));
|
||||
if (!matched) return null;
|
||||
firstMatch ??= matched;
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
}
|
||||
|
||||
export function sanitizePermissionGrants(raw: unknown): PermissionGrantRule[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const result: PermissionGrantRule[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const capabilityId = typeof record.capabilityId === 'string' ? record.capabilityId.trim() : '';
|
||||
if (!capabilityId) continue;
|
||||
|
||||
const rule: PermissionGrantRule = {
|
||||
id: typeof record.id === 'string' && record.id.trim()
|
||||
? record.id.trim().slice(0, 64)
|
||||
: createPermissionGrantId(),
|
||||
capabilityId,
|
||||
sessionPattern: typeof record.sessionPattern === 'string' && record.sessionPattern.trim()
|
||||
? record.sessionPattern.trim()
|
||||
: '*',
|
||||
createdAt: typeof record.createdAt === 'number' && Number.isFinite(record.createdAt)
|
||||
? record.createdAt
|
||||
: Date.now(),
|
||||
};
|
||||
|
||||
if (typeof record.commandPattern === 'string' && record.commandPattern.trim()) {
|
||||
rule.commandPattern = record.commandPattern.trim();
|
||||
}
|
||||
if (record.argsPattern && typeof record.argsPattern === 'object' && !Array.isArray(record.argsPattern)) {
|
||||
const argsPattern: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(record.argsPattern as Record<string, unknown>)) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
argsPattern[key] = value.trim();
|
||||
}
|
||||
}
|
||||
if (Object.keys(argsPattern).length > 0) {
|
||||
rule.argsPattern = argsPattern;
|
||||
}
|
||||
}
|
||||
if (typeof record.note === 'string' && record.note.trim()) {
|
||||
rule.note = record.note.trim().slice(0, 240);
|
||||
}
|
||||
|
||||
result.push(rule);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createPermissionGrantId(): string {
|
||||
return `grant_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
const COMMAND_GRANT_CAPABILITIES = new Set([
|
||||
'terminal.execute',
|
||||
'terminal.start',
|
||||
]);
|
||||
|
||||
const GRANTABLE_CAPABILITY_IDS: readonly string[] = Object.freeze(
|
||||
[...new Set(
|
||||
(cattyToolSpecs as CattyToolSpecRef[])
|
||||
.filter((spec) => spec.policy?.write && !spec.policy?.bypassesApproval)
|
||||
.map((spec) => spec.capabilityId),
|
||||
)].sort(),
|
||||
);
|
||||
|
||||
export function listGrantableCapabilityIds(): readonly string[] {
|
||||
return GRANTABLE_CAPABILITY_IDS;
|
||||
}
|
||||
|
||||
export function capabilitySupportsCommandPatternGrant(capabilityId: string): boolean {
|
||||
return COMMAND_GRANT_CAPABILITIES.has(capabilityId);
|
||||
}
|
||||
|
||||
function resolveCommandGrantPatterns(
|
||||
capabilityId: string,
|
||||
args: Record<string, unknown>,
|
||||
): string[] | undefined {
|
||||
if (!COMMAND_GRANT_CAPABILITIES.has(capabilityId)) return undefined;
|
||||
const command = typeof args.command === 'string' ? args.command.trim() : '';
|
||||
if (!command) return undefined;
|
||||
return buildAlwaysAllowCommandPatterns(command);
|
||||
}
|
||||
|
||||
export function buildGrantsFromApproval(
|
||||
capabilityId: string,
|
||||
args: Record<string, unknown>,
|
||||
_chatSessionId?: string,
|
||||
): PermissionGrantRule[] {
|
||||
// OpenCode-style always-allow: global scope (not bound to a terminal session UUID).
|
||||
const sessionPattern = '*';
|
||||
const commandPatterns = resolveCommandGrantPatterns(capabilityId, args);
|
||||
const createdAt = Date.now();
|
||||
|
||||
if (!commandPatterns) {
|
||||
return [{
|
||||
id: createPermissionGrantId(),
|
||||
capabilityId,
|
||||
sessionPattern,
|
||||
createdAt,
|
||||
}];
|
||||
}
|
||||
|
||||
if (commandPatterns.length === 0) return [];
|
||||
|
||||
return commandPatterns.map((commandPattern) => ({
|
||||
id: createPermissionGrantId(),
|
||||
capabilityId,
|
||||
sessionPattern,
|
||||
commandPattern,
|
||||
createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildGrantFromApproval(
|
||||
capabilityId: string,
|
||||
args: Record<string, unknown>,
|
||||
chatSessionId?: string,
|
||||
): PermissionGrantRule | null {
|
||||
return buildGrantsFromApproval(capabilityId, args, chatSessionId)[0] ?? null;
|
||||
}
|
||||
|
||||
let activeRules: PermissionGrantRule[] = [];
|
||||
|
||||
export function setActivePermissionGrants(rules: PermissionGrantRule[]): void {
|
||||
activeRules = [...rules];
|
||||
}
|
||||
|
||||
export function getActivePermissionGrants(): readonly PermissionGrantRule[] {
|
||||
return activeRules;
|
||||
}
|
||||
|
||||
export class PermissionGrantStore {
|
||||
private rules: PermissionGrantRule[];
|
||||
|
||||
constructor(rules: PermissionGrantRule[] = []) {
|
||||
this.rules = [...rules];
|
||||
}
|
||||
|
||||
getRules(): readonly PermissionGrantRule[] {
|
||||
return this.rules;
|
||||
}
|
||||
|
||||
setRules(rules: PermissionGrantRule[]): void {
|
||||
this.rules = [...rules];
|
||||
}
|
||||
|
||||
addRule(rule: PermissionGrantRule): void {
|
||||
this.rules = [...this.rules, rule];
|
||||
}
|
||||
|
||||
updateRule(id: string, updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>>): void {
|
||||
this.rules = this.rules.map((rule) => (
|
||||
rule.id === id ? { ...rule, ...updates } : rule
|
||||
));
|
||||
}
|
||||
|
||||
removeRule(id: string): void {
|
||||
this.rules = this.rules.filter((rule) => rule.id !== id);
|
||||
}
|
||||
|
||||
match(ctx: PermissionGrantMatchContext): PermissionGrantRule | null {
|
||||
return matchPermissionGrant(this.rules, ctx);
|
||||
}
|
||||
}
|
||||
28
infrastructure/ai/harness/promptContextSnapshot.test.ts
Normal file
28
infrastructure/ai/harness/promptContextSnapshot.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildPromptContextSnapshot } from './promptContextSnapshot';
|
||||
|
||||
test('buildPromptContextSnapshot records inspectable prompt inputs without credentials', () => {
|
||||
const snapshot = buildPromptContextSnapshot({
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-test',
|
||||
permissionMode: 'confirm',
|
||||
scopeType: 'terminal',
|
||||
scopeLabel: 'production',
|
||||
toolNames: ['terminal_execute', 'terminal_poll'],
|
||||
selectedSkillSlugs: ['diagnosing-bugs'],
|
||||
systemPrompt: 'secret-free rendered prompt',
|
||||
webSearchEnabled: false,
|
||||
hostSessionIds: ['session-1'],
|
||||
builtAt: 123,
|
||||
});
|
||||
|
||||
assert.equal(snapshot.version, 2);
|
||||
assert.deepEqual(snapshot.toolNames, ['terminal_execute', 'terminal_poll']);
|
||||
assert.equal(snapshot.systemPromptChars, 27);
|
||||
assert.match(snapshot.systemPromptHash, /^fnv1a-/);
|
||||
assert.deepEqual(snapshot.injections.map(item => item.source), [
|
||||
'system-prompt', 'capability-catalog', 'user-skills', 'terminal-scope', 'web-search',
|
||||
]);
|
||||
assert.equal(JSON.stringify(snapshot).includes('secret-free rendered prompt'), false);
|
||||
});
|
||||
85
infrastructure/ai/harness/promptContextSnapshot.ts
Normal file
85
infrastructure/ai/harness/promptContextSnapshot.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { AIPermissionMode } from '../types';
|
||||
|
||||
export interface PromptContextSnapshot {
|
||||
version: 2;
|
||||
audience: 'sidebar';
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
permissionMode: AIPermissionMode;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeLabel?: string;
|
||||
toolNames: string[];
|
||||
selectedSkillSlugs: string[];
|
||||
systemPromptChars: number;
|
||||
systemPromptHash: string;
|
||||
injections: Array<{
|
||||
order: number;
|
||||
source: 'system-prompt' | 'capability-catalog' | 'user-skills' | 'terminal-scope' | 'web-search';
|
||||
itemCount: number;
|
||||
chars?: number;
|
||||
hash: string;
|
||||
}>;
|
||||
webSearchEnabled: boolean;
|
||||
hostSessionIds: string[];
|
||||
/** Dynamic request estimate used by the Catty context meter. */
|
||||
contextWindow?: number;
|
||||
estimatedInputTokens?: number;
|
||||
builtAt: number;
|
||||
}
|
||||
|
||||
export function buildPromptContextSnapshot(input: {
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
permissionMode: AIPermissionMode;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeLabel?: string;
|
||||
toolNames: string[];
|
||||
selectedSkillSlugs?: string[];
|
||||
systemPrompt: string;
|
||||
webSearchEnabled: boolean;
|
||||
hostSessionIds: string[];
|
||||
builtAt?: number;
|
||||
}): PromptContextSnapshot {
|
||||
const toolNames = [...input.toolNames].sort();
|
||||
const selectedSkillSlugs = [...(input.selectedSkillSlugs ?? [])].sort();
|
||||
const hostSessionIds = [...input.hostSessionIds];
|
||||
const injectionValues = [
|
||||
{ source: 'system-prompt' as const, values: [input.systemPrompt], chars: input.systemPrompt.length },
|
||||
{ source: 'capability-catalog' as const, values: toolNames },
|
||||
{ source: 'user-skills' as const, values: selectedSkillSlugs },
|
||||
{ source: 'terminal-scope' as const, values: hostSessionIds },
|
||||
{ source: 'web-search' as const, values: [String(input.webSearchEnabled)] },
|
||||
];
|
||||
return {
|
||||
version: 2,
|
||||
audience: 'sidebar',
|
||||
providerId: input.providerId,
|
||||
modelId: input.modelId,
|
||||
permissionMode: input.permissionMode,
|
||||
scopeType: input.scopeType,
|
||||
scopeLabel: input.scopeLabel,
|
||||
toolNames,
|
||||
selectedSkillSlugs,
|
||||
systemPromptChars: input.systemPrompt.length,
|
||||
systemPromptHash: hashPromptPart(input.systemPrompt),
|
||||
injections: injectionValues.map((entry, order) => ({
|
||||
order,
|
||||
source: entry.source,
|
||||
itemCount: entry.values.length,
|
||||
...(entry.chars != null ? { chars: entry.chars } : {}),
|
||||
hash: hashPromptPart(entry.values.join('\n')),
|
||||
})),
|
||||
webSearchEnabled: input.webSearchEnabled,
|
||||
hostSessionIds,
|
||||
builtAt: input.builtAt ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function hashPromptPart(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
69
infrastructure/ai/harness/sdkSessionIdentity.test.ts
Normal file
69
infrastructure/ai/harness/sdkSessionIdentity.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { encodeSdkSessionIdentity, parseSdkSessionIdentity } from './sdkSessionIdentity';
|
||||
|
||||
test('SDK session identities preserve Codex runtime and default legacy values to sdk', () => {
|
||||
const encoded = encodeSdkSessionIdentity('thread-1', 'codex', '/bin/codex', 'app-server');
|
||||
assert.deepEqual(parseSdkSessionIdentity(encoded), {
|
||||
v: 1,
|
||||
id: 'thread-1',
|
||||
backend: 'codex',
|
||||
binPath: '/bin/codex',
|
||||
runtime: 'app-server',
|
||||
});
|
||||
|
||||
const legacy = encodeSdkSessionIdentity('thread-2', 'codex', '/bin/codex');
|
||||
const payload = JSON.parse(decodeURIComponent(legacy.slice('netcatty-sdk-session:'.length)));
|
||||
delete payload.runtime;
|
||||
const legacyWithoutRuntime = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify(payload))}`;
|
||||
assert.equal(parseSdkSessionIdentity(legacyWithoutRuntime)?.runtime, 'sdk');
|
||||
});
|
||||
|
||||
test('SDK session identities preserve Cursor auth mode', () => {
|
||||
const encoded = encodeSdkSessionIdentity(
|
||||
'61668441-bfcb-4795-a575-c46d70ad01fe',
|
||||
'cursor',
|
||||
'/usr/bin/agent',
|
||||
'sdk',
|
||||
'cli-login',
|
||||
'agent',
|
||||
);
|
||||
assert.deepEqual(parseSdkSessionIdentity(encoded), {
|
||||
v: 1,
|
||||
id: '61668441-bfcb-4795-a575-c46d70ad01fe',
|
||||
backend: 'cursor',
|
||||
binPath: '/usr/bin/agent',
|
||||
runtime: 'sdk',
|
||||
authMode: 'cli-login',
|
||||
cliMode: 'agent',
|
||||
});
|
||||
});
|
||||
|
||||
test('SDK session identities preserve Grok ACP and streaming-json runtimes', () => {
|
||||
const acp = encodeSdkSessionIdentity('sess-acp', 'grok', '/usr/bin/grok', 'acp');
|
||||
assert.deepEqual(parseSdkSessionIdentity(acp), {
|
||||
v: 1,
|
||||
id: 'sess-acp',
|
||||
backend: 'grok',
|
||||
binPath: '/usr/bin/grok',
|
||||
runtime: 'acp',
|
||||
});
|
||||
|
||||
const headless = encodeSdkSessionIdentity(
|
||||
'sess-json',
|
||||
'grok',
|
||||
'/usr/bin/grok',
|
||||
'streaming-json',
|
||||
);
|
||||
assert.deepEqual(parseSdkSessionIdentity(headless), {
|
||||
v: 1,
|
||||
id: 'sess-json',
|
||||
backend: 'grok',
|
||||
binPath: '/usr/bin/grok',
|
||||
runtime: 'streaming-json',
|
||||
});
|
||||
|
||||
// Aliases normalize to streaming-json.
|
||||
const alias = encodeSdkSessionIdentity('sess-cli', 'grok', '/usr/bin/grok', 'headless');
|
||||
assert.equal(parseSdkSessionIdentity(alias)?.runtime, 'streaming-json');
|
||||
});
|
||||
80
infrastructure/ai/harness/sdkSessionIdentity.ts
Normal file
80
infrastructure/ai/harness/sdkSessionIdentity.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export const SDK_SESSION_ID_PREFIX = 'netcatty-sdk-session:';
|
||||
|
||||
export type CursorAuthModeIdentity = 'api-key' | 'cli-login';
|
||||
export type CursorCliModeIdentity = 'ask' | 'agent';
|
||||
/** Codex dual runtime + Grok dual runtime. */
|
||||
export type SdkRuntimeIdentity = 'sdk' | 'app-server' | 'acp' | 'streaming-json';
|
||||
|
||||
export interface SdkSessionIdentityPayload {
|
||||
v: 1;
|
||||
id: string;
|
||||
backend: string;
|
||||
binPath: string;
|
||||
runtime?: SdkRuntimeIdentity;
|
||||
authMode?: CursorAuthModeIdentity;
|
||||
cliMode?: CursorCliModeIdentity;
|
||||
}
|
||||
|
||||
export function normalizeCursorAuthMode(
|
||||
authMode: string | undefined | null,
|
||||
): CursorAuthModeIdentity | undefined {
|
||||
return authMode === 'cli-login' ? 'cli-login' : authMode === 'api-key' ? 'api-key' : undefined;
|
||||
}
|
||||
|
||||
export function normalizeCursorCliMode(
|
||||
cliMode: string | undefined | null,
|
||||
): CursorCliModeIdentity | undefined {
|
||||
return cliMode === 'ask' ? 'ask' : cliMode === 'agent' ? 'agent' : undefined;
|
||||
}
|
||||
|
||||
export function normalizeSdkRuntime(
|
||||
runtime: string | undefined | null,
|
||||
): SdkRuntimeIdentity {
|
||||
const raw = String(runtime || '').trim().toLowerCase();
|
||||
if (raw === 'app-server') return 'app-server';
|
||||
if (raw === 'acp') return 'acp';
|
||||
if (raw === 'streaming-json' || raw === 'cli' || raw === 'headless') return 'streaming-json';
|
||||
return 'sdk';
|
||||
}
|
||||
|
||||
export function encodeSdkSessionIdentity(
|
||||
sessionId: string,
|
||||
sdkBackend?: string,
|
||||
binPath?: string,
|
||||
runtime: string = 'sdk',
|
||||
authMode?: string,
|
||||
cliMode?: string,
|
||||
): string {
|
||||
if (!sessionId || !sdkBackend) return sessionId;
|
||||
const payload: SdkSessionIdentityPayload = {
|
||||
v: 1,
|
||||
id: sessionId,
|
||||
backend: sdkBackend,
|
||||
binPath: binPath || '',
|
||||
runtime: normalizeSdkRuntime(runtime),
|
||||
};
|
||||
const normalizedAuthMode = normalizeCursorAuthMode(authMode);
|
||||
if (normalizedAuthMode) payload.authMode = normalizedAuthMode;
|
||||
const normalizedCliMode = normalizeCursorCliMode(cliMode);
|
||||
if (normalizedCliMode) payload.cliMode = normalizedCliMode;
|
||||
return `${SDK_SESSION_ID_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`;
|
||||
}
|
||||
|
||||
export function parseSdkSessionIdentity(value: string | undefined | null): SdkSessionIdentityPayload | null {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw.startsWith(SDK_SESSION_ID_PREFIX)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(raw.slice(SDK_SESSION_ID_PREFIX.length))) as SdkSessionIdentityPayload;
|
||||
if (parsed?.v !== 1 || !parsed.id || !parsed.backend) return null;
|
||||
const authMode = normalizeCursorAuthMode(parsed.authMode);
|
||||
const cliMode = normalizeCursorCliMode(parsed.cliMode);
|
||||
return {
|
||||
...parsed,
|
||||
runtime: normalizeSdkRuntime(parsed.runtime),
|
||||
...(authMode ? { authMode } : {}),
|
||||
...(cliMode ? { cliMode } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
135
infrastructure/ai/harness/sessionState.test.ts
Normal file
135
infrastructure/ai/harness/sessionState.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { SessionStateStore } from './sessionState.ts';
|
||||
|
||||
test('SessionStateStore tracks terminal commands and reinjection text', () => {
|
||||
const store = new SessionStateStore();
|
||||
store.mergeFromUserGoal('chat-1', 'Fix nginx upstream timeout');
|
||||
store.updateFromToolResult(
|
||||
'chat-1',
|
||||
'terminal_execute',
|
||||
{ sessionId: 'sess-1', command: 'tail -n 100 /var/log/nginx/error.log' },
|
||||
'upstream timed out',
|
||||
false,
|
||||
);
|
||||
|
||||
const text = store.toReinjectionText('chat-1');
|
||||
assert.ok(text?.includes('Fix nginx upstream timeout'));
|
||||
assert.ok(text?.includes('sess-1'));
|
||||
assert.ok(text?.includes('tail -n 100'));
|
||||
});
|
||||
|
||||
test('SessionStateStore records tool errors as blockers', () => {
|
||||
const store = new SessionStateStore();
|
||||
store.updateFromToolResult(
|
||||
'chat-1',
|
||||
'terminal_execute',
|
||||
{ sessionId: 'sess-1', command: 'systemctl restart nginx' },
|
||||
'{ "error": "Job failed" }',
|
||||
true,
|
||||
);
|
||||
const text = store.toReinjectionText('chat-1');
|
||||
assert.ok(text?.includes('Open blockers'));
|
||||
});
|
||||
|
||||
test('SessionStateStore restores active background jobs and poll cursors', () => {
|
||||
const store = new SessionStateStore();
|
||||
store.updateFromToolResult(
|
||||
'chat-1',
|
||||
'terminal_start',
|
||||
{ sessionId: 'sess-1', command: 'npm run dev' },
|
||||
JSON.stringify({ ok: true, jobId: 'job-1', status: 'running', nextOffset: 0 }),
|
||||
);
|
||||
store.updateFromToolResult(
|
||||
'chat-1',
|
||||
'terminal_poll',
|
||||
{ jobId: 'job-1', offset: 0 },
|
||||
JSON.stringify({ ok: true, jobId: 'job-1', status: 'running', nextOffset: 420 }),
|
||||
);
|
||||
|
||||
const state = store.get('chat-1');
|
||||
assert.equal(state.version, 1);
|
||||
assert.equal(state.activeJobs['job-1'].nextOffset, 420);
|
||||
const text = store.toReinjectionText('chat-1') ?? '';
|
||||
assert.match(text, /job-1/);
|
||||
assert.match(text, /offset=420/);
|
||||
assert.match(text, /poll the existing job/i);
|
||||
assert.match(text, /do not restart/i);
|
||||
assert.match(text, /unverified after compaction/i);
|
||||
});
|
||||
|
||||
test('SessionStateStore drops a remembered job after poll reports it missing', () => {
|
||||
const store = new SessionStateStore();
|
||||
store.updateFromToolResult(
|
||||
'chat-1', 'terminal_start', { sessionId: 'sess-1', command: 'npm run dev' },
|
||||
JSON.stringify({ jobId: 'job-lost', status: 'running' }), false,
|
||||
);
|
||||
store.updateFromToolResult(
|
||||
'chat-1', 'terminal_poll', { jobId: 'job-lost', offset: 0 },
|
||||
JSON.stringify({ error: 'Job not found' }), true,
|
||||
);
|
||||
assert.equal(store.get('chat-1').activeJobs['job-lost'], undefined);
|
||||
assert.doesNotMatch(store.toReinjectionText('chat-1') ?? '', /Remembered terminal jobs/);
|
||||
});
|
||||
|
||||
test('SessionStateStore preserves a running job after a transient poll error', () => {
|
||||
const store = new SessionStateStore();
|
||||
store.updateFromToolResult(
|
||||
'chat-1', 'terminal_start', { sessionId: 'sess-1', command: 'npm run dev' },
|
||||
JSON.stringify({ jobId: 'job-running', status: 'running', nextOffset: 420 }), false,
|
||||
);
|
||||
store.updateFromToolResult(
|
||||
'chat-1', 'terminal_poll', { jobId: 'job-running', offset: 420 },
|
||||
JSON.stringify({ error: 'temporary IPC timeout' }), true,
|
||||
);
|
||||
|
||||
const job = store.get('chat-1').activeJobs['job-running'];
|
||||
assert.equal(job.status, 'unverified');
|
||||
assert.equal(job.nextOffset, 420);
|
||||
assert.match(store.toReinjectionText('chat-1') ?? '', /job-running/);
|
||||
assert.match(store.toReinjectionText('chat-1') ?? '', /do not restart/i);
|
||||
});
|
||||
|
||||
test('SessionStateStore drops a job after poll confirms cancellation', () => {
|
||||
const store = new SessionStateStore();
|
||||
store.updateFromToolResult(
|
||||
'chat-1', 'terminal_start', { sessionId: 'sess-1', command: 'npm run dev' },
|
||||
JSON.stringify({ jobId: 'job-cancelled', status: 'running' }), false,
|
||||
);
|
||||
store.updateFromToolResult(
|
||||
'chat-1', 'terminal_poll', { jobId: 'job-cancelled', offset: 0 },
|
||||
JSON.stringify({ jobId: 'job-cancelled', status: 'cancelled', error: 'Cancelled' }), true,
|
||||
);
|
||||
|
||||
assert.equal(store.get('chat-1').activeJobs['job-cancelled'], undefined);
|
||||
});
|
||||
|
||||
test('SessionStateStore records the last terminal screen range read', () => {
|
||||
const store = new SessionStateStore();
|
||||
store.updateFromToolResult(
|
||||
'chat-1',
|
||||
'terminal_read_context',
|
||||
{ sessionId: 'sess-1', range: 'tail', startLine: 80, maxLines: 20 },
|
||||
JSON.stringify({ ok: true, sessionId: 'sess-1', startLine: 80, endLine: 99 }),
|
||||
);
|
||||
|
||||
assert.deepEqual(store.get('chat-1').terminalReadCursors['sess-1'], {
|
||||
range: 'tail',
|
||||
startLine: 80,
|
||||
endLine: 99,
|
||||
});
|
||||
});
|
||||
|
||||
test('SessionStateStore reinjects edited files and unfinished plan items', () => {
|
||||
const store = new SessionStateStore();
|
||||
store.mergeFileChanges('chat-1', ['/repo/src/a.ts', '/repo/src/b.ts']);
|
||||
store.mergePlan('chat-1', [
|
||||
{ text: 'inspect failure', completed: true },
|
||||
{ text: 'run regression tests', completed: false },
|
||||
]);
|
||||
|
||||
const text = store.toReinjectionText('chat-1') ?? '';
|
||||
assert.match(text, /\/repo\/src\/a\.ts/);
|
||||
assert.match(text, /\[done\] inspect failure/);
|
||||
assert.match(text, /\[todo\] run regression tests/);
|
||||
});
|
||||
294
infrastructure/ai/harness/sessionState.ts
Normal file
294
infrastructure/ai/harness/sessionState.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { redactSecretsForModel } from './modelSecretRedaction';
|
||||
|
||||
const MAX_DECISIONS = 15;
|
||||
const MAX_BLOCKERS = 10;
|
||||
|
||||
export interface ActiveTerminalJobState {
|
||||
sessionId?: string;
|
||||
command?: string;
|
||||
status: string;
|
||||
nextOffset: number;
|
||||
}
|
||||
|
||||
export interface TerminalReadCursorState {
|
||||
range: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
|
||||
export interface CattySessionState {
|
||||
version: 1;
|
||||
userGoal?: string;
|
||||
decisions: string[];
|
||||
activeHosts: Record<string, { hostname?: string; lastCommand?: string }>;
|
||||
activeJobs: Record<string, ActiveTerminalJobState>;
|
||||
terminalReadCursors: Record<string, TerminalReadCursorState>;
|
||||
editedFiles: string[];
|
||||
planItems: Array<{ text: string; completed: boolean }>;
|
||||
blockers: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function emptyState(): CattySessionState {
|
||||
return {
|
||||
version: 1,
|
||||
decisions: [],
|
||||
activeHosts: {},
|
||||
activeJobs: {},
|
||||
terminalReadCursors: {},
|
||||
editedFiles: [],
|
||||
planItems: [],
|
||||
blockers: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function pushUnique(list: string[], value: string, cap: number): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || list.includes(trimmed)) return list;
|
||||
return [...list, trimmed].slice(-cap);
|
||||
}
|
||||
|
||||
function parseResultObject(resultText: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(resultText);
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function terminalJobDefinitelyGone(result: Record<string, unknown> | undefined, resultText: string): boolean {
|
||||
const status = typeof result?.status === 'string' ? result.status.toLowerCase() : '';
|
||||
if (['completed', 'failed', 'stopped', 'exited', 'cancelled', 'canceled', 'not_found'].includes(status)) return true;
|
||||
const error = typeof result?.error === 'string' ? result.error : resultText;
|
||||
return /\b(?:job|task)\b.{0,40}\b(?:not found|does not exist|no longer exists|already (?:finished|completed|exited|stopped))\b/i.test(error)
|
||||
|| /\b(?:unknown|no such)\s+(?:job|task)\b/i.test(error);
|
||||
}
|
||||
|
||||
export class SessionStateStore {
|
||||
private readonly bySession = new Map<string, CattySessionState>();
|
||||
|
||||
get(chatSessionId: string): CattySessionState {
|
||||
return this.bySession.get(chatSessionId) ?? emptyState();
|
||||
}
|
||||
|
||||
clear(chatSessionId: string): void {
|
||||
this.bySession.delete(chatSessionId);
|
||||
}
|
||||
|
||||
mergeFromUserGoal(chatSessionId: string, goal: string | undefined): void {
|
||||
if (!goal?.trim()) return;
|
||||
const state = { ...this.get(chatSessionId) };
|
||||
state.userGoal = goal.trim().slice(0, 500);
|
||||
state.updatedAt = Date.now();
|
||||
this.bySession.set(chatSessionId, state);
|
||||
}
|
||||
|
||||
mergeFromAssistantContent(chatSessionId: string, content: string): void {
|
||||
const decisionPatterns = [
|
||||
/\bdecided to\b[:\s]+(.{10,200})/i,
|
||||
/\bwill use\b[:\s]+(.{10,200})/i,
|
||||
/\bconstraint[:\s]+(.{10,200})/i,
|
||||
];
|
||||
let state = this.get(chatSessionId);
|
||||
for (const pattern of decisionPatterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match?.[1]) {
|
||||
state = {
|
||||
...state,
|
||||
decisions: pushUnique(state.decisions, match[1].trim(), MAX_DECISIONS),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
this.bySession.set(chatSessionId, state);
|
||||
}
|
||||
|
||||
mergeFileChanges(chatSessionId: string, paths: string[]): void {
|
||||
const state = { ...this.get(chatSessionId) };
|
||||
state.editedFiles = paths.reduce(
|
||||
(files, path) => pushUnique(files, path, 50),
|
||||
state.editedFiles,
|
||||
);
|
||||
state.updatedAt = Date.now();
|
||||
this.bySession.set(chatSessionId, state);
|
||||
}
|
||||
|
||||
mergePlan(chatSessionId: string, items: Array<{ text: string; completed: boolean }>): void {
|
||||
const state = { ...this.get(chatSessionId) };
|
||||
state.planItems = items.slice(-30).map(item => ({
|
||||
text: item.text.slice(0, 300),
|
||||
completed: item.completed,
|
||||
}));
|
||||
state.updatedAt = Date.now();
|
||||
this.bySession.set(chatSessionId, state);
|
||||
}
|
||||
|
||||
updateFromToolResult(
|
||||
chatSessionId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown> | undefined,
|
||||
resultText: string,
|
||||
isError?: boolean,
|
||||
): void {
|
||||
const state = { ...this.get(chatSessionId) };
|
||||
const name = toolName.toLowerCase();
|
||||
const result = parseResultObject(resultText);
|
||||
|
||||
if (name === 'terminal_execute' || name === 'terminal.execute') {
|
||||
const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : undefined;
|
||||
const command = typeof args?.command === 'string' ? args.command : undefined;
|
||||
if (sessionId) {
|
||||
state.activeHosts = {
|
||||
...state.activeHosts,
|
||||
[sessionId]: {
|
||||
...state.activeHosts[sessionId],
|
||||
lastCommand: command,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'terminal_start' || name === 'terminal.start') {
|
||||
const jobId = typeof result?.jobId === 'string' ? result.jobId : undefined;
|
||||
if (jobId && !isError) {
|
||||
state.activeJobs = {
|
||||
...state.activeJobs,
|
||||
[jobId]: {
|
||||
sessionId: typeof args?.sessionId === 'string' ? args.sessionId : undefined,
|
||||
command: typeof args?.command === 'string' ? args.command : undefined,
|
||||
status: typeof result?.status === 'string' ? result.status : 'running',
|
||||
nextOffset: typeof result?.nextOffset === 'number' ? result.nextOffset : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'terminal_poll' || name === 'terminal.poll') {
|
||||
const jobId = typeof args?.jobId === 'string'
|
||||
? args.jobId
|
||||
: typeof result?.jobId === 'string' ? result.jobId : undefined;
|
||||
if (jobId && !isError) {
|
||||
const status = typeof result?.status === 'string' ? result.status : 'running';
|
||||
if (status === 'running' || status === 'stopping') {
|
||||
state.activeJobs = {
|
||||
...state.activeJobs,
|
||||
[jobId]: {
|
||||
...state.activeJobs[jobId],
|
||||
status,
|
||||
nextOffset: typeof result?.nextOffset === 'number'
|
||||
? result.nextOffset
|
||||
: state.activeJobs[jobId]?.nextOffset ?? 0,
|
||||
},
|
||||
};
|
||||
} else if (state.activeJobs[jobId]) {
|
||||
state.activeJobs = { ...state.activeJobs };
|
||||
delete state.activeJobs[jobId];
|
||||
}
|
||||
} else if (jobId && state.activeJobs[jobId]) {
|
||||
if (terminalJobDefinitelyGone(result, resultText)) {
|
||||
state.activeJobs = { ...state.activeJobs };
|
||||
delete state.activeJobs[jobId];
|
||||
} else {
|
||||
state.activeJobs = {
|
||||
...state.activeJobs,
|
||||
[jobId]: { ...state.activeJobs[jobId], status: 'unverified' },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'terminal_stop' || name === 'terminal.stop') {
|
||||
const jobId = typeof args?.jobId === 'string' ? args.jobId : undefined;
|
||||
if (jobId && state.activeJobs[jobId]) {
|
||||
state.activeJobs = {
|
||||
...state.activeJobs,
|
||||
[jobId]: { ...state.activeJobs[jobId], status: 'stopping' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'terminal_read_context' || name === 'terminal.read_context') {
|
||||
const sessionId = typeof args?.sessionId === 'string'
|
||||
? args.sessionId
|
||||
: typeof result?.sessionId === 'string' ? result.sessionId : undefined;
|
||||
if (sessionId && !isError) {
|
||||
state.terminalReadCursors = {
|
||||
...state.terminalReadCursors,
|
||||
[sessionId]: {
|
||||
range: typeof args?.range === 'string' ? args.range : 'viewport',
|
||||
startLine: typeof result?.startLine === 'number'
|
||||
? result.startLine
|
||||
: typeof args?.startLine === 'number' ? args.startLine : undefined,
|
||||
endLine: typeof result?.endLine === 'number' ? result.endLine : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ((name === 'session_close' || name === 'session.close') && !isError) {
|
||||
const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : undefined;
|
||||
if (sessionId) {
|
||||
state.activeHosts = { ...state.activeHosts };
|
||||
state.terminalReadCursors = { ...state.terminalReadCursors };
|
||||
delete state.activeHosts[sessionId];
|
||||
delete state.terminalReadCursors[sessionId];
|
||||
state.activeJobs = Object.fromEntries(
|
||||
Object.entries(state.activeJobs).filter(([, job]) => job.sessionId !== sessionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const preview = resultText.slice(0, 160).replace(/\s+/g, ' ').trim();
|
||||
if (preview) {
|
||||
state.blockers = pushUnique(state.blockers, `${toolName}: ${preview}`, MAX_BLOCKERS);
|
||||
}
|
||||
}
|
||||
|
||||
state.updatedAt = Date.now();
|
||||
this.bySession.set(chatSessionId, state);
|
||||
}
|
||||
|
||||
toReinjectionText(chatSessionId: string): string | undefined {
|
||||
const state = this.get(chatSessionId);
|
||||
const lines: string[] = [];
|
||||
if (state.userGoal) lines.push(`User goal: ${state.userGoal}`);
|
||||
if (state.decisions.length) {
|
||||
lines.push(`Decisions: ${state.decisions.slice(-5).join('; ')}`);
|
||||
}
|
||||
const hosts = Object.entries(state.activeHosts);
|
||||
if (hosts.length) {
|
||||
const hostSummary = hosts
|
||||
.slice(-5)
|
||||
.map(([id, host]) => `${id}${host.lastCommand ? ` (last: ${redactSecretsForModel(host.lastCommand)})` : ''}`)
|
||||
.join(', ');
|
||||
lines.push(`Active hosts: ${hostSummary}`);
|
||||
}
|
||||
const jobs = Object.entries(state.activeJobs);
|
||||
if (jobs.length) {
|
||||
const jobSummary = jobs.slice(-5).map(([jobId, job]) => (
|
||||
`${jobId} (status=${job.status}, offset=${job.nextOffset}${job.sessionId ? `, session=${job.sessionId}` : ''}${job.command ? `, command=${redactSecretsForModel(job.command)}` : ''})`
|
||||
)).join('; ');
|
||||
lines.push(`Remembered terminal jobs (status is unverified after compaction): ${jobSummary}. Poll the existing job from its saved offset to verify current status; do not restart its command.`);
|
||||
}
|
||||
const cursors = Object.entries(state.terminalReadCursors);
|
||||
if (cursors.length) {
|
||||
lines.push(`Terminal read cursors: ${cursors.slice(-5).map(([id, cursor]) => `${id} (${cursor.range}, lines=${cursor.startLine ?? '?'}-${cursor.endLine ?? '?'})`).join(', ')}`);
|
||||
}
|
||||
if (state.editedFiles.length) {
|
||||
lines.push(`Edited files: ${state.editedFiles.slice(-20).join(', ')}`);
|
||||
}
|
||||
if (state.planItems.length) {
|
||||
lines.push(`Plan: ${state.planItems.map(item => `${item.completed ? '[done]' : '[todo]'} ${item.text}`).join('; ')}`);
|
||||
}
|
||||
if (state.blockers.length) {
|
||||
lines.push(`Open blockers: ${state.blockers.slice(-3).join('; ')}`);
|
||||
}
|
||||
if (lines.length === 0) return undefined;
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export const globalSessionStateStore = new SessionStateStore();
|
||||
466
infrastructure/ai/harness/staleContextPruner.test.ts
Normal file
466
infrastructure/ai/harness/staleContextPruner.test.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { pruneStaleToolContext } from './staleContextPruner.ts';
|
||||
|
||||
test('pruneStaleToolContext supersedes older sftp reads for same path', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read',
|
||||
output: { type: 'text', value: 'old config body' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read',
|
||||
output: { type: 'text', value: 'new config body' },
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
assert.equal(result.didAdjust, true);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /superseded read/);
|
||||
assert.match(serialized, /new config body/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext supersedes older sftp_read_file reads for same path', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'old config body' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'new config body' },
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
assert.equal(result.didAdjust, true);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /superseded read/);
|
||||
assert.match(serialized, /new config body/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext keeps sftp reads for same path on different sessions', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'host-a config' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-b', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'host-b config' },
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
assert.equal(result.didAdjust, false);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /host-a config/);
|
||||
assert.match(serialized, /host-b config/);
|
||||
assert.doesNotMatch(serialized, /superseded read/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext supersedes repeated terminal polls and context reads', () => {
|
||||
const pair = (callId: string, toolName: string, input: Record<string, unknown>, output: string): ModelMessage[] => [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: callId, toolName, input }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
toolName,
|
||||
output: { type: 'text', value: output },
|
||||
}],
|
||||
},
|
||||
];
|
||||
const messages: ModelMessage[] = [
|
||||
...pair('p1', 'terminal_poll', { jobId: 'job-1', offset: 0 }, 'old poll'),
|
||||
...pair('p2', 'terminal_poll', { jobId: 'job-1', offset: 0 }, 'new poll'),
|
||||
...pair('r1', 'terminal_read_context', { sessionId: 's1', range: 'tail', maxLines: 20 }, 'old screen'),
|
||||
...pair('r2', 'terminal_read_context', { sessionId: 's1', range: 'tail', maxLines: 20 }, 'new screen'),
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.equal(result.didAdjust, true);
|
||||
assert.doesNotMatch(serialized, /old poll|old screen/);
|
||||
assert.match(serialized, /new poll/);
|
||||
assert.match(serialized, /new screen/);
|
||||
});
|
||||
|
||||
function terminalExecutePair(
|
||||
callId: string,
|
||||
sessionId: string,
|
||||
command: string,
|
||||
output: string,
|
||||
): ModelMessage[] {
|
||||
return [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: callId,
|
||||
toolName: 'terminal_execute',
|
||||
input: { sessionId, command },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
toolName: 'terminal_execute',
|
||||
output: { type: 'text', value: output },
|
||||
}],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
test('pruneStaleToolContext keeps last two terminal outputs per session', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
...terminalExecutePair('t1', 'sess-1', 'uptime', 'uptime-1'),
|
||||
...terminalExecutePair('t2', 'sess-1', 'df -h', 'df-2'),
|
||||
...terminalExecutePair('t3', 'sess-1', 'free -m', 'free-3'),
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
assert.equal(result.didAdjust, true);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /df-2/);
|
||||
assert.match(serialized, /free-3/);
|
||||
assert.doesNotMatch(serialized, /uptime-1/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext omits terminal outputs per session independently', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
...terminalExecutePair('a1', 'sess-a', 'uptime', 'a-uptime'),
|
||||
...terminalExecutePair('a2', 'sess-a', 'df -h', 'a-df'),
|
||||
...terminalExecutePair('a3', 'sess-a', 'free -m', 'a-free'),
|
||||
...terminalExecutePair('b1', 'sess-b', 'uptime', 'b-uptime'),
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
assert.equal(result.didAdjust, true);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /a-df/);
|
||||
assert.match(serialized, /a-free/);
|
||||
assert.match(serialized, /b-uptime/);
|
||||
assert.doesNotMatch(serialized, /a-uptime/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext preserves repeated sftp reads without budget pressure', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'before edit' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'after edit' },
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages);
|
||||
assert.equal(result.didAdjust, false);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /before edit/);
|
||||
assert.match(serialized, /after edit/);
|
||||
assert.doesNotMatch(serialized, /superseded read/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext keeps last successful read when a later read fails', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'valid config body' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'Permission denied error' },
|
||||
isError: true,
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
assert.equal(result.didAdjust, false);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /valid config body/);
|
||||
assert.doesNotMatch(serialized, /superseded read/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext keeps last successful read when a later JSON read fails', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { type: 'text', value: 'valid config body' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'sftp_read_file',
|
||||
output: { error: 'Permission denied' },
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
assert.equal(result.didAdjust, false);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /valid config body/);
|
||||
assert.doesNotMatch(serialized, /superseded read/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext preserves terminal output without budget pressure flag', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
...terminalExecutePair('t1', 'sess-1', 'uptime', 'uptime-1'),
|
||||
...terminalExecutePair('t2', 'sess-1', 'df -h', 'df-2'),
|
||||
...terminalExecutePair('t3', 'sess-1', 'free -m', 'free-3'),
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages);
|
||||
assert.equal(result.didAdjust, false);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /uptime-1/);
|
||||
assert.match(serialized, /df-2/);
|
||||
assert.match(serialized, /free-3/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext tiers generic old tool results while protecting recent turns', () => {
|
||||
const messages: ModelMessage[] = [];
|
||||
for (let turn = 0; turn < 12; turn += 1) {
|
||||
const callId = `call-${turn}`;
|
||||
messages.push(
|
||||
{ role: 'user', content: `turn ${turn}` },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: callId, toolName: 'custom_read', input: { turn } }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
toolName: 'custom_read',
|
||||
output: { type: 'text', value: `result-${turn}-${'x'.repeat(5_000)}-tail-${turn}` },
|
||||
}],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /older tool result omitted/);
|
||||
assert.match(serialized, /tool result shortened/);
|
||||
assert.match(serialized, /result-11-/);
|
||||
assert.match(serialized, /tail-11/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext retains old successful write outcomes', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'write-1',
|
||||
toolName: 'sftp_write',
|
||||
input: { sessionId: 'sess-1', path: '/etc/app.conf', content: 'enabled=true' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'write-1',
|
||||
toolName: 'sftp_write',
|
||||
output: { type: 'text', value: 'write succeeded: /etc/app.conf' },
|
||||
}],
|
||||
},
|
||||
...Array.from({ length: 11 }, (_, index) => ({ role: 'user' as const, content: `later ${index}` })),
|
||||
];
|
||||
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.match(serialized, /write succeeded: \/etc\/app\.conf/);
|
||||
assert.doesNotMatch(serialized, /older tool result omitted/);
|
||||
});
|
||||
|
||||
test('pruneStaleToolContext redacts commands in old terminal placeholders', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
...terminalExecutePair('t1', 'sess-1', 'curl --password swordfish', 'old-output'),
|
||||
...terminalExecutePair('t2', 'sess-1', 'uptime', 'newer-output'),
|
||||
...terminalExecutePair('t3', 'sess-1', 'df -h', 'latest-output'),
|
||||
];
|
||||
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.doesNotMatch(serialized, /swordfish/);
|
||||
assert.match(serialized, /REDACTED/);
|
||||
});
|
||||
284
infrastructure/ai/harness/staleContextPruner.ts
Normal file
284
infrastructure/ai/harness/staleContextPruner.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { redactSecretsForModel, redactSecretsInValueForModel } from './modelSecretRedaction';
|
||||
|
||||
const SUPERSEDED_READ_PREFIX = '[superseded read:';
|
||||
const EARLIER_TERMINAL_PREFIX = '[earlier terminal output omitted:';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function getToolCallMap(messages: ModelMessage[]): Map<string, { toolName: string; input: unknown }> {
|
||||
const map = new Map<string, { toolName: string; input: unknown }>();
|
||||
for (const message of messages) {
|
||||
if (message.role !== 'assistant' || !Array.isArray(message.content)) continue;
|
||||
for (const part of message.content as unknown[]) {
|
||||
if (!isRecord(part) || part.type !== 'tool-call') continue;
|
||||
const toolCallId = typeof part.toolCallId === 'string' ? part.toolCallId : '';
|
||||
const toolName = typeof part.toolName === 'string' ? part.toolName : '';
|
||||
if (toolCallId) {
|
||||
map.set(toolCallId, { toolName, input: part.input });
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getToolResultParts(message: ModelMessage): Array<Record<string, unknown>> {
|
||||
if (message.role !== 'tool' || !Array.isArray(message.content)) return [];
|
||||
return (message.content as unknown[]).filter((part) => {
|
||||
return isRecord(part) && part.type === 'tool-result';
|
||||
}) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function getToolResultText(part: Record<string, unknown>): string {
|
||||
const output = part.output;
|
||||
if (isRecord(output) && output.type === 'text' && typeof output.value === 'string') {
|
||||
return output.value;
|
||||
}
|
||||
if (typeof output === 'string') return output;
|
||||
return '';
|
||||
}
|
||||
|
||||
function isToolResultError(part: Record<string, unknown>, text: string): boolean {
|
||||
if (part.isError === true) return true;
|
||||
if (text.toLowerCase().includes('error')) return true;
|
||||
return isToolResultErrorOutput(part.output);
|
||||
}
|
||||
|
||||
function isToolResultErrorOutput(output: unknown): boolean {
|
||||
if (output == null) return false;
|
||||
if (typeof output === 'object') {
|
||||
const obj = output as Record<string, unknown>;
|
||||
if ('error' in obj && typeof obj.error === 'string') return true;
|
||||
if ('ok' in obj && obj.ok === false) return true;
|
||||
if (obj.type === 'json' || obj.type === 'object') {
|
||||
return isToolResultErrorOutput(obj.value);
|
||||
}
|
||||
}
|
||||
if (typeof output === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(output) as Record<string, unknown>;
|
||||
if ('error' in parsed && typeof parsed.error === 'string') return true;
|
||||
if ('ok' in parsed && parsed.ok === false) return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCachedOrSuperseded(text: string): boolean {
|
||||
return text.includes('[cached]')
|
||||
|| text.startsWith(SUPERSEDED_READ_PREFIX)
|
||||
|| text.startsWith(EARLIER_TERMINAL_PREFIX);
|
||||
}
|
||||
|
||||
function isSftpReadTool(toolName: string): boolean {
|
||||
return toolName === 'sftp_read'
|
||||
|| toolName === 'sftp.read'
|
||||
|| toolName === 'sftp_read_file';
|
||||
}
|
||||
|
||||
function isSafeReadOnlyToolName(toolName: string): boolean {
|
||||
const segments = toolName.toLowerCase().split(/[._-]+/);
|
||||
const readMarkers = new Set(['read', 'get', 'list', 'search', 'fetch', 'inspect', 'status', 'info', 'poll']);
|
||||
const writeMarkers = new Set([
|
||||
'write', 'set', 'update', 'create', 'delete', 'remove', 'start', 'stop', 'close',
|
||||
'execute', 'exec', 'run', 'upload', 'download', 'move', 'copy', 'rename', 'kill',
|
||||
]);
|
||||
return segments.some(segment => readMarkers.has(segment))
|
||||
&& !segments.some(segment => writeMarkers.has(segment));
|
||||
}
|
||||
|
||||
function readFingerprint(toolName: string, args: unknown): string | null {
|
||||
if (!isRecord(args)) return null;
|
||||
if (toolName === 'terminal_poll' || toolName === 'terminal.poll') {
|
||||
const jobId = args.jobId;
|
||||
if (typeof jobId !== 'string') return null;
|
||||
return `terminal-poll:${jobId}:${String(args.offset ?? 0)}`;
|
||||
}
|
||||
if (toolName === 'terminal_read_context' || toolName === 'terminal.read_context') {
|
||||
const sessionId = args.sessionId;
|
||||
if (typeof sessionId !== 'string') return null;
|
||||
return [
|
||||
'terminal-context',
|
||||
sessionId,
|
||||
String(args.range ?? 'viewport'),
|
||||
String(args.startLine ?? ''),
|
||||
String(args.maxLines ?? ''),
|
||||
].join(':');
|
||||
}
|
||||
if (isSftpReadTool(toolName)) {
|
||||
const path = args.path ?? args.remotePath;
|
||||
if (typeof path !== 'string') return null;
|
||||
const sessionId = args.sessionId;
|
||||
const sessionPart = typeof sessionId === 'string' ? sessionId : '';
|
||||
return `read:${sessionPart}:${path}`;
|
||||
}
|
||||
if (toolName === 'read_attachment' || toolName === 'harness.read_attachment') {
|
||||
const id = args.attachmentId ?? args.id ?? args.filename ?? args.name;
|
||||
return id != null ? `attachment:${String(id)}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function terminalFingerprint(toolName: string, args: unknown): string | null {
|
||||
if (toolName !== 'terminal_execute' && toolName !== 'terminal.execute') return null;
|
||||
if (!isRecord(args)) return null;
|
||||
const sessionId = args.sessionId;
|
||||
return typeof sessionId === 'string' ? `terminal:${sessionId}` : null;
|
||||
}
|
||||
|
||||
function replaceToolResultText(part: Record<string, unknown>, text: string): Record<string, unknown> {
|
||||
const output = part.output;
|
||||
if (isRecord(output) && output.type === 'text') {
|
||||
return { ...part, output: { ...output, value: text } };
|
||||
}
|
||||
return { ...part, output: { type: 'text', value: text } };
|
||||
}
|
||||
|
||||
function redactToolCallInputs(message: ModelMessage): ModelMessage {
|
||||
if (message.role !== 'assistant' || !Array.isArray(message.content)) return message;
|
||||
let changed = false;
|
||||
const content = (message.content as unknown[]).map(part => {
|
||||
if (!isRecord(part) || part.type !== 'tool-call' || !('input' in part)) return part;
|
||||
const redacted = redactSecretsInValueForModel(part.input);
|
||||
if (JSON.stringify(redacted) === JSON.stringify(part.input)) return part;
|
||||
changed = true;
|
||||
return { ...part, input: redacted };
|
||||
});
|
||||
return changed ? ({ ...message, content } as ModelMessage) : message;
|
||||
}
|
||||
|
||||
function compressMessageToolResults(
|
||||
message: ModelMessage,
|
||||
updater: (toolName: string, args: unknown, text: string, isError: boolean) => string | null,
|
||||
toolCallMap: Map<string, { toolName: string; input: unknown }>,
|
||||
): ModelMessage {
|
||||
const parts = getToolResultParts(message);
|
||||
if (parts.length === 0) return message;
|
||||
|
||||
let changed = false;
|
||||
const nextContent = (message.content as unknown[]).map((part) => {
|
||||
if (!isRecord(part) || part.type !== 'tool-result') return part;
|
||||
const toolCallId = typeof part.toolCallId === 'string' ? part.toolCallId : '';
|
||||
const meta = toolCallMap.get(toolCallId);
|
||||
const toolName = meta?.toolName ?? (typeof part.toolName === 'string' ? part.toolName : '');
|
||||
const args = meta?.input;
|
||||
const text = getToolResultText(part);
|
||||
const isError = isToolResultError(part, text);
|
||||
if (isCachedOrSuperseded(text)) return part;
|
||||
const replacement = updater(toolName, args, text, isError);
|
||||
if (replacement == null || replacement === text) return part;
|
||||
changed = true;
|
||||
return replaceToolResultText(part, replacement);
|
||||
});
|
||||
|
||||
return changed ? ({ ...message, content: nextContent } as ModelMessage) : message;
|
||||
}
|
||||
|
||||
export interface PruneStaleToolContextOptions {
|
||||
/** Supersede stale reads and omit older terminal output only under context budget pressure. */
|
||||
underBudgetPressure?: boolean;
|
||||
}
|
||||
|
||||
export function pruneStaleToolContext(
|
||||
messages: ModelMessage[],
|
||||
options: PruneStaleToolContextOptions = {},
|
||||
): {
|
||||
messages: ModelMessage[];
|
||||
didAdjust: boolean;
|
||||
} {
|
||||
const toolCallMap = getToolCallMap(messages);
|
||||
const latestReadByKey = new Map<string, number>();
|
||||
const terminalExecutionsBySession = new Map<string, Array<{ index: number; command?: string }>>();
|
||||
const underBudgetPressure = options.underBudgetPressure === true;
|
||||
const userTurnsAfter = new Array<number>(messages.length).fill(0);
|
||||
let laterUserTurns = 0;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
userTurnsAfter[index] = laterUserTurns;
|
||||
if (messages[index].role === 'user') laterUserTurns += 1;
|
||||
}
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
for (const part of getToolResultParts(message)) {
|
||||
const toolCallId = typeof part.toolCallId === 'string' ? part.toolCallId : '';
|
||||
const meta = toolCallMap.get(toolCallId);
|
||||
const toolName = meta?.toolName ?? (typeof part.toolName === 'string' ? part.toolName : '');
|
||||
const args = meta?.input;
|
||||
const readKey = readFingerprint(toolName, args);
|
||||
if (readKey) {
|
||||
const text = getToolResultText(part);
|
||||
const isError = isToolResultError(part, text);
|
||||
if (!isError) {
|
||||
latestReadByKey.set(readKey, index);
|
||||
}
|
||||
}
|
||||
const termKey = terminalFingerprint(toolName, args);
|
||||
if (underBudgetPressure && termKey) {
|
||||
const callArgs = isRecord(args) ? args : {};
|
||||
const entries = terminalExecutionsBySession.get(termKey) ?? [];
|
||||
entries.push({
|
||||
index,
|
||||
command: typeof callArgs.command === 'string' ? callArgs.command : undefined,
|
||||
});
|
||||
terminalExecutionsBySession.set(termKey, entries);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const keepTerminalIndices = new Set<number>();
|
||||
if (underBudgetPressure) {
|
||||
for (const entries of terminalExecutionsBySession.values()) {
|
||||
for (const entry of entries.slice(-2)) {
|
||||
keepTerminalIndices.add(entry.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
const terminalOmitByIndex = new Map<number, string>();
|
||||
if (underBudgetPressure) {
|
||||
for (const entries of terminalExecutionsBySession.values()) {
|
||||
for (const entry of entries) {
|
||||
if (keepTerminalIndices.has(entry.index)) continue;
|
||||
terminalOmitByIndex.set(
|
||||
entry.index,
|
||||
`${EARLIER_TERMINAL_PREFIX} command=${redactSecretsForModel(entry.command ?? 'unknown')}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let didAdjust = false;
|
||||
const next = messages.map((message, index) => {
|
||||
const redactedMessage = redactToolCallInputs(message);
|
||||
const updated = compressMessageToolResults(redactedMessage, (toolName, args, text, isError) => {
|
||||
if (isError) return null;
|
||||
const readKey = readFingerprint(toolName, args);
|
||||
if (underBudgetPressure && readKey) {
|
||||
const latestIndex = latestReadByKey.get(readKey);
|
||||
if (latestIndex != null && latestIndex !== index) {
|
||||
return `${SUPERSEDED_READ_PREFIX} ${readKey}]`;
|
||||
}
|
||||
}
|
||||
const termKey = terminalFingerprint(toolName, args);
|
||||
if (underBudgetPressure && termKey && terminalOmitByIndex.has(index)) {
|
||||
return terminalOmitByIndex.get(index)!;
|
||||
}
|
||||
if (underBudgetPressure) {
|
||||
const age = userTurnsAfter[index];
|
||||
if (age > 10 && !isError && isSafeReadOnlyToolName(toolName)) {
|
||||
return `[older tool result omitted: tool=${toolName || 'unknown'}, chars=${text.length}]`;
|
||||
}
|
||||
if (age >= 3 && text.length > 4_000) {
|
||||
return `${text.slice(0, 1_500)}\n\n[... tool result shortened: ${text.length - 3_000} chars omitted ...]\n\n${text.slice(-1_500)}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, toolCallMap);
|
||||
if (updated !== message) didAdjust = true;
|
||||
return updated;
|
||||
});
|
||||
|
||||
return { messages: next, didAdjust };
|
||||
}
|
||||
111
infrastructure/ai/harness/streamTimeouts.test.ts
Normal file
111
infrastructure/ai/harness/streamTimeouts.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildCattyStreamTimeouts } from './streamTimeouts';
|
||||
import { CATTY_APPROVAL_HARD_DEADLINE_MS } from '../shared/approvalConstants';
|
||||
import {
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
MAX_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeResponseIdleTimeoutSeconds,
|
||||
} from '../types';
|
||||
|
||||
describe('normalizeResponseIdleTimeoutSeconds', () => {
|
||||
it('keeps stored response wait values within the supported range', () => {
|
||||
assert.equal(normalizeResponseIdleTimeoutSeconds(Number.NaN), DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS);
|
||||
assert.equal(normalizeResponseIdleTimeoutSeconds(0), 1);
|
||||
assert.equal(
|
||||
normalizeResponseIdleTimeoutSeconds(MAX_RESPONSE_IDLE_TIMEOUT_SECONDS + 1),
|
||||
MAX_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCattyStreamTimeouts', () => {
|
||||
it('uses the configured response idle timeout for inactive model streams', () => {
|
||||
const responseIdleTimeoutMs = 12 * 60 * 1000;
|
||||
const timeouts = buildCattyStreamTimeouts({ responseIdleTimeoutMs });
|
||||
|
||||
assert.equal(timeouts.chunkMs, responseIdleTimeoutMs);
|
||||
assert.ok(timeouts.stepMs > responseIdleTimeoutMs);
|
||||
assert.ok(timeouts.totalMs != null);
|
||||
assert.ok(timeouts.totalMs > responseIdleTimeoutMs);
|
||||
});
|
||||
|
||||
it('scales the total stream budget so every step can use the configured response wait', () => {
|
||||
const responseIdleTimeoutMs = 20 * 60 * 1000;
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
responseIdleTimeoutMs,
|
||||
maxIterations: 3,
|
||||
});
|
||||
|
||||
assert.ok(timeouts.totalMs != null);
|
||||
assert.ok(timeouts.totalMs > responseIdleTimeoutMs * 3);
|
||||
});
|
||||
|
||||
it('keeps stream budgets from undercutting the configured command timeout', () => {
|
||||
const oneDayMs = 86_400 * 1000;
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
commandTimeoutMs: oneDayMs,
|
||||
});
|
||||
|
||||
assert.ok(timeouts.chunkMs > oneDayMs);
|
||||
assert.ok(timeouts.toolMs > oneDayMs);
|
||||
assert.ok(timeouts.stepMs > oneDayMs);
|
||||
assert.ok(timeouts.totalMs > oneDayMs);
|
||||
});
|
||||
|
||||
it('budgets sequential response waiting and command execution in each step', () => {
|
||||
const responseIdleTimeoutMs = 20 * 60 * 1000;
|
||||
const commandTimeoutMs = 10 * 60 * 1000;
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
responseIdleTimeoutMs,
|
||||
commandTimeoutMs,
|
||||
maxIterations: 2,
|
||||
});
|
||||
|
||||
assert.ok(timeouts.stepMs > responseIdleTimeoutMs + commandTimeoutMs);
|
||||
assert.ok(timeouts.totalMs != null);
|
||||
assert.ok(timeouts.totalMs >= timeouts.stepMs * 2);
|
||||
});
|
||||
|
||||
it('includes confirm-mode hard approval deadline in long command stream budgets', () => {
|
||||
const commandTimeoutMs = 60 * 1000;
|
||||
const expectedMinimum = commandTimeoutMs + CATTY_APPROVAL_HARD_DEADLINE_MS + (90 * 1000);
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
permissionMode: 'confirm',
|
||||
commandTimeoutMs,
|
||||
});
|
||||
|
||||
assert.ok(timeouts.chunkMs >= expectedMinimum);
|
||||
assert.ok(timeouts.toolMs >= expectedMinimum);
|
||||
assert.ok(timeouts.stepMs >= expectedMinimum);
|
||||
assert.ok(timeouts.totalMs >= expectedMinimum);
|
||||
});
|
||||
|
||||
it('scales the total stream budget for multi-step long command turns', () => {
|
||||
const commandTimeoutMs = 10 * 60 * 1000;
|
||||
const singleStepBudgetMs = commandTimeoutMs + CATTY_APPROVAL_HARD_DEADLINE_MS + (90 * 1000);
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
permissionMode: 'confirm',
|
||||
commandTimeoutMs,
|
||||
maxIterations: 2,
|
||||
});
|
||||
|
||||
assert.ok(timeouts.chunkMs >= singleStepBudgetMs);
|
||||
assert.ok(timeouts.toolMs >= singleStepBudgetMs);
|
||||
assert.ok(timeouts.stepMs >= singleStepBudgetMs);
|
||||
assert.ok(timeouts.totalMs != null);
|
||||
assert.ok(timeouts.totalMs >= singleStepBudgetMs * 2);
|
||||
});
|
||||
|
||||
it('omits total timeout when the multi-step budget exceeds timer limits', () => {
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
commandTimeoutMs: 86_400 * 1000,
|
||||
maxIterations: 100,
|
||||
});
|
||||
|
||||
assert.equal(timeouts.totalMs, undefined);
|
||||
assert.ok(timeouts.chunkMs > 86_400 * 1000);
|
||||
assert.ok(timeouts.toolMs > 86_400 * 1000);
|
||||
assert.ok(timeouts.stepMs > 86_400 * 1000);
|
||||
});
|
||||
});
|
||||
54
infrastructure/ai/harness/streamTimeouts.ts
Normal file
54
infrastructure/ai/harness/streamTimeouts.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import { CATTY_APPROVAL_HARD_DEADLINE_MS } from '../shared/approvalConstants';
|
||||
|
||||
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||
const TWO_MINUTES_MS = 2 * 60 * 1000;
|
||||
const NINETY_SECONDS_MS = 90 * 1000;
|
||||
const COMPACTION_TIMEOUT_MS = 90 * 1000;
|
||||
const MAX_ABORT_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
export interface BuildCattyStreamTimeoutsInput {
|
||||
permissionMode?: AIPermissionMode;
|
||||
commandTimeoutMs?: number;
|
||||
responseIdleTimeoutMs?: number;
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
/** v7 streamText timeout profile for Catty multi-step agent turns. */
|
||||
export function buildCattyStreamTimeouts(
|
||||
input: BuildCattyStreamTimeoutsInput = {},
|
||||
) {
|
||||
// Budget the hard approval deadline so a mid-review re-arm is not cut off by toolMs.
|
||||
const approvalBudgetMs = input.permissionMode === 'confirm' ? CATTY_APPROVAL_HARD_DEADLINE_MS : 0;
|
||||
const stepCount =
|
||||
Number.isFinite(input.maxIterations) && input.maxIterations != null && input.maxIterations > 0
|
||||
? Math.max(1, Math.floor(input.maxIterations))
|
||||
: 1;
|
||||
const commandTimeoutBudgetMs =
|
||||
Number.isFinite(input.commandTimeoutMs) && input.commandTimeoutMs > 0
|
||||
? input.commandTimeoutMs + approvalBudgetMs + NINETY_SECONDS_MS
|
||||
: 0;
|
||||
const responseIdleTimeoutMs =
|
||||
Number.isFinite(input.responseIdleTimeoutMs) && input.responseIdleTimeoutMs > 0
|
||||
? input.responseIdleTimeoutMs
|
||||
: TWO_MINUTES_MS;
|
||||
const responseStepBudgetMs = responseIdleTimeoutMs + NINETY_SECONDS_MS;
|
||||
const stepBudgetMs = Math.max(
|
||||
TEN_MINUTES_MS,
|
||||
responseStepBudgetMs + commandTimeoutBudgetMs,
|
||||
);
|
||||
const totalBudgetMs = Math.max(THIRTY_MINUTES_MS, stepBudgetMs * stepCount);
|
||||
const totalMs = totalBudgetMs <= MAX_ABORT_TIMEOUT_MS ? totalBudgetMs : undefined;
|
||||
return {
|
||||
totalMs,
|
||||
stepMs: stepBudgetMs,
|
||||
chunkMs: Math.max(responseIdleTimeoutMs, commandTimeoutBudgetMs),
|
||||
toolMs: Math.max(CATTY_APPROVAL_HARD_DEADLINE_MS + NINETY_SECONDS_MS, commandTimeoutBudgetMs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shorter timeout for LLM compaction summarize calls. */
|
||||
export function buildCattyCompactionTimeout() {
|
||||
return COMPACTION_TIMEOUT_MS;
|
||||
}
|
||||
100
infrastructure/ai/harness/terminalCompression.ts
Normal file
100
infrastructure/ai/harness/terminalCompression.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { compressVerboseText, truncateTextWithHeadAndTail } from '../requestPayloadCompression';
|
||||
import type { ToolOutputStore } from './toolOutputStore';
|
||||
import { redactSecretsForModel } from './modelSecretRedaction';
|
||||
|
||||
export const MAX_LIVE_TERMINAL_STDOUT_CHARS = 24_000;
|
||||
export const MAX_LIVE_TERMINAL_STDERR_CHARS = 12_000;
|
||||
|
||||
export interface TerminalExecuteResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
command?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface TerminalOutputHandle {
|
||||
kind: 'terminal-output';
|
||||
sessionId: string;
|
||||
command?: string;
|
||||
totalStdoutChars: number;
|
||||
totalStderrChars: number;
|
||||
handleId?: string;
|
||||
restartPersistenceAvailable?: boolean;
|
||||
}
|
||||
|
||||
export interface FitTerminalExecuteResultOptions {
|
||||
chatSessionId?: string;
|
||||
toolOutputStore?: ToolOutputStore;
|
||||
}
|
||||
|
||||
export function fitTerminalExecuteResultForModel(
|
||||
result: TerminalExecuteResult,
|
||||
options?: FitTerminalExecuteResultOptions,
|
||||
): TerminalExecuteResult {
|
||||
const stdout = truncateTextWithHeadAndTail(
|
||||
redactSecretsForModel(compressVerboseText(result.stdout)),
|
||||
MAX_LIVE_TERMINAL_STDOUT_CHARS,
|
||||
);
|
||||
const stderr = truncateTextWithHeadAndTail(
|
||||
redactSecretsForModel(compressVerboseText(result.stderr)),
|
||||
MAX_LIVE_TERMINAL_STDERR_CHARS,
|
||||
);
|
||||
|
||||
const fitted: TerminalExecuteResult = {
|
||||
...result,
|
||||
command: result.command ? redactSecretsForModel(result.command) : result.command,
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
|
||||
if (
|
||||
stdout.length < result.stdout.length
|
||||
|| stderr.length < result.stderr.length
|
||||
) {
|
||||
const fullContent = [
|
||||
result.command ? `command: ${result.command}` : '',
|
||||
result.stdout ? `stdout:\n${result.stdout}` : '',
|
||||
result.stderr ? `stderr:\n${result.stderr}` : '',
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
let handleId: string | undefined;
|
||||
if (options?.toolOutputStore && options.chatSessionId && fullContent) {
|
||||
handleId = options.toolOutputStore.store({
|
||||
chatSessionId: options.chatSessionId,
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: result.sessionId,
|
||||
content: fullContent,
|
||||
}).id;
|
||||
}
|
||||
|
||||
const handle: TerminalOutputHandle = {
|
||||
kind: 'terminal-output',
|
||||
sessionId: result.sessionId ?? 'unknown',
|
||||
command: result.command ? redactSecretsForModel(result.command) : result.command,
|
||||
totalStdoutChars: result.stdout.length,
|
||||
totalStderrChars: result.stderr.length,
|
||||
handleId,
|
||||
restartPersistenceAvailable: false,
|
||||
};
|
||||
fitted.stdout = appendOutputHandleNotice(stdout, handle, 'stdout');
|
||||
if (result.stderr) {
|
||||
fitted.stderr = appendOutputHandleNotice(stderr, handle, 'stderr');
|
||||
}
|
||||
}
|
||||
|
||||
return fitted;
|
||||
}
|
||||
|
||||
function appendOutputHandleNotice(
|
||||
truncated: string,
|
||||
handle: TerminalOutputHandle,
|
||||
stream: 'stdout' | 'stderr',
|
||||
): string {
|
||||
const totalChars = stream === 'stdout' ? handle.totalStdoutChars : handle.totalStderrChars;
|
||||
const handleSuffix = handle.handleId ? ` handleId=${handle.handleId}` : '';
|
||||
const restartSuffix = handle.handleId && handle.restartPersistenceAvailable === false
|
||||
? ' restartPersistence=unavailable (read before closing the app)'
|
||||
: '';
|
||||
return `${truncated}\n\n[output handle: session=${handle.sessionId}${handle.command ? ` command=${handle.command}` : ''} ${stream}=${totalChars} chars truncated for model context${handleSuffix}${restartSuffix}]`;
|
||||
}
|
||||
75
infrastructure/ai/harness/terminalMonitorGuard.test.ts
Normal file
75
infrastructure/ai/harness/terminalMonitorGuard.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isStreamingMonitorCommand, TerminalMonitorGuard } from './terminalMonitorGuard';
|
||||
import { applyMonitorStopResult } from './capabilityTools';
|
||||
|
||||
test('TerminalMonitorGuard bounds lines and batches', () => {
|
||||
const guard = new TerminalMonitorGuard();
|
||||
const result = guard.process('chat:job', `${'x'.repeat(800)}\n${'line\n'.repeat(1_000)}`);
|
||||
|
||||
assert.equal(result.action, 'deliver');
|
||||
assert.ok((result.content?.length ?? Infinity) <= 3_000);
|
||||
assert.ok((result.content?.split('\n')[0].length ?? Infinity) <= 500);
|
||||
});
|
||||
|
||||
test('TerminalMonitorGuard suppresses bursts and stops a sustained overload', () => {
|
||||
let now = 0;
|
||||
const guard = new TerminalMonitorGuard({ now: () => now });
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
assert.equal(guard.process('chat:job', `line ${index}`).action, 'deliver');
|
||||
}
|
||||
assert.equal(guard.process('chat:job', 'burst').action, 'suppress');
|
||||
let action = guard.process('chat:job', 'still flooding').action;
|
||||
for (now = 1_000; now <= 31_000 && action !== 'stop'; now += 1_000) {
|
||||
action = guard.process('chat:job', 'still flooding').action;
|
||||
}
|
||||
assert.equal(action, 'stop');
|
||||
});
|
||||
|
||||
test('monitor stop result does not claim success when the backend stop failed', () => {
|
||||
const failed = applyMonitorStopResult(
|
||||
{ jobId: 'job-1', status: 'running' },
|
||||
{ ok: false, error: 'lost worker' },
|
||||
12,
|
||||
);
|
||||
assert.equal(failed.status, 'running');
|
||||
assert.match(String(failed.output), /stop failed/);
|
||||
assert.match(String(failed.output), /may still be running/);
|
||||
|
||||
const accepted = applyMonitorStopResult(
|
||||
{ jobId: 'job-1', status: 'running' },
|
||||
{ ok: true },
|
||||
12,
|
||||
);
|
||||
assert.equal(accepted.status, 'stopping');
|
||||
assert.match(String(accepted.output), /stop requested/);
|
||||
});
|
||||
|
||||
test('isStreamingMonitorCommand requires an actual follow option', () => {
|
||||
assert.equal(isStreamingMonitorCommand('tail app-file.log'), false);
|
||||
assert.equal(isStreamingMonitorCommand('tail -n 10 foo.log'), false);
|
||||
assert.equal(isStreamingMonitorCommand('journalctl --since 5m foo'), false);
|
||||
assert.equal(isStreamingMonitorCommand('tail -f app.log'), true);
|
||||
assert.equal(isStreamingMonitorCommand('tail -n0F app.log'), true);
|
||||
assert.equal(isStreamingMonitorCommand('journalctl --follow -u nginx'), true);
|
||||
assert.equal(isStreamingMonitorCommand('docker logs -f api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('kubectl logs --follow pod/api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('cd /srv && watch npm test'), true);
|
||||
});
|
||||
|
||||
test('isStreamingMonitorCommand recognizes common wrappers and compose logs', () => {
|
||||
assert.equal(isStreamingMonitorCommand('sudo journalctl -f -u nginx'), true);
|
||||
assert.equal(isStreamingMonitorCommand('sudo -u root tail -f /var/log/syslog'), true);
|
||||
assert.equal(isStreamingMonitorCommand('env LANG=C tail --follow app.log'), true);
|
||||
assert.equal(isStreamingMonitorCommand('stdbuf -oL kubectl logs -f pod/api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('timeout 60s tail -f app.log'), true);
|
||||
assert.equal(isStreamingMonitorCommand('docker compose logs -f api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('sudo -H journalctl -f -u nginx'), true);
|
||||
assert.equal(isStreamingMonitorCommand('kubectl -n production logs -f pod/api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('kubectl --context prod logs --follow pod/api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('docker --context prod logs -f api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('docker compose -f compose.prod.yml logs -f api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('docker compose -p demo logs -f api'), true);
|
||||
assert.equal(isStreamingMonitorCommand('sudo tail -n 10 app.log'), false);
|
||||
assert.equal(isStreamingMonitorCommand('timeout 60s journalctl --since 5m'), false);
|
||||
});
|
||||
208
infrastructure/ai/harness/terminalMonitorGuard.ts
Normal file
208
infrastructure/ai/harness/terminalMonitorGuard.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { compressVerboseText } from '../requestPayloadCompression';
|
||||
|
||||
const MONITOR_LINE_MAX_CHARS = 500;
|
||||
const MONITOR_BATCH_MAX_CHARS = 3_000;
|
||||
const MONITOR_BURST = 10;
|
||||
const MONITOR_REFILL_MS = 2_000;
|
||||
const MONITOR_OVERLOAD_STOP_MS = 30_000;
|
||||
|
||||
interface MonitorState {
|
||||
tokens: number;
|
||||
lastRefillAt: number;
|
||||
overloadedAt?: number;
|
||||
lastSuppressedAt?: number;
|
||||
suppressedCount: number;
|
||||
}
|
||||
|
||||
export type MonitorGuardResult =
|
||||
| { action: 'deliver'; content: string; suppressedCount: number; sourceTruncated: boolean }
|
||||
| { action: 'suppress'; suppressedCount: number }
|
||||
| { action: 'stop'; suppressedCount: number };
|
||||
|
||||
export class TerminalMonitorGuard {
|
||||
private readonly states = new Map<string, MonitorState>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: { now?: () => number } = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
process(key: string, output: string): MonitorGuardResult {
|
||||
const now = this.now();
|
||||
const state = this.states.get(key) ?? {
|
||||
tokens: MONITOR_BURST,
|
||||
lastRefillAt: now,
|
||||
suppressedCount: 0,
|
||||
};
|
||||
if (state.lastSuppressedAt != null && now - state.lastSuppressedAt > MONITOR_REFILL_MS * 2) {
|
||||
state.overloadedAt = undefined;
|
||||
state.lastSuppressedAt = undefined;
|
||||
state.suppressedCount = 0;
|
||||
}
|
||||
if (state.overloadedAt != null && now - state.overloadedAt >= MONITOR_OVERLOAD_STOP_MS) {
|
||||
this.states.delete(key);
|
||||
return { action: 'stop', suppressedCount: state.suppressedCount + 1 };
|
||||
}
|
||||
|
||||
const refill = Math.floor((now - state.lastRefillAt) / MONITOR_REFILL_MS);
|
||||
if (refill > 0) {
|
||||
state.tokens = Math.min(MONITOR_BURST, state.tokens + refill);
|
||||
state.lastRefillAt += refill * MONITOR_REFILL_MS;
|
||||
}
|
||||
if (state.tokens <= 0) {
|
||||
state.suppressedCount += 1;
|
||||
state.overloadedAt ??= now;
|
||||
state.lastSuppressedAt = now;
|
||||
this.states.set(key, state);
|
||||
return { action: 'suppress', suppressedCount: state.suppressedCount };
|
||||
}
|
||||
|
||||
state.tokens -= 1;
|
||||
const suppressedCount = state.suppressedCount;
|
||||
state.suppressedCount = 0;
|
||||
this.states.set(key, state);
|
||||
const prefix = suppressedCount > 0 ? `[${suppressedCount} monitor batches suppressed]\n` : '';
|
||||
const fitted = fitMonitorBatch(`${prefix}${output}`);
|
||||
return {
|
||||
action: 'deliver',
|
||||
content: fitted.content,
|
||||
suppressedCount,
|
||||
sourceTruncated: fitted.sourceTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
this.states.delete(key);
|
||||
}
|
||||
|
||||
clearPrefix(prefix: string): void {
|
||||
for (const key of this.states.keys()) {
|
||||
if (key.startsWith(prefix)) this.states.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isStreamingMonitorCommand(command: unknown): boolean {
|
||||
if (typeof command !== 'string') return false;
|
||||
return command.split(/[;&|]+/).some((rawSegment) => {
|
||||
const tokens = unwrapMonitorCommandPrefixes(tokenizeCommandSegment(rawSegment));
|
||||
if (tokens.length === 0) return false;
|
||||
if (tokens[0]?.toLowerCase() === 'watch') return true;
|
||||
|
||||
const argsStart = findMonitorArgsStart(tokens);
|
||||
if (argsStart < 0) return false;
|
||||
return tokens.slice(argsStart).some(arg => /^--follow(?:=.+)?$/i.test(arg) || /^-[^-\s]*[fF]/.test(arg));
|
||||
});
|
||||
}
|
||||
|
||||
function findMonitorArgsStart(tokens: string[]): number {
|
||||
const command = tokens[0]?.toLowerCase();
|
||||
if (command === 'tail' || command === 'journalctl') return 1;
|
||||
if (command === 'kubectl') {
|
||||
const logsIndex = skipCommandOptions(tokens, 1, new Set([
|
||||
'-n', '--namespace', '--context', '--kubeconfig', '--cluster', '--user',
|
||||
'--request-timeout', '-s', '--server', '--token', '--as', '--as-group',
|
||||
'--cache-dir', '--certificate-authority', '--client-certificate', '--client-key',
|
||||
'--tls-server-name',
|
||||
]));
|
||||
return tokens[logsIndex]?.toLowerCase() === 'logs' ? logsIndex + 1 : -1;
|
||||
}
|
||||
if (command === 'docker') {
|
||||
const subcommandIndex = skipCommandOptions(tokens, 1, new Set([
|
||||
'--config', '-c', '--context', '-H', '--host', '-l', '--log-level',
|
||||
]));
|
||||
const subcommand = tokens[subcommandIndex]?.toLowerCase();
|
||||
if (subcommand === 'logs') return subcommandIndex + 1;
|
||||
if (subcommand !== 'compose') return -1;
|
||||
const logsIndex = skipCommandOptions(tokens, subcommandIndex + 1, new Set([
|
||||
'-f', '--file', '-p', '--project-name', '--profile', '--project-directory',
|
||||
'--env-file', '--parallel', '--progress', '--ansi',
|
||||
]));
|
||||
return tokens[logsIndex]?.toLowerCase() === 'logs' ? logsIndex + 1 : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function skipCommandOptions(tokens: string[], start: number, optionsWithValues: ReadonlySet<string>): number {
|
||||
let index = start;
|
||||
while (tokens[index]?.startsWith('-')) {
|
||||
const option = tokens[index]!;
|
||||
index += 1;
|
||||
if (option === '--') break;
|
||||
const optionName = canonicalOptionName(option);
|
||||
if (optionsWithValues.has(optionName) && !option.includes('=') && index < tokens.length) index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function tokenizeCommandSegment(segment: string): string[] {
|
||||
return segment.trim().match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/g) ?? [];
|
||||
}
|
||||
|
||||
function unwrapMonitorCommandPrefixes(input: string[]): string[] {
|
||||
const tokens = [...input];
|
||||
for (let pass = 0; pass < 8 && tokens.length > 0; pass += 1) {
|
||||
const wrapper = tokens[0]?.toLowerCase();
|
||||
if (wrapper === 'sudo') {
|
||||
tokens.shift();
|
||||
consumeWrapperOptions(tokens, new Set([
|
||||
'-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt',
|
||||
'-C', '--close-from', '-R', '--chroot', '-D', '--chdir', '-T', '--command-timeout',
|
||||
'-r', '--role', '-t', '--type',
|
||||
]));
|
||||
continue;
|
||||
}
|
||||
if (wrapper === 'env') {
|
||||
tokens.shift();
|
||||
consumeWrapperOptions(tokens, new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']));
|
||||
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
|
||||
continue;
|
||||
}
|
||||
if (wrapper === 'stdbuf') {
|
||||
tokens.shift();
|
||||
consumeWrapperOptions(tokens, new Set(['-i', '--input', '-o', '--output', '-e', '--error']));
|
||||
continue;
|
||||
}
|
||||
if (wrapper === 'timeout') {
|
||||
tokens.shift();
|
||||
consumeWrapperOptions(tokens, new Set(['-k', '--kill-after', '-s', '--signal']));
|
||||
if (tokens.length > 0) tokens.shift();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function consumeWrapperOptions(tokens: string[], optionsWithValues: ReadonlySet<string>): void {
|
||||
while (tokens[0]?.startsWith('-')) {
|
||||
const option = tokens.shift()!;
|
||||
if (option === '--') break;
|
||||
const optionName = canonicalOptionName(option);
|
||||
if (optionsWithValues.has(optionName) && !option.includes('=') && tokens.length > 0) tokens.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalOptionName(option: string): string {
|
||||
const optionName = option.split('=', 1)[0]!;
|
||||
return optionName.startsWith('--') ? optionName.toLowerCase() : optionName;
|
||||
}
|
||||
|
||||
function fitMonitorBatch(output: string): { content: string; sourceTruncated: boolean } {
|
||||
const normalized = compressVerboseText(output);
|
||||
let sourceTruncated = false;
|
||||
const lines = normalized.split('\n').map(line => {
|
||||
if (line.length <= MONITOR_LINE_MAX_CHARS) return line;
|
||||
sourceTruncated = true;
|
||||
return `${line.slice(0, MONITOR_LINE_MAX_CHARS - 28)}[... line shortened ...]`;
|
||||
});
|
||||
const content = lines.join('\n');
|
||||
if (content.length <= MONITOR_BATCH_MAX_CHARS) return { content, sourceTruncated };
|
||||
const marker = '\n[... monitor batch shortened ...]';
|
||||
return {
|
||||
content: `${content.slice(0, MONITOR_BATCH_MAX_CHARS - marker.length)}${marker}`,
|
||||
sourceTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const globalTerminalMonitorGuard = new TerminalMonitorGuard();
|
||||
81
infrastructure/ai/harness/tokenEstimator.ts
Normal file
81
infrastructure/ai/harness/tokenEstimator.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type { TokenEstimatorKind } from './types';
|
||||
|
||||
const CHARS_PER_TOKEN_FALLBACK = 4;
|
||||
|
||||
function estimateChars(value: unknown): number {
|
||||
if (value == null) return 0;
|
||||
if (typeof value === 'string') return value.length;
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce((sum, item) => sum + estimateChars(item), 0);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value).length;
|
||||
} catch {
|
||||
return String(value).length;
|
||||
}
|
||||
}
|
||||
return String(value).length;
|
||||
}
|
||||
|
||||
export function resolveEstimatorKind(providerId?: string | null): TokenEstimatorKind {
|
||||
const id = (providerId ?? '').toLowerCase();
|
||||
if (id.includes('openai') || id.includes('gpt')) return 'openai-heuristic';
|
||||
if (id.includes('anthropic') || id.includes('claude')) return 'anthropic-heuristic';
|
||||
if (id.includes('google') || id.includes('gemini')) return 'google-heuristic';
|
||||
return 'chars-div-4';
|
||||
}
|
||||
|
||||
function applyHeuristicMultiplier(chars: number, kind: TokenEstimatorKind): number {
|
||||
switch (kind) {
|
||||
case 'openai-heuristic':
|
||||
return Math.ceil(chars / 3.5);
|
||||
case 'anthropic-heuristic':
|
||||
return Math.ceil(chars / 3.2);
|
||||
case 'google-heuristic':
|
||||
return Math.ceil(chars / 3.8);
|
||||
default:
|
||||
return Math.ceil(chars / CHARS_PER_TOKEN_FALLBACK);
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateTextTokens(text: string, providerId?: string | null): number {
|
||||
const kind = resolveEstimatorKind(providerId);
|
||||
return applyHeuristicMultiplier(text.length, kind);
|
||||
}
|
||||
|
||||
export function estimateUnknownTokens(value: unknown, providerId?: string | null): number {
|
||||
const kind = resolveEstimatorKind(providerId);
|
||||
return applyHeuristicMultiplier(estimateChars(value), kind);
|
||||
}
|
||||
|
||||
export interface EstimateModelMessagesTokensInput {
|
||||
messages: ModelMessage[];
|
||||
providerId?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateModelMessagesTokensResult {
|
||||
tokens: number;
|
||||
estimatorKind: TokenEstimatorKind;
|
||||
}
|
||||
|
||||
export function estimateModelMessagesTokensWithKind(
|
||||
input: EstimateModelMessagesTokensInput,
|
||||
): EstimateModelMessagesTokensResult {
|
||||
const estimatorKind = resolveEstimatorKind(input.providerId);
|
||||
const chars = input.messages.reduce((total, message) => {
|
||||
return total + estimateChars(message.role) + estimateChars(message.content);
|
||||
}, 0);
|
||||
return {
|
||||
tokens: applyHeuristicMultiplier(chars, estimatorKind),
|
||||
estimatorKind,
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateModelMessagesTokens(
|
||||
messages: ModelMessage[],
|
||||
providerId?: string | null,
|
||||
): number {
|
||||
return estimateModelMessagesTokensWithKind({ messages, providerId }).tokens;
|
||||
}
|
||||
91
infrastructure/ai/harness/toolMessageIntegrity.test.ts
Normal file
91
infrastructure/ai/harness/toolMessageIntegrity.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { repairToolMessageIntegrity } from './toolMessageIntegrity';
|
||||
|
||||
test('repairToolMessageIntegrity drops orphan and duplicate tool results', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'terminal_poll', input: {} }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: 'call-1', toolName: 'terminal_poll', output: { type: 'text', value: 'first' } },
|
||||
{ type: 'tool-result', toolCallId: 'orphan', toolName: 'terminal_poll', output: { type: 'text', value: 'orphan' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{ type: 'tool-result', toolCallId: 'call-1', toolName: 'terminal_poll', output: { type: 'text', value: 'duplicate' } }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = repairToolMessageIntegrity(messages);
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
assert.equal(result.didAdjust, true);
|
||||
assert.match(serialized, /first/);
|
||||
assert.doesNotMatch(serialized, /orphan|duplicate/);
|
||||
});
|
||||
|
||||
test('repairToolMessageIntegrity completes interrupted tool calls with a synthetic result', () => {
|
||||
const messages: ModelMessage[] = [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'terminal_execute', input: { command: 'deploy' } }],
|
||||
}];
|
||||
|
||||
const result = repairToolMessageIntegrity(messages);
|
||||
assert.equal(result.messages.length, 2);
|
||||
assert.match(JSON.stringify(result.messages[1]), /interrupted before a result was recorded/);
|
||||
});
|
||||
|
||||
test('repairToolMessageIntegrity pairs reused tool call ids by occurrence order', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'reused', toolName: 'terminal_poll', input: { jobId: 'first' } }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{ type: 'tool-result', toolCallId: 'reused', toolName: 'terminal_poll', output: { type: 'text', value: 'first result' } }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'reused', toolName: 'terminal_poll', input: { jobId: 'second' } }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{ type: 'tool-result', toolCallId: 'reused', toolName: 'terminal_poll', output: { type: 'text', value: 'second result' } }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = repairToolMessageIntegrity(messages);
|
||||
assert.equal(result.didAdjust, false);
|
||||
assert.equal(result.messages, messages);
|
||||
assert.match(JSON.stringify(result.messages), /first result/);
|
||||
assert.match(JSON.stringify(result.messages), /second result/);
|
||||
});
|
||||
|
||||
test('repairToolMessageIntegrity pairs a reused id with the nearest preceding call', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'reused', toolName: 'terminal_execute', input: { command: 'old' } }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'reused', toolName: 'terminal_execute', input: { command: 'new' } }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{ type: 'tool-result', toolCallId: 'reused', toolName: 'terminal_execute', output: { type: 'text', value: 'new result' } }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = repairToolMessageIntegrity(messages);
|
||||
assert.equal(result.didAdjust, true);
|
||||
assert.deepEqual(result.messages.map(message => message.role), ['assistant', 'tool', 'assistant', 'tool']);
|
||||
assert.match(JSON.stringify(result.messages[1]), /interrupted before a result was recorded/);
|
||||
assert.match(JSON.stringify(result.messages[3]), /new result/);
|
||||
});
|
||||
84
infrastructure/ai/harness/toolMessageIntegrity.ts
Normal file
84
infrastructure/ai/harness/toolMessageIntegrity.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function repairToolMessageIntegrity(messages: ModelMessage[]): {
|
||||
messages: ModelMessage[];
|
||||
didAdjust: boolean;
|
||||
} {
|
||||
const pendingCalls = new Map<string, Array<Record<string, unknown>>>();
|
||||
const matchedCalls = new Set<Record<string, unknown>>();
|
||||
let didAdjust = false;
|
||||
const sanitized: ModelMessage[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === 'assistant' && Array.isArray(message.content)) {
|
||||
for (const part of message.content as unknown[]) {
|
||||
if (!isRecord(part) || part.type !== 'tool-call' || typeof part.toolCallId !== 'string') continue;
|
||||
const pending = pendingCalls.get(part.toolCallId) ?? [];
|
||||
pending.push(part);
|
||||
pendingCalls.set(part.toolCallId, pending);
|
||||
}
|
||||
sanitized.push(message);
|
||||
continue;
|
||||
}
|
||||
if (message.role !== 'tool' || !Array.isArray(message.content)) {
|
||||
sanitized.push(message);
|
||||
continue;
|
||||
}
|
||||
const content = (message.content as unknown[]).filter(part => {
|
||||
if (!isRecord(part) || part.type !== 'tool-result' || typeof part.toolCallId !== 'string') {
|
||||
return true;
|
||||
}
|
||||
const pending = pendingCalls.get(part.toolCallId);
|
||||
// Reused provider IDs are paired with the nearest preceding unresolved
|
||||
// call. This also handles an older interrupted call followed by a new
|
||||
// call that reused the same ID.
|
||||
const matchingCall = pending?.pop();
|
||||
if (!matchingCall) {
|
||||
didAdjust = true;
|
||||
return false;
|
||||
}
|
||||
matchedCalls.add(matchingCall);
|
||||
return true;
|
||||
});
|
||||
if (content.length === 0) {
|
||||
didAdjust = true;
|
||||
continue;
|
||||
}
|
||||
sanitized.push(content.length === message.content.length ? message : {
|
||||
...message,
|
||||
content,
|
||||
} as ModelMessage);
|
||||
}
|
||||
|
||||
const repaired: ModelMessage[] = [];
|
||||
for (const message of sanitized) {
|
||||
repaired.push(message);
|
||||
if (message.role !== 'assistant' || !Array.isArray(message.content)) continue;
|
||||
const missing = (message.content as unknown[]).filter(part => (
|
||||
isRecord(part)
|
||||
&& part.type === 'tool-call'
|
||||
&& typeof part.toolCallId === 'string'
|
||||
&& !matchedCalls.has(part)
|
||||
)) as Array<Record<string, unknown>>;
|
||||
if (missing.length === 0) continue;
|
||||
repaired.push({
|
||||
role: 'tool',
|
||||
content: missing.map(part => ({
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: String(part.toolCallId),
|
||||
toolName: typeof part.toolName === 'string' ? part.toolName : 'unknown',
|
||||
output: {
|
||||
type: 'text' as const,
|
||||
value: '[Tool call interrupted before a result was recorded. Do not assume it succeeded or repeat a write automatically; verify current state first.]',
|
||||
},
|
||||
isError: true,
|
||||
})),
|
||||
});
|
||||
didAdjust = true;
|
||||
}
|
||||
|
||||
return { messages: didAdjust ? repaired : messages, didAdjust };
|
||||
}
|
||||
988
infrastructure/ai/harness/toolOutputStore.test.ts
Normal file
988
infrastructure/ai/harness/toolOutputStore.test.ts
Normal file
@@ -0,0 +1,988 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS,
|
||||
TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS,
|
||||
TOOL_OUTPUT_READ_MAX_CHARS,
|
||||
type PersistedToolOutputRecord,
|
||||
type ToolOutputPersistence,
|
||||
ToolOutputStore,
|
||||
} from './toolOutputStore';
|
||||
import { ToolResultDedup } from './toolResultDedup';
|
||||
|
||||
test('ToolOutputStore stores and reads truncated output by handle', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'sess-1',
|
||||
content: 'A'.repeat(50_000),
|
||||
});
|
||||
|
||||
assert.ok(handle.id.startsWith('tool-output-'));
|
||||
assert.equal(handle.totalChars, 50_000);
|
||||
|
||||
const head = store.read({ handleId: handle.id, mode: 'head', maxChars: 100 }, 'chat-1');
|
||||
assert.equal(head?.length, 100);
|
||||
|
||||
const tail = store.read({ handleId: handle.id, mode: 'tail', maxChars: 50 }, 'chat-1');
|
||||
assert.equal(tail?.length, 50);
|
||||
assert.equal(tail, 'A'.repeat(50));
|
||||
|
||||
store.prune('chat-1');
|
||||
assert.equal(store.read({ handleId: handle.id }, 'chat-1'), null);
|
||||
});
|
||||
|
||||
test('ToolOutputStore pages large output with a hard per-read cap', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const content = `${'0123456789'.repeat(3_000)}END`;
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
content,
|
||||
});
|
||||
|
||||
const first = store.readChunk({
|
||||
handleId: handle.id,
|
||||
mode: 'range',
|
||||
maxChars: content.length,
|
||||
}, 'chat-1');
|
||||
assert.equal(first?.content.length, TOOL_OUTPUT_READ_MAX_CHARS);
|
||||
assert.equal(first?.nextOffset, TOOL_OUTPUT_READ_MAX_CHARS);
|
||||
assert.equal(first?.hasMore, true);
|
||||
|
||||
const second = store.readChunk({
|
||||
handleId: handle.id,
|
||||
mode: 'range',
|
||||
offset: first?.nextOffset,
|
||||
}, 'chat-1');
|
||||
assert.equal(second?.startOffset, first?.nextOffset);
|
||||
});
|
||||
|
||||
test('ToolOutputStore searches stored output without returning the whole body', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
content: `${'noise\n'.repeat(10_000)}Unique Failure Marker\n${'more noise\n'.repeat(10_000)}`,
|
||||
});
|
||||
|
||||
const result = store.readChunk({
|
||||
handleId: handle.id,
|
||||
mode: 'search',
|
||||
query: 'unique failure marker',
|
||||
}, 'chat-1');
|
||||
assert.deepEqual(result?.matchOffsets.length, 1);
|
||||
assert.match(result?.content ?? '', /Unique Failure Marker/);
|
||||
assert.ok((result?.content.length ?? Infinity) < TOOL_OUTPUT_READ_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('ToolOutputStore search advances only past matches included in the response', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
content: 'match middle match tail',
|
||||
});
|
||||
|
||||
const first = store.readChunk({
|
||||
handleId: handle.id,
|
||||
mode: 'search',
|
||||
query: 'match',
|
||||
maxChars: 1,
|
||||
}, 'chat-1');
|
||||
assert.doesNotMatch(first?.content ?? '', /No matches found/);
|
||||
assert.deepEqual(first?.matchOffsets, [0]);
|
||||
assert.equal(first?.nextOffset, 5);
|
||||
assert.equal(first?.hasMore, true);
|
||||
|
||||
const second = store.readChunk({
|
||||
handleId: handle.id,
|
||||
mode: 'search',
|
||||
query: 'match',
|
||||
offset: first?.nextOffset,
|
||||
maxChars: 30,
|
||||
}, 'chat-1');
|
||||
assert.deepEqual(second?.matchOffsets, [13]);
|
||||
});
|
||||
|
||||
test('ToolOutputStore never splits a Unicode surrogate pair at page boundaries', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const content = `${'a'.repeat(11_999)}😀中文结尾`;
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
content,
|
||||
});
|
||||
|
||||
const first = store.readChunk({ handleId: handle.id, mode: 'range' }, 'chat-1');
|
||||
assert.equal(first?.content.endsWith('\ud83d'), false);
|
||||
const second = store.readChunk({
|
||||
handleId: handle.id,
|
||||
mode: 'range',
|
||||
offset: first?.nextOffset,
|
||||
}, 'chat-1');
|
||||
assert.equal(`${first?.content}${second?.content}`, content);
|
||||
});
|
||||
|
||||
test('ToolOutputStore enforces per-handle, session count, and TTL limits', () => {
|
||||
let now = 1_000;
|
||||
const store = new ToolOutputStore({
|
||||
maxHandleChars: 20,
|
||||
maxHandlesPerSession: 2,
|
||||
maxCharsPerSession: 30,
|
||||
ttlMs: 100,
|
||||
now: () => now,
|
||||
});
|
||||
const first = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'a'.repeat(15) });
|
||||
const second = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'b'.repeat(15) });
|
||||
const third = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'c'.repeat(100) });
|
||||
|
||||
assert.equal(store.get(first.id, 'chat-1'), undefined);
|
||||
assert.equal(store.get(second.id, 'chat-1'), undefined);
|
||||
assert.equal(store.get(third.id, 'chat-1')?.storedChars, 20);
|
||||
assert.equal(store.get(third.id, 'chat-1')?.sourceTruncated, true);
|
||||
|
||||
now += 101;
|
||||
assert.equal(store.get(third.id, 'chat-1'), undefined);
|
||||
});
|
||||
|
||||
test('ToolOutputStore spills retained output through its persistence adapter', async () => {
|
||||
const files = new Map<string, string>();
|
||||
const deleted: string[] = [];
|
||||
const store = new ToolOutputStore({
|
||||
spillThresholdChars: 10,
|
||||
persistence: {
|
||||
write: async (_record, content) => {
|
||||
files.set('/netcatty/tool-output.log', content);
|
||||
return '/netcatty/tool-output.log';
|
||||
},
|
||||
read: async (path, input) => {
|
||||
const content = files.get(path);
|
||||
if (content == null) return null;
|
||||
const startOffset = input.mode === 'tail'
|
||||
? Math.max(0, content.length - (input.maxChars ?? 12_000))
|
||||
: Math.max(0, input.offset ?? 0);
|
||||
const selected = content.slice(startOffset, startOffset + (input.maxChars ?? 12_000));
|
||||
const endOffset = startOffset + selected.length;
|
||||
return {
|
||||
mode: input.mode ?? 'head',
|
||||
content: selected,
|
||||
totalChars: content.length,
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextOffset: endOffset,
|
||||
hasMore: endOffset < content.length,
|
||||
};
|
||||
},
|
||||
delete: async path => {
|
||||
deleted.push(path);
|
||||
files.delete(path);
|
||||
},
|
||||
},
|
||||
});
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
content: 'persist this terminal output',
|
||||
});
|
||||
|
||||
const result = await store.readChunkAsync({ handleId: handle.id, mode: 'full' }, 'chat-1');
|
||||
assert.equal(result?.content, 'persist this terminal output');
|
||||
assert.equal(store.get(handle.id, 'chat-1')?.fullContent, undefined);
|
||||
store.prune('chat-1');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(deleted, ['/netcatty/tool-output.log']);
|
||||
});
|
||||
|
||||
test('ToolOutputStore restores a durable handle after a runtime restart', async () => {
|
||||
const files = new Map<string, { record: PersistedToolOutputRecord; content: string }>();
|
||||
const persistence: ToolOutputPersistence = {
|
||||
write: async (record, content) => {
|
||||
const path = `/netcatty/${record.handleId}.log`;
|
||||
files.set(path, { record, content });
|
||||
return path;
|
||||
},
|
||||
restore: async (handleId, chatSessionId) => {
|
||||
for (const [path, entry] of files) {
|
||||
if (entry.record.handleId !== handleId || entry.record.chatSessionId !== chatSessionId) continue;
|
||||
return { path, record: entry.record };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
read: async (path, input) => {
|
||||
const content = files.get(path)?.content;
|
||||
if (content == null) return null;
|
||||
const startOffset = input.mode === 'tail'
|
||||
? Math.max(0, content.length - (input.maxChars ?? 12_000))
|
||||
: Math.max(0, input.offset ?? 0);
|
||||
const selected = content.slice(startOffset, startOffset + (input.maxChars ?? 12_000));
|
||||
const endOffset = startOffset + selected.length;
|
||||
return {
|
||||
mode: input.mode ?? 'head',
|
||||
content: selected,
|
||||
totalChars: content.length,
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextOffset: endOffset,
|
||||
hasMore: endOffset < content.length,
|
||||
};
|
||||
},
|
||||
delete: async path => {
|
||||
files.delete(path);
|
||||
},
|
||||
};
|
||||
|
||||
const beforeRestart = new ToolOutputStore({ spillThresholdChars: 0, persistence });
|
||||
const saved = beforeRestart.store({
|
||||
chatSessionId: 'chat-restart',
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'terminal-1',
|
||||
content: 'restart evidence in the middle',
|
||||
});
|
||||
await saved.spillPromise;
|
||||
|
||||
const afterRestart = new ToolOutputStore({ spillThresholdChars: 0, persistence });
|
||||
const restored = await afterRestart.readChunkAsync({
|
||||
handleId: saved.id,
|
||||
mode: 'search',
|
||||
query: 'evidence',
|
||||
}, 'chat-restart');
|
||||
|
||||
assert.match(restored?.content ?? '', /restart evidence/);
|
||||
assert.equal(afterRestart.get(saved.id, 'chat-restart')?.sessionId, 'terminal-1');
|
||||
assert.equal(await afterRestart.readChunkAsync({ handleId: saved.id }, 'chat-other'), null);
|
||||
});
|
||||
|
||||
test('ToolOutputStore drops a restored handle when its durable file is missing', async () => {
|
||||
const record: PersistedToolOutputRecord = {
|
||||
schemaVersion: 1,
|
||||
handleId: 'tool-output-missing',
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
totalChars: 100,
|
||||
storedChars: 100,
|
||||
sourceTruncated: false,
|
||||
preview: 'preview',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
};
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => ({ path: '/missing.log', record }),
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await store.readChunkAsync({ handleId: record.handleId }, 'chat-1'), null);
|
||||
assert.equal(store.listPendingHandles('chat-1').length, 0);
|
||||
});
|
||||
|
||||
test('ToolOutputStore can delete durable handles for a chat that was never restored', async () => {
|
||||
const deletedSessions: string[] = [];
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => null,
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
deleteSession: async chatSessionId => {
|
||||
deletedSessions.push(chatSessionId);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.prune('chat-after-restart');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(deletedSessions, ['chat-after-restart']);
|
||||
});
|
||||
|
||||
test('ToolOutputStore can delete durable terminal handles that were never restored', async () => {
|
||||
const deletedTerminalSessions: Array<[string, string]> = [];
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => null,
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
deleteTerminalSession: async (chatSessionId, terminalSessionId) => {
|
||||
deletedTerminalSessions.push([chatSessionId, terminalSessionId]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.pruneTerminalSession('chat-after-restart', 'terminal-closed');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(deletedTerminalSessions, [['chat-after-restart', 'terminal-closed']]);
|
||||
});
|
||||
|
||||
test('ToolOutputStore deletes unopened durable handles when a terminal closes', async () => {
|
||||
const deletedTerminals: string[] = [];
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/tmp/unused',
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
deleteTerminalEverywhere: async terminalSessionId => {
|
||||
deletedTerminals.push(terminalSessionId);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.pruneTerminalSessionEverywhere('terminal-unopened-after-restart');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(deletedTerminals, ['terminal-unopened-after-restart']);
|
||||
});
|
||||
|
||||
test('ToolOutputStore does not persist output that arrives after its terminal closed', async () => {
|
||||
let writes = 0;
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => {
|
||||
writes += 1;
|
||||
return '/late.log';
|
||||
},
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
deleteTerminalSession: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
store.pruneTerminalSessionEverywhere('terminal-closed-before-output');
|
||||
const lateHandle = store.store({
|
||||
chatSessionId: 'chat-late-output',
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'terminal-closed-before-output',
|
||||
content: 'late output',
|
||||
});
|
||||
await store.flush('chat-late-output');
|
||||
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(store.listPendingHandles('chat-late-output').length, 0);
|
||||
assert.equal(await store.readChunkAsync({ handleId: lateHandle.id }, 'chat-late-output'), null);
|
||||
});
|
||||
|
||||
test('ToolOutputStore does not resurrect a handle when its chat is deleted during restore', async () => {
|
||||
let finishRestore!: (value: { path: string; record: PersistedToolOutputRecord }) => void;
|
||||
const restoreFinished = new Promise<{ path: string; record: PersistedToolOutputRecord }>(resolve => {
|
||||
finishRestore = resolve;
|
||||
});
|
||||
const deletedPaths: string[] = [];
|
||||
const record: PersistedToolOutputRecord = {
|
||||
schemaVersion: 1,
|
||||
handleId: 'tool-output-racing-restore',
|
||||
chatSessionId: 'chat-racing-restore',
|
||||
capabilityId: 'terminal.execute',
|
||||
totalChars: 7,
|
||||
storedChars: 7,
|
||||
sourceTruncated: false,
|
||||
preview: 'private',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
};
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => restoreFinished,
|
||||
read: async () => ({
|
||||
mode: 'head', content: 'private', totalChars: 7, startOffset: 0, endOffset: 7, nextOffset: 7, hasMore: false,
|
||||
}),
|
||||
delete: async path => {
|
||||
deletedPaths.push(path);
|
||||
},
|
||||
deleteSession: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const reading = store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId);
|
||||
store.prune(record.chatSessionId);
|
||||
finishRestore({ path: '/netcatty/racing.log', record });
|
||||
|
||||
assert.equal(await reading, null);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(deletedPaths, ['/netcatty/racing.log']);
|
||||
assert.equal(store.listPendingHandles(record.chatSessionId).length, 0);
|
||||
});
|
||||
|
||||
test('ToolOutputStore waits for chat deletion before starting a later restore', async () => {
|
||||
let finishDeletion!: () => void;
|
||||
const deletionFinished = new Promise<void>(resolve => {
|
||||
finishDeletion = resolve;
|
||||
});
|
||||
let restoreCalls = 0;
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => {
|
||||
restoreCalls += 1;
|
||||
return null;
|
||||
},
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
deleteSession: async () => deletionFinished,
|
||||
},
|
||||
});
|
||||
|
||||
store.prune('chat-delete-window');
|
||||
const reading = store.readChunkAsync({ handleId: 'old-handle' }, 'chat-delete-window');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(restoreCalls, 0);
|
||||
|
||||
finishDeletion();
|
||||
assert.equal(await reading, null);
|
||||
assert.equal(restoreCalls, 1);
|
||||
});
|
||||
|
||||
test('ToolOutputStore does not resurrect an old handle when its terminal is deleted during restore', async () => {
|
||||
let finishRestore!: (value: { path: string; record: PersistedToolOutputRecord }) => void;
|
||||
const restoreFinished = new Promise<{ path: string; record: PersistedToolOutputRecord }>(resolve => {
|
||||
finishRestore = resolve;
|
||||
});
|
||||
const deletedPaths: string[] = [];
|
||||
const record: PersistedToolOutputRecord = {
|
||||
schemaVersion: 1,
|
||||
handleId: 'tool-output-terminal-race',
|
||||
chatSessionId: 'chat-terminal-race',
|
||||
capabilityId: 'terminal.execute',
|
||||
terminalSessionId: 'terminal-race',
|
||||
totalChars: 7,
|
||||
storedChars: 7,
|
||||
sourceTruncated: false,
|
||||
preview: 'private',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
};
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/new-output.log',
|
||||
restore: async () => restoreFinished,
|
||||
read: async () => null,
|
||||
delete: async path => {
|
||||
deletedPaths.push(path);
|
||||
},
|
||||
deleteTerminalSession: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const reading = store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId);
|
||||
store.pruneTerminalSessionEverywhere(record.terminalSessionId!);
|
||||
store.store({
|
||||
chatSessionId: record.chatSessionId,
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: record.terminalSessionId,
|
||||
content: 'new output',
|
||||
});
|
||||
finishRestore({ path: '/netcatty/old-output.log', record });
|
||||
|
||||
assert.equal(await reading, null);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.ok(deletedPaths.includes('/netcatty/old-output.log'));
|
||||
assert.equal(store.get(record.handleId, record.chatSessionId), undefined);
|
||||
});
|
||||
|
||||
test('ToolOutputStore keeps restoring one terminal when a different terminal is deleted', async () => {
|
||||
let finishRestore!: (value: { path: string; record: PersistedToolOutputRecord }) => void;
|
||||
const restoreFinished = new Promise<{ path: string; record: PersistedToolOutputRecord }>(resolve => {
|
||||
finishRestore = resolve;
|
||||
});
|
||||
const record: PersistedToolOutputRecord = {
|
||||
schemaVersion: 1,
|
||||
handleId: 'tool-output-terminal-b',
|
||||
chatSessionId: 'chat-two-terminals',
|
||||
capabilityId: 'terminal.execute',
|
||||
terminalSessionId: 'terminal-b',
|
||||
totalChars: 8,
|
||||
storedChars: 8,
|
||||
sourceTruncated: false,
|
||||
preview: 'terminal',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
};
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => restoreFinished,
|
||||
read: async () => ({
|
||||
mode: 'head', content: 'terminal', totalChars: 8, startOffset: 0, endOffset: 8, nextOffset: 8, hasMore: false,
|
||||
}),
|
||||
delete: async () => {},
|
||||
deleteTerminalSession: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const reading = store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId);
|
||||
store.pruneTerminalSession(record.chatSessionId, 'terminal-a');
|
||||
finishRestore({ path: '/netcatty/terminal-b.log', record });
|
||||
|
||||
assert.equal((await reading)?.content, 'terminal');
|
||||
});
|
||||
|
||||
test('ToolOutputStore can restore a durable handle after its in-memory cache expires', async () => {
|
||||
let now = 1_000;
|
||||
const files = new Map<string, { record: PersistedToolOutputRecord; content: string }>();
|
||||
const persistence: ToolOutputPersistence = {
|
||||
write: async (record, content) => {
|
||||
const path = `/netcatty/${record.handleId}.log`;
|
||||
files.set(path, { record, content });
|
||||
return path;
|
||||
},
|
||||
restore: async (handleId, chatSessionId) => {
|
||||
for (const [path, entry] of files) {
|
||||
if (entry.record.handleId === handleId && entry.record.chatSessionId === chatSessionId) {
|
||||
return { path, record: entry.record };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
read: async (path, input) => {
|
||||
const content = files.get(path)?.content;
|
||||
if (content == null) return null;
|
||||
return {
|
||||
mode: input.mode ?? 'head',
|
||||
content,
|
||||
totalChars: content.length,
|
||||
startOffset: 0,
|
||||
endOffset: content.length,
|
||||
nextOffset: content.length,
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
delete: async path => {
|
||||
files.delete(path);
|
||||
},
|
||||
};
|
||||
const store = new ToolOutputStore({ ttlMs: 100, now: () => now, persistence });
|
||||
const handle = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'durable' });
|
||||
await handle.spillPromise;
|
||||
now += 101;
|
||||
|
||||
const restored = await store.readChunkAsync({ handleId: handle.id }, 'chat-1');
|
||||
assert.equal(restored?.content, 'durable');
|
||||
});
|
||||
|
||||
test('ToolOutputStore flush waits until a handle is durable before a tool can return it', async () => {
|
||||
let finishWrite!: (path: string) => void;
|
||||
const writeFinished = new Promise<string>(resolve => {
|
||||
finishWrite = resolve;
|
||||
});
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => writeFinished,
|
||||
restore: async () => null,
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
},
|
||||
});
|
||||
const handle = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'persist me' });
|
||||
let flushed = false;
|
||||
const flushing = store.flush('chat-1').then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(flushed, false);
|
||||
finishWrite('/netcatty/durable.log');
|
||||
await flushing;
|
||||
assert.equal(handle.filePath, '/netcatty/durable.log');
|
||||
});
|
||||
|
||||
test('ToolOutputStore reports restart persistence only after the individual spill succeeds', async () => {
|
||||
let failWrite = true;
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => {
|
||||
if (failWrite) throw new Error('disk full');
|
||||
return '/netcatty/durable.log';
|
||||
},
|
||||
restore: async () => null,
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
},
|
||||
});
|
||||
const failed = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'memory only' });
|
||||
const failedNotice = `[output handle: handleId=${failed.id} restartPersistence=unavailable (read before closing the app)]`;
|
||||
await store.flush('chat-1');
|
||||
assert.equal(store.resolveRestartPersistenceNotices(failedNotice, 'chat-1'), failedNotice);
|
||||
|
||||
failWrite = false;
|
||||
const durable = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'saved' });
|
||||
const durableNotice = `[output handle: handleId=${durable.id} restartPersistence=unavailable (read before closing the app)]`;
|
||||
await store.flush('chat-1');
|
||||
assert.equal(
|
||||
store.resolveRestartPersistenceNotices(durableNotice, 'chat-1'),
|
||||
`[output handle: handleId=${durable.id}]`,
|
||||
);
|
||||
});
|
||||
|
||||
test('ToolOutputStore enforces a shared quota across chat sessions', () => {
|
||||
const store = new ToolOutputStore({
|
||||
maxCharsGlobal: 25,
|
||||
maxHandlesGlobal: 2,
|
||||
});
|
||||
const first = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'a'.repeat(15) });
|
||||
const second = store.store({ chatSessionId: 'chat-2', capabilityId: 'test', content: 'b'.repeat(15) });
|
||||
|
||||
assert.equal(store.get(first.id, 'chat-1'), undefined);
|
||||
assert.ok(store.get(second.id, 'chat-2'));
|
||||
});
|
||||
|
||||
test('ToolOutputStore rejects cross-chat handle reads', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-owner',
|
||||
capabilityId: 'terminal.execute',
|
||||
content: 'private output',
|
||||
});
|
||||
|
||||
assert.equal(await store.readChunkAsync({ handleId: handle.id }, 'chat-other'), null);
|
||||
});
|
||||
|
||||
test('ToolOutputStore reclaims chat generations after deletion churn settles', () => {
|
||||
const store = new ToolOutputStore();
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
store.prune(`chat-deleted-${index}`);
|
||||
}
|
||||
|
||||
assert.equal(store.getLifecycleMetadataStatsForTests().sessionGenerations, 0);
|
||||
});
|
||||
|
||||
test('ToolOutputStore rejects output that arrives after its chat was deleted', () => {
|
||||
const store = new ToolOutputStore();
|
||||
store.prune('chat-deleted-before-late-output');
|
||||
|
||||
const lateHandle = store.store({
|
||||
chatSessionId: 'chat-deleted-before-late-output',
|
||||
capabilityId: 'terminal.execute',
|
||||
content: 'late output',
|
||||
});
|
||||
|
||||
assert.equal(lateHandle.evicted, true);
|
||||
assert.equal(lateHandle.storedChars, 0);
|
||||
assert.equal(store.listPendingHandles('chat-deleted-before-late-output').length, 0);
|
||||
});
|
||||
|
||||
test('ToolOutputStore reclaims per-chat terminal deletion metadata after churn settles', () => {
|
||||
const store = new ToolOutputStore();
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
store.pruneTerminalSession(`chat-${index}`, `terminal-${index}`);
|
||||
}
|
||||
|
||||
const stats = store.getLifecycleMetadataStatsForTests();
|
||||
assert.equal(stats.terminalMutationGenerations, 0);
|
||||
assert.equal(stats.deletedTerminalSessions, 0);
|
||||
});
|
||||
|
||||
test('ToolOutputStore bounds closed-terminal tombstones while isolating recent late writes', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const churnCount = TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS + 2_000;
|
||||
for (let index = 0; index < churnCount; index += 1) {
|
||||
store.pruneTerminalSessionEverywhere(`terminal-closed-${index}`);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
store.getLifecycleMetadataStatsForTests().closedTerminalSessions,
|
||||
TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS,
|
||||
);
|
||||
const lateHandle = store.store({
|
||||
chatSessionId: 'chat-late-after-churn',
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: `terminal-closed-${churnCount - 1}`,
|
||||
content: 'late output after heavy churn',
|
||||
});
|
||||
assert.equal(lateHandle.evicted, true);
|
||||
assert.equal(store.listPendingHandles('chat-late-after-churn').length, 0);
|
||||
assert.equal(await store.readChunkAsync({ handleId: lateHandle.id }, 'chat-late-after-churn'), null);
|
||||
const oldestLateHandle = store.store({
|
||||
chatSessionId: 'chat-oldest-late-after-churn',
|
||||
capabilityId: 'terminal.execute',
|
||||
sessionId: 'terminal-closed-0',
|
||||
content: 'very late output after exact tombstone eviction',
|
||||
});
|
||||
assert.equal(oldestLateHandle.evicted, true);
|
||||
assert.equal(store.listPendingHandles('chat-oldest-late-after-churn').length, 0);
|
||||
});
|
||||
|
||||
test('ToolOutputStore bounds fallback terminal metadata when durable deletion is unavailable', () => {
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => null,
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
},
|
||||
});
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
store.pruneTerminalSession(`chat-fallback-${index}`, `terminal-fallback-${index}`);
|
||||
}
|
||||
|
||||
const stats = store.getLifecycleMetadataStatsForTests();
|
||||
assert.equal(stats.terminalMutationGenerations, TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS);
|
||||
assert.equal(stats.deletedTerminalSessions, TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS);
|
||||
});
|
||||
|
||||
test('ToolOutputStore keeps an in-flight restore tombstone protected during metadata churn', async () => {
|
||||
const chatSessionId = 'chat-protected-restore';
|
||||
const terminalSessionId = 'terminal-protected-restore';
|
||||
const handleId = 'tool-output-protected-restore';
|
||||
let finishRestore!: (value: { path: string; record: PersistedToolOutputRecord }) => void;
|
||||
const restoreFinished = new Promise<{ path: string; record: PersistedToolOutputRecord }>(resolve => {
|
||||
finishRestore = resolve;
|
||||
});
|
||||
const deletedPaths: string[] = [];
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async (requestedHandleId, requestedChatSessionId) => {
|
||||
if (requestedHandleId !== handleId || requestedChatSessionId !== chatSessionId) return null;
|
||||
return restoreFinished;
|
||||
},
|
||||
read: async () => null,
|
||||
delete: async path => { deletedPaths.push(path); },
|
||||
},
|
||||
});
|
||||
|
||||
const reading = store.readChunkAsync({ handleId }, chatSessionId);
|
||||
store.pruneTerminalSession(chatSessionId, terminalSessionId);
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
store.pruneTerminalSession(`chat-churn-${index}`, `terminal-churn-${index}`);
|
||||
}
|
||||
finishRestore({
|
||||
path: '/netcatty/protected-old-output.log',
|
||||
record: {
|
||||
schemaVersion: 1,
|
||||
handleId,
|
||||
chatSessionId,
|
||||
capabilityId: 'terminal.execute',
|
||||
terminalSessionId,
|
||||
totalChars: 3,
|
||||
storedChars: 3,
|
||||
sourceTruncated: false,
|
||||
preview: 'old',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await reading, null);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(deletedPaths, ['/netcatty/protected-old-output.log']);
|
||||
const stats = store.getLifecycleMetadataStatsForTests();
|
||||
assert.ok(stats.terminalMutationGenerations <= TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS);
|
||||
assert.ok(stats.deletedTerminalSessions <= TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS);
|
||||
});
|
||||
|
||||
test('ToolOutputStore retains a bounded tombstone when durable terminal deletion fails', async () => {
|
||||
const record: PersistedToolOutputRecord = {
|
||||
schemaVersion: 1,
|
||||
handleId: 'tool-output-delete-failed',
|
||||
chatSessionId: 'chat-delete-failed',
|
||||
capabilityId: 'terminal.execute',
|
||||
terminalSessionId: 'terminal-delete-failed',
|
||||
totalChars: 3,
|
||||
storedChars: 3,
|
||||
sourceTruncated: false,
|
||||
preview: 'old',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
};
|
||||
const deletedPaths: string[] = [];
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => ({ path: '/netcatty/delete-failed.log', record }),
|
||||
read: async () => ({
|
||||
mode: 'head',
|
||||
content: 'old',
|
||||
totalChars: 3,
|
||||
startOffset: 0,
|
||||
endOffset: 3,
|
||||
nextOffset: 3,
|
||||
hasMore: false,
|
||||
}),
|
||||
delete: async path => { deletedPaths.push(path); },
|
||||
deleteTerminalSession: async () => { throw new Error('disk busy'); },
|
||||
},
|
||||
});
|
||||
|
||||
store.pruneTerminalSession(record.chatSessionId, record.terminalSessionId!);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(await store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId), null);
|
||||
assert.deepEqual(deletedPaths, ['/netcatty/delete-failed.log']);
|
||||
const stats = store.getLifecycleMetadataStatsForTests();
|
||||
assert.equal(stats.terminalMutationGenerations, 1);
|
||||
assert.equal(stats.deletedTerminalSessions, 1);
|
||||
});
|
||||
|
||||
test('ToolOutputStore ignores an older terminal deletion failure after a newer deletion succeeds', async () => {
|
||||
let rejectFirstDeletion!: (error: Error) => void;
|
||||
let finishSecondDeletion!: () => void;
|
||||
const firstDeletion = new Promise<void>((_resolve, reject) => {
|
||||
rejectFirstDeletion = reject;
|
||||
});
|
||||
const secondDeletion = new Promise<void>(resolve => {
|
||||
finishSecondDeletion = resolve;
|
||||
});
|
||||
let deletionCalls = 0;
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => null,
|
||||
read: async () => null,
|
||||
delete: async () => {},
|
||||
deleteTerminalSession: async () => {
|
||||
deletionCalls += 1;
|
||||
return deletionCalls === 1 ? firstDeletion : secondDeletion;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.pruneTerminalSession('chat-repeat-delete', 'terminal-repeat-delete');
|
||||
store.pruneTerminalSession('chat-repeat-delete', 'terminal-repeat-delete');
|
||||
finishSecondDeletion();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
rejectFirstDeletion(new Error('older deletion failed'));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const stats = store.getLifecycleMetadataStatsForTests();
|
||||
assert.equal(stats.terminalMutationGenerations, 0);
|
||||
assert.equal(stats.deletedTerminalSessions, 0);
|
||||
assert.equal(stats.failedTerminalDeletions, 0);
|
||||
});
|
||||
|
||||
test('ToolOutputStore does not restore old output after durable chat deletion fails', async () => {
|
||||
const record: PersistedToolOutputRecord = {
|
||||
schemaVersion: 1,
|
||||
handleId: 'tool-output-chat-delete-failed',
|
||||
chatSessionId: 'chat-delete-failed',
|
||||
capabilityId: 'terminal.execute',
|
||||
totalChars: 3,
|
||||
storedChars: 3,
|
||||
sourceTruncated: false,
|
||||
preview: 'old',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
};
|
||||
const deletedPaths: string[] = [];
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async () => ({ path: '/netcatty/chat-delete-failed.log', record }),
|
||||
read: async () => ({
|
||||
mode: 'head',
|
||||
content: 'old',
|
||||
totalChars: 3,
|
||||
startOffset: 0,
|
||||
endOffset: 3,
|
||||
nextOffset: 3,
|
||||
hasMore: false,
|
||||
}),
|
||||
delete: async path => { deletedPaths.push(path); },
|
||||
deleteSession: async () => { throw new Error('disk busy'); },
|
||||
},
|
||||
});
|
||||
|
||||
store.prune(record.chatSessionId);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(await store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId), null);
|
||||
assert.deepEqual(deletedPaths, ['/netcatty/chat-delete-failed.log']);
|
||||
});
|
||||
|
||||
test('ToolOutputStore bounds failed chat deletion tombstones after heavy churn', async () => {
|
||||
const oldRecord: PersistedToolOutputRecord = {
|
||||
schemaVersion: 1,
|
||||
handleId: 'tool-output-old-failed-chat',
|
||||
chatSessionId: 'chat-delete-failure-0',
|
||||
capabilityId: 'terminal.execute',
|
||||
totalChars: 3,
|
||||
storedChars: 3,
|
||||
sourceTruncated: false,
|
||||
preview: 'old',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
};
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async (handleId, chatSessionId) => (
|
||||
handleId === oldRecord.handleId && chatSessionId === oldRecord.chatSessionId
|
||||
? { path: '/old-failed-chat.log', record: oldRecord }
|
||||
: null
|
||||
),
|
||||
read: async () => ({
|
||||
mode: 'head', content: 'old', totalChars: 3, startOffset: 0, endOffset: 3, nextOffset: 3, hasMore: false,
|
||||
}),
|
||||
delete: async () => {},
|
||||
deleteSession: async () => { throw new Error('disk busy'); },
|
||||
},
|
||||
});
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
store.prune(`chat-delete-failure-${index}`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const stats = store.getLifecycleMetadataStatsForTests();
|
||||
assert.equal(stats.failedSessionDeletions, TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS);
|
||||
assert.equal(stats.sessionGenerations, TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS);
|
||||
assert.equal(
|
||||
await store.readChunkAsync({ handleId: oldRecord.handleId }, oldRecord.chatSessionId),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('ToolOutputStore never restores a failed terminal deletion after exact tombstone churn', async () => {
|
||||
const oldRecord: PersistedToolOutputRecord = {
|
||||
schemaVersion: 1,
|
||||
handleId: 'tool-output-old-failed-terminal',
|
||||
chatSessionId: 'chat-terminal-failure-0',
|
||||
capabilityId: 'terminal.execute',
|
||||
terminalSessionId: 'terminal-failure-0',
|
||||
totalChars: 3,
|
||||
storedChars: 3,
|
||||
sourceTruncated: false,
|
||||
preview: 'old',
|
||||
storedAt: 1,
|
||||
accessedAt: 1,
|
||||
};
|
||||
const store = new ToolOutputStore({
|
||||
persistence: {
|
||||
write: async () => '/unused',
|
||||
restore: async (handleId, chatSessionId) => (
|
||||
handleId === oldRecord.handleId && chatSessionId === oldRecord.chatSessionId
|
||||
? { path: '/old-failed-terminal.log', record: oldRecord }
|
||||
: null
|
||||
),
|
||||
read: async () => ({
|
||||
mode: 'head', content: 'old', totalChars: 3, startOffset: 0, endOffset: 3, nextOffset: 3, hasMore: false,
|
||||
}),
|
||||
delete: async () => {},
|
||||
deleteTerminalSession: async () => { throw new Error('disk busy'); },
|
||||
},
|
||||
});
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
store.pruneTerminalSession(`chat-terminal-failure-${index}`, `terminal-failure-${index}`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
await store.readChunkAsync({ handleId: oldRecord.handleId }, oldRecord.chatSessionId),
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
store.getLifecycleMetadataStatsForTests().deletedTerminalSessions
|
||||
<= TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS,
|
||||
);
|
||||
});
|
||||
|
||||
test('saved-output read budgets reset at the start of each turn', () => {
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
assert.equal(dedup.takeBudget('read', 24_000, 24_000), 24_000);
|
||||
assert.equal(dedup.takeBudget('read', 1, 24_000), 0);
|
||||
dedup.beginTurn();
|
||||
assert.equal(dedup.takeBudget('read', 24_000, 24_000), 24_000);
|
||||
});
|
||||
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();
|
||||
32
infrastructure/ai/harness/toolResultDedup.test.ts
Normal file
32
infrastructure/ai/harness/toolResultDedup.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ToolResultDedup } from './toolResultDedup';
|
||||
|
||||
test('completed write replay is ordered, one-shot, and scoped to the current turn', () => {
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
dedup.rememberCompletedWrite('same-command', 'first');
|
||||
dedup.rememberCompletedWrite('same-command', 'second');
|
||||
dedup.enableWriteReplay();
|
||||
|
||||
assert.equal(dedup.replayCompletedWrite('same-command'), 'first');
|
||||
assert.equal(dedup.replayCompletedWrite('same-command'), 'second');
|
||||
assert.equal(dedup.replayCompletedWrite('same-command'), undefined);
|
||||
|
||||
dedup.rememberCompletedWrite('later-command', 'later');
|
||||
assert.equal(dedup.replayCompletedWrite('later-command'), undefined);
|
||||
|
||||
dedup.beginTurn();
|
||||
dedup.enableWriteReplay();
|
||||
assert.equal(dedup.replayCompletedWrite('same-command'), undefined);
|
||||
assert.equal(dedup.replayCompletedWrite('later-command'), undefined);
|
||||
});
|
||||
|
||||
test('completed writes already present in retry history are not replayed', () => {
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
dedup.rememberCompletedWrite('preserved-command', 'old result');
|
||||
dedup.enableWriteReplay(['preserved-command']);
|
||||
|
||||
assert.equal(dedup.replayCompletedWrite('preserved-command'), undefined);
|
||||
});
|
||||
136
infrastructure/ai/harness/toolResultDedup.ts
Normal file
136
infrastructure/ai/harness/toolResultDedup.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
export interface ToolResultDedupEntry {
|
||||
fingerprint: string;
|
||||
toolName: string;
|
||||
turnNumber: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export class ToolResultDedup {
|
||||
private turnNumber = 0;
|
||||
private readonly cache = new Map<string, ToolResultDedupEntry>();
|
||||
private readonly consumedBudgets = new Map<string, number>();
|
||||
private readonly completedWrites = new Map<string, unknown[]>();
|
||||
private replayableWrites = new Map<string, unknown[]>();
|
||||
private readonly terminalJobSessions = new Map<string, string>();
|
||||
private writeReplayEnabled = false;
|
||||
|
||||
beginTurn(): void {
|
||||
this.turnNumber += 1;
|
||||
this.consumedBudgets.clear();
|
||||
this.completedWrites.clear();
|
||||
this.replayableWrites.clear();
|
||||
this.writeReplayEnabled = false;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cache.clear();
|
||||
this.turnNumber = 0;
|
||||
this.consumedBudgets.clear();
|
||||
this.completedWrites.clear();
|
||||
this.replayableWrites.clear();
|
||||
this.terminalJobSessions.clear();
|
||||
this.writeReplayEnabled = false;
|
||||
}
|
||||
|
||||
rememberCompletedWrite(fingerprint: string, result: unknown): void {
|
||||
const results = this.completedWrites.get(fingerprint) ?? [];
|
||||
results.push(result);
|
||||
this.completedWrites.set(fingerprint, results);
|
||||
}
|
||||
|
||||
rememberTerminalJobSession(jobId: string, sessionId: string): void {
|
||||
this.terminalJobSessions.set(jobId, sessionId);
|
||||
}
|
||||
|
||||
terminalSessionForJob(jobId: string): string | undefined {
|
||||
return this.terminalJobSessions.get(jobId);
|
||||
}
|
||||
|
||||
enableWriteReplay(preservedFingerprints: Iterable<string> = []): void {
|
||||
this.replayableWrites = new Map(
|
||||
Array.from(this.completedWrites, ([fingerprint, results]) => [fingerprint, [...results]]),
|
||||
);
|
||||
this.writeReplayEnabled = true;
|
||||
for (const fingerprint of preservedFingerprints) {
|
||||
this.consumeReplay(fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
replayCompletedWrite(fingerprint: string): unknown | undefined {
|
||||
if (!this.writeReplayEnabled) return undefined;
|
||||
return this.consumeReplay(fingerprint);
|
||||
}
|
||||
|
||||
private consumeReplay(fingerprint: string): unknown | undefined {
|
||||
const results = this.replayableWrites.get(fingerprint);
|
||||
const result = results?.shift();
|
||||
if (results?.length === 0) this.replayableWrites.delete(fingerprint);
|
||||
return result;
|
||||
}
|
||||
|
||||
takeBudget(key: string, requested: number, limit: number): number {
|
||||
const consumed = this.consumedBudgets.get(key) ?? 0;
|
||||
const granted = Math.max(0, Math.min(requested, limit - consumed));
|
||||
this.consumedBudgets.set(key, consumed + granted);
|
||||
return granted;
|
||||
}
|
||||
|
||||
fingerprintFor(toolName: string, key: string): string {
|
||||
return `${toolName}:${key}`;
|
||||
}
|
||||
|
||||
check(fingerprint: string): ToolResultDedupEntry | undefined {
|
||||
return this.cache.get(fingerprint);
|
||||
}
|
||||
|
||||
remember(toolName: string, fingerprint: string, preview: string): void {
|
||||
this.cache.set(fingerprint, {
|
||||
fingerprint,
|
||||
toolName,
|
||||
turnNumber: this.turnNumber,
|
||||
preview: preview.slice(0, 160),
|
||||
});
|
||||
}
|
||||
|
||||
buildCachedNotice(entry: ToolResultDedupEntry): string {
|
||||
return `[cached] same as turn ${entry.turnNumber} for ${entry.toolName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function hashScopeKey(parts: Array<string | undefined>): string {
|
||||
return parts.filter(Boolean).join('|');
|
||||
}
|
||||
|
||||
export function buildTerminalWriteFingerprint(
|
||||
toolName: 'terminal_execute' | 'terminal_start',
|
||||
chatSessionId: string | undefined,
|
||||
args: { sessionId?: unknown; command?: unknown },
|
||||
): string | undefined {
|
||||
if (typeof args.sessionId !== 'string' || typeof args.command !== 'string') return undefined;
|
||||
const fingerprintToolName = toolName === 'terminal_start' ? 'terminal.start:write' : toolName;
|
||||
return `${fingerprintToolName}:${hashScopeKey([chatSessionId, args.sessionId, args.command])}`;
|
||||
}
|
||||
|
||||
export function previewToolResult(result: unknown): string {
|
||||
if (typeof result === 'string') return result.slice(0, 160);
|
||||
try {
|
||||
return JSON.stringify(result).slice(0, 160);
|
||||
} catch {
|
||||
return String(result).slice(0, 160);
|
||||
}
|
||||
}
|
||||
|
||||
export function hashToolResult(result: unknown): string {
|
||||
let value: string;
|
||||
try {
|
||||
value = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
} catch {
|
||||
value = String(result);
|
||||
}
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
59
infrastructure/ai/harness/toolResultFitting.test.ts
Normal file
59
infrastructure/ai/harness/toolResultFitting.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { fitLargeToolResultForModel } from './toolResultFitting';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
|
||||
describe('fitLargeToolResultForModel', () => {
|
||||
it('truncates large nested string fields and stores the full content behind a handle', () => {
|
||||
const store = new ToolOutputStore();
|
||||
const body = `${'alpha\n'.repeat(1000)}important-tail`;
|
||||
|
||||
const fitted = fitLargeToolResultForModel({
|
||||
result: {
|
||||
ok: true,
|
||||
note: {
|
||||
id: 'note-1',
|
||||
title: 'Runbook',
|
||||
content: body,
|
||||
},
|
||||
},
|
||||
capabilityId: 'vault.note.get',
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: store,
|
||||
maxStringChars: 500,
|
||||
}) as {
|
||||
note: { content: string };
|
||||
};
|
||||
|
||||
assert.notEqual(fitted.note.content, body);
|
||||
assert.match(fitted.note.content, /tool output handle/);
|
||||
assert.match(fitted.note.content, /capability=vault\.note\.get/);
|
||||
assert.match(fitted.note.content, /field=note\.content/);
|
||||
assert.match(fitted.note.content, /handleId=tool-output-/);
|
||||
|
||||
const handleId = fitted.note.content.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
assert.equal(store.read({ handleId, mode: 'full', maxChars: body.length + 100 }, 'chat-1'), body);
|
||||
});
|
||||
|
||||
it('leaves small results unchanged', () => {
|
||||
const result = {
|
||||
ok: true,
|
||||
note: {
|
||||
id: 'note-1',
|
||||
content: 'short note',
|
||||
},
|
||||
};
|
||||
|
||||
const fitted = fitLargeToolResultForModel({
|
||||
result,
|
||||
capabilityId: 'vault.note.get',
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
maxStringChars: 500,
|
||||
});
|
||||
|
||||
assert.equal(fitted, result);
|
||||
});
|
||||
});
|
||||
140
infrastructure/ai/harness/toolResultFitting.ts
Normal file
140
infrastructure/ai/harness/toolResultFitting.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { compressVerboseText, truncateTextWithHeadAndTail } from '../requestPayloadCompression';
|
||||
import type { ToolOutputStore } from './toolOutputStore';
|
||||
import { redactSecretsForModel } from './modelSecretRedaction';
|
||||
|
||||
export const MAX_LIVE_TOOL_STRING_CHARS = 8_000;
|
||||
|
||||
export interface FitLargeToolResultForModelInput {
|
||||
result: unknown;
|
||||
capabilityId: string;
|
||||
chatSessionId?: string;
|
||||
toolOutputStore?: ToolOutputStore;
|
||||
terminalSessionId?: string;
|
||||
maxStringChars?: number;
|
||||
normalizeStrings?: boolean;
|
||||
}
|
||||
|
||||
export function fitLargeToolResultForModel({
|
||||
result,
|
||||
capabilityId,
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
terminalSessionId,
|
||||
maxStringChars = MAX_LIVE_TOOL_STRING_CHARS,
|
||||
normalizeStrings = false,
|
||||
}: FitLargeToolResultForModelInput): unknown {
|
||||
return fitValue(result, {
|
||||
capabilityId,
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
terminalSessionId,
|
||||
maxStringChars,
|
||||
normalizeStrings,
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
interface FitValueContext {
|
||||
capabilityId: string;
|
||||
chatSessionId?: string;
|
||||
toolOutputStore?: ToolOutputStore;
|
||||
terminalSessionId?: string;
|
||||
maxStringChars: number;
|
||||
normalizeStrings: boolean;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
function fitValue(value: unknown, ctx: FitValueContext): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return fitString(value, ctx);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const next = value.map((entry, index) => {
|
||||
const fitted = fitValue(entry, {
|
||||
...ctx,
|
||||
path: [...ctx.path, `[${index}]`],
|
||||
});
|
||||
if (fitted !== entry) changed = true;
|
||||
return fitted;
|
||||
});
|
||||
return changed ? next : value;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
const fitted = fitValue(entry, {
|
||||
...ctx,
|
||||
path: [...ctx.path, key],
|
||||
});
|
||||
if (fitted !== entry) changed = true;
|
||||
next[key] = fitted;
|
||||
}
|
||||
|
||||
return changed ? next : value;
|
||||
}
|
||||
|
||||
function fitString(value: string, ctx: FitValueContext): string {
|
||||
const safeValue = redactSecretsForModel(value);
|
||||
const normalizedValue = ctx.normalizeStrings ? compressVerboseText(safeValue) : safeValue;
|
||||
if (safeValue.length <= ctx.maxStringChars && normalizedValue.length <= ctx.maxStringChars) {
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
const fitted = truncateTextWithHeadAndTail(
|
||||
ctx.normalizeStrings ? normalizedValue : compressVerboseText(safeValue),
|
||||
ctx.maxStringChars,
|
||||
);
|
||||
if (fitted === safeValue) return safeValue;
|
||||
|
||||
let handleId: string | undefined;
|
||||
if (ctx.toolOutputStore && ctx.chatSessionId) {
|
||||
handleId = ctx.toolOutputStore.store({
|
||||
chatSessionId: ctx.chatSessionId,
|
||||
capabilityId: ctx.capabilityId,
|
||||
content: value,
|
||||
sessionId: ctx.terminalSessionId,
|
||||
}).id;
|
||||
}
|
||||
|
||||
return appendToolOutputHandleNotice(fitted, {
|
||||
capabilityId: ctx.capabilityId,
|
||||
fieldPath: formatFieldPath(ctx.path),
|
||||
totalChars: value.length,
|
||||
handleId,
|
||||
restartPersistenceAvailable: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatFieldPath(path: string[]): string {
|
||||
if (path.length === 0) return '$';
|
||||
return path
|
||||
.map((part, index) => {
|
||||
if (part.startsWith('[')) return part;
|
||||
return index === 0 ? part : `.${part}`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function appendToolOutputHandleNotice(
|
||||
fitted: string,
|
||||
details: {
|
||||
capabilityId: string;
|
||||
fieldPath: string;
|
||||
totalChars: number;
|
||||
handleId?: string;
|
||||
restartPersistenceAvailable?: boolean;
|
||||
},
|
||||
): string {
|
||||
const handleSuffix = details.handleId ? ` handleId=${details.handleId}` : '';
|
||||
const restartSuffix = details.handleId && details.restartPersistenceAvailable === false
|
||||
? ' restartPersistence=unavailable (read before closing the app)'
|
||||
: '';
|
||||
return `${fitted}\n\n[tool output handle: capability=${details.capabilityId} field=${details.fieldPath} chars=${details.totalChars} truncated for model context${handleSuffix}${restartSuffix}]`;
|
||||
}
|
||||
69
infrastructure/ai/harness/traceStore.ts
Normal file
69
infrastructure/ai/harness/traceStore.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { AgentEvent, CompactionTrace } from './types';
|
||||
|
||||
const DEFAULT_MAX_EVENTS = 2_000;
|
||||
const DEFAULT_MAX_COMPACTIONS = 500;
|
||||
|
||||
export interface TraceExport {
|
||||
sessionId: string;
|
||||
events: AgentEvent[];
|
||||
compactions: CompactionTrace[];
|
||||
exportedAt: number;
|
||||
}
|
||||
|
||||
export class TraceStore {
|
||||
private readonly events = new Map<string, AgentEvent[]>();
|
||||
private readonly compactions = new Map<string, CompactionTrace[]>();
|
||||
private readonly maxEvents: number;
|
||||
private readonly maxCompactions: number;
|
||||
|
||||
constructor(
|
||||
maxEvents = DEFAULT_MAX_EVENTS,
|
||||
maxCompactions = Math.min(DEFAULT_MAX_COMPACTIONS, maxEvents),
|
||||
) {
|
||||
this.maxEvents = Math.max(1, Math.floor(maxEvents));
|
||||
this.maxCompactions = Math.max(1, Math.floor(maxCompactions));
|
||||
}
|
||||
|
||||
append(event: AgentEvent): void {
|
||||
const list = this.events.get(event.sessionId) ?? [];
|
||||
list.push(event);
|
||||
if (list.length > this.maxEvents) {
|
||||
list.splice(0, list.length - this.maxEvents);
|
||||
}
|
||||
this.events.set(event.sessionId, list);
|
||||
|
||||
if (event.type === 'compaction') {
|
||||
const traces = this.compactions.get(event.sessionId) ?? [];
|
||||
traces.push(event.trace);
|
||||
if (traces.length > this.maxCompactions) {
|
||||
traces.splice(0, traces.length - this.maxCompactions);
|
||||
}
|
||||
this.compactions.set(event.sessionId, traces);
|
||||
}
|
||||
}
|
||||
|
||||
getEvents(sessionId: string): readonly AgentEvent[] {
|
||||
return this.events.get(sessionId) ?? [];
|
||||
}
|
||||
|
||||
getCompactions(sessionId: string): readonly CompactionTrace[] {
|
||||
return this.compactions.get(sessionId) ?? [];
|
||||
}
|
||||
|
||||
exportTrace(sessionId: string): TraceExport {
|
||||
return {
|
||||
sessionId,
|
||||
events: [...(this.events.get(sessionId) ?? [])],
|
||||
compactions: [...(this.compactions.get(sessionId) ?? [])],
|
||||
exportedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
clear(sessionId: string): void {
|
||||
this.events.delete(sessionId);
|
||||
this.compactions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide trace store for harness debugging. */
|
||||
export const globalTraceStore = new TraceStore();
|
||||
@@ -0,0 +1,575 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ToolOutputStore } from '../toolOutputStore';
|
||||
import {
|
||||
buildCattySdkMessages,
|
||||
createContinuationContext,
|
||||
} from './cattyMessageBuilder';
|
||||
import type { ChatMessage } from '../../types';
|
||||
import { prepareCattyMessagesForStream } from '../cattyRuntime';
|
||||
|
||||
function buildHistory(messages: ChatMessage[]) {
|
||||
return buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1', true),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
test('legacy reasoning parts with an rs_ item id but no encrypted content are dropped from replay', () => {
|
||||
const messages: ChatMessage[] = [{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: 'legacy reasoning',
|
||||
providerOptions: { openai: { itemId: 'rs_legacy' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}];
|
||||
|
||||
const sdkMessages = buildHistory(messages);
|
||||
|
||||
assert.equal(sdkMessages.length, 1);
|
||||
// With the legacy reasoning part dropped, only text remains, so the
|
||||
// assistant content collapses to a plain string.
|
||||
assert.equal(sdkMessages[0].content, 'Done.');
|
||||
});
|
||||
|
||||
test('reasoning parts with encrypted content and non-OpenAI reasoning parts survive replay', () => {
|
||||
const messages: ChatMessage[] = [{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: 'replayable reasoning',
|
||||
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' } },
|
||||
},
|
||||
{
|
||||
text: 'plain reasoning',
|
||||
},
|
||||
],
|
||||
},
|
||||
}];
|
||||
|
||||
const sdkMessages = buildHistory(messages);
|
||||
|
||||
assert.equal(sdkMessages.length, 1);
|
||||
const content = sdkMessages[0].content;
|
||||
assert.ok(Array.isArray(content));
|
||||
assert.deepEqual(
|
||||
content.map((part) => (part as { type: string; text?: string }).text),
|
||||
['replayable reasoning', 'plain reasoning', 'Done.'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an unreplayable reasoning item discards the whole tool-call exchange from replay', () => {
|
||||
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
|
||||
const toolResult = {
|
||||
toolCallId: 'call-1',
|
||||
content: 'output',
|
||||
};
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: 'legacy reasoning',
|
||||
providerOptions: { openai: { itemId: 'rs_legacy' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
toolCalls: [toolCall],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 3,
|
||||
toolResults: [toolResult],
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
role: 'assistant',
|
||||
content: 'All done.',
|
||||
timestamp: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const sdkMessages = buildHistory(messages);
|
||||
|
||||
// The tool-call/tool-result exchange is discarded entirely; only the plain
|
||||
// assistant text messages survive replay.
|
||||
assert.equal(sdkMessages.length, 2);
|
||||
assert.equal(sdkMessages[0].content, 'Running it.');
|
||||
assert.equal(sdkMessages[1].content, 'All done.');
|
||||
|
||||
const chatMessages = buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1', false),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
assert.equal(chatMessages.length, 3);
|
||||
assert.equal(chatMessages[1].role, 'tool');
|
||||
});
|
||||
|
||||
test('a Responses model switch discards a reasoning-backed tool exchange from the old source', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [{
|
||||
text: 'replayable only for the original model',
|
||||
providerOptions: {
|
||||
openai: { itemId: 'rs_old_model', reasoningEncryptedContent: 'enc-old-model' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
|
||||
},
|
||||
];
|
||||
const buildForModel = (usesOpenAIResponses: boolean) => buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext(
|
||||
'provider-1',
|
||||
'openai',
|
||||
'model-2',
|
||||
usesOpenAIResponses,
|
||||
),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
|
||||
const responsesMessages = buildForModel(true);
|
||||
assert.deepEqual(responsesMessages, [{ role: 'assistant', content: 'Running it.' }]);
|
||||
|
||||
// Chat Completions does not require a Responses reasoning item alongside
|
||||
// the generic call/result pair, so keep the existing cross-model behavior.
|
||||
const chatMessages = buildForModel(false);
|
||||
assert.equal(chatMessages.length, 2);
|
||||
assert.equal(chatMessages[1].role, 'tool');
|
||||
});
|
||||
|
||||
test('a Responses provider switch keeps a non-OpenAI reasoning tool exchange', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'claude' },
|
||||
reasoningParts: [{
|
||||
text: 'prior Anthropic thinking',
|
||||
providerOptions: { anthropic: { signature: 'sig-1' } },
|
||||
}],
|
||||
},
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
|
||||
},
|
||||
];
|
||||
|
||||
const sdkMessages = buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('openai-1', 'openai', 'gpt-5', true),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
|
||||
assert.equal(sdkMessages.length, 2);
|
||||
const assistantContent = sdkMessages[0].content;
|
||||
assert.ok(Array.isArray(assistantContent));
|
||||
assert.deepEqual(
|
||||
assistantContent.map(part => part.type),
|
||||
['text', 'tool-call'],
|
||||
);
|
||||
assert.equal(sdkMessages[1].role, 'tool');
|
||||
});
|
||||
|
||||
test('metadata-free Responses reasoning discards its paired tool exchange', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [{ text: 'relay reasoning without replay metadata' }],
|
||||
},
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
|
||||
},
|
||||
];
|
||||
|
||||
const responsesMessages = buildHistory(messages);
|
||||
assert.deepEqual(responsesMessages, [{ role: 'assistant', content: 'Running it.' }]);
|
||||
|
||||
const responsesAfterModelSwitch = buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('provider-1', 'openai', 'model-2', true),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
assert.deepEqual(
|
||||
responsesAfterModelSwitch,
|
||||
[{ role: 'assistant', content: 'Running it.' }],
|
||||
);
|
||||
|
||||
const chatMessages = buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1', false),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
assert.equal(chatMessages.length, 2);
|
||||
assert.equal(chatMessages[1].role, 'tool');
|
||||
});
|
||||
|
||||
test('same-provider Chat to Responses switches keep the generic tool exchange', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [{ text: 'OpenAI Chat reasoning' }],
|
||||
openAIChatAssistantFields: { reasoning_content: 'OpenAI Chat reasoning' },
|
||||
},
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const modelId of ['model-1', 'model-2']) {
|
||||
const responsesMessages = buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('provider-1', 'openai', modelId, true),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
|
||||
assert.equal(responsesMessages.length, 2);
|
||||
const assistantContent = responsesMessages[0].content;
|
||||
assert.ok(Array.isArray(assistantContent));
|
||||
assert.deepEqual(
|
||||
assistantContent.map(part => part.type),
|
||||
['text', 'tool-call'],
|
||||
);
|
||||
assert.equal(responsesMessages[1].role, 'tool');
|
||||
}
|
||||
});
|
||||
|
||||
test('a durable summary survives when storage trimming shifts its boundary to zero', () => {
|
||||
const sdkMessages = buildCattySdkMessages({
|
||||
allMessages: [{
|
||||
id: 'recent-user',
|
||||
role: 'user',
|
||||
content: 'What should I do next?',
|
||||
timestamp: 1,
|
||||
}],
|
||||
contextCompaction: {
|
||||
summary: 'Earlier work completed the deployment.',
|
||||
compactedMessageCount: 0,
|
||||
},
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1', true),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
|
||||
assert.equal(sdkMessages.length, 3);
|
||||
assert.match(String(sdkMessages[0].content), /Earlier work completed the deployment/);
|
||||
assert.equal(sdkMessages[2].content, 'What should I do next?');
|
||||
});
|
||||
|
||||
test('tool exchanges with replayable reasoning are kept intact', () => {
|
||||
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
|
||||
const toolResult = {
|
||||
toolCallId: 'call-1',
|
||||
content: 'output',
|
||||
};
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: 'replayable reasoning',
|
||||
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
toolCalls: [toolCall],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 3,
|
||||
toolResults: [toolResult],
|
||||
},
|
||||
];
|
||||
|
||||
const sdkMessages = buildHistory(messages);
|
||||
|
||||
assert.equal(sdkMessages.length, 2);
|
||||
const assistantContent = sdkMessages[0].content;
|
||||
assert.ok(Array.isArray(assistantContent));
|
||||
assert.deepEqual(
|
||||
assistantContent.map((part) => (part as { type: string }).type),
|
||||
['reasoning', 'text', 'tool-call'],
|
||||
);
|
||||
assert.equal(sdkMessages[1].role, 'tool');
|
||||
});
|
||||
|
||||
test('final Responses preparation preserves encrypted reasoning for a tool exchange', () => {
|
||||
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [{
|
||||
text: 'replayable reasoning',
|
||||
providerOptions: {
|
||||
openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
toolCalls: [toolCall],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
|
||||
},
|
||||
];
|
||||
|
||||
const built = buildHistory(messages);
|
||||
const prepared = prepareCattyMessagesForStream(built, { preserveReasoning: true });
|
||||
const assistantContent = prepared[0].content;
|
||||
assert.ok(Array.isArray(assistantContent));
|
||||
const reasoning = assistantContent.find(part => part.type === 'reasoning');
|
||||
assert.deepEqual(reasoning?.providerOptions?.openai, {
|
||||
itemId: 'rs_new',
|
||||
reasoningEncryptedContent: 'enc-abc',
|
||||
});
|
||||
|
||||
const defaultPrepared = prepareCattyMessagesForStream(built);
|
||||
const defaultAssistantContent = defaultPrepared[0].content;
|
||||
assert.ok(Array.isArray(defaultAssistantContent));
|
||||
assert.equal(defaultAssistantContent.some(part => part.type === 'reasoning'), false);
|
||||
});
|
||||
|
||||
test('freshly streamed reasoning fragments with a null start payload keep the tool exchange', () => {
|
||||
// Mirrors a live `@ai-sdk/openai` Responses stream (store: false): the
|
||||
// reasoning-start fragment carries only the item id with
|
||||
// `reasoningEncryptedContent: null`, deltas omit the key, and the ciphertext
|
||||
// arrives on the reasoning-end fragment. The merge keeps both fragments for
|
||||
// the same item, which must still replay.
|
||||
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
|
||||
const toolResult = {
|
||||
toolCallId: 'call-1',
|
||||
content: 'output',
|
||||
};
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: '',
|
||||
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: null } },
|
||||
},
|
||||
{
|
||||
text: 'replayable reasoning',
|
||||
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
toolCalls: [toolCall],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 3,
|
||||
toolResults: [toolResult],
|
||||
},
|
||||
];
|
||||
|
||||
const sdkMessages = buildHistory(messages);
|
||||
|
||||
assert.equal(sdkMessages.length, 2);
|
||||
const assistantContent = sdkMessages[0].content;
|
||||
assert.ok(Array.isArray(assistantContent));
|
||||
// The empty ID-only start fragment is dropped from the replayed content;
|
||||
// the encrypted fragment for the same item carries the ciphertext.
|
||||
assert.deepEqual(
|
||||
assistantContent.map((part) => (part as { type: string }).type),
|
||||
['reasoning', 'text', 'tool-call'],
|
||||
);
|
||||
assert.equal(sdkMessages[1].role, 'tool');
|
||||
});
|
||||
|
||||
test('every text fragment of a multi-fragment replayable reasoning item is kept', () => {
|
||||
// A Responses reasoning item with several summary parts gives every
|
||||
// fragment the same item id; the ciphertext arrives only on the final
|
||||
// `output_item.done` fragment. Replayability is per item id, so all text
|
||||
// fragments of the item must survive the replay filter.
|
||||
const messages: ChatMessage[] = [{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: 'first summary',
|
||||
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: null } },
|
||||
},
|
||||
{
|
||||
text: 'second summary',
|
||||
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: null } },
|
||||
},
|
||||
{
|
||||
text: '',
|
||||
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}];
|
||||
|
||||
const sdkMessages = buildHistory(messages);
|
||||
|
||||
assert.equal(sdkMessages.length, 1);
|
||||
const content = sdkMessages[0].content;
|
||||
assert.ok(Array.isArray(content));
|
||||
assert.deepEqual(
|
||||
content.map((part) => (part as { type: string; text?: string }).text),
|
||||
['first summary', 'second summary', '', 'Done.'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an item id whose every fragment lacks ciphertext still discards the tool exchange', () => {
|
||||
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
|
||||
const toolResult = {
|
||||
toolCallId: 'call-1',
|
||||
content: 'output',
|
||||
};
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: 'legacy reasoning',
|
||||
providerOptions: { openai: { itemId: 'rs_legacy', reasoningEncryptedContent: null } },
|
||||
},
|
||||
],
|
||||
},
|
||||
toolCalls: [toolCall],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 3,
|
||||
toolResults: [toolResult],
|
||||
},
|
||||
];
|
||||
|
||||
const sdkMessages = buildHistory(messages);
|
||||
|
||||
assert.equal(sdkMessages.length, 1);
|
||||
assert.equal(sdkMessages[0].content, 'Running it.');
|
||||
});
|
||||
495
infrastructure/ai/harness/turnDrivers/cattyMessageBuilder.ts
Normal file
495
infrastructure/ai/harness/turnDrivers/cattyMessageBuilder.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
AISessionContextCompaction,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ToolResult,
|
||||
} from '../../types';
|
||||
import { buildTerminalWriteFingerprint } from '../toolResultDedup';
|
||||
import {
|
||||
buildHistoricalToolReplayMaps,
|
||||
buildHistoricalToolResultReplayText,
|
||||
buildHistoricalUserReplayContent,
|
||||
} from '../../../../components/ai/cattyHistoryReplay';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isInlineTextAttachment,
|
||||
} from '../../../../application/state/terminalSelectionAttachment';
|
||||
import {
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage,
|
||||
isProviderContinuationForSource,
|
||||
type OpenAIChatAssistantFields,
|
||||
type ProviderContinuation,
|
||||
type ProviderContinuationReasoningPart,
|
||||
} from '../../providerContinuation';
|
||||
import {
|
||||
toAssistantModelContent,
|
||||
type AssistantContentPart,
|
||||
type CattyProviderContinuationContext,
|
||||
} from '../../aiChatStreamingSupport';
|
||||
import { redactSecretsInValueForModel } from '../modelSecretRedaction';
|
||||
import { fitLargeUserInputForModel } from '../largeUserInput';
|
||||
import type { ToolOutputStore } from '../toolOutputStore';
|
||||
|
||||
const OPENAI_CHAT_ASSISTANT_FIELDS = Symbol('netcatty.openAIChatAssistantFields');
|
||||
|
||||
type ModelMessageWithOpenAIChatFields = ModelMessage & {
|
||||
[OPENAI_CHAT_ASSISTANT_FIELDS]?: OpenAIChatAssistantFields;
|
||||
};
|
||||
|
||||
function rememberOpenAIChatAssistantFields(
|
||||
message: ModelMessage,
|
||||
fields: OpenAIChatAssistantFields | undefined,
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
|
||||
): void {
|
||||
fieldsByMessage.set(message, fields);
|
||||
(message as ModelMessageWithOpenAIChatFields)[OPENAI_CHAT_ASSISTANT_FIELDS] = fields;
|
||||
}
|
||||
|
||||
function getRememberedOpenAIChatAssistantFields(
|
||||
message: ModelMessage,
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
|
||||
): OpenAIChatAssistantFields | undefined {
|
||||
if (fieldsByMessage.has(message)) return fieldsByMessage.get(message);
|
||||
return (message as ModelMessageWithOpenAIChatFields)[OPENAI_CHAT_ASSISTANT_FIELDS];
|
||||
}
|
||||
|
||||
function modelMessageHasToolCall(message: ModelMessage): boolean {
|
||||
if (message.role !== 'assistant' || !Array.isArray(message.content)) return false;
|
||||
return message.content.some((part) => part && typeof part === 'object' && (part as { type?: string }).type === 'tool-call');
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Responses histories — recorded before `reasoning-end` capture — store
|
||||
* reasoning parts that carry only the server-side `rs_…` item id and no
|
||||
* `reasoningEncryptedContent`. Replaying them against a stateless
|
||||
* (`store: false`) Responses turn makes the SDK emit a `reasoning` item
|
||||
* referencing an id that was never persisted, which the API rejects
|
||||
* ("Item with id 'rs_…' not found"), leaving the conversation unable to
|
||||
* continue. Dropping just the reasoning part is not enough: OpenAI Responses
|
||||
* stateless tool loops require the reasoning item to accompany its
|
||||
* function-call output, so replaying the paired `fc_…` call/result without it
|
||||
* is also rejected. Discard the entire incompatible call/result exchange
|
||||
* before replay (the assistant's plain text is still replayed); tool results
|
||||
* that reference the discarded call ids are skipped as well. Reasoning parts
|
||||
* with real ciphertext (or no OpenAI item id at all) are kept untouched.
|
||||
*/
|
||||
function getReasoningOpenAIItemId(
|
||||
part: ProviderContinuationReasoningPart,
|
||||
): string | undefined {
|
||||
const openaiOptions = part.providerOptions?.openai as
|
||||
| { itemId?: unknown }
|
||||
| undefined;
|
||||
const itemId = openaiOptions?.itemId;
|
||||
return typeof itemId === 'string' && itemId ? itemId : undefined;
|
||||
}
|
||||
|
||||
function partHasReasoningEncryptedContent(
|
||||
part: ProviderContinuationReasoningPart,
|
||||
): boolean {
|
||||
const openaiOptions = part.providerOptions?.openai as
|
||||
| { reasoningEncryptedContent?: unknown }
|
||||
| undefined;
|
||||
return typeof openaiOptions?.reasoningEncryptedContent === 'string'
|
||||
&& openaiOptions.reasoningEncryptedContent.length > 0;
|
||||
}
|
||||
|
||||
function hasOpenAIResponsesReasoningMetadata(
|
||||
parts: readonly ProviderContinuationReasoningPart[],
|
||||
): boolean {
|
||||
return parts.some(part => (
|
||||
getReasoningOpenAIItemId(part) !== undefined
|
||||
|| partHasReasoningEncryptedContent(part)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single Responses reasoning item is streamed as several fragments
|
||||
* (`reasoning-start`/`reasoning-delta`/`reasoning-end`): the initial fragment
|
||||
* carries only the item id (with `reasoningEncryptedContent: null`), deltas
|
||||
* omit the key, and the ciphertext arrives on the final fragment. The merge
|
||||
* therefore keeps an ID-only fragment next to the encrypted one for the *same*
|
||||
* item, so replayability must be decided per item id: an item is unreplayable
|
||||
* statelessly only when *no* fragment for that id carries ciphertext (the
|
||||
* legacy case where only the id was recorded). Fragments without an OpenAI
|
||||
* item id are always replayable.
|
||||
*/
|
||||
function hasUnreplayableReasoningItems(
|
||||
parts: readonly ProviderContinuationReasoningPart[],
|
||||
): boolean {
|
||||
const itemIds = new Set<string>();
|
||||
const itemIdsWithCiphertext = new Set<string>();
|
||||
let hasCiphertextWithoutItemId = false;
|
||||
for (const part of parts) {
|
||||
const itemId = getReasoningOpenAIItemId(part);
|
||||
if (!itemId) {
|
||||
if (partHasReasoningEncryptedContent(part)) {
|
||||
hasCiphertextWithoutItemId = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
itemIds.add(itemId);
|
||||
if (partHasReasoningEncryptedContent(part)) {
|
||||
itemIdsWithCiphertext.add(itemId);
|
||||
}
|
||||
}
|
||||
for (const itemId of itemIds) {
|
||||
if (!itemIdsWithCiphertext.has(itemId)) return true;
|
||||
}
|
||||
// The Responses converter also skips reasoning that has neither an item id
|
||||
// nor encrypted content. If that is the only reasoning attached to a tool
|
||||
// exchange, replaying the call/result without it is unsafe. Plain delta
|
||||
// fragments are still accepted when another fragment supplies the item's
|
||||
// ciphertext.
|
||||
return parts.length > 0 && itemIds.size === 0 && !hasCiphertextWithoutItemId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replayability is decided per reasoning item id (see
|
||||
* {@link hasUnreplayableReasoningItems}): when any fragment of an item carries
|
||||
* ciphertext, every fragment of that item is replayable. Filtering fragments
|
||||
* independently would drop the earlier text fragments of a multi-fragment
|
||||
* item whose ciphertext arrives only on the final fragment, truncating the
|
||||
* reasoning item sent on later turns.
|
||||
*/
|
||||
function collectReplayableReasoningParts(
|
||||
continuation: ProviderContinuation | undefined,
|
||||
): ProviderContinuationReasoningPart[] {
|
||||
const parts = continuation?.reasoningParts ?? [];
|
||||
const itemIdsWithCiphertext = new Set<string>();
|
||||
for (const part of parts) {
|
||||
const itemId = getReasoningOpenAIItemId(part);
|
||||
if (itemId && partHasReasoningEncryptedContent(part)) {
|
||||
itemIdsWithCiphertext.add(itemId);
|
||||
}
|
||||
}
|
||||
return parts.filter((part) => {
|
||||
const itemId = getReasoningOpenAIItemId(part);
|
||||
if (!itemId) return true;
|
||||
if (!itemIdsWithCiphertext.has(itemId)) return false;
|
||||
// The empty ID-only start fragment of a replayable item is redundant: the
|
||||
// encrypted fragment for the same item already identifies it, and
|
||||
// replaying both would duplicate the item id.
|
||||
return part.text.length > 0 || partHasReasoningEncryptedContent(part);
|
||||
});
|
||||
}
|
||||
|
||||
export function collectOpenAIChatAssistantFieldsForMessages(
|
||||
messages: ModelMessage[],
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
|
||||
): Array<OpenAIChatAssistantFields | undefined> {
|
||||
const fields: Array<OpenAIChatAssistantFields | undefined> = [];
|
||||
let previousMessageWasTool = false;
|
||||
for (const message of messages) {
|
||||
const needsContinuationFields = message.role === 'assistant'
|
||||
&& (modelMessageHasToolCall(message) || previousMessageWasTool);
|
||||
if (needsContinuationFields) {
|
||||
fields.push(getRememberedOpenAIChatAssistantFields(message, fieldsByMessage));
|
||||
}
|
||||
previousMessageWasTool = message.role === 'tool';
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export interface BuildCattySdkMessagesInput {
|
||||
allMessages: ChatMessage[];
|
||||
contextCompaction?: AISessionContextCompaction;
|
||||
includeCurrentUserMessage: boolean;
|
||||
trimmed: string;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
continuationContext: CattyProviderContinuationContext;
|
||||
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
|
||||
chatSessionId: string;
|
||||
toolOutputStore: ToolOutputStore;
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
|
||||
}
|
||||
|
||||
export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelMessage[] {
|
||||
const {
|
||||
allMessages,
|
||||
contextCompaction,
|
||||
includeCurrentUserMessage,
|
||||
trimmed,
|
||||
attachments,
|
||||
continuationContext,
|
||||
preserveTerminalToolResults = new Set<ToolResult>(),
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
fieldsByMessage,
|
||||
} = input;
|
||||
|
||||
const { resolvedToolCallsByAssistant, toolCallByToolResult } = buildHistoricalToolReplayMaps(allMessages);
|
||||
const nextFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
|
||||
const sdkMessages: ModelMessage[] = [];
|
||||
// Call ids whose exchange was discarded because the paired reasoning item is
|
||||
// not replayable statelessly; their tool results must not be replayed either.
|
||||
const discardedToolCallIds = new Set<string>();
|
||||
let previousHistoryMessageWasToolResult = false;
|
||||
|
||||
const compactedMessageCount = Math.min(
|
||||
allMessages.length,
|
||||
Math.max(0, contextCompaction?.compactedMessageCount ?? 0),
|
||||
);
|
||||
// The boundary can become zero when storage trims messages that were all
|
||||
// covered by the durable summary. Keep injecting that summary even though
|
||||
// no remaining persisted message needs to be skipped.
|
||||
if (contextCompaction?.summary) {
|
||||
sdkMessages.push({
|
||||
role: 'user',
|
||||
content: `[Previous conversation summary]\n\n${contextCompaction.summary}\n\n[Continue with the recent messages below.]`,
|
||||
});
|
||||
sdkMessages.push({
|
||||
role: 'assistant',
|
||||
content: 'I understand the previous conversation summary and will continue from the recent messages.',
|
||||
});
|
||||
}
|
||||
|
||||
for (const m of allMessages.slice(compactedMessageCount)) {
|
||||
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
|
||||
if (m.role === 'user') {
|
||||
const messageAttachments = m.attachments ?? m.images;
|
||||
const boundedContent = fitLargeUserInputForModel(m.content, chatSessionId, toolOutputStore);
|
||||
sdkMessages.push({
|
||||
role: 'user',
|
||||
content: buildHistoricalUserReplayContent(boundedContent, messageAttachments ?? []),
|
||||
});
|
||||
} else if (m.role === 'assistant') {
|
||||
const activeContinuation = isProviderContinuationForSource(
|
||||
m.providerContinuation,
|
||||
continuationContext.source,
|
||||
)
|
||||
? m.providerContinuation
|
||||
: undefined;
|
||||
const hasStoredOpenAIChatAssistantFields = Object.keys(
|
||||
m.providerContinuation?.openAIChatAssistantFields ?? {},
|
||||
).length > 0;
|
||||
// Provider/model identity alone cannot distinguish a Chat history from
|
||||
// a Responses history when the user changes only the API format. Chat
|
||||
// continuation fields are explicit evidence that its provider-specific
|
||||
// reasoning must not be replayed as a Responses reasoning item.
|
||||
const replayContinuation = continuationContext.usesOpenAIResponses
|
||||
&& hasStoredOpenAIChatAssistantFields
|
||||
? undefined
|
||||
: activeContinuation;
|
||||
const openAIChatAssistantFields = continuationContext.usesOpenAIResponses
|
||||
? undefined
|
||||
: getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
m,
|
||||
continuationContext.source,
|
||||
);
|
||||
if (m.toolCalls?.length) {
|
||||
const resolvedToolCalls = resolvedToolCallsByAssistant.get(m);
|
||||
const resolvedCalls = resolvedToolCalls
|
||||
? m.toolCalls.filter(tc => resolvedToolCalls.has(tc))
|
||||
: [];
|
||||
// An unreplayable (id-only, never encrypted) reasoning item poisons
|
||||
// the whole Responses tool exchange: without it the paired
|
||||
// function-call output is rejected, so discard the calls instead of
|
||||
// replaying them orphaned. The same applies when a model switch makes
|
||||
// reasoning metadata belong to a different source: it cannot be sent
|
||||
// to the active Responses model, so its tool exchange must not be sent
|
||||
// without it. Freshly streamed items whose ciphertext arrived on a
|
||||
// later fragment stay replayable.
|
||||
const storedReasoningParts = m.providerContinuation?.reasoningParts ?? [];
|
||||
const storedSource = m.providerContinuation?.source;
|
||||
const sameProviderConfig = storedSource?.providerConfigId
|
||||
=== continuationContext.source.providerConfigId
|
||||
&& storedSource?.providerType === continuationContext.source.providerType;
|
||||
const hasSourceMismatchedReasoning = !replayContinuation
|
||||
&& (
|
||||
hasOpenAIResponsesReasoningMetadata(storedReasoningParts)
|
||||
// A model change within the same Responses configuration is also
|
||||
// enough evidence that metadata-free reasoning came from this
|
||||
// wire format. Cross-provider Anthropic/Google reasoning remains
|
||||
// a generic, replayable call/result exchange. OpenAI Chat history
|
||||
// is also generic when its captured assistant fields identify the
|
||||
// original wire format.
|
||||
|| (
|
||||
sameProviderConfig
|
||||
&& storedReasoningParts.length > 0
|
||||
&& !hasStoredOpenAIChatAssistantFields
|
||||
)
|
||||
);
|
||||
const hasUnreplayableReasoning = resolvedCalls.length > 0
|
||||
&& continuationContext.usesOpenAIResponses
|
||||
&& (
|
||||
hasSourceMismatchedReasoning
|
||||
|| hasUnreplayableReasoningItems(replayContinuation?.reasoningParts ?? [])
|
||||
);
|
||||
if (hasUnreplayableReasoning) {
|
||||
for (const tc of resolvedCalls) discardedToolCallIds.add(tc.id);
|
||||
}
|
||||
const replayedCalls = hasUnreplayableReasoning ? [] : resolvedCalls;
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
if (replayedCalls.length > 0) {
|
||||
for (const part of collectReplayableReasoningParts(replayContinuation)) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
contentParts.push({
|
||||
type: 'reasoning' as const,
|
||||
text: part.text,
|
||||
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (m.content) {
|
||||
contentParts.push({
|
||||
type: 'text' as const,
|
||||
text: m.content,
|
||||
...(replayContinuation?.textProviderOptions ? { providerOptions: replayContinuation.textProviderOptions } : {}),
|
||||
});
|
||||
}
|
||||
for (const tc of replayedCalls) {
|
||||
const providerOptions = replayContinuation?.toolCallProviderOptionsById?.[tc.id];
|
||||
contentParts.push({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
input: redactSecretsInValueForModel(tc.arguments ?? {}),
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
if (contentParts.length > 0) {
|
||||
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
|
||||
sdkMessages.push(message);
|
||||
if (replayedCalls.length > 0) {
|
||||
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
|
||||
}
|
||||
}
|
||||
} else if (m.content) {
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
for (const part of collectReplayableReasoningParts(replayContinuation)) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
contentParts.push({
|
||||
type: 'reasoning' as const,
|
||||
text: part.text,
|
||||
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
contentParts.push({
|
||||
type: 'text' as const,
|
||||
text: m.content,
|
||||
...(replayContinuation?.textProviderOptions ? { providerOptions: replayContinuation.textProviderOptions } : {}),
|
||||
});
|
||||
const message: ModelMessage = {
|
||||
role: 'assistant',
|
||||
content: toAssistantModelContent(contentParts),
|
||||
};
|
||||
sdkMessages.push(message);
|
||||
if (currentMessageFollowsToolResult) {
|
||||
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
|
||||
}
|
||||
}
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
const replayableResults = m.toolResults.filter(
|
||||
(tr) => !discardedToolCallIds.has(tr.toolCallId),
|
||||
);
|
||||
if (replayableResults.length > 0) {
|
||||
sdkMessages.push({
|
||||
role: 'tool',
|
||||
content: replayableResults.map(tr => {
|
||||
const toolCall = toolCallByToolResult.get(tr);
|
||||
return {
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: toolCall?.name ?? 'unknown',
|
||||
output: {
|
||||
type: 'text' as const,
|
||||
value: buildHistoricalToolResultReplayText(tr, toolCall, {
|
||||
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length
|
||||
&& m.toolResults.some((tr) => !discardedToolCallIds.has(tr.toolCallId));
|
||||
}
|
||||
|
||||
if (includeCurrentUserMessage) {
|
||||
if (attachments?.length) {
|
||||
const modelText = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter(
|
||||
(attachment) => !isInlineTextAttachment(attachment),
|
||||
);
|
||||
if (!modelAttachments.length) {
|
||||
sdkMessages.push({ role: 'user', content: modelText });
|
||||
} else {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: modelText });
|
||||
for (const att of modelAttachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
}
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [message, fields] of nextFieldsByMessage.entries()) {
|
||||
fieldsByMessage.set(message, fields);
|
||||
}
|
||||
|
||||
return sdkMessages;
|
||||
}
|
||||
|
||||
export function collectToolResultsAfterMessage(
|
||||
messages: ChatMessage[],
|
||||
messageId: string,
|
||||
): Set<ToolResult> {
|
||||
const results = new Set<ToolResult>();
|
||||
let afterMessage = false;
|
||||
for (const message of messages) {
|
||||
if (message.id === messageId) {
|
||||
afterMessage = true;
|
||||
continue;
|
||||
}
|
||||
if (!afterMessage || message.role !== 'tool' || !message.toolResults?.length) continue;
|
||||
for (const result of message.toolResults) {
|
||||
results.add(result);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function collectPreservedTerminalWriteFingerprints(
|
||||
messages: ChatMessage[],
|
||||
messageId: string,
|
||||
chatSessionId: string,
|
||||
): string[] {
|
||||
const preservedResults = collectToolResultsAfterMessage(messages, messageId);
|
||||
const { toolCallByToolResult } = buildHistoricalToolReplayMaps(messages);
|
||||
const fingerprints: string[] = [];
|
||||
for (const result of preservedResults) {
|
||||
const call = toolCallByToolResult.get(result);
|
||||
if (call?.name !== 'terminal_execute' && call?.name !== 'terminal_start') continue;
|
||||
const fingerprint = buildTerminalWriteFingerprint(call.name, chatSessionId, call.arguments);
|
||||
if (fingerprint) fingerprints.push(fingerprint);
|
||||
}
|
||||
return fingerprints;
|
||||
}
|
||||
|
||||
export function createContinuationContext(
|
||||
providerConfigId: string,
|
||||
providerType: string,
|
||||
modelId: string,
|
||||
usesOpenAIResponses = false,
|
||||
): CattyProviderContinuationContext {
|
||||
return {
|
||||
source: {
|
||||
providerConfigId,
|
||||
providerType,
|
||||
modelId,
|
||||
},
|
||||
usesOpenAIResponses,
|
||||
openAIChatAssistantFields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export type { CattyProviderContinuationContext, ProviderContinuation };
|
||||
585
infrastructure/ai/harness/turnDrivers/cattyStreamProcessor.ts
Normal file
585
infrastructure/ai/harness/turnDrivers/cattyStreamProcessor.ts
Normal file
@@ -0,0 +1,585 @@
|
||||
import { streamText, isStepCount, type ModelMessage } from 'ai';
|
||||
import { classifyError } from '../../errorClassifier';
|
||||
import { isRequestTooLargeError } from '../../errorClassifier';
|
||||
import { isSdkStreamStateError } from '../../shared/streamStateErrors';
|
||||
import {
|
||||
createCattyRequestTooLargeRetryError,
|
||||
hadToolProgressBeforeRequestTooLarge,
|
||||
} from '../../cattyRequestTooLargeRetry';
|
||||
import { mapCattyStreamChunkToAgentEvents } from '../agentEventAdapter';
|
||||
import type { AgentEvent } from '../types';
|
||||
import type { ProviderAdvancedParams } from '../../types';
|
||||
import type { CattyReasoningProviderOptions } from '../../cattyReasoning';
|
||||
import { createModelFromConfig } from '../../sdk/providers';
|
||||
import type { CattyToolsBundle } from '../capabilityTools';
|
||||
import { buildCattyToolApproval } from '../cattyToolApproval';
|
||||
import type { CattyRuntimeContext } from '../cattyRuntimeContext';
|
||||
import { buildCattyStreamTimeouts } from '../streamTimeouts';
|
||||
import {
|
||||
extractProviderContinuationFromRawChunk,
|
||||
mergeProviderContinuation,
|
||||
normalizeProviderContinuationOptions,
|
||||
withProviderContinuationSource,
|
||||
type ProviderContinuation,
|
||||
} from '../../providerContinuation';
|
||||
import {
|
||||
formatToolErrorContent,
|
||||
generateId,
|
||||
isToolResultError,
|
||||
resolveStreamChunkToolCallId,
|
||||
type CattyProviderContinuationContext,
|
||||
type ErrorChunk,
|
||||
type RawChunk,
|
||||
type ReasoningChunk,
|
||||
type StreamChunk,
|
||||
type TextDeltaChunk,
|
||||
type ToolApprovalResponseChunk,
|
||||
type ToolCallChunk,
|
||||
type ToolErrorChunk,
|
||||
type ToolOutputDeniedChunk,
|
||||
type ToolResultChunk,
|
||||
} from '../../aiChatStreamingSupport';
|
||||
import type { ChatMessage } from '../../types';
|
||||
|
||||
export type CattyModel = ReturnType<typeof createModelFromConfig>;
|
||||
|
||||
export interface CattyStreamUiSink {
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
}
|
||||
|
||||
export interface ProcessCattyStreamInput {
|
||||
streamSessionId: string;
|
||||
model: CattyModel;
|
||||
systemPrompt: string;
|
||||
toolsBundle: CattyToolsBundle;
|
||||
sdkMessages: ModelMessage[];
|
||||
signal: AbortSignal;
|
||||
currentAssistantMsgId: string;
|
||||
maxIterations: number;
|
||||
advancedParams?: ProviderAdvancedParams;
|
||||
reasoningProviderOptions?: CattyReasoningProviderOptions;
|
||||
continuationContext?: CattyProviderContinuationContext;
|
||||
turnId?: string;
|
||||
commandTimeoutMs?: number;
|
||||
responseIdleTimeoutMs?: number;
|
||||
runtimeContext: CattyRuntimeContext;
|
||||
onAgentEvent?: (event: AgentEvent) => void;
|
||||
prepareStep?: (args: {
|
||||
stepNumber: number;
|
||||
messages: ModelMessage[];
|
||||
runtimeContext: CattyRuntimeContext;
|
||||
}) => Promise<{ messages: ModelMessage[]; runtimeContext?: CattyRuntimeContext } | undefined>;
|
||||
ui: CattyStreamUiSink;
|
||||
}
|
||||
|
||||
export interface ProcessCattyStreamResult {
|
||||
usage?: {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
performance?: {
|
||||
responseTimeMs?: number;
|
||||
timeToFirstOutputMs?: number;
|
||||
outputTokensPerSecond?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Skip trace emission for SDK-internal stream bookkeeping errors we suppress in UI. */
|
||||
export function shouldEmitAgentEventsForStreamChunk(chunk: StreamChunk): boolean {
|
||||
if (chunk.type !== 'error') return true;
|
||||
return !isSdkStreamStateError((chunk as ErrorChunk).error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect provider metadata that actually carries replayable reasoning
|
||||
* ciphertext (e.g. `openai.reasoningEncryptedContent`). A null/absent payload
|
||||
* must not be persisted: merging it over a previously captured ciphertext
|
||||
* would invalidate the stateless reasoning replay.
|
||||
*/
|
||||
function hasReasoningEncryptedContent(options: Record<string, Record<string, unknown>>): boolean {
|
||||
return Object.values(options).some(providerOptions =>
|
||||
typeof providerOptions?.reasoningEncryptedContent === 'string'
|
||||
&& providerOptions.reasoningEncryptedContent.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export async function processCattyStream(input: ProcessCattyStreamInput): Promise<ProcessCattyStreamResult> {
|
||||
const {
|
||||
streamSessionId,
|
||||
model,
|
||||
systemPrompt,
|
||||
toolsBundle,
|
||||
sdkMessages,
|
||||
signal,
|
||||
currentAssistantMsgId,
|
||||
maxIterations,
|
||||
advancedParams,
|
||||
reasoningProviderOptions,
|
||||
continuationContext,
|
||||
turnId,
|
||||
commandTimeoutMs,
|
||||
responseIdleTimeoutMs,
|
||||
runtimeContext: initialRuntimeContext,
|
||||
onAgentEvent,
|
||||
prepareStep,
|
||||
ui,
|
||||
} = input;
|
||||
|
||||
let runtimeContext = initialRuntimeContext;
|
||||
const { tools, toolsContext } = toolsBundle;
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: sdkMessages,
|
||||
instructions: systemPrompt,
|
||||
tools,
|
||||
toolsContext,
|
||||
runtimeContext,
|
||||
toolApproval: buildCattyToolApproval({
|
||||
permissionMode: runtimeContext.permissionMode,
|
||||
chatSessionId: runtimeContext.chatSessionId,
|
||||
}),
|
||||
stopWhen: isStepCount(maxIterations),
|
||||
abortSignal: signal,
|
||||
include: { rawChunks: true },
|
||||
timeout: buildCattyStreamTimeouts({
|
||||
permissionMode: runtimeContext.permissionMode,
|
||||
commandTimeoutMs,
|
||||
responseIdleTimeoutMs,
|
||||
maxIterations,
|
||||
}),
|
||||
telemetry: {
|
||||
functionId: `catty-${runtimeContext.agentKind}`,
|
||||
metadata: {
|
||||
chatSessionId: runtimeContext.chatSessionId,
|
||||
turnId: runtimeContext.turnId,
|
||||
},
|
||||
},
|
||||
onStart: ({ callId, modelId, runtimeContext: startContext }) => {
|
||||
onAgentEvent?.({
|
||||
id: `model-call-start-${callId}`,
|
||||
type: 'model_call_start',
|
||||
sessionId: streamSessionId,
|
||||
chatSessionId: startContext.chatSessionId,
|
||||
backend: 'catty',
|
||||
timestamp: Date.now(),
|
||||
turnId,
|
||||
callId,
|
||||
modelId,
|
||||
providerId: startContext.providerId,
|
||||
} as AgentEvent);
|
||||
},
|
||||
onStepEnd: (step) => {
|
||||
const usage = step.usage;
|
||||
onAgentEvent?.({
|
||||
id: `step-end-${step.callId}-${step.stepNumber}`,
|
||||
type: 'step_end',
|
||||
sessionId: streamSessionId,
|
||||
chatSessionId: step.runtimeContext.chatSessionId,
|
||||
backend: 'catty',
|
||||
timestamp: Date.now(),
|
||||
turnId,
|
||||
callId: step.callId,
|
||||
stepNumber: step.stepNumber,
|
||||
modelId: step.model.modelId,
|
||||
finishReason: step.finishReason,
|
||||
promptTokens: usage.inputTokens ?? 0,
|
||||
completionTokens: usage.outputTokens ?? 0,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
} as AgentEvent);
|
||||
},
|
||||
onEnd: ({ callId, usage, runtimeContext: endContext }) => {
|
||||
if (usage) {
|
||||
onAgentEvent?.({
|
||||
id: `usage-${callId}`,
|
||||
type: 'usage',
|
||||
sessionId: streamSessionId,
|
||||
chatSessionId: endContext.chatSessionId,
|
||||
backend: 'catty',
|
||||
timestamp: Date.now(),
|
||||
turnId,
|
||||
promptTokens: usage.inputTokens ?? 0,
|
||||
completionTokens: usage.outputTokens ?? 0,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
estimated: false,
|
||||
} as AgentEvent);
|
||||
}
|
||||
},
|
||||
...(prepareStep ? {
|
||||
prepareStep: async ({ stepNumber, messages, runtimeContext: stepRuntimeContext }) => {
|
||||
const prepared = await prepareStep({
|
||||
stepNumber,
|
||||
messages,
|
||||
runtimeContext: stepRuntimeContext as CattyRuntimeContext,
|
||||
});
|
||||
if (prepared?.runtimeContext) {
|
||||
runtimeContext = prepared.runtimeContext;
|
||||
}
|
||||
return prepared ?? { messages };
|
||||
},
|
||||
} : {}),
|
||||
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
|
||||
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
|
||||
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
|
||||
...(advancedParams?.frequencyPenalty != null && { frequencyPenalty: advancedParams.frequencyPenalty }),
|
||||
...(advancedParams?.presencePenalty != null && { presencePenalty: advancedParams.presencePenalty }),
|
||||
...(reasoningProviderOptions ? { providerOptions: reasoningProviderOptions } : {}),
|
||||
});
|
||||
|
||||
let activeMsgId = currentAssistantMsgId;
|
||||
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
|
||||
let hadToolProgress = false;
|
||||
const reader = result.stream.getReader();
|
||||
|
||||
let pendingText = '';
|
||||
let rafId: number | null = null;
|
||||
|
||||
const clearCompactionStatusFromAssistant = (messageId: string) => {
|
||||
ui.updateMessageById(streamSessionId, messageId, msg =>
|
||||
msg.role === 'assistant' && msg.statusText
|
||||
? { ...msg, statusText: undefined }
|
||||
: msg,
|
||||
);
|
||||
};
|
||||
|
||||
const ensureAssistantMessage = (): string => {
|
||||
if (lastAddedRole !== 'tool') return activeMsgId;
|
||||
clearCompactionStatusFromAssistant(activeMsgId);
|
||||
const newId = generateId();
|
||||
ui.addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
return activeMsgId;
|
||||
};
|
||||
|
||||
const updateAssistantContinuation = (
|
||||
messageId: string,
|
||||
continuation: ProviderContinuation | undefined,
|
||||
thinkingText = '',
|
||||
) => {
|
||||
if (!continuation && !thinkingText) return;
|
||||
const sourcedContinuation = withProviderContinuationSource(continuation, continuationContext?.source);
|
||||
ui.updateMessageById(streamSessionId, messageId, msg => {
|
||||
const providerContinuation = mergeProviderContinuation(msg.providerContinuation, sourcedContinuation);
|
||||
return {
|
||||
...msg,
|
||||
...(providerContinuation ? { providerContinuation } : {}),
|
||||
...(thinkingText ? { thinking: (msg.thinking || '') + thinkingText } : {}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getOpenAIReasoningText = (continuation: ProviderContinuation | undefined): string => {
|
||||
const reasoningContent = continuation?.openAIChatAssistantFields?.reasoning_content;
|
||||
return typeof reasoningContent === 'string' ? reasoningContent : '';
|
||||
};
|
||||
|
||||
const flushText = () => {
|
||||
if (pendingText) {
|
||||
const text = pendingText;
|
||||
pendingText = '';
|
||||
if (lastAddedRole === 'tool') {
|
||||
clearCompactionStatusFromAssistant(activeMsgId);
|
||||
const newId = generateId();
|
||||
ui.addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
} else {
|
||||
ui.updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + text,
|
||||
...(msg.statusText ? { statusText: undefined } : {}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
rafId = null;
|
||||
};
|
||||
|
||||
const cancelPendingFlush = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const appendToolResultToUi = (toolCallId: string, content: string, isError: boolean) => {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
hadToolProgress = true;
|
||||
ui.updateMessageById(streamSessionId, activeMsgId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
ui.addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
toolResults: [{
|
||||
toolCallId,
|
||||
content,
|
||||
isError,
|
||||
}],
|
||||
timestamp: Date.now(),
|
||||
executionStatus: 'completed',
|
||||
});
|
||||
lastAddedRole = 'tool';
|
||||
};
|
||||
|
||||
const deniedToolResultIds = new Set<string>();
|
||||
const appendDeniedToolResultToUi = (toolCallId: string, reason?: unknown) => {
|
||||
if (deniedToolResultIds.has(toolCallId)) return;
|
||||
deniedToolResultIds.add(toolCallId);
|
||||
appendToolResultToUi(
|
||||
toolCallId,
|
||||
formatToolErrorContent(reason, 'Tool execution denied.'),
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
let readResult: ReadableStreamReadResult<unknown>;
|
||||
try {
|
||||
readResult = await reader.read();
|
||||
} catch (readErr) {
|
||||
if (isRequestTooLargeError(readErr)) {
|
||||
throw createCattyRequestTooLargeRetryError(readErr, hadToolProgress);
|
||||
}
|
||||
throw readErr;
|
||||
}
|
||||
const { done, value } = readResult;
|
||||
if (done) break;
|
||||
const chunk = value as StreamChunk;
|
||||
if (shouldEmitAgentEventsForStreamChunk(chunk)) {
|
||||
for (const agentEvent of mapCattyStreamChunkToAgentEvents(chunk, {
|
||||
sessionId: streamSessionId,
|
||||
chatSessionId: streamSessionId,
|
||||
turnId,
|
||||
})) {
|
||||
onAgentEvent?.(agentEvent);
|
||||
}
|
||||
}
|
||||
switch (chunk.type) {
|
||||
case 'text':
|
||||
case 'text-delta': {
|
||||
const typedChunk = chunk as TextDeltaChunk;
|
||||
const text = typedChunk.text ?? typedChunk.textDelta;
|
||||
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
|
||||
if (providerOptions) {
|
||||
const messageId = ensureAssistantMessage();
|
||||
updateAssistantContinuation(messageId, { textProviderOptions: providerOptions });
|
||||
}
|
||||
if (text) {
|
||||
pendingText += text;
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(flushText);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning':
|
||||
case 'reasoning-start':
|
||||
case 'reasoning-delta': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ReasoningChunk;
|
||||
const rText = typedChunk.text ?? typedChunk.textDelta ?? typedChunk.delta ?? '';
|
||||
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
|
||||
const continuation = rText || providerOptions
|
||||
? {
|
||||
reasoningParts: [{
|
||||
text: rText,
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
}],
|
||||
} satisfies ProviderContinuation
|
||||
: undefined;
|
||||
if (continuation || rText) {
|
||||
const messageId = ensureAssistantMessage();
|
||||
updateAssistantContinuation(messageId, continuation, rText);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'raw': {
|
||||
const typedChunk = chunk as RawChunk;
|
||||
const continuation = extractProviderContinuationFromRawChunk(typedChunk.rawValue);
|
||||
if (continuation) {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const messageId = ensureAssistantMessage();
|
||||
updateAssistantContinuation(messageId, continuation, getOpenAIReasoningText(continuation));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning-end': {
|
||||
// With Responses `store: false`, the SDK delivers the reasoning
|
||||
// item's encrypted content on the reasoning-end chunk (via
|
||||
// output_item.done). It must be merged into the persisted reasoning
|
||||
// parts or the stateless replay silently drops the reasoning item.
|
||||
// Skip metadata without ciphertext (e.g. store:true item ids) so a
|
||||
// null payload cannot overwrite a previously captured one.
|
||||
const typedChunk = chunk as { providerMetadata?: unknown };
|
||||
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
|
||||
if (providerOptions && hasReasoningEncryptedContent(providerOptions)) {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const messageId = ensureAssistantMessage();
|
||||
updateAssistantContinuation(messageId, {
|
||||
reasoningParts: [{ text: '', providerOptions }],
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text-start':
|
||||
case 'text-end':
|
||||
case 'start':
|
||||
case 'finish':
|
||||
case 'start-step':
|
||||
case 'finish-step':
|
||||
case 'tool-approval-request':
|
||||
break;
|
||||
case 'tool-approval-response': {
|
||||
const typedChunk = chunk as ToolApprovalResponseChunk;
|
||||
if (typedChunk.approved === false) {
|
||||
const toolCallId = resolveStreamChunkToolCallId(typedChunk);
|
||||
if (toolCallId) {
|
||||
appendDeniedToolResultToUi(toolCallId, typedChunk.reason);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool-call': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolCallChunk;
|
||||
hadToolProgress = true;
|
||||
const messageId = ensureAssistantMessage();
|
||||
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
|
||||
ui.updateMessageById(streamSessionId, messageId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), {
|
||||
id: typedChunk.toolCallId,
|
||||
name: typedChunk.toolName,
|
||||
arguments: (typedChunk.input ?? typedChunk.args) as Record<string, unknown>,
|
||||
}],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
if (providerOptions) {
|
||||
updateAssistantContinuation(messageId, {
|
||||
toolCallProviderOptionsById: {
|
||||
[typedChunk.toolCallId]: providerOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
const typedChunk = chunk as ToolResultChunk;
|
||||
const toolOutput = typedChunk.output ?? typedChunk.result;
|
||||
appendToolResultToUi(
|
||||
typedChunk.toolCallId,
|
||||
typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput),
|
||||
isToolResultError(toolOutput),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'tool-error': {
|
||||
const typedChunk = chunk as ToolErrorChunk;
|
||||
appendToolResultToUi(
|
||||
typedChunk.toolCallId,
|
||||
formatToolErrorContent(typedChunk.error),
|
||||
true,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'tool-output-denied': {
|
||||
const typedChunk = chunk as ToolOutputDeniedChunk;
|
||||
appendDeniedToolResultToUi(typedChunk.toolCallId);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
const typedChunk = chunk as ErrorChunk;
|
||||
if (isSdkStreamStateError(typedChunk.error)) {
|
||||
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
|
||||
break;
|
||||
}
|
||||
if (isRequestTooLargeError(typedChunk.error)) {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
throw createCattyRequestTooLargeRetryError(
|
||||
typedChunk.error,
|
||||
hadToolProgress,
|
||||
);
|
||||
}
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
ui.updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
ui.addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(typedChunk.error),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const usage = await result.usage;
|
||||
const finalStep = await result.finalStep;
|
||||
const performance = finalStep?.performance;
|
||||
|
||||
if (performance) {
|
||||
onAgentEvent?.({
|
||||
id: `performance-${turnId ?? Date.now()}`,
|
||||
type: 'performance',
|
||||
sessionId: streamSessionId,
|
||||
chatSessionId: runtimeContext.chatSessionId,
|
||||
backend: 'catty',
|
||||
timestamp: Date.now(),
|
||||
turnId,
|
||||
responseTimeMs: performance.responseTimeMs,
|
||||
timeToFirstOutputMs: performance.timeToFirstOutputMs,
|
||||
outputTokensPerSecond: performance.outputTokensPerSecond,
|
||||
} as AgentEvent);
|
||||
}
|
||||
|
||||
return {
|
||||
usage: usage ? {
|
||||
promptTokens: usage.inputTokens,
|
||||
completionTokens: usage.outputTokens,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
} : undefined,
|
||||
performance: performance ? {
|
||||
responseTimeMs: performance.responseTimeMs,
|
||||
timeToFirstOutputMs: performance.timeToFirstOutputMs,
|
||||
outputTokensPerSecond: performance.outputTokensPerSecond,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { hadToolProgressBeforeRequestTooLarge };
|
||||
549
infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts
Normal file
549
infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type { OpenAIChatAssistantFields } from '../../providerContinuation';
|
||||
import {
|
||||
DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
estimateUnknownTokens,
|
||||
findSafeChatMessageCompactionSplitIndex,
|
||||
resolveContextWindow,
|
||||
} from '../../contextCompaction';
|
||||
import { buildSystemPrompt } from '../../cattyAgent/systemPrompt';
|
||||
import {
|
||||
isWebSearchReady,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
normalizeResponseIdleTimeoutSeconds,
|
||||
resolveOpenAIApi,
|
||||
resolveProviderStyle,
|
||||
} from '../../types';
|
||||
import {
|
||||
applyResponsesApiStatelessStoreOption,
|
||||
buildCattyReasoningProviderOptions,
|
||||
estimateReasoningOutputReserve,
|
||||
} from '../../cattyReasoning';
|
||||
import { createModelFromConfig } from '../../sdk/providers';
|
||||
import { createCattyToolsFromCatalog } from '../capabilityTools';
|
||||
import { createInitialCattyRuntimeContext } from '../cattyRuntimeContext';
|
||||
import { prepareStepContext, extractLatestUserGoal } from '../contextManager';
|
||||
import {
|
||||
compactCattyMessages,
|
||||
prepareCattyMessagesForStream,
|
||||
} from '../cattyRuntime';
|
||||
import { computeTotalInputTokens, DEFAULT_MAX_OUTPUT_TOKENS } from '../contextBudget';
|
||||
import { clearChatSessionCancelled } from '../agentStop';
|
||||
import { isRequestTooLargeError } from '../../errorClassifier';
|
||||
import { getNetcattyBridge, generateId, resolveUserSkillsContext } from '../../aiChatStreamingSupport';
|
||||
import {
|
||||
buildCattySdkMessages,
|
||||
collectOpenAIChatAssistantFieldsForMessages,
|
||||
collectPreservedTerminalWriteFingerprints,
|
||||
collectToolResultsAfterMessage,
|
||||
createContinuationContext,
|
||||
} from './cattyMessageBuilder';
|
||||
import { hadToolProgressBeforeRequestTooLarge, processCattyStream } from './cattyStreamProcessor';
|
||||
import type { CattyTurnInput, TurnDriver, TurnDriverContext } from './types';
|
||||
import { fitLargeUserInputForModel } from '../largeUserInput';
|
||||
import { buildPromptContextSnapshot } from '../promptContextSnapshot';
|
||||
|
||||
export class CattyTurnDriver implements TurnDriver {
|
||||
readonly backend = 'catty' as const;
|
||||
|
||||
async run(input: import('./types').TurnInput, ctx: TurnDriverContext): Promise<void> {
|
||||
if (input.backend !== 'catty') {
|
||||
throw new Error('CattyTurnDriver received non-catty input');
|
||||
}
|
||||
await runCattyTurn(input, ctx);
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
// Abort is handled via AbortSignal on the turn input.
|
||||
}
|
||||
}
|
||||
|
||||
async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Promise<void> {
|
||||
const {
|
||||
chatSessionId: sessionId,
|
||||
userText: trimmed,
|
||||
signal,
|
||||
currentSession,
|
||||
assistantMsgId,
|
||||
context,
|
||||
attachments,
|
||||
maxIterations,
|
||||
bridge,
|
||||
ui,
|
||||
} = input;
|
||||
|
||||
const netcattyBridge = (bridge ?? getNetcattyBridge()) as NonNullable<ReturnType<typeof getNetcattyBridge>>;
|
||||
const toolOutputTempBridge = netcattyBridge as typeof netcattyBridge & {
|
||||
getToolOutputPersistenceStatus?: () => Promise<{ durable: boolean; reason?: string }>;
|
||||
writeToolOutputTemp?: (
|
||||
record: import('../toolOutputStore').PersistedToolOutputRecord,
|
||||
content: string,
|
||||
) => Promise<{ ok: boolean; path?: string; error?: string }>;
|
||||
restoreToolOutputTemp?: (
|
||||
handleId: string,
|
||||
chatSessionId: string,
|
||||
) => Promise<{ path: string; record: import('../toolOutputStore').PersistedToolOutputRecord } | null>;
|
||||
readToolOutputTemp?: (
|
||||
path: string,
|
||||
request: import('../toolOutputStore').ReadToolOutputInput,
|
||||
) => Promise<Omit<import('../toolOutputStore').ToolOutputReadResult, 'handleId' | 'storedChars' | 'sourceTruncated'> | null>;
|
||||
deleteToolOutputTemp?: (path: string) => Promise<{ ok: boolean }>;
|
||||
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
|
||||
deleteTerminalToolOutputsTemp?: (
|
||||
chatSessionId: string,
|
||||
terminalSessionId: string,
|
||||
) => Promise<{ deletedCount: number }>;
|
||||
};
|
||||
const persistenceStatus = await toolOutputTempBridge.getToolOutputPersistenceStatus?.()
|
||||
.catch(() => ({ durable: false }));
|
||||
if (
|
||||
toolOutputTempBridge.writeToolOutputTemp
|
||||
&& toolOutputTempBridge.readToolOutputTemp
|
||||
&& toolOutputTempBridge.deleteToolOutputTemp
|
||||
) {
|
||||
ctx.toolOutputStore.setPersistence?.({
|
||||
write: async (record, content) => {
|
||||
if (!persistenceStatus?.durable) {
|
||||
throw new Error(persistenceStatus?.reason || 'Secure local storage is unavailable.');
|
||||
}
|
||||
const result = await toolOutputTempBridge.writeToolOutputTemp!(record, content);
|
||||
if (!result.ok || !result.path) {
|
||||
throw new Error(result.error || 'Unable to persist tool output.');
|
||||
}
|
||||
return result.path;
|
||||
},
|
||||
restore: persistenceStatus?.durable && toolOutputTempBridge.restoreToolOutputTemp
|
||||
? (handleId, chatSessionId) => toolOutputTempBridge.restoreToolOutputTemp!(handleId, chatSessionId)
|
||||
: undefined,
|
||||
read: (path, request) => toolOutputTempBridge.readToolOutputTemp!(path, request),
|
||||
delete: async path => {
|
||||
await toolOutputTempBridge.deleteToolOutputTemp!(path);
|
||||
},
|
||||
deleteSession: toolOutputTempBridge.deleteChatToolOutputsTemp
|
||||
? async chatSessionId => {
|
||||
await toolOutputTempBridge.deleteChatToolOutputsTemp!(chatSessionId);
|
||||
}
|
||||
: undefined,
|
||||
deleteTerminalSession: toolOutputTempBridge.deleteTerminalToolOutputsTemp
|
||||
? async (chatSessionId, terminalSessionId) => {
|
||||
await toolOutputTempBridge.deleteTerminalToolOutputsTemp!(chatSessionId, terminalSessionId);
|
||||
}
|
||||
: undefined,
|
||||
deleteTerminalEverywhere: toolOutputTempBridge.deleteTerminalToolOutputsEverywhereTemp
|
||||
? async terminalSessionId => {
|
||||
await toolOutputTempBridge.deleteTerminalToolOutputsEverywhereTemp!(terminalSessionId);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
ctx.toolOutputStore.setPersistence?.(undefined);
|
||||
}
|
||||
await clearChatSessionCancelled(sessionId, netcattyBridge);
|
||||
if (netcattyBridge.aiMcpUpdateSessions) {
|
||||
await netcattyBridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
|
||||
}
|
||||
if (attachments?.length && netcattyBridge.aiMcpUpdateAttachments) {
|
||||
await netcattyBridge.aiMcpUpdateAttachments(attachments, sessionId);
|
||||
}
|
||||
const userSkillsContext = await resolveUserSkillsContext(
|
||||
netcattyBridge,
|
||||
trimmed,
|
||||
context.selectedUserSkillSlugs,
|
||||
);
|
||||
const modelUserText = fitLargeUserInputForModel(trimmed, sessionId, ctx.toolOutputStore);
|
||||
const getExecutorContext = context.getExecutorContext ?? (() => ({
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
|
||||
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
|
||||
}));
|
||||
const toolsBundle = createCattyToolsFromCatalog(
|
||||
netcattyBridge,
|
||||
getExecutorContext,
|
||||
context.commandBlocklist,
|
||||
context.globalPermissionMode,
|
||||
context.webSearchConfig ?? undefined,
|
||||
sessionId,
|
||||
ctx.toolOutputStore,
|
||||
ctx.toolResultDedup,
|
||||
);
|
||||
const { tools } = toolsBundle;
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
scopeType: context.scopeType,
|
||||
scopeLabel: context.scopeLabel,
|
||||
hosts: context.terminalSessions,
|
||||
permissionMode: context.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
|
||||
userSkillsContext,
|
||||
});
|
||||
|
||||
if (!context.activeProvider) {
|
||||
ui.reportStreamError(sessionId, signal, 'No AI provider configured. Please configure a provider in Settings → AI.');
|
||||
return;
|
||||
}
|
||||
|
||||
const activeModelId = context.activeModelId || context.activeProvider.defaultModel || '';
|
||||
const promptContext = buildPromptContextSnapshot({
|
||||
providerId: context.activeProvider.providerId,
|
||||
modelId: activeModelId,
|
||||
permissionMode: context.permissionMode ?? context.globalPermissionMode,
|
||||
scopeType: context.scopeType,
|
||||
scopeLabel: context.scopeLabel,
|
||||
toolNames: Object.keys(tools),
|
||||
selectedSkillSlugs: context.selectedUserSkillSlugs,
|
||||
systemPrompt,
|
||||
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
|
||||
hostSessionIds: context.terminalSessions.map(session => session.sessionId),
|
||||
});
|
||||
ctx.emit({
|
||||
id: `context-snapshot-${ctx.turnId}`,
|
||||
type: 'context_snapshot',
|
||||
snapshot: promptContext,
|
||||
} as import('../types').AgentEvent);
|
||||
const continuationContext = createContinuationContext(
|
||||
context.activeProvider.id,
|
||||
context.activeProvider.providerId,
|
||||
activeModelId,
|
||||
resolveProviderStyle(context.activeProvider) === 'openai'
|
||||
&& resolveOpenAIApi(context.activeProvider) === 'responses',
|
||||
);
|
||||
|
||||
ui.setStreamingForScope(sessionId, true);
|
||||
|
||||
try {
|
||||
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
|
||||
|
||||
const buildSdkMessages = (
|
||||
allMessages: import('../../types').ChatMessage[],
|
||||
includeCurrentUserMessage: boolean,
|
||||
options: { preserveTerminalToolResults?: ReadonlySet<import('../../types').ToolResult> } = {},
|
||||
sessionContextCompaction = currentSession?.contextCompaction,
|
||||
) => buildCattySdkMessages({
|
||||
allMessages,
|
||||
contextCompaction: sessionContextCompaction,
|
||||
includeCurrentUserMessage,
|
||||
trimmed: modelUserText,
|
||||
attachments: includeCurrentUserMessage ? attachments : undefined,
|
||||
continuationContext,
|
||||
preserveTerminalToolResults: options.preserveTerminalToolResults,
|
||||
chatSessionId: sessionId,
|
||||
toolOutputStore: ctx.toolOutputStore,
|
||||
fieldsByMessage: openAIChatAssistantFieldsByMessage,
|
||||
});
|
||||
|
||||
const responseIdleTimeoutSeconds = normalizeResponseIdleTimeoutSeconds(
|
||||
context.responseIdleTimeout ?? Number.NaN,
|
||||
);
|
||||
const responseIdleTimeoutMs = responseIdleTimeoutSeconds * 1000;
|
||||
|
||||
let model;
|
||||
try {
|
||||
model = createModelFromConfig(
|
||||
{
|
||||
...context.activeProvider,
|
||||
defaultModel: activeModelId,
|
||||
},
|
||||
{
|
||||
getOpenAIChatAssistantFields: () => continuationContext.openAIChatAssistantFields,
|
||||
streamIdleTimeoutMs: responseIdleTimeoutMs,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Catty] Model creation failed:', e);
|
||||
ui.reportStreamError(sessionId, signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const contextWindow = resolveContextWindow({
|
||||
provider: context.activeProvider,
|
||||
modelId: activeModelId,
|
||||
defaultContextWindow: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
});
|
||||
const maxOutputTokens = context.activeProvider.advancedParams?.maxTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
const reasoningProviderOptions = applyResponsesApiStatelessStoreOption(
|
||||
context.activeProvider,
|
||||
buildCattyReasoningProviderOptions(
|
||||
context.activeProvider,
|
||||
context.reasoningEffort,
|
||||
activeModelId,
|
||||
),
|
||||
);
|
||||
const preserveStatelessResponsesReasoning = reasoningProviderOptions?.openai?.store === false;
|
||||
const reasoningReserveTokens = estimateReasoningOutputReserve(reasoningProviderOptions);
|
||||
// Fold thinking budget into compaction maxOutput only. reservedTokens is
|
||||
// added to estimated input separately, so adding the budget there too
|
||||
// would count the same 10k/20k twice.
|
||||
const compactionMaxOutputTokens = maxOutputTokens + reasoningReserveTokens;
|
||||
const providerId = context.activeProvider.providerId;
|
||||
const outputReserveTokens = Math.min(maxOutputTokens, Math.ceil(contextWindow * 0.05));
|
||||
const getRequestReserveTokens = () => outputReserveTokens + estimateUnknownTokens({
|
||||
systemPrompt,
|
||||
toolNames: Object.keys(tools),
|
||||
openAIChatAssistantFields: Array.from(openAIChatAssistantFieldsByMessage.values()),
|
||||
}, providerId);
|
||||
|
||||
const prepareMessagesForStream = (messages: ModelMessage[]): ModelMessage[] => {
|
||||
const pruned = prepareCattyMessagesForStream(messages, {
|
||||
preserveReasoning: preserveStatelessResponsesReasoning,
|
||||
});
|
||||
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
|
||||
pruned,
|
||||
openAIChatAssistantFieldsByMessage,
|
||||
);
|
||||
return pruned;
|
||||
};
|
||||
|
||||
const compactMessages = async (
|
||||
messages: ModelMessage[],
|
||||
options: {
|
||||
force?: boolean;
|
||||
compressForRequestTooLargeRetry?: boolean;
|
||||
protectRecentMessages?: number;
|
||||
},
|
||||
) => {
|
||||
const pendingHandles = ctx.toolOutputStore.listPendingHandles(sessionId);
|
||||
const sessionStateText = ctx.sessionStateStore.toReinjectionText(sessionId);
|
||||
const result = await compactCattyMessages({
|
||||
messages,
|
||||
sessionId,
|
||||
chatSessionId: sessionId,
|
||||
provider: context.activeProvider,
|
||||
modelId: activeModelId || context.activeProvider?.defaultModel,
|
||||
reservedTokens: getRequestReserveTokens,
|
||||
maxOutputTokens: compactionMaxOutputTokens,
|
||||
model,
|
||||
toolOutputStore: ctx.toolOutputStore,
|
||||
abortSignal: signal,
|
||||
trigger: options.force ? 'force' : options.compressForRequestTooLargeRetry ? '413-retry' : 'pre-turn',
|
||||
force: options.force,
|
||||
compressForRequestTooLargeRetry: options.compressForRequestTooLargeRetry,
|
||||
protectRecentMessages: options.protectRecentMessages,
|
||||
onCompactionStart: (trigger) => {
|
||||
ctx.emit({
|
||||
id: `compaction-start-${Date.now()}`,
|
||||
type: 'compaction_start',
|
||||
sessionId,
|
||||
chatSessionId: sessionId,
|
||||
backend: 'catty',
|
||||
timestamp: Date.now(),
|
||||
trigger,
|
||||
} as import('../types').AgentEvent);
|
||||
},
|
||||
onCompaction: (trace) => {
|
||||
ctx.emit({
|
||||
id: `compaction-${Date.now()}`,
|
||||
type: 'compaction',
|
||||
trace,
|
||||
} as import('../types').AgentEvent);
|
||||
if (options.compressForRequestTooLargeRetry && trace.did413Fallback) {
|
||||
console.warn('[Catty] Request content compressed after forced context compaction.');
|
||||
}
|
||||
},
|
||||
reinjection: {
|
||||
permissionMode: context.permissionMode ?? context.globalPermissionMode,
|
||||
sessionStateText,
|
||||
sessionScopeSummary: pendingHandles.length
|
||||
? `Pending tool output handles: ${pendingHandles.map(h => h.id).join(', ')}`
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const emitContextSnapshot = (messages: ModelMessage[]) => {
|
||||
ctx.emit({
|
||||
id: `context-snapshot-${Date.now()}-${ctx.turnId}`,
|
||||
type: 'context_snapshot',
|
||||
snapshot: {
|
||||
...promptContext,
|
||||
contextWindow,
|
||||
estimatedInputTokens: computeTotalInputTokens({
|
||||
messages,
|
||||
providerId,
|
||||
systemPrompt,
|
||||
toolNames: Object.keys(tools),
|
||||
}),
|
||||
},
|
||||
} as import('../types').AgentEvent);
|
||||
};
|
||||
|
||||
if (context.forceCompaction) {
|
||||
// Persist a tool-safe UI-message boundary and summarize only that head
|
||||
// slice so the durable compact coordinate system matches buildCattySdkMessages.
|
||||
const uiMessages = currentSession?.messages ?? [];
|
||||
const compactedMessageCount = findSafeChatMessageCompactionSplitIndex(
|
||||
uiMessages,
|
||||
DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
);
|
||||
if (compactedMessageCount <= 0) {
|
||||
emitContextSnapshot(prepareMessagesForStream(buildSdkMessages(uiMessages, false)));
|
||||
return;
|
||||
}
|
||||
|
||||
const headSdkMessages = buildSdkMessages(
|
||||
uiMessages.slice(0, compactedMessageCount),
|
||||
false,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
const compacted = await compactMessages(headSdkMessages, {
|
||||
force: true,
|
||||
// Summarize the entire head; the protected tail lives in UI messages.
|
||||
protectRecentMessages: 0,
|
||||
});
|
||||
if (compacted.summary) {
|
||||
const nextCompaction = {
|
||||
summary: compacted.summary,
|
||||
compactedMessageCount,
|
||||
};
|
||||
ui.persistContextCompaction?.(sessionId, nextCompaction);
|
||||
emitContextSnapshot(prepareMessagesForStream(
|
||||
buildSdkMessages(uiMessages, false, {}, nextCompaction),
|
||||
));
|
||||
} else {
|
||||
// Non-durable outcome: keep the meter honest (full current context).
|
||||
emitContextSnapshot(prepareMessagesForStream(buildSdkMessages(uiMessages, false)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let messagesForStream = buildSdkMessages(currentSession?.messages ?? [], true);
|
||||
messagesForStream = (await compactMessages(messagesForStream, {})).messages;
|
||||
messagesForStream = prepareMessagesForStream(messagesForStream);
|
||||
emitContextSnapshot(messagesForStream);
|
||||
|
||||
const runtimeContext = createInitialCattyRuntimeContext({
|
||||
chatSessionId: sessionId,
|
||||
turnId: ctx.turnId,
|
||||
providerId: context.activeProvider?.providerId,
|
||||
modelId: activeModelId,
|
||||
permissionMode: context.permissionMode ?? context.globalPermissionMode,
|
||||
scopeType: context.scopeType,
|
||||
scopeLabel: context.scopeLabel,
|
||||
userGoal: extractLatestUserGoal(messagesForStream),
|
||||
promptContext,
|
||||
});
|
||||
const commandTimeoutSeconds =
|
||||
Number.isFinite(context.commandTimeout) && context.commandTimeout > 0
|
||||
? normalizeCommandTimeoutSeconds(context.commandTimeout)
|
||||
: undefined;
|
||||
const commandTimeoutMs =
|
||||
commandTimeoutSeconds != null
|
||||
? commandTimeoutSeconds * 1000
|
||||
: undefined;
|
||||
|
||||
const runStream = async (streamMessages: ModelMessage[], streamAssistantMsgId: string) => {
|
||||
await processCattyStream({
|
||||
streamSessionId: sessionId,
|
||||
model,
|
||||
systemPrompt,
|
||||
toolsBundle,
|
||||
sdkMessages: streamMessages,
|
||||
signal,
|
||||
currentAssistantMsgId: streamAssistantMsgId,
|
||||
maxIterations,
|
||||
advancedParams: context.activeProvider?.advancedParams,
|
||||
reasoningProviderOptions,
|
||||
continuationContext,
|
||||
turnId: ctx.turnId,
|
||||
commandTimeoutMs,
|
||||
responseIdleTimeoutMs,
|
||||
runtimeContext,
|
||||
onAgentEvent: (event) => ctx.emit(event),
|
||||
prepareStep: async ({ stepNumber, messages, runtimeContext: stepRuntimeContext }) => {
|
||||
const prepared = await prepareStepContext({
|
||||
messages,
|
||||
stepNumber,
|
||||
sessionId,
|
||||
chatSessionId: sessionId,
|
||||
providerId: context.activeProvider?.providerId,
|
||||
modelId: activeModelId,
|
||||
contextWindow,
|
||||
reservedTokens: getRequestReserveTokens(),
|
||||
maxOutputTokens: compactionMaxOutputTokens,
|
||||
toolOutputStore: ctx.toolOutputStore,
|
||||
runtimeContext: stepRuntimeContext,
|
||||
onEvent: (event) => ctx.emit(event),
|
||||
});
|
||||
emitContextSnapshot(prepared.messages);
|
||||
return {
|
||||
messages: prepared.messages,
|
||||
runtimeContext: prepared.runtimeContext,
|
||||
};
|
||||
},
|
||||
ui: {
|
||||
addMessageToSession: ui.addMessageToSession,
|
||||
updateMessageById: ui.updateMessageById,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await runStream(messagesForStream, assistantMsgId);
|
||||
} catch (streamErr) {
|
||||
if (signal.aborted || !isRequestTooLargeError(streamErr)) {
|
||||
throw streamErr;
|
||||
}
|
||||
|
||||
console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
|
||||
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
|
||||
let retryBaseMessages = messagesForStream;
|
||||
let retryAssistantMsgId = assistantMsgId;
|
||||
let preservedWriteFingerprints: string[] = [];
|
||||
if (hadToolProgress) {
|
||||
const latestSession = ui.getLatestSession?.(sessionId);
|
||||
if (latestSession) {
|
||||
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
|
||||
latestSession.messages,
|
||||
assistantMsgId,
|
||||
sessionId,
|
||||
);
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
|
||||
preserveTerminalToolResults: collectToolResultsAfterMessage(
|
||||
latestSession.messages,
|
||||
assistantMsgId,
|
||||
),
|
||||
}, latestSession.contextCompaction);
|
||||
}
|
||||
retryAssistantMsgId = generateId();
|
||||
ui.addMessageToSession(sessionId, {
|
||||
id: retryAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
model: activeModelId || context.activeProvider?.defaultModel || '',
|
||||
providerId: context.activeProvider?.providerId,
|
||||
});
|
||||
} else {
|
||||
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
|
||||
...msg,
|
||||
content: '',
|
||||
thinking: undefined,
|
||||
thinkingDurationMs: undefined,
|
||||
providerContinuation: undefined,
|
||||
toolCalls: undefined,
|
||||
errorInfo: undefined,
|
||||
executionStatus: undefined,
|
||||
pendingApproval: undefined,
|
||||
}));
|
||||
}
|
||||
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
|
||||
const retryMessages = prepareMessagesForStream((await compactMessages(retryBaseMessages, {
|
||||
force: true,
|
||||
compressForRequestTooLargeRetry: true,
|
||||
})).messages);
|
||||
emitContextSnapshot(retryMessages);
|
||||
await runStream(retryMessages, retryAssistantMsgId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
ui.reportStreamError(sessionId, signal, err);
|
||||
} finally {
|
||||
ui.updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
ui.setStreamingForScope(sessionId, false);
|
||||
context.autoTitleSession(sessionId, context.titleText ?? trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
export const cattyTurnDriver = new CattyTurnDriver();
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AgentActivity, AgentUsage } from '../../types';
|
||||
|
||||
export function upsertAgentActivity(
|
||||
activities: AgentActivity[] | undefined,
|
||||
nextActivity: AgentActivity,
|
||||
): AgentActivity[] {
|
||||
const current = activities ?? [];
|
||||
const existingIndex = current.findIndex((activity) => activity.id === nextActivity.id);
|
||||
if (existingIndex < 0) return [...current, nextActivity];
|
||||
return current.map((activity, index) => index === existingIndex ? nextActivity : activity);
|
||||
}
|
||||
|
||||
export function resolveEstimatedUsageFallback(
|
||||
prompt: string,
|
||||
actualUsageReported: boolean,
|
||||
): AgentUsage | null {
|
||||
if (actualUsageReported) return null;
|
||||
const estimatedTokens = Math.ceil(prompt.length / 4);
|
||||
return {
|
||||
inputTokens: estimatedTokens,
|
||||
outputTokens: 0,
|
||||
totalTokens: estimatedTokens,
|
||||
estimated: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AISession, AgentActivity, ChatMessage, ExternalAgentConfig } from '../../types';
|
||||
import { SessionStateStore } from '../sessionState';
|
||||
import { ToolOutputStore } from '../toolOutputStore';
|
||||
import { ToolResultDedup } from '../toolResultDedup';
|
||||
import { externalSdkTurnDriver } from './externalSdkTurnDriver';
|
||||
import { resolveEstimatedUsageFallback, upsertAgentActivity } from './externalSdkEventState';
|
||||
import type { TurnUiCallbacks } from './types';
|
||||
|
||||
async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
if (predicate()) return;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
}
|
||||
throw new Error('condition not reached');
|
||||
}
|
||||
|
||||
test('upsertAgentActivity replaces streaming updates by stable item id', () => {
|
||||
const running: AgentActivity = {
|
||||
id: 'plan-1',
|
||||
type: 'plan_update',
|
||||
status: 'running',
|
||||
items: [{ text: 'Map events', completed: false }],
|
||||
};
|
||||
const completed: AgentActivity = {
|
||||
...running,
|
||||
status: 'completed',
|
||||
items: [{ text: 'Map events', completed: true }],
|
||||
};
|
||||
|
||||
const first = upsertAgentActivity(undefined, running);
|
||||
const second = upsertAgentActivity(first, completed);
|
||||
|
||||
assert.equal(second.length, 1);
|
||||
assert.deepEqual(second[0], completed);
|
||||
});
|
||||
|
||||
test('upsertAgentActivity preserves the order of unrelated activities', () => {
|
||||
const search: AgentActivity = {
|
||||
id: 'search-1',
|
||||
type: 'web_search',
|
||||
status: 'completed',
|
||||
query: 'Codex events',
|
||||
};
|
||||
const warning: AgentActivity = {
|
||||
id: 'warning-1',
|
||||
type: 'warning',
|
||||
status: 'completed',
|
||||
message: 'Search result unavailable',
|
||||
};
|
||||
|
||||
assert.deepEqual(upsertAgentActivity([search], warning), [search, warning]);
|
||||
});
|
||||
|
||||
test('estimated usage is used only when the SDK did not report actual usage', () => {
|
||||
assert.deepEqual(resolveEstimatedUsageFallback('12345678', false), {
|
||||
inputTokens: 2,
|
||||
outputTokens: 0,
|
||||
totalTokens: 2,
|
||||
estimated: true,
|
||||
});
|
||||
assert.equal(resolveEstimatedUsageFallback('12345678', true), null);
|
||||
});
|
||||
|
||||
test('plan updates replace the same activity across tool message boundaries', async () => {
|
||||
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
|
||||
let onDone: (() => void) | undefined;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
onAiSdkAgentEvent: (_requestId, callback) => {
|
||||
onEvent = callback as (event: Record<string, unknown>) => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentDone: (_requestId, callback) => {
|
||||
onDone = callback as () => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentError: () => () => {},
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
aiSdkAgentStream: async () => {
|
||||
queueMicrotask(() => {
|
||||
onEvent?.({
|
||||
type: 'plan-update',
|
||||
itemId: 'plan-1',
|
||||
status: 'running',
|
||||
items: [{ text: 'Run command', completed: false }],
|
||||
});
|
||||
onEvent?.({
|
||||
type: 'tool-call',
|
||||
toolName: 'shell',
|
||||
toolCallId: 'tool-1',
|
||||
args: { command: 'true' },
|
||||
});
|
||||
onEvent?.({
|
||||
type: 'tool-result',
|
||||
toolName: 'shell',
|
||||
toolCallId: 'tool-1',
|
||||
output: 'ok',
|
||||
});
|
||||
onEvent?.({
|
||||
type: 'plan-update',
|
||||
itemId: 'plan-1',
|
||||
status: 'completed',
|
||||
items: [{ text: 'Run command', completed: true }],
|
||||
});
|
||||
onDone?.();
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const session: AISession = {
|
||||
id: 'chat-1',
|
||||
title: 'Test',
|
||||
agentId: 'codex',
|
||||
scope: { type: 'global' },
|
||||
messages: [{ id: 'assistant-1', role: 'assistant', content: '', timestamp: 1 }],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const ui: TurnUiCallbacks = {
|
||||
addMessageToSession: (_sessionId, message) => {
|
||||
session.messages.push(message);
|
||||
},
|
||||
updateLastMessage: (_sessionId, updater) => {
|
||||
const lastIndex = session.messages.length - 1;
|
||||
session.messages[lastIndex] = updater(session.messages[lastIndex]);
|
||||
},
|
||||
updateMessageById: (_sessionId, messageId, updater) => {
|
||||
const messageIndex = session.messages.findIndex((message) => message.id === messageId);
|
||||
if (messageIndex >= 0) {
|
||||
session.messages[messageIndex] = updater(session.messages[messageIndex]);
|
||||
}
|
||||
},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
getLatestSession: () => session,
|
||||
};
|
||||
const agentConfig: ExternalAgentConfig = {
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
command: 'codex',
|
||||
enabled: true,
|
||||
sdkBackend: 'codex',
|
||||
};
|
||||
const controller = new AbortController();
|
||||
|
||||
await externalSdkTurnDriver.run({
|
||||
backend: 'external-sdk',
|
||||
chatSessionId: session.id,
|
||||
assistantMsgId: 'assistant-1',
|
||||
userText: 'Run the plan',
|
||||
signal: controller.signal,
|
||||
agentConfig,
|
||||
attachedImages: [],
|
||||
context: {
|
||||
terminalSessions: [],
|
||||
providers: [],
|
||||
toolIntegrationMode: 'mcp',
|
||||
selectedUserSkillSlugs: [],
|
||||
permissionMode: 'confirm',
|
||||
},
|
||||
bridge,
|
||||
ui,
|
||||
}, {
|
||||
turnId: 'turn-1',
|
||||
chatSessionId: session.id,
|
||||
sessionId: session.id,
|
||||
backend: 'external-sdk',
|
||||
signal: controller.signal,
|
||||
emit: () => {},
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
toolResultDedup: new ToolResultDedup(),
|
||||
sessionStateStore: new SessionStateStore(),
|
||||
});
|
||||
|
||||
const planActivities = session.messages
|
||||
.flatMap((message: ChatMessage) => message.agentActivities ?? [])
|
||||
.filter((activity) => activity.id === 'plan-1');
|
||||
|
||||
assert.equal(planActivities.length, 1);
|
||||
assert.equal(planActivities[0].status, 'completed');
|
||||
assert.deepEqual(planActivities[0].type === 'plan_update' ? planActivities[0].items : [], [
|
||||
{ text: 'Run command', completed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('accepted steering persists a user bubble and routes buffered output to a continuation', async () => {
|
||||
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
|
||||
let onDone: (() => void) | undefined;
|
||||
let releaseSteer: (() => void) | undefined;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
onAiSdkAgentEvent: (_requestId, callback) => {
|
||||
onEvent = callback as (event: Record<string, unknown>) => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentDone: (_requestId, callback) => {
|
||||
onDone = callback as () => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentError: () => () => {},
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
aiSdkAgentStream: async () => ({ ok: true }),
|
||||
aiSdkAgentSteer: async () => {
|
||||
onEvent?.({ type: 'text-delta', textDelta: 'after steer' });
|
||||
await new Promise<void>(resolve => { releaseSteer = resolve; });
|
||||
return { status: 'accepted' };
|
||||
},
|
||||
};
|
||||
const session: AISession = {
|
||||
id: 'chat-steer',
|
||||
title: 'Steer',
|
||||
agentId: 'codex',
|
||||
scope: { type: 'global' },
|
||||
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const ui: TurnUiCallbacks = {
|
||||
addMessageToSession: (_sessionId, message) => session.messages.push(message),
|
||||
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
|
||||
updateMessageById: (_sessionId, messageId, updater) => {
|
||||
const index = session.messages.findIndex(message => message.id === messageId);
|
||||
if (index >= 0) session.messages[index] = updater(session.messages[index]);
|
||||
},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
getLatestSession: () => session,
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = externalSdkTurnDriver.run({
|
||||
backend: 'external-sdk',
|
||||
chatSessionId: session.id,
|
||||
assistantMsgId: 'assistant-initial',
|
||||
userText: 'initial prompt',
|
||||
signal: controller.signal,
|
||||
agentConfig: {
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
command: 'codex',
|
||||
enabled: true,
|
||||
sdkBackend: 'codex',
|
||||
codexRuntime: 'app-server',
|
||||
},
|
||||
attachedImages: [],
|
||||
context: {
|
||||
terminalSessions: [],
|
||||
providers: [],
|
||||
toolIntegrationMode: 'mcp',
|
||||
selectedUserSkillSlugs: [],
|
||||
permissionMode: 'confirm',
|
||||
},
|
||||
bridge,
|
||||
ui,
|
||||
}, {
|
||||
turnId: 'turn-steer',
|
||||
chatSessionId: session.id,
|
||||
sessionId: session.id,
|
||||
backend: 'external-sdk',
|
||||
signal: controller.signal,
|
||||
emit: () => {},
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
toolResultDedup: new ToolResultDedup(),
|
||||
sessionStateStore: new SessionStateStore(),
|
||||
});
|
||||
await waitFor(() => Boolean(onEvent));
|
||||
onEvent?.({ type: 'text-delta', textDelta: 'before steer' });
|
||||
|
||||
const steer = externalSdkTurnDriver.steer({
|
||||
chatSessionId: session.id,
|
||||
userMessageId: 'user-steer',
|
||||
userText: 'change direction',
|
||||
prompt: 'change direction',
|
||||
attachments: [],
|
||||
attachedImages: [],
|
||||
});
|
||||
await waitFor(() => Boolean(releaseSteer));
|
||||
assert.equal(session.messages.length, 1);
|
||||
releaseSteer?.();
|
||||
const result = await steer;
|
||||
|
||||
assert.equal(result.status, 'accepted');
|
||||
assert.deepEqual(session.messages.map(message => message.role), ['assistant', 'user', 'assistant']);
|
||||
assert.equal(session.messages[0].content, 'before steer');
|
||||
assert.equal(session.messages[1].content, 'change direction');
|
||||
assert.equal(session.messages[2].content, 'after steer');
|
||||
|
||||
onDone?.();
|
||||
await run;
|
||||
});
|
||||
|
||||
test('accepted steering keeps buffered results for existing tool calls on their original assistant message', async () => {
|
||||
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
|
||||
let onDone: (() => void) | undefined;
|
||||
let releaseSteer: (() => void) | undefined;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
onAiSdkAgentEvent: (_requestId, callback) => {
|
||||
onEvent = callback as (event: Record<string, unknown>) => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentDone: (_requestId, callback) => {
|
||||
onDone = callback as () => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentError: () => () => {},
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
aiSdkAgentStream: async () => ({ ok: true }),
|
||||
aiSdkAgentSteer: async () => {
|
||||
onEvent?.({
|
||||
type: 'tool-result',
|
||||
toolName: 'shell',
|
||||
toolCallId: 'tool-before-steer',
|
||||
output: 'completed before steer acceptance',
|
||||
});
|
||||
await new Promise<void>(resolve => { releaseSteer = resolve; });
|
||||
return { status: 'accepted' };
|
||||
},
|
||||
};
|
||||
const session: AISession = {
|
||||
id: 'chat-steer-tool-result',
|
||||
title: 'Steer tool result',
|
||||
agentId: 'codex',
|
||||
scope: { type: 'global' },
|
||||
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const ui: TurnUiCallbacks = {
|
||||
addMessageToSession: (_sessionId, message) => session.messages.push(message),
|
||||
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
|
||||
updateMessageById: (_sessionId, messageId, updater) => {
|
||||
const index = session.messages.findIndex(message => message.id === messageId);
|
||||
if (index >= 0) session.messages[index] = updater(session.messages[index]);
|
||||
},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
getLatestSession: () => session,
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = externalSdkTurnDriver.run({
|
||||
backend: 'external-sdk',
|
||||
chatSessionId: session.id,
|
||||
assistantMsgId: 'assistant-initial',
|
||||
userText: 'initial prompt',
|
||||
signal: controller.signal,
|
||||
agentConfig: {
|
||||
id: 'codex', name: 'Codex', command: 'codex', enabled: true,
|
||||
sdkBackend: 'codex', codexRuntime: 'app-server',
|
||||
},
|
||||
attachedImages: [],
|
||||
context: {
|
||||
terminalSessions: [], providers: [], toolIntegrationMode: 'mcp',
|
||||
selectedUserSkillSlugs: [], permissionMode: 'confirm',
|
||||
},
|
||||
bridge,
|
||||
ui,
|
||||
}, {
|
||||
turnId: 'turn-steer-tool-result', chatSessionId: session.id, sessionId: session.id,
|
||||
backend: 'external-sdk', signal: controller.signal, emit: () => {},
|
||||
toolOutputStore: new ToolOutputStore(), toolResultDedup: new ToolResultDedup(),
|
||||
sessionStateStore: new SessionStateStore(),
|
||||
});
|
||||
await waitFor(() => Boolean(onEvent));
|
||||
onEvent?.({
|
||||
type: 'tool-call',
|
||||
toolName: 'shell',
|
||||
toolCallId: 'tool-before-steer',
|
||||
args: { command: 'sleep 1' },
|
||||
});
|
||||
assert.equal(session.messages[0].executionStatus, 'running');
|
||||
|
||||
const steer = externalSdkTurnDriver.steer({
|
||||
chatSessionId: session.id,
|
||||
userMessageId: 'user-steer-tool-result',
|
||||
userText: 'change direction',
|
||||
prompt: 'change direction',
|
||||
attachments: [],
|
||||
attachedImages: [],
|
||||
});
|
||||
await waitFor(() => Boolean(releaseSteer));
|
||||
assert.equal(session.messages.length, 1);
|
||||
assert.equal(session.messages[0].executionStatus, 'running');
|
||||
|
||||
releaseSteer?.();
|
||||
const result = await steer;
|
||||
|
||||
assert.equal(result.status, 'accepted');
|
||||
assert.deepEqual(
|
||||
session.messages.map(message => message.role),
|
||||
['assistant', 'tool', 'user', 'assistant'],
|
||||
);
|
||||
assert.equal(session.messages[0].executionStatus, 'completed');
|
||||
assert.equal(session.messages[0].toolCalls?.[0]?.id, 'tool-before-steer');
|
||||
assert.equal(session.messages[1].toolResults?.[0]?.toolCallId, 'tool-before-steer');
|
||||
assert.equal(session.messages[2].content, 'change direction');
|
||||
assert.equal(session.messages[3].content, '');
|
||||
|
||||
onEvent?.({ type: 'text-delta', textDelta: 'continued after steer' });
|
||||
// Text deltas are rAF-batched like Catty; wait one frame before asserting.
|
||||
await new Promise<void>((resolve) => {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(() => resolve());
|
||||
return;
|
||||
}
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
assert.equal(session.messages[3].content, 'continued after steer');
|
||||
|
||||
onDone?.();
|
||||
await run;
|
||||
});
|
||||
|
||||
test('failed steering keeps buffered output on the original assistant message', async () => {
|
||||
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
|
||||
let onDone: (() => void) | undefined;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
onAiSdkAgentEvent: (_requestId, callback) => {
|
||||
onEvent = callback as (event: Record<string, unknown>) => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentDone: (_requestId, callback) => {
|
||||
onDone = callback as () => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentError: () => () => {},
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
aiSdkAgentStream: async () => ({ ok: true }),
|
||||
aiSdkAgentSteer: async () => {
|
||||
onEvent?.({ type: 'text-delta', textDelta: 'kept output' });
|
||||
return { status: 'not-steerable', turnKind: 'compact' };
|
||||
},
|
||||
};
|
||||
const session: AISession = {
|
||||
id: 'chat-steer-failed',
|
||||
title: 'Steer failed',
|
||||
agentId: 'codex',
|
||||
scope: { type: 'global' },
|
||||
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const ui: TurnUiCallbacks = {
|
||||
addMessageToSession: (_sessionId, message) => session.messages.push(message),
|
||||
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
|
||||
updateMessageById: (_sessionId, messageId, updater) => {
|
||||
const index = session.messages.findIndex(message => message.id === messageId);
|
||||
if (index >= 0) session.messages[index] = updater(session.messages[index]);
|
||||
},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = externalSdkTurnDriver.run({
|
||||
backend: 'external-sdk',
|
||||
chatSessionId: session.id,
|
||||
assistantMsgId: 'assistant-initial',
|
||||
userText: 'initial prompt',
|
||||
signal: controller.signal,
|
||||
agentConfig: {
|
||||
id: 'codex', name: 'Codex', command: 'codex', enabled: true,
|
||||
sdkBackend: 'codex', codexRuntime: 'app-server',
|
||||
},
|
||||
attachedImages: [],
|
||||
context: {
|
||||
terminalSessions: [], providers: [], toolIntegrationMode: 'mcp',
|
||||
selectedUserSkillSlugs: [], permissionMode: 'confirm',
|
||||
},
|
||||
bridge,
|
||||
ui,
|
||||
}, {
|
||||
turnId: 'turn-steer-failed', chatSessionId: session.id, sessionId: session.id,
|
||||
backend: 'external-sdk', signal: controller.signal, emit: () => {},
|
||||
toolOutputStore: new ToolOutputStore(), toolResultDedup: new ToolResultDedup(),
|
||||
sessionStateStore: new SessionStateStore(),
|
||||
});
|
||||
await waitFor(() => Boolean(onEvent));
|
||||
const result = await externalSdkTurnDriver.steer({
|
||||
chatSessionId: session.id,
|
||||
userMessageId: 'user-not-added',
|
||||
userText: 'cannot apply',
|
||||
prompt: 'cannot apply',
|
||||
attachments: [],
|
||||
attachedImages: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { status: 'not-steerable', turnKind: 'compact' });
|
||||
assert.equal(session.messages.length, 1);
|
||||
assert.equal(session.messages[0].content, 'kept output');
|
||||
onDone?.();
|
||||
await run;
|
||||
});
|
||||
495
infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.ts
Normal file
495
infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { getExternalAgentSdkBackend } from '../../managedAgents';
|
||||
import {
|
||||
runSdkAgentTurn,
|
||||
steerSdkAgentTurn,
|
||||
type SdkAgentCallbacks,
|
||||
} from '../../sdkAgentAdapter';
|
||||
import {
|
||||
getNetcattyBridge,
|
||||
generateId,
|
||||
resolveUserSkillsContext,
|
||||
isToolResultError,
|
||||
} from '../../aiChatStreamingSupport';
|
||||
import type { AgentActivity, AgentUsage, ChatMessage } from '../../types';
|
||||
import type {
|
||||
ExternalTurnInput,
|
||||
TurnDriver,
|
||||
TurnDriverContext,
|
||||
TurnSteerInput,
|
||||
TurnSteerResult,
|
||||
} from './types';
|
||||
import { resolveEstimatedUsageFallback, upsertAgentActivity } from './externalSdkEventState';
|
||||
import {
|
||||
clearCodebuddyElicitationsForChat,
|
||||
completeCodebuddyElicitation,
|
||||
registerCodebuddyElicitation,
|
||||
} from '../../shared/codebuddyElicitations';
|
||||
|
||||
interface LiveExternalTurn {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
signal: AbortSignal;
|
||||
agentConfig: ExternalTurnInput['agentConfig'];
|
||||
steer(input: TurnSteerInput): Promise<TurnSteerResult>;
|
||||
ended: boolean;
|
||||
}
|
||||
|
||||
export class ExternalSdkTurnDriver implements TurnDriver {
|
||||
readonly backend = 'external-sdk' as const;
|
||||
private readonly liveTurns = new Map<string, LiveExternalTurn>();
|
||||
|
||||
async run(input: import('./types').TurnInput, ctx: TurnDriverContext): Promise<void> {
|
||||
if (input.backend !== 'external-sdk') {
|
||||
throw new Error('ExternalSdkTurnDriver received non-external input');
|
||||
}
|
||||
try {
|
||||
await runExternalTurn(input, ctx, (liveTurn) => {
|
||||
this.liveTurns.set(input.chatSessionId, liveTurn);
|
||||
});
|
||||
} finally {
|
||||
this.liveTurns.delete(input.chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async steer(input: TurnSteerInput): Promise<TurnSteerResult> {
|
||||
const liveTurn = this.liveTurns.get(input.chatSessionId);
|
||||
if (!liveTurn || liveTurn.ended) return { status: 'inactive' };
|
||||
if (
|
||||
getExternalAgentSdkBackend(liveTurn.agentConfig) !== 'codex'
|
||||
|| liveTurn.agentConfig.codexRuntime !== 'app-server'
|
||||
) {
|
||||
return { status: 'unsupported' };
|
||||
}
|
||||
return liveTurn.steer(input);
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
// Abort is handled via AbortSignal on the turn input.
|
||||
}
|
||||
}
|
||||
|
||||
async function runExternalTurn(
|
||||
input: ExternalTurnInput,
|
||||
ctx: TurnDriverContext,
|
||||
registerLiveTurn: (liveTurn: LiveExternalTurn) => void,
|
||||
): Promise<void> {
|
||||
const {
|
||||
chatSessionId: sessionId,
|
||||
assistantMsgId,
|
||||
userText: trimmed,
|
||||
signal,
|
||||
agentConfig,
|
||||
attachedImages,
|
||||
context,
|
||||
bridge,
|
||||
ui,
|
||||
} = input;
|
||||
|
||||
const netcattyBridge = bridge ?? getNetcattyBridge();
|
||||
const sdkBackend = getExternalAgentSdkBackend(agentConfig);
|
||||
|
||||
if (!sdkBackend || !netcattyBridge) {
|
||||
ui.reportStreamError(
|
||||
sessionId,
|
||||
signal,
|
||||
'This agent has no SDK backend configured. Re-discover it in Settings -> AI.',
|
||||
);
|
||||
ui.setStreamingForScope(sessionId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const userSkillsContext = await resolveUserSkillsContext(
|
||||
netcattyBridge,
|
||||
trimmed,
|
||||
context.selectedUserSkillSlugs,
|
||||
);
|
||||
|
||||
const requestId = ctx.turnId;
|
||||
ui.setStreamingForScope(sessionId, true);
|
||||
|
||||
if (netcattyBridge.aiMcpUpdateSessions) {
|
||||
await netcattyBridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
|
||||
}
|
||||
|
||||
let needsNewAssistantMsg = false;
|
||||
let activeAssistantMessageId = assistantMsgId;
|
||||
let steerInFlight = false;
|
||||
let ended = false;
|
||||
interface BufferedUiOperation {
|
||||
operation: () => void;
|
||||
flushBeforeSteerBoundary: boolean;
|
||||
}
|
||||
const bufferedUiOperations: BufferedUiOperation[] = [];
|
||||
|
||||
const runOrBufferUiOperation = (
|
||||
operation: () => void,
|
||||
options: { flushBeforeSteerBoundary?: boolean } = {},
|
||||
) => {
|
||||
if (steerInFlight) {
|
||||
bufferedUiOperations.push({
|
||||
operation,
|
||||
flushBeforeSteerBoundary: options.flushBeforeSteerBoundary === true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
operation();
|
||||
};
|
||||
const flushBufferedUiOperations = (
|
||||
shouldFlush: (entry: BufferedUiOperation) => boolean = () => true,
|
||||
) => {
|
||||
const operations = bufferedUiOperations.splice(0);
|
||||
operations.forEach((entry) => {
|
||||
if (shouldFlush(entry)) {
|
||||
entry.operation();
|
||||
} else {
|
||||
bufferedUiOperations.push(entry);
|
||||
}
|
||||
});
|
||||
};
|
||||
const maybeCreateAssistantMsg = () => {
|
||||
if (!needsNewAssistantMsg) return;
|
||||
needsNewAssistantMsg = false;
|
||||
activeAssistantMessageId = generateId();
|
||||
ui.addMessageToSession(sessionId, {
|
||||
id: activeAssistantMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
model: agentConfig.name || 'external',
|
||||
});
|
||||
};
|
||||
const updateActiveAssistant = (updater: (message: ChatMessage) => ChatMessage) => {
|
||||
maybeCreateAssistantMsg();
|
||||
ui.updateMessageById(sessionId, activeAssistantMessageId, updater);
|
||||
};
|
||||
let pendingText = '';
|
||||
let rafId: number | null = null;
|
||||
const appendTextToActiveAssistant = (textChunk: string) => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
content: msg.content + textChunk,
|
||||
statusText: undefined,
|
||||
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
|
||||
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
|
||||
}));
|
||||
};
|
||||
const flushPendingText = () => {
|
||||
if (pendingText) {
|
||||
const textChunk = pendingText;
|
||||
pendingText = '';
|
||||
runOrBufferUiOperation(() => appendTextToActiveAssistant(textChunk));
|
||||
}
|
||||
rafId = null;
|
||||
};
|
||||
const scheduleFrame = (cb: () => void): number => (
|
||||
typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame(cb)
|
||||
: setTimeout(cb, 0) as unknown as number
|
||||
);
|
||||
const cancelFrame = (id: number): void => {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(id);
|
||||
return;
|
||||
}
|
||||
clearTimeout(id);
|
||||
};
|
||||
const cancelPendingTextFlush = () => {
|
||||
if (rafId !== null) {
|
||||
cancelFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
const enqueueTextDelta = (textChunk: string) => {
|
||||
if (!textChunk) return;
|
||||
// While steering buffers UI ops, skip rAF so text enters the buffer immediately.
|
||||
if (steerInFlight) {
|
||||
runOrBufferUiOperation(() => appendTextToActiveAssistant(textChunk));
|
||||
return;
|
||||
}
|
||||
pendingText += textChunk;
|
||||
if (rafId === null) {
|
||||
rafId = scheduleFrame(flushPendingText);
|
||||
}
|
||||
};
|
||||
const flushTextBeforeNonTextEvent = () => {
|
||||
cancelPendingTextFlush();
|
||||
flushPendingText();
|
||||
};
|
||||
|
||||
const toolNamesByCallId = new Map<string, string>();
|
||||
const toolCallMessageIds = new Map<string, string>();
|
||||
const activityMessageIds = new Map<string, string>();
|
||||
let actualUsageReported = false;
|
||||
const updateActivity = (activity: AgentActivity) => {
|
||||
const activityMessageId = activityMessageIds.get(activity.id);
|
||||
if (activityMessageId) {
|
||||
ui.updateMessageById(sessionId, activityMessageId, msg => ({
|
||||
...msg,
|
||||
agentActivities: upsertAgentActivity(msg.agentActivities, activity),
|
||||
statusText: undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
maybeCreateAssistantMsg();
|
||||
activityMessageIds.set(activity.id, activeAssistantMessageId);
|
||||
ui.updateMessageById(sessionId, activeAssistantMessageId, msg => ({
|
||||
...msg,
|
||||
agentActivities: upsertAgentActivity(msg.agentActivities, activity),
|
||||
statusText: undefined,
|
||||
}));
|
||||
};
|
||||
const updateUsage = (usage: AgentUsage) => {
|
||||
updateActiveAssistant(msg => ({ ...msg, usage }));
|
||||
};
|
||||
const callbacks: SdkAgentCallbacks = {
|
||||
onTextDelta: (text: string) => {
|
||||
enqueueTextDelta(text);
|
||||
},
|
||||
onThinkingDelta: (text: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
thinking: (msg.thinking || '') + text,
|
||||
}));
|
||||
});
|
||||
},
|
||||
onThinkingDone: () => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
});
|
||||
},
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
const id = toolCallId || `tc_${Date.now()}`;
|
||||
maybeCreateAssistantMsg();
|
||||
toolNamesByCallId.set(id, toolName);
|
||||
toolCallMessageIds.set(id, activeAssistantMessageId);
|
||||
ui.updateMessageById(sessionId, activeAssistantMessageId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), { id, name: toolName, arguments: args }],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
});
|
||||
},
|
||||
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
const existingToolCallMessageId = toolCallMessageIds.get(toolCallId);
|
||||
runOrBufferUiOperation(() => {
|
||||
const effectiveToolName = toolName ?? toolNamesByCallId.get(toolCallId);
|
||||
const toolCallMessageId = existingToolCallMessageId
|
||||
?? toolCallMessageIds.get(toolCallId);
|
||||
const updateToolCallOwner = (msg: ChatMessage) => {
|
||||
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
|
||||
const updatedToolCalls = effectiveToolName && !effectiveToolName.includes('sdk_agent_dynamic_tool') && msg.toolCalls
|
||||
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: effectiveToolName } : tc)
|
||||
: msg.toolCalls;
|
||||
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
|
||||
};
|
||||
if (toolCallMessageId) {
|
||||
ui.updateMessageById(sessionId, toolCallMessageId, updateToolCallOwner);
|
||||
} else {
|
||||
updateActiveAssistant(updateToolCallOwner);
|
||||
}
|
||||
ui.addMessageToSession(sessionId, {
|
||||
id: generateId(),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
toolResults: [{
|
||||
toolCallId,
|
||||
toolName: effectiveToolName,
|
||||
content: result,
|
||||
isError: isToolResultError(result),
|
||||
}],
|
||||
timestamp: Date.now(),
|
||||
executionStatus: 'completed',
|
||||
});
|
||||
needsNewAssistantMsg = true;
|
||||
}, {
|
||||
// A result for a tool call already rendered before steering belongs to
|
||||
// that original assistant segment. Commit it before adding the steer
|
||||
// user/continuation boundary so tool-call history stays contiguous.
|
||||
flushBeforeSteerBoundary: existingToolCallMessageId !== undefined,
|
||||
});
|
||||
},
|
||||
onFileChange: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
},
|
||||
onWebSearch: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
},
|
||||
onPlanUpdate: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
},
|
||||
onWarning: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
},
|
||||
onUsage: (usage: AgentUsage) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
actualUsageReported = true;
|
||||
updateUsage(usage);
|
||||
});
|
||||
},
|
||||
onStatus: (message: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
|
||||
});
|
||||
},
|
||||
onHook: (hookEvent: string, payload: Record<string, unknown>) => {
|
||||
// Surface lifecycle hooks as status text so the user sees tool activity.
|
||||
const toolName = (payload.toolName as string) || '';
|
||||
if (hookEvent === 'PreToolUse' && toolName) {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({ ...msg, statusText: `Running ${toolName}…` }));
|
||||
});
|
||||
} else if (hookEvent === 'Notification') {
|
||||
const message = (payload.message as string) || '';
|
||||
if (message) {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onElicitationCreate: (elicitationId: string, request: Record<string, unknown>) => {
|
||||
registerCodebuddyElicitation({
|
||||
elicitationId,
|
||||
chatSessionId: sessionId,
|
||||
request,
|
||||
});
|
||||
},
|
||||
onElicitationComplete: (notification) => {
|
||||
completeCodebuddyElicitation(notification);
|
||||
},
|
||||
onSessionId: (externalSessionId: string) => {
|
||||
context.updateExternalSessionId?.(sessionId, externalSessionId);
|
||||
},
|
||||
onError: (error: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
ui.reportStreamError(sessionId, signal, error);
|
||||
ui.setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
},
|
||||
};
|
||||
|
||||
const liveTurn: LiveExternalTurn = {
|
||||
requestId,
|
||||
sessionId,
|
||||
signal,
|
||||
agentConfig,
|
||||
ended: false,
|
||||
async steer(steerInput) {
|
||||
if (steerInFlight) return { status: 'busy' };
|
||||
if (ended || signal.aborted) return { status: 'cancelled' };
|
||||
// Commit any rAF-batched text before steering buffers UI ops.
|
||||
flushTextBeforeNonTextEvent();
|
||||
steerInFlight = true;
|
||||
const result = await steerSdkAgentTurn(
|
||||
netcattyBridge,
|
||||
requestId,
|
||||
sessionId,
|
||||
steerInput.prompt,
|
||||
steerInput.attachedImages.length > 0 ? steerInput.attachedImages : undefined,
|
||||
steerInput.userMessageId,
|
||||
);
|
||||
|
||||
if (result.status === 'accepted' && !ended && !signal.aborted) {
|
||||
flushBufferedUiOperations(entry => entry.flushBeforeSteerBoundary);
|
||||
ui.addMessageToSession(sessionId, {
|
||||
id: steerInput.userMessageId,
|
||||
role: 'user',
|
||||
content: steerInput.userText,
|
||||
...(steerInput.attachments?.length ? { attachments: steerInput.attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const continuationMessageId = generateId();
|
||||
ui.addMessageToSession(sessionId, {
|
||||
id: continuationMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
model: agentConfig.name || 'external',
|
||||
});
|
||||
activeAssistantMessageId = continuationMessageId;
|
||||
needsNewAssistantMsg = false;
|
||||
steerInFlight = false;
|
||||
flushBufferedUiOperations();
|
||||
return { status: 'accepted', assistantMessageId: continuationMessageId };
|
||||
}
|
||||
|
||||
steerInFlight = false;
|
||||
flushBufferedUiOperations();
|
||||
if (result.status === 'accepted') return { status: 'cancelled' };
|
||||
return result;
|
||||
},
|
||||
};
|
||||
registerLiveTurn(liveTurn);
|
||||
|
||||
try {
|
||||
await runSdkAgentTurn(
|
||||
netcattyBridge,
|
||||
requestId,
|
||||
sessionId,
|
||||
agentConfig,
|
||||
trimmed,
|
||||
callbacks,
|
||||
signal,
|
||||
undefined,
|
||||
context.selectedAgentModel,
|
||||
context.existingSessionId,
|
||||
context.historyMessages,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
context.toolIntegrationMode,
|
||||
context.defaultTargetSession,
|
||||
userSkillsContext,
|
||||
context.permissionMode,
|
||||
{
|
||||
traceSink: (event) => ctx.emit(event),
|
||||
skipHarnessTrace: true,
|
||||
},
|
||||
);
|
||||
|
||||
const estimatedUsage = resolveEstimatedUsageFallback(trimmed, actualUsageReported);
|
||||
if (estimatedUsage) {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateUsage(estimatedUsage));
|
||||
ctx.emit({
|
||||
id: `usage-${ctx.turnId}`,
|
||||
type: 'usage',
|
||||
promptTokens: estimatedUsage.inputTokens,
|
||||
completionTokens: estimatedUsage.outputTokens,
|
||||
totalTokens: estimatedUsage.totalTokens,
|
||||
estimated: true,
|
||||
} as import('../types').AgentEvent);
|
||||
}
|
||||
} finally {
|
||||
ended = true;
|
||||
liveTurn.ended = true;
|
||||
flushTextBeforeNonTextEvent();
|
||||
clearCodebuddyElicitationsForChat(sessionId);
|
||||
if (steerInFlight) {
|
||||
steerInFlight = false;
|
||||
flushBufferedUiOperations();
|
||||
}
|
||||
ui.setStreamingForScope(sessionId, false);
|
||||
}
|
||||
}
|
||||
|
||||
export const externalSdkTurnDriver = new ExternalSdkTurnDriver();
|
||||
187
infrastructure/ai/harness/turnDrivers/types.ts
Normal file
187
infrastructure/ai/harness/turnDrivers/types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../types';
|
||||
import type { ExecutorContext } from '../../cattyAgent/executor';
|
||||
import type { AgentBackend, AgentEvent, AgentEventListener } from '../types';
|
||||
import type { ToolOutputStore } from '../toolOutputStore';
|
||||
import type { ToolResultDedup } from '../toolResultDedup';
|
||||
import type { SessionStateStore } from '../sessionState';
|
||||
import type { DefaultTargetSessionHint } from '../../sdkAgentAdapter';
|
||||
import type { AgentStopBridge } from '../agentStop';
|
||||
|
||||
export interface TerminalSessionInfo {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
|
||||
activePortForwards?: Array<{
|
||||
ruleId: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
localPort?: number;
|
||||
status?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TurnUiCallbacks {
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
getLatestSession?: (sessionId: string) => AISession | undefined;
|
||||
persistContextCompaction?: (
|
||||
sessionId: string,
|
||||
compaction: import('../../types').AISessionContextCompaction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CattyTurnContext {
|
||||
activeProvider: ProviderConfig | undefined;
|
||||
activeModelId: string;
|
||||
reasoningEffort?: string;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
permissionMode?: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
commandTimeout?: number;
|
||||
responseIdleTimeout?: number;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
titleText?: string;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
forceCompaction?: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalTurnContext {
|
||||
existingSessionId?: string;
|
||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
defaultTargetSession?: DefaultTargetSessionHint;
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
}
|
||||
|
||||
export interface CattyTurnInput {
|
||||
backend: 'catty';
|
||||
chatSessionId: string;
|
||||
sendScopeKey: string;
|
||||
userText: string;
|
||||
signal: AbortSignal;
|
||||
currentSession: AISession | undefined;
|
||||
assistantMsgId: string;
|
||||
context: CattyTurnContext;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
maxIterations: number;
|
||||
bridge?: AgentStopBridge | null;
|
||||
ui: TurnUiCallbacks;
|
||||
}
|
||||
|
||||
export interface ExternalTurnInput {
|
||||
backend: 'external-sdk';
|
||||
chatSessionId: string;
|
||||
assistantMsgId: string;
|
||||
userText: string;
|
||||
signal: AbortSignal;
|
||||
agentConfig: ExternalAgentConfig;
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>;
|
||||
context: ExternalTurnContext;
|
||||
bridge?: Record<string, (...args: unknown[]) => unknown> | null;
|
||||
ui: TurnUiCallbacks;
|
||||
}
|
||||
|
||||
export type TurnInput = CattyTurnInput | ExternalTurnInput;
|
||||
|
||||
export interface TurnResult {
|
||||
turnId: string;
|
||||
reason: 'completed' | 'aborted' | 'error';
|
||||
}
|
||||
|
||||
export type TurnSteerFailureReason =
|
||||
| 'not-steerable'
|
||||
| 'busy'
|
||||
| 'inactive'
|
||||
| 'unsupported'
|
||||
| 'cancelled'
|
||||
| 'failed';
|
||||
|
||||
export type TurnSteerResult =
|
||||
| { status: 'accepted'; assistantMessageId: string }
|
||||
| {
|
||||
status: TurnSteerFailureReason;
|
||||
message?: string;
|
||||
turnKind?: 'review' | 'compact';
|
||||
};
|
||||
|
||||
export interface TurnSteerInput {
|
||||
chatSessionId: string;
|
||||
userMessageId: string;
|
||||
userText: string;
|
||||
prompt: string;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
attachedImages: Array<{
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filename?: string;
|
||||
filePath?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TurnDriverContext {
|
||||
turnId: string;
|
||||
chatSessionId: string;
|
||||
sessionId: string;
|
||||
backend: AgentBackend;
|
||||
signal: AbortSignal;
|
||||
emit: (event: Omit<AgentEvent, 'turnId' | 'sessionId' | 'chatSessionId' | 'backend' | 'timestamp'> & Partial<Pick<AgentEvent, 'turnId' | 'sessionId' | 'chatSessionId' | 'backend' | 'timestamp'>>) => void;
|
||||
toolOutputStore: ToolOutputStore;
|
||||
toolResultDedup: ToolResultDedup;
|
||||
sessionStateStore: SessionStateStore;
|
||||
onEvent?: AgentEventListener;
|
||||
}
|
||||
|
||||
export interface TurnDriver {
|
||||
readonly backend: AgentBackend;
|
||||
run(input: TurnInput, ctx: TurnDriverContext): Promise<void>;
|
||||
steer?(input: TurnSteerInput): Promise<TurnSteerResult>;
|
||||
abort?(chatSessionId: string): void;
|
||||
}
|
||||
|
||||
export interface PrepareStepContextInput {
|
||||
messages: ModelMessage[];
|
||||
stepNumber: number;
|
||||
sessionId: string;
|
||||
chatSessionId?: string;
|
||||
providerId?: string | null;
|
||||
modelId?: string | null;
|
||||
contextWindow?: number;
|
||||
reservedTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
protectRecentMessages?: number;
|
||||
toolOutputStore?: ToolOutputStore;
|
||||
runtimeContext: import('../cattyRuntimeContext').CattyRuntimeContext;
|
||||
onEvent?: AgentEventListener;
|
||||
}
|
||||
36
infrastructure/ai/harness/twoPassCompaction.test.ts
Normal file
36
infrastructure/ai/harness/twoPassCompaction.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { TwoPassCompactionCache, fingerprintMessages } from './twoPassCompaction';
|
||||
|
||||
test('fingerprintMessages includes complete tool arguments canonically', () => {
|
||||
const a: ModelMessage[] = [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: '1', toolName: 'terminal_execute', input: { b: 2, a: 1 } }],
|
||||
}];
|
||||
const reordered: ModelMessage[] = [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: '1', toolName: 'terminal_execute', input: { a: 1, b: 2 } }],
|
||||
}];
|
||||
const changed: ModelMessage[] = [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: '1', toolName: 'terminal_execute', input: { a: 9, b: 2 } }],
|
||||
}];
|
||||
|
||||
assert.equal(fingerprintMessages(a), fingerprintMessages(reordered));
|
||||
assert.notEqual(fingerprintMessages(a), fingerprintMessages(changed));
|
||||
});
|
||||
|
||||
test('TwoPassCompactionCache reuses only an unchanged model and prefix', async () => {
|
||||
const cache = new TwoPassCompactionCache();
|
||||
const messages = Array.from({ length: 20 }, (_, index) => ({
|
||||
role: index % 2 ? 'assistant' : 'user',
|
||||
content: `message ${index}`,
|
||||
})) as ModelMessage[];
|
||||
cache.start('chat-1', 'model-a', messages, async () => 'NOTE1');
|
||||
|
||||
const hit = await cache.consume('chat-1', 'model-a', messages);
|
||||
assert.equal(hit?.note, 'NOTE1');
|
||||
assert.ok((hit?.prefixLength ?? 0) < messages.length);
|
||||
assert.equal(await cache.consume('chat-1', 'model-b', messages), undefined);
|
||||
});
|
||||
95
infrastructure/ai/harness/twoPassCompaction.ts
Normal file
95
infrastructure/ai/harness/twoPassCompaction.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { findSafeCompactionSplitIndex } from '../contextCompaction';
|
||||
|
||||
interface CacheEntry {
|
||||
modelId: string;
|
||||
prefixLength: number;
|
||||
fingerprint: string;
|
||||
notePromise: Promise<string>;
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => [key, canonicalize(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function fingerprintMessages(messages: ModelMessage[]): string {
|
||||
const value = JSON.stringify(canonicalize(messages));
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export class TwoPassCompactionCache {
|
||||
private readonly entries = new Map<string, CacheEntry>();
|
||||
|
||||
start(
|
||||
chatSessionId: string,
|
||||
modelId: string,
|
||||
messages: ModelMessage[],
|
||||
producer: (prefix: ModelMessage[]) => Promise<string>,
|
||||
): boolean {
|
||||
const current = this.entries.get(chatSessionId);
|
||||
if (
|
||||
current
|
||||
&& current.modelId === modelId
|
||||
&& messages.length >= current.prefixLength
|
||||
&& fingerprintMessages(messages.slice(0, current.prefixLength)) === current.fingerprint
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const protectedTail = Math.max(10, Math.ceil(messages.length * 0.05));
|
||||
const prefixLength = findSafeCompactionSplitIndex(messages, protectedTail);
|
||||
if (prefixLength <= 0) return false;
|
||||
const prefix = messages.slice(0, prefixLength);
|
||||
const fingerprint = fingerprintMessages(prefix);
|
||||
if (
|
||||
current
|
||||
&& current.modelId === modelId
|
||||
&& current.prefixLength === prefixLength
|
||||
&& current.fingerprint === fingerprint
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.entries.set(chatSessionId, {
|
||||
modelId,
|
||||
prefixLength,
|
||||
fingerprint,
|
||||
notePromise: producer(prefix).then(note => note.slice(0, 12_000)).catch(() => ''),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async consume(
|
||||
chatSessionId: string,
|
||||
modelId: string,
|
||||
messages: ModelMessage[],
|
||||
): Promise<{ note: string; prefixLength: number } | undefined> {
|
||||
const entry = this.entries.get(chatSessionId);
|
||||
if (!entry || entry.modelId !== modelId || messages.length < entry.prefixLength) {
|
||||
this.entries.delete(chatSessionId);
|
||||
return undefined;
|
||||
}
|
||||
const prefix = messages.slice(0, entry.prefixLength);
|
||||
if (fingerprintMessages(prefix) !== entry.fingerprint) {
|
||||
this.entries.delete(chatSessionId);
|
||||
return undefined;
|
||||
}
|
||||
const note = await entry.notePromise;
|
||||
return note ? { note, prefixLength: entry.prefixLength } : undefined;
|
||||
}
|
||||
|
||||
clear(chatSessionId: string): void {
|
||||
this.entries.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const globalTwoPassCompactionCache = new TwoPassCompactionCache();
|
||||
224
infrastructure/ai/harness/types.ts
Normal file
224
infrastructure/ai/harness/types.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Unified agent harness event protocol.
|
||||
* Catty (Vercel AI SDK) and external SDK drivers emit the same shapes.
|
||||
*/
|
||||
|
||||
export type AgentEventType =
|
||||
| 'turn_start'
|
||||
| 'model_delta'
|
||||
| 'reasoning_delta'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'file_change'
|
||||
| 'web_search'
|
||||
| 'plan_update'
|
||||
| 'approval_requested'
|
||||
| 'approval_resolved'
|
||||
| 'compaction'
|
||||
| 'compaction_start'
|
||||
| 'usage'
|
||||
| 'performance'
|
||||
| 'model_call_start'
|
||||
| 'step_end'
|
||||
| 'context_snapshot'
|
||||
| 'error'
|
||||
| 'turn_end';
|
||||
|
||||
export type AgentBackend = 'catty' | 'external-sdk';
|
||||
|
||||
export type ApprovalOutcome = 'approved' | 'denied' | 'timeout';
|
||||
|
||||
export type ContextPrepareTrigger = 'pre-turn' | '413-retry' | 'force' | 'step';
|
||||
|
||||
export interface AgentEventBase {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
chatSessionId?: string;
|
||||
backend: AgentBackend;
|
||||
timestamp: number;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
export interface TurnStartEvent extends AgentEventBase {
|
||||
type: 'turn_start';
|
||||
backendLabel?: string;
|
||||
}
|
||||
|
||||
export interface ContextSnapshotEvent extends AgentEventBase {
|
||||
type: 'context_snapshot';
|
||||
snapshot: import('./promptContextSnapshot').PromptContextSnapshot;
|
||||
}
|
||||
|
||||
export interface ModelDeltaEvent extends AgentEventBase {
|
||||
type: 'model_delta';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ReasoningDeltaEvent extends AgentEventBase {
|
||||
type: 'reasoning_delta';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolCallEvent extends AgentEventBase {
|
||||
type: 'tool_call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolResultEvent extends AgentEventBase {
|
||||
type: 'tool_result';
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
result: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface FileChangeEvent extends AgentEventBase {
|
||||
type: 'file_change';
|
||||
itemId: string;
|
||||
status: 'completed' | 'failed';
|
||||
changes: Array<{ path: string; kind: 'add' | 'delete' | 'update' }>;
|
||||
}
|
||||
|
||||
export interface WebSearchEvent extends AgentEventBase {
|
||||
type: 'web_search';
|
||||
itemId: string;
|
||||
query: string;
|
||||
status: 'running' | 'completed';
|
||||
}
|
||||
|
||||
export interface PlanUpdateEvent extends AgentEventBase {
|
||||
type: 'plan_update';
|
||||
itemId: string;
|
||||
status: 'running' | 'completed';
|
||||
items: Array<{ text: string; completed: boolean }>;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestedEvent extends AgentEventBase {
|
||||
type: 'approval_requested';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApprovalResolvedEvent extends AgentEventBase {
|
||||
type: 'approval_resolved';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
outcome: ApprovalOutcome;
|
||||
persistedGrantId?: string;
|
||||
}
|
||||
|
||||
export type TokenEstimatorKind = 'chars-div-4' | 'openai-heuristic' | 'anthropic-heuristic' | 'google-heuristic';
|
||||
|
||||
export interface CompactionTrace {
|
||||
trigger: ContextPrepareTrigger;
|
||||
estimatedTokensBefore: number;
|
||||
estimatedTokensAfter: number;
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
compressedMessageCount: number;
|
||||
retainedTailCount: number;
|
||||
summaryLength?: number;
|
||||
didTypedCompression: boolean;
|
||||
didLlmSummarize: boolean;
|
||||
did413Fallback: boolean;
|
||||
estimatorKind?: TokenEstimatorKind;
|
||||
archiveHandleId?: string;
|
||||
artifactHandleId?: string;
|
||||
archiveChars?: number;
|
||||
twoPassCacheHit?: boolean;
|
||||
twoPassPrefixMessages?: number;
|
||||
}
|
||||
|
||||
export interface CompactionEvent extends AgentEventBase {
|
||||
type: 'compaction';
|
||||
trace: CompactionTrace;
|
||||
}
|
||||
|
||||
export interface CompactionStartEvent extends AgentEventBase {
|
||||
type: 'compaction_start';
|
||||
trigger: ContextPrepareTrigger;
|
||||
}
|
||||
|
||||
export interface ErrorEvent extends AgentEventBase {
|
||||
type: 'error';
|
||||
message: string;
|
||||
code?: string;
|
||||
recoverable?: boolean;
|
||||
}
|
||||
|
||||
export interface UsageEvent extends AgentEventBase {
|
||||
type: 'usage';
|
||||
promptTokens: number;
|
||||
cachedPromptTokens?: number;
|
||||
completionTokens: number;
|
||||
reasoningTokens?: number;
|
||||
totalTokens: number;
|
||||
estimated?: boolean;
|
||||
}
|
||||
|
||||
export interface PerformanceEvent extends AgentEventBase {
|
||||
type: 'performance';
|
||||
responseTimeMs?: number;
|
||||
timeToFirstOutputMs?: number;
|
||||
outputTokensPerSecond?: number;
|
||||
}
|
||||
|
||||
export interface ModelCallStartEvent extends AgentEventBase {
|
||||
type: 'model_call_start';
|
||||
callId: string;
|
||||
modelId: string;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
export interface StepEndEvent extends AgentEventBase {
|
||||
type: 'step_end';
|
||||
callId: string;
|
||||
stepNumber: number;
|
||||
modelId?: string;
|
||||
finishReason?: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface TurnEndEvent extends AgentEventBase {
|
||||
type: 'turn_end';
|
||||
reason?: 'completed' | 'aborted' | 'error';
|
||||
}
|
||||
|
||||
export type AgentEvent =
|
||||
| TurnStartEvent
|
||||
| ContextSnapshotEvent
|
||||
| ModelDeltaEvent
|
||||
| ReasoningDeltaEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
| FileChangeEvent
|
||||
| WebSearchEvent
|
||||
| PlanUpdateEvent
|
||||
| ApprovalRequestedEvent
|
||||
| ApprovalResolvedEvent
|
||||
| CompactionEvent
|
||||
| CompactionStartEvent
|
||||
| UsageEvent
|
||||
| PerformanceEvent
|
||||
| ModelCallStartEvent
|
||||
| StepEndEvent
|
||||
| ErrorEvent
|
||||
| TurnEndEvent;
|
||||
|
||||
export type AgentEventListener = (event: AgentEvent) => void;
|
||||
|
||||
export interface ContextPrepareResult {
|
||||
messages: import('ai').ModelMessage[];
|
||||
didAdjust: boolean;
|
||||
trace?: CompactionTrace;
|
||||
}
|
||||
|
||||
export interface ExternalBridgeHistoryMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
Reference in New Issue
Block a user