Skip to content

Python: a tool runs with its defaults when the reply is cut off before any argument is written #14723

Open

Description

@simin-yuan

What happens

With automatic function calling enabled (FunctionChoiceBehavior.Auto), ChatCompletionClientBase runs a tool call without looking at finish_reason. When the model hits max_tokens right after the tool name, the arguments are an empty string; if the function's parameters are all optional, it is invoked with the defaults — although the model was stopped before it wrote the arguments that decide what the tool does.

A real response, captured from qwen-plus on an OpenAI-compatible endpoint with max_tokens=3:

finish_reason = "length"
tool          = set_mode
arguments     = ""

so set_mode(mode="default") runs. The same request returns {" at max_tokens=4 and the complete {"mode": "silent"} at 9 — the model was cut off mid-write, and at 3 nothing had been written yet.

Reproduction

Through the real OpenAIChatCompletion connector, with a mock HTTP transport, so no network or key is needed:

import httpx
from openai import AsyncOpenAI
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from semantic_kernel.kernel import Kernel

calls = []


class Plugin:
    @kernel_function(name="set_mode")
    def set_mode(self, mode: str = "default") -> str:
        calls.append(mode)
        return "done"


# one streamed tool call: the tool name arrives, the arguments are empty, the reply ends at the limit
body = (
    'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1",'
    '"type":"function","function":{"name":"set_mode","arguments":""}}]}}]}\n\n'
    'data: {"choices":[{"index":0,"delta":{},"finish_reason":"length"}]}\n\n'
    "data: [DONE]\n\n"
)

client = AsyncOpenAI(
    api_key="sk-" + "a" * 48,
    base_url="http://mock/v1",
    http_client=httpx.AsyncClient(
        transport=httpx.MockTransport(
            lambda request: httpx.Response(
                200, headers={"content-type": "text/event-stream"}, content=body.encode()
            )
        )
    ),
)
service = OpenAIChatCompletion(ai_model_id="gpt-4o-mini", async_client=client)

kernel = Kernel()
kernel.add_plugin(Plugin(), plugin_name="p")
history = ChatHistory()
history.add_user_message("do it")

async for _ in service.get_streaming_chat_message_content(
    history, PromptExecutionSettings(function_choice_behavior=FunctionChoiceBehavior.Auto()), kernel=kernel
):
    pass

print(calls)  # ["default"] — the tool ran although nothing was written

The non-streaming path (get_chat_message_contents) behaves the same.

Expected behavior

Do not run a call whose arguments were never written when the provider marked the reply as cut off. Everything else should keep its current behaviour: a non-empty argument string — truncated or not — is handled by the existing parsing, and a call that finished writing its arguments runs even if the reply was cut off afterwards.

Environment

  • semantic-kernel 1.46.0 and main at 28c0d60
  • Python 3.11, Windows 11
  • The reproduction and the tests use OpenAIChatCompletion with httpx.MockTransport; no network access needed
  • The cut-off response above was captured from qwen-plus on an OpenAI-compatible endpoint, max_tokens 1–20, streaming and non-streaming

Notes

  • Affects both loops in python/semantic_kernel/connectors/ai/chat_completion_client_base.py.
  • I have a patch with regression tests through the real connector for both paths, including the compatibility cases, and can open a PR.

Activity

  1. added
    .NETIssue or Pull requests regarding .NET code
    pythonPull requests for the Python Semantic Kernel
    on Nov 5, 2026
  2. changed the title [-]Python: a tool runs with its defaults when the reply is cut off before any argument is written[/-] [+].Net: Python: a tool runs with its defaults when the reply is cut off before any argument is written[/+] on Nov 5, 2026
  3. changed the title [-].Net: Python: a tool runs with its defaults when the reply is cut off before any argument is written[/-] [+]Python: a tool runs with its defaults when the reply is cut off before any argument is written[/+] on Nov 5, 2026
  4. kimnamu commented on Nov 6, 2026

    @kimnamu

    Thanks for the precise repro and for listing which shapes must keep running; it made the same loop quick to check with BedrockChatCompletion. On Bedrock the cut-off call reaches the loop as {} (non-streaming) or '{}' (streaming), and each open PR catches one of the two.

    Live, Claude Haiku 4.5 on Bedrock, set_mode(mode: str = "default") with FunctionChoiceBehavior.Auto():

    Reply cut off after the tool name (max_tokens=40) main df1cb33 #14724 612d72c #14727 6aee7b6 #14724 + one line
    non-streaming, arguments={} ❌ runs with defaults ❌ runs with defaults ✅ not run ✅ not run
    streaming, arguments='{}' ❌ runs with defaults ✅ not run ❌ runs with defaults ✅ not run
    complete call (max_tokens=400) ✅ runs silent ✅ runs silent ✅ runs silent ✅ runs silent

    Bedrock never sent partial input when cut off: toolUse.input = {} from Converse, a start event plus one empty delta from ConverseStream, stopReason: max_tokens (LENGTH via bedrock/services/model_provider/utils.py:220). A deliberate no-argument call looks the same but ends with tool_use, so here an empty mapping under LENGTH means nothing was written. For #14724 that is one line in _arguments_never_written:

    -    return raw is None
    +    return raw is None or (isinstance(raw, Mapping) and not raw)

    For the same reason, the first Copilot note on #14727 (keep {}) brings the default run back; applied locally, both paths ran it again.

    Happy to leave the fix with you both; raw output below.

    Raw Bedrock shapes (Haiku 4.5 and 5.5)

    boto3 converse / converse_stream, the same tool (p-set_mode, one optional string parameter), user "Set the mode to silent.", us-east-1. Haiku 4.5, cut off and complete:

    converse        maxTokens=40  stopReason=max_tokens ['toolUse(name=p-set_mode, input={})']
    converse_stream maxTokens=40  stopReason=max_tokens ['start(toolUse p-set_mode)', "delta(input='')"]
    converse        maxTokens=56  stopReason=tool_use  ['toolUse(name=p-set_mode, input={"mode": "silent"})']
    converse_stream maxTokens=56  stopReason=tool_use  ['start(toolUse p-set_mode)', "delta(input='')", 'delta(input=\'{"mode"\')', "delta(input=': ')", 'delta(input=\'"silent"}\')']
    

    Every budget from 30 to 54 gave the maxTokens=40 lines; Haiku 5.5 gave the same lines at 25, 35 and 45. A tool with no parameters, called on purpose (maxTokens=300, two runs per model):

    converse        tool_use [{}]
    converse_stream ['tool_use'] ['']
    
    Semantic Kernel runs

    main, #14724 and #14727: three runs each with the same outcome. #14724 + one line: calls=[] on both paths in the two runs where I printed it.

    # main df1cb33
    non-streaming calls=['default']  first tool call in history: arguments={} finish_reason=FinishReason.LENGTH
    streaming     calls=['default']  first tool call in history: arguments='{}' finish_reason=FinishReason.LENGTH
    # #14724 612d72c
    non-streaming calls=['default', 'default']  first tool call in history: arguments={} finish_reason=FinishReason.LENGTH
    streaming     calls=[]  first tool call in history: arguments='{}' finish_reason=FinishReason.LENGTH
    # #14727 6aee7b6
    non-streaming calls=[]  first tool call in history: no FunctionCallContent in history
    streaming     calls=['default', 'default', 'default']  first tool call in history: arguments='{}' finish_reason=FinishReason.LENGTH
    # #14724 + one line
    non-streaming calls=[]  first tool call in history: arguments={} finish_reason=FinishReason.LENGTH
    streaming     calls=[]  first tool call in history: arguments='{}' finish_reason=FinishReason.LENGTH
    # #14727 with the first Copilot note applied (filter only None and "")
    non-streaming calls=['default']  first tool call in history: arguments={} finish_reason=FinishReason.LENGTH
    streaming     calls=['default']  first tool call in history: arguments='{}' finish_reason=FinishReason.LENGTH
    

    With the one line, test_cut_off_tool_call.py plus tests/unit/connectors/ai/{bedrock,anthropic,open_ai} and tests/unit/agents/chat_completion give 426 passed (test_openai_request_settings.py left out, it needs azure.search, which isn't installed here).

    One thing that is not from either PR: with #14724 the model is told why and retries, and at max_tokens=40 it was cut off again on every attempt in three of four runs. The final request SK then sends without tools fails on Bedrock with ValidationException: The toolConfig field must be defined when using toolUse and toolResult content blocks. main raises the same after a normal tool round once the attempt limit is reached (maximum_auto_invoke_attempts=1, complete call), so it is separate from this issue.

    # python -I sk_run.py <semantic-kernel python/ dir> <model_id> <max_tokens> [maximum_auto_invoke_attempts]
    import asyncio
    import sys
    
    sys.path.insert(0, sys.argv[1])
    
    import semantic_kernel
    from semantic_kernel.connectors.ai.bedrock import BedrockChatCompletion, BedrockChatPromptExecutionSettings
    from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
    from semantic_kernel.contents import ChatHistory, FunctionCallContent
    from semantic_kernel.functions import kernel_function
    from semantic_kernel.kernel import Kernel
    
    assert semantic_kernel.__file__.startswith(sys.argv[1]), semantic_kernel.__file__
    calls: list[str] = []
    
    
    class Plugin:
        @kernel_function(name="set_mode", description="Set the device mode.")
        def set_mode(self, mode: str = "default") -> str:
            calls.append(mode)
            return "done"
    
    
    def history() -> ChatHistory:
        h = ChatHistory()
        h.add_system_message("Call the tool directly. Do not write any text before or after the tool call.")
        h.add_user_message("Set the mode to silent.")
        return h
    
    
    def first_call(h: ChatHistory) -> str:
        for m in h.messages:
            for i in m.items:
                if isinstance(i, FunctionCallContent):
                    return f"arguments={i.arguments!r} finish_reason={m.finish_reason}"
        return "no FunctionCallContent in history"
    
    
    async def main() -> None:
        kernel = Kernel()
        kernel.add_plugin(Plugin(), plugin_name="p")
        service = BedrockChatCompletion(model_id=sys.argv[2])
        settings = BedrockChatPromptExecutionSettings(
            max_tokens=int(sys.argv[3]), function_choice_behavior=FunctionChoiceBehavior.Auto()
        )
        if len(sys.argv) > 4:
            settings.function_choice_behavior.maximum_auto_invoke_attempts = int(sys.argv[4])
    
        h = history()
        try:
            await service.get_chat_message_contents(h, settings, kernel=kernel)
        except Exception as e:
            print(f"non-streaming raised {type(e).__name__}: {str(e)[-110:]}")
        print(f"non-streaming calls={calls}  first tool call in history: {first_call(h)}")
        calls.clear()
    
        h = history()
        async for _ in service.get_streaming_chat_message_contents(h, settings, kernel=kernel):
            pass
        print(f"streaming     calls={calls}  first tool call in history: {first_call(h)}")
    
    
    asyncio.run(main())

    Model ID: us.anthropic.claude-haiku-4-5-20251001-v1:0, AWS_DEFAULT_REGION=us-east-1.

  5. simin-yuan commented on Nov 7, 2026

    @simin-yuan
    Author

    Thanks for checking this on Bedrock and for the shape table — that is the part I could not see from the OpenAI side.

    #14724 now covers both shapes you measured:

    • 081d926 applies your one-liner in _arguments_never_written (raw is None or (isinstance(raw, Mapping) and not raw)), so the non-streamed {} is refused as well.
    • 52a775a treats streamed argument text that holds only an empty object as nothing written. That is how this connector reports a cut-off call — input={} serialized to "{}" — and before that commit the streamed half was only covered for connectors that send an empty delta.

    Both are gated on finish_reason=length: a reply that did not reach the limit still runs an empty object. The tests go through the Anthropic connector, which hands the input over the same way Bedrock does — the cut-off case and its not-cut-off counterpart for each shape, four in total. 461 tests over connectors/ai/{open_ai,anthropic,bedrock} and agents/chat_completion, 0 failed.

    Agreed that the toolConfig error is separate — it reads like its own issue, and I have not filed anything for it.

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

    .NETIssue or Pull requests regarding .NET codepythonPull requests for the Python Semantic Kerneltriage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions