@lqc123qwe/car-runtime 1.13.0 → 1.14.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.
package/dist/seam.d.ts CHANGED
@@ -17,6 +17,11 @@
17
17
  * 只读载入面;载入/校验单一权威在内核,桌面禁自实现哈希重算)+ K-S3-3 exportForensicsBundle/verifyBundle 出面
18
18
  * (取证包导出 + 读回重验;断链即中止语义随本面)。恢复面 K-S3-2 零登记确认:SessionLog.fromEvents 自 M5 DEC-4
19
19
  * 起已随本面可达(fork.ts 生产调用点),S2 D-S2-6 前提修正登记(S3 执行期发现)。
20
+ * 加法登记:1.14.0 —— PluginManager 装载报告面(K-S4-1 / D-43):loadPlugins 出面(六阶段流水线
21
+ * discover/verify/parse/validate/topo/register——LoadReportVO 结构化 VO,verify/topo 真值取代 v0.1 桌面
22
+ * mountPlugin 组合呈现的 SKIPPED 标注)+ ReloadManager 出面(热重载 epoch 击穿 + invalidate 语义随本面)+
23
+ * verifyPluginFile 出面(签名校验挂载点——宿主自有装载路径可复用同一签名门;minisign 轨唯一实装,sigstore
24
+ * 主轨维持 verifier 骨架期预留——D-43 裁决:登记 strict 期限不引入 sigstore 依赖,engines 硬不兼容见评估报告)。
20
25
  */
21
26
  export { SessionLog, loadSessionLog, deriveSessionId, type SessionEvent, type EventKind, type Actor } from './session/log.ts';
22
27
  export { SessionFileStore } from './session/store.ts';
@@ -40,3 +45,5 @@ export { renderSdkFromRegistry } from './ptc/sdk.ts';
40
45
  export type { McpClientSpanHandle, PtcRelayHandle } from './runtime-core/trace.ts';
41
46
  export { redact, type RedactionResult } from './runtime-core/redaction.ts';
42
47
  export { exportForensicsBundle, verifyBundle, type ForensicsBundle, type ExportMeta } from './session/export.ts';
48
+ export { loadPlugins, ReloadManager, type LoadReportVO, type LoadPluginsResult, type LoadPluginsOptions, type LoadStage, type StageStatus, type PeerConflictChain } from './load/report.ts';
49
+ export { verifyPluginFile, type SignatureGateOptions, type SigGateResult } from './load/sigGate.ts';
package/dist/seam.js CHANGED
@@ -17,6 +17,11 @@
17
17
  * 只读载入面;载入/校验单一权威在内核,桌面禁自实现哈希重算)+ K-S3-3 exportForensicsBundle/verifyBundle 出面
18
18
  * (取证包导出 + 读回重验;断链即中止语义随本面)。恢复面 K-S3-2 零登记确认:SessionLog.fromEvents 自 M5 DEC-4
19
19
  * 起已随本面可达(fork.ts 生产调用点),S2 D-S2-6 前提修正登记(S3 执行期发现)。
20
+ * 加法登记:1.14.0 —— PluginManager 装载报告面(K-S4-1 / D-43):loadPlugins 出面(六阶段流水线
21
+ * discover/verify/parse/validate/topo/register——LoadReportVO 结构化 VO,verify/topo 真值取代 v0.1 桌面
22
+ * mountPlugin 组合呈现的 SKIPPED 标注)+ ReloadManager 出面(热重载 epoch 击穿 + invalidate 语义随本面)+
23
+ * verifyPluginFile 出面(签名校验挂载点——宿主自有装载路径可复用同一签名门;minisign 轨唯一实装,sigstore
24
+ * 主轨维持 verifier 骨架期预留——D-43 裁决:登记 strict 期限不引入 sigstore 依赖,engines 硬不兼容见评估报告)。
20
25
  */
21
26
  export { SessionLog, loadSessionLog, deriveSessionId } from './session/log.js';
22
27
  export { SessionFileStore } from './session/store.js';
@@ -40,3 +45,6 @@ export { renderSdkFromRegistry } from './ptc/sdk.js';
40
45
  // —— 1.13.0:桌面 S3 治理面(文件安全/审计/取证)——
41
46
  export { redact } from './runtime-core/redaction.js';
42
47
  export { exportForensicsBundle, verifyBundle } from './session/export.js';
48
+ // —— 1.14.0(K-S4-1 / D-43):PluginManager 装载报告面 + 签名校验挂载点 ——
49
+ export { loadPlugins, ReloadManager } from './load/report.js';
50
+ export { verifyPluginFile } from './load/sigGate.js';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 1.14-S2 · 历史压缩面(K-MP-2 / D-40)——策略器 + 执行器 + 确定性摘要
3
+ *
4
+ * 口径(红线先立后码):
5
+ * - **append-only 红线**:压缩 = 追加一条 'compact' 事件(payload 携带摘要 + 折叠前缀边界
6
+ * compactedUpTo),**不改写 / 不删除任何已落链事件**——SessionLog 无 update/delete API,
7
+ * 压缩只走 append;node:test 断言压缩前后 verifyChain() 均 null 且旧事件逐字节不变;
8
+ * - **投影语义**:compact 只影响其后请求的投影(deriveMessages last-wins),压缩前快照
9
+ * (atSeq ≤ compact.seq)投影仍为全量形态——N1 不变量(Model-visible means logged)
10
+ * 逐快照自洽,无需豁免;
11
+ * - **保护段**:首个 system 事件(会话级提示)+ 最近 N turn(protectLastTurns,缺省 4)
12
+ * 不进折叠区间;中途 system 事件按普通事件折叠(内容保留进摘要,信息不丢——D-40 执行期细化);
13
+ * - **工具对完整性(toolPairIntegrity)**:折叠边界若落在 toolCall↔toolResult 配对中间
14
+ * (call 在区间内、result 在保护段)→ 边界回退到该 toolCall(成对整体移出),循环至不动点;
15
+ * - **fail-visible**:无可压缩区间(全部在保护段 / 已压缩过)→ CAR-E-COMPACT-NORANGE 显式报错,
16
+ * 禁静默成功;摘要生成产物非非空字符串 → CAR-E-COMPACT-SUMMARY 显式报错;
17
+ * - **零新依赖**:摘要缺省为确定性生成器(轮次意图 + 工具调用统计 + 收口状态——事实拼装,
18
+ * 禁估算);宿主可注入 LLM 摘要(summarize 缝——内核不依赖 LLM 做压缩);
19
+ * - **触发面**:shouldCompact 判定(thresholdEvents 事实计数,非 token 估算)+ applyCompaction
20
+ * 立即 API;自动挂载点在宿主装配层(runTurn 之间调用,内核不改 runTurn 语义)。
21
+ */
22
+ import type { SessionEvent, SessionLog } from './log.ts';
23
+ export interface CompactionConfig {
24
+ /** 缺省 false = 行为零变化(1.13 兼容线) */
25
+ enabled?: boolean;
26
+ /** 距上次压缩以来的新增事件数阈值(事实计数,非 token 估算);缺省 200 */
27
+ thresholdEvents?: number;
28
+ /** 保护最近 N 个 turn 不折叠;缺省 4 */
29
+ protectLastTurns?: number;
30
+ }
31
+ export interface CompactionPlan {
32
+ /** 折叠前缀边界 [0, compactedUpTo)——compact 事件 payload 原样携带 */
33
+ compactedUpTo: number;
34
+ /** 折叠区间覆盖的 turn 数(去重) */
35
+ compactedTurns: number;
36
+ /** 折叠区间事件引用(供摘要生成;只读) */
37
+ events: readonly SessionEvent[];
38
+ }
39
+ /** shouldCompact:距上次压缩的新增事件数 ≥ thresholdEvents(enabled 缺省关 = 零变化) */
40
+ export declare function shouldCompact(log: SessionLog, cfg: CompactionConfig | undefined): boolean;
41
+ /**
42
+ * planCompaction:计算折叠区间(纯函数,不落链)。
43
+ * 保护段 = 首个 system 事件 + 最近 protectLastTurns 个 turn(按 turnId 首次出现序);
44
+ * 边界回退保证 toolCall↔toolResult 成对(toolPairIntegrity);无可压缩区间显式报错。
45
+ */
46
+ export declare function planCompaction(log: SessionLog, protectLastTurns?: number): CompactionPlan;
47
+ /** 确定性摘要(缺省生成器):事实拼装(轮次意图 + 工具统计 + 收口状态),禁估算禁 LLM 依赖 */
48
+ export declare function defaultSummarizer(events: readonly SessionEvent[]): string;
49
+ /**
50
+ * applyCompaction:执行压缩(唯一落链动作 = append 一条 'compact' 事件——原事件链不改写)。
51
+ * summarize 缝:宿主可注入 LLM 摘要(返回非空字符串;缺省确定性生成器)。
52
+ */
53
+ export declare function applyCompaction(log: SessionLog, opts?: {
54
+ protectLastTurns?: number;
55
+ turnId?: string;
56
+ summarize?: (events: readonly SessionEvent[]) => string;
57
+ }): SessionEvent;
@@ -0,0 +1,128 @@
1
+ /** 上次压缩边界(无 compact 事件 = 0)——重复压缩不越界(幂等区间的下界) */
2
+ function lastCompactedUpTo(log) {
3
+ let last = 0;
4
+ for (const e of log.events) {
5
+ if (e.kind !== 'compact')
6
+ continue;
7
+ const p = (e.payload ?? {});
8
+ if (typeof p.compactedUpTo === 'number' && p.compactedUpTo > last)
9
+ last = p.compactedUpTo;
10
+ }
11
+ return last;
12
+ }
13
+ /** shouldCompact:距上次压缩的新增事件数 ≥ thresholdEvents(enabled 缺省关 = 零变化) */
14
+ export function shouldCompact(log, cfg) {
15
+ if (!cfg || cfg.enabled !== true)
16
+ return false;
17
+ const threshold = cfg.thresholdEvents ?? 200;
18
+ return log.events.length - lastCompactedUpTo(log) >= threshold;
19
+ }
20
+ /**
21
+ * planCompaction:计算折叠区间(纯函数,不落链)。
22
+ * 保护段 = 首个 system 事件 + 最近 protectLastTurns 个 turn(按 turnId 首次出现序);
23
+ * 边界回退保证 toolCall↔toolResult 成对(toolPairIntegrity);无可压缩区间显式报错。
24
+ */
25
+ export function planCompaction(log, protectLastTurns = 4) {
26
+ const events = log.events;
27
+ const floor = lastCompactedUpTo(log); // 上界不越过上次压缩边界(幂等)
28
+ // 保护起点候选:最近 N 个 turn 的首事件 seq
29
+ const turnFirst = new Map();
30
+ for (const e of events) {
31
+ if (e.turnId === 'compaction')
32
+ continue; // 压缩事件自身不构成 turn
33
+ if (!turnFirst.has(e.turnId))
34
+ turnFirst.set(e.turnId, e.seq);
35
+ }
36
+ const recentTurns = [...turnFirst.entries()].slice(-protectLastTurns);
37
+ let protectStart = recentTurns.length ? Math.min(...recentTurns.map(([, seq]) => seq)) : events.length;
38
+ // 统一规则:折叠区间 = [lowerBound, protectStart),lowerBound = max(上次压缩边界, 首个 system.seq + 1)
39
+ // ——首个 system(会话级提示)恒不被折叠;floor 已含它时(上次压缩边界在其后)不受限
40
+ const firstSystem = events.find(e => e.kind === 'system');
41
+ const lowerBound = firstSystem ? Math.max(floor, firstSystem.seq + 1) : floor;
42
+ if (protectStart <= lowerBound) {
43
+ throw new Error(`CAR-E-COMPACT-NORANGE: 无可压缩区间(保护段 = 首个 system + 最近 ${protectLastTurns} turn,上次压缩边界 = ${floor})——禁静默成功`);
44
+ }
45
+ // toolPairIntegrity:边界落在配对中间 → 回退到 toolCall(成对整体移出),循环至不动点
46
+ for (let guard = 0; guard < 1000; guard++) {
47
+ const callIds = new Map(); // id → call seq(区间内)
48
+ for (let i = lowerBound; i < protectStart; i++) {
49
+ const e = events[i];
50
+ if (e.kind === 'toolCall') {
51
+ const id = e.payload?.id;
52
+ if (typeof id === 'string')
53
+ callIds.set(id, e.seq);
54
+ }
55
+ }
56
+ let earliestCallSeq;
57
+ for (let i = protectStart; i < events.length; i++) {
58
+ const e = events[i];
59
+ if (e.kind !== 'toolResult')
60
+ continue;
61
+ const id = e.payload?.id;
62
+ if (typeof id === 'string' && callIds.has(id)) {
63
+ const callSeq = callIds.get(id);
64
+ if (earliestCallSeq === undefined || callSeq < earliestCallSeq)
65
+ earliestCallSeq = callSeq;
66
+ }
67
+ }
68
+ if (earliestCallSeq === undefined)
69
+ break; // 无破裂配对——边界成立
70
+ protectStart = earliestCallSeq; // 成对整体移出
71
+ if (protectStart <= lowerBound) {
72
+ throw new Error(`CAR-E-COMPACT-NORANGE: 工具对回退后无区间(toolCall↔toolResult 成对完整性不可满足)——禁静默成功`);
73
+ }
74
+ }
75
+ const compacted = events.slice(lowerBound, protectStart);
76
+ const turns = new Set(compacted.map(e => e.turnId));
77
+ return { compactedUpTo: protectStart, compactedTurns: turns.size, events: compacted };
78
+ }
79
+ /** 确定性摘要(缺省生成器):事实拼装(轮次意图 + 工具统计 + 收口状态),禁估算禁 LLM 依赖 */
80
+ export function defaultSummarizer(events) {
81
+ const cut = (v, n) => {
82
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
83
+ return s.length > n ? s.slice(0, n) + '…' : s;
84
+ };
85
+ const lines = [`[会话压缩摘要] 折叠事件 ${events.length} 条(${events[0]?.seq ?? 0}–${events.at(-1)?.seq ?? 0}):`];
86
+ const toolCounts = new Map();
87
+ const endReasons = new Map();
88
+ for (const e of events) {
89
+ if (e.kind === 'user')
90
+ lines.push(`- [${e.turnId}] 用户:${cut(e.payload, 200)}`);
91
+ else if (e.kind === 'assistant' && typeof e.payload === 'string' && e.payload.trim())
92
+ lines.push(`- [${e.turnId}] 助手:${cut(e.payload, 160)}`);
93
+ else if (e.kind === 'toolCall') {
94
+ const p = e.payload;
95
+ const name = typeof p?.tool === 'string' ? p.tool : '?';
96
+ toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
97
+ }
98
+ else if (e.kind === 'turnEnd') {
99
+ const r = e.meta?.reason;
100
+ const key = typeof r === 'string' ? r : 'unknown';
101
+ endReasons.set(key, (endReasons.get(key) ?? 0) + 1);
102
+ }
103
+ }
104
+ if (toolCounts.size) {
105
+ lines.push(`工具调用:${[...toolCounts.entries()].map(([t, n]) => `${t} ×${n}`).join('、')}`);
106
+ }
107
+ if (endReasons.size) {
108
+ lines.push(`turn 收口:${[...endReasons.entries()].map(([r, n]) => `${r} ×${n}`).join('、')}`);
109
+ }
110
+ return lines.join('\n');
111
+ }
112
+ /**
113
+ * applyCompaction:执行压缩(唯一落链动作 = append 一条 'compact' 事件——原事件链不改写)。
114
+ * summarize 缝:宿主可注入 LLM 摘要(返回非空字符串;缺省确定性生成器)。
115
+ */
116
+ export function applyCompaction(log, opts = {}) {
117
+ const plan = planCompaction(log, opts.protectLastTurns ?? 4);
118
+ const summary = opts.summarize ? opts.summarize(plan.events) : defaultSummarizer(plan.events);
119
+ if (typeof summary !== 'string' || !summary.trim().length) {
120
+ throw new Error(`CAR-E-COMPACT-SUMMARY: 摘要生成产物为空(fail-visible,禁静默折叠空摘要)`);
121
+ }
122
+ return log.append('runtime', 'compact', opts.turnId ?? 'compaction', {
123
+ summary,
124
+ compactedUpTo: plan.compactedUpTo,
125
+ compactedTurns: plan.compactedTurns,
126
+ compactedEvents: plan.events.length,
127
+ });
128
+ }
@@ -1,4 +1,4 @@
1
- export type EventKind = 'user' | 'assistant' | 'toolCall' | 'toolResult' | 'turnEnd' | 'goalUpdate' | 'hostRaw' | 'fork' | 'system' | 'usage';
1
+ export type EventKind = 'user' | 'assistant' | 'toolCall' | 'toolResult' | 'turnEnd' | 'goalUpdate' | 'hostRaw' | 'fork' | 'system' | 'usage' | 'compact' | 'routing';
2
2
  export type Actor = 'user' | 'model' | 'plugin' | 'runtime';
3
3
  export interface SessionEvent {
4
4
  seq: number;
@@ -41,6 +41,10 @@ export declare class SessionLog {
41
41
  * 模型可见流只保留最终结果(真实 provider 对重复 tool_call_id/tool_use_id 拒绝或未定义,
42
42
  * 1.4 观察项经真实适配器落地升级为缺陷面)。id 缺席不收敛(hostRaw/无 id 载荷形态不变,
43
43
  * golden ④ 投影语义兼容);授权拒绝单条即唯一结果原样保留。适配器侧零改动(消费已收敛上游)。
44
+ * 1.14-S2(K-MP-2 / D-40):compact 事件投影——scoped 内最后一条 compact 生效(last-wins),
45
+ * 其 compactedUpTo 前缀折叠为单条 system 摘要(事件原文不改写,仅投影层折叠);compact 之前的
46
+ * 请求快照(atSeq ≤ compact.seq)不含 compact 事件 → 投影仍为全量形态(N1 不变量自洽——
47
+ * 「请求时发出 = 日志前缀投影」逐快照成立);无 compact 事件的日志投影与 1.13 逐字节一致。
44
48
  */
45
49
  deriveMessages(upTo?: number): Array<{
46
50
  role: string;
@@ -64,11 +64,29 @@ export class SessionLog {
64
64
  * 模型可见流只保留最终结果(真实 provider 对重复 tool_call_id/tool_use_id 拒绝或未定义,
65
65
  * 1.4 观察项经真实适配器落地升级为缺陷面)。id 缺席不收敛(hostRaw/无 id 载荷形态不变,
66
66
  * golden ④ 投影语义兼容);授权拒绝单条即唯一结果原样保留。适配器侧零改动(消费已收敛上游)。
67
+ * 1.14-S2(K-MP-2 / D-40):compact 事件投影——scoped 内最后一条 compact 生效(last-wins),
68
+ * 其 compactedUpTo 前缀折叠为单条 system 摘要(事件原文不改写,仅投影层折叠);compact 之前的
69
+ * 请求快照(atSeq ≤ compact.seq)不含 compact 事件 → 投影仍为全量形态(N1 不变量自洽——
70
+ * 「请求时发出 = 日志前缀投影」逐快照成立);无 compact 事件的日志投影与 1.13 逐字节一致。
67
71
  */
68
72
  deriveMessages(upTo) {
69
73
  const scoped = upTo !== undefined ? this.events.slice(0, upTo) : this.events;
70
- const lastSeqById = new Map();
74
+ let compactEvent;
71
75
  for (const e of scoped) {
76
+ if (e.kind === 'compact')
77
+ compactEvent = e;
78
+ } // last-wins
79
+ let compactedUpTo;
80
+ if (compactEvent) {
81
+ const cp = (compactEvent.payload ?? {});
82
+ if (typeof cp.summary !== 'string' || !cp.summary.length || typeof cp.compactedUpTo !== 'number') {
83
+ throw new Error(`CAR-E-COMPACT-PAYLOAD: compact 事件 seq=${compactEvent.seq} 缺有效 summary/compactedUpTo(投影拒绝——fail-visible)`);
84
+ }
85
+ compactedUpTo = cp.compactedUpTo; // 折叠边界 = payload 声明的前缀边界(非 compact 事件自身 seq)
86
+ }
87
+ const projected = compactedUpTo !== undefined ? scoped.filter(e => e.seq >= compactedUpTo) : scoped;
88
+ const lastSeqById = new Map();
89
+ for (const e of projected) {
72
90
  if (e.kind !== 'toolResult')
73
91
  continue;
74
92
  const id = e.payload?.id;
@@ -76,7 +94,9 @@ export class SessionLog {
76
94
  lastSeqById.set(id, e.seq);
77
95
  }
78
96
  const out = [];
79
- for (const e of scoped) {
97
+ if (compactEvent)
98
+ out.push({ role: 'system', content: (compactEvent.payload.summary) });
99
+ for (const e of projected) {
80
100
  if (e.kind === 'system')
81
101
  out.push({ role: 'system', content: e.payload });
82
102
  else if (e.kind === 'user')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lqc123qwe/car-runtime",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Composable Agent Runtime (CAR) - microkernel plugin system with sandboxed PTC, append-only hashed session log, and MCP host bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import { Context } from './kernel/context.ts'
25
25
  import { mountPlugin } from './load/loader.ts'
26
26
  import { verifyPluginFile } from './load/sigGate.ts'
27
27
  import { loadCarConfig, mergeSignatureGate, mergeLlmConfig, warmCarConfig } from './load/config.ts'
28
+ import { assembleModelPool } from './load/pool.ts'
28
29
  import { generateSigningKeypair, signPluginFile, resolveTrustRoot } from './load/sign.ts'
29
30
  import { doctorCredentials, doctorConnectivity, doctorKeychain, doctorSignature, doctorModelReadiness } from './dx/doctor.ts'
30
31
  import { RuntimeCore, createOpenAICompatAdapter, createAnthropicAdapter } from './runtime-core/llm.ts'
@@ -64,8 +65,9 @@ const HELP = `用法: car <command> [args]
64
65
  CAR_UNSIGNED_ALLOW=1 显式声明豁免(warn 缺签路径计数 confirmed=yes,横幅保留)
65
66
  配置文件通道(1.5 起双载体):cwd car.config.ts(export default {…};发现序 ts > json)或
66
67
  car.config.json 或 --config <path>;键位 sandbox.unsigned.allow / sandbox.sig.enforce /
67
- sandbox.sig.trustRoot / llm.* / mcp.servers.*(stdio: command/args/env;远程: url/headers,
68
- \${ENV_VAR} 引用注入凭据);优先级 flag > env > 配置 > 缺省;未知键 fail-visible`
68
+ sandbox.sig.trustRoot / llm.* / llm.modelPool.*(多端点 fallback:endpoints id/baseUrl/model/
69
+ adapterId/keyEnv/priority/weight/maxTokens + probe + maxAttempts;路由决策落链)/ mcp.servers.*
70
+ (stdio: command/args/env;远程: url/headers,\${ENV_VAR} 引用注入凭据);优先级 flag > env > 配置 > 缺省;未知键 fail-visible`
69
71
 
70
72
  // 进程内热重载会话(ReloadManager 持有 epoch 与实例注册表;同进程连续 reload 语义完整)
71
73
  let reloadMgrPromise: Promise<import('./load/report.ts').ReloadManager> | undefined
@@ -193,27 +195,55 @@ async function main(): Promise<number> {
193
195
  ...(mtFlag !== undefined ? { maxTokens: Number(mtFlag) } : {}),
194
196
  })
195
197
  const prompt = flagValue(rest, '--prompt')
196
- if (!llm.baseUrl || !llm.model || prompt === undefined) {
197
- console.error('CAR-E-LLM-CONFIG: 真实模型路径需要 baseUrl + model + --prompt——')
198
- console.error(' 配置:car.config.ts(export default)或 car.config.json {"llm":{"baseUrl":"https://…","model":"…"}} / env CAR_LLM_BASE_URL+CAR_LLM_MODEL / flag --base-url --model --prompt')
199
- console.error(' anthropic:llm.adapterId "anthropic"(或 --adapter-id anthropic)+ baseUrl https://api.anthropic.com;凭据 ANTHROPIC_API_KEY')
200
- console.error(' 凭据:OPENAI_API_KEY / ANTHROPIC_API_KEY(env fallback 需 CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback)或 keychain;诊断:car doctor')
198
+ if (prompt === undefined) {
199
+ console.error('CAR-E-LLM-CONFIG: 真实模型路径需要 --prompt——')
201
200
  console.error(' (无模型演示流:--demo)')
202
201
  return 2
203
202
  }
204
- const modelName = llm.model
203
+ // 1.14-S4(K-POOL-1 / D-42):modelPool 登记时池装配压过 llm.baseUrl/model/adapterId(每次路由决策落链可解释)
204
+ const poolCfg = cfgLlm.config.modelPool ?? null
205
+ const cfgBaseUrl = llm.baseUrl
206
+ const cfgModel = llm.model
207
+ let modelName = cfgModel ?? ''
205
208
  const credService = new CredentialService()
206
209
  const core = new RuntimeCore(credService)
207
210
  core.bindContext(ctx)
208
211
  // 适配器经根作用域 effect 可逆注册(bindContext 必须先于 register——否则走非可逆直注册分支);
209
- // 1.5-S1(D-20):adapterId='anthropic' → anthropic 适配器(Messages API);缺省 openai-compat 不变
210
- core.registerLlmAdapter(llm.adapterId === 'anthropic'
211
- ? createAnthropicAdapter({ baseUrl: llm.baseUrl, credentials: credService, provider: 'anthropic' })
212
- : createOpenAICompatAdapter({
213
- baseUrl: llm.baseUrl,
214
- credentials: credService,
215
- provider: 'openai-compat',
216
- }), { default: true })
212
+ // 1.5-S1(D-20):adapterId='anthropic' → anthropic 适配器(Messages API);缺省 openai-compat 不变;
213
+ // 1.14-S4(K-POOL-1 / D-42):modelPool 登记时池装配压过单端点(onDecide → routing/decide 落链)
214
+ if (poolCfg) {
215
+ const assembly = assembleModelPool({
216
+ pool: poolCfg,
217
+ credentials: credService,
218
+ onDecide: d => log.append('runtime', 'routing', d.turnId ?? 'T0', {
219
+ type: 'decide',
220
+ selected: d.selected,
221
+ reason: d.reason,
222
+ retries: d.retries,
223
+ attempts: d.attempts,
224
+ }),
225
+ })
226
+ core.registerLlmAdapter(assembly.adapter, { default: true })
227
+ // chatStep model 形参 = 首位候选(池内逐成员改写 req.model——usage 按成员模型落账)
228
+ modelName = assembly.members[0]!.model
229
+ console.log(`[pool] ${assembly.members.length} 端点入池(候选序:${assembly.members.map(m => m.id).join(' → ')})`)
230
+ } else {
231
+ if (!cfgBaseUrl || !cfgModel) {
232
+ console.error('CAR-E-LLM-CONFIG: 真实模型路径需要 baseUrl + model——')
233
+ console.error(' 配置:car.config.ts(export default)或 car.config.json {"llm":{"baseUrl":"https://…","model":"…"}} / env CAR_LLM_BASE_URL+CAR_LLM_MODEL / flag --base-url --model')
234
+ console.error(' anthropic:llm.adapterId "anthropic"(或 --adapter-id anthropic)+ baseUrl https://api.anthropic.com;凭据 ANTHROPIC_API_KEY')
235
+ console.error(' 模型池:llm.modelPool.endpoints(多端点 fallback + health 预检;凭据 keyEnv 引用;路由决策落链)')
236
+ console.error(' 凭据:OPENAI_API_KEY / ANTHROPIC_API_KEY(env fallback 需 CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback)或 keychain;诊断:car doctor')
237
+ return 2
238
+ }
239
+ core.registerLlmAdapter(llm.adapterId === 'anthropic'
240
+ ? createAnthropicAdapter({ baseUrl: cfgBaseUrl, credentials: credService, provider: 'anthropic' })
241
+ : createOpenAICompatAdapter({
242
+ baseUrl: cfgBaseUrl,
243
+ credentials: credService,
244
+ provider: 'openai-compat',
245
+ }), { default: true })
246
+ }
217
247
  // MCP 工具进 turn(D-18):--mcp <serverId>(可重复;server 启动规格在 car.config.json mcp.servers 登记)
218
248
  const mcpIds: string[] = []
219
249
  // 1.8-D29:活跃 turn span 句柄(startTurn 后赋值——MCP 工具闭包运行期取 client span 挂链面)
@@ -302,7 +332,8 @@ async function main(): Promise<number> {
302
332
  model: modelName,
303
333
  tools: toToolDefinitions(toolsMap),
304
334
  ...(llm.maxTokens !== undefined ? { maxTokens: llm.maxTokens } : {}),
305
- ...(llm.adapterId ? { adapterId: llm.adapterId } : {}),
335
+ // 1.14-S4:池装配时不传 adapterId(池适配器 = default;req.model 由池内按成员改写)
336
+ ...(poolCfg ? {} : llm.adapterId ? { adapterId: llm.adapterId } : {}),
306
337
  signal,
307
338
  }).then(
308
339
  res => { step.end(res.stopReason); return res },
@@ -35,12 +35,59 @@ export interface SandboxSigConfig {
35
35
  sigTrustRoot?: string
36
36
  }
37
37
 
38
+ export interface CompactionConfig {
39
+ /** 缺省 false = 行为零变化(1.13 兼容线) */
40
+ enabled?: boolean
41
+ /** 距上次压缩的新增事件数阈值(事实计数非估算);缺省 200 */
42
+ thresholdEvents?: number
43
+ /** 保护最近 N 个 turn 不折叠;缺省 4 */
44
+ protectLastTurns?: number
45
+ }
46
+
38
47
  export interface LlmConfig {
39
48
  baseUrl?: string
40
49
  model?: string
41
50
  adapterId?: string
42
51
  maxTokens?: number
43
52
  allowEnvFallback?: boolean
53
+ /** 1.14-S2(K-MP-2 / D-40):历史压缩策略(阈值自动触发判定入参;立即压缩 API 独立可用) */
54
+ compaction?: CompactionConfig
55
+ }
56
+
57
+ /** 1.14-S4(K-POOL-1 / D-42):模型池端点登记(多端点 fallback 候选;凭据仅收 keyEnv 引用) */
58
+ export interface ModelPoolEndpointSpec {
59
+ /** 端点标识(routing/decide 落链定位;缺省 ep-1..N 派生;重复显式拒绝) */
60
+ id?: string
61
+ baseUrl: string
62
+ model: string
63
+ /** 适配器家族(config 可装配集 = 内置两族;缺省 openai-compat) */
64
+ adapterId?: 'openai-compat' | 'anthropic'
65
+ /** 凭据 env 变量名引用(明文 key 显式拒绝——CR-05;缺省按 adapterId 缺省表 + CAR_LLM_API_KEY) */
66
+ keyEnv?: string
67
+ /** 优先级:数值越小越优先(缺省 0) */
68
+ priority?: number
69
+ /** 同优先级内次序:越大越先(缺省 0——D-42:登记生效为排序 tie-break,不做负载均衡调度) */
70
+ weight?: number
71
+ /** 成员级 maxTokens(缺省回落 llm.maxTokens) */
72
+ maxTokens?: number
73
+ }
74
+
75
+ /** 1.14-S4(K-POOL-1 / D-42):health 预检策略(doctor 面同款口径——值不打印) */
76
+ export interface ModelPoolProbeConfig {
77
+ /** 缺省开;不可达端点按序跳过(fail-visible 落 routing/decide attempts) */
78
+ enabled?: boolean
79
+ /** 单端点探测超时(缺省 4000ms) */
80
+ timeoutMs?: number
81
+ /** 探测结果缓存 TTL(缺省 30000ms;0 = 每请求重探) */
82
+ ttlMs?: number
83
+ }
84
+
85
+ /** 1.14-S4(K-POOL-1 / D-42):模型池登记。与 llm.baseUrl/model/adapterId 并存时池装配压过(每次路由决策落链可解释);llm.maxTokens/allowEnvFallback/compaction 仍生效。无 modelPool 配置 = 行为与 1.13 逐字节一致(兼容验收线) */
86
+ export interface ModelPoolConfig {
87
+ endpoints: ModelPoolEndpointSpec[]
88
+ probe?: ModelPoolProbeConfig
89
+ /** 候选请求尝试上限(含首选;缺省 = 端点数) */
90
+ maxAttempts?: number
44
91
  }
45
92
 
46
93
  export interface McpServerSpec {
@@ -59,6 +106,8 @@ export interface CarConfig {
59
106
  sandbox?: SandboxSigConfig
60
107
  /** 1.4-S3(D-16):模型接入配置键位空间;凭据禁入(CR-05)——只放端点/模型名/开关 */
61
108
  llm?: LlmConfig
109
+ /** 1.14-S4(K-POOL-1 / D-42):模型池登记(多端点 fallback;与 llm.* 并存时池压过,路由决策落链) */
110
+ modelPool?: ModelPoolConfig
62
111
  /** 1.4-S7(D-18):MCP server 登记面(--mcp <id> 显式启用) */
63
112
  mcp?: { servers: Record<string, McpServerSpec> }
64
113
  }
@@ -82,6 +131,66 @@ const nonEmptyTrimmed = () =>
82
131
  const stringMap = () =>
83
132
  z.record(z.string(), z.string({ invalid_type_error: '必须为字符串' }), { invalid_type_error: '必须为字符串映射', required_error: '必须为字符串映射' })
84
133
 
134
+ // 1.14-S4(K-POOL-1)辅助:正整数 / 非负整数
135
+ const positiveInt = () =>
136
+ z.number({ invalid_type_error: '必须为正整数', required_error: '必须为正整数' })
137
+ .refine(v => Number.isFinite(v) && Number.isInteger(v) && v > 0, { message: '必须为正整数' })
138
+ const nonNegativeInt = () =>
139
+ z.number({ invalid_type_error: '必须为非负整数', required_error: '必须为非负整数' })
140
+ .refine(v => Number.isFinite(v) && Number.isInteger(v) && v >= 0, { message: '必须为非负整数' })
141
+
142
+ // 明文凭据键黑名单(1.14-S4 / CR-05):专用文案先于未知键检查(首个 issue 渲染)
143
+ const PLAINTEXT_CRED_KEYS = ['key', 'apiKey', 'api_key', 'apikey', 'token', 'password']
144
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
145
+ const POOL_ENDPOINT_KEYS = new Set(['id', 'baseUrl', 'model', 'adapterId', 'keyEnv', 'priority', 'weight', 'maxTokens'])
146
+
147
+ /**
148
+ * 池端点 schema(1.14-S4):passthrough + superRefine 显式拒绝(等价 strict,但明文凭据键
149
+ * 给专用文案——CR-05 红线「明文 key 显式拒绝」必须可读可引导,不能只报「未知键」)。
150
+ */
151
+ const poolEndpointSchema = z.object({
152
+ id: nonEmptyTrimmed().optional(),
153
+ baseUrl: nonEmptyTrimmed().refine(v => /^https?:\/\//.test(v), { message: '必须为 http(s) URL' }),
154
+ model: nonEmptyTrimmed(),
155
+ adapterId: z.enum(['openai-compat', 'anthropic'], { errorMap: () => ({ message: '必须为 openai-compat 或 anthropic(config 可装配集 = 内置两族)' }) }).optional(),
156
+ keyEnv: nonEmptyTrimmed().refine(v => ENV_NAME_RE.test(v), { message: '必须为环境变量名(明文凭据禁入——凭据仅收 keyEnv 引用,CR-05)' }).optional(),
157
+ priority: positiveInt().optional(),
158
+ weight: nonNegativeInt().optional(),
159
+ maxTokens: positiveInt().optional(),
160
+ }).passthrough().superRefine((obj, ctx) => {
161
+ for (const k of PLAINTEXT_CRED_KEYS) {
162
+ if (k in obj) {
163
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [k], message: `明文凭据禁入——凭据仅收 keyEnv(环境变量名引用),明文键 "${k}" 一律拒绝` })
164
+ return
165
+ }
166
+ }
167
+ for (const k of Object.keys(obj)) {
168
+ if (!POOL_ENDPOINT_KEYS.has(k)) {
169
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [k], message: '未登记键(登记键位:id/baseUrl/model/adapterId/keyEnv/priority/weight/maxTokens)' })
170
+ return
171
+ }
172
+ }
173
+ })
174
+
175
+ const modelPoolSchema = z.object({
176
+ endpoints: z.array(poolEndpointSchema, { invalid_type_error: '必须为端点数组', required_error: '必须为端点数组' })
177
+ .min(1, { message: '至少登记一个端点(池空 = CAR-E-CONFIG;无池需求请移除 modelPool 键,行为与 1.13 一致)' }),
178
+ probe: z.object({
179
+ enabled: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
180
+ timeoutMs: positiveInt().optional(),
181
+ ttlMs: nonNegativeInt().optional(),
182
+ }).strict().optional(),
183
+ maxAttempts: positiveInt().optional(),
184
+ }).strict().superRefine((obj, ctx) => {
185
+ // 端点 id 唯一(缺省派生同口径)——routing/decide 落链定位歧义禁入
186
+ const ids = new Set<string>()
187
+ for (const [i, ep] of (obj.endpoints ?? []).entries()) {
188
+ const id = ep.id ?? `ep-${i + 1}`
189
+ if (ids.has(id)) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['endpoints', i], message: `端点 id "${id}" 重复(落链定位歧义,禁静默)` })
190
+ ids.add(id)
191
+ }
192
+ })
193
+
85
194
  const mcpServerSchema = z.object({
86
195
  command: nonEmptyTrimmed().optional(),
87
196
  url: nonEmptyTrimmed().refine(v => /^https?:\/\//.test(v), { message: '必须为 http(s) URL' }).optional(),
@@ -118,14 +227,24 @@ const rawSchema = z.object({
118
227
  .refine(v => Number.isFinite(v) && Number.isInteger(v) && v > 0, { message: '必须为正整数' })
119
228
  .optional(),
120
229
  allowEnvFallback: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
230
+ // 1.14-S2(K-MP-2 / D-40):历史压缩策略(阈值事实计数,非 token 估算)
231
+ compaction: z.object({
232
+ enabled: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
233
+ thresholdEvents: z.number({ invalid_type_error: '必须为正整数', required_error: '必须为正整数' })
234
+ .refine(v => Number.isInteger(v) && v > 0, { message: '必须为正整数' }).optional(),
235
+ protectLastTurns: z.number({ invalid_type_error: '必须为正整数', required_error: '必须为正整数' })
236
+ .refine(v => Number.isInteger(v) && v > 0, { message: '必须为正整数' }).optional(),
237
+ }).strict().optional(),
121
238
  }).strict().optional(),
239
+ // 1.14-S4(K-POOL-1 / D-42):模型池登记(多端点 fallback + health + routing/decide 落链)
240
+ modelPool: modelPoolSchema.optional(),
122
241
  mcp: z.object({
123
242
  servers: z.record(z.string(), mcpServerSchema, { invalid_type_error: '必须为对象(serverId → 启动规格)' }),
124
243
  }).strict().optional(),
125
244
  }, { invalid_type_error: '配置顶层必须为 JSON 对象' }).strict()
126
245
 
127
246
  /** 未登记键提示(渲染层拼 path)——与系统设计键位空间同步 */
128
- const KEY_SPACES = '登记键位:sandbox.* / llm.* / mcp.servers.*'
247
+ const KEY_SPACES = '登记键位:sandbox.* / llm.* / llm.modelPool.* / mcp.servers.*'
129
248
 
130
249
  /** zod issue → CAR-E-CONFIG 渲染(首个 issue;unrecognized_keys 按键路径展开) */
131
250
  function renderIssue(error: z.ZodError): string {
@@ -161,6 +280,13 @@ function parseCarConfig(raw: unknown): { config: CarConfig; error?: string } {
161
280
  if (d.llm.adapterId !== undefined) llm.adapterId = d.llm.adapterId
162
281
  if (d.llm.maxTokens !== undefined) llm.maxTokens = d.llm.maxTokens
163
282
  if (d.llm.allowEnvFallback !== undefined) llm.allowEnvFallback = d.llm.allowEnvFallback
283
+ if (d.llm.compaction) {
284
+ const cp: CompactionConfig = {}
285
+ if (d.llm.compaction.enabled !== undefined) cp.enabled = d.llm.compaction.enabled
286
+ if (d.llm.compaction.thresholdEvents !== undefined) cp.thresholdEvents = d.llm.compaction.thresholdEvents
287
+ if (d.llm.compaction.protectLastTurns !== undefined) cp.protectLastTurns = d.llm.compaction.protectLastTurns
288
+ llm.compaction = cp
289
+ }
164
290
  out.llm = llm
165
291
  }
166
292
  if (d.mcp) {
@@ -176,6 +302,14 @@ function parseCarConfig(raw: unknown): { config: CarConfig; error?: string } {
176
302
  }
177
303
  out.mcp = { servers }
178
304
  }
305
+ if (d.modelPool) {
306
+ const mp = d.modelPool as { endpoints: ModelPoolEndpointSpec[]; probe?: ModelPoolProbeConfig; maxAttempts?: number }
307
+ out.modelPool = {
308
+ endpoints: mp.endpoints.map(ep => ({ ...ep })),
309
+ ...(mp.probe ? { probe: { ...mp.probe } } : {}),
310
+ ...(mp.maxAttempts !== undefined ? { maxAttempts: mp.maxAttempts } : {}),
311
+ }
312
+ }
179
313
  return { config: out }
180
314
  }
181
315