Description
Problem
When an agent built with create_harness_agent(...) is given a response_format — either per call via options={"response_format": Model} or via default_options — AgentLoopMiddleware forwards it unchanged into every loop iteration.
response_format is a per-run concept. _agents.py pops it out of the run options (_agents.py:856, :1402) and applies it when parsing the response (_agents.py:1141, :1205). _harness/_agent.py:640 documents that "each loop iteration is a full agent run (including tool approval)". Nothing in the harness withholds the format from those runs — response_format occurs exactly once in the whole _harness package (_loop.py:184), and that occurrence is the judge's own internal call, not the caller's.
So on every iteration the model is instructed to return an object matching the schema. It does — and returning a schema-valid object is a reasonable way for a model to end a turn, so it never calls todos_complete. todos_remaining() keeps returning True, and the loop runs all the way to loop_max_iterations.
Impact
This fails silently and expensively:
-
Nothing raises and nothing logs.
-
Every iteration looks successful. Each returns a valid, parseable object matching the caller's schema. There is no error to correlate against.
-
The caller pays loop_max_iterations× the tokens for what should be one answer; the default cap is 10.
-
The final answer is produced by a model that has been re-asked the same question N times against an accumulating progress log, which is not what the schema-shaped result is meant to represent.
The triggering combination — a todo-driven loop plus a structured result — is not exotic. It is the natural way to ask a harness agent for a machine-readable answer.
We hit this in production, fixed it on our non-streaming path, and then independently reintroduced it on our streaming path, because the strip has to be repeated at every call site that enters the loop. That we could not reliably avoid it even while knowing about it is the main argument for handling it in the framework rather than in docs.
Expected
response_format should not bind the loop's working iterations. Either:
-
Withhold and re-apply (what we ended up implementing): strip response_format from the iterations, then run one final turn with it applied and the loop suspended, so the caller still gets its structured result without the constraint driving the loop. Preserves the current API surface and needs no caller change.
-
Reject at construction: raise when a response_format is combined with loop_should_continue, pointing at the recommended pattern. Less useful, but far better than the current silent budget burn.
Option 1 also has to cover the streaming path, which is where we regressed — it needs the same treatment, or an explicit documented statement that streaming has no final synthesis turn.
Related
This looks like the same root cause family as #7236 (CompactionProvider.after_run fires once per AgentLoopMiddleware iteration rather than once per real user turn). Both are per-run concepts silently reinterpreted as per-iteration, because the loop makes each iteration a full agent run. A general fix — an explicit notion of "outer run" vs "loop iteration" that per-run concerns bind to — would likely address both, and probably others.
Code Sample
"""Pure agent-framework repro: response_format binds every agent-loop iteration."""
from __future__ import annotations
import asyncio
from typing import Any
from pydantic import BaseModel
from agent_framework import (
AgentSession,
BaseChatClient,
ChatResponse,
Message,
TodoItem,
TodoProvider,
create_harness_agent,
todos_remaining,
)
MAX_ITERATIONS = 5
class Answer(BaseModel):
"""The structured result the caller wants back once the work is done."""
answer: str
class RecordingChatClient(BaseChatClient):
"""Records the ``response_format`` seen on each chat call.
Returns a schema-valid object every time and never calls ``todos_complete`` --
which is exactly what a real model does when a response_format is bound to the
call: it satisfies the schema instead of using the loop's completion tool.
"""
seen: list[Any]
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
object.__setattr__(self, "seen", [])
async def _inner_get_response( # type: ignore[override]
self,
*,
messages: Any,
stream: bool,
options: Any,
**kwargs: Any,
) -> ChatResponse:
self.seen.append((options or {}).get("response_format"))
return ChatResponse(
messages=[Message("assistant", ['{"answer": "partial"}'])]
)
async def main() -> None:
client = RecordingChatClient()
todo_provider = TodoProvider()
agent = create_harness_agent(
client=client,
loop_should_continue=todos_remaining(),
loop_max_iterations=MAX_ITERATIONS,
todo_provider=todo_provider,
disable_web_search=True,
)
session = AgentSession()
# One open todo, so todos_remaining() keeps the loop going until the cap.
await todo_provider.store.save_state(
session,
[TodoItem(id=1, title="do the work", is_complete=False)],
next_id=2,
source_id=todo_provider.source_id,
)
await agent.run(
"Do the work.",
session=session,
options={"response_format": Answer},
)
bound = [s for s in client.seen if s is not None]
print(f"chat calls made : {len(client.seen)}")
print(f"calls with response_format: {len(bound)}")
print(f"per-call values : {[getattr(s, '__name__', s) for s in client.seen]}")
print()
if len(bound) > 1:
print(
f"REPRO: response_format was bound to {len(bound)} of {len(client.seen)} "
"iterations. Expected it to be withheld from the loop and applied once."
)
else:
print("NOT REPRODUCED: response_format was not bound to every iteration.")
if __name__ == "__main__":
asyncio.run(main())
Error Messages / Stack Traces
Package Versions
1.12.0
Python Version
3.12
Additional Context
No response
Description
Problem
When an agent built with
create_harness_agent(...)is given aresponse_format— either per call viaoptions={"response_format": Model}or viadefault_options—AgentLoopMiddlewareforwards it unchanged into every loop iteration.response_formatis a per-run concept._agents.pypops it out of the run options (_agents.py:856,:1402) and applies it when parsing the response (_agents.py:1141,:1205)._harness/_agent.py:640documents that "each loop iteration is a full agent run (including tool approval)". Nothing in the harness withholds the format from those runs —response_formatoccurs exactly once in the whole_harnesspackage (_loop.py:184), and that occurrence is the judge's own internal call, not the caller's.So on every iteration the model is instructed to return an object matching the schema. It does — and returning a schema-valid object is a reasonable way for a model to end a turn, so it never calls
todos_complete.todos_remaining()keeps returningTrue, and the loop runs all the way toloop_max_iterations.Impact
This fails silently and expensively:
Nothing raises and nothing logs.
Every iteration looks successful. Each returns a valid, parseable object matching the caller's schema. There is no error to correlate against.
The caller pays
loop_max_iterations× the tokens for what should be one answer; the default cap is 10.The final answer is produced by a model that has been re-asked the same question N times against an accumulating progress log, which is not what the schema-shaped result is meant to represent.
The triggering combination — a todo-driven loop plus a structured result — is not exotic. It is the natural way to ask a harness agent for a machine-readable answer.
We hit this in production, fixed it on our non-streaming path, and then independently reintroduced it on our streaming path, because the strip has to be repeated at every call site that enters the loop. That we could not reliably avoid it even while knowing about it is the main argument for handling it in the framework rather than in docs.
Expected
response_formatshould not bind the loop's working iterations. Either:Withhold and re-apply (what we ended up implementing): strip
response_formatfrom the iterations, then run one final turn with it applied and the loop suspended, so the caller still gets its structured result without the constraint driving the loop. Preserves the current API surface and needs no caller change.Reject at construction: raise when a
response_formatis combined withloop_should_continue, pointing at the recommended pattern. Less useful, but far better than the current silent budget burn.Option 1 also has to cover the streaming path, which is where we regressed — it needs the same treatment, or an explicit documented statement that streaming has no final synthesis turn.
Related
This looks like the same root cause family as #7236 (
CompactionProvider.after_runfires once perAgentLoopMiddlewareiteration rather than once per real user turn). Both are per-run concepts silently reinterpreted as per-iteration, because the loop makes each iteration a full agent run. A general fix — an explicit notion of "outer run" vs "loop iteration" that per-run concerns bind to — would likely address both, and probably others.Code Sample
Error Messages / Stack Traces
Package Versions
1.12.0
Python Version
3.12
Additional Context
No response