@thincoder/core 0.10.11 → 0.10.13
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 +21 -0
- package/advisor/messages.mjs +1 -1
- package/advisor/project-context.mjs +2 -2
- package/agent/run-stages.mjs +33 -29
- package/agent/setup.mjs +3 -0
- package/agent/turn-loop.mjs +3 -0
- package/agent-tools/batch-gate.mjs +65 -49
- package/agent-tools/batch-lifecycle.mjs +82 -12
- package/agent-tools/batch-paths.mjs +10 -8
- package/agent-tools/batch-skeleton.mjs +9 -1
- package/agent-tools/batch-unit-test-gate.mjs +107 -0
- package/agent-tools/design-slots.mjs +72 -12
- package/agent-tools/design-token.mjs +18 -2
- package/agent-tools/manifest.mjs +1 -1
- package/agent-tools/review-facts.mjs +1 -1
- package/agent.mjs +1 -0
- package/client-identity.mjs +54 -0
- package/config.mjs +19 -13
- package/context.mjs +5 -5
- package/doc-check/index.mjs +28 -15
- package/doc-check/paragraph.mjs +6 -3
- package/embedding.mjs +2 -0
- package/generate-title.mjs +3 -2
- package/ledger-close-proof.mjs +80 -0
- package/ledger-cmd.mjs +64 -53
- package/ledger-db.mjs +60 -59
- package/ledger-tools.mjs +60 -23
- package/ledger.mjs +12 -10
- package/manifest-discovery.mjs +41 -26
- package/manifest.mjs +17 -16
- package/model-specs.mjs +17 -18
- package/package.json +1 -1
- package/prompts/advisor-design.md +4 -3
- package/prompts/common.md +11 -1
- package/prompts/discipline-engineering.md +16 -7
- package/prompts/discipline-normal.md +2 -1
- package/prompts/persona-coder.md +8 -1
- package/prompts/persona-eng-coder.md +8 -0
- package/prompts/persona-eng-designer.md +11 -1
- package/prompts/persona-engineering.md +23 -11
- package/prompts/persona-explore.md +6 -0
- package/prompts/persona-normal.md +6 -0
- package/prompts/persona-plan.md +6 -0
- package/provider/anthropic.mjs +12 -7
- package/provider/core.mjs +19 -3
- package/provider/errors.mjs +41 -0
- package/provider/google.mjs +10 -4
- package/provider/list-models.mjs +4 -3
- package/provider/responses.mjs +17 -7
- package/session-lifecycle.mjs +3 -0
- package/session-slot-write.mjs +21 -1
- package/session-slots-manifest.mjs +21 -4
- package/token-ttl.mjs +68 -4
- package/token-window.mjs +23 -2
- package/tool-docs/wait_for.md +15 -6
package/provider/errors.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { RETRYABLE_STATUS, RATE_LIMIT_BACKOFF_MS } from "./rate.mjs"
|
|
11
|
+
import { requestBudget } from "../config.mjs"
|
|
11
12
|
|
|
12
13
|
/** Parse Retry-After: 秒数 or HTTP-date;上限 300s(会诊 #11)— 异常头不得让 CLI 睡数小时。
|
|
13
14
|
* header 缺失/非法时退回指数退避表(rateLimitHits 计数取档)。 */
|
|
@@ -132,3 +133,43 @@ export function classifyResponsesErrorCode(code) {
|
|
|
132
133
|
return null
|
|
133
134
|
}
|
|
134
135
|
}
|
|
136
|
+
|
|
137
|
+
/** 越窗报文特征(§6.17 HTTP 400 分类——三个渠道的逐字形态;词表就这一处)。 */
|
|
138
|
+
const CONTEXT_OVERFLOW_PHRASES = ["maximum context length", "context_length_exceeded", "prompt is too long"]
|
|
139
|
+
|
|
140
|
+
/** 越窗 typed 错误(§6.17):三实数逐字带出(prompt / max_tokens / 窗口,另附 limit);kind / retryable
|
|
141
|
+
* 复用同族既有词表(`classifyResponsesErrorCode("context_length_exceeded")`),零新词。 */
|
|
142
|
+
export function contextOverflowError(provider, prompt, budget, remedy) {
|
|
143
|
+
const cls = classifyResponsesErrorCode("context_length_exceeded")
|
|
144
|
+
const err = new Error(
|
|
145
|
+
`context overflow: prompt ~${prompt} tokens · max_tokens ${budget.maxTokens ?? "not sent"}` +
|
|
146
|
+
` · window ${budget.window} (limit ${budget.limit}) — ${remedy} [model ${provider?.model ?? "unknown"}]`,
|
|
147
|
+
)
|
|
148
|
+
err.kind = cls.kind
|
|
149
|
+
err.retryable = cls.retryable
|
|
150
|
+
return err
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 发送面净判(§6.17 第二道,四个装配面同形一次调用):`estPrompt` = 输入侧估算
|
|
154
|
+
* (= `estimateRequestTokens(X) − (body.max_tokens ?? 0)`,四面同一定义——不减会把输出预算白扣一份)。
|
|
155
|
+
* 内部阶段豁免:`compress` / `distill` / 续写本身就是越线后用来把上下文降下来的那次调用,
|
|
156
|
+
* 判它 = 判掉止血动作本身(标签已存在,零新增机制)。 */
|
|
157
|
+
export function assertPromptFits(provider, estPrompt, logCtx) {
|
|
158
|
+
const stage = logCtx?.stage
|
|
159
|
+
if (stage === "compress" || stage === "distill" || logCtx?.isContinuation === true) return
|
|
160
|
+
const budget = requestBudget(provider)
|
|
161
|
+
if (!(estPrompt > budget.limit)) return
|
|
162
|
+
throw contextOverflowError(provider, estPrompt, budget, "the request was not sent; reduce the conversation (/compact or prune) before retrying")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** HTTP 400 越窗报文 ⇒ typed 错误(§6.17);未命中 ⇒ 原样返回入参错误(不发明分类)。 */
|
|
166
|
+
export function markContextOverflow(error, status, text) {
|
|
167
|
+
if (status !== 400) return error
|
|
168
|
+
const lower = String(text ?? "").toLowerCase()
|
|
169
|
+
if (!CONTEXT_OVERFLOW_PHRASES.some((p) => lower.includes(p))) return error
|
|
170
|
+
const cls = classifyResponsesErrorCode("context_length_exceeded")
|
|
171
|
+
error.kind = cls.kind
|
|
172
|
+
error.retryable = cls.retryable
|
|
173
|
+
if (cls.hint) error.message += `(${cls.hint})`
|
|
174
|
+
return error
|
|
175
|
+
}
|
package/provider/google.mjs
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { proxyFetch } from "../proxy.mjs"
|
|
8
|
+
import { withUserAgent } from "../client-identity.mjs"
|
|
8
9
|
import { requestWithRetry } from "./retry.mjs"
|
|
9
10
|
import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
|
|
10
11
|
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
11
12
|
import { terminateBody } from "../stream-destroy.mjs"
|
|
12
13
|
import { requestBudget } from "../config.mjs"
|
|
13
14
|
import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
|
|
15
|
+
import { assertPromptFits } from "./errors.mjs"
|
|
14
16
|
|
|
15
17
|
/** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
|
|
16
18
|
function mapFunctionCallingConfig(choice) {
|
|
@@ -77,7 +79,7 @@ export function convertMessages(messages) {
|
|
|
77
79
|
|
|
78
80
|
/** Build and send a Gemini chat request. Returns the same shape as core.mjs chat.
|
|
79
81
|
* 2026-08-31 会诊 #6:接入 rateGate/recordRate(原实现完全绕过 TPM/RPM 闸门)。 */
|
|
80
|
-
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice }) {
|
|
82
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, logCtx }) {
|
|
81
83
|
const systemMessages = messages.filter((m) => m.role === "system")
|
|
82
84
|
const contents = convertMessages(messages)
|
|
83
85
|
|
|
@@ -118,7 +120,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
118
120
|
|
|
119
121
|
// 会诊 #6:TPM/RPM 闸门 + 记账
|
|
120
122
|
const { rateGate, recordRate, estimateRequestTokens } = await import("./rate.mjs")
|
|
121
|
-
|
|
123
|
+
// §6.17 发送面净判:估准面补 `tools`(原只投影 messages);body 用 `maxOutputTokens`、无 `max_tokens` 键 ⇒ 减项恒 0
|
|
124
|
+
const estimated = estimateRequestTokens({ messages, tools })
|
|
125
|
+
const estPrompt = estimated - (body.max_tokens ?? 0)
|
|
126
|
+
assertPromptFits(provider, estPrompt, logCtx)
|
|
122
127
|
await rateGate(provider, estimated, onWait, signal)
|
|
123
128
|
|
|
124
129
|
// 2026-08-31:5xx/网络与 OpenAI 格式统一退避重试链(原完全无重试——Gemini 高峰
|
|
@@ -126,7 +131,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
126
131
|
const response = await requestWithRetry(
|
|
127
132
|
() => proxyFetch(url, {
|
|
128
133
|
method: "POST",
|
|
129
|
-
headers: { ...(provider.headers
|
|
134
|
+
headers: { ...withUserAgent(provider.headers), "Content-Type": "application/json" }, // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17);内置头在后故同名胜出
|
|
130
135
|
body: JSON.stringify(body),
|
|
131
136
|
// 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
|
|
132
137
|
signal,
|
|
@@ -145,6 +150,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
145
150
|
content: result.content,
|
|
146
151
|
reasoning: result.reasoning,
|
|
147
152
|
finishReason: result.finishReason ?? null, // §6.31.7 F-DA5(原三 transport 恒 null——字段值收正)
|
|
153
|
+
estimated: estPrompt, // §6.17 自校准采样(den 与净判同口径)
|
|
148
154
|
usage: {
|
|
149
155
|
prompt_tokens: usage.prompt_tokens ?? 0,
|
|
150
156
|
completion_tokens: usage.completion_tokens ?? 0,
|
|
@@ -154,7 +160,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
154
160
|
}
|
|
155
161
|
}
|
|
156
162
|
|
|
157
|
-
return { content: result.content, reasoning: result.reasoning, finishReason: result.finishReason ?? null, toolCalls: result.toolCalls }
|
|
163
|
+
return { content: result.content, reasoning: result.reasoning, finishReason: result.finishReason ?? null, estimated: estPrompt, toolCalls: result.toolCalls }
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
/** Gemini `finishReason` ⇒ 核统一 `finishReason`(§6.31.7 F-DA5——归一只喂既有分类;
|
package/provider/list-models.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* 解析保持防御性(字段缺失即跳过该项)。候选不过滤非对话模型(embedding 等——§16.6 #14)。
|
|
15
15
|
*/
|
|
16
16
|
import { proxyFetch } from "../proxy.mjs"
|
|
17
|
+
import { withUserAgent } from "../client-identity.mjs"
|
|
17
18
|
import { resolveProviderSecrets } from "../env-ref.mjs"
|
|
18
19
|
import { positiveInt, recordListedContext } from "../model-context.mjs"
|
|
19
20
|
|
|
@@ -74,7 +75,7 @@ function listedWindowOf(row) {
|
|
|
74
75
|
|
|
75
76
|
async function listOpenai(provider, signal) {
|
|
76
77
|
const url = `${provider.baseURL}/models`
|
|
77
|
-
const data = await fetchJson(provider, url, { ...(provider.headers
|
|
78
|
+
const data = await fetchJson(provider, url, { ...withUserAgent(provider.headers), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
|
|
78
79
|
// L3 写面的收集段(PROVIDER.md §6.15):解析 `data[]` 时顺带收 `id → 窗口`——三字段按序取
|
|
79
80
|
// 第一个**合法**值(R25 ∥ R26),非法相当于该字段缺席;三字段都不可得 = 该模型列表未报(不进表)。
|
|
80
81
|
const ids = []
|
|
@@ -92,7 +93,7 @@ async function listOpenai(provider, signal) {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
async function listAnthropic(provider, signal) {
|
|
95
|
-
const headers = { ...(provider.headers
|
|
96
|
+
const headers = { ...withUserAgent(provider.headers), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
|
|
96
97
|
const base = `${provider.baseURL}/models?limit=1000`
|
|
97
98
|
const out = []
|
|
98
99
|
let afterId = null
|
|
@@ -108,7 +109,7 @@ async function listAnthropic(provider, signal) {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
async function listGoogle(provider, signal) {
|
|
111
|
-
const headers = { ...(provider.headers
|
|
112
|
+
const headers = { ...withUserAgent(provider.headers) }
|
|
112
113
|
const base = `${provider.baseURL}/models?key=${encodeURIComponent(provider.apiKey ?? "")}&pageSize=1000`
|
|
113
114
|
const out = []
|
|
114
115
|
let pageToken = null
|
package/provider/responses.mjs
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
|
|
15
15
|
import { proxyFetch } from "../proxy.mjs"
|
|
16
|
+
import { withUserAgent } from "../client-identity.mjs"
|
|
16
17
|
import { requestWithRetry } from "./retry.mjs"
|
|
17
18
|
import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
|
|
18
19
|
import { buildBody, normalizeUsage } from "./responses-request.mjs"
|
|
19
|
-
import { classifyResponsesErrorCode } from "./errors.mjs"
|
|
20
|
+
import { classifyResponsesErrorCode, assertPromptFits } from "./errors.mjs"
|
|
20
21
|
// 同名 re-export(2026-09-29 structure-split-2 · 台账 #620——缝 = 同名再出口):请求构造面出档 `provider/responses-request.mjs`;
|
|
21
22
|
// `buildBody` / `isStoreRequiredHost` / `builtinToolsFor` 三名既有导出经本档转口——消费者 import 面零改。
|
|
22
23
|
export { buildBody, isStoreRequiredHost, builtinToolsFor } from "./responses-request.mjs"
|
|
@@ -223,15 +224,18 @@ function handleEvent(ev, h) {
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
/** 主入口:请求 + 链状态推进(与 core.mjs 的 chat 同形返回)。 */
|
|
226
|
-
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, stateful }) {
|
|
227
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, stateful, logCtx }) {
|
|
227
228
|
const { body: reqBody, previousResponseId, warnings, newChain } = buildBody(provider, messages, tools, { toolChoice, stateful })
|
|
228
229
|
const body = { ...reqBody, ...(previousResponseId ? { previous_response_id: previousResponseId } : {}) }
|
|
229
230
|
|
|
230
231
|
// rateGate/recordRate 对齐 core(responses body 无 messages 键——按本地全量 messages 估算)
|
|
231
|
-
|
|
232
|
+
// §6.17 发送面净判:估准面补 `tools`(原只投影 messages);body 用 `max_output_tokens`、无 `max_tokens` 键 ⇒ 减项恒 0
|
|
233
|
+
const estimated = estimateRequestTokens({ messages, tools })
|
|
234
|
+
const estPrompt = estimated - (body.max_tokens ?? 0)
|
|
235
|
+
assertPromptFits(provider, estPrompt, logCtx)
|
|
232
236
|
await rateGate(provider, estimated, onWait, signal)
|
|
233
|
-
//
|
|
234
|
-
const headers = { ...(provider.headers
|
|
237
|
+
// 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17):定制头在前、内置头在后——同名内置头胜出(三处 fetch 共用)
|
|
238
|
+
const headers = { ...withUserAgent(provider.headers), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }
|
|
235
239
|
|
|
236
240
|
// round2 复验 #1(2026-08-31):retry 层对 4xx 非可重试是 throw 而非返回——
|
|
237
241
|
// requestWithRetry 从不返回 400/404 响应 → 下方 isChainInvalidError 分支原来不可达(D6 死代码)。
|
|
@@ -287,11 +291,17 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
287
291
|
{ signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
|
|
288
292
|
)
|
|
289
293
|
}
|
|
290
|
-
return finish(provider, response, {
|
|
294
|
+
return finish(provider, response, {
|
|
295
|
+
onToken, onReasoning, signal, newChain, warnings, estimated,
|
|
296
|
+
// §6.17 自校准口径:链式(`previous_response_id`)请求的 body 只带增量(`responses-request.mjs`
|
|
297
|
+
// `body.input = newOutputs`),而分母是本地全量估算 ⇒ 两者口径不同,本轮不采样。
|
|
298
|
+
estPrompt: previousResponseId ? undefined : estPrompt,
|
|
299
|
+
})
|
|
291
300
|
}
|
|
292
301
|
|
|
293
|
-
async function finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated }) {
|
|
302
|
+
async function finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated, estPrompt }) {
|
|
294
303
|
const result = await parseStream(response, { onToken, onReasoning, signal })
|
|
304
|
+
result.estimated = estPrompt // §6.17 自校准采样(den 与净判同口径)
|
|
295
305
|
recordRate(provider, estimated, result.usage)
|
|
296
306
|
// 链状态推进:completed 事件里 response.id 供同一 turn 后续增量;
|
|
297
307
|
// 截断/失败/无 id(部分端点不回传)→ 链作废(后续全量,正确性优先)。
|
package/session-lifecycle.mjs
CHANGED
|
@@ -322,6 +322,9 @@ export function resetSessionState(agent) {
|
|
|
322
322
|
agent._runStartHistoryLen = 0
|
|
323
323
|
agent._lastPromptTokens = null
|
|
324
324
|
agent._usageAtLen = null
|
|
325
|
+
// §6.17 估算自校准:新会话无样本(比 = 1)——跨会话不继承旧模型的估算偏差
|
|
326
|
+
agent._tok = null
|
|
327
|
+
agent._tokRatio = 1
|
|
325
328
|
agent.planMode = false
|
|
326
329
|
agent.goal = null
|
|
327
330
|
agent._pendingReminders = []
|
package/session-slot-write.mjs
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
*
|
|
19
19
|
* token 台账字段(`engDesignTokens`)的**槽 I/O 面**不在本档——单源 = `token-ttl.mjs`
|
|
20
20
|
* (settle 落盘 / 门禁回读 / 链终清理三面齐备);本档只承载其**保存面合并规则**
|
|
21
|
-
* (`mergeEngTokensForSave`——空态保留 + 并集 +
|
|
21
|
+
* (`mergeEngTokensForSave`——空态保留 + 并集 + 新铸者胜,纯函数)。批次归属旁列
|
|
22
|
+
* (`engDesignTokenBatches`)同款分层:槽 I/O 在 `token-ttl.mjs`,本档只持其保存面合并规则
|
|
23
|
+
* (`mergeEngTokenBatchesForSave`)。槽标题写面同样不在
|
|
22
24
|
* 本档——单源 = `session-rename.mjs` 的 `renameSlot`(同 `{ok, reason}` 契约 + mtime 门控)。
|
|
23
25
|
*/
|
|
24
26
|
|
|
@@ -193,6 +195,24 @@ export function mergeEngTokensForSave(incoming, existing) {
|
|
|
193
195
|
return out
|
|
194
196
|
}
|
|
195
197
|
|
|
198
|
+
/**
|
|
199
|
+
* 保存面「批次归属旁列」合并规则(DESIGN-TOKEN-SETTLEMENT §10 / KD-BG7——纯函数):
|
|
200
|
+
* 沿用 `mergeEngTokensForSave` 的「槽独有项保留」半边(忙时保存不得把内存快照的缺席当成删除),
|
|
201
|
+
* 同 key 冲突**以新写者胜**——旁列值是路径,无 mint 时刻可比,不比 `expiresAt`。
|
|
202
|
+
* - `incoming === undefined`(调用方未携带该字段)⇒ 保留槽值;
|
|
203
|
+
* - incoming 空值 ⇒ 槽有值则保留槽、槽也空 ⇒ null(写 null = 清字段);
|
|
204
|
+
* - 两端都有 ⇒ 并集:槽独有项保留、incoming 覆盖同 key。
|
|
205
|
+
* 理由:忙时保存把归属写丢 ⇒ 收口 no-op ⇒ 凭证留存,正是 §10 要消的窗口。
|
|
206
|
+
*/
|
|
207
|
+
export function mergeEngTokenBatchesForSave(incoming, existing) {
|
|
208
|
+
if (incoming === undefined) return existing ?? null
|
|
209
|
+
const hasNew = incoming && typeof incoming === "object" && !Array.isArray(incoming) && Object.keys(incoming).length > 0
|
|
210
|
+
const slotHas = existing && typeof existing === "object" && !Array.isArray(existing) && Object.keys(existing).length > 0
|
|
211
|
+
if (!hasNew) return slotHas ? existing : null
|
|
212
|
+
if (!slotHas) return incoming
|
|
213
|
+
return { ...existing, ...incoming }
|
|
214
|
+
}
|
|
215
|
+
|
|
196
216
|
/** 会话级偏好(§6.21)键闭集——表外键 = 整 patch 拒(写未发生)。 */
|
|
197
217
|
const SLOT_PREF_KEYS = new Set(["provider", "model", "effort"])
|
|
198
218
|
|
|
@@ -100,15 +100,32 @@ export function slotMtime(cwd, slot) {
|
|
|
100
100
|
try { return statSync(slotPath(cwd, slot)).mtimeMs } catch { return null }
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
/** 清单读(两形——DESIGN-TOKEN-SETTLEMENT §10「槽号解析层同判」· KD-BG8):缺省宽容 ∥ `strict`。
|
|
104
|
+
* 判据 = 「没有」与「读不到」分清:文件不存在(`ENOENT`)⇒ 默认空账本(无槽号 = 合法缺省,**两形同**);
|
|
105
|
+
* 在盘但读失败(`EACCES` ∥ `EISDIR`…)∥ 解析失败 ⇒ 宽容形照旧吞错回默认,**严格形抛**(携 manifest
|
|
106
|
+
* 路径 + 底层消息)。收口门面(`consumeBatchDesignTokens`)用严格形——manifest 损坏若被读成「无槽号」,
|
|
107
|
+
* 收口就落成「已收口而凭证仍在」,正是 F-D8 要消的窗口。
|
|
108
|
+
* 判别用 `err.code === "ENOENT"`——**不用 `existsSync`**:它把权限错也吞成「不存在」,而收口面要拒的正是那类。
|
|
109
|
+
* **形状非法**(可读但解析成 `null` ∥ 非对象)按「读不到」处置(fail-closed;与 `saveManifest` 的 `shape-invalid`
|
|
110
|
+
* 同判)——严格形抛、拒文沿用「could NOT be read」:方向只更严,措辞不另立第三类。
|
|
111
|
+
* 与 `readEngTokenBatchesFromSlot`(token-ttl.mjs)同一两形先例:同一读函数、同一判据、缺省宽容;
|
|
112
|
+
* 展示 / 尽力面一律缺省(现行调用点零改),收口门面才传 `{ strict: true }`。 */
|
|
113
|
+
export function loadManifest(cwd, { strict = false } = {}) {
|
|
114
|
+
let p = "(unresolved manifest path)"
|
|
104
115
|
try {
|
|
105
|
-
|
|
106
|
-
if (!existsSync(p)) return { slots: {}, sessionId: null }
|
|
116
|
+
p = manifestPath(cwd)
|
|
107
117
|
const m = JSON.parse(readFileSync(p, "utf8"))
|
|
108
118
|
if (!m.slots) m.slots = {} // 2026-09-01 会诊 deepseek 🔵:损坏的 {} manifest 不再让调用方抛 TypeError
|
|
109
119
|
if (!m.sessionId) m.sessionId = null
|
|
110
120
|
return m
|
|
111
|
-
} catch
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// 「没有」:文件不存在 = 合法缺省(新会话 / 首建前)——两形一致,严格形也不抛
|
|
123
|
+
if (e?.code === "ENOENT") return { slots: {}, sessionId: null }
|
|
124
|
+
if (strict) {
|
|
125
|
+
throw new Error(`loadManifest: the session manifest could NOT be read (${p}: ${e?.message ?? e}) — the slot number cannot be resolved, so "no slot" cannot be told apart from "cannot read"; the engineering closeout is refused with the record untouched (fix the manifest file or its permissions and retry).`)
|
|
126
|
+
}
|
|
127
|
+
return { slots: {}, sessionId: null }
|
|
128
|
+
}
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
/** 可信合并基座判据(§6.23 判据句 1 · ②):顶层非 null 非数组对象 ∧(`slots` / `slotSessions`
|
package/token-ttl.mjs
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* - **D3**:**单值镜像 `_engDesignToken` 退役**——AC3 零写 + 仅一次性迁移读。本模块
|
|
21
21
|
* 不再写镜像(engTokenSlotFields 只产多槽表);restoreEngTokens 是唯一迁移读点(旧
|
|
22
22
|
* slot 残留 engDesignToken → 迁入 Map,此后不写不读镜像)。
|
|
23
|
+
* - **§10(2026-10-26)**:批次归属旁列 `engDesignTokenBatches`(designId → 批档绝对路径)与
|
|
24
|
+
* 槽多槽表**同点序列化 / 同拍恢复 / 同处落盘**;轻读面 = `readEngTokenBatchesFromSlot`。
|
|
25
|
+
* token 值形零改(仍 `<uuid>:<expiryMs>`)——旁列只承载归属,不承载授权。
|
|
23
26
|
*
|
|
24
27
|
* 依赖方向:本模块只 import 底层槽 I/O(session-slots.mjs 原语 + session-guard.mjs 轮转
|
|
25
28
|
* 守卫)——session.mjs 同时 import 本模块(engTokenSlotFields/restoreEngTokens),形成
|
|
@@ -108,12 +111,17 @@ export function purgeExpiredDesignTokens(agent) {
|
|
|
108
111
|
* (JSON-safe);空/缺 Map → undefined → JSON.stringify 丢 key——清过的会话写
|
|
109
112
|
* NO field,不从上次 save 复活槽。
|
|
110
113
|
* DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `engDesignToken` 退役——
|
|
111
|
-
* saveSession 不再写镜像字段(AC3 零写);只产多槽表 engDesignTokens。
|
|
114
|
+
* saveSession 不再写镜像字段(AC3 零写);只产多槽表 engDesignTokens。
|
|
115
|
+
* 批次归属旁列 `engDesignTokenBatches`(DESIGN-TOKEN-SETTLEMENT §10)同处序列化——
|
|
116
|
+
* 与槽表并列、同空态语义(空表 ⇒ undefined ⇒ 字段不落盘);承载归属,不承载恢复。 */
|
|
112
117
|
export function engTokenSlotFields(agent) {
|
|
113
118
|
return {
|
|
114
119
|
engDesignTokens: agent._engDesignTokens instanceof Map && agent._engDesignTokens.size > 0
|
|
115
120
|
? Object.fromEntries(agent._engDesignTokens)
|
|
116
121
|
: undefined,
|
|
122
|
+
engDesignTokenBatches: agent._engDesignTokenBatches instanceof Map && agent._engDesignTokenBatches.size > 0
|
|
123
|
+
? Object.fromEntries(agent._engDesignTokenBatches)
|
|
124
|
+
: undefined,
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
|
|
@@ -151,6 +159,16 @@ export function restoreEngTokens(agent, data) {
|
|
|
151
159
|
}
|
|
152
160
|
if (map.size > 0) agent._engDesignTokens = map
|
|
153
161
|
else delete agent._engDesignTokens
|
|
162
|
+
// 批次归属旁列同拍读入(§10):值 = 批档绝对路径,无 TTL 可言(承载归属,不承载授权)。
|
|
163
|
+
// 槽因过期被丢而旁列行仍在 = 惰性残留:消费面按 id 反查时自然落成 no-op(不猜归属)。
|
|
164
|
+
const batches = new Map()
|
|
165
|
+
if (data.engDesignTokenBatches && typeof data.engDesignTokenBatches === "object" && !Array.isArray(data.engDesignTokenBatches)) {
|
|
166
|
+
for (const [id, b] of Object.entries(data.engDesignTokenBatches)) {
|
|
167
|
+
if (typeof b === "string" && b) batches.set(id, b)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (batches.size > 0) agent._engDesignTokenBatches = batches
|
|
171
|
+
else delete agent._engDesignTokenBatches
|
|
154
172
|
let droppedDesignTokens = 0
|
|
155
173
|
for (const id of before) if (!map.has(id)) droppedDesignTokens++
|
|
156
174
|
return { droppedDesignTokens }
|
|
@@ -159,11 +177,14 @@ export function restoreEngTokens(agent, data) {
|
|
|
159
177
|
// ── 会话槽权威台账 I/O(DESIGN-TOKEN-SETTLEMENT D1/D2/D3——槽文件 = 权威结算台账) ──
|
|
160
178
|
|
|
161
179
|
/** 当前会话槽号(读侧——无认领副作用):粘性 _slot 优先;未钉槽时读 manifest active
|
|
162
|
-
* (loadManifest 只读——不在门禁路径触发认领写)。无槽 → null。
|
|
163
|
-
|
|
180
|
+
* (loadManifest 只读——不在门禁路径触发认领写)。无槽 → null。
|
|
181
|
+
* `strict` 透传 manifest 读形(§10「槽号解析层同判」):缺省宽容(manifest 不可读 ⇒ 无槽号);
|
|
182
|
+
* 收口面 `{strict:true}` ⇒ manifest 在盘但读失败 ∥ 解析失败即抛;manifest 不存在 ∥ `active` 缺席
|
|
183
|
+
* 仍是无槽号(合法缺省——「读到了,说没有」)。 */
|
|
184
|
+
function currentSlotNoReadOnly(agent, { strict = false } = {}) {
|
|
164
185
|
const slot = agent?._slot
|
|
165
186
|
if (Number.isInteger(slot)) return slot
|
|
166
|
-
const active = loadManifest(agent?.cwd)?.active
|
|
187
|
+
const active = loadManifest(agent?.cwd, { strict })?.active
|
|
167
188
|
return Number.isInteger(active) ? active : null
|
|
168
189
|
}
|
|
169
190
|
|
|
@@ -185,6 +206,46 @@ export function readEngTokensFromSlot(agent) {
|
|
|
185
206
|
} catch { return null }
|
|
186
207
|
}
|
|
187
208
|
|
|
209
|
+
/** 读当前会话槽文件的批次归属旁列(DESIGN-TOKEN-SETTLEMENT §10——不做 TTL 过滤、不改盘)。
|
|
210
|
+
* 与 `readEngTokensFromSlot` 同形同读法(JSON.parse 轻读、零 reconcile 副作用);两者不能合成
|
|
211
|
+
* 一个函数——既有读者的字段名硬编码(只返回 `data.engDesignTokens`)⇒ 同形兄弟函数。
|
|
212
|
+
* **两形(§10「读形(收口面严格 ∥ 清点面宽容)」)**:`strict:false`(缺省 · 清点面)读不到 ⇒ `null`,
|
|
213
|
+
* 批列照旧如实降级 `—`(F-SL1);`strict:true`(收口面)读不到 ⇒ **抛**(close 被拒、档零字节变、可重试)。
|
|
214
|
+
* 判据 = **「没有」与「读不到」分清**:无槽号 ∥ 文件不存在(`ENOENT`)∥ 无该字段 ⇒ 空集(合法缺省,
|
|
215
|
+
* 两形同);其余 I/O 错(`EACCES` ∥ `EISDIR` ∥ `EIO`…)∥ `JSON.parse` 失败 ⇒ 宽容形 `null` / 严格形抛。
|
|
216
|
+
* **槽号解析层同判(manifest 层)**:槽号来自 `currentSlotNoReadOnly(agent, { strict })`——`_slot` 未钉时
|
|
217
|
+
* 读 manifest;strict 形把 manifest 的「读不到」原样抛出(拒因点名 manifest,不被本函数重包成旁列读失败)。
|
|
218
|
+
* 不用 `existsSync` 兜底——它把权限错也吞成「不存在」,正是收口面必须拒的那一类(「无法判定归属 =
|
|
219
|
+
* 无法证明清账」;§10「严格读的边界」)。
|
|
220
|
+
* 返回 {designId: <批档绝对路径>} 对象或 null(无槽/无文件/无该字段;宽容形的不可读亦 `null`)。 */
|
|
221
|
+
export function readEngTokenBatchesFromSlot(agent, { strict = false } = {}) {
|
|
222
|
+
// 槽号解析在 try 之外:manifest 层的「读不到」自带 manifest 坐标(§10「槽号解析层同判」),
|
|
223
|
+
// 不在这里被重包成「旁列读不到」——两层拒因各自点名。宽容形此步不抛(不可读 ⇒ 无槽号 ⇒ null)。
|
|
224
|
+
const slot = currentSlotNoReadOnly(agent, { strict })
|
|
225
|
+
if (slot == null) return null
|
|
226
|
+
let p = "(unresolved slot path)"
|
|
227
|
+
try {
|
|
228
|
+
p = slotPath(agent.cwd, slot)
|
|
229
|
+
let raw
|
|
230
|
+
try {
|
|
231
|
+
raw = readFileSync(p, "utf8")
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (e?.code === "ENOENT") return null // 「没有」:文件不存在(新会话 / 尚无槽)——合法缺省
|
|
234
|
+
throw e // 「读不到」:其余 I/O 错交外层按读形处置
|
|
235
|
+
}
|
|
236
|
+
const data = JSON.parse(raw)
|
|
237
|
+
return (data && typeof data === "object" && !Array.isArray(data.engDesignTokenBatches)
|
|
238
|
+
&& data.engDesignTokenBatches && typeof data.engDesignTokenBatches === "object")
|
|
239
|
+
? data.engDesignTokenBatches
|
|
240
|
+
: null
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (strict) {
|
|
243
|
+
throw new Error(`readEngTokenBatchesFromSlot: the session slot file's batch-attribution side column could NOT be read (${p}: ${e?.message ?? e}) — the closeout consume cannot tell which slots belong to this batch, so the closeout is refused with the record untouched; fix the slot file or its permissions and retry.`)
|
|
244
|
+
}
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
188
249
|
/** 内存 miss 时从权威槽 reconcile(D2 spawn 门 + D3 dispatch 写门同源):把槽文件中
|
|
189
250
|
* **未过期**项并进内存 Map(TTL 过滤保留——expired 不并入,fail-closed;格式/畸形串
|
|
190
251
|
* 与 restoreEngTokens 同语义读回——门禁格式拒);槽无活项 → 内存原样。返回判定用 Map。
|
|
@@ -281,6 +342,9 @@ export function persistEngTokens(agent) {
|
|
|
281
342
|
const fields = engTokenSlotFields(agent)
|
|
282
343
|
if (fields.engDesignTokens !== undefined) data.engDesignTokens = fields.engDesignTokens
|
|
283
344
|
else delete data.engDesignTokens
|
|
345
|
+
// 批次归属旁列逐字段复制同处(§10「保存 / 合并路径」第二处):空表 ⇒ 字段删(不从上次落盘复活)。
|
|
346
|
+
if (fields.engDesignTokenBatches !== undefined) data.engDesignTokenBatches = fields.engDesignTokenBatches
|
|
347
|
+
else delete data.engDesignTokenBatches
|
|
284
348
|
// 单值镜像字段永不复写(D3 零写)——残留 engDesignToken 随本次写一并清(更快退役,
|
|
285
349
|
// 杜绝 stale 镜像 + 空 Map 组合在下次 restore 二次迁移复活死 token 的边角)。
|
|
286
350
|
delete data.engDesignToken
|
package/token-window.mjs
CHANGED
|
@@ -138,11 +138,13 @@ export function contextUsage(agent, extras = {}) {
|
|
|
138
138
|
const overhead = system + tools
|
|
139
139
|
const historyTokens = estimateTokens(history)
|
|
140
140
|
const measured = agent?._lastPromptTokens != null
|
|
141
|
+
// §6.3 / §6.17 估算自校准:估算值乘 `_tokRatio`;`_lastPromptTokens` 是服务端真值,不乘。
|
|
142
|
+
const ratio = agent?._tokRatio ?? 1
|
|
141
143
|
return {
|
|
142
144
|
basis: measured ? "measured" : "estimated",
|
|
143
145
|
total: measured
|
|
144
|
-
? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
|
|
145
|
-
: historyTokens + overhead,
|
|
146
|
+
? agent._lastPromptTokens + Math.round(estimateTokens(history.slice(agent._usageAtLen ?? history.length)) * ratio)
|
|
147
|
+
: Math.round((historyTokens + overhead) * ratio),
|
|
146
148
|
history: historyTokens,
|
|
147
149
|
system,
|
|
148
150
|
tools,
|
|
@@ -153,6 +155,25 @@ export function contextUsage(agent, extras = {}) {
|
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
/**
|
|
159
|
+
* 估算自校准采样(§6.17「估算自校准」)——池化比:`num += usage.prompt_tokens`、`den += estPrompt`
|
|
160
|
+
* (分子分母同取输入侧口径;`estPrompt` 由发送面随 result 带回,与净判同一个值)。
|
|
161
|
+
* `key = format:model` 变 ⇒ 归零重采(切模型自清,不接四个刷新点);`_tok` / `_tokRatio` 只由本函数写,
|
|
162
|
+
* `/new` 与 run 装配复位。无样本 ⇒ 比 = 1(估算维持原口径)。
|
|
163
|
+
* 作用面 = `contextUsage` 两支承(§6.3);实测基线 `_lastPromptTokens` 不乘。
|
|
164
|
+
*/
|
|
165
|
+
export function recordTokenSample(agent, provider, estPrompt, usage) {
|
|
166
|
+
const prompt = Number(usage?.prompt_tokens)
|
|
167
|
+
if (!agent || !(prompt > 0) || !(estPrompt > 0)) return
|
|
168
|
+
const key = `${provider?.format ?? "openai"}:${provider?.model ?? ""}`
|
|
169
|
+
if (agent._tok?.key !== key) agent._tok = { key, num: 0, den: 0 }
|
|
170
|
+
agent._tok.num += prompt
|
|
171
|
+
agent._tok.den += estPrompt
|
|
172
|
+
// 下界 = 1:已知偏差方向是估算偏低;比 < 1 会把全部估算支缩小、重开「该压没压」原始故障,
|
|
173
|
+
// 保守方向不取(仅一侧校准,零新魔数)。
|
|
174
|
+
agent._tokRatio = Math.max(1, agent._tok.num / agent._tok.den)
|
|
175
|
+
}
|
|
176
|
+
|
|
156
177
|
/**
|
|
157
178
|
* 状态行百分比(§6.16.4「与状态行 ctx% 的单源关系」)——与 CLI 状态行(`render-frame.mjs` 式)与 VSC `ctxPercentForHistory` **同式**;
|
|
158
179
|
* stats 报出的就是用户看到的那个百分比(不新增第三口径);空历史 ⇒ 0(状态行的 `ctxPct > 0` 显示门在端侧)。
|
package/tool-docs/wait_for.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
Wait until a condition becomes true — polls
|
|
1
|
+
Wait until a condition becomes true — polls every interval_ms and returns as soon as it holds or timeout_ms passes.
|
|
2
|
+
|
|
3
|
+
**Use it only for states that cannot announce themselves**: a file appearing, a port opening, a background bash or browser task finishing. Nothing delivers those into the session, so a blocking wait is the only way to learn about them.
|
|
4
|
+
|
|
5
|
+
**Never use it to wait for a subagent or a review report.** Those arrive on their own when the session is idle — the act of waiting for one is simply ending the turn.
|
|
6
|
+
|
|
7
|
+
`wait_for "subagent id:N done"` and `wait_for "advisor settled"` park the session for the whole budget, poll hundreds of times, and learn nothing you would not have been handed anyway.
|
|
8
|
+
|
|
9
|
+
Measured in practice: two such waits in one session burned ~4.5 minutes and 210 polls on reports that were already on their way. The conditions stay supported (a background task genuinely may need joining), but reaching for them on a child is the classic misuse.
|
|
2
10
|
|
|
3
11
|
Parameters:
|
|
4
12
|
- condition (required): a semantic condition expression:
|
|
5
|
-
- `advisor settled` — an in-flight advisor review session has finished
|
|
6
|
-
- `subagent id:N done` — async subagent N (the id your async spawn ack returned) has settled
|
|
7
|
-
- `consult done` — every consult_start session has drained
|
|
13
|
+
- `advisor settled` — an in-flight advisor review session has finished — **discouraged**: the report is delivered automatically; end the turn instead
|
|
14
|
+
- `subagent id:N done` — async subagent N (the id your async spawn ack returned) has settled — **discouraged**, same reason
|
|
15
|
+
- `consult done` — every consult_start session has drained — the verdict text is delivered automatically; wait only when a synchronous caller cannot proceed without it
|
|
8
16
|
- `bash id:N done` — background bash task N (the id from the bash async ack, `bash#N`) has finished — settled, or already left the pool (nothing left to wait on)
|
|
9
17
|
- `browser id:N done` — a background browser task N (the id from the ack, `browser#N`) has settled — finished, or already left the pool (nothing left to wait on)
|
|
10
18
|
- `file exists:path` — the file at path exists (path relative to cwd)
|
|
11
19
|
- `port open:N` — something is listening on 127.0.0.1 port N
|
|
12
|
-
- The agent-internal conditions (advisor / subagent / consult / bash) apply to ASYNC sessions only — a synchronous call already completed before it returned and has nothing to wait for.
|
|
20
|
+
- The agent-internal conditions (advisor / subagent / consult / bash) apply to ASYNC sessions only — a synchronous call already completed before it returned and has nothing to wait for.
|
|
21
|
+
- An unknown condition is an explicit error (`wait_for: unsupported condition "..."` — the supported forms are listed above), never a silent wait.
|
|
13
22
|
- interval_ms: poll interval (default 1000, floor 100 — polling never busy-spins)
|
|
14
23
|
- timeout_ms: overall ceiling (default 30000; config.json `agent.waitForTimeoutMs` overrides the default; hard cap 600000 like the execute tool)
|
|
15
24
|
|
|
@@ -21,4 +30,4 @@ Returns:
|
|
|
21
30
|
Notes:
|
|
22
31
|
- Read-only and non-destructive — it only observes (agent pools, the filesystem, a local port probe).
|
|
23
32
|
- Blocking by design — call it ALONE in a turn, not batched with calls that depend on its result.
|
|
24
|
-
-
|
|
33
|
+
- This is the one blocking call an agent usually should not reach for. Before calling it, ask: **will this state announce itself to me anyway?** If yes (a subagent report, a review digest, a timer), end the turn and let it arrive.
|