/** * outcome-delivery.ts — Shared rendering for a subagent outcome. * * A terminated child's outcome reaches the parent model through exactly one * carrier: the foreground tool return, the resume tool return, the * `get_subagent_result` report, or the completion nudge. Each carrier owns its * own framing (spawn notes, XML envelope, report header) and its own * truncation — a nudge is a preview, a pull is the full text — but the body * they wrap is the same fact, so it is rendered here rather than four times. * * A workspace notice rides the same four carriers without being part of the * outcome: it reports where a teardown put the child's work, which the parent * needs whether the run succeeded or failed. A notice produced after the * result was already delivered reaches none of them and is announced on its * own instead. * * The updates a child sent mid-run ride the carriers too, for the runs whose * outcome a blocked carrier holds: the announcement channel would deliver them * after that carrier's own return, so it renders them itself instead. * * The updates, the notice, and the ask-back affordance are appended in one * fixed order by `renderOutcomeAddenda`, so the three carriers that compose * them cannot disagree about what follows the body. The affordance is the one * addendum that asks the parent to call back, so it is also the one that has * to know whether that call would be accepted. * * Pure functions only: no SDK types, no record types, no side effects. */ import type { ResumeRefusal } from "#src/lifecycle/subagent"; import type { SubagentStatus } from "#src/lifecycle/subagent-state"; import { assertNever } from "./exhaustive"; import { type TurnBudget, wrappedUpAtTurnLimit, } from "#src/lifecycle/turn-limits"; /** * What a terminal status means, independent of how a carrier renders it. * * One source of truth for the facts, because the carriers disagreed on them: * the nudge reported an abort as "max turns exceeded" while the foreground * result added "output may be incomplete", and the pull and resume carriers * reported nothing at all. Presentation still differs — see the two renderers * below — because a standalone label and a mid-sentence parenthetical are * different grammar, not different facts. */ type StatusMeaning = { /** Sentence-initial label, e.g. "Wrapped up". */ label: string; /** Why, without terminal punctuation, e.g. "after turn-budget warning". */ detail: string; }; /** A run that finished on its own after the harness warned it about its turn limit. */ const WRAPPED_UP: StatusMeaning = { label: "Wrapped up", detail: "after turn-budget warning", }; /** * Only what the status presentations read: the status, the error an error * label names, and the budget that qualifies a completed run. * * A read view over a live record, so its optionals accept an explicit * `undefined`: a record's getters answer `undefined` for "not decided", and a * renderer told "there is no error here" does not care whether that arrived as * an absent key or a present one. Producers of configuration and snapshots use * `defined()` instead — there, "unset" and "set to nothing" are different facts * worth preserving. */ export type StatusOutcome = { status: SubagentStatus; error?: string | undefined; turnBudget?: TurnBudget | undefined; }; function statusMeaning(outcome: StatusOutcome): StatusMeaning | undefined { switch (outcome.status) { case "aborted": return { label: "Aborted", detail: "turn limit reached, output may be incomplete", }; case "stopped": return { label: "Stopped", detail: "user request" }; case "completed": if (wrappedUpAtTurnLimit(outcome)) { return WRAPPED_UP; } return undefined; case "running": case "queued": case "error": return undefined; default: return assertNever(outcome.status); } } /** * Why a resume is unavailable *for good*, worded as a subordinate clause. * * The resume door words the same three facts as standalone refusals; a clause * that continues someone else's sentence is different grammar, not a different * fact — the split `STATUS_MEANINGS` makes between `label` and `detail`. * * The transient refusals are excluded by type rather than by omission: a * refusal that lifts when a run settles asks the parent to wait, where these * three ask it to give up, so they get their own sentence in the renderer below. */ const RESUME_REFUSAL_CLAUSES: Record< Exclude, string > = { // Deliberately not "...no session to resume": the clause is followed by a // colon, and "resume:" is the exact token the parent must not see here. "no-session": "it has no active session", "session-released": "its session was released after its retention window", "workspace-disposed": "it ran in an isolated workspace that has since been removed", }; /** * Standalone label form, e.g. "Wrapped up (after turn-budget warning)". * * An error reports its message instead: the status alone does not say what * went wrong. */ export function renderStatusLabel(outcome: StatusOutcome): string { if (outcome.status === "error") return `Error: ${outcome.error ?? "unknown"}`; const meaning = statusMeaning(outcome); return meaning ? `${meaning.label} (${meaning.detail})` : "Done"; } /** * Parenthetical suffix form, e.g. " (wrapped up — after turn-budget warning)", for a * carrier appending to its own sentence. Empty when the status is unremarkable * or when the body already carries the explanation, as an error's does. */ export function renderStatusNote(outcome: StatusOutcome): string { const meaning = statusMeaning(outcome); if (!meaning) return ""; return ` (${meaning.label.toLowerCase()} \u2014 ${meaning.detail})`; } /** * Only what the body formatter reads. Narrower than any record type so a * caller cannot come to depend on fields this module does not use. */ export type OutcomeBody = { status: SubagentStatus; // A read view over a live record, so the optionals take an explicit // `undefined` — see the note on `StatusOutcome`. result?: string | undefined; error?: string | undefined; /** Whether the agent was stopped before the limiter ever admitted it. */ stoppedWhileQueued: boolean; }; /** * The trailing affordance for a child that ended its turn with a question. * Empty when the child asked nothing. * * Names the exact call that answers it when a resume would be accepted, why it * cannot be answered when one would be refused for good, and what to wait for * when the child is simply not finished — the parent is never told to make a * call this extension declines. * * Takes the id, question, and refusal rather than a record: the three facts it * needs, so a carrier holding any shape can call it. */ export function renderQuestionAffordance( agentId: string, question: string | undefined, refusal: ResumeRefusal | undefined, ): string { if (!question) return ""; const quoted = question .split("\n") .map((line) => ` ${line}`) .join("\n"); // A queued resume's run is new and its predecessor was replaced, so neither // carries a question through a real record; the type still requires the arm. if (refusal === "still-running" || refusal === "resume-queued") { return ( "\n\nThis agent asked a question before it finished running, so it cannot be " + `resumed yet:\n\n${quoted}\n\n` + "Wait for it to settle \u2014 get_subagent_result with wait: true returns when it " + "does \u2014 then answer it." ); } if (refusal) { return ( "\n\nThis agent ended its run with a question that can no longer be answered \u2014 " + `${RESUME_REFUSAL_CLAUSES[refusal]}:\n\n${quoted}\n\n` + "Spawn a new agent with the context it needs; this one cannot be resumed." ); } return ( `\n\nThis agent is waiting on an answer:\n\n${quoted}\n\n` + `Answer by calling subagent with resume: "${agentId}" and your answer as the prompt.` ); } /** * The provider's own wording for where a teardown left the child's work, for a * carrier appending it after the outcome body. Empty when no teardown reported * anything — including every run whose addendum rode the result text instead. * * Takes the notice rather than a record, so a carrier holding any shape can * call it, and so the framing has one home if a carrier ever needs its own. */ export function renderWorkspaceNotice(notice: string | undefined): string { return notice ?? ""; } /** * What a carrier appends after the outcome body: where the work went, then the * call to action that follows it. * * The fields rather than a record, so a carrier holding any shape can call it * — both `Subagent` and `AgentReport` satisfy this structurally. */ export type OutcomeAddenda = { id: string; runUpdates?: ReadonlyArray | undefined; workspaceNotice?: string | undefined; pendingQuestion?: string | undefined; /** * Why a resume would be refused; undefined when one would be accepted. * * Required rather than optional so a carrier that forgets it fails to * compile. An omitted optional would silently mean "resumable", which is the * fail-open this field exists to close. */ resumeRefusal: ResumeRefusal | undefined; }; /** The addenda tail every outcome carrier appends, in one order. */ export function renderOutcomeAddenda(outcome: OutcomeAddenda): string { return ( // What the agent flagged along the way, then where the work went, then the // call to action that follows both. renderRunUpdates(outcome.runUpdates) + renderWorkspaceNotice(outcome.workspaceNotice) + renderQuestionAffordance( outcome.id, outcome.pendingQuestion, outcome.resumeRefusal, ) ); } /** * The updates a child sent while a carrier held this run's outcome, quoted the * way the ask-back affordance quotes the child's question. Empty for a run the * child said nothing during — which is every run whose updates the parent was * free to receive as they happened. */ export function renderRunUpdates( updates: ReadonlyArray | undefined, ): string { if (!updates?.length) return ""; const quoted = updates .map((update) => update .split("\n") .map((line) => ` ${line}`) .join("\n"), ) .join("\n\n"); return `\n\nUpdates this agent sent while it worked:\n\n${quoted}`; } /** * The outcome body every carrier reports: a running note, a queued note, an * error line, a never-started note, or the trimmed result. * * The empty-result fallback tests truthiness rather than nullishness. A `??` * here passed an empty string through, which is exactly what a run whose * provider errored carries — so the parent received a completion header * followed by nothing and confabulated the child's work. The two renderers in * `notification.ts` always guarded this by truthiness; this is the third * agreeing with them (#889). */ export function renderOutcomeBody(outcome: OutcomeBody): string { switch (outcome.status) { case "running": return "Agent is still running. Use wait: true or check back later."; case "queued": return ( "Agent is queued and has not started; it is waiting for a concurrency slot (maxConcurrent). " + "Use wait: true or check back later." ); case "error": return `Error: ${outcome.error}`; case "completed": case "aborted": case "stopped": { if (outcome.stoppedWhileQueued) return "Agent was stopped while queued and never started. No work was performed."; const trimmed = outcome.result?.trim(); return trimmed || "No output."; } default: return assertNever(outcome.status); } }