Skip to content

Python: add MiddlewareFailure, a first-class fatal signal for function middleware - #7562

Merged
Evan Mattson (moonbox3) merged 5 commits into
microsoft:mainfrom
MohammadHaroonAbuomar:mhabuomar/middleware-failure
Aug 18, 2026
Merged

Python: add MiddlewareFailure, a first-class fatal signal for function middleware#7562
Evan Mattson (moonbox3) merged 5 commits into
microsoft:mainfrom
MohammadHaroonAbuomar:mhabuomar/middleware-failure

Conversation

@MohammadHaroonAbuomar

@MohammadHaroonAbuomarMohammadHaroonAbuomar commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Motivation & Context

_auto_invoke_function converts 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 is MiddlewareTermination. 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.halted back-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 like MiddlewareTermination): 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 with raise ... from. Middleware must not catch it — swallowing it converts a fail-closed abort back into a running, possibly unguarded loop.

    2. The _tools.py loop contract widens, precisely as follows. Before: every exception raised by function middleware or a tool body — except MiddlewareTermination and UserInputRequiredException — was converted into a tool-error function_result and the loop continued. After: exactly one additional exception type, the new explicit MiddlewareFailure, 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 of get_response to 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 error function_result per dangling call (approval-response wrappers unwrap to their underlying calls; hosted-tool approvals are left to their own provider protocol), submitted with tool_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 Responses store=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; MiddlewareTermination semantics are untouched. As a drive-by, the new cancel-and-await around the batch gather also stops sibling tool tasks from being orphaned on the pre-existing missing-call_idKeyError escape path.

    3. Why a dedicated exception type instead of a fatal flag on MiddlewareTermination (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):

    • The repo has six places that absorb or specially handle MiddlewareTermination (agent pipeline suppress, chat pipeline suppress, two _tools.py sites, 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.
    • Mutation probe: re-basing MiddlewareFailure onto MiddlewareTermination (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.
    • Agent and chat middleware already have fail-loud semantics for every exception, and MiddlewareFailure behaves identically there, so one type gives uniform semantics across all three categories with no per-seam code (pinned by test).
    • It also sidesteps the result-capture machinery _auto_invoke_function performs 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"):

    • Deleted: _RunState.halted and all three run-boundary raise state.halted checks; the state.halted arm of the termination special case in the function middleware; the context.result mutation in _halt_on_enforcement_failure; the no-run-state terminate-with-error-result hack (a partial install now fails loudly with MiddlewareFailure).
    • The approval-request pass-through is retained but consolidated to a single check on the normal path: it is framework approval control flow independent of failure signaling, and deleting it outright would swallow third-party MiddlewareTermination and change should_terminate on the approval-replay path. It is pinned by a new termination-path approval test.
    • Tool-seam host_error blocks keep surfacing as agent_hooks.InterceptionBlocked at the run boundary (one deny surface at every seam, matching the model-seam behavior): the feature's own halts travel as a privately tagged MiddlewareFailure subclass whose chained InterceptionBlocked cause is re-raised by the agent middleware. Only the private tag authorizes the unwrap — a third-party MiddlewareFailure with a crafted InterceptionBlocked cause 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.md gains the middleware-failure invariants, two scenario-matrix rows naming the regression tests, and the related-issue entry; python/CODING_STANDARD.md exception tree updated; MiddlewareFailure and FunctionMiddleware docstrings 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, MiddlewareTermination is 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 as MiddlewareFailure (still a MiddlewareException, 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

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

CopilotAI balanced review requested due to automatic review settings August 7, 2026 06:55
@agent-framework-automationagent-framework-automationBot added documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python labels Aug 7, 2026
@github-actions

github-actionsBot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
FileDescription
python/packages/core/agent_framework/_middleware.pyDefines and documents MiddlewareFailure.
python/packages/core/agent_framework/_tools.pyPropagates failures and cancels tool batches.
python/packages/core/agent_framework/_agent_hooks.pyMigrates agent-hooks failure handling.
python/packages/core/agent_framework/__init__.pyAdds the runtime export.
python/packages/core/agent_framework/__init__.pyiAdds the typing export.
python/packages/core/tests/core/test_middleware_with_agent.pyTests fatal-signal behavior.
python/packages/core/tests/core/test_agent_hooks.pyTests migrated agent-hooks behavior.
python/CODING_STANDARD.mdUpdates the exception hierarchy.
docs/specs/004-python-function-calling-loop.mdDocuments loop invariants and scenarios.

Review details

  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment threadpython/packages/core/agent_framework/_agent_hooks.py Outdated
Comment threadpython/packages/core/agent_framework/_tools.py
Comment threadpython/packages/core/agent_framework/_tools.py
Comment threadpython/packages/core/agent_framework/_agent_hooks.py Outdated
Comment threadpython/packages/core/agent_framework/_tools.py Outdated
Comment threadpython/packages/core/agent_framework/_tools.py
@moonbox3

Copy link
Copy Markdown
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>
@moonbox3
Evan Mattson (moonbox3) added this pull request to the merge queueAug 18, 2026
Merged via the queue into microsoft:main with commit 58da0ccAug 18, 2026
37 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationUsage: [Issues, PRs], Target: documentation in the code base and learn docspythonUsage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: a first-class fatal signal for function middleware (fail-closed escape from the auto-invoke loop)

3 participants

@MohammadHaroonAbuomar@moonbox3