@thincoder/core 0.10.10 → 0.10.11
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.
- package/CHANGELOG.md +28 -2
- package/agent/completion.mjs +4 -1
- package/agent/family-tools.mjs +3 -3
- package/agent/helpers.mjs +13 -11
- package/agent/light-round.mjs +1 -4
- package/agent/record-results.mjs +6 -1
- package/agent/setup-reminders.mjs +1 -1
- package/agent/unbooked-pen.mjs +62 -0
- package/agent-tools/advisor.mjs +15 -31
- package/agent-tools/audit-block.mjs +16 -62
- package/agent-tools/batch-gate.mjs +10 -18
- package/agent-tools/context.mjs +1 -1
- package/agent-tools/design-token.mjs +9 -2
- package/agent-tools/digest-budget.mjs +2 -0
- package/agent-tools/doc-check.mjs +3 -14
- package/agent-tools/manifest.mjs +3 -4
- package/agent-tools/review-facts.mjs +35 -81
- package/agent-tools/settings.mjs +65 -5
- package/agent-tools/spawn-gates.mjs +68 -31
- package/agent-tools/subagent-async.mjs +6 -3
- package/agent-tools/subagent-spawn.mjs +42 -61
- package/agent-tools/subagent.mjs +15 -15
- package/browser/launch.mjs +14 -3
- package/browser/queue.mjs +31 -3
- package/browser/session.mjs +4 -0
- package/config-defaults.mjs +8 -0
- package/config-presets.mjs +1 -1
- package/config.mjs +60 -11
- package/doc-check/anchors.mjs +10 -9
- package/doc-check/index.mjs +27 -23
- package/doc-check/paragraph.mjs +119 -0
- package/fts-text.mjs +4 -1
- package/ledger-cmd.mjs +17 -4
- package/ledger-db.mjs +14 -8
- package/ledger.mjs +1 -1
- package/manifest-discovery.mjs +19 -10
- package/manifest.mjs +17 -5
- package/model-context.mjs +98 -0
- package/model-specs.mjs +110 -37
- package/package.json +2 -2
- package/permission.mjs +3 -1
- package/prompts/advisor-design.md +15 -0
- package/prompts/common.md +1 -0
- package/prompts/discipline-engineering.md +27 -6
- package/prompts/discipline-normal.md +7 -7
- package/prompts/persona-eng-designer.md +16 -4
- package/prompts/persona-engineering.md +16 -27
- package/provider/anthropic.mjs +127 -4
- package/provider/core.mjs +16 -6
- package/provider/google.mjs +8 -8
- package/provider/list-models.mjs +37 -5
- package/provider/normalize.mjs +2 -2
- package/provider/responses.mjs +7 -7
- package/provider/sse.mjs +6 -5
- package/proxy-transport.mjs +5 -3
- package/proxy.mjs +2 -1
- package/session-guard.mjs +7 -1
- package/session-lifecycle.mjs +23 -8
- package/session-segments.mjs +1 -0
- package/session-slot-claims.mjs +4 -0
- package/session-slot-write.mjs +6 -0
- package/session-slots-manifest.mjs +4 -0
- package/token-ttl.mjs +10 -0
- package/tool-docs/advisor.md +24 -5
- package/tool-docs/doc_check.md +23 -16
- package/tool-docs/ledger.md +2 -2
- package/tool-docs/subagent.md +41 -16
- package/tools/web-deepseek.mjs +26 -11
- package/doc-check/version.mjs +0 -84
- package/doc-check/width.mjs +0 -87
package/provider/anthropic.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { specForModel, requestBudget } from "../config.mjs"
|
|
8
8
|
import { proxyFetch } from "../proxy.mjs"
|
|
9
9
|
import { requestWithRetry } from "./retry.mjs"
|
|
10
|
-
import { effectiveFetchTimeoutMs } from "./core.mjs"
|
|
10
|
+
import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
|
|
11
11
|
import { abortError } from "../abort-provenance.mjs"
|
|
12
12
|
|
|
13
13
|
const ANTHROPIC_VERSION = "2023-06-01"
|
|
@@ -32,6 +32,123 @@ export function normalizeTools(tools) {
|
|
|
32
32
|
}))
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// PROVIDER.md §6.27 常量:告警码唯一发射点 = R7 分支;占位文案由批内单测逐字锁定。
|
|
36
|
+
const TOOL_ARGS_MALFORMED = "anthropic-tool-args-malformed"
|
|
37
|
+
const CONTINUED_HISTORY_TEXT = "[conversation continued from the history below]"
|
|
38
|
+
const EMPTY_TOOL_RESULT = "(no output)"
|
|
39
|
+
const RAW_ARGS_EXCERPT = 80
|
|
40
|
+
|
|
41
|
+
/** R6 ∥ R7:OpenAI 形 `arguments` → Anthropic `input`。
|
|
42
|
+
* 缺 ∥ 空串 ∥ null ⇒ `{}`(R6,无告警);非法 JSON ∥ 解析结果非普通对象 ⇒ `{}` + 告警(R7)。
|
|
43
|
+
* 不抛:历史是持久的,抛错会让该会话此后每轮必死;真实结果仍在 tool_result 里(D-PR42)。 */
|
|
44
|
+
function toolInputOf(raw, toolName, warnings) {
|
|
45
|
+
if (raw == null || raw === "") return {}
|
|
46
|
+
let parsed
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw)
|
|
49
|
+
} catch {
|
|
50
|
+
parsed = undefined
|
|
51
|
+
}
|
|
52
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
53
|
+
warnings.push({
|
|
54
|
+
name: TOOL_ARGS_MALFORMED,
|
|
55
|
+
message: `tool "${toolName}" arguments are not a JSON object — input sent as {} (raw: ${String(raw).slice(0, RAW_ARGS_EXCERPT)})`,
|
|
56
|
+
})
|
|
57
|
+
return {}
|
|
58
|
+
}
|
|
59
|
+
return parsed
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** R12:`image_url` part → Anthropic image 块(data URL ⇒ base64 source;其余 ⇒ url source)。 */
|
|
63
|
+
function imageBlockOf(part) {
|
|
64
|
+
const url = part.image_url?.url || ""
|
|
65
|
+
const data = url.match(/^data:([^;,]+);base64,(.*)$/s)
|
|
66
|
+
if (data) return { type: "image", source: { type: "base64", media_type: data[1], data: data[2] } }
|
|
67
|
+
return { type: "image", source: { type: "url", url } }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** R2–R5 ∥ R12 ∥ 表外默认:单个 OpenAI part → Anthropic 块。 */
|
|
71
|
+
function blockOfPart(part) {
|
|
72
|
+
if (!part || typeof part !== "object") return null
|
|
73
|
+
if (part.type === "text") return { type: "text", text: part.text }
|
|
74
|
+
if (part.type === "image_url") return imageBlockOf(part)
|
|
75
|
+
// 表外默认:未列出的 part 类型原样直通(上游按类型拒收 = 显式失败),本层不静默丢弃
|
|
76
|
+
return { ...part }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** R8:tool 消息的 `content` → tool_result 内容;空 ∥ 缺失 ⇒ 占位(`record-results.mjs:85` 原样推,无 stdout 的成功调用 = "")。 */
|
|
80
|
+
function toolResultText(content) {
|
|
81
|
+
if (typeof content === "string") return content === "" ? EMPTY_TOOL_RESULT : content
|
|
82
|
+
if (content == null) return EMPTY_TOOL_RESULT
|
|
83
|
+
return content // 表外(本仓只产出字符串):原样透传,由上游显式判收
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** R8 ∥ R9 ∥ R10:user 消息内 `tool_result` 恒在其它块之前(官方约束,§6.27 R8)。 */
|
|
87
|
+
function toolResultsFirst(blocks) {
|
|
88
|
+
if (!blocks.some((b) => b.type === "tool_result")) return blocks
|
|
89
|
+
return [...blocks.filter((b) => b.type === "tool_result"), ...blocks.filter((b) => b.type !== "tool_result")]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* PROVIDER.md §6.27:OpenAI 形态会话历史 → Anthropic Messages API 形态(纯函数)。
|
|
94
|
+
* 返回 `{ messages, warnings }`(I5 ∥ I6);入参零写——历史是四条通道共用的持久表示。
|
|
95
|
+
* system 已由 `chat()` 抽到顶层(R1),本函数只处理其余消息。
|
|
96
|
+
*/
|
|
97
|
+
export function convertMessages(messages) {
|
|
98
|
+
const out = []
|
|
99
|
+
const warnings = []
|
|
100
|
+
const push = (role, blocks) => {
|
|
101
|
+
if (blocks.length === 0) return // R13:空块消息丢弃(上游拒收空 content 数组——I3)
|
|
102
|
+
const ordered = role === "user" ? toolResultsFirst(blocks) : blocks
|
|
103
|
+
const last = out[out.length - 1]
|
|
104
|
+
if (last && last.role === role) {
|
|
105
|
+
// R10:转换后相邻同 role 合并,块按原序拼接(user 侧 tool_result 仍恒在前)
|
|
106
|
+
last.content = role === "user" ? toolResultsFirst([...last.content, ...ordered]) : [...last.content, ...ordered]
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
out.push({ role, content: ordered })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const m of messages) {
|
|
113
|
+
if (!m || typeof m !== "object") continue
|
|
114
|
+
if (m.role === "system") continue // R1:system 由 chat() 抽到顶层,永不进 messages
|
|
115
|
+
if (m.role === "tool") {
|
|
116
|
+
// R8 ∥ R9:tool 消息 ⇒ 含 tool_result 块的 user 消息(连续多条经 push 的合并自然并入同一条)
|
|
117
|
+
push("user", [{ type: "tool_result", tool_use_id: m.tool_call_id, content: toolResultText(m.content) }])
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
const blocks = []
|
|
121
|
+
if (typeof m.content === "string") {
|
|
122
|
+
if (m.content !== "") blocks.push({ type: "text", text: m.content }) // R13:空串 ⇒ 零块
|
|
123
|
+
} else if (Array.isArray(m.content)) {
|
|
124
|
+
for (const part of m.content) {
|
|
125
|
+
const block = blockOfPart(part)
|
|
126
|
+
if (block) blocks.push(block)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (m.role === "assistant") {
|
|
130
|
+
// R5:tool_calls 追加 tool_use 块;文本块在前、tool_use 块在后
|
|
131
|
+
for (const tc of m.tool_calls ?? []) {
|
|
132
|
+
if (!tc || typeof tc !== "object") continue
|
|
133
|
+
blocks.push({
|
|
134
|
+
type: "tool_use",
|
|
135
|
+
id: tc.id,
|
|
136
|
+
name: tc.function?.name,
|
|
137
|
+
input: toolInputOf(tc.function?.arguments, tc.function?.name, warnings),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// user ∥ 未列出的 role:按 user 收口(出口 role 只允许 user/assistant——I1)
|
|
142
|
+
push(m.role === "assistant" ? "assistant" : "user", blocks)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// R11:首条须 user(精简历史可 assistant 开头);全空输入不造占位消息(I1)
|
|
146
|
+
if (out.length > 0 && out[0].role !== "user") {
|
|
147
|
+
out.unshift({ role: "user", content: [{ type: "text", text: CONTINUED_HISTORY_TEXT }] })
|
|
148
|
+
}
|
|
149
|
+
return { messages: out, warnings }
|
|
150
|
+
}
|
|
151
|
+
|
|
35
152
|
/** Build and send an Anthropic chat request. Returns the same shape as core.mjs chat.
|
|
36
153
|
* 2026-08-31 会诊 #6:接入 rateGate/recordRate + 429 Retry-After 单次重试
|
|
37
154
|
* (原实现完全绕过 TPM/RPM 闸门与记账——用户配了 tpm 以为受控实际不受控)。
|
|
@@ -48,10 +165,13 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
48
165
|
}
|
|
49
166
|
}
|
|
50
167
|
|
|
168
|
+
// PROVIDER.md §6.27:OpenAI 形态历史 → Messages API 形态(system 抽离之后、请求体组装之前)
|
|
169
|
+
const { messages: adaptedMessages, warnings } = convertMessages(chatMessages)
|
|
170
|
+
|
|
51
171
|
const spec = specForModel(provider.model)
|
|
52
172
|
const body = {
|
|
53
173
|
model: provider.model,
|
|
54
|
-
messages:
|
|
174
|
+
messages: adaptedMessages,
|
|
55
175
|
stream: true,
|
|
56
176
|
// §6.17 发送面同源:anthropic 恒发 max_tokens(显式 ?? 规格 maxOutput——原第三级兜底不可达已随本批删净,
|
|
57
177
|
// §6.17「装配面不留第二算式」)
|
|
@@ -102,14 +222,17 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
102
222
|
// 响应头阶段仍用 fetchTimeoutMs(600s 默认),body 阶段由 parseAnthropicStream 读侧 idle 管
|
|
103
223
|
signal,
|
|
104
224
|
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
105
|
-
_bodyIdleMs:
|
|
225
|
+
_bodyIdleMs: effectiveBodyIdleMs(provider),
|
|
106
226
|
}, provider.proxyUri),
|
|
107
227
|
{ signal, onWait, buildMessage: (status, text) => `Anthropic API error ${status}: ${text}` },
|
|
108
228
|
)
|
|
109
229
|
|
|
110
230
|
const result = await parseAnthropicStream(response, { onToken, onReasoning, signal })
|
|
111
231
|
recordRate(provider, estimated, result.usage)
|
|
112
|
-
|
|
232
|
+
const finished = finishAnthropic(result)
|
|
233
|
+
// §6.27 告警投递:R7 的畸形参数告警并入既有 `_warnings` 机读线(消费面 agent/run-stages.mjs:44-45)
|
|
234
|
+
finished._warnings = [...(finished._warnings ?? []), ...warnings]
|
|
235
|
+
return finished
|
|
113
236
|
}
|
|
114
237
|
|
|
115
238
|
/** Anthropic `stop_reason` ⇒ 核统一 `finishReason`(§6.31.7 F-DA5——归一只喂既有分类;
|
package/provider/core.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { providerSpec, requestBudget, resolveEnableThinking } from "../config.mjs"
|
|
8
|
+
import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
|
|
8
9
|
import { resolveProviderSecrets } from "../env-ref.mjs"
|
|
9
10
|
import { proxyFetch } from "../proxy.mjs"
|
|
10
11
|
import { escapeMessages, stripLocalMessageFields } from "../escape.mjs"
|
|
@@ -63,8 +64,7 @@ export function createProvider(config) {
|
|
|
63
64
|
|
|
64
65
|
/** Send a streaming chat completion request with automatic continuation on truncation */
|
|
65
66
|
// 2026-09-01 根因修复:600s 绝对墙钟曾腰斩长上下文子代理(eng-coder TTFB>10min 即死)——TTFB 阶段改用
|
|
66
|
-
// fetchTimeoutMs(默认 600s,agent.fetchTimeoutMs 可配),body 阶段 idle 超时(
|
|
67
|
-
const FETCH_BODY_IDLE_MS = 120_000
|
|
67
|
+
// fetchTimeoutMs(默认 600s,agent.fetchTimeoutMs 可配),body 阶段 idle 超时(agent.bodyIdleTimeoutMs 可配,无新数据才断)。
|
|
68
68
|
/** §6.14 设计值:prefix 续写只保留最近 8 条非工具文本(截断点语境足够,N 以测试锁定) */
|
|
69
69
|
const PREFIX_CONTINUATION_KEEP = 8
|
|
70
70
|
|
|
@@ -73,6 +73,14 @@ export function effectiveFetchTimeoutMs(provider) {
|
|
|
73
73
|
return Number.isFinite(provider?.fetchTimeoutMs) && provider.fetchTimeoutMs > 0 ? provider.fetchTimeoutMs : 600_000
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** body 阶段读侧空闲超时(TIMEOUT-DIGEST.md §2.1 —— 唯一解析点,各消费点同读本函数):
|
|
77
|
+
* 键 `agent.bodyIdleTimeoutMs` 经 loadConfig 提升为 `provider.bodyIdleTimeoutMs`;
|
|
78
|
+
* 非法 ∥ 缺省 ⇒ 回退 DEFAULT_BODY_IDLE_MS(不抛错、不解释成「禁用」)。 */
|
|
79
|
+
export function effectiveBodyIdleMs(provider) {
|
|
80
|
+
return Number.isFinite(provider?.bodyIdleTimeoutMs) && provider.bodyIdleTimeoutMs > 0
|
|
81
|
+
? provider.bodyIdleTimeoutMs : DEFAULT_BODY_IDLE_MS
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
export async function chat(provider, opts = {}) {
|
|
77
85
|
// LOGGING(docs/design/LOGGING.md):llm:* 事件统一在此落点——所有 chat 调用
|
|
78
86
|
// (主回合/消化轮/compress/distill/advisor/子代理/auto-think/consult)都经本函数,
|
|
@@ -143,6 +151,8 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
|
|
|
143
151
|
if (provider.format === "anthropic") {
|
|
144
152
|
const { chat: anthropicChat } = await import("./anthropic.mjs")
|
|
145
153
|
const { normalizeTools } = await import("./anthropic.mjs")
|
|
154
|
+
// §6.27:配对归一见转换前(缺回执的 tool_call 会变成孤儿 tool_use,上游同样拒收)——复用 OpenAI 径同一函数
|
|
155
|
+
messages = normalizeToolPairing(messages)
|
|
146
156
|
const result = await anthropicChat(provider, {
|
|
147
157
|
messages,
|
|
148
158
|
tools: tools?.length ? normalizeTools(tools) : null,
|
|
@@ -243,7 +253,7 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
|
|
|
243
253
|
await rateGate(provider, estimated, onWait, signal)
|
|
244
254
|
|
|
245
255
|
const response = await requestWithRetry(provider, body, signal, onWait)
|
|
246
|
-
const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns })
|
|
256
|
+
const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns, idleMs: effectiveBodyIdleMs(provider) })
|
|
247
257
|
recordRate(provider, estimated, result.usage)
|
|
248
258
|
|
|
249
259
|
// Stream rule triggered, user interrupted, or network partial — return immediately.
|
|
@@ -261,7 +271,7 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
|
|
|
261
271
|
await sleepInterruptible(3000, signal)
|
|
262
272
|
}
|
|
263
273
|
const retryResponse = await requestWithRetry(provider, body, signal, onWait)
|
|
264
|
-
const retryResult = await readSSE(retryResponse, { onToken, onReasoning })
|
|
274
|
+
const retryResult = await readSSE(retryResponse, { onToken, onReasoning, idleMs: effectiveBodyIdleMs(provider) })
|
|
265
275
|
recordRate(provider, estimated, retryResult.usage)
|
|
266
276
|
if (retryResult.finishReason !== "insufficient_system_resource") {
|
|
267
277
|
// Merge any partial content from the failed attempt (streaming already showed it)
|
|
@@ -395,12 +405,12 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
395
405
|
body: JSON.stringify(body),
|
|
396
406
|
// 2026-09-01 根因修复:原 600s 绝对墙钟会腰斩长上下文子代理(TTFB/首 token >10min 即死)。
|
|
397
407
|
// 拆分语义:响应头阶段仍用 fetchTimeoutMs(600s,覆盖网关排队);body 阶段由读侧 idle 超时管
|
|
398
|
-
// (sse.mjs
|
|
408
|
+
// (sse.mjs idleMs——effectiveBodyIdleMs 传入,无新数据才断)。signal 只保留用户取消链,不再叠加绝对墙钟。
|
|
399
409
|
signal,
|
|
400
410
|
// 2026-08-31 会诊 #4:代理路径响应头超时对齐直连语义(原 15s 与直连 600s 割裂,
|
|
401
411
|
// DeepSeek 排队 TTFB>15s 即误报)— 仅 _ 前缀内部字段,proxyFetch 消费
|
|
402
412
|
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
403
|
-
_bodyIdleMs:
|
|
413
|
+
_bodyIdleMs: effectiveBodyIdleMs(provider),
|
|
404
414
|
}
|
|
405
415
|
// #878 D-PX8:请求调用点收口——恒走 `proxyFetch`(无 proxyUri 即直连分支,内部 abort 通道单点建设;
|
|
406
416
|
// 原 call-site 分叉删除)。
|
package/provider/google.mjs
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { proxyFetch } from "../proxy.mjs"
|
|
8
8
|
import { requestWithRetry } from "./retry.mjs"
|
|
9
|
-
import { effectiveFetchTimeoutMs } from "./core.mjs"
|
|
9
|
+
import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
|
|
10
10
|
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
11
11
|
import { terminateBody } from "../stream-destroy.mjs"
|
|
12
12
|
import { requestBudget } from "../config.mjs"
|
|
13
|
+
import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
|
|
13
14
|
|
|
14
15
|
/** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
|
|
15
16
|
function mapFunctionCallingConfig(choice) {
|
|
@@ -130,12 +131,12 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
130
131
|
// 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
|
|
131
132
|
signal,
|
|
132
133
|
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
133
|
-
_bodyIdleMs:
|
|
134
|
+
_bodyIdleMs: effectiveBodyIdleMs(provider),
|
|
134
135
|
}, provider.proxyUri),
|
|
135
136
|
{ signal, onWait, buildMessage: (status, text) => `Gemini API error ${status}: ${text}` },
|
|
136
137
|
)
|
|
137
138
|
|
|
138
|
-
const result = await parseGeminiStream(response, { onToken, onReasoning, signal })
|
|
139
|
+
const result = await parseGeminiStream(response, { onToken, onReasoning, signal, idleMs: effectiveBodyIdleMs(provider) })
|
|
139
140
|
recordRate(provider, estimated, result.usage)
|
|
140
141
|
|
|
141
142
|
const usage = result.usage
|
|
@@ -168,7 +169,7 @@ function mapGeminiFinishReason(reason) {
|
|
|
168
169
|
* Parse Gemini SSE stream.
|
|
169
170
|
* Format: data: {...}\n\n (each line is a complete JSON object)
|
|
170
171
|
*/
|
|
171
|
-
async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
172
|
+
async function parseGeminiStream(response, { onToken, onReasoning, signal, idleMs = DEFAULT_BODY_IDLE_MS }) {
|
|
172
173
|
const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
|
|
173
174
|
const decoder = new TextDecoder()
|
|
174
175
|
let buffer = ""
|
|
@@ -214,8 +215,7 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
if (!response.body) throw new Error("No stream response body")
|
|
217
|
-
// 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续
|
|
218
|
-
const READ_IDLE_MS = 120_000
|
|
218
|
+
// 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续 `idleMs` 无新 chunk 判死
|
|
219
219
|
let idleTimer = null
|
|
220
220
|
let idleError = null
|
|
221
221
|
const armIdle = () => {
|
|
@@ -223,9 +223,9 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
|
223
223
|
idleTimer = setTimeout(() => {
|
|
224
224
|
// #878:断流单点分流——直连 fetch(web 流)走 `IDLE_ABORT` 内部 abort 通道;
|
|
225
225
|
// proxy 形回落 destroyBody(原契约)。相位串 = 本分支专属 google-sse-idle。
|
|
226
|
-
idleError = timeoutError(`SSE idle timeout: no data for ${
|
|
226
|
+
idleError = timeoutError(`SSE idle timeout: no data for ${idleMs / 1000}s`, "provider", "google-sse-idle")
|
|
227
227
|
try { terminateBody(response, idleError) } catch { /* already gone */ }
|
|
228
|
-
},
|
|
228
|
+
}, idleMs)
|
|
229
229
|
idleTimer.unref?.()
|
|
230
230
|
}
|
|
231
231
|
armIdle()
|
package/provider/list-models.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { proxyFetch } from "../proxy.mjs"
|
|
17
17
|
import { resolveProviderSecrets } from "../env-ref.mjs"
|
|
18
|
+
import { positiveInt, recordListedContext } from "../model-context.mjs"
|
|
18
19
|
|
|
19
20
|
const LIST_TIMEOUT_MS = 15_000
|
|
20
21
|
/** 翻页上限(cursor loop 防死循环——任一分页失败即整体抛出,不部分返回)。 */
|
|
@@ -57,10 +58,37 @@ function collect(rows, pick) {
|
|
|
57
58
|
return out
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** 列表窗口三字段名(取用序 = `context_window` > `context_length` > `max_model_len`)。
|
|
62
|
+
* 只从 openai 形态(`data[]`)取:anthropic / google 两分支不取(零实测——需求 §4.8 边界)。 */
|
|
63
|
+
const LISTED_CONTEXT_FIELDS = ["context_window", "context_length", "max_model_len"]
|
|
64
|
+
|
|
65
|
+
/** 三字段按序取第一个合法值(不是「取首字段」):字段缺失 ∥ 值非法 ⇒ 跳下一个;
|
|
66
|
+
* 都不可得 ⇒ `null`(L3 缺席——R26 与字段缺席逐字同判)。 */
|
|
67
|
+
function listedWindowOf(row) {
|
|
68
|
+
for (const field of LISTED_CONTEXT_FIELDS) {
|
|
69
|
+
const value = positiveInt(row?.[field])
|
|
70
|
+
if (value !== null) return value
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
async function listOpenai(provider, signal) {
|
|
61
76
|
const url = `${provider.baseURL}/models`
|
|
62
77
|
const data = await fetchJson(provider, url, { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
|
|
63
|
-
|
|
78
|
+
// L3 写面的收集段(PROVIDER.md §6.15):解析 `data[]` 时顺带收 `id → 窗口`——三字段按序取
|
|
79
|
+
// 第一个**合法**值(R25 ∥ R26),非法相当于该字段缺席;三字段都不可得 = 该模型列表未报(不进表)。
|
|
80
|
+
const ids = []
|
|
81
|
+
// 形状门:载荷读得出来(data 是数组)才建表;非数组 = 读不出,返回 null 让调用侧不写缓存
|
|
82
|
+
// (「不报」与「读不出」分开——后者不清空上次成功值,同「拉取失败不清空」口径)。
|
|
83
|
+
const windows = Array.isArray(data?.data) ? new Map() : null
|
|
84
|
+
for (const m of Array.isArray(data?.data) ? data.data : []) {
|
|
85
|
+
const id = m?.id
|
|
86
|
+
if (typeof id !== "string" || !id) continue
|
|
87
|
+
ids.push(id)
|
|
88
|
+
const tokens = listedWindowOf(m)
|
|
89
|
+
if (tokens !== null) windows.set(id, tokens)
|
|
90
|
+
}
|
|
91
|
+
return { ids, windows }
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
async function listAnthropic(provider, signal) {
|
|
@@ -94,18 +122,22 @@ async function listGoogle(provider, signal) {
|
|
|
94
122
|
return out
|
|
95
123
|
}
|
|
96
124
|
|
|
97
|
-
/** List available model IDs from the provider's /models endpoint (format 分派——M1)。
|
|
125
|
+
/** List available model IDs from the provider's /models endpoint (format 分派——M1)。
|
|
126
|
+
* 返回形态仍是 `string[]`(六个消费面只吃 id——零改);`openai` 分支在返回前把
|
|
127
|
+
* `id → 自报窗口` 写进核缓存(L3 写面——零新增请求)。 */
|
|
98
128
|
export async function listModels(provider, { signal } = {}) {
|
|
99
129
|
// #57(provider-config-family 批):清单拉取入口凭据值位解析(CONFIG.md §6.3)——三 format 分支共享。
|
|
100
130
|
provider = resolveProviderSecrets(provider)
|
|
101
131
|
const base = String(provider?.baseURL ?? "").replace(/\/+$/, "")
|
|
102
132
|
if (!base) throw new Error("GET /models failed: baseURL is missing") // §2.5 #115 并入(VSC 明确报错)
|
|
103
133
|
const format = provider?.format
|
|
104
|
-
const
|
|
105
|
-
: format === "google" ? await listGoogle(provider, signal)
|
|
134
|
+
const collected = format === "anthropic" ? { ids: await listAnthropic(provider, signal) }
|
|
135
|
+
: format === "google" ? { ids: await listGoogle(provider, signal) }
|
|
106
136
|
: await listOpenai(provider, signal)
|
|
137
|
+
// L3 写面:成功拉取后才写(失败照旧抛出、不清空上次成功值——上次自报比静态表更接近事实)。
|
|
138
|
+
if (collected.windows) recordListedContext(base, collected.windows)
|
|
107
139
|
// §2.5 #115 并入(VSC 排序):返回前本端排序——UI 确定性。
|
|
108
|
-
return ids.filter((id) => typeof id === "string" && id).sort()
|
|
140
|
+
return collected.ids.filter((id) => typeof id === "string" && id).sort()
|
|
109
141
|
}
|
|
110
142
|
|
|
111
143
|
/** 渠道准入失败消息(§2.5 #115 / VSC M8 并入——逐字长句 = 消息本体;状态标签由 UI 层组合)。
|
package/provider/normalize.mjs
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* Split from core.mjs (TODO #2: 420 lines, past the 300 advisory). These two
|
|
5
5
|
* pure functions sanitize the message array right before it hits the wire;
|
|
6
6
|
* no dependency on chat()/retry logic. core.mjs re-exports them so
|
|
7
|
-
* provider/index.mjs
|
|
8
|
-
* The caller passes the spec (providerSpec from core.mjs — provider-aware).
|
|
7
|
+
* provider/index.mjs keeps its import paths.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
|
|
@@ -148,6 +147,7 @@ export function normalizeToolPairing(messages) {
|
|
|
148
147
|
out.push(m)
|
|
149
148
|
if (m.role !== "assistant" || !m.tool_calls?.length) continue
|
|
150
149
|
for (const tc of m.tool_calls) {
|
|
150
|
+
if (!tc || typeof tc !== "object") continue // 空元素不携带 id:跳过,不让整个回合崩(#31)
|
|
151
151
|
const t = toolById.get(tc.id)
|
|
152
152
|
if (t) {
|
|
153
153
|
toolById.delete(tc.id)
|
package/provider/responses.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 事件流规范:流以 response.completed / response.incomplete / response.failed 结束,
|
|
12
12
|
* 没有 "data: [DONE]"。
|
|
13
13
|
*/
|
|
14
|
-
import { effectiveFetchTimeoutMs } from "./core.mjs"
|
|
14
|
+
import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
|
|
15
15
|
import { proxyFetch } from "../proxy.mjs"
|
|
16
16
|
import { requestWithRetry } from "./retry.mjs"
|
|
17
17
|
import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
|
|
@@ -244,8 +244,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
244
244
|
headers,
|
|
245
245
|
body: JSON.stringify(body),
|
|
246
246
|
signal,
|
|
247
|
-
|
|
248
|
-
_bodyIdleMs:
|
|
247
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
248
|
+
_bodyIdleMs: effectiveBodyIdleMs(provider),
|
|
249
249
|
}, provider.proxyUri),
|
|
250
250
|
{ signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
|
|
251
251
|
)
|
|
@@ -263,8 +263,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
263
263
|
headers,
|
|
264
264
|
body: JSON.stringify(fullBody),
|
|
265
265
|
signal,
|
|
266
|
-
|
|
267
|
-
_bodyIdleMs:
|
|
266
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
267
|
+
_bodyIdleMs: effectiveBodyIdleMs(provider),
|
|
268
268
|
}, provider.proxyUri),
|
|
269
269
|
{ signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
|
|
270
270
|
)
|
|
@@ -281,8 +281,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
281
281
|
headers,
|
|
282
282
|
body: JSON.stringify(fresh2.body),
|
|
283
283
|
signal,
|
|
284
|
-
|
|
285
|
-
_bodyIdleMs:
|
|
284
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
285
|
+
_bodyIdleMs: effectiveBodyIdleMs(provider),
|
|
286
286
|
}, provider.proxyUri),
|
|
287
287
|
{ signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
|
|
288
288
|
)
|
package/provider/sse.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
6
6
|
import { terminateBody } from "../stream-destroy.mjs"
|
|
7
|
+
import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Normalize provider cache fields into DeepSeek-style prompt_cache_hit/miss_tokens.
|
|
@@ -89,7 +90,7 @@ function finalizeToolCalls(result) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired }) {
|
|
93
|
+
export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired, idleMs = DEFAULT_BODY_IDLE_MS }) {
|
|
93
94
|
// Early intercept: non-SSE responses — either error bodies (HTTP >= 400) or
|
|
94
95
|
// valid single-chunk JSON completions (some APIs return JSON despite stream:true).
|
|
95
96
|
const contentType = response.headers.get("content-type") || ""
|
|
@@ -196,9 +197,9 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
196
197
|
if (!response.body) throw new Error("No stream response body")
|
|
197
198
|
// 2026-09-01 读侧 idle 超时(根因修复):原实现靠 fetch 层的 600s 绝对墙钟兜底——长上下文子代理
|
|
198
199
|
// 单次生成(或上游排队)超 10 分钟即被腰斩("The operation was aborted due to timeout" 直透)。
|
|
199
|
-
// 现改为:读侧空闲超时——body 只要有数据流动就永不超时,连续
|
|
200
|
+
// 现改为:读侧空闲超时——body 只要有数据流动就永不超时,连续 `idleMs` 无新 chunk 才判死
|
|
201
|
+
// (`idleMs` 由 provider/core.mjs 的 effectiveBodyIdleMs 算好传入,缺省 DEFAULT_BODY_IDLE_MS)。
|
|
200
202
|
// 直连 fetch 与 proxyFetch 统一走这里(proxy 的 _bodyIdleMs 语义与之等价,双保险)。
|
|
201
|
-
const READ_IDLE_MS = 120_000
|
|
202
203
|
let idleTimer = null
|
|
203
204
|
let idleError = null
|
|
204
205
|
const armIdle = () => {
|
|
@@ -206,9 +207,9 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
206
207
|
idleTimer = setTimeout(() => {
|
|
207
208
|
// #878:断流单点分流——直连 fetch(web 流)走 `IDLE_ABORT` 内部 abort 通道,
|
|
208
209
|
// proxy 形回落 destroyBody(原契约)。
|
|
209
|
-
idleError = timeoutError(`SSE idle timeout: no data for ${
|
|
210
|
+
idleError = timeoutError(`SSE idle timeout: no data for ${idleMs / 1000}s`, "provider", "sse-idle")
|
|
210
211
|
try { terminateBody(response, idleError) } catch { /* already gone */ }
|
|
211
|
-
},
|
|
212
|
+
}, idleMs)
|
|
212
213
|
idleTimer.unref?.()
|
|
213
214
|
}
|
|
214
215
|
armIdle()
|
package/proxy-transport.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"
|
|
|
15
15
|
import { abortError, timeoutError } from "./abort-provenance.mjs"
|
|
16
16
|
import { createChunkedDecoder } from "./proxy-chunked.mjs"
|
|
17
17
|
import { destroyBody } from "./stream-destroy.mjs"
|
|
18
|
+
import { DEFAULT_BODY_IDLE_MS } from "./config-defaults.mjs"
|
|
18
19
|
|
|
19
20
|
export const FETCH_TIMEOUT = 15_000
|
|
20
21
|
|
|
@@ -35,10 +36,11 @@ const CE_DECODERS = { gzip: createGunzip, "x-gzip": createGunzip, deflate: creat
|
|
|
35
36
|
* absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
|
|
36
37
|
* 2026-08-31 会诊 #4:timeout 只覆盖"响应头阶段";settle 后 body 空闲 > bodyIdleMs 即
|
|
37
38
|
* 断流(原实现头部 timer 到齐即清,流式中途停摆会无限挂起直到用户 Ctrl+C)。
|
|
38
|
-
* bodyIdleMs
|
|
39
|
+
* bodyIdleMs 缺省 = DEFAULT_BODY_IDLE_MS(与 `agent.bodyIdleTimeoutMs` 同源);传 0 不是「禁用」——
|
|
40
|
+
* 定时器立即触发(本批不提供禁用开关)。
|
|
39
41
|
* 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
|
|
40
42
|
*/
|
|
41
|
-
export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs =
|
|
43
|
+
export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = DEFAULT_BODY_IDLE_MS) {
|
|
42
44
|
return new Promise((resolve, reject) => {
|
|
43
45
|
const target = new URL(urlStr)
|
|
44
46
|
const method = opts.method ?? "GET"
|
|
@@ -229,7 +231,7 @@ export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
|
|
|
229
231
|
}
|
|
230
232
|
const signal = opts?.signal
|
|
231
233
|
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
|
|
232
|
-
const bodyIdleMs = opts?._bodyIdleMs ??
|
|
234
|
+
const bodyIdleMs = opts?._bodyIdleMs ?? DEFAULT_BODY_IDLE_MS
|
|
233
235
|
|
|
234
236
|
if (signal?.aborted) return reject(abortError(signal, "provider", "proxy"))
|
|
235
237
|
|
package/proxy.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { URL } from "node:url"
|
|
7
7
|
import { normalizeProxy } from "./config.mjs"
|
|
8
|
+
import { DEFAULT_BODY_IDLE_MS } from "./config-defaults.mjs"
|
|
8
9
|
import { IDLE_ABORT } from "./stream-destroy.mjs"
|
|
9
10
|
import { isLoopbackTarget, resolveProxyConfig, resolveWebProxy } from "./proxy-target.mjs"
|
|
10
11
|
import { FETCH_TIMEOUT, streamHttpResponse, tcpConnectProxy, tunnelHttps } from "./proxy-transport.mjs"
|
|
@@ -48,6 +49,6 @@ export async function proxyFetch(urlStr, opts, proxyUri) {
|
|
|
48
49
|
// http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
|
|
49
50
|
const sock = await tcpConnectProxy(proxyUri, opts?.signal, FETCH_TIMEOUT)
|
|
50
51
|
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : FETCH_TIMEOUT
|
|
51
|
-
const bodyIdleMs = opts?._bodyIdleMs ??
|
|
52
|
+
const bodyIdleMs = opts?._bodyIdleMs ?? DEFAULT_BODY_IDLE_MS
|
|
52
53
|
return streamHttpResponse(sock, urlStr, opts, headerTimeoutMs, true, bodyIdleMs)
|
|
53
54
|
}
|
package/session-guard.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { RECORD_DIR_SUFFIX } from "./session-store.mjs"
|
|
|
22
22
|
// 本进程端名(创建端取值链缺省值)——静态环:本档 ↔ session-slots.mjs(经 session.mjs,同
|
|
23
23
|
// 两档头注:函数体内运行时使用、求值期零顶层调用 ⇒ 环安全)。
|
|
24
24
|
import { sessionEnd } from "./session-slots.mjs"
|
|
25
|
+
import { logEvent } from "./log.mjs" // 槽事件(docs/core/design/SESSION-OBSERVABILITY.md §3.1):guard 侧轮转同报 `via=guard`。
|
|
25
26
|
|
|
26
27
|
/** 轮转时随迁记录存储(.bak 路径联动)——本会话活动绑定面跳过(防拔掉活动存储)。 */
|
|
27
28
|
function moveSidecar(src, dst, agent) {
|
|
@@ -55,10 +56,12 @@ export function guardForeignSlotFile(agent, p, slot) {
|
|
|
55
56
|
const myStart = agent._sessionStart ?? null
|
|
56
57
|
const diskForeign = typeof disk?.cwd === "string" && disk.cwd.toLowerCase() !== agent.cwd.toLowerCase()
|
|
57
58
|
if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
|
|
59
|
+
const reason = diskIsNewer ? "newer-version" : diskForeign ? "foreign-cwd" : "other-session"
|
|
58
60
|
const bak = `${p}.bak-${Date.now()}`
|
|
59
61
|
renameSync(p, bak)
|
|
60
62
|
moveSidecar(p, bak, agent)
|
|
61
63
|
console.error(`[session] slot ${slot} holds ${diskIsNewer ? `a newer-version file (v${disk.version})` : diskForeign ? `a foreign-cwd file (${disk.cwd})` : `another session (start ${diskStart}, ours ${myStart})`} — preserved as ${basename(bak)}`)
|
|
64
|
+
logEvent("slot:rotate", { slot, reason, bak: true, via: "guard" })
|
|
62
65
|
agent._slotMtime = { p, mtimeMs: st.mtimeMs }
|
|
63
66
|
agent._slotCreatedBy = sessionEnd() // 轮转:现场已让位 ⇒ 本端将首物化新文件
|
|
64
67
|
return bak
|
|
@@ -70,9 +73,12 @@ export function guardForeignSlotFile(agent, p, slot) {
|
|
|
70
73
|
// 文件存在但不可读(损坏/半写):改名 .corrupted 保留现场(2026-09-01 advisor 🔵——
|
|
71
74
|
// 与自身 loadSlotFile 的 .corrupted 约定 + VS Code saveSessionToSlot 对齐;.bak 保留
|
|
72
75
|
// 给 F2 轮转路径,损坏现场不再混入轮转后缀)。
|
|
76
|
+
// 设计档 §3.1 / D13:catch 补 `preserved` 跟踪——否则 `unreadable` / `rotate-failed` 两值不可判。
|
|
77
|
+
let preserved = false
|
|
73
78
|
if (existsSync(p)) {
|
|
74
|
-
try { renameSync(p, `${p}.corrupted`) } catch {}
|
|
79
|
+
try { renameSync(p, `${p}.corrupted`); preserved = true } catch {}
|
|
75
80
|
}
|
|
81
|
+
logEvent("slot:rotate", { slot, reason: preserved ? "unreadable" : "rotate-failed", bak: false, via: "guard" })
|
|
76
82
|
agent._slotCreatedBy = sessionEnd() // 损坏改名:现场已让位 ⇒ 本端将首物化新文件
|
|
77
83
|
return null
|
|
78
84
|
}
|