import { isRecord, truncate } from "@oh-my-pi/pi-utils"; import { type AsyncJob, type AsyncJobManagerApi, agentProgress } from "../async"; import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; import type { ToolSession } from "../tools"; import { ToolAbortError } from "../tools/tool-errors"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { withBridgeTimeoutPause } from "./bridge-timeout"; import { type CompletionAttempt, getCompletionHandle, type CompletionHandleEntry } from "./completion-bridge"; import type { JsStatusEvent } from "./js/shared/types"; /** Synthetic bridge name reserved for waiting on eval handles. */ export const EVAL_WAIT_BRIDGE_NAME = "__wait__"; /** Synthetic bridge name reserved for inspecting an eval handle. */ export const EVAL_STATUS_BRIDGE_NAME = "__status__"; /** Synthetic bridge name reserved for cancelling an eval handle. */ export const EVAL_CANCEL_BRIDGE_NAME = "__cancel__"; export type EvalHandleKind = "agent" | "completion"; export type EvalHandleState = "running" | "completed" | "failed" | "cancelled"; /** Stable process-local reference sent by eval runtimes. */ export interface EvalHandleRef { kind: EvalHandleKind; id: string; } /** Current or terminal state returned to an eval handle. */ export interface EvalHandleSnapshot extends EvalHandleRef { status: EvalHandleState; text?: string; data?: unknown; error?: string; /** Completion fallback attempts; only on `__status__` snapshots. */ attempts?: CompletionAttempt[]; } interface EvalHandleBridgeOptions { session: ToolSession; signal?: AbortSignal; emitStatus?: (event: JsStatusEvent) => void; } type ResolvedHandle = | { ref: EvalHandleRef; job: AsyncJob; manager: AsyncJobManagerApi } | { ref: EvalHandleRef; completion: CompletionHandleEntry }; function parseRef(value: unknown): EvalHandleRef { if (!isRecord(value)) throw new ToolError("handle must be an object with kind and id"); const { kind, id } = value; if ((kind !== "agent" && kind !== "completion") || typeof id !== "string" || id.length === 0) { throw new ToolError("handle must contain kind agent|completion and a non-empty id"); } return { kind, id }; } function parseRefs(args: unknown): { items: EvalHandleRef[]; timeoutMs?: number } { if (!isRecord(args) || !Array.isArray(args.items)) { throw new ToolError("wait() requires an items array"); } const items = args.items.map(parseRef); const timeoutMs = args.timeoutMs; if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0)) { throw new ToolError("wait() timeoutMs must be a non-negative finite number"); } return { items, ...(typeof timeoutMs === "number" ? { timeoutMs } : {}) }; } function parseSingleRef(args: unknown): EvalHandleRef { if (!isRecord(args)) throw new ToolError("handle request requires an item"); return parseRef(args.item); } function resolveHandle(ref: EvalHandleRef, options: EvalHandleBridgeOptions): ResolvedHandle { const ownerId = options.session.getAgentId?.() ?? MAIN_AGENT_ID; if (ref.kind === "agent") { const manager = options.session.asyncJobManager; const job = manager?.getJob(ref.id); if (!manager || !job || job.ownerId !== ownerId) { throw new ToolError(`unknown agent handle ${ref.id}; result expired — read agent://${ref.id}`); } return { ref, job, manager }; } const completion = getCompletionHandle(ref.id); if (!completion || completion.ownerId !== ownerId) { throw new ToolError(`unknown completion handle ${ref.id}; result expired`); } return { ref, completion }; } function agentSnapshot(ref: EvalHandleRef, job: AsyncJob): EvalHandleSnapshot { if (job.status === "running") return { ...ref, status: "running" }; if (job.status === "failed") { return { ...ref, status: "failed", error: job.errorText || "Agent failed" }; } if (job.status === "cancelled") { return { ...ref, status: "cancelled", error: job.errorText || job.resultText || "Agent cancelled" }; } const snapshot: EvalHandleSnapshot = { ...ref, status: "completed", text: job.resultText ?? "" }; const evalResult = job.latestDetails?.evalResult; if (isRecord(evalResult)) { if (typeof evalResult.text === "string") snapshot.text = evalResult.text; if (Object.hasOwn(evalResult, "data")) snapshot.data = evalResult.data; } return snapshot; } function completionSnapshot(ref: EvalHandleRef, entry: CompletionHandleEntry): EvalHandleSnapshot { if (!entry.settled) return { ...ref, status: "running" }; if (entry.error) { return { ...ref, status: entry.controller.signal.aborted ? "cancelled" : "failed", error: entry.error, }; } const snapshot: EvalHandleSnapshot = { ...ref, status: "completed", text: entry.result?.text ?? "" }; if (entry.result && Object.hasOwn(entry.result, "data")) snapshot.data = entry.result.data; return snapshot; } function snapshot(resolved: ResolvedHandle): EvalHandleSnapshot { return "job" in resolved ? agentSnapshot(resolved.ref, resolved.job) : completionSnapshot(resolved.ref, resolved.completion); } function cancelResolved(resolved: ResolvedHandle, reason?: unknown): boolean { if ("job" in resolved) { return resolved.manager.cancel(resolved.job.id, { ownerId: resolved.job.ownerId }); } if (resolved.completion.settled) return false; resolved.completion.controller.abort(reason); return true; } /** * Longest string an `agent` status event keeps. Eval renderers show these fields on one line * (a native status row stops at 200 cells, an agent tree row at 48), while the eval result * persists every kept event in its details. */ const AGENT_STATUS_STRING_MAX = 256; /** * The scalar fields of a subagent progress snapshot, long strings cut. Its nested data (tool * data, recent tools and output) stays with the job: no eval renderer reads it, and copying it * into each snapshot stored the full assignment and every extracted record with the eval result. * `assignment` and `task` are dropped too: the row shows `taskPreview` instead. */ function agentStatusFields(progress: Record): Record { const fields: Record = {}; for (const [key, value] of Object.entries(progress)) { if (key === "assignment" || key === "task") continue; if (typeof value === "string") fields[key] = truncate(value, AGENT_STATUS_STRING_MAX); else if (typeof value === "number" || typeof value === "boolean") fields[key] = value; } return fields; } function emitProgress( resolved: ResolvedHandle, emitStatus: ((event: JsStatusEvent) => void) | undefined, previousProgress: unknown, ): unknown { if (!emitStatus || !("job" in resolved)) return previousProgress; const id = resolved.job.agentId ?? resolved.job.id; const progress = agentProgress(resolved.job.latestDetails, id); if (!progress || progress === previousProgress) return previousProgress; const task = typeof progress.assignment === "string" ? progress.assignment : progress.task; const taskPreview = typeof task === "string" ? task.split("\n")[0]?.slice(0, 120) : undefined; emitStatus({ ...agentStatusFields(progress), op: "agent", id, taskPreview: taskPreview || undefined, }); return progress; } /** Largest setTimeout delay before 32-bit overflow (Node/Bun warn and fire in ~1ms past this). */ const MAX_TIMER_MS = 2_147_483_647; async function waitForSettlement( resolved: ResolvedHandle[], timeoutMs: number | undefined, signal: AbortSignal | undefined, ): Promise<"settled" | "timeout" | "aborted"> { const waiting = Promise.allSettled( resolved.map(handle => ("job" in handle ? handle.job.promise : handle.completion.promise)), ).then((): "settled" => "settled"); const races: Array> = [waiting]; let timeout: NodeJS.Timeout | undefined; if (timeoutMs !== undefined) { const deferred = Promise.withResolvers<"timeout">(); // setTimeout overflows past 2^31-1 ms (fires in ~1ms with a // TimeoutOverflowWarning): re-arm in max-size chunks (#12375). let remaining = timeoutMs; const arm = (): void => { timeout = setTimeout( () => { remaining -= MAX_TIMER_MS; if (remaining > 0) arm(); else deferred.resolve("timeout"); }, Math.min(Math.max(0, remaining), MAX_TIMER_MS), ); timeout.unref?.(); }; arm(); races.push(deferred.promise); } let onAbort: (() => void) | undefined; if (signal) { const deferred = Promise.withResolvers<"aborted">(); onAbort = () => deferred.resolve("aborted"); if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); races.push(deferred.promise); } try { return await Promise.race(races); } finally { clearTimeout(timeout); if (onAbort) signal?.removeEventListener("abort", onAbort); } } /** Wait for agent/completion handles and consume settled agent job deliveries. */ export async function runEvalWait( args: unknown, options: EvalHandleBridgeOptions, ): Promise<{ items: EvalHandleSnapshot[] }> { const { items, timeoutMs } = parseRefs(args); const resolved = items.map(item => resolveHandle(item, options)); // Worker job rows are snapshots: re-read each row when it settles, before // another handle can hold this wait open past the row's retention. const refresh = (handle: ResolvedHandle): void => { if ("job" in handle) handle.job = handle.manager.getJob(handle.ref.id) ?? handle.job; }; for (const handle of resolved) { if ("job" in handle) void handle.job.promise.then(() => refresh(handle)); } const emittedProgress = new Map(); const emitLatestProgress = (handle: ResolvedHandle): void => { if (!options.emitStatus || !("job" in handle)) return; refresh(handle); const progress = emitProgress(handle, options.emitStatus, emittedProgress.get(handle.ref.id)); if (progress) emittedProgress.set(handle.ref.id, progress); }; return await withBridgeTimeoutPause( options.emitStatus, async () => { for (const handle of resolved) emitLatestProgress(handle); const interval = setInterval(() => { for (const handle of resolved) emitLatestProgress(handle); }, 1_000); interval.unref?.(); let outcome: "settled" | "timeout" | "aborted"; // An untimed wait keeps the eval call open until every awaited agent // finishes: no message to the caller lands before then. const waiter = options.session.getAgentId?.() ?? MAIN_AGENT_ID; const awaits = (options.session.agentRegistry ?? AgentRegistry.global()).awaits; const releaseHolds = timeoutMs === undefined ? resolved.flatMap(handle => "job" in handle && handle.job.agentId ? [awaits.hold(waiter, [handle.job.agentId], { interruptible: false })] : [], ) : []; try { outcome = await waitForSettlement(resolved, timeoutMs, options.signal); } finally { clearInterval(interval); for (const release of releaseHolds) release(); } for (const handle of resolved) { refresh(handle); emitLatestProgress(handle); } if (outcome === "aborted") { for (const handle of resolved) cancelResolved(handle, options.signal?.reason); await Promise.allSettled( resolved.map(handle => ("job" in handle ? handle.job.promise : handle.completion.promise)), ); throw new ToolAbortError(undefined, { cause: options.signal?.reason }); } const snapshots = resolved.map(snapshot); const settledAgentIds = resolved .filter(handle => "job" in handle && handle.job.status !== "running") .map(handle => handle.ref.id); const manager = options.session.asyncJobManager; if (manager && settledAgentIds.length > 0) manager.consumeJobResults(settledAgentIds); return { items: snapshots }; }, resolved.some(handle => "job" in handle) ? { deferExternalAbort: true } : undefined, ); } /** Return one handle's current state without waiting or consuming it. */ export function runEvalStatus(args: unknown, options: EvalHandleBridgeOptions): EvalHandleSnapshot { const resolved = resolveHandle(parseSingleRef(args), options); const result = snapshot(resolved); if ("completion" in resolved) result.attempts = resolved.completion.attempts.map(attempt => ({ ...attempt })); return result; } /** Cancel one running eval handle owned by this session. */ export function runEvalCancel(args: unknown, options: EvalHandleBridgeOptions): { cancelled: boolean } { const resolved = resolveHandle(parseSingleRef(args), options); return { cancelled: cancelResolved(resolved, new ToolAbortError("Cancelled by eval handle")) }; }