[BUG] OpenAIResponsesModel reports tool_use instead of max_tokens when a function call is cut off by max_output_tokens · Issue #4135 · strands-agents/harness-sdk · GitHub
Skip to content

[BUG] OpenAIResponsesModel reports tool_use instead of max_tokens when a function call is cut off by max_output_tokens #4135

Closed

Description

@BlueX888

Checks

  • I have updated to the lastest minor and patch version of Strands
  • I have checked the documentation and this is not expected behavior
  • I have searched ./issues and there are no duplicates of my issue

SDK Language

Python

Strands Version

1.54.0 (PyPI) — same code on main @ e13bfcd

Language Runtime Version

Python 3.12.14

Operating System

macOS 26.2

Installation Method

git clone

Steps to Reproduce

When OpenAIResponsesModel receives response.incomplete with incomplete_details.reason == "max_output_tokens" after a function_call output item has started, the stream reports stopReason: "tool_use" instead of "max_tokens". The event loop then executes the truncated tool call (with input: {} because the partial JSON does not parse) instead of running the max-tokens recovery path.

Minimal reproduction (no network; the OpenAI client is mocked the same way tests/strands/models/test_openai_responses.py does):

import asyncio, unittest.mock
from types import SimpleNamespace as NS

import strands
from strands import Agent
from strands.models.openai_responses import OpenAIResponsesModel
from strands.types.exceptions import MaxTokensReachedException


async def agen(items):
    for item in items:
        yield item


# A response whose function_call arguments are cut off by max_output_tokens.
EVENTS = [
    NS(type="response.created", response=NS(id="resp_1")),
    NS(type="response.output_item.added",
       item=NS(type="function_call", call_id="call_1", name="write_file", id="fc_1")),
    NS(type="response.function_call_arguments.delta", item_id="fc_1",
       delta='{"path": "notes.md", "content": "Lorem ipsum dol'),
    NS(type="response.incomplete",
       response=NS(usage=NS(input_tokens=10, output_tokens=50, total_tokens=60, input_tokens_details=None),
                   incomplete_details=NS(reason="max_output_tokens"))),
]
SECOND = [
    NS(type="response.output_text.delta", delta="Done."),
    NS(type="response.completed",
       response=NS(usage=NS(input_tokens=20, output_tokens=2, total_tokens=22, input_tokens_details=None))),
]


@strands.tool
def write_file(path: str, content: str) -> str:
    """Write content to a file."""
    return f"wrote {len(content)} bytes to {path}"


async def main():
    with unittest.mock.patch.object(strands.models.openai_responses.openai, "AsyncOpenAI") as cls:
        client = unittest.mock.AsyncMock()
        cls.return_value.__aenter__.return_value = client
        model = OpenAIResponsesModel(model_id="gpt-4o", params={"max_output_tokens": 50})

        # 1) model-level stop reason
        client.responses.create = unittest.mock.AsyncMock(return_value=agen(EVENTS))
        events = [e async for e in model.stream([{"role": "user", "content": [{"text": "save my notes"}]}])]
        print("messageStop:", [e for e in events if "messageStop" in e])

        # 2) agent-level effect
        client.responses.create = unittest.mock.AsyncMock(side_effect=[agen(EVENTS), agen(SECOND)])
        agent = Agent(model=model, tools=[write_file], callback_handler=None)
        try:
            result = await agent.invoke_async("save my notes")
            print("stop_reason:", result.stop_reason)
            for m in agent.messages:
                print(m["role"], m["content"])
        except MaxTokensReachedException as e:
            print("MaxTokensReachedException:", e)


asyncio.run(main())

Expected Behavior

messageStop should carry stopReason: "max_tokens", so the event loop replaces the truncated tool use via recover_message_on_max_tokens_reached and raises MaxTokensReachedException, exactly as it does for the other providers in the same situation.

Basis:

  • OpenAIModel (Chat Completions) reports max_tokens for the same scenario, because it forwards the API's finish_reason == "length" regardless of whether tool-call deltas were accumulated (src/strands/models/openai.py, message_stop mapping). Running the same truncated-tool-call stream through OpenAIModel gives stop_reason = max_tokens.
  • The event loop's contract is that max_tokens means "tool use blocks may be incomplete or unreliable" and must not be executed (src/strands/event_loop/_recover_message_on_max_tokens_reached.py, and the stop_reason == "max_tokens" branches in event_loop.py).
  • The OpenAI SDK documents incomplete_details.reason as "max_output_tokens" | "content_filter" — "the reason why the response is incomplete" (openai.types.responses.response.IncompleteDetails). A function call whose arguments were cut off is by definition incomplete.
  • The existing test test_stream_response_incomplete already asserts max_tokens for the text-only case; the tool-call case is just not covered.

Actual Behavior

Output on main @ e13bfcd (identical with strands-agents==1.54.0):

WARNING strands.event_loop.streaming | tool_name=<write_file>, raw_input=<{"path": "notes.md", "content": "Lorem ipsum dol> | failed to parse tool input json, defaulting to empty dict
messageStop: [{'messageStop': {'stopReason': 'tool_use'}}]
stop_reason: end_turn
user [{'text': 'save my notes'}]
assistant [{'toolUse': {'toolUseId': 'call_1', 'name': 'write_file', 'input': {}}}]
user [{'toolResult': {'toolUseId': 'call_1', 'status': 'error', 'content': [{'text': 'Error: Validation failed for input parameters: 2 validation errors for Write_fileTool\npath\n  Field required [type=missing, input_value={}, input_type=dict]\n ...'}]}}]
assistant [{'text': 'Done.'}]

So the truncation is never surfaced: the tool is invoked with empty input, the model gets a validation error as the tool result, and the run ends with end_turn. For a tool with all-optional parameters the tool would actually execute with defaults.

Root cause: src/strands/models/openai_responses.py lines 486–492 give tool_calls precedence over the length stop reason:

# Determine finish reason: tool_calls > max_tokens (length) > normal stop
if tool_calls:
    finish_reason = "tool_calls"
elif stop_reason == "length":
    finish_reason = "length"

tool_calls is populated on response.output_item.added, i.e. before any arguments have streamed, so any function call that is in flight when the response is cut off wins over length.

Additional Context

The TypeScript Responses adapter has the same precedence (strands-ts/src/models/openai/responses-adapter.ts, if (state.toolCalls.size > 0) stopReason = 'toolUse' after maxTokens was set from incomplete_details), so the fix probably wants to be ported.

I'm happy to work on this once a maintainer confirms; the change I verified locally is to let stop_reason == "length" take precedence over tool_calls (existing test_openai_responses.py still passes) plus a test for the truncated-tool-call case.

Possible Solution

See above: check stop_reason == "length" before tool_calls when computing finish_reason.

Related Issues

#4113 (the "failed to parse tool input json" warning) is adjacent but about zero-argument tools; this issue is about the stop reason.

Activity

  1. added
    bugSomething isn't working
    area-modelRelated to models or model providers
    on Sep 3, 2026
  2. BlueX888 commented on Sep 3, 2026

    @BlueX888
    ContributorAuthor

    Since the change is two lines plus a regression test, I went ahead and opened #4139 for review rather than waiting. Happy to adjust if you'd prefer a different approach.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-modelRelated to models or model providersbugSomething isn't workingpythonPull requests that update python code

    Type

    Fields

    Language

    Python

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions