Files
NetMesh/infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

550 lines
21 KiB
TypeScript

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();