@lqc123qwe/car-runtime 1.4.0 → 1.6.0

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.
@@ -9,8 +9,12 @@
9
9
  * - 重试基线(§3.5.4):仅网络错误/429/5xx、≤2 次、指数退避 1s/2s;**首块前失败才可重试**
10
10
  * (已消费的 chunk 不可重放——重试不产生额外副作用);重试耗尽 = CarM8Error B080001
11
11
  * (BD-04 error 收口由消费方 runTurn 落 turnEnd);流中途失败 = finishReason 'error' chunk;
12
- * - 超时基线:总 120s / 首字节 30s(均可注入缩短供测试);TLS 强制(https-only);
13
- * - declaredSideEffect 不出站(权限面只进权限门,不进模型请求体)。
12
+ * - 超时基线:总 120s / 首字节 30s(均可注入缩短供测试);TLS 强制(https-only,D-12a
13
+ * loopback 豁免共用 assertTlsOrLoopback);重试/超时/取消引擎单点共用(1.5-S1 streamSsePost
14
+ * ——openai-compat / anthropic 语义不漂移,parse 为唯一 provider 差异点);
15
+ * - declaredSideEffect 不出站(权限面只进权限门,不进模型请求体);
16
+ * - 1.5-S1(D-20):内置 anthropic 适配器(Messages API 流式;max_tokens 必填缺省 4096;
17
+ * system 顶层化;凭据 provider='anthropic')。
14
18
  */
15
19
  import { CarM8Error, providerUnreachable } from './errors.ts'
16
20
  import { CredentialService } from './credentials.ts'
@@ -107,6 +111,101 @@ export class RuntimeCore {
107
111
  }
108
112
  }
109
113
 
114
+ // ==================== 适配器共用基线(1.5-S1 抽取:D-12a TLS 门 + §3.5.4 流式引擎) ====================
115
+
116
+ /**
117
+ * D-12a TLS 强制门(openai-compat / anthropic 共用,口径不变):https 直接通过;
118
+ * http 仅豁免 loopback——本地模型服务(Ollama 等)与本地 E2E;非回环地址维持 TLS 强制
119
+ * (§3.2.M8.4)。豁免集显式列举,不扩大到局域网。
120
+ */
121
+ export function assertTlsOrLoopback(baseUrl: string): void {
122
+ if (baseUrl.startsWith('https://')) return
123
+ let loopback = false
124
+ try { const host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, ''); loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1' } catch { /* 解析失败 = 非法 URL,按非回环拒绝 */ }
125
+ if (!loopback) {
126
+ throw new Error(`CAR-E-LLM-TLS: baseUrl 必须 https(TLS 强制,§3.2.M8.4);http 仅豁免 loopback(127.0.0.1/[::1]/localhost,D-12a)——远程明文部署请经 TLS 代理或自建适配器`)
127
+ }
128
+ }
129
+
130
+ /** 流式 POST 请求规格(凭据解析在 buildRequest 内——A080001 首块前抛出,runTurn 可 error 收口) */
131
+ interface SsePostSpec {
132
+ url: string
133
+ buildRequest: () => Promise<{ method: 'POST'; headers: Record<string, string>; body: string }>
134
+ fetchImpl: typeof fetch
135
+ signal?: { aborted: boolean }
136
+ timeoutMs: number
137
+ firstByteMs: number
138
+ retries: number
139
+ backoffMs: number[]
140
+ sleep: (ms: number) => Promise<void>
141
+ }
142
+
143
+ /**
144
+ * §3.5.4 流式 POST 引擎(openai-compat / anthropic 单点共用,1.5-S1 抽取——两适配器重试/
145
+ * 超时/取消语义不漂移):仅网络错误/429/5xx、≤2 次、指数退避 1s/2s;首块前失败才可重试
146
+ * (已消费 chunk 不可重放);重试耗尽 = CarM8Error B080001;外部取消 ≠ 失败(D-19:静默
147
+ * 收口不 retry 不 error chunk);流自然结束未携带 finishReason = AL-05 显式 error chunk。
148
+ * `parse` 为唯一 provider 差异点(SSE 字节流 → LlmChunk)。
149
+ */
150
+ async function* streamSsePost(spec: SsePostSpec, parse: (body: AsyncIterable<Uint8Array>) => AsyncIterable<LlmChunk>): AsyncIterable<LlmChunk> {
151
+ const payload = await spec.buildRequest()
152
+ for (let attempt = 0; ; attempt++) {
153
+ // D-19:外部取消 ≠ 失败——attempt 前检查,静默收口(无 chunk、无 retry)
154
+ if (spec.signal?.aborted) return
155
+ const controller = new AbortController()
156
+ const started = Date.now()
157
+ let firstByteSeen = false
158
+ const firstByteTimer = setTimeout(() => controller.abort(), spec.firstByteMs)
159
+ const overallTimer = setTimeout(() => controller.abort(), spec.timeoutMs)
160
+ try {
161
+ const res = await spec.fetchImpl(spec.url, { ...payload, signal: controller.signal })
162
+ if (res.status === 429 || res.status >= 500) throw new Retryable(`HTTP ${res.status}`)
163
+ if (!res.ok) {
164
+ const text = await res.text().catch(() => '')
165
+ // 4xx 业务错不重试(§3.5.4);错误信息不含凭据(凭据值不进错误路径)
166
+ yield { finishReason: 'error', error: { code: 'B080001', message: `provider HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ''}` } }
167
+ return
168
+ }
169
+ if (!res.body) throw new Retryable('响应无 body 流')
170
+ let sawFinish = false
171
+ for await (const chunk of parse(res.body)) {
172
+ // D-19:chunk 间隙检查取消——abort 连接并静默收口(不产 error chunk,不违反
173
+ // finishReason 不可变;上层 chatStep 以自身 signal 检查抛 TurnAborted)
174
+ if (spec.signal?.aborted) { controller.abort(); return }
175
+ if (!firstByteSeen) { firstByteSeen = true; clearTimeout(firstByteTimer) }
176
+ if (chunk.finishReason !== undefined) sawFinish = true
177
+ yield chunk
178
+ }
179
+ // 流自然结束但未给 finishReason = 异常终止(AL-05 口径:缺失显式化,禁吞没);
180
+ // 已携带 finishReason 的流原样收口——兜底不得追加(M8-BUG-1 口径延续)
181
+ if (!sawFinish) {
182
+ yield { finishReason: 'error', error: { code: 'B080001', message: '流结束未携带 finish_reason(AL-05)' } }
183
+ }
184
+ return
185
+ } catch (e) {
186
+ // D-19:外部取消(abort 连锁的 fetch 异常)≠ 失败——静默收口,不走 retry
187
+ if (spec.signal?.aborted) return
188
+ const beforeFirstByte = !firstByteSeen
189
+ clearTimeout(firstByteTimer)
190
+ // 首块前失败 = 网络错误/429/5xx/首字节超时(§3.5.4 重试条件全集)——未消费任何 chunk
191
+ if (beforeFirstByte && attempt < spec.retries) {
192
+ await spec.sleep(spec.backoffMs[Math.min(attempt, spec.backoffMs.length - 1)]!)
193
+ continue
194
+ }
195
+ if (beforeFirstByte) {
196
+ // 重试耗尽 → B080001(BD-04:消费方 runTurn 以 error 收口)
197
+ throw providerUnreachable(`${(e as Error).name}: ${(e as Error).message}(尝试 ${attempt + 1}/${spec.retries + 1},${Date.now() - started}ms)`)
198
+ }
199
+ // 流中途失败:已消费 chunk 不可重放 → error chunk 收口
200
+ yield { finishReason: 'error', error: { code: 'B080001', message: `流中途失败:${(e as Error).message}` } }
201
+ return
202
+ } finally {
203
+ clearTimeout(firstByteTimer)
204
+ clearTimeout(overallTimer)
205
+ }
206
+ }
207
+ }
208
+
110
209
  // ==================== 内置 openai-compat 适配器(流式 SSE) ====================
111
210
 
112
211
  export interface OpenAICompatOptions {
@@ -198,15 +297,8 @@ async function* parseSseStream(body: AsyncIterable<Uint8Array>): AsyncIterable<L
198
297
  export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter & { id: string } {
199
298
  const id = opts.id ?? 'openai-compat'
200
299
  const baseUrl = opts.baseUrl.replace(/\/+$/, '')
201
- if (!baseUrl.startsWith('https://')) {
202
- // 1.4-S4(D-12a,1.2 规划期裁决):http 仅豁免 loopback——本地模型服务(Ollama 等)与
203
- // 本地 E2E;非回环地址维持 TLS 强制(§3.2.M8.4)。豁免集显式列举,不扩大到局域网。
204
- let loopback = false
205
- try { const host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, ''); loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1' } catch { /* 解析失败 = 非法 URL,按非回环拒绝 */ }
206
- if (!loopback) {
207
- throw new Error(`CAR-E-LLM-TLS: baseUrl 必须 https(TLS 强制,§3.2.M8.4);http 仅豁免 loopback(127.0.0.1/[::1]/localhost,D-12a)——远程明文部署请经 TLS 代理或自建适配器`)
208
- }
209
- }
300
+ // 1.4-S4(D-12a,1.2 规划期裁决):TLS 门 1.5-S1 抽取共用(口径与报错文案不变)
301
+ assertTlsOrLoopback(baseUrl)
210
302
  const url = `${baseUrl}/chat/completions`
211
303
  const fetchImpl = opts.fetchImpl ?? fetch
212
304
  const sleep = opts.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
@@ -218,7 +310,7 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
218
310
  const toOpenAI = (req: LlmRequest, apiKey: string) => ({
219
311
  // 1.4-BUG-1:method: 'POST' 缺失——M8 最小实现潜伏缺陷(注入式 fetch 的单测忽略 method,
220
312
  // 真 undici 对 GET+body 直接拒绝);car run 真路径接线时暴露(生产调用点核查产出)
221
- method: 'POST',
313
+ method: 'POST' as const,
222
314
  headers: {
223
315
  'content-type': 'application/json',
224
316
  authorization: `Bearer ${apiKey}`,
@@ -249,72 +341,194 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
249
341
 
250
342
  return {
251
343
  id,
252
- async *chat(req: LlmRequest): AsyncIterable<LlmChunk> {
253
- // 凭据门:resolve → reveal(SQ-07 凭据解析;A080001 在此抛出——首块前,runTurn 可 error 收口)
254
- let apiKey = ''
255
- if (opts.credentials) {
256
- const ref = opts.credentials.resolve(opts.provider ?? id, {})
257
- apiKey = opts.credentials.reveal(ref, {})
258
- }
259
- const payload = toOpenAI(req, apiKey)
260
-
261
- for (let attempt = 0; ; attempt++) {
262
- // 1.4-S4(D-19):外部取消 ≠ 失败——attempt 前检查,静默收口(无 chunk、无 retry)
263
- if (req.signal?.aborted) return
264
- const controller = new AbortController()
265
- const started = Date.now()
266
- let firstByteSeen = false
267
- const firstByteTimer = setTimeout(() => controller.abort(), firstByteMs)
268
- const overallTimer = setTimeout(() => controller.abort(), timeoutMs)
269
- try {
270
- const res = await fetchImpl(url, { ...payload, signal: controller.signal })
271
- if (res.status === 429 || res.status >= 500) throw new Retryable(`HTTP ${res.status}`)
272
- if (!res.ok) {
273
- const text = await res.text().catch(() => '')
274
- // 4xx 业务错不重试(§3.5.4);错误信息不含凭据(Bearer 值不进错误路径)
275
- yield { finishReason: 'error', error: { code: 'B080001', message: `provider HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ''}` } }
276
- return
277
- }
278
- if (!res.body) throw new Retryable('响应无 body 流')
279
- let sawFinish = false
280
- for await (const chunk of parseSseStream(res.body)) {
281
- // 1.4-S4(D-19):chunk 间隙检查取消——abort 连接并静默收口(不产 error chunk,
282
- // 不违反 finishReason 不可变;上层 chatStep 以自身 signal 检查抛 TurnAborted)
283
- if (req.signal?.aborted) { controller.abort(); return }
284
- if (!firstByteSeen) { firstByteSeen = true; clearTimeout(firstByteTimer) }
285
- if (chunk.finishReason !== undefined) sawFinish = true
286
- yield chunk
287
- }
288
- // 流自然结束但未给 finishReason = 异常终止(AL-05 口径:缺失显式化,禁吞没);
289
- // 已携带 finishReason 的流原样收口——兜底不得追加(M8-BUG-1:无条件 error chunk
290
- // 会污染成功流并触发 finishReason 守卫冲突;成功流零 error chunk 由 s25 断言钉死)
291
- if (!sawFinish) {
292
- yield { finishReason: 'error', error: { code: 'B080001', message: '流结束未携带 finish_reason(AL-05)' } }
344
+ chat(req: LlmRequest): AsyncIterable<LlmChunk> {
345
+ return streamSsePost({
346
+ url,
347
+ // 凭据门:resolve → reveal(SQ-07 凭据解析;A080001 在此抛出——首块前,runTurn 可 error 收口)
348
+ buildRequest: async () => {
349
+ let apiKey = ''
350
+ if (opts.credentials) {
351
+ const ref = opts.credentials.resolve(opts.provider ?? id, {})
352
+ apiKey = opts.credentials.reveal(ref, {})
293
353
  }
294
- return
295
- } catch (e) {
296
- // 1.4-S4(D-19):外部取消(abort 连锁的 fetch 异常)≠ 失败——静默收口,不走 retry
297
- if (req.signal?.aborted) return
298
- const beforeFirstByte = !firstByteSeen
299
- clearTimeout(firstByteTimer)
300
- // 首块前失败 = 网络错误/429/5xx/首字节超时(§3.5.4 重试条件全集)——未消费任何
301
- // chunk,请求体可重放,重试不产生额外副作用;流中途失败不可重放(chunk 已消费)
302
- if (beforeFirstByte && attempt < retries) {
303
- await sleep(backoffMs[Math.min(attempt, backoffMs.length - 1)]!)
304
- continue
305
- }
306
- if (beforeFirstByte) {
307
- // 重试耗尽 → B080001(BD-04:消费方 runTurn 以 error 收口)
308
- throw providerUnreachable(`${(e as Error).name}: ${(e as Error).message}(尝试 ${attempt + 1}/${retries + 1},${Date.now() - started}ms)`)
309
- }
310
- // 流中途失败:已消费 chunk 不可重放 → error chunk 收口
311
- yield { finishReason: 'error', error: { code: 'B080001', message: `流中途失败:${(e as Error).message}` } }
312
- return
313
- } finally {
314
- clearTimeout(firstByteTimer)
315
- clearTimeout(overallTimer)
354
+ return toOpenAI(req, apiKey)
355
+ },
356
+ fetchImpl,
357
+ signal: req.signal,
358
+ timeoutMs, firstByteMs, retries, backoffMs, sleep,
359
+ }, parseSseStream)
360
+ },
361
+ }
362
+ }
363
+
364
+ // ==================== 内置 anthropic 适配器(Messages API 流式 SSE,1.5-S1 / D-20) ====================
365
+
366
+ export interface AnthropicAdapterOptions {
367
+ id?: string
368
+ /** API 源(如 https://api.anthropic.com;请求路径 = {baseUrl}/v1/messages,与官方 SDK base_url 同约定) */
369
+ baseUrl: string
370
+ /** 凭据服务与 provider 名(x-api-key 来源;provider 缺省 'anthropic' → ANTHROPIC_API_KEY) */
371
+ credentials?: CredentialService
372
+ provider?: string
373
+ fetchImpl?: typeof fetch
374
+ /** §3.5.4 基线:总 120s / 首字节 30s / 2 次退避 1s-2s(与 openai-compat 同款) */
375
+ timeoutMs?: number
376
+ firstByteMs?: number
377
+ retries?: number
378
+ backoffMs?: number[]
379
+ sleep?: (ms: number) => Promise<void>
380
+ /** D-20:Anthropic API max_tokens 必填——req.maxTokens 缺席时取此缺省(provider 差异登记,缺省 4096) */
381
+ defaultMaxTokens?: number
382
+ }
383
+
384
+ /** stop_reason → CAR finishReason(未映射 fail-visible——openai FINISH_MAP 同纪律) */
385
+ const ANTHROPIC_STOP_REASON_MAP: Record<string, LlmChunk['finishReason']> = {
386
+ end_turn: 'stop',
387
+ stop_sequence: 'stop',
388
+ max_tokens: 'length',
389
+ tool_use: 'toolUse',
390
+ refusal: 'aborted',
391
+ }
392
+
393
+ /**
394
+ * Anthropic Messages API 请求体(D-20):system 消息提升为顶层 `system`(多条 \n\n 连接);
395
+ * toolResult 收敛进 user 消息 content(连续多条合并为单 user 多 tool_result 块——Anthropic
396
+ * 消息序列约束);assistant 工具调用 = content:[{type:'tool_use',id,name,input}];
397
+ * declaredSideEffect 不出站(与 openai-compat 同红线)。
398
+ */
399
+ function toAnthropic(req: LlmRequest, apiKey: string) {
400
+ const system: string[] = []
401
+ const turns: Array<{ role: 'user' | 'assistant'; content: unknown }> = []
402
+ let pendingToolResults: Array<Record<string, unknown>> = []
403
+ const flushToolResults = () => {
404
+ if (!pendingToolResults.length) return
405
+ turns.push({ role: 'user', content: pendingToolResults })
406
+ pendingToolResults = []
407
+ }
408
+ for (const m of req.messages) {
409
+ if (m.role === 'system') {
410
+ system.push(typeof m.content === 'string' ? m.content : JSON.stringify(m.content))
411
+ continue
412
+ }
413
+ if (m.role === 'toolResult') {
414
+ const p = m.content as { id?: string; result?: unknown; error?: string }
415
+ pendingToolResults.push({
416
+ type: 'tool_result',
417
+ tool_use_id: p?.id ?? '',
418
+ content: p?.error ?? JSON.stringify(p?.result ?? null),
419
+ ...(p?.error ? { is_error: true } : {}),
420
+ })
421
+ continue
422
+ }
423
+ flushToolResults()
424
+ if (m.role === 'assistant' && m.content != null && typeof m.content === 'object' && 'toolCall' in (m.content as object)) {
425
+ const tc = (m.content as { toolCall: { id: string; tool: string; args: unknown } }).toolCall
426
+ turns.push({ role: 'assistant', content: [{ type: 'tool_use', id: tc.id, name: tc.tool, input: tc.args ?? {} }] })
427
+ } else {
428
+ turns.push({ role: m.role, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) })
429
+ }
430
+ }
431
+ flushToolResults()
432
+ return {
433
+ method: 'POST' as const,
434
+ headers: {
435
+ 'content-type': 'application/json',
436
+ 'x-api-key': apiKey,
437
+ 'anthropic-version': '2023-06-01',
438
+ },
439
+ body: JSON.stringify({
440
+ model: req.model,
441
+ stream: true,
442
+ max_tokens: req.maxTokens ?? 4096,
443
+ ...(system.length ? { system: system.join('\n\n') } : {}),
444
+ messages: turns,
445
+ ...(req.tools.length
446
+ ? { tools: req.tools.map(t => ({ name: t.name, description: t.description, input_schema: t.parameters })) }
447
+ : {}),
448
+ }),
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Anthropic SSE → LlmChunk 流解析(data-only:data JSON 自带 `type` 字段,`event:` 行不依赖)。
454
+ * content_block_start(tool_use) → toolCallDelta{id,name};input_json_delta → toolCallDelta{argumentsDelta};
455
+ * message_delta.stop_reason → finishReason(终态信号,单次);ping/message_stop/未知事件容错跳过。
456
+ */
457
+ async function* parseAnthropicSseStream(body: AsyncIterable<Uint8Array>): AsyncIterable<LlmChunk> {
458
+ const decoder = new TextDecoder()
459
+ let buf = ''
460
+ for await (const bytes of body) {
461
+ buf += decoder.decode(bytes, { stream: true })
462
+ let nl: number
463
+ while ((nl = buf.indexOf('\n')) >= 0) {
464
+ const line = buf.slice(0, nl).replace(/\r$/, '')
465
+ buf = buf.slice(nl + 1)
466
+ if (!line.startsWith('data:')) continue
467
+ const data = line.slice(5).trim()
468
+ if (!data) continue
469
+ let evt: any
470
+ try { evt = JSON.parse(data) } catch { continue } // 心跳/注释行容错跳过
471
+ const type = evt?.type
472
+ if (type === 'content_block_start') {
473
+ const block = evt.content_block
474
+ if (block?.type === 'tool_use') {
475
+ yield { toolCallDelta: { index: evt.index, id: block.id, name: block.name } }
316
476
  }
477
+ } else if (type === 'content_block_delta') {
478
+ const d = evt.delta
479
+ if (d?.type === 'text_delta' && typeof d.text === 'string' && d.text.length) {
480
+ yield { delta: d.text }
481
+ } else if (d?.type === 'input_json_delta' && typeof d.partial_json === 'string' && d.partial_json.length) {
482
+ yield { toolCallDelta: { index: evt.index, argumentsDelta: d.partial_json } }
483
+ }
484
+ // thinking_delta / citations_delta 等非 CAR 消费面:容错跳过
485
+ } else if (type === 'message_delta') {
486
+ const raw = evt.delta?.stop_reason
487
+ if (raw != null) {
488
+ const finish = ANTHROPIC_STOP_REASON_MAP[String(raw)]
489
+ yield finish
490
+ ? { finishReason: finish }
491
+ : { finishReason: 'error', error: { code: 'B080001', message: `未映射的 stop_reason "${raw}"` } }
492
+ }
493
+ } else if (type === 'error') {
494
+ // 流内 error 事件(overloaded_error 等)→ error chunk 收口(后续 message_delta 由守卫拦冲突)
495
+ yield { finishReason: 'error', error: { code: 'B080001', message: `provider 流错误:${evt.error?.message ?? JSON.stringify(evt).slice(0, 200)}` } }
317
496
  }
497
+ }
498
+ }
499
+ }
500
+
501
+ export function createAnthropicAdapter(opts: AnthropicAdapterOptions): LlmAdapter & { id: string } {
502
+ const id = opts.id ?? 'anthropic'
503
+ const baseUrl = opts.baseUrl.replace(/\/+$/, '')
504
+ assertTlsOrLoopback(baseUrl)
505
+ const url = `${baseUrl}/v1/messages`
506
+ const fetchImpl = opts.fetchImpl ?? fetch
507
+ const sleep = opts.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
508
+ const timeoutMs = opts.timeoutMs ?? 120_000
509
+ const firstByteMs = opts.firstByteMs ?? 30_000
510
+ const retries = opts.retries ?? 2
511
+ const backoffMs = opts.backoffMs ?? [1_000, 2_000]
512
+
513
+ return {
514
+ id,
515
+ chat(req: LlmRequest): AsyncIterable<LlmChunk> {
516
+ return streamSsePost({
517
+ url,
518
+ // 凭据门:resolve → reveal(provider='anthropic' → keychain car-runtime/anthropic →
519
+ // env ANTHROPIC_API_KEY——credentials.ts 1.4 既有登记;A080001 首块前抛出)
520
+ buildRequest: async () => {
521
+ let apiKey = ''
522
+ if (opts.credentials) {
523
+ const ref = opts.credentials.resolve(opts.provider ?? id, {})
524
+ apiKey = opts.credentials.reveal(ref, {})
525
+ }
526
+ return toAnthropic(req, apiKey)
527
+ },
528
+ fetchImpl,
529
+ signal: req.signal,
530
+ timeoutMs, firstByteMs, retries, backoffMs, sleep,
531
+ }, parseAnthropicSseStream)
318
532
  },
319
533
  }
320
534
  }
@@ -4,6 +4,7 @@
4
4
  * 设计口径:
5
5
  * - 哈希链(SHA-256)为 CAR 自研增强(dsh 无此物,源码级核实)——合规卖点落地
6
6
  * - 「Model-visible means logged」:断言口径 = 请求时快照 === 日志前缀 [0, atSeq) 投影
7
+ * - 1.6-S1(D-23):投影对同 id toolResult 收敛 last-wins——事件面/审计面不动,模型可见流去冗余
7
8
  * - append-only:本层仅暴露 append/verifyChain/deriveMessages/assertModelVisibleLogged
8
9
  */
9
10
  import { createHash } from 'node:crypto'
@@ -83,16 +84,33 @@ export class SessionLog {
83
84
  return null
84
85
  }
85
86
 
86
- /** 投影:从日志前缀重建模型可见消息流(不依赖模型运行时状态) */
87
+ /**
88
+ * 投影:从日志前缀重建模型可见消息流(不依赖模型运行时状态)。
89
+ * 1.6-S1(D-23)同 id toolResult 收敛 last-wins:写类工具授权决策(runtime/toolResult)
90
+ * 与执行结果(plugin/toolResult)成对落链(M4 既有语义冻结——审计全量留痕不动),
91
+ * 模型可见流只保留最终结果(真实 provider 对重复 tool_call_id/tool_use_id 拒绝或未定义,
92
+ * 1.4 观察项经真实适配器落地升级为缺陷面)。id 缺席不收敛(hostRaw/无 id 载荷形态不变,
93
+ * golden ④ 投影语义兼容);授权拒绝单条即唯一结果原样保留。适配器侧零改动(消费已收敛上游)。
94
+ */
87
95
  deriveMessages(upTo?: number): Array<{ role: string; [k: string]: unknown }> {
96
+ const scoped = upTo !== undefined ? this.events.slice(0, upTo) : this.events
97
+ const lastSeqById = new Map<string, number>()
98
+ for (const e of scoped) {
99
+ if (e.kind !== 'toolResult') continue
100
+ const id = (e.payload as { id?: unknown } | null)?.id
101
+ if (typeof id === 'string') lastSeqById.set(id, e.seq)
102
+ }
88
103
  const out: Array<{ role: string; [k: string]: unknown }> = []
89
- for (const e of this.events) {
90
- if (upTo !== undefined && e.seq >= upTo) break
104
+ for (const e of scoped) {
91
105
  if (e.kind === 'system') out.push({ role: 'system', content: e.payload })
92
106
  else if (e.kind === 'user') out.push({ role: 'user', content: e.payload })
93
107
  else if (e.kind === 'assistant') out.push({ role: 'assistant', content: e.payload })
94
108
  else if (e.kind === 'toolCall') out.push({ role: 'assistant', toolCall: e.payload })
95
- else if (e.kind === 'toolResult') out.push({ role: 'toolResult', content: e.payload })
109
+ else if (e.kind === 'toolResult') {
110
+ const id = (e.payload as { id?: unknown } | null)?.id
111
+ if (typeof id === 'string' && lastSeqById.get(id) !== e.seq) continue // D-23:后续有同 id 最终结果,本条为授权中间记录
112
+ out.push({ role: 'toolResult', content: e.payload })
113
+ }
96
114
  }
97
115
  return out
98
116
  }