/**
* Default `post-tool-use` hook: when a tool result carries `is_error`, set
* `additionalContext` with a system-notice that coaches the model to either
* retry with corrected parameters (for recoverable errors) or report the
* failure to the user (for unrecoverable ones).
*
* The coaching is delivered via `additionalContext`, not by mutating the tool
* result's `content`. The loop appends it to the provider-bound history as a
* separate block after the tool_result event is emitted, so the model sees the
* guidance while the client-facing and persisted tool output stay the tool's
* actual result. This mirrors how Claude Code (`additionalContext`) and Codex
* (`additional_contexts`) surface PostToolUse feedback as separate context
* rather than rewriting the tool response.
*
* The coaching is bounded per tool: once a single tool has failed
* `MAX_CONSECUTIVE_ERROR_NUDGES` times in a row the notice is dropped — the
* error is likely not something the model can fix on its own, and continuing
* to coach a retry only burns tokens. The consecutive-failure count is derived
* from the conversation history (the trailing run of error results for this
* tool name, plus the current one) rather than a loop-held counter, so the
* guard survives mid-run compaction rewriting the history array. A successful
* result for the tool resets its streak.
*/
import type {
HookFunction,
Message,
PostToolUseContext,
} from "@vellumai/plugin-api";
/**
* Canonical tool-error coaching text. Kept as a module-level constant so tests
* and plugins that wrap the default can match it without duplicating the
* string.
*
* This is shown to the model as provider-only context, not the user. Edits
* here affect retry behavior but not end-user UX directly.
*/
export const TOOL_ERROR_NUDGE_TEXT =
"This tool call returned an error. If the error looks recoverable (e.g. missing or invalid parameters), fix the parameters and retry. If the error is clearly unrecoverable (e.g. a service is down, a resource does not exist, or a permission is permanently denied), report it to the user.";
/**
* Number of back-to-back failures of a single tool to coach before giving up.
* Coaching fires on the 1st through Nth consecutive failure and is dropped from
* the (N+1)th onward.
*/
const MAX_CONSECUTIVE_ERROR_NUDGES = 3;
/** Map every `tool_use` block id in history to the tool name it invoked. */
function toolNamesById(messages: ReadonlyArray): Map {
const names = new Map();
for (const message of messages) {
if (message.role !== "assistant") {
continue;
}
for (const block of message.content) {
if (block.type === "tool_use") {
names.set(block.id, block.name);
}
}
}
return names;
}
/**
* Trailing run of consecutive error results for `toolName` already in history.
* Walks the tool's results in chronological order and counts back from the most
* recent until a successful result breaks the streak. The current result is not
* yet in history, so callers add it themselves.
*/
function priorConsecutiveErrors(
messages: ReadonlyArray,
toolName: string,
namesById: ReadonlyMap,
): number {
const isErrorByOrder: boolean[] = [];
for (const message of messages) {
if (message.role !== "user") {
continue;
}
for (const block of message.content) {
if (block.type !== "tool_result") {
continue;
}
if (namesById.get(block.tool_use_id) !== toolName) {
continue;
}
isErrorByOrder.push(block.is_error === true);
}
}
let streak = 0;
for (let i = isErrorByOrder.length - 1; i >= 0; i--) {
if (!isErrorByOrder[i]) {
break;
}
streak++;
}
return streak;
}
const postToolUse: HookFunction = async (ctx) => {
if (ctx.toolResponse.is_error !== true) {
return;
}
const namesById = toolNamesById(ctx.messages);
const toolName = namesById.get(ctx.toolResponse.tool_use_id);
// Prior failures of this tool plus the current one. An unresolved name (the
// current turn's tool_use is always in history, so this is defensive) falls
// back to coaching this lone failure.
const consecutiveErrors =
(toolName === undefined
? 0
: priorConsecutiveErrors(ctx.messages, toolName, namesById)) + 1;
if (consecutiveErrors > MAX_CONSECUTIVE_ERROR_NUDGES) {
ctx.logger.info(
{
plugin: "tool-error",
toolName,
toolUseId: ctx.toolResponse.tool_use_id,
consecutiveErrors,
},
"Skipping tool-error coaching after repeated consecutive failures",
);
return;
}
ctx.additionalContext = TOOL_ERROR_NUDGE_TEXT;
};
export default postToolUse;