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
275 lines
8.5 KiB
TypeScript
275 lines
8.5 KiB
TypeScript
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);
|
|
}
|
|
}
|