[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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user