Repository navigation
Replies: 5 comments
|
补一个数据点。我在 0.2.2-alpha.2(主帖是 0.2.1-rc.5,两代都有)遇到同款,Windows + WSL2,web profile,官方 API。20 天出现了 13 次,全是长 tool-call 场景。截断都断在 arguments 字符串中间,单条 176~7935 字符不等,断的位置没什么规律,不太像超 token,更像流式中途断的。 翻了翻 translate.ts 150 行附近:注释里说截断的 JSON 应该由 assembler 剪掉,但实际代码是先 JSON.parse,然后非 max-tokens 的截断原因直接 malformed 抛错: try { parsed = JSON.parse(content.arguments) }
catch (_invalidProviderToolJson) { return malformed('tool input is invalid JSON') }剪枝路径一直在,就是这一步没走到。和 #11598 的 .kind 裸读应该是同一片代码。 我本地把非 max-tokens 的截断改走已有的 max-tokens 剪枝路径,之后就没再复现。代价是那一轮 tool-call 会整体丢掉,不过比整轮失败连上下文一起丢强。所以比起把 MALFORMED_RESPONSE 塞进 DEFAULT_RETRYABLE_CODES,走现成剪枝路径改动面更小。 |
|
Adding an independent macOS arm64 / Node 24.19.0 / dsh 0.2.1-rc.4 reproduction and a credential-free minimal fixture. What we observedA completed worker handed back an artifact path. The coordinating agent then successfully inspected the artifact and read three preview images. On its next model step, it proposed two tool calls:
The persisted There are no This points to malformed generated tool arguments plus terminal failure handling, not demonstrated stream-fragment corruption. We did not retain the original HTTP/SSE bytes, so the journal reconstruction is not a raw-wire replay. Isolated verificationThe unmodified installed 6/6 assertions passed; no API key, live model request, network request, or tool execution. This is an adapter/parser test, not an agent-loop recovery test. Current master inspected: Suggested recovery boundaryPlease keep rejecting invalid arguments. Prefer a narrowly classified invalid-tool-arguments path, with bounded model correction or failed-step regeneration only when the execution layer guarantees no call in that step has started. Preserve earlier successful steps, cancellation and retry limits. Do not silently fix punctuation and execute the result, substitute Regression coverage should distinguish this from malformed SSE framing, cover a mixed valid/invalid batch, exhaustion/cancellation, and verify that previously completed side effects are never repeated. User-facing diagnostics can name the tool/block without exposing argument contents; the private journal already retained useful diagnostic evidence in this case. Minimal sanitized offline reproduction (repro.mjs)Run with Node 24 against the installed provider's entry point: node repro.mjs /path/to/@deepseek-ai/dsh-llm-deepseek/lib/index.jsOr run without an argument where import assert from 'node:assert/strict';
// Pass a path/URL to the installed dsh-llm-deepseek/lib/index.js, or run
// where @deepseek-ai/dsh-llm-deepseek is resolvable. No API key is needed.
const { DeepSeekAdapter } = await import(process.argv[2] ?? '@deepseek-ai/dsh-llm-deepseek');
const config = {
baseURL: 'https://fixture.invalid', defaults: { thinking: 'disabled' },
maxTokens: 4096, defaultContextWindow: 100000, models: [],
streamIdleTimeoutMs: 10000, maxRequestFilesBytes: 1000000,
maxInlineRequestImageBytes: 1000000, maxImagesPerRequest: 5,
imageOffloadByteQuantum: 1000, inlineImageOffloadByteQuantum: 1000,
imageOffloadCountQuantum: 1, filesApiTimeoutMs: 1000,
filePolicy: {}, retryPolicy: {},
};
let responseBytes;
let chunkSize;
let fetchCount = 0;
globalThis.fetch = async (url) => {
assert.equal(String(url), 'https://fixture.invalid/v1/messages');
fetchCount++;
const body = new ReadableStream({ start(controller) {
for (let i = 0; i < responseBytes.length; i += chunkSize) {
controller.enqueue(responseBytes.slice(i, i + chunkSize));
}
controller.close();
} });
return new Response(body, { headers: { 'content-type': 'text/event-stream' } });
};
const adapter = new DeepSeekAdapter({
options: () => config, resolveFiles: () => ({}),
resolveAuth: async () => ({ headers: {} }), resolveUserId: () => 'fixture',
prepareExtensions: async () => ({ fields: {}, accept: async () => {} }),
});
for (const extraBrace of [true, false]) {
const args = ['{"caption":"演示"}' + (extraBrace ? '}' : ''), '{"ok":true}'];
const events = [{ type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } }];
for (let index = 0; index < args.length; index++) {
events.push({ type: 'content_block_start', index, content_block: {
type: 'tool_use', id: `call_${index}`, name: `example_${index}`, input: {},
} });
// Separate argument deltas; also split SSE bytes independently below.
for (const partial_json of args[index]) {
events.push({ type: 'content_block_delta', index,
delta: { type: 'input_json_delta', partial_json } });
}
events.push({ type: 'content_block_stop', index });
}
events.push({ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 10 } },
{ type: 'message_stop' });
responseBytes = new TextEncoder().encode(events.map(e =>
`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(''));
for (chunkSize of [1, 31, 65536]) {
const chunks = [];
let failure;
try {
for await (const chunk of adapter.stream({ provider: 'deepseek', model: 'fixture',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Parser fixture' }] }],
})) chunks.push(chunk);
} catch (error) { failure = { code: error.code, message: error.message }; }
const blocks = chunks.filter(c => c.type === 'block-end');
assert.deepEqual(blocks.map(c => c.block.arguments), args);
if (extraBrace) {
assert.deepEqual(failure, { code: 'MALFORMED_RESPONSE',
message: 'DeepSeek Messages stream: tool input is invalid JSON' });
assert.equal(chunks.some(c => c.type === 'finish'), false);
} else {
assert.equal(failure, undefined);
assert.equal(chunks.at(-1).reason.kind, 'tool-calls');
}
console.log(JSON.stringify({ extraBrace, chunkSize, passed: true, failure }));
}
}
assert.equal(fetchCount, 6);
console.log('6/6 parser fixtures passed; network requests=0; tools executed=0'); |
|
补充一个字符级定位的案例,以及一条此前没在本帖记录的证据:运行时已有的 JSON 修复层救不了这类错误,所以「顺手修一下 JSON」不是可选项。 环境
现象一个普通对话轮的第 10 步,模型产出的工具入参不是合法 JSON:
字符级定位(业务内容已脱敏)
也就是说:这是模型生成时的局部转义失手,不是流损坏。任何「修 assembler / 修 SSE 分片」的方向都覆盖不到它。 新证据:已有的 repair 层必然失败调用链
所以只剩两条路:可重试,或非致命错误 + 模型同轮自我修订。 与 #8976 补丁的关系赞同那个补丁的方向,补两条支持论据:
retryPolicy:
mode: normal
retryableCodes:
- EMPTY_RESPONSE
- RATE_LIMIT
- SERVER
- TIMEOUT
- TRANSPORT
- MALFORMED_RESPONSE
- STREAM_CLOSED但默认值仍然只有 5 个码,等于每个新用户都要自己踩一次才知道要加 —— 这大概也是本帖跨 5 周、多个帖子仍持续有人报的原因。 建议
恳请给一个目标版本或里程碑。 |
|
TL;DR (EN) — Independent macOS reproduction on the newest release line ( 环境
频率:2%/步,这才是"总是失败"的来源该会话切到这条路由后共 154 个模型步,坏 3 次(1.9%):
三次落在完全不同的位置(step 16 / step 1 / step 43),并且紧接着的 turn 在同一份 history 下重发"继续"就成功、之后连跑 42 步。按 2%/步算,40 步的 turn 约 55% 会踩中一次 —— 用户侧的感受就是"这个会话每轮都失败",而它其实是概率性的。 字符级证据:DSML 标记泄漏进 arguments三次都发生在 …, "justification": "", "danger-full-access</||DSML|| parameter>\n</||DSML|| invoke>\n</||DSML|| calls>另两次是同一形态的混合体,DSML 片段把下一个键名整个吃掉,只剩值: …, "justification": "", "danger-full-access</||DSML|| parameter>\n<||DSML|| parameter name=": 10000, "workdir": "<repo>"}
…, "login": true, "run_in_background": false, "danger-full-access</||DSML|| parameter>\n<||DSML|| parameter name=": 1000, "workdir": "<repo>"}所以坏法有两个:字符串里出现裸换行( 三次坏点都集中在 关键点:SSE 信封是完好的。 与主帖结论一致的三点复核
现阶段的绕过方式(每台机器自己打,不替代上游修复)profile patch 里给两条 DeepSeek 路由显式扩码, - id: llm-deepseek
config:
retryPolicy:
mode: normal
maxRetries: 5
retryableCodes:
[MALFORMED_RESPONSE, STREAM_CLOSED, EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]( 这只能由用户自己发现并手工配置 —— 也正因如此,主帖和 #8976 的诉求是对的:把可恢复的那一类分类进默认重试集合(或给它一个独立 code,如 |
|
补一个「尾部截断」变体(provider 环境:DSH Desktop / 核心 0.2.2-rc.1,macOS arm64;provider 现场(2026-11-12 14:31:48 +08:00,turn 1 / step 19):一条 assistant 消息两个 bash 调用。
结果:pi-ai 的 两点补充:
频率:本机 4 天内 3 次(2 次 messages 端点、1 次 completions 端点),全部整轮失败。 这条是否已包含在 #11577 的修复范围里? |
Uh oh!
There was an error while loading. Please reload this page.
环境
api.deepseek.com(无代理)deepseek-official(@deepseek-ai/dsh-llm-deepseek-api-key),modeldeepseek-flash(DeepSeek-V4.1-Flash),thinking 开,contextWindow 1,048,576,maxTokens 131,072billion-context(本轮运行失败DeepSeek Messages stream: tool input is invalid JSON #7705 的初步归因已在该帖后续评论与 [Ideas] One malformed tool-call argument kills the whole turn: MALFORMED_RESPONSE/STREAM_CLOSED are not default-retryable (46 turns lost in production audit) #8976 中被证伪)rc.5 复现(Windows,2026-10-28 → 10-30)
统计方式:
~/.dsh/sessions下 394 个会话日志逐 zstd 帧解码后全文扫描(日志是多帧 zstd,普通 zstd 解只出第一帧,容易漏)。session-695b…tool input is invalid JSONask_user,395 字符参数,末尾多一个};JSON.parse报Unexpected non-whitespace character after JSON at position 394session-1854…tool input is invalid JSONmemory,485 字符;长中文串尾部错位(内容已脱敏):实际…exit 0。".target": "daily"},应为…exit 0。", "target": "daily"}session-1854…duplicate message_starttranslate.ts#L111抛MALFORMED_RESPONSE对照实验(同一会话、同一天):turn 11 / step 53 连续 5 次
Service is too busy(SERVER)被dsh-llm-retry自动重试并恢复(llm/retry事件 5 条);上面两个参数错误前后 0 条llm/retry。⇒ 重试器没坏,是错误码不在白名单。另:失败的
assistant/attempt.stream里 tool-call 的block-end已经产出(参数完整交付),所以这不是“流被截断”,而是模型把参数写坏 / 上游 SSE 事件异常。两次坏格式都是长中文自由文本参数的尾部错位(多括号、引号/逗号错位),与 #9402(hh9527、LDyty 的取证)和 #9503(未转义引号、截断)的形态一致。根因(master 现状,2026-10-30)
packages/llm/llm-deepseek/src/translate.ts#L148-L158(message_stop分支,非max-tokens):每个 tool-call 都JSON.parse(content.arguments),失败即malformed('tool input is invalid JSON')→MALFORMED_RESPONSE(L26)。packages/llm/llm/src/retry-policy.ts#L18-L24:DEFAULT_RETRYABLE_CODES = [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]—— 没有MALFORMED_RESPONSE,也没有STREAM_CLOSED;schema 默认值 L90。@deepseek-ai/dsh-llm-retry只在policy.retryableCodes.includes(failure.code)时重试 ⇒ 该失败即终点。Truncated tool JSON is retained in the stream, then pruned by the shared assembler—— 只有max-tokens被豁免;"写完整但写错"这一类没有走同一条容错路。packages/core/tools/src/batch.ts#L98-L103的parseArguments解析失败会保留原文、交给工具 schema 报可纠正错误;但translate在它之前就硬失败了。建议修复
短期(最小,可今天就合)
把
MALFORMED_RESPONSE+STREAM_CLOSED加进DEFAULT_RETRYABLE_CODES(或新增更窄的INVALID_TOOL_INPUT_JSON,只在“全部 tool-call 参数不可读且无 text/reasoning 伴随”时可重试)。Frank2015a/deepseek-harness@fix/default-retry-stream-failures(d719982,2026-10-06,改retry-policy.ts+tests/retry-policy.spec.ts#L16)。master 的retry-policy.ts自 2026-08-18 未变,补丁应仍可直接应用。正解(建议排期)
isError工具结果回给模型,让模型在同轮重写该调用;仅当没有可读调用时才按可重试错误处理。参考 Vercel AI SDKrepairToolCall/ opencode 的 repair-then-retry(How other agent platforms handle malformed tool-call arguments — and the two layers this repo is missing #9503 已做跨平台调研),也与batch.ts::parseArguments的容忍行为一致。JSON.parseposition、参数尾部片段、provider request-id;并把失败原因写进会话正文(现在只在轨迹/日志里,用户只看到“本轮失败”,不知道回“继续”能恢复——Bug: dsh-llm-deepseek 偶发把工具参数拼成非法 JSON ⇒ MALFORMED_RESPONSE 硬终止(无重试、无原文) #9402)。input_json_delta拼出非法 JSON / 重复message_start)覆盖:可重试或可恢复、不得整轮静默失败、duplicate message_start有明确分类。为什么现在必须提(催促)
cordis.patch.yml兜底。用户侧临时兜底(不算修复)
在
~/.dsh/profiles/desktop/cordis.patch.yml的- id: llm-deepseek(及llm-deepseek-account)config 追加:重启 DSH 生效。更窄的社区方案:
@argszero/cordis-plugin-empty-response-retry(#9402)。相关: #7705 #8920 #8976 #9402 #9503 #10406
All reactions