@thincoder/core 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/advisor/citations.mjs +40 -4
  3. package/advisor/compaction.mjs +21 -2
  4. package/advisor/convergence.mjs +1 -1
  5. package/advisor/history.mjs +8 -6
  6. package/advisor/loop.mjs +29 -27
  7. package/advisor/messages.mjs +5 -4
  8. package/advisor/run.mjs +5 -1
  9. package/advisor/timeline.mjs +26 -0
  10. package/advisor.mjs +4 -2
  11. package/agent/completion.mjs +3 -1
  12. package/agent/digest-account.mjs +188 -0
  13. package/agent/dispatch-run.mjs +29 -12
  14. package/agent/family-tools.mjs +11 -11
  15. package/agent/helpers.mjs +8 -0
  16. package/agent/record-results.mjs +10 -1
  17. package/agent/run-stages.mjs +27 -4
  18. package/agent/run-start.mjs +49 -3
  19. package/agent/spawn-child.mjs +2 -1
  20. package/agent/subagent-event.mjs +71 -0
  21. package/agent/suspension.mjs +60 -12
  22. package/agent/turn-loop.mjs +31 -2
  23. package/agent/write-gate.mjs +13 -25
  24. package/agent-tools/advisor-async.mjs +25 -7
  25. package/agent-tools/advisor-settle.mjs +7 -9
  26. package/agent-tools/advisor.mjs +34 -33
  27. package/agent-tools/async-settle.mjs +29 -5
  28. package/agent-tools/batch-lifecycle.mjs +29 -7
  29. package/agent-tools/batch-paths.mjs +22 -2
  30. package/agent-tools/batch-read.mjs +36 -0
  31. package/agent-tools/batch-skeleton.mjs +59 -33
  32. package/agent-tools/batch.mjs +16 -6
  33. package/agent-tools/design-token.mjs +76 -16
  34. package/agent-tools/escalate-async.mjs +4 -2
  35. package/agent-tools/review-facts.mjs +143 -16
  36. package/agent-tools/subagent-actions-query.mjs +13 -6
  37. package/agent-tools/subagent-async.mjs +16 -4
  38. package/agent-tools/subagent-panel.mjs +121 -37
  39. package/agent-tools/subagent-spawn.mjs +1 -1
  40. package/agent-tools/subagent.mjs +5 -3
  41. package/agent-tools/verify.mjs +5 -0
  42. package/agent-tools/zero-write-watch.mjs +77 -0
  43. package/agent.mjs +1 -1
  44. package/auto-think.mjs +26 -2
  45. package/config-presets.mjs +8 -0
  46. package/config.mjs +30 -0
  47. package/conventions.mjs +26 -6
  48. package/declaration.mjs +26 -2
  49. package/embedding.mjs +35 -3
  50. package/escape.mjs +1 -1
  51. package/i18n.mjs +5 -0
  52. package/ledger-cmd.mjs +13 -21
  53. package/ledger-db.mjs +34 -1
  54. package/ledger-migrate.mjs +4 -3
  55. package/ledger-read.mjs +94 -0
  56. package/ledger-surface.mjs +15 -46
  57. package/ledger-tools.mjs +148 -85
  58. package/ledger-variant-notice.mjs +47 -0
  59. package/ledger.mjs +38 -64
  60. package/manifest-schema.mjs +19 -7
  61. package/manifest.mjs +32 -11
  62. package/mcp/transport-http.mjs +52 -4
  63. package/mcp/transport-stdio.mjs +33 -8
  64. package/mcp.mjs +13 -5
  65. package/memory/code-search.mjs +5 -3
  66. package/memory/core.mjs +6 -4
  67. package/memory/docs.mjs +5 -3
  68. package/memory/schema.mjs +2 -0
  69. package/package.json +1 -1
  70. package/prompts/advisor-design.md +3 -2
  71. package/prompts/advisor-round1.md +1 -0
  72. package/prompts/advisor-round2.md +8 -3
  73. package/prompts/advisor-round3.md +7 -2
  74. package/prompts/common.md +2 -2
  75. package/prompts/discipline-normal.md +2 -1
  76. package/prompts/persona-engineering.md +7 -4
  77. package/provider/anthropic.mjs +14 -2
  78. package/provider/core.mjs +10 -4
  79. package/provider/errors.mjs +23 -0
  80. package/provider/google.mjs +31 -4
  81. package/provider/normalize.mjs +57 -0
  82. package/provider/responses-request.mjs +3 -1
  83. package/provider/responses.mjs +38 -4
  84. package/provider/sse.mjs +45 -13
  85. package/proxy.mjs +13 -3
  86. package/session.mjs +13 -2
  87. package/shell-identity.mjs +107 -0
  88. package/stream-destroy.mjs +62 -0
  89. package/tool-docs/advisor.md +1 -1
  90. package/tool-docs/bash.md +1 -1
  91. package/tool-docs/batch.md +1 -1
  92. package/tool-docs/delete.md +7 -5
  93. package/tool-docs/ledger.md +6 -0
  94. package/tool-docs/subagent.md +10 -0
  95. package/tool-docs/verify.md +1 -1
  96. package/tools/bash.mjs +9 -4
  97. package/tools/edit-batch.mjs +7 -2
  98. package/tools/index.mjs +1 -4
  99. package/tools/patch.mjs +8 -2
  100. package/tools/write-path.mjs +6 -4
  101. package/undo-stack.mjs +23 -9
  102. package/tool-docs/ledger_add.md +0 -1
  103. package/tool-docs/ledger_close.md +0 -1
  104. package/tool-docs/ledger_count.md +0 -1
  105. package/tool-docs/ledger_query.md +0 -1
  106. package/tool-docs/ledger_update.md +0 -1
@@ -110,6 +110,15 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
110
110
  return finishAnthropic(result)
111
111
  }
112
112
 
113
+ /** Anthropic `stop_reason` ⇒ 核统一 `finishReason`(§6.31.7 F-DA5——归一只喂既有分类;
114
+ * 余者原样透传)。缺席 ⇒ null(缺席支 = agent/run-stages.mjs 同面提醒 + 留痕)。 */
115
+ function mapAnthropicStopReason(reason) {
116
+ if (reason === "end_turn" || reason === "stop_sequence") return "stop"
117
+ if (reason === "tool_use") return "tool_calls"
118
+ if (reason === "max_tokens") return "length"
119
+ return reason
120
+ }
121
+
113
122
  /** Convert the parsed stream to the core.mjs result shape (usage → OpenAI-compatible). */
114
123
  function finishAnthropic(result) {
115
124
  const usage = result.usage
@@ -117,6 +126,7 @@ function finishAnthropic(result) {
117
126
  return {
118
127
  content: result.content,
119
128
  reasoning: result.reasoning,
129
+ finishReason: result.finishReason ?? null, // §6.31.7 F-DA5(原三 transport 恒 null——字段值收正)
120
130
  usage: {
121
131
  prompt_tokens: usage.input_tokens ?? 0,
122
132
  completion_tokens: usage.output_tokens ?? 0,
@@ -127,7 +137,7 @@ function finishAnthropic(result) {
127
137
  toolCalls: result.toolCalls,
128
138
  }
129
139
  }
130
- return { content: result.content, reasoning: result.reasoning, toolCalls: result.toolCalls }
140
+ return { content: result.content, reasoning: result.reasoning, finishReason: result.finishReason ?? null, toolCalls: result.toolCalls }
131
141
  }
132
142
 
133
143
  /**
@@ -135,7 +145,7 @@ function finishAnthropic(result) {
135
145
  * Events: message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop
136
146
  */
137
147
  async function parseAnthropicStream(response, { onToken, onReasoning, signal }) {
138
- const result = { content: "", reasoning: "", toolCalls: [], usage: null }
148
+ const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
139
149
  const decoder = new TextDecoder()
140
150
  let buffer = ""
141
151
  const toolBlocks = new Map()
@@ -172,6 +182,8 @@ async function parseAnthropicStream(response, { onToken, onReasoning, signal })
172
182
  }
173
183
  case "message_delta":
174
184
  if (json.usage) result.usage = json.usage
185
+ // §6.31.7 F-DA5(#929 · 批 digest-accounting):`stop_reason` 捕获——缺席 ⇒ 留 null(窄口)
186
+ if (json.delta?.stop_reason) result.finishReason = mapAnthropicStopReason(json.delta.stop_reason)
175
187
  break
176
188
  case "message_stop":
177
189
  for (const [, block] of toolBlocks) {
package/provider/core.mjs CHANGED
@@ -126,6 +126,8 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
126
126
  // window/clamping logic below reads the overridden value where it matters.
127
127
  const spec = providerSpec(provider)
128
128
  messages = stripImagesForTextModel(messages, spec)
129
+ // #853 D-PR32:历史图片累计字节预算——发送前对消息副本累计驱逐(最老先弃;history 本体零触)。
130
+ messages = capHistoryImages(messages)
129
131
  // SESSION.md §6.9 T-S3: local-only message fields (ts/transient) never reach the wire.
130
132
  // Stripped BEFORE format dispatch — anthropic/responses transports pass whole message
131
133
  // objects through verbatim (only the OpenAI path ran escapeMessages). Copy-on-write:
@@ -330,7 +332,7 @@ export function buildContinuationMessages(messages, result, spec) {
330
332
  // Pre-send payload normalization lives in normalize.mjs (2026-08-31 extract,
331
333
  // TODO #2); re-exported so provider/index.mjs and tool-pairing.test.mjs keep
332
334
  // their import paths.
333
- import { stripImagesForTextModel, normalizeToolPairing } from "./normalize.mjs"
335
+ import { stripImagesForTextModel, normalizeToolPairing, capHistoryImages } from "./normalize.mjs"
334
336
  export { stripImagesForTextModel, normalizeToolPairing }
335
337
  /** Merge tool calls from a retry/continuation into the accumulated result.
336
338
  * 2026-08-31 会诊 #7/#17:readSSE 输出的 tc 已 finalize(无 index 字段),
@@ -392,9 +394,9 @@ async function requestWithRetry(provider, body, signal, onWait) {
392
394
  _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
393
395
  _bodyIdleMs: FETCH_BODY_IDLE_MS,
394
396
  }
395
- response = provider.proxyUri
396
- ? await proxyFetch(url, opts, provider.proxyUri)
397
- : await fetch(url, opts)
397
+ // #878 D-PX8:请求调用点收口——恒走 `proxyFetch`(无 proxyUri 即直连分支,内部 abort 通道单点建设;
398
+ // 原 call-site 分叉删除)。
399
+ response = await proxyFetch(url, opts, provider.proxyUri)
398
400
  } catch (error) {
399
401
  // AGENT-LOOP-SUBAGENT.md §6.12 站点 #2(第 24 批):fetch 拒否面补标来源(undici 拒否的 AbortError 现场无 reason)
400
402
  if (error.name === "AbortError") throw annotateAbort(error, signal, "provider", "request")
@@ -406,6 +408,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
406
408
 
407
409
  const text = await response.text().catch(() => "")
408
410
  let message = `LLM API error ${response.status}: ${text}`
411
+ // #853 D-PR32:413 可操作化——长会话图片累积的高频形态,从裸 HTTP 码变为可执行指引。
412
+ if (response.status === 413) {
413
+ message += " — request body too large (HTTP 413): run /compact to compress the conversation history, or remove historical images."
414
+ }
409
415
  // 401 双平台提示 + 诊断回显(2026-08-31 会诊 #15):
410
416
  // Kimi 双平台 key 不互通的提示保留;通用加 baseURL host + key 前 6 位掩码,
411
417
  // 帮用户快速分辨"配错平台还是配错账号"。
@@ -99,3 +99,26 @@ export function assertProviderModel(provider) {
99
99
  throw new ProviderError(provider, "model is undefined — provider cloned without model re-derivation (set providers[].model — the channel default model)")
100
100
  }
101
101
  }
102
+
103
+ /**
104
+ * #907-D8:Responses 流内错误码 → 分类(最小集五码)。宿主 = 本文件(分类族单源——与
105
+ * parseRetryAfter / isNonRetryableError 同族);消费面 = responses.mjs 流内错误出口
106
+ * (response.failed onFailed ∥ type:"error" 帧处置)。HTTP 级分类(isNonRetryableError)
107
+ * 零改——本函数只服务流内错误面。未列码 → null(不发明分类,服务端消息原样透传)。
108
+ * retryable 是信息位:本批不接自动重试(无流内重试消费面),仅供上层展示/上报。
109
+ */
110
+ export function classifyResponsesErrorCode(code) {
111
+ switch (code) {
112
+ case "context_length_exceeded":
113
+ return { kind: "context_overflow", retryable: false, hint: "上下文超限——/compact 压缩历史后重试" }
114
+ case "insufficient_quota":
115
+ return { kind: "quota", retryable: false, hint: "配额/计费不足——检查 provider 余额或套餐额度" }
116
+ case "invalid_prompt":
117
+ return { kind: "invalid_request", retryable: false, hint: null }
118
+ case "server_is_overloaded":
119
+ case "server_error":
120
+ return { kind: "server", retryable: true, hint: null }
121
+ default:
122
+ return null
123
+ }
124
+ }
@@ -8,6 +8,7 @@ import { proxyFetch } from "../proxy.mjs"
8
8
  import { requestWithRetry } from "./retry.mjs"
9
9
  import { effectiveFetchTimeoutMs } from "./core.mjs"
10
10
  import { abortError, timeoutError } from "../abort-provenance.mjs"
11
+ import { terminateBody } from "../stream-destroy.mjs"
11
12
 
12
13
  /** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
13
14
  function mapFunctionCallingConfig(choice) {
@@ -135,6 +136,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
135
136
  return {
136
137
  content: result.content,
137
138
  reasoning: result.reasoning,
139
+ finishReason: result.finishReason ?? null, // §6.31.7 F-DA5(原三 transport 恒 null——字段值收正)
138
140
  usage: {
139
141
  prompt_tokens: usage.prompt_tokens ?? 0,
140
142
  completion_tokens: usage.completion_tokens ?? 0,
@@ -144,7 +146,15 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
144
146
  }
145
147
  }
146
148
 
147
- return { content: result.content, reasoning: result.reasoning, toolCalls: result.toolCalls }
149
+ return { content: result.content, reasoning: result.reasoning, finishReason: result.finishReason ?? null, toolCalls: result.toolCalls }
150
+ }
151
+
152
+ /** Gemini `finishReason` ⇒ 核统一 `finishReason`(§6.31.7 F-DA5——归一只喂既有分类;
153
+ * 余者原样透传)。缺席 ⇒ null(缺席支 = agent/run-stages.mjs 同面提醒 + 留痕)。 */
154
+ function mapGeminiFinishReason(reason) {
155
+ if (reason === "STOP") return "stop"
156
+ if (reason === "MAX_TOKENS") return "length"
157
+ return reason
148
158
  }
149
159
 
150
160
  /**
@@ -152,7 +162,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
152
162
  * Format: data: {...}\n\n (each line is a complete JSON object)
153
163
  */
154
164
  async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
155
- const result = { content: "", reasoning: "", toolCalls: [], usage: null }
165
+ const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
156
166
  const decoder = new TextDecoder()
157
167
  let buffer = ""
158
168
 
@@ -171,6 +181,8 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
171
181
 
172
182
  const candidate = json.candidates?.[0]
173
183
  if (!candidate) return
184
+ // §6.31.7 F-DA5(#929 · 批 digest-accounting):`candidate.finishReason` 捕获——缺席 ⇒ 留 null(窄口)
185
+ if (candidate.finishReason) result.finishReason = mapGeminiFinishReason(candidate.finishReason)
174
186
 
175
187
  const parts = candidate.content?.parts || []
176
188
  for (const part of parts) {
@@ -197,10 +209,14 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
197
209
  // 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续 120s 无新 chunk 判死
198
210
  const READ_IDLE_MS = 120_000
199
211
  let idleTimer = null
212
+ let idleError = null
200
213
  const armIdle = () => {
201
214
  if (idleTimer) clearTimeout(idleTimer)
202
215
  idleTimer = setTimeout(() => {
203
- try { response.body?.destroy(timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "google-sse-idle")) } catch { /* already gone */ }
216
+ // #878:断流单点分流——直连 fetch(web 流)走 `IDLE_ABORT` 内部 abort 通道;
217
+ // proxy 形回落 destroyBody(原契约)。相位串 = 本腿专属 google-sse-idle。
218
+ idleError = timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "google-sse-idle")
219
+ try { terminateBody(response, idleError) } catch { /* already gone */ }
204
220
  }, READ_IDLE_MS)
205
221
  idleTimer.unref?.()
206
222
  }
@@ -238,6 +254,16 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
238
254
  result.interruptMessage = signal.reason.message
239
255
  return result
240
256
  }
257
+ // #878:看门狗终结两径归一(idleFired)——无内容 ⇒ idle 超时错误;有内容 ⇒ partial
258
+ //(已流出内容保留,不整轮报废)。非 idle 错误照旧走下方 hasPartial 径(原语义零变)。
259
+ if (idleError) {
260
+ if (result.content || result.reasoning || result.toolCalls.length) {
261
+ result.partial = true
262
+ result.networkError = idleError.message ?? String(idleError)
263
+ return result
264
+ }
265
+ throw idleError
266
+ }
241
267
  if (hasPartial(e)) {
242
268
  result.partial = true
243
269
  result.networkError = e.message ?? String(e)
@@ -251,7 +277,8 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
251
277
  return result
252
278
  }
253
279
 
254
- /** google.mjs 无 hasChoices 追踪——只有流中途死且已有内容才标 partial(同 sse.mjs 语义的简化版) */
280
+ /** google.mjs 无 hasChoices 追踪——非 idle 网络错误按文案判 partial(同 sse.mjs 语义的简化版);
281
+ * idle 径两分(有内容 ⇒ partial ∥ 无内容 ⇒ 超时错误)归 catch(#878)。 */
255
282
  function hasPartial(e) {
256
283
  return /ECONNRESET|terminated|idle timeout|network/i.test(e?.message ?? "")
257
284
  }
@@ -10,6 +10,63 @@
10
10
 
11
11
  const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
12
12
 
13
+ /** 历史图片累计字节预算(D-PR32——40MB 级请求体的保守下界,留两张大图余量;可调)。 */
14
+ export const IMAGE_HISTORY_BYTES_BUDGET = 32 * 1024 * 1024
15
+
16
+ /** 图片驱逐占位文本(D-PR32 逐字)。 */
17
+ const IMAGE_BUDGET_PLACEHOLDER = "[image omitted — dropped to keep the request under the history image budget]"
18
+
19
+ /** 单 part 的 base64 载荷长度(仅 data URL;非 data 引用不过请求体、零计账)。 */
20
+ function imagePartBytes(p) {
21
+ if (p?.type !== "image_url") return 0
22
+ const url = p.image_url?.url
23
+ if (typeof url !== "string" || !url.startsWith("data:")) return 0
24
+ const comma = url.indexOf(",")
25
+ return comma >= 0 ? url.length - comma - 1 : 0
26
+ }
27
+
28
+ /**
29
+ * 历史图片累计字节预算(D-PR32):全部 image part 的 base64 载荷和超 budgetBytes 时,
30
+ * 从**最老**图起替换为占位文本,直到剩余 ≤ 预算。纯函数 + 拷贝语义(未改动时返回原引用;
31
+ * 改动时仅重建涉及的消息对象)——发送前对消息副本用;注入面对 `history` 本体回写(同函数同预算)。
32
+ * @param {Array} messages
33
+ * @param {number} [budgetBytes]
34
+ */
35
+ export function capHistoryImages(messages, budgetBytes = IMAGE_HISTORY_BYTES_BUDGET) {
36
+ if (!Array.isArray(messages)) return messages
37
+ const hits = []
38
+ let total = 0
39
+ for (let mi = 0; mi < messages.length; mi++) {
40
+ const parts = messages[mi]?.content
41
+ if (!Array.isArray(parts)) continue
42
+ for (let pi = 0; pi < parts.length; pi++) {
43
+ const bytes = imagePartBytes(parts[pi])
44
+ if (bytes <= 0) continue
45
+ total += bytes
46
+ hits.push({ mi, pi, bytes })
47
+ }
48
+ }
49
+ if (total <= budgetBytes || hits.length === 0) return messages
50
+ const drop = new Set()
51
+ let remaining = total
52
+ for (const hit of hits) {
53
+ if (remaining <= budgetBytes) break
54
+ drop.add(`${hit.mi}:${hit.pi}`)
55
+ remaining -= hit.bytes
56
+ }
57
+ return messages.map((m, mi) => {
58
+ const parts = m?.content
59
+ if (!Array.isArray(parts)) return m
60
+ let changed = false
61
+ const next = parts.map((p, pi) => {
62
+ if (!drop.has(`${mi}:${pi}`)) return p
63
+ changed = true
64
+ return { type: "text", text: IMAGE_BUDGET_PLACEHOLDER }
65
+ })
66
+ return changed ? { ...m, content: next } : m
67
+ })
68
+ }
69
+
13
70
  export function stripImagesForTextModel(messages, spec) {
14
71
  let changed = false
15
72
  const out = messages.map((m) => {
@@ -196,7 +196,9 @@ export function buildBody(provider, messages, tools, opts = {}) {
196
196
  store: wantStateful && hostStateful && isStoreRequiredHost(provider.baseURL),
197
197
  ...(extracted ? { instructions: extracted } : {}),
198
198
  ...(toolsFlat ? { tools: toolsFlat } : {}),
199
- ...(spec.maxOutput || provider.maxTokens ? { max_output_tokens: provider.maxTokens ?? spec.maxOutput } : {}),
199
+ // 显式才发(#907-D5):仅 provider.maxTokens 显式设定时发送——spec.maxOutput 是规格侧
200
+ // 保守猜测非渠道合同值,恒发 = 渠道口径漂移 400 风险;无显式值 ⇒ 服务端默认(模型上限兜底)。
201
+ ...(provider.maxTokens != null ? { max_output_tokens: provider.maxTokens } : {}),
200
202
  }
201
203
  // 内置工具声明追加(2026-08-31 用户拍板):web_search 与本地 function 工具共存
202
204
  const builtin = builtinToolsFor(provider.baseURL, provider.builtinTools)
@@ -16,6 +16,7 @@ import { proxyFetch } from "../proxy.mjs"
16
16
  import { requestWithRetry } from "./retry.mjs"
17
17
  import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
18
18
  import { buildBody, normalizeUsage } from "./responses-request.mjs"
19
+ import { classifyResponsesErrorCode } from "./errors.mjs"
19
20
  // 同名 re-export(2026-09-29 structure-split-2 · 台账 #620——缝 = 同名再出口):请求构造面出档 `provider/responses-request.mjs`;
20
21
  // `buildBody` / `isStoreRequiredHost` / `builtinToolsFor` 三名既有导出经本档转口——消费者 import 面零改。
21
22
  export { buildBody, isStoreRequiredHost, builtinToolsFor } from "./responses-request.mjs"
@@ -27,6 +28,19 @@ export function isChainInvalidError(status) {
27
28
  return status === 404 || status === 400
28
29
  }
29
30
 
31
+ /** #907-D9/D8:error 帧处置回调(handleEvent 消费)——按 D8 码级标注定形后抛「标记错误」,catch 双支零丢失转发。 */
32
+ function errFrameError(ev) {
33
+ const err = ev?.error
34
+ const msg = err?.message ?? JSON.stringify(err ?? ev ?? {}).slice(0, 300)
35
+ const cls = classifyResponsesErrorCode(err?.code)
36
+ const e = new Error(cls?.hint ? `${msg}(${cls.hint})` : msg)
37
+ if (cls?.kind) e.errorKind = cls.kind
38
+ e.retryable = cls?.retryable ?? false
39
+ if (typeof err?.code === "number") e.status = err.code
40
+ e.responsesErrFrame = true // #907-D9 哨兵标记(不用 message 锚定——消息必须原样保留)
41
+ return e
42
+ }
43
+
30
44
  /**
31
45
  * Responses 事件流解析(事件状态机)。输出与 chat completions 同形:
32
46
  * { content, reasoning, toolCalls, usage, finishReason, interrupted? }
@@ -67,17 +81,21 @@ export async function parseStream(response, { onToken, onReasoning, signal }) {
67
81
  if (!slot) return
68
82
  if (fullArgs && fullArgs !== slot.arguments) slot.arguments = fullArgs
69
83
  },
70
- onCompleted: seal,
84
+ onCompleted: (resp) => { result.finishReason = "stop"; seal(resp) }, // §6.31.7 F-DA5:completed ⇒ stop(原恒 null ⇒ agent 面缺席)
71
85
  onIncomplete: (resp) => {
72
86
  seal(resp)
73
87
  // 非长度原因(content_filter 等)不能报成 "length"——agent 层按原因给用户提示(`agent/run-stages.mjs:43`)
74
88
  result.finishReason = resp?.incomplete_details?.reason === "content_filter" ? "content_filter" : "length"
75
89
  },
76
90
  onFailed: (resp) => {
91
+ // #907-D8:码级标注——errorKind/retryable 信息位 ∥ hint 拼入消息 ∥ status 仅数值(字符串码塞 status 是缺陷)
77
92
  const err = resp?.error
78
93
  const msg = err?.message ?? JSON.stringify(err ?? {}).slice(0, 500)
79
- const e = new Error(`responses API failed: ${msg}`)
80
- e.status = resp?.error?.code
94
+ const cls = classifyResponsesErrorCode(err?.code)
95
+ const e = new Error(cls?.hint ? `responses API failed: ${msg}(${cls.hint})` : `responses API failed: ${msg}`)
96
+ if (cls?.kind) e.errorKind = cls.kind
97
+ e.retryable = cls?.retryable ?? false
98
+ if (typeof err?.code === "number") e.status = err.code
81
99
  throw e
82
100
  },
83
101
  })
@@ -88,6 +106,18 @@ export async function parseStream(response, { onToken, onReasoning, signal }) {
88
106
  seal(null)
89
107
  return { ...result, interrupted: true, interruptMessage: signal.reason.message }
90
108
  }
109
+ // #907-D9:error 帧双支——有流出(content ∥ toolCalls)⇒ partial 抢救返回;无流出 ⇒ 转抛(上抛为主)。
110
+ if (e?.responsesErrFrame) {
111
+ if (result.content.length > 0 || result.toolCalls.length > 0) {
112
+ const existing = result._warnings ??= []
113
+ if (!existing.some((w) => w.name === "responses-error-frame")) existing.push({ name: "responses-error-frame", message: e.message })
114
+ result.partial = true
115
+ result.errorDetail = e.message.slice(0, 300)
116
+ seal(null) // 无 responseId ⇒ finish() 链重置自动生效(错误后下轮全量重发)
117
+ return result
118
+ }
119
+ throw e
120
+ }
91
121
  throw e
92
122
  }
93
123
 
@@ -185,6 +215,8 @@ function handleEvent(ev, h) {
185
215
  case "response.failed":
186
216
  h.onFailed?.(ev.response)
187
217
  break
218
+ case "error": // #907-D9:标准 error 帧(原 default 静默跳过 = 空内容当回复——本件修复)
219
+ throw errFrameError(ev)
188
220
  default:
189
221
  break
190
222
  }
@@ -268,6 +300,8 @@ async function finish(provider, response, { onToken, onReasoning, signal, newCha
268
300
  } else {
269
301
  provider._responsesChain = null
270
302
  }
271
- result._warnings = warnings
303
+ // #907-D9:合并而非覆盖——parseStream partial 抢救支已注入 responses-error-frame 警告
304
+ if (result._warnings?.length) result._warnings = [...warnings, ...result._warnings]
305
+ else result._warnings = warnings
272
306
  return result
273
307
  }
package/provider/sse.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  * Extracted from core.mjs. Parses Server-Sent Events for LLM chat responses.
4
4
  */
5
5
  import { abortError, timeoutError } from "../abort-provenance.mjs"
6
+ import { terminateBody } from "../stream-destroy.mjs"
6
7
 
7
8
  /**
8
9
  * Normalize provider cache fields into DeepSeek-style prompt_cache_hit/miss_tokens.
@@ -20,29 +21,43 @@ export function normalizeUsageCache(u) {
20
21
  }
21
22
  return u
22
23
  }
24
+ /** 帧形状分派用前缀补差(D-PR31):全同 ⇒ ""(零再增);已收为前缀 ⇒ 只补差量;
25
+ * 无前缀关系 ⇒ 整段按增量追加(不吞真实内容——无法判定时不丢文本)。 */
26
+ function snapshotSuffix(accumulated, text) {
27
+ if (text === accumulated) return ""
28
+ if (text.startsWith(accumulated)) return text.slice(accumulated.length)
29
+ return text
30
+ }
31
+
23
32
  /** Defensive tool-call merge (PROVIDER.md §10): skip null/malformed elements and count them;
24
- * merge slots by index / id / name / tail, accumulate arguments. */
25
- function mergeToolCalls(result, delta) {
33
+ * merge slots by index / id / name / tail, accumulate arguments.
34
+ * `snapshot: true`(D-PR31——`choice.message` 完整快照帧):已有 slot(index / id 命中)的
35
+ * arguments **覆盖**(非 +=)——修快照帧二次拼接;新 slot 照常建、其余路径零变。 */
36
+ function mergeToolCalls(result, delta, { snapshot = false } = {}) {
26
37
  for (const tc of delta.tool_calls ?? []) {
27
38
  if (!tc || typeof tc !== "object") { result.droppedToolCalls++; continue }
28
39
  let slot
40
+ let existed = false
29
41
  if (Number.isInteger(tc.index) && tc.index >= 0) {
42
+ existed = result.toolCalls[tc.index] != null
30
43
  slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
31
44
  } else if (tc.id) {
32
45
  slot = result.toolCalls.find((s) => s && s.id === tc.id)
46
+ existed = !!slot
33
47
  if (!slot) { slot = { id: tc.id, name: "", arguments: "" }; result.toolCalls.push(slot) }
34
48
  } else if (tc.function?.name) {
35
49
  slot = { id: "", name: "", arguments: "" }
36
50
  result.toolCalls.push(slot)
37
51
  } else {
38
52
  slot = result.toolCalls[result.toolCalls.length - 1]
53
+ existed = !!slot
39
54
  if (!slot) { result.droppedToolCalls++; continue }
40
55
  }
41
56
  if (tc.id && !slot.id) slot.id = tc.id
42
57
  if (tc.function?.name && !slot.name) slot.name = tc.function.name
43
58
  const arg = tc.function?.arguments
44
- if (typeof arg === "string") slot.arguments += arg
45
- else if (arg != null) slot.arguments += JSON.stringify(arg)
59
+ if (typeof arg === "string") slot.arguments = snapshot && existed ? arg : slot.arguments + arg
60
+ else if (arg != null) slot.arguments = snapshot && existed ? JSON.stringify(arg) : slot.arguments + JSON.stringify(arg)
46
61
  }
47
62
  }
48
63
 
@@ -135,19 +150,29 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
135
150
  hasChoices = true
136
151
  if (choice.finish_reason) result.finishReason = choice.finish_reason
137
152
 
138
- const delta = choice.delta ?? choice.message ?? {}
153
+ // 帧形状分派(D-PR31):`delta == null && message != null` = 完整快照帧(MiniMax v2 类端点)——
154
+ // content / reasoning 前缀补差、tool_calls 覆盖;`delta` 在场 = 纯增量语义照旧
155
+ //(真增量文本天然可重复——不做去重)。
156
+ const snapshot = choice.delta == null && choice.message != null
157
+ const delta = snapshot ? choice.message : (choice.delta ?? {})
139
158
  // reasoning 方言:DeepSeek/Kimi/GLM 用 reasoning_content,OpenAI o 系和部分
140
159
  // 路由器用 reasoning —— 两个都认(2026-08-31 会诊 #9)
141
160
  const rDelta = delta.reasoning_content ?? delta.reasoning
142
161
  if (rDelta) {
143
- result.reasoning += rDelta
144
- onReasoning?.(rDelta)
162
+ const add = snapshot ? snapshotSuffix(result.reasoning, rDelta) : rDelta
163
+ if (add) {
164
+ result.reasoning += add
165
+ onReasoning?.(add)
166
+ }
145
167
  }
146
168
  if (delta.content) {
147
- result.content += delta.content
148
- onToken?.(delta.content)
169
+ const add = snapshot ? snapshotSuffix(result.content, delta.content) : delta.content
170
+ if (add) {
171
+ result.content += add
172
+ onToken?.(add)
173
+ }
149
174
  }
150
- mergeToolCalls(result, delta)
175
+ mergeToolCalls(result, delta, { snapshot })
151
176
  }
152
177
 
153
178
  const processLines = (lines) => {
@@ -172,10 +197,14 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
172
197
  // 直连 fetch 与 proxyFetch 统一走这里(proxy 的 _bodyIdleMs 语义与之等价,双保险)。
173
198
  const READ_IDLE_MS = 120_000
174
199
  let idleTimer = null
200
+ let idleError = null
175
201
  const armIdle = () => {
176
202
  if (idleTimer) clearTimeout(idleTimer)
177
203
  idleTimer = setTimeout(() => {
178
- try { response.body?.destroy(timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "sse-idle")) } catch { /* already gone */ }
204
+ // #878:断流单点分流——直连 fetch(web 流)走 `IDLE_ABORT` 内部 abort 通道,
205
+ // proxy 形回落 destroyBody(原契约)。
206
+ idleError = timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "sse-idle")
207
+ try { terminateBody(response, idleError) } catch { /* already gone */ }
179
208
  }, READ_IDLE_MS)
180
209
  idleTimer.unref?.()
181
210
  }
@@ -224,20 +253,23 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
224
253
  if (idleTimer) clearTimeout(idleTimer)
225
254
  return result
226
255
  }
256
+ // #878:看门狗终结归类归一(abort 通道下底层错误形不定)——无内容 ⇒ idle 超时错误;
257
+ // 有部分内容 ⇒ partial + networkError(原径语义保持)。
258
+ const err = idleError ?? e
227
259
  // 2026-08-31 会诊 #2(流中断丢全部已收内容):网络级失败(ECONNRESET / 半截 EOF /
228
260
  // proxy 断连)时若已解析出内容,把 partial 交回上层而不是整轮报废重试。
229
261
  // 标记 partial:true + networkError(上层可决定续写/重试/展示部分结果)。
230
262
  if (hasChoices && (result.content || result.toolCalls.length)) {
231
263
  finalizeToolCalls(result)
232
264
  result.partial = true
233
- result.networkError = e.message ?? String(e)
265
+ result.networkError = err.message ?? String(err)
234
266
  const existing = result._warnings ??= []
235
267
  if (!existing.some((w) => w.name === "network-partial")) {
236
268
  existing.push({ name: "network-partial", message: `stream interrupted by network error after partial output: ${result.networkError}` })
237
269
  }
238
270
  return result
239
271
  }
240
- throw e
272
+ throw err
241
273
  }
242
274
 
243
275
  if (idleTimer) clearTimeout(idleTimer)
package/proxy.mjs CHANGED
@@ -7,6 +7,7 @@ import { connect as tlsConnect } from "node:tls";
7
7
  import { PassThrough } from "node:stream";
8
8
  import { URL } from "node:url";
9
9
  import { abortError, timeoutError } from "./abort-provenance.mjs";
10
+ import { IDLE_ABORT, destroyBody } from "./stream-destroy.mjs";
10
11
 
11
12
  const FETCH_TIMEOUT = 15_000
12
13
 
@@ -91,12 +92,12 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
91
92
  function fail(err) {
92
93
  cleanup()
93
94
  if (!settled) { settled = true; reject(err) }
94
- else body.destroy(err)
95
+ else destroyBody(body, err)
95
96
  }
96
97
  /** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
97
98
  function armIdle() {
98
99
  clearTimeout(idleTimer)
99
- if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(timeoutError("Response body timeout (idle)", "provider", "proxy-body-idle")), bodyIdleMs)
100
+ if (bodyIdleMs > 0) idleTimer = setTimeout(() => destroyBody(body, timeoutError("Response body timeout (idle)", "provider", "proxy-body-idle")), bodyIdleMs)
100
101
  }
101
102
 
102
103
  sock.on("data", (d) => {
@@ -261,9 +262,18 @@ function tcpConnectProxy(proxyUri, signal, timeout) {
261
262
  /**
262
263
  * Generic fetch with proxy support.
263
264
  * No proxy → native fetch. Proxy + HTTPS → CONNECT tunnel. Proxy + HTTP → 经典代理转发(绝对 URI 请求行)。
265
+ * 直连分支 = 内部 abort 通道建设点(#878 D-PX8):建 `AbortController` + signal 合成(`AbortSignal.any`,
266
+ * 无 user signal 时单独用)后经 `IDLE_ABORT` 挂 response——web `ReadableStream` 的读侧看门狗
267
+ * (`readSSE` ∥ `parseGeminiStream`)经 `terminateBody` 恢复有效;proxy 两分支不挂(自有 `_bodyIdleMs`)。
264
268
  */
265
269
  export async function proxyFetch(urlStr, opts, proxyUri) {
266
- if (!proxyUri) return globalThis.fetch(urlStr, opts)
270
+ if (!proxyUri) {
271
+ const idleAbort = new AbortController()
272
+ const signal = opts?.signal ? AbortSignal.any([opts.signal, idleAbort.signal]) : idleAbort.signal
273
+ const response = await globalThis.fetch(urlStr, { ...opts, signal })
274
+ response[IDLE_ABORT] = idleAbort
275
+ return response
276
+ }
267
277
  const target = new URL(urlStr)
268
278
  if (target.protocol === "https:") return tunnelHttps(urlStr, opts, proxyUri)
269
279
  // http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
package/session.mjs CHANGED
@@ -106,8 +106,11 @@ function digestFromStore(fields, counters, mtimeMs) {
106
106
  * Restore now always rebuilds from history (lazy, see startup.mjs).
107
107
  * 2026-08-31 会诊 F1:slot 粘性——首次认领后缓存 agent._slot,永不重跑 ensureActive
108
108
  * (原实现每次保存重推,manifest active 被并发方翻动时会话静默迁移)。
109
- * 返回轮转的 .bak 路径或 null。 */
110
- export function saveSession(agent) {
109
+ * 返回轮转的 .bak 路径或 null。
110
+ * **`opts.prefsSeedOnly`(加性 · 2026-10-04 解锁批 · R2——唯一传者 = 桌面两落盘点)**:回合关联落盘
111
+ * **不携会话级三键**(`provider` / `model` / `effort`)——写前读槽:复合在场(`activeProvider` 非空串 ∧
112
+ * `activeModel` 真值)⇒ 槽值赢 ∥ 缺 ⇒ 记忆值播种;`effort` 单键在场(值可 `null`)⇒ 槽值保留。 */
113
+ export function saveSession(agent, opts = {}) {
111
114
  // _fullHistory is written at the source via pushReal — no flush needed here.
112
115
  // history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
113
116
  // contextHistory = machine context (possibly compacted) so CLI resume keeps the token savings
@@ -168,6 +171,14 @@ export function saveSession(agent) {
168
171
  // 原值——`"off"` 记号原样写盘,不回填为具体档);缺之 ⇒ 下一次回合保存把槽写面结果整对象抹除。
169
172
  // `undefined` = 本会话未携带(老槽无该键 / 未经 `applySession`)⇒ 键不落盘(禁回填)。
170
173
  if (agent._slotEffort !== undefined) fields.effort = agent._slotEffort
174
+ // R2(2026-10-04 解锁批 · 加性 `prefsSeedOnly`):回合关联落盘**不携三键**——写前读槽(守卫后:轮转现场不参与判据),
175
+ // 复合在场(`activeProvider` 非空串 ∧ `activeModel` 真值)⇒ 槽值赢(防起跑快照覆写忙期新选定)∥ 缺 ⇒ 记忆值播种。
176
+ if (opts.prefsSeedOnly === true) {
177
+ const onDisk = loadSlotFile(agent.cwd, slot) ?? {}
178
+ const compositeOn = typeof onDisk.activeProvider === "string" && onDisk.activeProvider !== "" && Boolean(onDisk.activeModel)
179
+ if (compositeOn) Object.assign(fields, { activeProvider: onDisk.activeProvider, activeModel: onDisk.activeModel })
180
+ if (Object.hasOwn(onDisk, "effort")) fields.effort = onDisk.effort ?? null
181
+ }
171
182
  // 绑定态 → 流式投影(段原文拼接——VSC 兼容面逐字同形);未绑定 → 既有全量物化写
172
183
  if (agent._recordStore) saveProjectedSlot(agent, p, fields, contextHistory)
173
184
  else writeSessionFile(p, { ...fields, history: legacyHistory(agent), contextHistory })