import type { AgentToolResult, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import type { KernelToHostMessage } from "../bridge/protocol.ts"; import type { AgentExecuteTool } from "../bridges/agent-bridge.ts"; import { isReservedToolName, runReservedTool, } from "../bridges/reserved-dispatch.ts"; import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts"; import { appendSchemaHint } from "../bridges/schema-hint.ts"; import type { CompletionRequest, CompletionResult, } from "../completion/handler.ts"; import { handleCompletionToolCall } from "../completion/tool-bridge.ts"; import type { ResolvedCodemodeSettings } from "../config/settings.ts"; import { sanitizeTerminalLabel } from "../render/sanitize.ts"; import { withBridgeTimeoutPause } from "../timeouts/bridge-timeout.ts"; import type { TimeoutPauseHandle } from "../timeouts/idle-timeout.ts"; import { boundToolCallArgs, capCodePoints, toolCallResultPreview, trackToolCall, } from "./call-capture.ts"; import { CellResultBuilder, type CellState } from "./cell-runtime.ts"; import { type EvalImageResizer, marshalToolResult, toolResultIsError, } from "./image.ts"; import type { SessionStore } from "./session-store.ts"; import { upsertStatusEvent } from "./status-events.ts"; import type { BridgeCallSession, EvalKernel, EvalStatusEvent, EvalToolCallSummary, EvalToolDetails, } from "./types.ts"; export type { CellState } from "./cell-runtime.ts"; interface ResolvedToolReply { readonly errorText?: string; readonly resultPreview?: string; readonly toolCallOk: boolean; readonly value: unknown; } interface ToolCallEnrichment { readonly args: unknown; readonly argsTruncated?: true; readonly callId: string; readonly startedAt: number; } export interface CellBridgeRuntime { readonly artifactPath?: string; readonly complete?: ( request: CompletionRequest, ctx: ExtensionContext ) => Promise; readonly ctx: ExtensionContext; readonly executeTool: AgentExecuteTool; readonly imageResizer?: EvalImageResizer; readonly listTools?: () => readonly EvalSchemaToolInfo[]; readonly pauseTimeout?: () => void; readonly resumeTimeout?: () => void; /** Session value store backing the store()/load() helpers; committed or discarded at settlement. */ readonly sessionStore: SessionStore; readonly settings: ResolvedCodemodeSettings; } export class CellHandler { readonly #kernel: EvalKernel; readonly #state: CellState; readonly #runtime: CellBridgeRuntime; readonly #resultBuilder: CellResultBuilder; constructor( kernel: EvalKernel, state: CellState, runtime: CellBridgeRuntime ) { this.#kernel = kernel; this.#state = state; this.#runtime = runtime; const settings = runtime.settings.outputSink; this.#resultBuilder = new CellResultBuilder({ state, headBytes: settings.headBytes, maxColumns: settings.maxColumns, chunkThrottleMs: settings.chunkThrottleMs, model: runtime.ctx.model, ...(runtime.artifactPath === undefined ? {} : { artifactPath: runtime.artifactPath }), ...(runtime.imageResizer === undefined ? {} : { imageResizer: runtime.imageResizer }), }); } async handle(message: KernelToHostMessage): Promise { if (!this.#state.active) { return; } switch (message.type) { case "text": this.#resultBuilder.push(message.data); return; case "phase": this.#resultBuilder.setPhase(message.title); return; case "status": this.#recordStatus(message.event); return; case "log": this.#resultBuilder.push(`${message.message}\n`); return; case "display": this.#resultBuilder.display(message); return; case "tool-call": { const pending = this.#handleToolCall(message); this.#state.pendingBridgeCalls.push(pending); await pending; return; } case "ready": case "init-failed": case "result": case "closed": return; default: throw new TypeError(`Unhandled kernel message: ${String(message)}`); } } async finalize( result: Extract ): Promise> { const finalized = await this.#resultBuilder.finalize(result); // Commit rule mirrors Codex: staged store writes commit only when the // cell completes; every failed outcome (ok: false) discards them. if (result.ok) { this.#runtime.sessionStore.commit(this.#state.input.language); } else { this.#runtime.sessionStore.discard(this.#state.input.language); } return finalized; } async finalizeCancellation( error: Error ): Promise> { this.#runtime.sessionStore.discard(this.#state.input.language); return await this.#resultBuilder.finalizeCancellation(error); } async flushOutput(): Promise { await this.#resultBuilder.flushOutput(); } liveResult(): AgentToolResult { return this.#resultBuilder.liveResult(); } async #handleToolCall( message: Extract ): Promise { if (message.toolName === "eval") { const error = "recursive eval is not allowed"; trackToolCall(this.#state.toolCalls, { name: message.toolName, ok: false, error, }); this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: false, error: { message: error }, }); return; } if (isReservedToolName(message.toolName)) { await this.#deliverToolReply(message, async () => ({ value: await runReservedTool(message.toolName, { callId: message.callId, args: message.args, executeTool: this.#runtime.executeTool, taskToolName: this.#runtime.settings.taskTools.task, taskOutputToolName: this.#runtime.settings.taskTools.output, listTools: this.#runtime.listTools, signal: this.#state.signal, emitStatus: (event) => this.#recordStatus(event), marshalToolResult, sessionStore: this.#runtime.sessionStore, }), toolCallOk: true, })); return; } if (message.toolName === "completion" && this.#runtime.complete) { const complete = this.#runtime.complete; const result = await withBridgeTimeoutPause( this.#timeoutPauseHandle(), () => handleCompletionToolCall({ message, kernel: this.#kernel, complete, ctx: this.#runtime.ctx, isActive: () => this.#state.active, }) ); if (!this.#state.active) { return; } trackToolCall( this.#state.toolCalls, result.ok ? { name: message.toolName, ok: true } : { name: message.toolName, ok: false, error: result.error } ); this.#resultBuilder.emitUpdate(false); return; } const capturedArgs = boundToolCallArgs(message.args); const startedAt = Date.now(); await this.#deliverToolReply( message, async () => { const allowed = this.#state.input.tools; if (allowed !== undefined && !allowed.includes(message.toolName)) { throw new RangeError( `Tool "${message.toolName}" is not enabled for this cell. Enabled tools: ${allowed.length > 0 ? allowed.join(", ") : "(none)"}` ); } const result = await this.#runtime.executeTool( message.toolName, message.args, { signal: this.#state.signal, } ); const toolCallOk = !toolResultIsError(result); if (toolCallOk) { const resultPreview = toolCallResultPreview(result); return { value: marshalToolResult(result), toolCallOk, ...(resultPreview === undefined ? {} : { resultPreview }), }; } let errorText: string | undefined; for (const part of result.content) { if (part.type !== "text") { continue; } errorText = capCodePoints(sanitizeTerminalLabel(part.text), 512); break; } return { value: marshalToolResult(result), toolCallOk, ...(errorText === undefined ? {} : { errorText }), }; }, { callId: message.callId, args: capturedArgs.args, startedAt, ...(capturedArgs.truncated ? { argsTruncated: true } : {}), } ); } async #deliverToolReply( message: Extract, resolve: () => Promise, enrich?: ToolCallEnrichment ): Promise { try { const reply = await withBridgeTimeoutPause( this.#timeoutPauseHandle(), resolve ); if (!this.#state.active) { return; } this.#pushToolCall( message.toolName, reply.toolCallOk, enrich, reply.resultPreview, reply.errorText ); this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: true, value: reply.value, }); } catch (error) { if (!this.#state.active) { return; } const text = appendSchemaHint( error instanceof Error ? error.message : String(error), message.toolName, this.#toolParameters(message.toolName) ); this.#pushToolCall(message.toolName, false, enrich, undefined, text); this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: false, error: { message: text }, }); } this.#resultBuilder.emitUpdate(false); } #pushToolCall( name: string, ok: boolean, enrich: ToolCallEnrichment | undefined, resultPreview: string | undefined, error: string | undefined ): void { const summary: EvalToolCallSummary = { name, ok, ...(error === undefined ? {} : { error }), }; if (enrich !== undefined) { Object.assign(summary, { callId: enrich.callId, args: enrich.args, durationMs: Date.now() - enrich.startedAt, ...(enrich.argsTruncated === true ? { argsTruncated: true } : {}), ...(resultPreview === undefined ? {} : { resultPreview }), }); } trackToolCall(this.#state.toolCalls, summary); } #timeoutPauseHandle(): TimeoutPauseHandle | undefined { const { pauseTimeout, resumeTimeout } = this.#runtime; if (pauseTimeout === undefined || resumeTimeout === undefined) { return; } return { pause: pauseTimeout, resume: resumeTimeout }; } /** * Per-cell hooks for the HTTP bridge route (subprocess py/rb tool calls): * the session manager records bridge calls into this cell's toolCalls and * status events exactly like the in-process JS path does, keeping the * enrichment pipeline language-neutral. Registered per active cell while the * cell runs; one active cell per language is the invariant the detached * manager enforces. */ bridgeSession(): BridgeCallSession { return { trackToolCall: (summary) => { if (!this.#state.active) { return; } trackToolCall(this.#state.toolCalls, summary); this.#resultBuilder.emitUpdate(false); }, emitStatus: (event) => this.#recordStatus(event), }; } #toolParameters(toolName: string): unknown { return this.#runtime.listTools?.().find((tool) => tool.name === toolName) ?.parameters; } #recordStatus(event: EvalStatusEvent): void { if (!this.#runtime.settings.statusEvents) { return; } upsertStatusEvent(this.#state.statusEvents, event); this.#resultBuilder.emitUpdate(false); } }