Skip to content

Python: [Bug]: an output guard on a streamed run releases the full answer before it can raise, unless stream_buffer_updates=True is set #8954

Description

@Vancir

Description

An agent middleware that checks the model's answer (a secret, a policy violation) works on run(). On run(stream=True) the same guard raises only after every update has already been yielded to the consumer. This holds for with_result_hook and, on main, for result gates (context.stream_result_gates_after, ResponseStream.with_result_gate). with_result_gate is documented as a "blocking gate", but it blocks only the final result, not the updates. Updates are held back only if the author also sets stream_buffer_updates=True, and nothing warns when a result gate is registered without it.

The docs for the gate API do describe the opt-in, but a guard written the natural way still fails open, silently. Switching a guarded endpoint from run() to run(stream=True) for latency removes the protection without any error, and the exception raised afterwards makes logs look like the answer was blocked.

Why it matters

Output checks are used as a leak guard (system-prompt or retrieved secrets, personal data, disallowed content). Whoever can steer the model, for example through prompt injection in retrieved content, gets the content in the stream even though the guard "fired". Any follow-up logic that trusts the exception (dropping the answer from history, recording a blocked event) runs after the leak.

Environment

  • agent-framework-core main @ f1fb145
  • Python 3.12, Linux; offline (scripted chat client)

Reproduce

# agent-framework: an output guard in agent middleware does not stop a streamed answer
# from reaching the consumer unless the stream is explicitly buffered.
# Offline: scripted chat client, nothing leaves the process.
import asyncio
import warnings
from importlib.metadata import version

from agent_framework import (Agent, AgentMiddleware, BaseChatClient, ChatMiddlewareLayer, ChatResponse,
                             ChatResponseUpdate, Content, FunctionInvocationLayer, Message, ResponseStream)
from agent_framework.observability import ChatTelemetryLayer

PIECES = ["the secret ", "is ", "1234"]
EXPECTED = "'' / guard raised"          # the reader should receive nothing


class Answers(FunctionInvocationLayer, ChatMiddlewareLayer, ChatTelemetryLayer, BaseChatClient):
    """Model that answers "the secret is 1234" (in three pieces when streamed)."""

    def __init__(self):
        super().__init__(middleware=[])

    def _inner_get_response(self, *, messages, stream, options, **kw):
        if stream:
            async def updates():
                for piece in PIECES:
                    yield ChatResponseUpdate(contents=[Content.from_text(piece)], role="assistant")
            return ResponseStream(updates(), finalizer=ChatResponse.from_updates)

        async def whole():
            return ChatResponse(messages=Message(role="assistant", contents=["".join(PIECES)]))
        return whole()


def check(final):
    """The output guard: refuse any answer that contains the secret."""
    if "1234" in final.text:
        raise RuntimeError("blocked by output guard")
    return final


class ResultHookGuard(AgentMiddleware):
    """Checks the finished answer with with_result_hook (works on 1.19.0 and main)."""

    async def process(self, context, call_next):
        await call_next()
        if isinstance(context.result, ResponseStream):
            context.result = context.result.with_result_hook(check)
        else:
            check(context.result)


class ResultGateGuard(AgentMiddleware):
    """Checks the finished answer with a result gate, the API documented on main for blocking output."""

    def __init__(self, buffer):
        self.buffer = buffer

    async def process(self, context, call_next):
        if context.stream:
            if not hasattr(context, "stream_result_gates_after"):
                raise NotImplementedError
            context.stream_result_gates_after.append(check)      # registered before call_next()
            context.stream_buffer_updates = self.buffer
            await call_next()
        else:
            await call_next()
            check(context.result)


async def run(guard, stream):
    delivered = []
    try:
        if stream:
            async for update in Agent(client=Answers(), middleware=[guard]).run("go", stream=True):
                if update.text:
                    delivered.append(update.text)       # what the application forwards to the reader
        else:
            delivered.append((await Agent(client=Answers(), middleware=[guard]).run("go")).text)
    except NotImplementedError:
        return "n/a (API not in this release)"
    except RuntimeError:
        return f"{''.join(delivered)!r:20} / guard raised"
    return f"{''.join(delivered)!r:20} / guard did NOT raise"


async def main():
    print("agent-framework-core", version("agent-framework-core"))
    print(f"{'scenario':48} {'expected':24} actual")
    rows = [("not streamed, result hook", ResultHookGuard(), False),
            ("streamed, with_result_hook", ResultHookGuard(), True),
            ("streamed, result gate", ResultGateGuard(buffer=False), True),
            ("streamed, result gate + stream_buffer_updates", ResultGateGuard(buffer=True), True)]
    for name, guard, stream in rows:
        print(f"{name:48} {EXPECTED:24} {await run(guard, stream)}")


warnings.simplefilter("ignore")
asyncio.run(main())

main @ f1fb145:

agent-framework-core 1.19.0
scenario                                         expected                 actual
not streamed, result hook                        '' / guard raised        ''                   / guard raised
streamed, with_result_hook                       '' / guard raised        'the secret is 1234' / guard raised
streamed, result gate                            '' / guard raised        'the secret is 1234' / guard raised
streamed, result gate + stream_buffer_updates    '' / guard raised        ''                   / guard raised

Expected vs actual

  • Expected: a guard that raises on the finished answer means the consumer never receives that answer, or the API makes it hard to register such a guard without holding the stream back.
  • Actual: with_result_hook and an unbuffered result gate raise after the consumer already received 'the secret is 1234'. Only stream_buffer_updates = True gives the expected result.

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 1, 2026
  2. added
    agentsUsage: [Issues, PRs], Target: Single agent
    middlewareUsage: [Issues, PRs], Target: middleware related features
    on Oct 1, 2026
  3. changed the issue type fromtoon Oct 1, 2026
  4. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 1, 2026
  5. he-yufeng commented on Oct 2, 2026

    @he-yufeng
    Contributor

    Verified the mechanism on current main, it holds exactly as described.

    In _middleware.py the chat path (:1429-1444) and the agent path (:1639-1654) both do the same thing: buffer_updates is applied only when context.stream_buffer_updates is set (default False at :339 and :727), while result gates are attached unconditionally via with_result_gate. The gate runs when the stream finalizes the aggregated result, so with buffering off every update has long been released to the consumer by the time the guard raises. The exception then documents a block that did not happen. Fail-open, silently, on the natural way of writing the guard.

    Three directions I can see, in increasing invasiveness:

    1. Warn at registration. A result gate registered on a streaming run with stream_buffer_updates=False logs a warning that the gate cannot hold back updates, plus a doc note at with_result_gate. Non-breaking, turns the silent footgun into an audible one, but the guard still fails open for anyone who misses the warning.
    2. Gate implies buffering. Registering any result gate on a streamed run flips buffering on for that stream, so "blocking gate" actually blocks. This matches the documented intent but quietly changes latency semantics for existing guarded streams, so it is arguably vNext material rather than a patch.
    3. Per-gate opt-in. with_result_gate(gate, hold_updates=True) or similar, keeping the choice explicit per gate. Most precise, but new API surface for something option 2 would cover implicitly.

    My inclination is 1 now (it is strictly additive) plus a maintainer call on 2 vs 3 for vNext. Happy to implement once you point at a direction.

  6. he-yufeng commented on Oct 3, 2026

    @he-yufeng
    Contributor

    Went ahead with direction 1 in #9029: a warning from both middleware pipelines when result gates are registered on an unbuffered streamed run, plus a docstring note on with_result_gate(). Strictly additive, no delivery semantics change. Directions 2 and 3 stay open for a maintainer call as vNext material.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentmiddlewareUsage: [Issues, PRs], Target: middleware related featurespythonUsage: [Issues, PRs], Target: Python

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions