Files
workdsh/patches/[email protected]
T
JunkaiWang-TheoPhy 02d719f888 Stop empty-name tool calls from re-entering the model loop
Desktop ships upstream rc packages through Yarn patch resolutions, so the empty-name guard belongs in the patched agent loop rather than in a desktop-only wrapper. The new regression loads the installed loop implementation, exercises an empty-name tool call directly, and proves the turn now ends after one synthetic error with new-session guidance instead of entering normal unknown-tool dispatch.

Constraint: Desktop consumes upstream Harness rc packages through local Yarn patches
Rejected: Patch dsh-llm-deepseek again | adapter already preserves prior non-empty tool-call deltas, but finalized empty names still need loop-level termination
Rejected: Treat all unknown tools as terminal | would broaden behavior beyond the proxy-corruption case from issue #157
Confidence: high
Scope-risk: narrow
Directive: Keep the empty-name guard in the agent loop until upstream ships an rc that closes the loop at source
Tested: yarn workspace dsh-plugin-desktop vitest run tests/empty-tool-call-loop.spec.ts
Tested: yarn workspace dsh-plugin-desktop vitest run tests/deepseek-streaming-tool-call.spec.ts tests/empty-tool-call-loop.spec.ts
Tested: yarn workspace dsh-plugin-desktop vitest run tests/package.spec.ts -t 'patches the agent loop to stop after one empty-name tool call'
Tested: yarn workspace dsh-plugin-desktop typecheck
Tested: yarn workspace dsh-plugin-desktop build
2026-08-26 09:11:09 +08:00

50 lines
2.2 KiB
Diff

diff --git a/lib/index.js b/lib/index.js
index 17e11c9df8..597c77594b 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -114,6 +114,24 @@ var RuntimeContextProjection = class {
* @param signal - abort signal shared by the step.
* @param acceptContext - accepts committed result context for the next step boundary.
*/
+const EMPTY_TOOL_NAME_ERROR = "assistant emitted a tool call with an empty tool name";
+const EMPTY_TOOL_NAME_RESULT = "Error: assistant emitted a tool call with an empty tool name. This usually means an upstream proxy or cached long session dropped tool_call.name/id. Start a new session or use the official DeepSeek API directly.";
+function hasEmptyToolCallName(block) {
+ return typeof block.name === "string" && block.name.trim() === "";
+}
+function appendEmptyToolCallFailure(session, turn, step, block) {
+ const callSeq = appendToolCall(session, turn, step, block);
+ appendToolResult(session, turn, step, block, {
+ content: [{
+ type: "text",
+ text: EMPTY_TOOL_NAME_RESULT
+ }],
+ isError: true,
+ error: {
+ message: EMPTY_TOOL_NAME_ERROR
+ }
+ }, callSeq);
+}
async function executeToolCalls(ctx, turn, step, toolCalls, signal, acceptContext) {
const agent = ctx.agents.requireInitiator();
const { session } = agent;
@@ -131,8 +149,18 @@ async function executeToolCalls(ctx, turn, step, toolCalls, signal, acceptContex
let concluded = false;
while (next < planned.length) {
const first = planned[next];
+ if (hasEmptyToolCallName(first.block)) {
+ appendEmptyToolCallFailure(session, turn, step, first.block);
+ return { concluded: true };
+ }
const mode = ctx.tools.executionMode(first.exec).kind;
- const outcome = await runGroup(ctx, turn, step, mode === "parallel" ? planned.slice(next) : [first], mode, signal, acceptContext);
+ const group = [first];
+ if (mode === "parallel") while (next + group.length < planned.length) {
+ const candidate = planned[next + group.length];
+ if (hasEmptyToolCallName(candidate.block)) break;
+ group.push(candidate);
+ }
+ const outcome = await runGroup(ctx, turn, step, group, mode, signal, acceptContext);
next += outcome.consumed;
concluded ||= outcome.concluded;
if (outcome.aborted) {