Repository navigation
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
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_hookand an unbuffered result gate raise after the consumer already received'the secret is 1234'. Onlystream_buffer_updates = Truegives the expected result.
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
he-yufeng commentedon Oct 2, 2026
Verified the mechanism on current main, it holds exactly as described.
In
_middleware.pythe chat path (:1429-1444) and the agent path (:1639-1654) both do the same thing:buffer_updatesis applied only whencontext.stream_buffer_updatesis set (defaultFalseat :339 and :727), while result gates are attached unconditionally viawith_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:
stream_buffer_updates=Falselogs a warning that the gate cannot hold back updates, plus a doc note atwith_result_gate. Non-breaking, turns the silent footgun into an audible one, but the guard still fails open for anyone who misses the warning.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.
he-yufeng commentedon Oct 3, 2026
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.Python: fix(core): warn when a streamed run registers result gates wi…