Skip to content

Python: [Bug]: agent loop binds per-call response_format to every iteration, so the agent satisfies the schema instead of calling todos_complete #7402

Description

@DaiqianHu

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_optionsAgentLoopMiddleware 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:

  1. 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.

  2. 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

Metadata

Metadata

Assignees

Labels

pythonUsage: [Issues, PRs], Target: Python

Type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions