UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

947 lines (946 loc) • 38.5 kB
/** * runIsolatedAgent() — the production sub-agent runner. * * One function replacing what every consumer hand-rolled on top of * generate(): a worker-mode instance (memory off, orchestration off, * observability inherited), a tool-using research pass under the turn budget * (wrap-up nudge + stall watchdog + honest stopReason), an extraction pass * that ALWAYS runs tools-off on its own timeout, a structured-recovery * ladder with corrective re-asks, a delivery guarantee (non-empty execution * records never produce an empty result), abort chaining, a lifecycle event * stream, leashed legs with TTL'd resume handles, and mechanical * waste-signature detection. * * Behavioral contract: docs/plans/2026-07-27-isolated-agent-runner-rfc.md. */ import { randomUUID } from "crypto"; import { logger } from "../utils/logger.js"; import { recoverStructuredData } from "./structuredRecovery.js"; // ── Defaults (all overridable via options) ───────────────────────────────── const DEFAULT_HANDLE_TTL_MS = 600_000; // 10 min const DEFAULT_EXTRACTION_TIMEOUT_MS = 60_000; const DEFAULT_EXTRACTION_MAX_RETRIES = 2; const DEFAULT_MAX_STEPS = 10; // AgentDefinition's documented default const DEFAULT_DUPLICATE_CALL_LIMIT = 2; const DEFAULT_EMPTY_RESULT_STREAK_LIMIT = 3; const DEFAULT_ERROR_STREAK_LIMIT = 3; const DEFAULT_NO_NEW_RESULTS_LIMIT = 8; /** Evidence text budget for the extraction prompt. */ const EXTRACTION_EVIDENCE_MAX_CHARS = 120_000; /** Bounded per-event result summary for onEvent listeners. */ const EVENT_RESULT_SUMMARY_CHARS = 400; // ── Session registry (leashed handles) ───────────────────────────────────── const sessions = new Map(); /** * True when the SAME host + caller session already has an open (live) * leashed handle for this agent definition. * * Deliberately narrow: a handle belongs to the conversation that opened it, * so the conflict only exists when the caller-supplied `sessionId` matches — * a multi-tenant host must never refuse thread B because thread A has an * in-flight leg (thread B cannot see, continue, or legitimately steer that * handle). Callers without a stable sessionId get no conflict detection; * cross-session concurrency is the delegation pool's job. */ export function hasOpenIsolatedAgentHandle(host, definitionId, callerSessionId) { if (callerSessionId === undefined) { return { open: false }; } for (const session of sessions.values()) { if (session.host === host && session.definition.id === definitionId && session.callerSessionId === callerSessionId && !session.tombstone) { return { open: true, handle: session.handle }; } } return { open: false }; } // ── Small helpers ────────────────────────────────────────────────────────── function emitEvent(options, event) { if (!options?.onEvent) { return; } try { options.onEvent(event); } catch { // Listener errors never break the run. } } function stableStringify(value) { try { if (value === null || typeof value !== "object") { return JSON.stringify(value) ?? "undefined"; } if (Array.isArray(value)) { return `[${value.map(stableStringify).join(",")}]`; } const record = value; const keys = Object.keys(record).sort(); return `{${keys .map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`) .join(",")}}`; } catch { return String(value); } } function isEmptyResultText(resultText) { const trimmed = resultText.trim(); return (trimmed === "" || trimmed === "null" || trimmed === "undefined" || trimmed === "[]" || trimmed === "{}" || trimmed === '""'); } function summarizeParams(params, maxChars = 120) { const text = stableStringify(params); return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text; } function addUsage(total, delta) { if (!delta) { return; } total.input += delta.input ?? 0; total.output += delta.output ?? 0; total.total += delta.total ?? 0; } function chatMessage(role, content) { return { id: randomUUID(), role, content }; } function isAbortLikeError(error) { const message = error instanceof Error ? error.message : String(error); const name = error instanceof Error ? error.name : ""; return name === "AbortError" || /abort/i.test(message); } /** * A leg is "cut short" — and therefore resumable in leashed mode — only when * something bounded it: the turn budget (`time-limit`), the step cap, the * leg's own tool-call budget, or a waste trip. A generate that returned * normally WITHOUT a stopReason is a finished turn: several provider loops * (e.g. the chat-completions/LiteLLM path) never set `stopReason`, and * inferring "unfinished" from its absence would leave those paths * `in_progress` forever (and mislabel every healthy non-leashed run partial). */ function isLegCutShort(legOutcome) { const stop = legOutcome.result?.stopReason; return (stop === "time-limit" || stop === "step-cap" || legOutcome.budgetTripped || legOutcome.wasteSignals.length > 0); } /** * Mechanical digest of a run's execution records: which tools ran, ok/failed * counts, and bounded excerpts of successful payloads. The delivery * guarantee's last line of defense — a non-empty execution record can never * produce an empty result. */ export function buildMechanicalDigest(records) { const toolsRun = {}; for (const record of records) { const entry = (toolsRun[record.toolName] ??= { calls: 0, ok: 0, failed: 0, }); entry.calls++; if (record.isError) { entry.failed++; } else { entry.ok++; } } const excerpts = records .filter((r) => !r.isError && !isEmptyResultText(r.resultText)) .slice(-10) .map((r) => ({ toolName: r.toolName, params: summarizeParams(r.params), resultText: r.resultText.length > 2000 ? `${r.resultText.slice(0, 2000)}…` : r.resultText, })); return { kind: "mechanical-digest", toolsRun, excerpts }; } // ── Waste detection ──────────────────────────────────────────────────────── function checkWasteSignatures(record, session, state, thresholds) { const tripped = []; const duplicateLimit = thresholds?.duplicateCallLimit ?? DEFAULT_DUPLICATE_CALL_LIMIT; const emptyLimit = thresholds?.emptyResultStreakLimit ?? DEFAULT_EMPTY_RESULT_STREAK_LIMIT; const errorLimit = thresholds?.errorStreakLimit ?? DEFAULT_ERROR_STREAK_LIMIT; const noNewLimit = thresholds?.noNewResultsLimit ?? DEFAULT_NO_NEW_RESULTS_LIMIT; // Duplicate call hash (same tool + same normalized params in this run) const callHash = `${record.toolName}:${stableStringify(record.params)}`; const seenCount = (session.seenCallHashes.get(callHash) ?? 0) + 1; session.seenCallHashes.set(callHash, seenCount); if (seenCount > duplicateLimit) { tripped.push(`duplicate-call: ${record.toolName} called ${seenCount}× with identical params`); } // Consecutive empty results if (!record.isError && isEmptyResultText(record.resultText)) { state.emptyStreak++; if (state.emptyStreak >= emptyLimit) { tripped.push(`empty-results: ${state.emptyStreak} consecutive empty/zero results`); } } else { state.emptyStreak = 0; } // Consecutive errors if (record.isError) { state.errorStreak++; if (state.errorStreak >= errorLimit) { tripped.push(`error-streak: ${state.errorStreak} consecutive failures`); } } else { state.errorStreak = 0; } // Budget burn without new distinct results const resultHash = stableStringify(record.resultText); if (session.seenResultHashes.has(resultHash) || record.isError || isEmptyResultText(record.resultText)) { state.callsSinceNewResult++; if (state.callsSinceNewResult >= noNewLimit) { tripped.push(`no-new-results: ${state.callsSinceNewResult} calls without a new distinct result`); } } else { session.seenResultHashes.add(resultHash); state.callsSinceNewResult = 0; } return tripped; } // ── Extraction ───────────────────────────────────────────────────────────── function buildEvidenceBlock(records) { const lines = []; let budget = EXTRACTION_EVIDENCE_MAX_CHARS; // Newest evidence is the most relevant — walk backwards, then restore order. for (let i = records.length - 1; i >= 0 && budget > 0; i--) { const r = records[i]; const line = `[${i + 1}] ${r.toolName}(${summarizeParams(r.params, 300)}) → ${r.isError ? "ERROR" : "ok"} (${r.durationMs}ms)\n${r.resultText}`; budget -= line.length; if (budget < 0 && lines.length > 0) { break; } lines.push(line); } return lines.reverse().join("\n\n"); } async function runExtractionPass(args) { const extraction = args.definition.extraction; if (!extraction) { return { errors: [] }; } const maxRetries = extraction.maxRetries ?? DEFAULT_EXTRACTION_MAX_RETRIES; const timeoutMs = extraction.timeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS; // Phase-level deadline so the composed wall-clock is bounded by ONE knob: // callers deriving outer ceilings need a single number, not // (maxRetries + 1) × timeoutMs arithmetic. const totalTimeoutMs = extraction.totalTimeoutMs ?? (maxRetries + 1) * timeoutMs; const extractionDeadline = Date.now() + totalTimeoutMs; const wireSchema = extraction.wireSchema ?? extraction.schema; emitEvent(args.runOptions, { type: "phase", runId: args.runId, agentId: args.definition.id, timestamp: Date.now(), phase: "extraction", }); const evidence = buildEvidenceBlock(args.records); const basePrompt = [ "You are the extraction pass of a two-pass research agent. Produce ONLY the requested JSON — no prose, no markdown fencing.", extraction.shapeDoc ? `Required JSON shape:\n${extraction.shapeDoc}` : "", evidence ? `Evidence — tool executions from the research pass:\n${evidence}` : "No tool executions were recorded.", args.researchContent ? `Researcher's notes:\n${args.researchContent}` : "", ] .filter(Boolean) .join("\n\n"); let prompt = basePrompt; let attachSchema = true; const allErrors = []; for (let attempt = 0; attempt <= maxRetries; attempt++) { if (args.abortSignal?.aborted) { allErrors.push("extraction aborted by parent signal"); return { errors: allErrors }; } const remainingMs = extractionDeadline - Date.now(); if (remainingMs <= 0) { allErrors.push(`extraction phase deadline reached (${totalTimeoutMs}ms total)`); break; } const attemptTimeoutMs = Math.min(timeoutMs, remainingMs); let result; try { result = await args.worker.generate({ input: { text: prompt }, systemPrompt: "You convert research evidence into schema-valid JSON. Output only JSON.", provider: args.runOptions?.overrides?.provider ?? args.definition.provider, model: args.runOptions?.overrides?.model ?? args.definition.model, disableTools: true, // The extraction pass runs on its OWN timeout — never carved out of // the research budget. timeout: attemptTimeoutMs, turnTimeoutMs: attemptTimeoutMs, maxSteps: 1, ...(args.runOptions?.overrides?.maxTokens !== undefined && { maxTokens: args.runOptions.overrides.maxTokens, }), ...(attachSchema && { schema: wireSchema }), ...(args.abortSignal && { abortSignal: args.abortSignal }), }); } catch (error) { if (args.abortSignal?.aborted || isAbortLikeError(error)) { allErrors.push("extraction aborted"); return { errors: allErrors }; } const message = error instanceof Error ? error.message : String(error); allErrors.push(`extraction generate failed: ${message}`); if (attachSchema) { // Providers can reject the wire schema (constrained-decoding // complexity limits) — retry without it; the local ladder still // validates. attachSchema = false; continue; } continue; } addUsage(args.usage, result.usage); const recovered = recoverStructuredData({ content: result.content, structuredData: result.structuredData, schema: extraction.schema, coerce: extraction.coerce, }); if (recovered.data !== undefined) { return { data: recovered.data, source: recovered.source, errors: allErrors, }; } allErrors.push(...recovered.errors); // Corrective re-ask: carry the validation errors and the shape doc. prompt = [ basePrompt, `Your previous output failed validation:\n${recovered.errors.join("\n")}`, extraction.shapeDoc ? `Fix the output to match this shape exactly:\n${extraction.shapeDoc}` : "Fix the output to be valid JSON matching the required schema exactly.", ].join("\n\n"); } return { errors: allErrors }; } // ── Research leg ─────────────────────────────────────────────────────────── async function runResearchLeg(session, prompt) { const { definition, options, worker, runId } = session; const overrides = options.overrides; const leg = options.leg; const legStart = Date.now(); const legRecords = []; const wasteSignals = []; const wasteState = { emptyStreak: 0, errorStreak: 0, callsSinceNewResult: 0, }; // Internal controller: waste trips and tool-call budgets end the leg early; // the parent signal chains in so an aborted parent stops the leg too. const legController = new AbortController(); let abortedByParent = false; let budgetTripped = false; const onParentAbort = () => { abortedByParent = true; legController.abort(); }; if (options.abortSignal) { if (options.abortSignal.aborted) { onParentAbort(); } else { options.abortSignal.addEventListener("abort", onParentAbort, { once: true, }); } } const onRecord = (record) => { legRecords.push(record); session.allRecords.push(record); session.spentToolCalls++; emitEvent(options, { type: "tool_call", runId, agentId: definition.id, timestamp: record.startedAt, toolName: record.toolName, params: record.params, }); emitEvent(options, { type: "tool_result", runId, agentId: definition.id, timestamp: Date.now(), toolName: record.toolName, resultSummary: record.resultText.slice(0, EVENT_RESULT_SUMMARY_CHARS), isError: record.isError, }); const tripped = checkWasteSignatures(record, session, wasteState, options.waste); if (tripped.length > 0) { wasteSignals.push(...tripped.filter((t) => !wasteSignals.includes(t))); emitEvent(options, { type: "waste", runId, agentId: definition.id, timestamp: Date.now(), wasteSignals: [...wasteSignals], }); legController.abort(); } if (leg?.budgetToolCalls !== undefined && legRecords.length >= leg.budgetToolCalls && !legController.signal.aborted) { budgetTripped = true; legController.abort(); } }; // Leg wall-clock budget rides the REAL turn budget machinery: the model // gets the wrap-up nudge and consolidates — never a bare wall-clock kill. const turnBudgetCandidates = [overrides?.turnTimeoutMs, leg?.budgetMs].filter((v) => typeof v === "number" && v > 0); const turnTimeoutMs = turnBudgetCandidates.length > 0 ? Math.min(...turnBudgetCandidates) : undefined; const generateOptions = { input: { text: prompt }, systemPrompt: definition.instructions, provider: overrides?.provider ?? definition.provider, model: overrides?.model ?? definition.model, ...(definition.temperature !== undefined && { temperature: definition.temperature, }), ...(definition.tools && definition.tools.length > 0 && { toolFilter: definition.tools }), maxSteps: overrides?.maxSteps ?? definition.maxSteps ?? DEFAULT_MAX_STEPS, ...(overrides?.maxTokens !== undefined && { maxTokens: overrides.maxTokens, }), ...(turnTimeoutMs !== undefined && { turnTimeoutMs }), ...(overrides?.stallTimeoutMs !== undefined && { stallTimeoutMs: overrides.stallTimeoutMs, }), ...(overrides?.wrapupTimeLeadMs !== undefined && { wrapupTimeLeadMs: overrides.wrapupTimeLeadMs, }), abortSignal: legController.signal, requestId: `${runId}-leg${session.legIndex}`, toolExecutionCapture: { ...(options.capture ?? {}), onRecord, }, ...(session.history.length > 0 && { conversationMessages: session.history, }), context: { agentId: definition.id, runId, ...(options.toolContext ?? {}), }, }; let result; let researchError; try { result = await worker.generate(generateOptions); } catch (error) { if (abortedByParent || legController.signal.aborted) { // Early leg end (parent abort / waste trip / tool budget) — the // records survive; extraction still runs from them. } else { researchError = error instanceof Error ? error.message : String(error); emitEvent(options, { type: "error", runId, agentId: definition.id, timestamp: Date.now(), error: researchError, }); } } finally { if (options.abortSignal) { options.abortSignal.removeEventListener("abort", onParentAbort); } } if (result?.stopReason === "time-limit") { emitEvent(options, { type: "wrapup", runId, agentId: definition.id, timestamp: Date.now(), }); } if (result?.usage) { addUsage(session.usage, result.usage); } // Maintain the worker's conversation for continuation legs. session.history.push(chatMessage("user", prompt)); if (result?.content) { session.history.push(chatMessage("assistant", result.content)); } else if (legRecords.length > 0 || wasteSignals.length > 0) { // An early leg end (waste trip / tool budget / parent abort) killed the // generate mid-step, so there is no assistant text. Push a synthetic // assistant message from what the runner already knows — otherwise the // resumed worker (and askNextPlan, which replays this history) is blind // to its own prior tool activity and re-runs the same queries, which the // run-scoped duplicate-call signature then trips on immediately. const header = wasteSignals.length > 0 ? `[leg ended early — waste signals: ${wasteSignals.join("; ")}]` : "[leg ended early before a written summary]"; session.history.push(chatMessage("assistant", `${header}\nTool activity this leg:\n${buildDelta(legRecords).join("\n")}`)); } const durationMs = Date.now() - legStart; session.spentMs += durationMs; return { result, researchError, legRecords, wasteSignals, toolCalls: legRecords.length, durationMs, aborted: abortedByParent, budgetTripped, }; } /** Ask the worker for its own 1–2 sentence next-step statement (leg wrap). */ async function askNextPlan(session) { try { const result = await session.worker.generate({ input: { text: "In 1-2 sentences, state the next concrete step you would take to finish this investigation. Output only the statement.", }, systemPrompt: session.definition.instructions, provider: session.options.overrides?.provider ?? session.definition.provider, model: session.options.overrides?.model ?? session.definition.model, disableTools: true, maxSteps: 1, timeout: 20_000, turnTimeoutMs: 20_000, conversationMessages: session.history, ...(session.options.abortSignal && { abortSignal: session.options.abortSignal, }), }); addUsage(session.usage, result.usage); return result.content?.trim() || undefined; } catch { return undefined; } } // ── Outcome assembly ─────────────────────────────────────────────────────── function resolveBudget(session) { const budgetMs = session.options.leg?.budgetMs; return { spentMs: session.spentMs, remainingMs: budgetMs !== undefined ? Math.max(0, budgetMs - session.spentMs) : 0, spentToolCalls: session.spentToolCalls, }; } function buildDelta(legRecords) { return legRecords.map((r) => `${r.toolName}(${summarizeParams(r.params, 80)}) → ${r.isError ? "error" : isEmptyResultText(r.resultText) ? "empty" : `ok (${r.resultText.length} chars)`}`); } async function finalizeOutcome(session, legOutcome, startedAt) { const { definition, options, runId } = session; const result = legOutcome.result; const researchStop = legOutcome.aborted ? "aborted" : (result?.stopReason ?? (legOutcome.researchError ? "provider-error" : undefined)); const researchContent = result?.content ?? ""; const records = session.allRecords; // Extraction ALWAYS runs when configured — even when the research generate // died, it extracts from the execution records instead of losing the run. // Parent abort is the one exception: a cancelled run stops cleanly. let data; let extractionSource; let extractionError; if (definition.extraction && !options.abortSignal?.aborted) { const extracted = await runExtractionPass({ worker: session.worker, definition, runOptions: options, runId, records, researchContent, usage: session.usage, abortSignal: options.abortSignal, }); data = extracted.data; extractionSource = extracted.source; if (data === undefined && extracted.errors.length > 0) { extractionError = extracted.errors.slice(-4).join(" | "); } } const hasEvidence = records.length > 0 || researchContent.trim().length > 0; // "Research finished cleanly" is derived from the leg's own control flow, // not from provider stopReason vocabulary: a missing stopReason (e.g. the // chat-completions loop never sets one) is a clean finish, while any // runner-imposed cut (time-limit / step-cap / tool budget / waste) is not. const researchCleanFinish = !isLegCutShort(legOutcome) && (researchStop === "completed" || researchStop === undefined); let status; if (legOutcome.aborted) { status = hasEvidence ? "partial" : "error"; } else if (data !== undefined) { status = researchCleanFinish ? "completed" : "partial"; } else if (definition.extraction) { if (!hasEvidence) { status = legOutcome.researchError ? "error" : "insufficient_data"; } else { // Delivery guarantee: mechanical digest instead of an empty result. data = buildMechanicalDigest(records); status = legOutcome.researchError ? "error" : "partial"; } } else { status = legOutcome.researchError ? "error" : researchCleanFinish ? "completed" : "partial"; } const outcome = { status, ...(data !== undefined && { data }), ...(researchContent && { content: researchContent }), ...(researchStop !== undefined && { stopReason: researchStop }), // Terminal outcomes carry the WHOLE run's records (all legs) — the data // above was built from all of them; in_progress legs carry per-leg // records instead. toolExecutions: records, usage: { ...session.usage }, durationMs: Date.now() - startedAt, ...(extractionSource && { extractionSource }), ...(extractionError && { extractionError }), ...(legOutcome.wasteSignals.length > 0 && { wasteSignals: legOutcome.wasteSignals, }), ...(options.leg && { budget: resolveBudget(session) }), }; emitEvent(options, { type: status === "error" ? "error" : "complete", runId, agentId: definition.id, timestamp: Date.now(), status, ...(legOutcome.researchError && { error: legOutcome.researchError }), }); return outcome; } async function disposeWorkerQuietly(worker) { try { await worker.dispose(); } catch (error) { logger.debug("[IsolatedAgent] Worker dispose failed", { error: error instanceof Error ? error.message : String(error), }); } } function registerHandle(session) { sessions.set(session.handle, session); const ttlMs = session.options.handleTtlMs ?? DEFAULT_HANDLE_TTL_MS; session.ttlTimer = setTimeout(() => { void expireSession(session); }, ttlMs); // Never keep the process alive for an abandoned handle. session.ttlTimer.unref?.(); } async function expireSession(session) { // The session may have been finalized/stopped between the timer firing // and this callback running — never expire an entry we no longer own. if (sessions.get(session.handle) !== session || session.tombstone) { return; } // In-flight guard: the TTL can fire in the race window before // continueIsolatedAgent clears the timer. Never dispose a worker // mid-generate — the running leg's own completion path re-registers the // handle (fresh TTL) or finalizes and cleans up. if (session.running) { return; } // TTL expiry: auto-dispose, keep the final outcome retrievable once — // an abandoned leg is never silently lost. The tombstone is set BEFORE // the async dispose so a concurrent continueAgent/stopAgent sees it and // returns it instead of starting a leg on a worker being disposed. const records = session.allRecords; session.tombstone = { status: records.length > 0 ? "partial" : "error", data: buildMechanicalDigest(records), stopReason: "handle-ttl-expired", toolExecutions: records, usage: { ...session.usage }, durationMs: session.spentMs, ...(session.options.leg && { budget: resolveBudget(session) }), }; await disposeWorkerQuietly(session.worker); logger.debug("[IsolatedAgent] Handle expired; outcome tombstoned", { handle: session.handle, agentId: session.definition.id, }); // Second stage: evict the tombstone if nobody ever retrieves it, so an // abandoned handle doesn't retain its records/history forever. const graceTimer = setTimeout(() => { if (sessions.get(session.handle) === session) { sessions.delete(session.handle); } }, session.options.handleTtlMs ?? DEFAULT_HANDLE_TTL_MS); graceTimer.unref?.(); } // ── Public API ───────────────────────────────────────────────────────────── /** * Run an isolated sub-agent: research pass with tools under the turn budget, * extraction pass tools-off on its own timeout, structured recovery, * delivery guarantee, abort chaining, events, and (when `options.leg` is * set) leashed legs with a TTL'd resume handle. */ export async function runIsolatedAgent(host, definition, input, options = {}) { if (!definition?.id || !definition?.instructions) { throw new Error("runIsolatedAgent: definition must include id and instructions"); } const startedAt = Date.now(); const runId = randomUUID(); const leashed = !!options.leg; const worker = host.createWorkerInstance({ logTag: `agent:${definition.id}`, }); const session = { handle: `agent-run-${runId}`, runId, host, worker, definition, options, callerSessionId: options.toolContext?.sessionId, running: false, history: [], legIndex: 0, spentMs: 0, spentToolCalls: 0, allRecords: [], usage: { input: 0, output: 0, total: 0 }, seenCallHashes: new Map(), seenResultHashes: new Set(), }; // Tool context — set on the worker for EVERY tool call, including the // caller-supplied sessionId (run id when none supplied). worker.setToolContext({ ...(options.toolContext ?? {}), sessionId: options.toolContext?.sessionId ?? runId, }); emitEvent(options, { type: "start", runId, agentId: definition.id, timestamp: startedAt, }); emitEvent(options, { type: "phase", runId, agentId: definition.id, timestamp: Date.now(), phase: "research", }); const prompt = typeof input === "string" ? input : JSON.stringify(input, null, 2); let keepAlive = false; try { const legOutcome = await runResearchLeg(session, prompt); // Leashed mode: a leg the runner CUT SHORT (budget / step cap / waste) // returns in_progress with a resume handle. A generate that returned // normally — with or without a provider stopReason — is a finished turn // and finalizes (see isLegCutShort). if (leashed && !legOutcome.aborted && !legOutcome.researchError && isLegCutShort(legOutcome)) { const nextPlan = await askNextPlan(session); const legInfo = { index: session.legIndex, toolCalls: legOutcome.toolCalls, durationMs: legOutcome.durationMs, }; emitEvent(options, { type: "leg_end", runId, agentId: definition.id, timestamp: Date.now(), legIndex: session.legIndex, ...(legOutcome.wasteSignals.length > 0 && { wasteSignals: legOutcome.wasteSignals, }), }); session.legIndex++; registerHandle(session); keepAlive = true; return { status: "in_progress", ...(legOutcome.result?.content && { content: legOutcome.result.content, }), ...(legOutcome.result?.stopReason && { stopReason: legOutcome.result.stopReason, }), toolExecutions: legOutcome.legRecords, usage: { ...session.usage }, durationMs: Date.now() - startedAt, handle: session.handle, leg: legInfo, delta: buildDelta(legOutcome.legRecords), ...(nextPlan && { nextPlan }), ...(legOutcome.wasteSignals.length > 0 && { wasteSignals: legOutcome.wasteSignals, }), budget: resolveBudget(session), }; } return await finalizeOutcome(session, legOutcome, startedAt); } finally { if (!keepAlive) { await disposeWorkerQuietly(worker); } } } /** * Resume a leashed run. `guidance`, when provided, is appended as a user * turn before the next leg — this is how a supervisor re-steers a drifting * worker. An expired handle returns its tombstoned final outcome once. * Handles are owned by the host that created them; a handle with a leg * already in flight refuses a second continuation instead of racing it. */ export async function continueIsolatedAgent(host, handle, guidance) { const session = sessions.get(handle); if (!session || session.host !== host) { throw new Error(`Unknown agent handle "${handle}" — it may belong to another instance, or it expired and its final outcome was already retrieved.`); } if (session.tombstone) { // Retrievable exactly once. sessions.delete(handle); return session.tombstone; } if (session.running) { throw new Error(`Agent handle "${handle}" already has a leg in progress — await it before continuing.`); } session.running = true; if (session.ttlTimer) { clearTimeout(session.ttlTimer); session.ttlTimer = undefined; } if (guidance) { session.history.push(chatMessage("user", guidance)); } const startedAt = Date.now(); let keepAlive = false; try { const legOutcome = await runResearchLeg(session, guidance ? "Follow the supervisor guidance above and continue the investigation." : "Continue the investigation from where you left off."); if (!legOutcome.aborted && !legOutcome.researchError && isLegCutShort(legOutcome)) { const nextPlan = await askNextPlan(session); const legInfo = { index: session.legIndex, toolCalls: legOutcome.toolCalls, durationMs: legOutcome.durationMs, }; emitEvent(session.options, { type: "leg_end", runId: session.runId, agentId: session.definition.id, timestamp: Date.now(), legIndex: session.legIndex, }); session.legIndex++; registerHandle(session); keepAlive = true; return { status: "in_progress", ...(legOutcome.result?.content && { content: legOutcome.result.content, }), ...(legOutcome.result?.stopReason && { stopReason: legOutcome.result.stopReason, }), toolExecutions: legOutcome.legRecords, usage: { ...session.usage }, durationMs: Date.now() - startedAt, handle: session.handle, leg: legInfo, delta: buildDelta(legOutcome.legRecords), ...(nextPlan && { nextPlan }), ...(legOutcome.wasteSignals.length > 0 && { wasteSignals: legOutcome.wasteSignals, }), budget: resolveBudget(session), }; } sessions.delete(handle); return await finalizeOutcome(session, legOutcome, startedAt); } finally { session.running = false; if (!keepAlive) { sessions.delete(handle); await disposeWorkerQuietly(session.worker); } } } /** * Stop a leashed run: dispose the worker and return the final outcome * (mechanical digest over everything gathered so far). Refuses while a leg * is in flight — cancel an in-flight leg via the run's `abortSignal` instead * of disposing its worker mid-generate. */ export async function stopIsolatedAgent(host, handle) { const session = sessions.get(handle); if (!session || session.host !== host) { throw new Error(`Unknown agent handle "${handle}" — it may belong to another instance, or it expired and its final outcome was already retrieved.`); } if (session.running) { throw new Error(`Agent handle "${handle}" has a leg in progress — await it (or abort via the run's abortSignal) before stopping.`); } sessions.delete(handle); if (session.tombstone) { return session.tombstone; } if (session.ttlTimer) { clearTimeout(session.ttlTimer); } await disposeWorkerQuietly(session.worker); const records = session.allRecords; return { status: records.length > 0 ? "partial" : "error", data: buildMechanicalDigest(records), stopReason: "stopped", toolExecutions: records, usage: { ...session.usage }, durationMs: session.spentMs, ...(session.options.leg && { budget: resolveBudget(session) }), }; }