MoonshotAI / MoonshotAI/kimi-code
Tool calls silently degraded: truncated tool names, dropped arguments, and no-op Bash substitutions
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: atool_call_partcarrying noindexis dropped without error when something (text, thinking) has been flushed between the header and the fragment —flushParthandles onlyisContentPartandisToolCall. Result: truncated argument JSON, no diagnostic.agent/loop/loopService.ts:callsByIndex.set(part._streamIndex, …)is called even when_streamIndexisundefined, so two index-less calls collide on theundefinedkey 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.md → absent |
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 Bash — 54 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_thinkingwitheffort = "high". 39 of 130 steps had empty thinking in one session. finishReasonis a cleantool_calls/stopthroughout — 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
- Classify an empty / unparseable
tool_callspayload as a retryable step failure so the existing attempt budget applies. - Optionally expose strict / structured tool calling for providers that support constrained decoding.
- 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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