← stitchkitDocumentation

ADR 0041 — The cause of a failed tool call is observable

  • Status: Accepted — closes an asymmetry between the HTTP and tool paths of ADR 0007 / ADR 0014; extends the raw hooks of ADR 0012
  • Date: 2026-08-06

Context

An agent on a consuming project reported that when a tool handler fails, nothing in their logs says why. Their audit rows carried the failure, the caller got the envelope, and the log line for the request was blank on the reason.

Verified before answering, and the report was accurate — though not for the reason given. It was framed as “ToolLifecycle has only beforeHandle / afterHandle, so there is nothing to hook”. ToolCallHooks.afterToolCall does fire for every failure and does carry the ToolResult, so a thrown AppError is fully legible today: code and details are there. Those cases were never broken.

Amended by ADR 0045 (2026-08-06). This paragraph also said setRequestError from that hook reaches the enclosing request’s log line. A tool call now runs in its own context, so it does not — which only strengthens the conclusion below: route the cause to your own sink.

The hole is the unexpected throw — a dropped connection, a TypeError — the case you actually consult logs for. normalizeError handles it like this (internal/errors.ts):

console.error('[stitchkit] unhandled error:', err);
return new AppError('INTERNAL_SERVER_ERROR', 'Internal server error', 500);

The scrubbing is correct: a raw Error.message can carry a connection string or a file path, and a tool result is read by a model and often relayed to a user. What was wrong is that the scrubbed value was the only thing any consumer hook could see. The real value existed for one statement and left through a console.error that cannot be routed, filtered or correlated.

The HTTP path has no such hole. hooks.onError(ctx, error, endpoint) receives the value as thrown, before normalisation. The same handler, reached through two transports, gave two different answers to “why did it fail” — which is precisely what ADR 0014 says the tool surface must not do.

Decision

Add ToolCallHooks.onToolError — the value as thrown, fired from the catch in executeToolMethod before toolResultFromError runs.

onToolError?: (
  toolName: string,
  error: unknown,
  context: ToolCallContext,
  endpoint: MethodDef,
) => void | Promise<void>;

On ToolCallHooks, not as an onError twin on ToolLifecycle. This is the part worth recording, because the request was for the opposite and it is the question that will be asked again.

ToolLifecycle documents that a whole createServer({ hooks }) object is assignable to it — that is how a createAuthHook result drops in as beforeHandle and gates tools with the same rules as routes. Putting onError there forces a choice between two bad ends, both probed rather than assumed:

  • Reuse the HTTP signature (=> Response | Promise<Response> | undefined) and ignore the return on the tool path. Assignability survives; a shared onError silently stops producing its envelope on one of two transports. A value that is accepted and quietly dropped is a trap.
  • Narrow it to => void | Promise<void>. Honest, and it breaks the assignment — confirmed with tsc: the void-return bivariance rule does not apply when the target return type is a void | Promise<void> union, so LifecycleHooks stops being assignable to ToolLifecycle and every consumer passing a shared hooks object fails to compile.

ToolCallHooks carries no such constraint. It is already the observation surface, it is where afterToolCall lives, and it is the object a project passes for audit — so the hook lands next to the one that needed it.

It fires only where information is destroyed: a throw from lifecycle.beforeHandle, the handler, or lifecycle.afterHandle. Not for a beforeToolCall rejection, an argument-validation failure or an output-schema mismatch — each of those is already described in full by the ToolResult that afterToolCall receives, and a second path to the same information only invites double-logging.

It observes; it does not handle. The return value is ignored, and a throw from the hook is reported to console.error and swallowed. A broken sink must not replace the failure it was called to observe, nor cost the audit record. It is awaited before afterToolCall, so whatever it records — a setRequestError, typically — is in place when the audit hook reads the context.

normalizeError keeps its console.error. A project that wires onToolError now sees the failure twice, in two formats. That was weighed and accepted: the HTTP path has had exactly this property since the beginning (hooks.onError and the console write both fire), and the loud default is what makes an unwired framework debuggable. Removing it would trade a duplicate for a silence in every project that wires nothing.

Consequences

  • The cause of a tool failure is routable — to a log line, an error tracker, an audit row — instead of being visible only on stderr.
  • Tool and HTTP transports now answer “why did it fail” the same way, closing an ADR 0014 parity gap that was not previously stated.
  • Purely additive: an optional field on an optional hooks object, reaching every mount that already accepts hooks (mountMcp, createMcpHandler, mountAgent, createCli) through the shared createToolRunner.
  • The tool envelope stays the framework’s alone. If a project ever needs to shape a tool error rather than observe it, that is a separate decision and a separate hook — deliberately not conflated with this one.