环境
- PI-Desktop 0.15.3(Windows,已是最新版本)
- 本文由 AI 依据本机运行日志 + 发布包产物分析得出,其中"复现"一节是在本机实跑的结果,未经人工逐行复核
现象
某个会话跑到中途后,之后每一轮都固定返回同样一条错误,且与所选服务商无关:
AI 服务返回了错误 PROVIDER_ERROR
Cannot read properties of undefined (reading 'length')
- 换成另一个第三方中转(协议都不一样:一个 Anthropic Messages 风格、一个 OpenAI Chat Completions 风格)仍是同一条报错 → 与具体上游无关。
- 从出问题的会话分叉(fork)出的新会话继承同样的失败;新建空白会话一切正常。
- 出问题的会话上下文很大(单轮请求 290+ 条消息)。
- 界面上的「继续」重试也无法恢复。
日志证据(已去隐私)
logs/app/session.log 中连续 4 次一模一样的记录:
{"event":"agent.turn.failed","code":"PROVIDER_ERROR",
"data":{"message":"Cannot read properties of undefined (reading 'length')","retriable":true,
"details":{"phase":"stream","requestMessages":294,"providerWaitMs":63057,"streamMs":1,"retryAttempt":10}}}
几个关键点:
message 是 V8 的原始 TypeError 文本,不是上游返回的文案。同一个日志里上游真正的报错长这样:404 "model not found"、API error (404)。也就是说:错误是客户端解码流的时候抛的,然后被原样当成 provider 错误上抛了。
providerWaitMs = streamStartedAt - requestStartedAt ≈ 63s(首字节等待),streamMs = endedAt - streamStartedAt = 0~1ms。即崩在"收到第一个 message_start 之后、处理紧随其后的第一个内容帧"的同一时间片内,而不是请求阶段。
- 同一会话更早还出现过
STREAM_FAILED: stream stalled: no provider events for 180000ms,所以这一次不是空闲超时。
retryAttempt: 10 已经顶到 PROVIDER_RETRY_MAX_RETRIES = 10,而这个计数是跨轮累计、只有干净结束的那一轮才清零(if (!failed && !aborted) { this.providerTransientRetryAttempt = 0; ... })。所以预算耗尽后再也不重试,直接把原始 TypeError 弹给用户——这解释了两个现象:retryAttempt 永远是 10,以及"一点就报错"。
复现(用发布包里那段代码实跑)
把 resources/agent-runtime/sidecar.js 里 @earendil-works/pi-ai/dist/utils/assistant-message-frame.js 的 AssistantMessageFrameEncoder 单独抽出来,喂入"上游省略了空字符串字段"的帧(很多网关/中转会省略空串字段):
[1] 正常 text_delta : 未抛错 -> {"type":"text_delta","contentIndex":0,"delta":"hi"}
[2] text_delta 的 delta 字段缺失 : TypeError: Cannot read properties of undefined (reading 'length')
[3] thinking_delta 的 delta 字段缺失 : TypeError: Cannot read properties of undefined (reading 'length')
[4] toolcall_delta 的 delta 字段缺失 : TypeError: Cannot read properties of undefined (reading 'length')
[5] text_start 的块缺少 text 字段 : TypeError: Cannot read properties of undefined (reading 'length')
[6] thinking_start 的块缺少 thinking 字段 : TypeError: Cannot read properties of undefined (reading 'length')
正常帧完全没事,只要少一个字符串字段就原样吐出线上那条报错。
代码位置(0.15.3 打包产物)
1) @earendil-works/pi-ai/dist/utils/assistant-message-frame.js — AssistantMessageFrameEncoder.encode()
这里只校验了 content.type,没有校验字符串字段本身:
case "text_start": {
const content = eventBlock(event);
if (content.type !== "text") throw new Error(`text_start event points to ${content.type} block ...`);
this.startBlock(event.contentIndex, { kind: "text", coveredChars: content.text.length, deltaChars: 0 }); // ← text 可能是 undefined
...
}
case "thinking_start": {
...
this.startBlock(event.contentIndex, { kind: "thinking", coveredChars: content.thinking.length, deltaChars: 0 }); // ← 同上
}
encodeTextDelta(contentIndex, delta, kind) {
...
state3.deltaChars += delta.length; // ← delta 可能是 undefined
const covered = Math.max(0, state3.coveredChars - deltaStart);
if (covered >= delta.length) return void 0;
...
}
// toolcall_delta 分支里还有一处: event.delta.length === 0 ? void 0 : {...}
2) @earendil-works/pi-ai/dist/api/anthropic-messages.js — 建块时有 ?? "",delta 路径却是裸透传
// 建块(这里是安全的)
const block = { type: "text", text: event.content_block.text ?? "", index: event.index };
// delta(不安全)
block.text += event.delta.text;
stream12.push({ type: "text_delta", contentIndex: index5, delta: event.delta.text, partial: output });
// thinking_delta -> delta: event.delta.thinking
// input_json_delta -> delta: event.delta.partial_json
3) 同类漏网(OpenAI 兼容协议的 reasoning_details 兼容解析)
function parseLegacyEncryptedReasoningDetail(signature) {
...
return parsed.type === "reasoning.encrypted" && typeof parsed.id === "string"
&& parsed.id.length > 0 && parsed.data.length > 0 ? parsed : void 0; // ← parsed.data 没做类型校验
}
对比:同文件的 isOpenAIReasoningDetail() 是有 typeof detail.data === "string" 守卫的,这个 legacy 解析函数漏了。
建议
- 给上面所有
.length 加 typeof x === "string" 守卫或 ?? "" 兜底。缺字段的帧应当降级为"忽略这个 delta"或一条可读的 STREAM_FRAME_MALFORMED,不该把 V8 的原始 TypeError 当 provider 错误抛给用户——否则用户会一直以为是自己的服务商坏了。
retryAttempt 的累计计数建议按轮重置,或在详情里区分「本轮第几次重试」,否则看到 10 无法判断是本次重试还是历史累计。
- 如果方便,加一个记录原始 SSE 帧的调试开关(类似现有的
PI_DESKTOP_STREAM_IDLE_TIMEOUT_MS)。这类"上游响应形状"问题,有原始帧一次就能定位,否则只能靠猜。
- 修在 pi-ai 层最合适:frame encoder 是所有 api adapter 的公共出口,一处守卫可以覆盖全部协议。
说明:以上日志取证、代码定位与复现均由 AI 完成;代码片段摘自 0.15.3 的打包产物(sidecar.js),与仓库源码的行号可能不一致。
环境
现象
某个会话跑到中途后,之后每一轮都固定返回同样一条错误,且与所选服务商无关:
日志证据(已去隐私)
logs/app/session.log中连续 4 次一模一样的记录:{"event":"agent.turn.failed","code":"PROVIDER_ERROR", "data":{"message":"Cannot read properties of undefined (reading 'length')","retriable":true, "details":{"phase":"stream","requestMessages":294,"providerWaitMs":63057,"streamMs":1,"retryAttempt":10}}}几个关键点:
message是 V8 的原始 TypeError 文本,不是上游返回的文案。同一个日志里上游真正的报错长这样:404 "model not found"、API error (404)。也就是说:错误是客户端解码流的时候抛的,然后被原样当成 provider 错误上抛了。providerWaitMs = streamStartedAt - requestStartedAt≈ 63s(首字节等待),streamMs = endedAt - streamStartedAt= 0~1ms。即崩在"收到第一个message_start之后、处理紧随其后的第一个内容帧"的同一时间片内,而不是请求阶段。STREAM_FAILED: stream stalled: no provider events for 180000ms,所以这一次不是空闲超时。retryAttempt: 10已经顶到PROVIDER_RETRY_MAX_RETRIES = 10,而这个计数是跨轮累计、只有干净结束的那一轮才清零(if (!failed && !aborted) { this.providerTransientRetryAttempt = 0; ... })。所以预算耗尽后再也不重试,直接把原始 TypeError 弹给用户——这解释了两个现象:retryAttempt永远是 10,以及"一点就报错"。复现(用发布包里那段代码实跑)
把
resources/agent-runtime/sidecar.js里@earendil-works/pi-ai/dist/utils/assistant-message-frame.js的AssistantMessageFrameEncoder单独抽出来,喂入"上游省略了空字符串字段"的帧(很多网关/中转会省略空串字段):正常帧完全没事,只要少一个字符串字段就原样吐出线上那条报错。
代码位置(0.15.3 打包产物)
1)
@earendil-works/pi-ai/dist/utils/assistant-message-frame.js—AssistantMessageFrameEncoder.encode()这里只校验了
content.type,没有校验字符串字段本身:2)
@earendil-works/pi-ai/dist/api/anthropic-messages.js— 建块时有?? "",delta 路径却是裸透传3) 同类漏网(OpenAI 兼容协议的 reasoning_details 兼容解析)
对比:同文件的
isOpenAIReasoningDetail()是有typeof detail.data === "string"守卫的,这个 legacy 解析函数漏了。建议
.length加typeof x === "string"守卫或?? ""兜底。缺字段的帧应当降级为"忽略这个 delta"或一条可读的STREAM_FRAME_MALFORMED,不该把 V8 的原始 TypeError 当 provider 错误抛给用户——否则用户会一直以为是自己的服务商坏了。retryAttempt的累计计数建议按轮重置,或在详情里区分「本轮第几次重试」,否则看到10无法判断是本次重试还是历史累计。PI_DESKTOP_STREAM_IDLE_TIMEOUT_MS)。这类"上游响应形状"问题,有原始帧一次就能定位,否则只能靠猜。