@bolloon/bolloon-agent 0.5.0 → 0.5.2

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.
Files changed (121) hide show
  1. package/README.md +39 -0
  2. package/dist/agents/agent-lang.js +101 -0
  3. package/dist/agents/agent-manifest-protocol.js +4 -0
  4. package/dist/agents/execution-supervisor.js +87 -5
  5. package/dist/agents/gateway-group.js +44 -3
  6. package/dist/agents/model-policy.js +651 -0
  7. package/dist/agents/pi-sdk-tools.js +84 -33
  8. package/dist/agents/pi-sdk.js +184 -31
  9. package/dist/agents/run-store.js +53 -0
  10. package/dist/agents/task/task-runner.js +8 -1
  11. package/dist/agents/workflow-pivot-loop.js +22 -13
  12. package/dist/cli/clipboard.js +65 -0
  13. package/dist/cli/ink-app.js +145 -17
  14. package/dist/cli/log-gate.js +386 -0
  15. package/dist/cli/mention-data.js +2 -0
  16. package/dist/cli/model-selector.js +538 -0
  17. package/dist/cli/reply-hygiene.js +54 -0
  18. package/dist/cli/setup-wizard.js +686 -69
  19. package/dist/cli/startup-notice.js +160 -0
  20. package/dist/cli/stores.js +22 -1
  21. package/dist/cli/theme.js +49 -0
  22. package/dist/cli/tui-select.js +970 -0
  23. package/dist/cli-entry.js +125 -18
  24. package/dist/efficode/compress.js +78 -0
  25. package/dist/efficode/crc32.js +38 -0
  26. package/dist/efficode/did.js +66 -0
  27. package/dist/efficode/index.js +24 -0
  28. package/dist/efficode/lz77.js +170 -0
  29. package/dist/efficode/negotiate.js +187 -0
  30. package/dist/efficode/ops.js +87 -0
  31. package/dist/efficode/packet.js +271 -0
  32. package/dist/efficode/types.js +45 -0
  33. package/dist/electron-build/agents/agent-manifest-protocol.js +4 -0
  34. package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
  35. package/dist/electron-build/agents/execution-supervisor.js +86 -4
  36. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  37. package/dist/electron-build/agents/goal-store.js.map +1 -1
  38. package/dist/electron-build/agents/model-policy.js +706 -0
  39. package/dist/electron-build/agents/model-policy.js.map +1 -0
  40. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  41. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  42. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  43. package/dist/electron-build/agents/pi-sdk.js +185 -31
  44. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  45. package/dist/electron-build/agents/run-store.js +55 -1
  46. package/dist/electron-build/agents/run-store.js.map +1 -1
  47. package/dist/electron-build/agents/task/task-runner.js +8 -1
  48. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  49. package/dist/electron-build/agents/workflow-pivot-loop.js +22 -13
  50. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  51. package/dist/electron-build/llm/config-store.js +146 -6
  52. package/dist/electron-build/llm/config-store.js.map +1 -1
  53. package/dist/electron-build/llm/connection-probe.js +764 -0
  54. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  55. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  56. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  57. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  58. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  59. package/dist/electron-build/llm/model-catalog.js +778 -0
  60. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  61. package/dist/electron-build/llm/model-discovery.js +1202 -0
  62. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  63. package/dist/electron-build/llm/model-selection.js +1315 -0
  64. package/dist/electron-build/llm/model-selection.js.map +1 -0
  65. package/dist/electron-build/llm/pi-ai.js +751 -55
  66. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  67. package/dist/electron-build/llm/provider-catalog.js +969 -0
  68. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  69. package/dist/electron-build/llm/provider-registry.js +950 -0
  70. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  71. package/dist/electron-build/llm/tool-name.js +266 -0
  72. package/dist/electron-build/llm/tool-name.js.map +1 -0
  73. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  74. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  75. package/dist/electron-build/utils/dual-source.js +32 -156
  76. package/dist/electron-build/utils/dual-source.js.map +1 -1
  77. package/dist/electron-build/utils/update-manager.js +22 -72
  78. package/dist/electron-build/utils/update-manager.js.map +1 -1
  79. package/dist/electron-build/utils/update-state.js +2 -19
  80. package/dist/electron-build/utils/update-state.js.map +1 -1
  81. package/dist/electron-build/utils/version-identity.js +193 -0
  82. package/dist/electron-build/utils/version-identity.js.map +1 -0
  83. package/dist/electron-build/utils/version-info.js +47 -101
  84. package/dist/electron-build/utils/version-info.js.map +1 -1
  85. package/dist/index.js +282 -96
  86. package/dist/ios/bolloon-web.json +10 -0
  87. package/dist/ios/mobile-contacts.js +44 -11
  88. package/dist/ios/mobile-core.js +1723 -84
  89. package/dist/ios/mobile-tasks.js +339 -0
  90. package/dist/ios/mobile-update.js +1442 -0
  91. package/dist/ios/mobile.js +308 -35
  92. package/dist/ios/routes-llm-config.js +344 -52
  93. package/dist/ios/routes-mobile-tasks.js +353 -0
  94. package/dist/ios/server.js +163 -4
  95. package/dist/ios/sw.js +1 -1
  96. package/dist/llm/config-store.js +146 -6
  97. package/dist/llm/connection-probe.js +744 -0
  98. package/dist/llm/custom-provider-store.js +224 -0
  99. package/dist/llm/data/provider-catalog-baked.js +19 -0
  100. package/dist/llm/model-catalog.js +717 -0
  101. package/dist/llm/model-discovery.js +1137 -0
  102. package/dist/llm/model-selection.js +1241 -0
  103. package/dist/llm/pi-ai.js +732 -54
  104. package/dist/llm/provider-catalog.js +898 -0
  105. package/dist/llm/provider-registry.js +917 -0
  106. package/dist/llm/tool-name.js +220 -0
  107. package/dist/setup/onboard.js +34 -8
  108. package/dist/utils/dual-source-facts.js +223 -0
  109. package/dist/utils/dual-source.js +12 -141
  110. package/dist/utils/update-manager.js +16 -59
  111. package/dist/utils/update-state.js +6 -18
  112. package/dist/utils/version-identity.js +176 -0
  113. package/dist/utils/version-info.js +9 -78
  114. package/dist/web/bolloon-web.json +10 -0
  115. package/dist/web/mobile-core.js +1721 -84
  116. package/dist/web/mobile-update.js +1442 -0
  117. package/dist/web/mobile.js +372 -35
  118. package/dist/web/routes-llm-config.js +344 -52
  119. package/dist/web/sw.js +1 -1
  120. package/package.json +1 -1
  121. package/scripts/build-web.ts +36 -0
@@ -0,0 +1,651 @@
1
+ /**
2
+ * model-policy.ts — 长期任务 / Supervisor 的模型策略 (P7, 2026-09-26)
3
+ *
4
+ * 要解决的问题: 一个跑到一半的长任务, 用户切了全局默认模型 —— 这条执行链会不会**静默漂移**?
5
+ * 「漂移」的三种表现都在这层堵掉:
6
+ * ① 在跑的 Run 被改成新模型 (历史执行链再也解释不清);
7
+ * ② 新 Run 该用新模型却继续吃旧的 (用户切了不生效);
8
+ * ③ 模型换了, 于是已经成功执行过的**非幂等动作**被重做一遍 (重复写文件 / 重复付款 / 重复提交)。
9
+ *
10
+ * 三条规则 (与计划 P7 逐条对齐):
11
+ * · 切 Global **不自动改写正在执行的 Run**; 新 Run 默认用最新 Global。
12
+ * · 同一个 Goal 换不换模型由 Goal 的 `modelPolicy` 决定:
13
+ * `auto` 新 Run 可换 (当前 Run 不换); 允许 Supervisor 按**模型相关**失败类别挑备用模型
14
+ * `pinned` 该 Goal 全程固定 provider/model/baseUrl (与实际快照冲突时**不改写**, 只留冲突事实)
15
+ * `session` 只跟随当前交互会话的绑定; Global 的新默认**不传播**进这个 Goal
16
+ * · 发生切换**写 Run 事件**; 模型变了**不构成**重跑非幂等工具的理由 (守卫照带)。
17
+ *
18
+ * 事实边界 (与记录层/入口层的分工):
19
+ * · **真源**是 Run 自己的模型快照 `RunRecord.modelConfig` (P1 定稿) —— 本层不另建一份配置来源,
20
+ * 也不复算它: `intent='current_run'` 时**原样**返回快照, 一个字都不改。
21
+ * · 本层算出来的 `to` 只用于**下一个 Run 的启动快照**和**切换事件**; 它绝不回写老 Run 的快照。
22
+ * · 策略解析纯函数化 (`resolveRunModel` 不碰磁盘), I/O 壳在文件后半段 (`resolveNextRunModel`)。
23
+ *
24
+ * 策略存哪: `~/.bolloon/model-policy/<goalId>.json` (与 Goal 记录同级目录, 加成文件)。
25
+ * 为什么不写进 Goal 记录: Goal 记录是别处的写入面, 本层不改它; 而 Goal 记录上**若**已带
26
+ * `modelPolicy` 字段 (未来主线补上), 本层**优先读它** —— 那条路一旦接通, sidecar 自动让位。
27
+ */
28
+ import * as fs from 'fs/promises';
29
+ import * as path from 'path';
30
+ import * as os from 'os';
31
+ import * as crypto from 'crypto';
32
+ import { configHashOf, normalizeBaseUrl, effectiveModelConfig, readSessionSelection, } from '../llm/model-selection.js';
33
+ import { argsDigestOf, isNonIdempotentTool, readRun, recordModelSwitch, } from './run-store.js';
34
+ /**
35
+ * 解析 Goal 模型策略。**两种输入都要能吃**: 记录字段直接给的对象 / sidecar 文件里的对象。
36
+ *
37
+ * 缺省与降级的口径 (宁可不动, 也不擅自跟随全局):
38
+ * · 没有这个字段 (`undefined`/`null`) → `auto` (计划里 `auto` 是未声明时的默认行为), ok=true;
39
+ * · 未知模式 (拼错 / 新版本写的未来值) → 保留字面模式不识别 → 降级成**冻结语义**的 `auto` 且 ok=false,
40
+ * 并把原文原因带出去 (调用方记事件时必须照说, 不许美化);
41
+ * · `pinned` 缺 provider/model → ok=false (不完整的固定 = 谁也不许换)。
42
+ */
43
+ export function parseGoalModelPolicy(raw) {
44
+ if (raw === undefined || raw === null) {
45
+ return { ok: true, policy: { mode: 'auto' }, reason: 'Goal 未声明 modelPolicy → 按 auto (新 Run 可换, 当前 Run 不换)' };
46
+ }
47
+ const obj = typeof raw === 'string' ? safeJson(raw) : raw;
48
+ if (!obj || typeof obj !== 'object') {
49
+ return { ok: false, policy: { mode: 'auto' }, reason: `modelPolicy 不是对象 (收到 ${typeof raw}) → 降级为 auto, 且不自动改写在跑的 Run` };
50
+ }
51
+ const rec = obj;
52
+ const rawMode = String(rec.mode ?? '').trim().toLowerCase();
53
+ if (rawMode === 'auto' || rawMode === 'pinned' || rawMode === 'session') {
54
+ const policy = { mode: rawMode };
55
+ if (rec.note !== undefined)
56
+ policy.note = String(rec.note).slice(0, 300);
57
+ if (rec.updatedBy !== undefined)
58
+ policy.updatedBy = String(rec.updatedBy).slice(0, 100);
59
+ if (rec.updatedAt !== undefined)
60
+ policy.updatedAt = String(rec.updatedAt).slice(0, 40);
61
+ if (rawMode === 'pinned') {
62
+ const pinRaw = (rec.pin ?? {});
63
+ const provider = String(pinRaw.provider ?? rec.provider ?? '').trim().toLowerCase();
64
+ const model = String(pinRaw.model ?? rec.model ?? '').trim();
65
+ const baseUrl = String(pinRaw.baseUrl ?? rec.baseUrl ?? '').trim();
66
+ if (!provider || !model) {
67
+ return { ok: false, policy: { mode: 'pinned' }, reason: 'pinned 策略缺 pin.provider/pin.model → 固定不下来 (保守: 保持原模型, 不跟随全局)' };
68
+ }
69
+ policy.pin = { provider, model, ...(baseUrl ? { baseUrl: normalizeBaseUrl(baseUrl) } : {}) };
70
+ }
71
+ return { ok: true, policy };
72
+ }
73
+ return {
74
+ ok: false,
75
+ policy: { mode: 'auto' },
76
+ reason: `未知的 modelPolicy.mode='${String(rec.mode)}' (只认 auto/pinned/session) → 降级为 auto 且**不自动改写在跑的 Run**`,
77
+ };
78
+ }
79
+ function safeJson(text) {
80
+ try {
81
+ return JSON.parse(text);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ /** 策略存成一行给人看: 三种模式各自的语义都要能读出来 */
88
+ export function describeGoalModelPolicy(p) {
89
+ switch (p.mode) {
90
+ case 'pinned':
91
+ return p.pin
92
+ ? `pinned · 全程固定 ${p.pin.provider}/${p.pin.model}${p.pin.baseUrl ? ` @ ${p.pin.baseUrl}` : ' @ 供应商默认 URL'}`
93
+ : 'pinned · 但固定配置不完整 (保守: 谁都不许换)';
94
+ case 'session':
95
+ return 'session · 只跟随当前交互会话的绑定 (Global 的新默认不传播进这个 Goal)';
96
+ default:
97
+ return 'auto · 新 Run 用最新默认, 当前 Run 不换; Supervisor 可按模型相关失败类别挑备用';
98
+ }
99
+ }
100
+ /** 终态 = 这条 Run 不会再执行; 其余 (含 stalled/interrupted/paused) 都算"还在链子上", 快照是权威 */
101
+ export const TERMINAL_RUN_STATUSES = ['done', 'failed', 'aborted'];
102
+ export function isTerminalRunStatus(status) {
103
+ return TERMINAL_RUN_STATUSES.includes(status);
104
+ }
105
+ /**
106
+ * 从一份配置算出 Run 快照 (与 `runModelConfigOf` 同一 hash 算法 —— 不另写一份摘要函数)。
107
+ * 归一化在这里做: 同一个地址写 `http://h/v1/` 和 `http://h/v1` 必须得到同一个 hash。
108
+ */
109
+ export function runConfigOf(opts) {
110
+ const baseUrl = normalizeBaseUrl(opts.baseUrl || '');
111
+ return {
112
+ provider: String(opts.provider || ''),
113
+ model: String(opts.model || ''),
114
+ baseUrl,
115
+ configHash: configHashOf({ provider: String(opts.provider || ''), model: String(opts.model || ''), baseUrl }),
116
+ selectionScope: opts.selectionScope,
117
+ capturedAt: opts.capturedAt || new Date().toISOString(),
118
+ };
119
+ }
120
+ function sameConfig(a, b) {
121
+ if (!a || !b)
122
+ return false;
123
+ return a.configHash === b.configHash
124
+ && normalizeBaseUrl(a.baseUrl || '') === normalizeBaseUrl(b.baseUrl || '')
125
+ && a.provider === b.provider
126
+ && a.model === b.model;
127
+ }
128
+ /**
129
+ * 唯一决定函数 (纯函数: 不读盘、不写盘)。
130
+ *
131
+ * 语义表 (逐行可判):
132
+ *
133
+ * | intent | 条件 | config 来源 | frozen | switched |
134
+ * | ------------- | -------------------------------------- | ----------------- | ------ | -------- |
135
+ * | current_run | 有快照 | **Run 快照** | true | false |
136
+ * | current_run | 无快照 (P1 之前的老记录) | null (不编造) | true | false |
137
+ * | next_run auto | 有最新有效默认 | 最新默认 | false | hash 不同则 true |
138
+ * | next_run auto | 没有最新有效默认 | 上一份快照 | true | false |
139
+ * | next_run pinned | pin 完整 | Goal 固定三元组 | false | hash 不同则 true |
140
+ * | next_run pinned | pin 不完整 (或策略解析失败) | 上一份快照/最新默认 | true | false |
141
+ * | next_run session | 有会话绑定 | 会话绑定 | false | hash 不同则 true |
142
+ * | next_run session | 无会话绑定 | 上一份快照, 再退最新默认 | true | false |
143
+ *
144
+ * `switched=true` 只说明"这一份与记录里的那份不同" —— 它是**事实**陈述, 不是"切换被允许"。
145
+ */
146
+ export function resolveRunModel(input) {
147
+ const parsed = parseGoalModelPolicy(input.policy);
148
+ const problems = [...(input.policyProblems || []), ...(parsed.reason && !parsed.ok ? [parsed.reason] : [])];
149
+ const policy = parsed.policy;
150
+ const prev = input.run?.modelConfig || null;
151
+ const runId = input.run?.runId || '';
152
+ const goalId = input.run?.goalId;
153
+ const now = input.now || new Date().toISOString();
154
+ const base = {
155
+ intent: input.intent,
156
+ runId,
157
+ goalId,
158
+ mode: parsed.ok ? policy.mode : 'unknown',
159
+ policyOk: parsed.ok,
160
+ conflict: [],
161
+ };
162
+ // ── ① 这条 Run 自己: 快照是**唯一真源**, 一个字都不改 ──────────────
163
+ if (input.intent === 'current_run') {
164
+ const conflict = policy.mode === 'pinned' && policy.pin && prev ? pinConflict(prev, policy.pin) : [];
165
+ if (!prev) {
166
+ return {
167
+ ...base, config: null, source: 'none', switched: false, frozen: true, conflict,
168
+ reason: `这条 Run 没有模型快照 (P1 之前的老记录) → 无法回答它当时用哪一份, 也不替它改写成新默认`,
169
+ };
170
+ }
171
+ const reasons = [
172
+ `正在执行的 Run 用**自己的快照** (${prev.provider}/${prev.model} @ ${prev.baseUrl}, hash=${prev.configHash})`,
173
+ 'Global 切换不自动改写它的快照',
174
+ ];
175
+ if (conflict.length)
176
+ reasons.push(`但它与 Goal 的 pinned 固定策略不符 (${conflict.map((c) => c.field).join(', ')}) → 只留冲突事实, 不在跑动中换模型`);
177
+ if (!parsed.ok)
178
+ reasons.push(`策略解析失败 (${problems.join('; ')}) → 保守: 保持原模型`);
179
+ return {
180
+ ...base, config: prev, source: 'run_snapshot', switched: false, frozen: true, conflict,
181
+ reason: reasons.join('; '),
182
+ };
183
+ }
184
+ // ── ② 下一个 Run: 由策略决定 ──────────────────────────────────────
185
+ const latestConfig = input.latest
186
+ ? runConfigOf({
187
+ provider: input.latest.provider,
188
+ model: input.latest.model,
189
+ baseUrl: input.latest.baseUrl,
190
+ selectionScope: input.latest.source,
191
+ capturedAt: now,
192
+ })
193
+ : null;
194
+ // 策略用不了 (解析失败 / pinned 不完整) → 冻住: 能不动就不动
195
+ const policyUnusable = !parsed.ok || (policy.mode === 'pinned' && !policy.pin);
196
+ if (policyUnusable) {
197
+ const keep = prev || latestConfig;
198
+ return {
199
+ ...base,
200
+ config: keep,
201
+ source: prev ? 'run_snapshot' : (latestConfig ? 'global' : 'none'),
202
+ switched: false,
203
+ frozen: true,
204
+ reason: keep
205
+ ? `策略不可用 (${problems.join('; ') || 'pinned 缺固定配置'}) → 保守: 沿用 ${prev ? '上一个 Run 的快照' : '最新默认'}, **不**跟随全局切换`
206
+ : `策略不可用 (${problems.join('; ') || 'pinned 缺固定配置'}) 且没有任何可用配置 → 无法为新 Run 定一份模型`,
207
+ };
208
+ }
209
+ if (policy.mode === 'pinned' && policy.pin) {
210
+ const pinned = runConfigOf({
211
+ provider: policy.pin.provider,
212
+ model: policy.pin.model,
213
+ baseUrl: policy.pin.baseUrl || latestConfig?.baseUrl || '',
214
+ selectionScope: 'run', // Goal 显式绑定 = 优先级最高的那一层
215
+ capturedAt: now,
216
+ });
217
+ if (!pinned.baseUrl) {
218
+ // 固定里没写 URL, 又没有可继承的默认 → 缺一块就是缺一块, 不猜
219
+ return {
220
+ ...base, config: null, source: 'goal_pin', switched: false, frozen: true,
221
+ reason: `pinned 固定了 ${policy.pin.provider}/${policy.pin.model}, 但 baseUrl 既未固定也无处继承 → 不为新 Run 编造地址`,
222
+ };
223
+ }
224
+ return {
225
+ ...base, config: pinned, source: 'goal_pin', switched: !sameConfig(prev, pinned), frozen: false,
226
+ reason: pinned.provider === prev?.provider && pinned.model === prev?.model && normalizeBaseUrl(pinned.baseUrl) === normalizeBaseUrl(prev?.baseUrl || '')
227
+ ? `pinned: 新 Run 用的仍是固定那一条 (${pinned.provider}/${pinned.model} @ ${pinned.baseUrl})`
228
+ : `pinned: 新 Run 用 Goal 固定的 ${pinned.provider}/${pinned.model} @ ${pinned.baseUrl} (与上一个 Run 的 ${prev ? `${prev.provider}/${prev.model} hash=${prev.configHash}` : '无快照'} 不同)`,
229
+ };
230
+ }
231
+ if (policy.mode === 'session') {
232
+ const sb = input.sessionBinding;
233
+ if (sb && sb.provider && sb.model) {
234
+ const fromSession = runConfigOf({
235
+ provider: sb.provider,
236
+ model: sb.model,
237
+ baseUrl: sb.baseUrl || latestConfig?.baseUrl || '',
238
+ selectionScope: 'session',
239
+ capturedAt: now,
240
+ });
241
+ return {
242
+ ...base, config: fromSession, source: 'session', switched: !sameConfig(prev, fromSession), frozen: false,
243
+ reason: `session: 新 Run 跟随当前交互会话的绑定 (${fromSession.provider}/${fromSession.model} @ ${fromSession.baseUrl}); Global 的切换不传播进这个 Goal`,
244
+ };
245
+ }
246
+ const keep = prev || latestConfig;
247
+ return {
248
+ ...base,
249
+ config: keep,
250
+ source: prev ? 'run_snapshot' : (latestConfig ? 'global' : 'none'),
251
+ switched: false,
252
+ frozen: true,
253
+ reason: keep
254
+ ? `session: 当前没有会话绑定 → 保守沿用 ${prev ? '上一个 Run 的快照 (不让 Global 的新默认漏进来)' : '最新默认 (首次执行)'}`
255
+ : 'session: 当前没有会话绑定, 也没有任何可用配置 → 无法为新 Run 定一份模型',
256
+ };
257
+ }
258
+ // auto
259
+ if (!latestConfig) {
260
+ const keep = prev;
261
+ return {
262
+ ...base,
263
+ config: keep, source: prev ? 'run_snapshot' : 'none', switched: false, frozen: true,
264
+ reason: keep
265
+ ? `auto: 读不到最新有效默认 → 保守沿用上一个 Run 的快照 (${keep.provider}/${keep.model} hash=${keep.configHash}), 不假装换了`
266
+ : 'auto: 读不到最新有效默认, 也没有历史快照 → 无法为新 Run 定一份模型',
267
+ };
268
+ }
269
+ return {
270
+ ...base, config: latestConfig, source: 'global', switched: !sameConfig(prev, latestConfig), frozen: false,
271
+ reason: prev
272
+ ? (sameConfig(prev, latestConfig)
273
+ ? `auto: 新 Run 用最新默认 (与上一个 Run 同一份, hash=${latestConfig.configHash})`
274
+ : `auto: 新 Run 用最新默认 ${latestConfig.provider}/${latestConfig.model} @ ${latestConfig.baseUrl} (hash=${latestConfig.configHash}); 上一个 Run 是 ${prev.configHash}`)
275
+ : `auto: 首次执行, 用最新默认 (${latestConfig.provider}/${latestConfig.model} @ ${latestConfig.baseUrl})`,
276
+ };
277
+ }
278
+ function pinConflict(snapshot, pin) {
279
+ const out = [];
280
+ if (snapshot.provider !== pin.provider)
281
+ out.push({ field: 'provider', snapshot: snapshot.provider, pinned: pin.provider });
282
+ if (snapshot.model !== pin.model)
283
+ out.push({ field: 'model', snapshot: snapshot.model, pinned: pin.model });
284
+ if (pin.baseUrl && normalizeBaseUrl(snapshot.baseUrl || '') !== normalizeBaseUrl(pin.baseUrl)) {
285
+ out.push({ field: 'baseUrl', snapshot: normalizeBaseUrl(snapshot.baseUrl || ''), pinned: normalizeBaseUrl(pin.baseUrl) });
286
+ }
287
+ return out;
288
+ }
289
+ /** 一行决定 (CLI/UI/日志都能直接用) */
290
+ export function formatModelDecision(d) {
291
+ const cfg = d.config
292
+ ? `${d.config.provider}/${d.config.model} @ ${d.config.baseUrl} (hash=${d.config.configHash}, scope=${d.config.selectionScope})`
293
+ : '(没有可用的模型配置)';
294
+ return `${d.intent} · mode=${d.mode} · source=${d.source} · ${d.switched ? '已换' : (d.frozen ? '冻结' : '未换')} · ${cfg} · ${d.reason}`;
295
+ }
296
+ /** 一条 Run 里已成功的**非幂等**动作 (恢复/续跑的守卫来源; 与 run-store 的算法一致) */
297
+ export function guardsOfRun(rec) {
298
+ return (rec.steps || [])
299
+ .filter((s) => s.ok && isNonIdempotentTool(s.tool))
300
+ .map((s) => ({ tool: s.tool, argsDigest: s.argsDigest, summary: s.summary || '(已执行)' }));
301
+ }
302
+ /** 去重合并 (同 tool + 同指纹只留一条; 没有指纹的按 tool 留一条最保守的) */
303
+ export function mergeGuards(...lists) {
304
+ const out = [];
305
+ const seen = new Set();
306
+ for (const list of lists) {
307
+ for (const g of list || []) {
308
+ const key = `${g.tool}\u0000${g.argsDigest || ''}`;
309
+ if (seen.has(key))
310
+ continue;
311
+ seen.add(key);
312
+ out.push({ tool: g.tool, argsDigest: g.argsDigest, summary: g.summary });
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+ /**
318
+ * 为"下一个 Run"生成跨 Run 守卫 —— 不管模型换没换。
319
+ *
320
+ * 为什么单独成函数 (而不是直接用 buildContinuationPlan): 模型切换这件事最容易被人顺手写成
321
+ * 「换了模型 → 计划失效 → 从头再来」。这里把守卫**独立于模型决定**地算出来, 并且报出
322
+ * `guardsDropped` 供门禁断言; 换模型的逻辑再怎么写, 都不该让这个数字变成非 0。
323
+ */
324
+ export function planSwitchContinuation(opts) {
325
+ const from = opts.prevRun?.modelConfig || null;
326
+ const to = opts.decision.config;
327
+ const previous = opts.prevRun ? guardsOfRun(opts.prevRun) : [];
328
+ const guards = mergeGuards(opts.plan?.replayGuards || [], previous);
329
+ const dropped = previous.filter((p) => !guards.some((g) => g.tool === p.tool && (g.argsDigest || '') === (p.argsDigest || ''))).length;
330
+ const modelChanged = !!from && !!to ? !sameConfig(from, to) : false;
331
+ const uniq = mergeGuards(guards).filter((g) => isNonIdempotentTool(g.tool));
332
+ return {
333
+ modelChanged,
334
+ from,
335
+ to,
336
+ guards: uniq,
337
+ previousGuardCount: previous.length,
338
+ guardsDropped: dropped,
339
+ instructionNote: uniq.length
340
+ ? `${modelChanged ? '模型已切换' : '模型未变'}, 但以下非幂等动作**此前已成功执行过**, 绝不重复执行 (换模型不是重做副作用的理由): ${uniq.slice(-8).map((g) => g.tool).join(', ')}`
341
+ : '没有需要守住的非幂等动作 (此前没有成功执行过这类工具)',
342
+ };
343
+ }
344
+ /**
345
+ * 「这一步要不要真执行」的唯一判定 (与执行链里的重放守卫同语义)。
346
+ *
347
+ * 匹配规则 (保守): 工具名相等 且 (守卫没记指纹 或 这一步没指纹 或 指纹相等) → 跳过。
348
+ * 也就是说**只有确认是同一个动作**才跳过; 换了参数的动作照跑 (那是新动作, 不是重做)。
349
+ */
350
+ export function shouldSkipAsReplay(guards, tool, args) {
351
+ const d = argsDigestOf(args);
352
+ const hit = (guards || []).find((g) => g.tool === tool && (!g.argsDigest || !d || g.argsDigest === d));
353
+ return hit ? { skip: true, summary: hit.summary } : { skip: false };
354
+ }
355
+ // ============================================================
356
+ // Supervisor: 按失败类别挑备用模型
357
+ // ============================================================
358
+ /** 与模型**相关**的失败类别: 换一个模型可能真的解决 (网络/鉴权/工具调用能力/输出不可解析) */
359
+ export const MODEL_RELATED_ERROR_CLASSES = ['transient', 'auth', 'no_such_tool', 'unparsable'];
360
+ /**
361
+ * Supervisor 能不能给下一个 Run 挑备用模型?
362
+ * auto + 模型相关失败 → 允许 (计划原文: 允许 Supervisor 按失败类别选备用模型)
363
+ * auto + 非模型相关 → 不允许 (工具参数错/策略拒绝/持久化失败, 换模型解决不了, 只会掩盖)
364
+ * pinned → 不允许 (固定就是要可追溯; 只能暂停/找人)
365
+ * session → 不允许 (只跟随交互会话, Supervisor 不在那个会话里)
366
+ */
367
+ export function supervisorMaySwitchModel(opts) {
368
+ const cls = opts.errorClass;
369
+ if (opts.mode !== 'auto') {
370
+ return { allowed: false, reason: opts.mode === 'pinned'
371
+ ? 'pinned: 全程固定 provider/model/baseUrl → Supervisor 不许挑备用模型 (只能暂停/找人)'
372
+ : opts.mode === 'session'
373
+ ? 'session: 模型只跟随当前交互会话 → Supervisor 不在那个会话里, 不许挑备用模型'
374
+ : '策略模式未知 → 保守: 不许挑备用模型' };
375
+ }
376
+ if (!cls)
377
+ return { allowed: false, reason: 'auto: 没有失败类别 → 不为"顺手换个模型"开门' };
378
+ if (!MODEL_RELATED_ERROR_CLASSES.includes(cls)) {
379
+ return { allowed: false, reason: `auto: 失败类别 ${cls} 与模型无关 (工具参数/权限/持久化/外部等待) → 换模型只会掩盖问题` };
380
+ }
381
+ return { allowed: true, reason: `auto: 失败类别 ${cls} 与模型相关 → 允许挑备用模型` };
382
+ }
383
+ /** 从候选里挑第一个**与当前不同**的一份 (同一份不算备用; 挑不到就如实说没有) */
384
+ export function pickFallbackConfig(current, candidates) {
385
+ for (const c of candidates || []) {
386
+ if (!c || !c.provider || !c.model)
387
+ continue;
388
+ if (sameConfig(current, c))
389
+ continue;
390
+ return { ok: true, config: c, reason: `备用模型: ${c.provider}/${c.model} @ ${c.baseUrl} (hash=${c.configHash})` };
391
+ }
392
+ return { ok: false, reason: '没有可用备用模型 (候选为空, 或全都与当前那一份相同)' };
393
+ }
394
+ // ============================================================
395
+ // 策略存储 (加成文件, 与 Goal 记录同级)
396
+ // ============================================================
397
+ export function modelPolicyDir() {
398
+ return path.join(os.homedir(), '.bolloon', 'model-policy');
399
+ }
400
+ export function modelPolicyPath(goalId) {
401
+ return path.join(modelPolicyDir(), `${goalId}.json`);
402
+ }
403
+ /** 原子写 (tmp + rename + 0600): 读到的永远是完整 JSON */
404
+ async function writePolicyFile(goalId, value) {
405
+ await fs.mkdir(modelPolicyDir(), { recursive: true });
406
+ const p = modelPolicyPath(goalId);
407
+ const tmp = `${p}.${process.pid}-${crypto.randomBytes(3).toString('hex')}.tmp`;
408
+ await fs.writeFile(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
409
+ await fs.rename(tmp, p);
410
+ }
411
+ /**
412
+ * 写 Goal 的策略 (统一入口: 归一化 + 加时间戳, 调用方不许自己拼 JSON)。
413
+ * `pinned` 必须带完整 pin, 否则**拒绝写** (写一份用不了的策略比不写更坏)。
414
+ */
415
+ export async function writeGoalModelPolicy(goalId, policy, opts = {}) {
416
+ const parsed = parseGoalModelPolicy(policy);
417
+ if (!parsed.ok)
418
+ return { ok: false, reason: parsed.reason };
419
+ const value = {
420
+ ...parsed.policy,
421
+ updatedAt: new Date().toISOString(),
422
+ updatedBy: opts.updatedBy || 'unknown',
423
+ };
424
+ await writePolicyFile(String(goalId), value);
425
+ return { ok: true, policy: value };
426
+ }
427
+ /**
428
+ * 读 Goal 的策略。
429
+ * 优先 Goal 记录上的 `modelPolicy` 字段 (未来主线接上就自动生效); 没有才读 sidecar;
430
+ * 都没有 = 未声明 (origin='default', 语义为 auto)。**读不到 ≠ 不许换**, 但读不到也**不许假装读到**。
431
+ */
432
+ export async function readGoalModelPolicy(goalId, opts = {}) {
433
+ let record = opts.goalRecord;
434
+ if (record === undefined) {
435
+ try {
436
+ const { readGoal } = await import('./goal-store.js');
437
+ record = await readGoal(String(goalId));
438
+ }
439
+ catch {
440
+ record = null;
441
+ }
442
+ }
443
+ const fromRecord = record && typeof record === 'object' ? record.modelPolicy : undefined;
444
+ if (fromRecord !== undefined && fromRecord !== null) {
445
+ const parsed = parseGoalModelPolicy(fromRecord);
446
+ return {
447
+ policy: parsed.policy,
448
+ origin: parsed.ok ? 'goal_record' : 'invalid',
449
+ problems: parsed.ok ? [] : [parsed.reason || 'goal_record 上的 modelPolicy 解析失败'],
450
+ raw: fromRecord,
451
+ };
452
+ }
453
+ let fileText;
454
+ try {
455
+ fileText = await fs.readFile(modelPolicyPath(String(goalId)), 'utf8');
456
+ }
457
+ catch (err) {
458
+ // 文件不存在 = 未声明 (不是"读到了一份坏策略"); 读失败 (权限/盘错) 要能被看见, 不能当成"没有"
459
+ const code = err?.code;
460
+ if (code && code !== 'ENOENT') {
461
+ return {
462
+ policy: { mode: 'auto' },
463
+ origin: 'invalid',
464
+ problems: [`策略文件读不出来 (${code}): 按"未声明"处理, 但这一条要如实带出去`],
465
+ raw: undefined,
466
+ };
467
+ }
468
+ return { policy: { mode: 'auto' }, origin: 'default', problems: [], raw: undefined };
469
+ }
470
+ let raw;
471
+ try {
472
+ raw = JSON.parse(String(fileText));
473
+ }
474
+ catch (err) {
475
+ // 损坏 ≠ 没声明: 损坏要变成"保守 + 留痕", 不能静默当成默认策略
476
+ return {
477
+ policy: { mode: 'auto' },
478
+ origin: 'invalid',
479
+ problems: [`策略文件损坏 (${String(err?.message || err).slice(0, 120)}): 按保守语义处理 (不跟随全局), 并且这一条不许被说成"没有策略"`],
480
+ raw: undefined,
481
+ };
482
+ }
483
+ const parsed = parseGoalModelPolicy(raw);
484
+ return {
485
+ policy: parsed.policy,
486
+ origin: parsed.ok ? 'sidecar' : 'invalid',
487
+ problems: parsed.ok ? [] : [parsed.reason || 'sidecar 里的 modelPolicy 解析失败'],
488
+ raw,
489
+ };
490
+ }
491
+ /**
492
+ * 唯一 I/O 入口: 「这条**已在执行**的 Run 该用哪一份模型」。
493
+ *
494
+ * 回答只有一个基本面: **它自己的快照** (P1 定稿的那一份), 无论 Global 现在是什么。
495
+ * 与 `resolveNextRunModel` 的分工: 这里回答"现在这条", 那里回答"下一条"。
496
+ * `record=true` 时把结论写成 Run 事件 (含"与 pinned 策略冲突"这种必须留痕的事实)。
497
+ */
498
+ export async function resolveCurrentRunModel(runId, opts = {}) {
499
+ const rec = await readRun(runId);
500
+ if (!rec)
501
+ return null;
502
+ let policy = opts.policy;
503
+ let policyOrigin = 'default';
504
+ let policyProblems = [];
505
+ if (policy === undefined && rec.goalId) {
506
+ const view = await readGoalModelPolicy(rec.goalId);
507
+ policy = view.policy;
508
+ policyOrigin = view.origin;
509
+ policyProblems = view.problems;
510
+ }
511
+ let latest = null;
512
+ if (opts.latest !== undefined)
513
+ latest = opts.latest || null;
514
+ else {
515
+ try {
516
+ latest = await effectiveModelConfig({});
517
+ }
518
+ catch {
519
+ latest = null;
520
+ }
521
+ }
522
+ const decision = resolveRunModel({ intent: 'current_run', run: rec, policy, policyProblems, latest, sessionBinding: null, now: opts.now });
523
+ let event;
524
+ if (opts.record && decision.config) {
525
+ const res = await recordModelSwitch(runId, {
526
+ to: decision.config,
527
+ from: rec.modelConfig || null,
528
+ outcome: decision.conflict.length ? 'conflict' : 'frozen',
529
+ mode: (decision.mode === 'auto' || decision.mode === 'pinned' || decision.mode === 'session' ? decision.mode : 'unknown'),
530
+ source: decision.source,
531
+ reason: decision.reason.slice(0, 400),
532
+ guardsCarried: 0,
533
+ goalId: rec.goalId,
534
+ });
535
+ event = { ok: res.ok, reason: res.reason };
536
+ }
537
+ return { decision, policyOrigin, policyProblems, event };
538
+ }
539
+ /**
540
+ * 唯一 I/O 入口: 「下一个 Run 用什么模型」。
541
+ *
542
+ * 做四件事, 顺序固定 (算 → 守 → 记 → 交):
543
+ * ① 读事实: 上一个 Run 的快照 + Goal 策略 + 最新有效默认 + 会话绑定;
544
+ * ② 决定: `resolveRunModel({intent:'next_run'})` (纯函数);
545
+ * ③ 守卫: `planSwitchContinuation` —— **模型换不换都带跨 Run 非幂等守卫**;
546
+ * ④ 记事件: 把这次决定写成 Run 事件 (switched/frozen/conflict 都写, 结果如实)。
547
+ *
548
+ * 它**不启动 Run**, 也**不改**任何老 Run 的快照: 只回答"下一份该是什么", 由调用方去 startRun。
549
+ */
550
+ export async function resolveNextRunModel(opts = {}) {
551
+ const prevRun = opts.prevRunId ? await readRun(opts.prevRunId) : null;
552
+ const goalId = opts.goalId || prevRun?.goalId;
553
+ let policy = opts.policy;
554
+ let policyOrigin = 'default';
555
+ let policyProblems = [];
556
+ if (policy === undefined && goalId) {
557
+ const view = await readGoalModelPolicy(goalId);
558
+ policy = view.policy;
559
+ policyOrigin = view.origin;
560
+ policyProblems = view.problems;
561
+ }
562
+ else if (policy === undefined) {
563
+ policy = undefined; // 未声明 → auto (parse 的缺省)
564
+ }
565
+ let latest;
566
+ if (opts.latest !== undefined)
567
+ latest = opts.latest;
568
+ else {
569
+ try {
570
+ latest = await effectiveModelConfig({ sessionKey: opts.sessionKey });
571
+ }
572
+ catch {
573
+ latest = null;
574
+ }
575
+ }
576
+ let sessionBinding = null;
577
+ try {
578
+ sessionBinding = await readSessionSelection(opts.sessionKey);
579
+ }
580
+ catch {
581
+ sessionBinding = null;
582
+ }
583
+ const decision = resolveRunModel({
584
+ intent: 'next_run',
585
+ run: prevRun,
586
+ policy,
587
+ policyProblems,
588
+ latest,
589
+ sessionBinding,
590
+ now: opts.now,
591
+ });
592
+ // Supervisor 备用模型: 策略 + 失败类别都允许才换; 不允许时**不改**决定, 只说原因
593
+ let finalDecision = decision;
594
+ if (opts.errorClass || (opts.fallbackCandidates && opts.fallbackCandidates.length)) {
595
+ const verdict = supervisorMaySwitchModel({ mode: decision.mode, errorClass: opts.errorClass });
596
+ if (verdict.allowed) {
597
+ const picked = pickFallbackConfig(decision.config, opts.fallbackCandidates || []);
598
+ if (picked.ok && picked.config) {
599
+ finalDecision = {
600
+ ...decision,
601
+ config: picked.config,
602
+ source: 'supervisor_fallback',
603
+ switched: !sameConfig(prevRun?.modelConfig || null, picked.config),
604
+ frozen: false,
605
+ reason: `${decision.reason}; ${verdict.reason}; ${picked.reason}`,
606
+ };
607
+ }
608
+ else if (opts.errorClass) {
609
+ finalDecision = { ...decision, reason: `${decision.reason}; ${verdict.reason}; ${picked.reason}` };
610
+ }
611
+ }
612
+ else {
613
+ finalDecision = { ...decision, reason: `${decision.reason}; ${verdict.reason}` };
614
+ }
615
+ }
616
+ const continuation = prevRun ? planSwitchContinuation({ prevRun, decision: finalDecision }) : null;
617
+ // 事件: 只有真的"有一个 Run 可挂"时才写; 挂不上要如实说 (不是沉默地不记)
618
+ const targetRunId = opts.recordEventOnRunId || opts.prevRunId;
619
+ let event = { ok: false, reason: '没有可挂事件的 Run (既没给 prevRunId 也没给 recordEventOnRunId) → 本次决定未落盘' };
620
+ if (targetRunId && finalDecision.config) {
621
+ const outcome = finalDecision.conflict.length ? 'conflict'
622
+ : finalDecision.switched ? 'switched'
623
+ : 'frozen';
624
+ const res = await recordModelSwitch(targetRunId, {
625
+ to: finalDecision.config,
626
+ from: prevRun?.modelConfig || null,
627
+ outcome,
628
+ mode: (finalDecision.mode === 'auto' || finalDecision.mode === 'pinned' || finalDecision.mode === 'session'
629
+ ? finalDecision.mode
630
+ : 'unknown'),
631
+ source: finalDecision.source === 'none' ? 'none' : finalDecision.source,
632
+ reason: finalDecision.reason.slice(0, 400),
633
+ guardsCarried: continuation ? continuation.guards.length : 0,
634
+ goalId,
635
+ });
636
+ event = { ...res, onRunId: targetRunId };
637
+ if (!res.ok)
638
+ event.reason = `${res.reason} (决定已做出但没留下事件: ${finalDecision.reason.slice(0, 120)})`;
639
+ }
640
+ else if (targetRunId) {
641
+ event = { ok: false, onRunId: targetRunId, reason: '决定里没有可用的模型配置 → 不写事件 (不编一份没有的配置)' };
642
+ }
643
+ return {
644
+ decision: finalDecision,
645
+ continuation,
646
+ startRunModelConfig: finalDecision.config || undefined,
647
+ event,
648
+ policyOrigin,
649
+ policyProblems,
650
+ };
651
+ }