Make one rule govern every failure in the function-invocation seam - #147
Conversation
A thrown error behaved differently depending on which layer threw it. A tool body's exception was written to `ctx.error` and `next()` resolved, so a middleware wrapping the call could not see it; an inner middleware's exception propagated normally. Same syntax, opposite outcome. And a middleware exception failed the whole run while a tool exception became a result the model reads. Both now follow one rule, the one .NET and Python already use: the failure travels out through the middleware around it, and whatever nothing recovered becomes this call's `function_result`. The round still counts against `maxConsecutiveErrors`, so a layer that keeps failing still ends the run. `MiddlewareFailed` is the way to say a failure must end the run instead. It is never turned into a result, it cancels the rest of a concurrent batch, and it reaches the caller. A tool body may throw it too, matching where Python places its own re-raise. `ctx.error` stays readable for a middleware that only wants to observe, but the throw is the channel: clearing it no longer clears the failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR standardizes the error contract at the function-invocation middleware seam so that failures from either tool bodies or function middleware propagate consistently through await next(), and introduces a dedicated fatal error (MiddlewareFailed) to abort an entire run when needed.
Changes:
- Make tool-body failures unwind through the function middleware onion (so
try/catchandtry/finallyaroundawait next()behave consistently). - Treat ordinary function-middleware exceptions as recoverable tool-call failures reported via
function_result(counting towardmaxConsecutiveErrors), while addingMiddlewareFailedas an explicit fatal escape hatch. - Add/adjust tests and update example + changelog guidance for middleware authors.
File summaries
| File | Description |
|---|---|
| packages/core/src/middleware/middleware.ts | Updates function middleware JSDoc to reflect new failure propagation and recovery patterns. |
| packages/core/src/middleware/middleware.test.ts | Adjusts recovery test to use try/catch around next() under the new contract. |
| packages/core/src/index.ts | Exports MiddlewareFailed on the core public surface. |
| packages/core/src/errors.ts | Adds the new MiddlewareFailed error type and documentation. |
| packages/core/src/client/function-middleware-errors.test.ts | New test suite pinning the unified seam contract and MiddlewareFailed behavior (including concurrency cancellation signaling). |
| packages/core/src/client/function-execution.ts | Implements unified exception boundary behavior and abort signaling for concurrent batches on MiddlewareFailed. |
| examples/03-extensibility/01-middleware.ts | Updates timing middleware example to use finally so failures are timed/logged too. |
| CHANGELOG.md | Documents the breaking changes and migration guidance for middleware authors. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A middleware that threw after `await next()` had already succeeded left `ctx.result` set, and the seam read that as a completed call: the failure was reported nowhere. The call as a whole did not succeed, so the result is cleared alongside recording the error. Recovery is unaffected — a middleware that recovers catches the failure itself, so the chain resolves and never reaches that branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
CHANGELOG.md:26
- The PR description/checklist says the public API change (adding
MiddlewareFailed) is reflected in the package README, but there are noMiddlewareFailedreferences in anyREADME.mdin this repo. If the intent is to document the new fatal-error contract/migration for middleware authors, please either update the relevant README(s) (root and/orpackages/core/README.md) or adjust the PR description so it accurately reflects what shipped.
- **[BREAKING] `@polymind-inc/agent-framework-core`** — an exception from a function middleware is
reported to the model as that call's `function_result` and the loop continues, instead of failing
the run. Tool bodies already behaved this way; middleware did not, and all three reference
implementations treat both the same. The round still counts against `maxConsecutiveErrors`
(default 3), so a layer that keeps failing still ends the run — this is not a licence to fail
forever. **This reversal is silent**: nothing in the type system or the linter will point at
middleware that relied on throwing to abort. Throw the new `MiddlewareFailed` where that was the
intent — it is never turned into a result, it cancels the rest of the concurrent batch, and it
- Files reviewed: 8/8 changed files
- Comments generated: 1
- Review effort level: Lite
… README `ctx.error` is set only for a tool-body failure. A middleware's throw unwinds without passing through the tool seam, so it is still unset while an outer `catch` or `finally` runs — pinned with a test rather than asserted. The core README carried nothing about the middleware error contract, which the PR checklist claimed it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What this changes
Closes #107, both halves.
Where a failure surfaces. A tool body's exception was written to
ctx.errorandnext()resolved, so a middleware wrapping the call never saw it — while an exception from an inner
middleware propagated normally. The same
try { await next() } catchbehaved differentlydepending on which layer threw. The repo's own
examples/03-extensibility/01-middleware.tstimingmiddleware demonstrated the consequence: it silently skipped exactly the calls worth timing.
Whether a failure is fatal. A middleware exception failed the whole run; a tool exception did
not. Both now become that call's
function_resultand the loop continues, still counting againstmaxConsecutiveErrors(default 3, the same number Go uses), so a layer that keeps failing stillends the run.
MiddlewareFailedsays a failure must end the run instead: never turned into a result, cancels therest of a concurrent batch, reaches the caller. A tool body may throw it too.
A correction to how this issue was framed
The issue's second half is written as "are tool exceptions fatal", and I carried that framing into
the design notes. Pinning the current behaviour first showed it was wrong: tool bodies were
already fail-open (
runToolcatches and returns an exception result — its own comment says "nevera failed run"). Only middleware exceptions were fatal. That narrowed the change and made it a
unification rather than a reversal of the tool contract.
It also dissolved the security argument I had for keeping the old behaviour. That argument was that
toolApprovalMiddleware'srule.whenpredicate throwing should abort the run. But the predicate isevaluated before
next(), so reporting its failure to the model does not let the tool run — thecall is still stopped. What changes is only whether the run dies immediately or after the error
budget. There is a test for that.
Parity
AgentHooksFunctionMiddlewarewrapsawait next(context, …)in
try/catchand catches the invocation's exception; Python runs the tool body asfinal_handlerinsidemiddleware_pipeline.executeand catches outside it (_tools.py:1607).Go has no function-middleware seam.
exceptions into tool errors and keeps running", with
context.Terminateas the only loud escape.Python:
except Exception → _function_execution_error_result, re-raising onlyMiddlewareFailure,MiddlewareTerminationandUserInputRequiredException(_tools.py:1635). Go: fail-open withMaximumConsecutiveErrorsPerRequest, default 3.MiddlewareFailedis Python'sMiddlewareFailure; the name follows this codebase's existingMiddlewareTerminated. A tool body may raise it because Python's re-raise sits outside the wholepipeline, tool body included.
ignores its signal runs to completion and may still have its effects; the result is discarded
either way.
ctx.errorstays set alongside the throw, so a middleware canobserve without catching. The throw is the channel — clearing
ctx.errordoes not clear thefailure.
MiddlewareFailedadded)Notes
Not in scope, filing separately: settlement of dangling calls when the service owns the transcript,
which
MiddlewareFailedand the iteration limit can both produce. Python implements it(
_tools.py:3096-3130) and it is what keeps Anthropic from rejecting the next request — an unansweredtool_useis a measured400there.Checklist
pnpm checkpasses (lint, typecheck, build, test)CHANGELOG.md