/** * Default `post-model-call` hook: when the model yields a turn with no tool * calls, decide whether to let the turn end, rewrite it for the user, or * re-query the model. * * Two cases warrant intervention: * * 1. **Refusal stop.** The provider returned `stopReason === "refusal"` with no * visible text (Anthropic's safety classifier zeroed the response) and no * earlier turn this run already delivered visible text. The hook rewrites * the turn into a plain-text apology (`REFUSAL_FALLBACK_TEXT`) by replacing * {@link PostModelCallContext.content} and lets the turn end. A retry is * deliberately not attempted: a safety-classifier refusal re-fires on a * re-query, so the canned message is the intended terminal response. * 2. **Empty turn after tool use.** The turn produced no visible text, follows * at least one prior assistant turn this run, and no earlier turn this run * already delivered visible text. The hook re-queries the model with * `NUDGE_TEXT` (a tool trail exists to summarize, so a retry can recover a * real answer). Main-agent turns only: background, subagent, and compaction * calls have no user awaiting a summary, so per the post-model-call contract * the nudge self-gates on {@link PostModelCallContext.callSite}. The retry is * bounded to one pass per run by a one-shot per-conversation mark this hook * sets; the sibling `stop` hook (see `./stop.ts`) clears it when the turn * terminates, so the next run nudges afresh. * * Every other case leaves the decision at `"stop"` (the model said its piece, * or there is nothing to act on). * * Both prior-turn signals are derived from the current response cycle — the * messages after the last genuine user prompt (a user turn that isn't purely * tool results). Scoping this way keeps prior conversation turns from polluting * the signals, and deriving the boundary from history content rather than an * index means mid-run compaction (which rewrites the array in place) can't * invalidate it. A prior assistant turn this cycle implies a completed tool-use * iteration (an empty turn nudges-and-continues without pushing an assistant * message), so "a prior assistant turn exists" is the equivalent of "this is * not the first model call". * * 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. * * Only a finalized, no-tool reply is actionable. A provider rejection carries * no turn content to assess (a recovery hook like history-repair owns that), * and a tool-bearing turn continues naturally — the loop runs the tools and * ignores the decision — so the hook returns early for both. */ import { type ContentBlock, type HookFunction, INTERNAL_NUDGE_OUTPUT_SUPPRESSION, isToolResultMessage, type Message, type PostModelCallContext, REFUSAL_FALLBACK_TEXT, } from "@vellumai/plugin-api"; import { isEmptyResponseNudged, markEmptyResponseNudged, } from "../nudge-state-store.js"; // Re-exported so existing importers (tests, sibling hooks) keep resolving // REFUSAL_FALLBACK_TEXT from this module; the definition lives in the host's // `context/refusal-quarantine.ts` alongside its detector (single source of // truth). export { REFUSAL_FALLBACK_TEXT }; /** * Canonical nudge text for an empty turn after tool use. Must stay verbatim so * a plugin that wraps the default sees a stable string. * * Wire-compat note: this is shown to the LLM, not the user. Edits here affect * model behavior but not end-user UX directly. */ export const NUDGE_TEXT = "Your previous response was empty. You must respond to the user with a summary of what you found or did. Do not use any tools — just respond with text." + INTERNAL_NUDGE_OUTPUT_SUPPRESSION + ""; function hasVisibleText(content: ReadonlyArray): boolean { return content.some( (block) => block.type === "text" && block.text.trim().length > 0, ); } function hasToolUse(content: ReadonlyArray): boolean { return content.some((block) => block.type === "tool_use"); } function isAssistantTurn(message: Message): boolean { return message.role === "assistant"; } /** * 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; } const postModelCall: HookFunction = async (ctx) => { // A provider rejection carries no turn content to assess (a recovery hook // owns the rejection); the sibling `stop` hook clears the mark when the turn // terminates. if (ctx.error) { return; } // A tool-bearing turn continues mid-run — the loop runs the tools — so leave // the mark intact to keep the one-nudge-per-run bound across tool iterations. if (hasToolUse(ctx.content)) { return; } const turnHasVisibleText = hasVisibleText(ctx.content); const cycleMessages = currentCycleMessages(ctx.messages); const priorAssistantTurns = cycleMessages.filter(isAssistantTurn); const hadPriorAssistantTurn = priorAssistantTurns.length > 0; const priorAssistantHadVisibleText = priorAssistantTurns.some((message) => hasVisibleText(message.content), ); // Refusal stop: rewrite the empty turn into a user-facing apology and let it // end. Skipped when an earlier turn this run already replied, so the apology // never lands beneath a real answer. if ( ctx.stopReason === "refusal" && !turnHasVisibleText && !priorAssistantHadVisibleText ) { ctx.content = [{ type: "text", text: REFUSAL_FALLBACK_TEXT }]; return; } const isEmptyTurnAfterTools = !turnHasVisibleText && hadPriorAssistantTurn && !priorAssistantHadVisibleText; if (isEmptyTurnAfterTools) { // Only the user-facing reply gets the re-query nudge. Background, subagent, // and compaction calls have no user awaiting a summary, and the // post-model-call contract requires self-gating on call site to avoid // re-querying them. The refusal-rewrite above is a user-facing terminal // fallback, not a re-query, so it stays ungated. if (ctx.callSite !== "mainAgent") { return; } // Re-query once to recover a real answer. The one-shot per-conversation // mark makes the hook self-limiting: a second empty turn this run finds the // mark already set and lets the turn end rather than nudging again. if (!isEmptyResponseNudged(ctx.conversationId)) { markEmptyResponseNudged(ctx.conversationId); ctx.messages.push({ role: "user", content: [{ type: "text", text: NUDGE_TEXT }], }); ctx.decision = "continue"; ctx.logger.warn( { plugin: "empty-response", conversationId: ctx.conversationId }, "Model returned empty response after tool results — retrying", ); return; } ctx.logger.error( { plugin: "empty-response", conversationId: ctx.conversationId }, "Model returned empty response after tool results — retries exhausted", ); } }; export default postModelCall;