/** * Default `post-tool-use` hook: when an interactive turn accumulates several * tool-call rounds without the model having shown a `task_progress` card, * surface a soft notice via `additionalContext` reminding it to show progress. * * Motivation: the system prompt and the `ui_show` tool description both ask * the model to show a `task_progress` card on long multi-step turns, but * weaker models (e.g. MiniMax M3) disregard the static instruction and run * 30+ tool calls with no user-visible progress. A reminder injected mid-turn, * right after a tool result, is far more salient than static prompt text. * * The nudge is strictly best-effort: * - It fires at most once per turn (no nagging). * - It is advisory — the model may decline if it is about to finish or judges * a card unnecessary. * - A failed or ignored card never blocks the turn. * * It is scoped to weaker open-weight model families (the plugin's own list, * {@link NUDGE_TARGET_MODEL_PATTERN}) that disregard the static progress-card * instruction; models that follow the prompt are never reminded. It also * self-targets: a model that already showed a card this turn is never nudged. * * The turn state is derived from conversation history on every call (mirroring * the tool-error and exploration-drift plugins) so the signal survives * mid-turn compaction rewriting the array. The current turn is the trailing * window bounded by the last genuine user message (a user row with no * tool_result blocks). Within it we count tool-use rounds and detect whether a * `ui_show` task_progress card was issued. * * Gating (cheap `ctx` reads, checked up front so the hot path short-circuits * before scanning history): * - mainAgent call site only — background turns (wake, title-gen, memory) run * under their own call sites and subagents under `subagentSpawn`, none of * which have a live user watching a progress card. * - the client must support dynamic UI surfaces — on channels that lack it * (SMS, phone, email, most chat bridges) `ui_show` is filtered out of the * tool set, so the nudge would only coach the model toward a tool it cannot * call. * - a weaker open-weight model family ({@link NUDGE_TARGET_MODEL_PATTERN}); * models that follow the static prompt never need the reminder. * * Dedup uses a per-conversation high-water mark of the round count at the last * nudge: a non-zero mark means "already nudged this turn", which also dedupes * the parallel tool results of one batch (they observe identical history and * compute the same round count). The mark resets when the round count drops * below it (a new turn restarts counting low). */ import type { ContentBlock, HookFunction, Message, PostToolUseContext, } from "@vellumai/plugin-api"; /** * Canonical nudge notice. Module-level constant so tests and wrapping plugins * can match it without duplicating the string. Shown to the model as * provider-only context, never to the user. Deliberately soft: coarse steps * are fine and the model may skip it when wrapping up. */ export const TASK_PROGRESS_NUDGE_TEXT = 'You are several tool calls into this turn with no progress card shown. A card is optional, not required: if the turn is wrapping up, is not really multi-step, or you cannot form clean steps, skip it and keep working — a one-line note of what you are doing is a fine substitute, and proceeding with no card is also fine. Only if a live step tracker would genuinely help the user, show it with a SINGLE self-contained ui_show call that already contains the steps: ui_show({ surface_type: "card", data: { template: "task_progress", templateData: { title: "", status: "in_progress", steps: [{ label: "", status: "in_progress" }, { label: "", status: "pending" }] } } }). Coarse steps are fine. Do not call ui_show with an empty `data: {}` and fill it in afterward — an empty card renders as a blank box; either include the steps now or skip the card. Advance it later with ui_update under `data.templateData`. Never let the card interrupt the actual work; if one ever looks wrong, just dismiss it and move on. You will not be nudged about this again this turn.'; /** * Number of tool-use rounds in a turn, with no task_progress card shown, that * triggers the nudge. Tuned conservatively: a turn of one or two rounds (the * common simple case) is never touched; only a clearly multi-step turn is * nudged, and only once. Lower from telemetry if cards still arrive too late. */ export const TASK_PROGRESS_NUDGE_ROUND_THRESHOLD = 3; /** * Weaker open-weight models (Kimi K2, DeepSeek, MiniMax, GLM) that tend to * disregard the static progress-card instruction and so benefit from the * mid-turn nudge. Kimi is scoped to the K2 generation — K3+ is frontier-class * and follows static instructions. This is the plugin's own coaching policy, * kept local and matched against `ctx.model` rather than read off a shared * context flag — "weak" / "needs steering" is a judgement specific to this * nudge, not a general property of the turn, and the set is the plugin * owner's to tune. */ const NUDGE_TARGET_MODEL_PATTERN = /kimi-k2|deepseek|minimax|glm/i; /** * Round count at the last nudge, per conversation. A non-zero entry means the * turn has already been nudged; it resets when the round count drops below the * mark (a new turn). Mirrors the exploration-drift high-water mark so parallel * results of one batch dedupe to a single notice. */ const lastNudgedRoundsByConversation = new Map(); /** Test-only: clear the per-conversation nudge high-water marks. */ export function resetTaskProgressNudgeStateForTests(): void { lastNudgedRoundsByConversation.clear(); } /** * True when a `ui_show` tool input shows a `task_progress` card — accepting the * template either at the top level or nested under `data`, mirroring the * server-side normalization tolerance. */ function isTaskProgressShowInput(input: unknown): boolean { if (input === null || typeof input !== "object") { return false; } const record = input as Record; if (record.template === "task_progress") { return true; } const data = record.data; return ( data !== null && typeof data === "object" && (data as Record).template === "task_progress" ); } /** * Scan the trailing turn (walking back to the last genuine user message) for * the number of tool-use rounds and whether a task_progress card was shown. * The current round's assistant tool_use is already in history; its result is * not, so the count includes the current round. */ function scanTurn(messages: ReadonlyArray): { rounds: number; taskProgressShown: boolean; } { let rounds = 0; let taskProgressShown = false; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role === "user") { const carriesToolResult = message.content.some( (block: ContentBlock) => block.type === "tool_result" || block.type === "web_search_tool_result", ); if (!carriesToolResult) { break; } // genuine user prompt — turn boundary continue; } if (message.role !== "assistant") { continue; } let hasToolUse = false; for (const block of message.content) { if (block.type !== "tool_use") { continue; } hasToolUse = true; if (block.name === "ui_show" && isTaskProgressShowInput(block.input)) { taskProgressShown = true; } } if (hasToolUse) { rounds++; } } return { rounds, taskProgressShown }; } const postToolUse: HookFunction = async (ctx) => { // These gates are cheap ctx reads, so short-circuit before scanning history: // act only on a live user-facing main-agent turn (subagents run under // `subagentSpawn`, background work under its own call sites), on a client // that can actually render the card, driven by a model family this nudge // targets. if (ctx.callSite !== "mainAgent") { return; } if (!ctx.supportsDynamicUi) { return; } if (!NUDGE_TARGET_MODEL_PATTERN.test(ctx.model)) { return; } const { rounds, taskProgressShown } = scanTurn(ctx.messages); let lastNudged = lastNudgedRoundsByConversation.get(ctx.conversationId) ?? 0; if (rounds < lastNudged) { // New turn (round count restarted low) — drop the stale mark. lastNudgedRoundsByConversation.delete(ctx.conversationId); lastNudged = 0; } // A card now exists this turn: clear any stale mark and never nudge. if (taskProgressShown) { if (lastNudged !== 0) { lastNudgedRoundsByConversation.delete(ctx.conversationId); } return; } if (rounds < TASK_PROGRESS_NUDGE_ROUND_THRESHOLD) { return; } if (lastNudged !== 0) { return; } // already nudged this turn lastNudgedRoundsByConversation.set(ctx.conversationId, rounds); ctx.logger.info( { plugin: "task-progress-nudge", rounds }, "Multi-step turn with no task_progress card — nudging the model to show progress", ); ctx.additionalContext = ctx.additionalContext ? `${ctx.additionalContext}\n${TASK_PROGRESS_NUDGE_TEXT}` : TASK_PROGRESS_NUDGE_TEXT; }; export default postToolUse;