@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.
- package/CHANGELOG.md +65 -0
- package/advisor/citations.mjs +40 -4
- package/advisor/compaction.mjs +21 -2
- package/advisor/convergence.mjs +1 -1
- package/advisor/history.mjs +8 -6
- package/advisor/loop.mjs +29 -27
- package/advisor/messages.mjs +5 -4
- package/advisor/run.mjs +5 -1
- package/advisor/timeline.mjs +26 -0
- package/advisor.mjs +4 -2
- package/agent/completion.mjs +3 -1
- package/agent/digest-account.mjs +188 -0
- package/agent/dispatch-run.mjs +29 -12
- package/agent/family-tools.mjs +11 -11
- package/agent/helpers.mjs +8 -0
- package/agent/record-results.mjs +10 -1
- package/agent/run-stages.mjs +27 -4
- package/agent/run-start.mjs +49 -3
- package/agent/spawn-child.mjs +2 -1
- package/agent/subagent-event.mjs +71 -0
- package/agent/suspension.mjs +60 -12
- package/agent/turn-loop.mjs +31 -2
- package/agent/write-gate.mjs +13 -25
- package/agent-tools/advisor-async.mjs +25 -7
- package/agent-tools/advisor-settle.mjs +7 -9
- package/agent-tools/advisor.mjs +34 -33
- package/agent-tools/async-settle.mjs +29 -5
- package/agent-tools/batch-lifecycle.mjs +29 -7
- package/agent-tools/batch-paths.mjs +22 -2
- package/agent-tools/batch-read.mjs +36 -0
- package/agent-tools/batch-skeleton.mjs +59 -33
- package/agent-tools/batch.mjs +16 -6
- package/agent-tools/design-token.mjs +76 -16
- package/agent-tools/escalate-async.mjs +4 -2
- package/agent-tools/review-facts.mjs +143 -16
- package/agent-tools/subagent-actions-query.mjs +13 -6
- package/agent-tools/subagent-async.mjs +16 -4
- package/agent-tools/subagent-panel.mjs +121 -37
- package/agent-tools/subagent-spawn.mjs +1 -1
- package/agent-tools/subagent.mjs +5 -3
- package/agent-tools/verify.mjs +5 -0
- package/agent-tools/zero-write-watch.mjs +77 -0
- package/agent.mjs +1 -1
- package/auto-think.mjs +26 -2
- package/config-presets.mjs +8 -0
- package/config.mjs +30 -0
- package/conventions.mjs +26 -6
- package/declaration.mjs +26 -2
- package/embedding.mjs +35 -3
- package/escape.mjs +1 -1
- package/i18n.mjs +5 -0
- package/ledger-cmd.mjs +13 -21
- package/ledger-db.mjs +34 -1
- package/ledger-migrate.mjs +4 -3
- package/ledger-read.mjs +94 -0
- package/ledger-surface.mjs +15 -46
- package/ledger-tools.mjs +148 -85
- package/ledger-variant-notice.mjs +47 -0
- package/ledger.mjs +38 -64
- package/manifest-schema.mjs +19 -7
- package/manifest.mjs +32 -11
- package/mcp/transport-http.mjs +52 -4
- package/mcp/transport-stdio.mjs +33 -8
- package/mcp.mjs +13 -5
- package/memory/code-search.mjs +5 -3
- package/memory/core.mjs +6 -4
- package/memory/docs.mjs +5 -3
- package/memory/schema.mjs +2 -0
- package/package.json +1 -1
- package/prompts/advisor-design.md +3 -2
- package/prompts/advisor-round1.md +1 -0
- package/prompts/advisor-round2.md +8 -3
- package/prompts/advisor-round3.md +7 -2
- package/prompts/common.md +2 -2
- package/prompts/discipline-normal.md +2 -1
- package/prompts/persona-engineering.md +7 -4
- package/provider/anthropic.mjs +14 -2
- package/provider/core.mjs +10 -4
- package/provider/errors.mjs +23 -0
- package/provider/google.mjs +31 -4
- package/provider/normalize.mjs +57 -0
- package/provider/responses-request.mjs +3 -1
- package/provider/responses.mjs +38 -4
- package/provider/sse.mjs +45 -13
- package/proxy.mjs +13 -3
- package/session.mjs +13 -2
- package/shell-identity.mjs +107 -0
- package/stream-destroy.mjs +62 -0
- package/tool-docs/advisor.md +1 -1
- package/tool-docs/bash.md +1 -1
- package/tool-docs/batch.md +1 -1
- package/tool-docs/delete.md +7 -5
- package/tool-docs/ledger.md +6 -0
- package/tool-docs/subagent.md +10 -0
- package/tool-docs/verify.md +1 -1
- package/tools/bash.mjs +9 -4
- package/tools/edit-batch.mjs +7 -2
- package/tools/index.mjs +1 -4
- package/tools/patch.mjs +8 -2
- package/tools/write-path.mjs +6 -4
- package/undo-stack.mjs +23 -9
- package/tool-docs/ledger_add.md +0 -1
- package/tool-docs/ledger_close.md +0 -1
- package/tool-docs/ledger_count.md +0 -1
- package/tool-docs/ledger_query.md +0 -1
- package/tool-docs/ledger_update.md +0 -1
package/provider/anthropic.mjs
CHANGED
|
@@ -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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
// 帮用户快速分辨"配错平台还是配错账号"。
|
package/provider/errors.mjs
CHANGED
|
@@ -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
|
+
}
|
package/provider/google.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/provider/normalize.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/provider/responses.mjs
CHANGED
|
@@ -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
|
|
80
|
-
e
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
else if (arg != null) slot.arguments
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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 })
|