/** * Default `post-model-call` hook: when a user-facing turn is about to end with a * progress surface the model showed but never closed, nudge the model — once * per run — to complete or dismiss it, then re-query so it can act. * * Motivation: the model can call `ui_show` with a `task_progress` card (or a * `work_result` in an `in_progress` state) to show live progress, but weaker * models often never advance it to a terminal status or `ui_dismiss` it. The * surface then renders a spinner forever, long after the work finished. Static * prompt guidance is easy to ignore; a reminder injected at the moment the turn * would otherwise end — with a concrete "you left this open" signal — is far * more salient. * * The nudge is strictly best-effort and self-targeting: * - It fires at most once per run (no looping if the model declines). * - It is advisory — the model may leave the surface open if the work it * represents is genuinely still running. * - A model that already completed or dismissed its surfaces is never nudged, * so capable models that close their surfaces pay nothing. * * Only a finalized, no-tool, main-agent reply is actionable: * - A provider rejection carries no turn content to assess (a recovery hook * like history-repair owns that). * - A tool-bearing turn continues naturally — the loop runs the tools and the * model gets another chance to close the surface — so we leave it alone. * - Background call sites (wake, title-gen, memory) have no live user watching * a spinner, so the nudge would only burn a model round. * * No subagent guard is needed: the `ui-surface` tools are gated on a connected * client (see `conversation-tool-setup.ts`), and subagents have none — so a * subagent can never create a surface and so can never trigger this hook. * * The dangling-surface signal is derived from the current response cycle (the * messages after the last genuine user prompt) by correlating each progress * `ui_show` with its `surface_id` result and folding in later `ui_update` / * `ui_dismiss` calls. Deriving the cycle boundary from history content rather * than an index means mid-run compaction (which rewrites the array in place) * can't invalidate it. * * The one-shot bound is split across two hooks: this hook marks the * conversation when it nudges, and the sibling `stop` hook clears the mark when * the turn terminates, so the next run nudges afresh. * * Defaults register before any user plugin, so this hook runs at the front of * the `post-model-call` chain — later hooks see (and may override) its decision. */ import { type ContentBlock, type HookFunction, INTERNAL_NUDGE_OUTPUT_SUPPRESSION, type Message, type PostModelCallContext, } from "@vellumai/plugin-api"; import { isSurfaceCompletionNudged, markSurfaceCompletionNudged, } from "../nudge-state-store.js"; /** * Canonical nudge text. Shown to the model as provider-only context, never to * the user. Kept verbatim so a plugin that wraps the default sees a stable * string. Deliberately soft: the model may leave the surface open if the work * is genuinely still running. */ export const SURFACE_COMPLETION_NUDGE_TEXT = 'You showed the user a progress surface this turn (a task_progress card or a work_result) and are about to end the turn with it still marked in_progress. If that work is finished, advance it to a terminal state now — call ui_update to set its status to "completed" (or "failed"), or ui_dismiss it — so the user is not left watching a card spin forever. Do this only if the work it represents is actually done; if it is genuinely still running, leave it. Then give your final reply.' + INTERNAL_NUDGE_OUTPUT_SUPPRESSION + ""; /** * Surface statuses that mean the progress surface has reached a terminal state * and needs no completion nudge. Covers both `task_progress` * (`completed`/`failed`) and `work_result` (`completed`/`partial`/`failed`), * plus `cancelled` for tolerance. */ const TERMINAL_STATUSES = new Set([ "completed", "failed", "partial", "cancelled", ]); function hasToolUse(content: ReadonlyArray): boolean { return content.some((block) => block.type === "tool_use"); } /** A user-role message carrying only tool results, not a fresh prompt. */ function isToolResultMessage(message: Message): boolean { return ( message.role === "user" && message.content.length > 0 && message.content.every((block) => block.type === "tool_result") ); } /** * Messages belonging to the current response cycle: everything after the last * genuine user prompt. Falls back to the whole history when none is found. */ function currentCycleMessages( messages: ReadonlyArray, ): ReadonlyArray { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role === "user" && !isToolResultMessage(message)) { return messages.slice(i + 1); } } return messages; } function asRecord(value: unknown): Record | undefined { return value !== null && typeof value === "object" ? (value as Record) : undefined; } /** * Pull a surface status out of a `ui_show` / `ui_update` input, tolerating the * shapes the server-side normalization accepts: nested under * `data.templateData` (task_progress), under `data` (work_result), or at the * top level. Returns a lowercased status, or `undefined` when none is present. */ function extractStatus(input: Record): string | undefined { const data = asRecord(input.data); const templateData = asRecord(data?.templateData) ?? asRecord(input.templateData); const raw = templateData?.status ?? data?.status ?? input.status; return typeof raw === "string" ? raw.trim().toLowerCase() : undefined; } /** * Whether a `ui_show` input describes a surface with a progress lifecycle: a * `task_progress` card or a `work_result`. Returns the initial status when so. */ function progressShowInfo(input: Record): { isProgress: boolean; status: string | undefined; } { const surfaceType = input.surface_type; if (surfaceType === "work_result") { return { isProgress: true, status: extractStatus(input) }; } if (surfaceType === "card") { const data = asRecord(input.data); const template = data?.template ?? input.template; if (template === "task_progress") { return { isProgress: true, status: extractStatus(input) }; } } return { isProgress: false, status: undefined }; } function surfaceIdOf(input: Record): string | undefined { return typeof input.surface_id === "string" ? input.surface_id : undefined; } /** Parse the `{ surfaceId }` JSON a successful `ui_show` returns. */ function parseSurfaceId(content: string): string | undefined { try { const parsed = JSON.parse(content) as unknown; const record = asRecord(parsed); return typeof record?.surfaceId === "string" ? record.surfaceId : undefined; } catch { return undefined; } } interface SurfaceState { /** Latest known status, lowercased; `undefined` when never set explicitly. */ status: string | undefined; dismissed: boolean; } function isNonTerminal(state: SurfaceState): boolean { if (state.dismissed) { return false; } return state.status === undefined || !TERMINAL_STATUSES.has(state.status); } /** * True when the current response cycle left at least one progress surface * (a `task_progress` card or `work_result`) open: shown but neither advanced to * a terminal status nor dismissed. * * Each progress `ui_show` is correlated to its `surface_id` via the matching * tool result (the result lands in the next message; updates and dismisses * arrive in later messages, after the model has the id in hand). Later * `ui_update` / `ui_dismiss` calls fold their status / dismissal onto the * tracked surface. */ function hasDanglingProgressSurface(messages: ReadonlyArray): boolean { const surfaces = new Map(); // tool_use_id -> initial status of a progress ui_show awaiting its result id. const pendingShows = new Map(); for (const message of currentCycleMessages(messages)) { if (message.role === "assistant") { for (const block of message.content) { if (block.type !== "tool_use") { continue; } if (block.name === "ui_show") { const info = progressShowInfo(block.input); if (info.isProgress) { pendingShows.set(block.id, info.status); } } else if (block.name === "ui_update") { const id = surfaceIdOf(block.input); const status = extractStatus(block.input); if (id && status !== undefined) { const existing = surfaces.get(id); if (existing) { existing.status = status; } else { surfaces.set(id, { status, dismissed: false }); } } } else if (block.name === "ui_dismiss") { const id = surfaceIdOf(block.input); if (id) { const existing = surfaces.get(id); if (existing) { existing.dismissed = true; } else { surfaces.set(id, { status: undefined, dismissed: true }); } } } } continue; } if (message.role !== "user") { continue; } for (const block of message.content) { // guard:allow-tool-result-only — only the local tool executor's // `tool_result` carries a `ui_show` `surfaceId` to correlate. A // `web_search_tool_result` comes from a `server_tool_use`, never a // `ui_show`, so it can never match a pending show and is correctly skipped. if (block.type !== "tool_result") { continue; } if (!pendingShows.has(block.tool_use_id)) { continue; } const initialStatus = pendingShows.get(block.tool_use_id); pendingShows.delete(block.tool_use_id); const id = parseSurfaceId(block.content); if (!id) { continue; } const existing = surfaces.get(id); // A later update/dismiss can register the id before its show result is // scanned only if history was reordered; guard so we never clobber a // known terminal/dismissed state with the initial status. if (existing) { if (existing.status === undefined && !existing.dismissed) { existing.status = initialStatus; } } else { surfaces.set(id, { status: initialStatus, dismissed: false }); } } } for (const state of surfaces.values()) { if (isNonTerminal(state)) { return true; } } return false; } const postModelCall: HookFunction = async (ctx) => { // A provider rejection carries no turn content to assess (a recovery hook // owns the rejection). if (ctx.error) { return; } // A tool-bearing turn continues mid-run — the loop runs the tools and the // model gets another chance to close the surface — so leave it alone. if (hasToolUse(ctx.content)) { return; } // Only nudge the user-facing reply: background call sites have no live user // watching a spinner. if (ctx.callSite !== "mainAgent") { return; } // One nudge per run; the sibling `stop` hook clears the mark on terminal stop. if (isSurfaceCompletionNudged(ctx.conversationId)) { return; } if (!hasDanglingProgressSurface(ctx.messages)) { return; } markSurfaceCompletionNudged(ctx.conversationId); ctx.messages.push({ role: "user", content: [{ type: "text", text: SURFACE_COMPLETION_NUDGE_TEXT }], }); ctx.decision = "continue"; ctx.logger.info( { plugin: "surface-completion-nudge", conversationId: ctx.conversationId }, "Turn ending with an open progress surface — nudging the model to complete or dismiss it", ); }; export default postModelCall;