Repository navigation
Description
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-kernel1.46.0 andmainat28c0d60- Python 3.11, Windows 11
- The reproduction and the tests use
OpenAIChatCompletionwithhttpx.MockTransport; no network access needed - The cut-off response above was captured from qwen-plus on an OpenAI-compatible endpoint,
max_tokens1–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
[-]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[/+][-].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[/+]kimnamu commentedon Nov 6, 2026
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")withFunctionChoiceBehavior.Auto():max_tokens=40)df1cb33612d72c6aee7b6arguments={}arguments='{}'max_tokens=400)silentsilentsilentsilentBedrock never sent partial input when cut off:
toolUse.input = {}from Converse, a start event plus one empty delta from ConverseStream,stopReason: max_tokens(LENGTHviabedrock/services/model_provider/utils.py:220). A deliberate no-argument call looks the same but ends withtool_use, so here an empty mapping underLENGTHmeans nothing was written. For #14724 that is one line in_arguments_never_written: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:Every budget from 30 to 54 gave the
maxTokens=40lines; 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):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.With the one line,
test_cut_off_tool_call.pyplustests/unit/connectors/ai/{bedrock,anthropic,open_ai}andtests/unit/agents/chat_completiongive 426 passed (test_openai_request_settings.pyleft out, it needsazure.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=40it was cut off again on every attempt in three of four runs. The final request SK then sends without tools fails on Bedrock withValidationException: 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.Model ID:
us.anthropic.claude-haiku-4-5-20251001-v1:0,AWS_DEFAULT_REGION=us-east-1.Python: refuse a cut-off call whose arguments arrive as an empty mapping
simin-yuan commentedon Nov 7, 2026
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:
081d926applies 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.52a775atreats 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 overconnectors/ai/{open_ai,anthropic,bedrock}andagents/chat_completion, 0 failed.Agreed that the
toolConfigerror is separate — it reads like its own issue, and I have not filed anything for it.