Uh oh!
There was an error while loading. Please reload this page.
Python: add MiddlewareFailure, a first-class fatal signal for function middleware - #7562
Merged
Evan Mattson (moonbox3) merged 5 commits intoAug 18, 2026
Conversation
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 7, 2026 06:55 — with
GitHub Actions
Inactive
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 7, 2026 06:55 — with
GitHub Actions
Inactive
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 7, 2026 06:55 — with
GitHub Actions
Inactive
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 7, 2026 06:55 — with
GitHub Actions
Inactive
Contributor
Contributor
There was a problem hiding this comment.
Pull request overview
Adds MiddlewareFailure as a public fail-closed signal for function middleware and tools.
Changes:
- Propagates fatal middleware failures and cancels parallel tool tasks.
- Migrates agent-hooks away from its halted-state back-channel.
- Adds exports, documentation, specifications, and regression tests.
Show a summary per file
| File | Description |
|---|---|
python/packages/core/agent_framework/_middleware.py | Defines and documents MiddlewareFailure. |
python/packages/core/agent_framework/_tools.py | Propagates failures and cancels tool batches. |
python/packages/core/agent_framework/_agent_hooks.py | Migrates agent-hooks failure handling. |
python/packages/core/agent_framework/__init__.py | Adds the runtime export. |
python/packages/core/agent_framework/__init__.pyi | Adds the typing export. |
python/packages/core/tests/core/test_middleware_with_agent.py | Tests fatal-signal behavior. |
python/packages/core/tests/core/test_agent_hooks.py | Tests migrated agent-hooks behavior. |
python/CODING_STANDARD.md | Updates the exception hierarchy. |
docs/specs/004-python-function-calling-loop.md | Documents loop invariants and scenarios. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Balanced
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 7, 2026 07:06 — with
GitHub Actions
Inactive
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 7, 2026 07:07 — with
GitHub Actions
Inactive
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 7, 2026 07:56 — with
GitHub Actions
Inactive
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 7, 2026 07:57 — with
GitHub Actions
Inactive
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Evan Mattson (moonbox3)
commented
Aug 17, 2026
Contributor
MohammadHaroonAbuomar please also have a look at the merge conflict |
… middleware The function-invocation loop converts every exception raised by function middleware into a tool-error result and keeps looping, so middleware that needs fail-closed semantics (enforcement layers, guardrails) had no loud escape: the agent-hooks feature simulated one by mutating shared run state, raising MiddlewareTermination, and re-raising the real failure two hops away at the run boundary. Introduce MiddlewareFailure (a MiddlewareException sibling of MiddlewareTermination) as the loop's explicit fail-closed escape: - _auto_invoke_function re-raises it (both the direct and the pipeline path) instead of absorbing it into a tool-error result; ordinary exceptions keep the absorb-and-continue contract. - A failing call fails the whole parallel batch: in-flight sibling tool tasks are cancelled and awaited before the failure propagates. - Every existing MiddlewareTermination absorb site (agent/chat pipelines, _execute_single_function_call, harness loop, purview) passes it through untouched by construction, and agent/chat middleware exceptions already propagate, so one exception type gives uniform fail-loud semantics across all three categories. Migrate the agent-hooks feature to the new signal: delete the _RunState.halted back-channel and its three run-boundary re-raise checks, drop the halted arm of the termination special case in the function middleware (the approval-request pass-through moves to the single approval check on the normal path), and fail partial installs loudly. Tool-seam host_error blocks keep surfacing as InterceptionBlocked at the run boundary via the exception cause chain (one deny surface at every seam, pinned by tests). Spec 004 gains the middleware-failure invariants and matrix rows. Closesmicrosoft#7522 Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Review round follow-ups for the MiddlewareFailure feature: - Only agent-hooks' own tagged tool-seam halts (_ToolSeamBlockFailure) authorize re-raising the chained InterceptionBlocked at the run boundary; a third-party MiddlewareFailure with a crafted InterceptionBlocked cause now propagates as raised instead of laundering an attacker-shaped interception record into the feature's deny surface (regression test added, verified by mutation). - Document that middleware must not catch MiddlewareFailure (docstring and spec 004): swallowing it converts a fail-closed abort back into a running, possibly unguarded loop. - Pin the trailing termination re-raise in the agent-hooks function middleware: an inner short-circuit is bracketed and still propagates, skipping outer middleware post-code (test fails with the re-raise removed). Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
… cancellation Address two automated-review findings on the MiddlewareFailure PR, both confirmed empirically: - _reraise_tool_seam_block created a two-object exception-chain cycle (block.__cause__ -> wrapper -> block) by re-raising the chained InterceptionBlocked `from` its transport wrapper. Detach the wrapper's back-links and re-raise bare, recording the wrapper as the block's __context__ — acyclic, both exceptions still visible in tracebacks. Regression test walks the chain and pins finiteness (verified to fail against the cyclic re-raise). - Batch cancellation is cooperative: a synchronous tool body already running in a worker thread (asyncio.to_thread) cannot be interrupted by task cancellation and may complete its side effects after the failure reached the caller; its result is discarded either way and propagation is not delayed behind it. Narrow the stated contract (MiddlewareFailure docstring, loop comment, spec 004) and pin it with a blocking-sync-sibling regression test. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
…abort Address maintainer review on the MiddlewareFailure PR: - A MiddlewareFailure escaping a tool batch on a service-managed conversation left the hosted thread ending in unresolved function_call items: _update_continuation_state persists session.service_session_id when the model turn completes (before tool execution), and probe-verified the next run sends only the new user message against that conversation — OpenAI-style continuations reject such a request, so a routine policy abort left the session permanently stuck. Both loops now settle the thread before propagating: one error function_result per dangling call, submitted with tool_choice="none" in a single extra request whose response is discarded; a settlement failure never masks the abort, and runs without a service-managed conversation make no extra request. Pinned by three regression tests (non-streaming, streaming, and the no-conversation no-cost case); spec 004 and the MiddlewareFailure docstring updated. - Make the three tool-bracket escape tuples in the agent-hooks function middleware identical (MiddlewareTermination, MiddlewareFailure, CancelledError): a MiddlewareFailure raised inside the post/error-bracket emit bodies is unreachable today, but the uniform tuples remove the need to reason about why they would differ, and preserve the exact exception (including the private tool-seam tag) if the emitter ever surfaces one. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Address maintainer review on the MiddlewareFailure settlement path, both probe-verified (branch rebased onto current main first): - Advance the persisted continuation to the settlement response. For response-ID continuations (OpenAI Responses store=True, where the response id is the continuation handle) the settlement response is the first endpoint whose chain includes the synthetic tool outputs; leaving session.service_session_id on the pre-settlement response made the settlement ineffective — the next run would continue from the still-unresolved turn. The settlement response now runs through _update_function_invocation_continuation_state (a no-op for stable conversation-object ids). Pinned by a regression test that fails with the advance removed. - Cover the approval-resolution phase: a MiddlewareFailure raised while an approved tool is replayed escapes loudly (probe-verified, already the case) but executed before the loops' settlement seams, leaving the original — already service-persisted — call unresolved. _resolve_approval_responses now takes a settle_dangling_calls callback invoked with the approved batch on abort; the settlement helper became a layer method taking explicit calls (approval-response wrappers unwrap to their underlying calls, hosted-tool approvals are left to their provider protocol) and carries its own best-effort containment. Pinned by deny-during- replay regression tests in both response modes, mutation-verified. Spec 004 invariants and matrix rows updated accordingly. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomarforce-pushed
the
mhabuomar/middleware-failure
branch
from
August 18, 2026 08:12
59f67ea to
97c0111CompareMohammadHaroonAbuomar
requested review from
SergeyMenshykh and Roger Barreto (rogerbarreto)
as code ownersAugust 18, 2026 08:13
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 18, 2026 08:13 — with
GitHub Actions
Inactive
MohammadHaroonAbuomartemporarily deployed
to
github-app-auth
August 18, 2026 08:13 — with
GitHub Actions
Inactive
Evan Mattson (moonbox3)
approved these changes
Aug 18, 2026
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation & Context
_auto_invoke_functionconverts every exception raised by function middleware (or the tool it wraps) into a tool-error result and keeps looping, so the only loud escape from the auto-invoke loop isMiddlewareTermination. Middleware that needs fail-closed semantics — enforcement layers, guardrails — had to mutate shared state, raise the loop's one loud exception, and re-raise the real failure two hops away at the run boundary, which is exactly what the agent-hooks feature (#7515) did with its_RunState.haltedback-channel. Evan Mattson (@moonbox3) flagged this in review (#7515 (comment)) and suggested tracking a first-class signal as follow-up; #7522 is that follow-up.This PR gives every function-middleware author fail-closed semantics without the state-mutation dance, and migrates the agent-hooks feature onto the new signal, deleting the back-channel.
Description & Review Guide
What are the major changes?
1. New public exception
MiddlewareFailure(MiddlewareException)(_middleware.py, exported likeMiddlewareTermination): the function-invocation loop's explicit fail-closed escape. Raise it from function middleware (or a tool body) to abort the run; chain the underlying error withraise ... from. Middleware must not catch it — swallowing it converts a fail-closed abort back into a running, possibly unguarded loop.2. The
_tools.pyloop contract widens, precisely as follows. Before: every exception raised by function middleware or a tool body — exceptMiddlewareTerminationandUserInputRequiredException— was converted into a tool-errorfunction_resultand the loop continued. After: exactly one additional exception type, the new explicitMiddlewareFailure, is re-raised instead of absorbed, on both the direct and the middleware-pipeline execution paths; when it escapes one call of a parallel batch, the in-flight sibling invocations are cancelled and awaited before it propagates out ofget_responseto the run's caller (for streaming runs, it is raised when the stream is consumed). Cancellation is cooperative: an async sibling stops at its next suspension point, while a synchronous tool body already executing in a worker thread (asyncio.to_thread) cannot be interrupted and may complete its side effects — its result is discarded either way and never reaches the transcript, the model, or history, and failure propagation is not delayed behind it (pinned by a blocking-sync-sibling regression test). On a service-managed conversation (persisted conversation id), the loop settles the aborted batch before propagating — one errorfunction_resultper dangling call (approval-response wrappers unwrap to their underlying calls; hosted-tool approvals are left to their own provider protocol), submitted withtool_choice="none"in a single extra request — and then advances the persisted continuation to the settlement response (required for response-ID continuations such as OpenAI Responsesstore=True, where the settlement response is the first endpoint whose chain includes the synthetic outputs; a no-op for stable conversation-object ids); the settlement response is otherwise discarded. Settlement also covers the approval-resolution phase, so a failure raised while an approved tool is replayed settles the original, already-persisted calls. Without a service-managed conversation no extra request is made. (Raised by Evan Mattson (@moonbox3) in review, pinned by six regression tests across both response modes.) The absorb-into-tool-error contract for ordinary exceptions is unchanged and now pinned by a dedicated regression test;MiddlewareTerminationsemantics are untouched. As a drive-by, the new cancel-and-await around the batchgatheralso stops sibling tool tasks from being orphaned on the pre-existing missing-call_idKeyErrorescape path.3. Why a dedicated exception type instead of a
fatalflag onMiddlewareTermination(the two shapes proposed in Python: a first-class fatal signal for function middleware (fail-closed escape from the auto-invoke loop) #7522; Atharva Vichare (@atty57) asked the same question on the issue, including whether the agent/chat seams should honor it):MiddlewareTermination(agent pipeline suppress, chat pipeline suppress, two_tools.pysites, the harness loop, purview). A flag fails open at any site — present or future — that forgets to check it; a distinct type bypasses all of them by construction. Empirically: harness and purview needed zero changes.MiddlewareFailureontoMiddlewareTermination(simulating flag-style handling) makes 7 of the 8 new regression tests fail silently open — the runs complete normally with the fatal signal absorbed at the various termination-handling seams. Only the absorb-contract pin still passes. That settles type-vs-flag empirically.MiddlewareFailurebehaves identically there, so one type gives uniform semantics across all three categories with no per-seam code (pinned by test)._auto_invoke_functionperforms on terminations, which a fatal termination would have to special-case.4. Agent-hooks migration (the Python: a first-class fatal signal for function middleware (fail-closed escape from the auto-invoke loop) #7522 deliverable "delete the halted back-channel and the approval special case"):
_RunState.haltedand all three run-boundaryraise state.haltedchecks; thestate.haltedarm of the termination special case in the function middleware; thecontext.resultmutation in_halt_on_enforcement_failure; the no-run-state terminate-with-error-result hack (a partial install now fails loudly withMiddlewareFailure).MiddlewareTerminationand changeshould_terminateon the approval-replay path. It is pinned by a new termination-path approval test.host_errorblocks keep surfacing asagent_hooks.InterceptionBlockedat the run boundary (one deny surface at every seam, matching the model-seam behavior): the feature's own halts travel as a privately taggedMiddlewareFailuresubclass whose chainedInterceptionBlockedcause is re-raised by the agent middleware. Only the private tag authorizes the unwrap — a third-partyMiddlewareFailurewith a craftedInterceptionBlockedcause propagates exactly as raised, so untrusted middleware cannot launder an attacker-shaped interception record into the feature's audit-bearing deny surface (pinned by an adversarial regression test, verified by mutation).5. Spec and docs:
docs/specs/004-python-function-calling-loop.mdgains the middleware-failure invariants, two scenario-matrix rows naming the regression tests, and the related-issue entry;python/CODING_STANDARD.mdexception tree updated;MiddlewareFailureandFunctionMiddlewaredocstrings document the exception semantics.What is the impact of these changes?
Non-breaking. No existing behavior changes for code that does not raise the new exception: ordinary exceptions still absorb into tool errors,
MiddlewareTerminationis untouched, and the non-hooked loop behavior is pinned by regression tests. Within the experimental agent-hooks feature, two deliberate surface improvements: a partial bundle install now fails loudly instead of quietly stopping the loop, and enforcement-layer failures surface asMiddlewareFailure(still aMiddlewareException, so existing handlers keep working).What do you want reviewers to focus on?
The exact wording of the widened loop contract in item 2 (it is the load-bearing behavior change), the batch cancellation semantics in
_try_execute_function_call_groups, and the tagged-unwrap design at the agent-hooks run boundary.Related Issue
Fixes#7522
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.