MoonshotAI / MoonshotAI/kimi-code

Tool calls silently degraded: truncated tool names, dropped arguments, and no-op Bash substitutions

Open
#3,220 3 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
7.5k
Forks
1.2k
Avg merge
11h 53m
Merged PRs (30d)
350

Description

Tool calls silently degraded: truncated tool names, dropped arguments, and no-op Bash substitutions

Environment

kimi CLI 0.38.0
Model kimi-code/k3 (provider managed:kimi-code, https://api.kimi.com/coding/v1)
Thinking enabled = true, effort = "high" (model declares always_thinking)
OS macOS 15 (Darwin 25.6.0), arm64
Node 26.7.0
MCP servers ~12 connected, ~155 tools in the catalog (~248 KB of schemas per request)

Also reproduced through the embedded server (kap-server + agent-core-v2) in a desktop host, so this is not TUI-specific.

Summary

Two distinct defects make agent runs stall. The first is an engine bug with a proposed patch. The second is a model-side emission problem that the engine currently accepts silently instead of retrying.


Bug 1 — tool name truncated and arguments dropped when function.name is split across SSE deltas

Symptom
Tool "mcp__fuse-browser__browser_ope" not found

The call is recorded with an empty argument object:

{"name":"mcp__fuse-browser__browser_ope","args":{}}

The tool catalog sent in the request contains the correct mcp__fuse-browser__browser_open. The engine answers Tool not found, the model retries the same call, and the turn loops until the step budget is exhausted. Observed 8–16 occurrences per affected session.

Root cause

packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts, convertChatCompletionStreamToolCall.

The buffered branch emits the ToolCall header as soon as the first delta carrying a non-empty function.name arrives:

const buffered = bufferedByIndex.get(streamIndex) ?? { arguments: '', emitted: false };
...
if (!buffered.emitted) {
  if (!hasConcreteName) { /* buffer args */ return []; }
  buffered.emitted = true;                 // <-- committed on the FIRST name fragment
  ...
  return [toolCallHeader];                 // name = this fragment only
}
if (!hasArguments) return [];              // <-- any later name fragment is discarded here

The OpenAI-compatible streaming contract does not guarantee that function.name arrives in a single chunk, and it does not guarantee name-then-arguments ordering. When a provider splits the name, every fragment after the first is dropped, and arguments buffered before the name is complete are lost with it.

The same defect exists, unfixed, in the v1 copy at packages/kosong/src/providers/chat-completions-stream.ts (consumed by packages/agent-core and packages/node-sdk, i.e. the CLI path).

Proposed fix

Accumulate the name; emit only once the arguments field appears (present, even empty — typeof functionArguments === 'string', not length > 0, which is what distinguishes "field omitted" from "field present but empty" per openai-python's ChoiceDeltaToolCallFunction); add a stream-end flush for calls that never receive an arguments field (vLLM omits it in some paths).

export interface BufferedChatCompletionToolCall {
  id?: string;
  name: string;          // added
  arguments: string;
  emitted: boolean;
}

if (!buffered.emitted) {
  if (hasConcreteName) buffered.name += functionName;
  const startsArguments = typeof functionArguments === 'string';
  if (buffered.name.length === 0 || !startsArguments) {
    if (hasArguments) buffered.arguments += functionArguments;
    bufferedByIndex.set(streamIndex, buffered);
    return [];
  }
  buffered.emitted = true;
  const initialArguments = buffered.arguments + functionArguments;
  buffered.arguments = '';
  bufferedByIndex.set(streamIndex, buffered);
  return [header(buffered, streamIndex, initialArguments)];
}

plus flushChatCompletionStreamToolCalls(bufferedByIndex) called after the for await loop in _convertStreamResponse (openai-legacy.ts), inside the try.

Verified locally: 6 unit tests covering split names, arguments-before-name, empty arguments, two concurrent indices, and end-of-stream flush. Three of them fail on the current code, all pass with the patch. Full agent-core-v2 suite stays green (5775 tests).

Two related silent-drop paths found while fixing this
  • kosong/contract/generate.ts: a tool_call_part carrying no index is dropped without error when something (text, thinking) has been flushed between the header and the fragment — flushPart handles only isContentPart and isToolCall. Result: truncated argument JSON, no diagnostic.
  • agent/loop/loopService.ts: callsByIndex.set(part._streamIndex, …) is called even when _streamIndex is undefined, so two index-less calls collide on the undefined key and later fragments are attributed to the wrong tool in the transcript.

Bug 2 — intended tool calls replaced by no-op Bash commands

Symptom

The model states its intent in the thinking block and emits an unrelated trivial shell command instead of the tool it just described. Excerpt from a fresh CLI session (kimi -p "…", small context), asked to write a file with Write:

step thinking emitted call
1 "The user asks for a documentation file, rapport.md…" Bash mkdir -p … && cat > …
2 "Now write the rapport.md in French, long and detailed." Bash pwd
3 (empty) Bash ls rapport.mdabsent

The file is never created. Write, Edit and Agent are never emitted.

In a longer session the pattern dominates the run: 117 tool calls, of which 100 Bash54 with command: "true", 20 with command: "", 5 with command: "EOF" — and zero Write/Edit/Agent. "true" and "" are no-ops: the engine reports success, the model believes it acted, and the turn advances without any effect.

Two further observations:

  • The thinking block collapses to empty on the degenerate steps, on a model that declares always_thinking with effort = "high". 39 of 130 steps had empty thinking in one session.
  • finishReason is a clean tool_calls / stop throughout — no truncation, no API error. The request is byte-identical across healthy and degenerate steps (same system-prompt hash, same tools hash, same params); only the message history grows.
Note

vLLM's K3 deployment notes describe a matching model-side behaviour: "We've occasionally seen K3 emit a tool-call format its own parser does not expect, yielding an empty tool_calls result", described as "prompt- and run-dependent, not a blanket failure", with the recommendation to validate against the schema and retry or fall back when tool_calls comes back empty, or to use strict/structured tool calling.
https://vllm.ai/blog/2026-07-27-k3

The engine currently treats a degenerate call as a successful step. loopControl.maxAttemptsPerStep already exists but is never engaged for this case, because nothing classifies "empty or no-op tool call" as a failure.

Suggested handling
  1. Classify an empty / unparseable tool_calls payload as a retryable step failure so the existing attempt budget applies.
  2. Optionally expose strict / structured tool calling for providers that support constrained decoding.
  3. Surface a warning when a turn produces consecutive no-effect tool calls, instead of silently reporting success.

Reproduction

cd "$(mktemp -d)"
kimi -p "Write a file report.md containing a full page of documentation about the architecture of an HTTP server in TypeScript: introduction, layer diagram, code samples, error handling section. Make it long and detailed. Use the Write tool."
ls report.md   # absent

Then inspect ~/.kimi-code/sessions/<workspace>/<session>/agents/main/wire.jsonl:

jq -r 'select(.event.name) | .event.name' wire.jsonl | sort | uniq -c
jq -c 'select(.event.name=="Bash") | .event.args' wire.jsonl

Bug 1 reproduces more reliably with a large MCP catalog; Bug 2 appeared in a fresh session with a near-empty context, so it is not a context-length effect.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts and packages/kosong/src/providers/chat-completions-stream.ts, then inspect kosong/contract/generate.ts and agent/loop/loopService.ts. Run the reported reproduction and inspect wire.jsonl with the provided jq commands; compare against the six locally verified stream tests. Done means split and out-of-order tool-call fragments remain intact, and empty or no-op calls are surfaced or retried rather than reported as successful.

Written by the indexing model from the issue text.

Assessment

Tech stack
nodejs, typescript
Domain
backend, cli, tooling
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.