@bd7pil/reeve 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +223 -2
- package/THIRD-PARTY.md +25 -0
- package/agents/advisor.md +45 -0
- package/agents/critic.md +66 -0
- package/agents/executor.md +49 -0
- package/agents/foreman.md +120 -0
- package/agents/librarian.md +43 -0
- package/agents/planner.md +51 -0
- package/agents/reviewer.md +60 -0
- package/agents/scout.md +42 -0
- package/default-chain.json +251 -0
- package/index.js +4 -0
- package/licenses/oh-my-opencode-slim.LICENSE +21 -0
- package/licenses/open-code-review.LICENSE +201 -0
- package/package.json +43 -3
- package/skills/review/SKILL.md +116 -0
- package/skills/review/default.md +22 -0
- package/skills/review/go.md +69 -0
- package/skills/review/json.md +1 -0
- package/skills/review/package_json.md +3 -0
- package/skills/review/python.md +77 -0
- package/skills/review/ts_js_tsx_jsx.md +40 -0
- package/skills/review/yaml.md +1 -0
- package/src/chain.js +358 -0
- package/src/concurrency.js +125 -0
- package/src/config-write.js +136 -0
- package/src/constants.js +107 -0
- package/src/index.js +613 -0
- package/src/ledger.js +253 -0
- package/src/models.js +114 -0
- package/src/proxy.js +587 -0
- package/src/reconcile.js +141 -0
- package/src/register.js +137 -0
- package/src/roster.js +44 -0
- package/src/snapshot.js +171 -0
- package/src/stats.js +384 -0
- package/src/supervisor.js +124 -0
- package/src/terminal-gate.js +98 -0
package/src/stats.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// 性能统计:把 Bifrost 的调用日志与本地台账**聚合成面板要看的形状**。
|
|
2
|
+
//
|
|
3
|
+
// 分工(两条取数路径,别混):
|
|
4
|
+
// · Bifrost 侧 = "请求级归因":哪一档(routing_rule_name)、真打了谁(provider/model)、
|
|
5
|
+
// 是第几跳回退(fallback_index)、成没成、多久。**只有 Bifrost 知道真模型是谁** ——
|
|
6
|
+
// 引擎侧的 session 记录里模型名永远是 `reeve/pro` 这类别名(见 PROGRESS §6.2 归因边界)。
|
|
7
|
+
// · 编排侧 = "任务级台账":谁派给了谁、跑多久、超时/停止/对账、在飞与排队。
|
|
8
|
+
//
|
|
9
|
+
// 取数顺序(每步降级,最后如实标 unavailable):
|
|
10
|
+
// ① `GET /api/logs`(需 setup token —— 插件自己写的 600 文件)→ 实测形状见下
|
|
11
|
+
// ② 不产出统计:面板显示"拿不到",并给出手工核对的办法
|
|
12
|
+
// 为什么不做 logs.db 直读:引擎运行时里的 node:sqlite 版本未知(Bifrost 的库用了较新的
|
|
13
|
+
// 索引语法,EL7 的 python3.6 读它直接报 malformed schema)—— 直读是**最后**的手段,先不做。
|
|
14
|
+
//
|
|
15
|
+
// **实测形状**(2026-10-19 EL7,Bifrost 2.3.0):
|
|
16
|
+
// GET /api/logs?limit=N&offset=0[&sort_by=timestamp&order=desc|asc]
|
|
17
|
+
// → 200 `{ has_logs, logs:[…], pagination:{limit,offset,sort_by,order,total_count}, stats:{…} }`
|
|
18
|
+
// 行里的关键字段:`id, timestamp(ISO, 纳秒), provider, model, fallback_index, number_of_retries,
|
|
19
|
+
// routing_rule_id, routing_rule_name, selected_key_name, latency(ms, 浮点), upstream_latency,
|
|
20
|
+
// overhead_latency, status('success'|'error'), stream, session_id, user_agent, app`
|
|
21
|
+
// · 排序参数生效(order=asc 拿到最旧的);未知参数**被静默忽略**(试过 content=false 等一律无效)
|
|
22
|
+
// · `content_summary` 里是**完整提示词内容**,每行约 6.7 KB —— 聚合时一律丢掉(面板不该看内容)
|
|
23
|
+
// · 无 token → 401;limit 上限在 1000(5000 → 400)
|
|
24
|
+
// · `stats` 块除 total_requests 外全是 0,不可信 → 自己按行聚合
|
|
25
|
+
//
|
|
26
|
+
// 因此轮询策略:先 `limit=1` 探一下(`total_count` + 最新 id 就是"有没有变"的指纹),
|
|
27
|
+
// 变了才抓 `limit` 行回来聚合 —— 空闲时每分钟只有一次 6 KB 的请求。
|
|
28
|
+
|
|
29
|
+
export const STATS_DEFAULTS = Object.freeze({
|
|
30
|
+
/** 轮询间隔(unref 定时器,失败只记日志) */
|
|
31
|
+
intervalMs: 60_000,
|
|
32
|
+
/** 每次抓多少行(抓的是**最新**的若干行) */
|
|
33
|
+
limit: 100,
|
|
34
|
+
/** 聚合窗口:只有落在这个窗口里的行参与统计 */
|
|
35
|
+
windowMs: 24 * 60 * 60 * 1000,
|
|
36
|
+
/** 模型目录的刷新间隔(`/v1/models` 会真去上游探测并被 Bifrost 记日志 —— 别跟统计同频) */
|
|
37
|
+
modelsIntervalMs: 30 * 60 * 1000,
|
|
38
|
+
timeoutMs: 6_000,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** 面板要的形状里保留哪些字段 —— 其余(尤其 content_summary)一律丢掉。 */
|
|
42
|
+
const KEEP_FIELDS = [
|
|
43
|
+
'id',
|
|
44
|
+
'timestamp',
|
|
45
|
+
'provider',
|
|
46
|
+
'model',
|
|
47
|
+
'fallback_index',
|
|
48
|
+
'number_of_retries',
|
|
49
|
+
'routing_rule_id',
|
|
50
|
+
'routing_rule_name',
|
|
51
|
+
'selected_key_name',
|
|
52
|
+
'latency',
|
|
53
|
+
'upstream_latency',
|
|
54
|
+
'status',
|
|
55
|
+
'stream',
|
|
56
|
+
'session_id',
|
|
57
|
+
'prompt_tokens',
|
|
58
|
+
'completion_tokens',
|
|
59
|
+
'total_tokens',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
// ── 纯函数(可测) ─────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** 最近秩百分位(不做插值 —— 面板看的是"典型有多慢",不是数学分位)。输入会被排序副本。 */
|
|
65
|
+
export function percentile(values, p) {
|
|
66
|
+
const xs = (values ?? []).filter((v) => Number.isFinite(v)).sort((a, b) => a - b)
|
|
67
|
+
if (xs.length === 0) return null
|
|
68
|
+
const idx = Math.min(xs.length - 1, Math.max(0, Math.ceil(p * xs.length) - 1))
|
|
69
|
+
return xs[idx]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 一组延迟(毫秒)→ `{n, avg, p50, p95, max}`;一个数都没有就返回 n=0 的空壳。 */
|
|
73
|
+
export function summarizeLatency(values) {
|
|
74
|
+
const xs = (values ?? []).filter((v) => Number.isFinite(v))
|
|
75
|
+
if (xs.length === 0) return { n: 0, avg: null, p50: null, p95: null, max: null }
|
|
76
|
+
const round = (v) => (v == null ? null : Math.round(v))
|
|
77
|
+
return {
|
|
78
|
+
n: xs.length,
|
|
79
|
+
avg: round(xs.reduce((s, v) => s + v, 0) / xs.length),
|
|
80
|
+
p50: round(percentile(xs, 0.5)),
|
|
81
|
+
p95: round(percentile(xs, 0.95)),
|
|
82
|
+
max: round(Math.max(...xs)),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `reeve/pro` → `pro`;不是 REEVE 的档位规则就返回 null("不属于本层")。 */
|
|
87
|
+
export function tierOfRuleName(name) {
|
|
88
|
+
const m = /^reeve\/(flash|pro|multimodal)$/.exec(String(name ?? ''))
|
|
89
|
+
return m ? m[1] : null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 丢内容、留计数:面板只该看归因与延迟,不该看提示词。 */
|
|
93
|
+
export function normalizeLogRow(row) {
|
|
94
|
+
const out = {}
|
|
95
|
+
for (const k of KEEP_FIELDS) if (k in row) out[k] = row[k]
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `provider/model` 的引用串(链里就是这个写法)。 */
|
|
100
|
+
export function modelRef(row) {
|
|
101
|
+
return row?.provider && row?.model ? `${row.provider}/${row.model}` : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseTs(value) {
|
|
105
|
+
const t = Date.parse(String(value ?? ''))
|
|
106
|
+
return Number.isFinite(t) ? t : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 抓回来的行 → 归因统计。
|
|
111
|
+
* `source` 只表达"这批数字从哪来",`window` 里给出实际覆盖的时间范围(别让面板假装是 24h)。
|
|
112
|
+
*
|
|
113
|
+
* ⚠️ 两条实测出来的口径(都踩到过):
|
|
114
|
+
* · **`model` 为空的行不是调用**:那是 Bifrost 去上游探 `/v1/models` 的痕迹(rule 为 null、
|
|
115
|
+
* model 为空串)。实测一次探测每 7 个 provider 就写 7 行 —— 混进来会把"调用次数"灌爆。
|
|
116
|
+
* 所以按"有 model"过滤,并如实报出过滤了多少行。
|
|
117
|
+
* · **status 有三态**:`success` / `error` / `cancelled`(客户端把流掐了)。cancelled 不算失败。
|
|
118
|
+
*/
|
|
119
|
+
export function aggregateBifrostRows(rows, { now = Date.now(), windowMs = STATS_DEFAULTS.windowMs } = {}) {
|
|
120
|
+
const normalized = (rows ?? []).map(normalizeLogRow)
|
|
121
|
+
const withTs = normalized.map((r) => ({ row: r, at: parseTs(r.timestamp) }))
|
|
122
|
+
const probes = withTs.filter((x) => !x.row.model)
|
|
123
|
+
const calls = withTs.filter((x) => x.row.model)
|
|
124
|
+
const since = now - windowMs
|
|
125
|
+
const inWindow = calls.filter((x) => x.at != null && x.at >= since)
|
|
126
|
+
// 窗口内一条都没有(机器闲了很久)→ 退成"就用抓到的这些行",并如实标 relaxed
|
|
127
|
+
const relaxed = inWindow.length === 0 && calls.length > 0
|
|
128
|
+
const used = relaxed ? calls : inWindow
|
|
129
|
+
|
|
130
|
+
const tiers = new Map()
|
|
131
|
+
const providers = new Map()
|
|
132
|
+
const models = new Map()
|
|
133
|
+
let fallbackHops = 0
|
|
134
|
+
let fallbackRequests = 0
|
|
135
|
+
const byIndex = {}
|
|
136
|
+
let ok = 0
|
|
137
|
+
let error = 0
|
|
138
|
+
let cancelled = 0
|
|
139
|
+
const latencies = []
|
|
140
|
+
|
|
141
|
+
for (const { row, at } of used) {
|
|
142
|
+
const isOk = row.status === 'success'
|
|
143
|
+
const isCancelled = row.status === 'cancelled'
|
|
144
|
+
if (isOk) ok += 1
|
|
145
|
+
else if (isCancelled) cancelled += 1
|
|
146
|
+
else error += 1
|
|
147
|
+
const lat = Number(row.latency)
|
|
148
|
+
if (Number.isFinite(lat)) latencies.push(lat)
|
|
149
|
+
|
|
150
|
+
const tier = tierOfRuleName(row.routing_rule_name)
|
|
151
|
+
const bucketKey = tier ?? '(非 REEVE 档位)'
|
|
152
|
+
const bucket =
|
|
153
|
+
tiers.get(bucketKey) ??
|
|
154
|
+
{ tier, rule: row.routing_rule_name ?? null, n: 0, ok: 0, error: 0, cancelled: 0, fallbackRequests: 0, fallbackHops: 0, latency: [], providers: new Map() }
|
|
155
|
+
bucket.n += 1
|
|
156
|
+
if (isOk) bucket.ok += 1
|
|
157
|
+
else if (isCancelled) bucket.cancelled += 1
|
|
158
|
+
else bucket.error += 1
|
|
159
|
+
const fb = Number(row.fallback_index) || 0
|
|
160
|
+
if (fb > 0) {
|
|
161
|
+
bucket.fallbackRequests += 1
|
|
162
|
+
bucket.fallbackHops += fb
|
|
163
|
+
fallbackRequests += 1
|
|
164
|
+
fallbackHops += fb
|
|
165
|
+
}
|
|
166
|
+
byIndex[fb] = (byIndex[fb] ?? 0) + 1
|
|
167
|
+
if (Number.isFinite(lat)) bucket.latency.push(lat)
|
|
168
|
+
const ref = modelRef(row)
|
|
169
|
+
if (ref) bucket.providers.set(ref, (bucket.providers.get(ref) ?? 0) + 1)
|
|
170
|
+
tiers.set(bucketKey, bucket)
|
|
171
|
+
|
|
172
|
+
const p = providers.get(row.provider ?? '(未知)') ?? { provider: row.provider ?? '(未知)', n: 0, ok: 0, error: 0, cancelled: 0, latency: [], lastAt: null }
|
|
173
|
+
p.n += 1
|
|
174
|
+
if (isOk) p.ok += 1
|
|
175
|
+
else if (isCancelled) p.cancelled += 1
|
|
176
|
+
else p.error += 1
|
|
177
|
+
if (Number.isFinite(lat)) p.latency.push(lat)
|
|
178
|
+
if (at != null && (p.lastAt == null || at > p.lastAt)) p.lastAt = at
|
|
179
|
+
providers.set(p.provider, p)
|
|
180
|
+
|
|
181
|
+
if (ref) {
|
|
182
|
+
const m = models.get(ref) ?? { ref, provider: row.provider, model: row.model, n: 0, ok: 0, error: 0, lastAt: null }
|
|
183
|
+
m.n += 1
|
|
184
|
+
if (isOk) m.ok += 1
|
|
185
|
+
else if (!isCancelled) m.error += 1
|
|
186
|
+
if (at != null && (m.lastAt == null || at > m.lastAt)) m.lastAt = at
|
|
187
|
+
models.set(ref, m)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const tsList = used.map((x) => x.at).filter((v) => v != null)
|
|
192
|
+
const TIER_ORDER = ['flash', 'pro', 'multimodal']
|
|
193
|
+
const tierRows = [...tiers.values()]
|
|
194
|
+
.map((b) => ({
|
|
195
|
+
tier: b.tier,
|
|
196
|
+
rule: b.rule,
|
|
197
|
+
n: b.n,
|
|
198
|
+
ok: b.ok,
|
|
199
|
+
error: b.error,
|
|
200
|
+
cancelled: b.cancelled,
|
|
201
|
+
fallbackRequests: b.fallbackRequests,
|
|
202
|
+
fallbackHops: b.fallbackHops,
|
|
203
|
+
latency: summarizeLatency(b.latency),
|
|
204
|
+
providers: [...b.providers.entries()]
|
|
205
|
+
.map(([ref, n]) => ({ ref, n }))
|
|
206
|
+
.sort((a, b) => b.n - a.n),
|
|
207
|
+
}))
|
|
208
|
+
.sort((a, b) => {
|
|
209
|
+
const ia = a.tier ? TIER_ORDER.indexOf(a.tier) : 99
|
|
210
|
+
const ib = b.tier ? TIER_ORDER.indexOf(b.tier) : 99
|
|
211
|
+
return ia - ib || b.n - a.n
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
window: {
|
|
216
|
+
ms: windowMs,
|
|
217
|
+
since: relaxed ? (tsList.length ? Math.min(...tsList) : null) : since,
|
|
218
|
+
until: tsList.length ? Math.max(...tsList) : null,
|
|
219
|
+
relaxed,
|
|
220
|
+
},
|
|
221
|
+
fetched: normalized.length,
|
|
222
|
+
probes: probes.length,
|
|
223
|
+
used: used.length,
|
|
224
|
+
total: used.length,
|
|
225
|
+
ok,
|
|
226
|
+
error,
|
|
227
|
+
cancelled,
|
|
228
|
+
latency: summarizeLatency(latencies),
|
|
229
|
+
fallback: { requests: fallbackRequests, hops: fallbackHops, byIndex },
|
|
230
|
+
byTier: tierRows,
|
|
231
|
+
byProvider: [...providers.values()]
|
|
232
|
+
.map((p) => ({ provider: p.provider, n: p.n, ok: p.ok, error: p.error, cancelled: p.cancelled, lastAt: p.lastAt, latency: summarizeLatency(p.latency) }))
|
|
233
|
+
.sort((a, b) => b.n - a.n),
|
|
234
|
+
byModel: [...models.values()].sort((a, b) => b.n - a.n).slice(0, 20),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 台账(本地记录)→ 每 agent 的任务级统计。
|
|
240
|
+
* 时长 = `endedAt - startedAt`(终态的才算;在飞的只报数)。
|
|
241
|
+
*/
|
|
242
|
+
export function aggregateLedger(records, { now = Date.now() } = {}) {
|
|
243
|
+
const byAgent = new Map()
|
|
244
|
+
const totals = { n: 0, ok: 0, error: 0, stopped: 0, running: 0, uncertain: 0, timedOut: 0 }
|
|
245
|
+
const durations = []
|
|
246
|
+
let firstAt = null
|
|
247
|
+
let lastAt = null
|
|
248
|
+
for (const r of records ?? []) {
|
|
249
|
+
const agent = r.agent ?? '(未标注)'
|
|
250
|
+
const b = byAgent.get(agent) ?? { agent, n: 0, ok: 0, error: 0, stopped: 0, running: 0, uncertain: 0, timedOut: 0, durations: [] }
|
|
251
|
+
b.n += 1
|
|
252
|
+
totals.n += 1
|
|
253
|
+
const state = String(r.state ?? '')
|
|
254
|
+
if (state === 'completed') {
|
|
255
|
+
b.ok += 1
|
|
256
|
+
totals.ok += 1
|
|
257
|
+
} else if (state === 'error') {
|
|
258
|
+
b.error += 1
|
|
259
|
+
totals.error += 1
|
|
260
|
+
} else if (state === 'stopped') {
|
|
261
|
+
b.stopped += 1
|
|
262
|
+
totals.stopped += 1
|
|
263
|
+
} else if (state === 'running') {
|
|
264
|
+
b.running += 1
|
|
265
|
+
totals.running += 1
|
|
266
|
+
}
|
|
267
|
+
if (r.statusUncertain === true) {
|
|
268
|
+
b.uncertain += 1
|
|
269
|
+
totals.uncertain += 1
|
|
270
|
+
}
|
|
271
|
+
if (r.timedOut === true) {
|
|
272
|
+
b.timedOut += 1
|
|
273
|
+
totals.timedOut += 1
|
|
274
|
+
}
|
|
275
|
+
const start = Number(r.startedAt)
|
|
276
|
+
const end = Number(r.endedAt)
|
|
277
|
+
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
|
|
278
|
+
b.durations.push(end - start)
|
|
279
|
+
durations.push(end - start)
|
|
280
|
+
}
|
|
281
|
+
if (Number.isFinite(start) && (firstAt == null || start < firstAt)) firstAt = start
|
|
282
|
+
if (Number.isFinite(end) && (lastAt == null || end > lastAt)) lastAt = end
|
|
283
|
+
byAgent.set(agent, b)
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
totals: { ...totals, uncertain: totals.uncertain },
|
|
287
|
+
window: { from: firstAt, until: lastAt, now },
|
|
288
|
+
latency: summarizeLatency(durations),
|
|
289
|
+
byAgent: [...byAgent.values()]
|
|
290
|
+
.map(({ durations: d, ...rest }) => ({ ...rest, latency: summarizeLatency(d) }))
|
|
291
|
+
.sort((a, b) => b.n - a.n),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 快照里的 `stats` 一节。
|
|
297
|
+
* `source` 三态:`bifrost.api`(拿到了)/ `unavailable`(拿不到,附 error)/ `disabled`。
|
|
298
|
+
*/
|
|
299
|
+
export function buildStats({ bifrost = null, orchestrate = null, now = Date.now(), windowMs = STATS_DEFAULTS.windowMs, source = 'unavailable', error = null, checkedAt = null } = {}) {
|
|
300
|
+
return { generatedAt: now, checkedAt, windowMs, source, error, bifrost, orchestrate }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── IO(薄,注入 fetch 便于测试) ────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function headers(token) {
|
|
306
|
+
return token ? { 'X-Bifrost-Setup-Token': token } : {}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 抓日志。/api/logs 的 `pagination.total_count` + 最新一行的 id 就是"有没有新东西"的指纹。
|
|
311
|
+
* @returns {{rows: any[], total: number|null, signature: string|null, error: string|null}}
|
|
312
|
+
*/
|
|
313
|
+
export async function fetchBifrostLogs({
|
|
314
|
+
port,
|
|
315
|
+
host = '127.0.0.1',
|
|
316
|
+
token = null,
|
|
317
|
+
limit = STATS_DEFAULTS.limit,
|
|
318
|
+
offset = 0,
|
|
319
|
+
order = 'desc',
|
|
320
|
+
fetchImpl = fetch,
|
|
321
|
+
timeoutMs = STATS_DEFAULTS.timeoutMs,
|
|
322
|
+
} = {}) {
|
|
323
|
+
const url = `http://${host}:${port}/api/logs?limit=${Number(limit)}&offset=${Number(offset)}&sort_by=timestamp&order=${order}`
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetchImpl(url, { headers: headers(token), signal: AbortSignal.timeout(timeoutMs) })
|
|
326
|
+
if (res.status !== 200) return { rows: [], total: null, signature: null, error: `HTTP ${res.status}` }
|
|
327
|
+
const doc = await res.json()
|
|
328
|
+
// 实测是 `logs`;`data` 只是当年的消费假设,留着兜底
|
|
329
|
+
const rows = Array.isArray(doc?.logs) ? doc.logs : Array.isArray(doc?.data) ? doc.data : []
|
|
330
|
+
const total = Number(doc?.pagination?.total_count)
|
|
331
|
+
return {
|
|
332
|
+
rows,
|
|
333
|
+
total: Number.isFinite(total) ? total : null,
|
|
334
|
+
signature: `${Number.isFinite(total) ? total : '?'}:${rows[0]?.id ?? '(空)'}`,
|
|
335
|
+
error: null,
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return { rows: [], total: null, signature: null, error: String(err?.message ?? err).slice(0, 140) }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Bifrost 自己发现出来的模型目录(`provider/model`)—— 面板选择器的候选来源之一。 */
|
|
343
|
+
export async function fetchBifrostModels({ port, host = '127.0.0.1', fetchImpl = fetch, timeoutMs = STATS_DEFAULTS.timeoutMs } = {}) {
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetchImpl(`http://${host}:${port}/v1/models`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
346
|
+
if (res.status !== 200) return { models: [], error: `HTTP ${res.status}` }
|
|
347
|
+
const doc = await res.json()
|
|
348
|
+
const models = (Array.isArray(doc?.data) ? doc.data : [])
|
|
349
|
+
.map((m) => {
|
|
350
|
+
const ref = String(m?.id ?? '')
|
|
351
|
+
const i = ref.indexOf('/')
|
|
352
|
+
if (i <= 0) return null
|
|
353
|
+
return { ref, provider: ref.slice(0, i), model: ref.slice(i + 1), context: Number(m?.context_length) || null }
|
|
354
|
+
})
|
|
355
|
+
.filter(Boolean)
|
|
356
|
+
return { models, error: null }
|
|
357
|
+
} catch (err) {
|
|
358
|
+
return { models: [], error: String(err?.message ?? err).slice(0, 140) }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 面板选择器的候选 = 发现出来的 ∪ 链上已经在用的 ∪ 引擎注册的(reeve/*)。
|
|
364
|
+
* 为什么必须含"链上在用的"与"手工可填":实测 `/v1/models` 只覆盖 4/7 个 provider
|
|
365
|
+
* (zhipuai-coding-plan 的对话路径是自定义的,`/v1/models` 探不到;opencode zen 与
|
|
366
|
+
* alibaba-cn 同理)。发现不全就如实标出来,而不是假装列表就是全集。
|
|
367
|
+
*/
|
|
368
|
+
export function mergeModelCandidates({ discovered = [], chained = [], engine = [] } = {}) {
|
|
369
|
+
const out = new Map()
|
|
370
|
+
const add = (m, source) => {
|
|
371
|
+
const ref = typeof m === 'string' ? m : m?.ref
|
|
372
|
+
// 认不出的、以及 `provider/`(同模型换 provider,不是完整引用)都丢掉
|
|
373
|
+
const i = String(ref ?? '').indexOf('/')
|
|
374
|
+
if (!ref || i <= 0 || i === ref.length - 1) return
|
|
375
|
+
const cur = out.get(ref) ?? { ref, provider: ref.slice(0, i), model: ref.slice(i + 1), context: null, sources: [] }
|
|
376
|
+
if (typeof m === 'object' && Number(m?.context)) cur.context = Number(m.context)
|
|
377
|
+
if (!cur.sources.includes(source)) cur.sources.push(source)
|
|
378
|
+
out.set(ref, cur)
|
|
379
|
+
}
|
|
380
|
+
for (const m of discovered) add(m, 'bifrost')
|
|
381
|
+
for (const ref of chained) add(ref, 'chain')
|
|
382
|
+
for (const m of engine) add(m, 'engine')
|
|
383
|
+
return [...out.values()].sort((a, b) => a.ref.localeCompare(b.ref))
|
|
384
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 墙钟监督器:卡死的子任务要被掐掉。
|
|
2
|
+
//
|
|
3
|
+
// 规则(抄 omo-slim,MIT):
|
|
4
|
+
// - **只监督 `background === true` 的任务**(前台的调用方自己在等,不需要第三方掐)
|
|
5
|
+
// - **每任务一次性定时器**;**只对新 generation 重新武装**
|
|
6
|
+
// - **忙碌 / 工具活动 / 存活信号都不续期**(卡死判定不能被"它还在动"这种信号骗过)
|
|
7
|
+
// - 到期后:先武装宽限定时器 → 再发**恰好一次** interrupt
|
|
8
|
+
// - 宽限到期仍未确认 → 标 statusUncertain 并落 error, timedOut
|
|
9
|
+
// - **已发布的硬超时不可被迟到的完成/忙碌替换**(由 Ledger.commitTerminal 的单调性保证)
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_ABORT_GRACE_MS,
|
|
13
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
14
|
+
REASON,
|
|
15
|
+
STATE,
|
|
16
|
+
} from './constants.js'
|
|
17
|
+
|
|
18
|
+
export class Supervisor {
|
|
19
|
+
#timers = new Map() // taskID -> { generation, timer, phase: 'wall' | 'grace' }
|
|
20
|
+
|
|
21
|
+
constructor({
|
|
22
|
+
ledger,
|
|
23
|
+
interrupt,
|
|
24
|
+
logger = console,
|
|
25
|
+
abortGraceMs = DEFAULT_ABORT_GRACE_MS,
|
|
26
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
27
|
+
}) {
|
|
28
|
+
this.ledger = ledger
|
|
29
|
+
this.interrupt = interrupt
|
|
30
|
+
this.logger = logger
|
|
31
|
+
this.abortGraceMs = abortGraceMs
|
|
32
|
+
this.maxAttempts = maxAttempts
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 为一条记录武装墙钟。只有 background 任务、且未发布终态时才武装。 */
|
|
36
|
+
arm(taskID, wallClockMs) {
|
|
37
|
+
const record = this.ledger.get(taskID)
|
|
38
|
+
if (!record) return false
|
|
39
|
+
if (!record.background) return false
|
|
40
|
+
if (record.terminalPublished) return false
|
|
41
|
+
|
|
42
|
+
const existing = this.#timers.get(taskID)
|
|
43
|
+
if (existing && existing.generation === record.generation) {
|
|
44
|
+
// 同一个 generation 已经有定时器 —— 不重复武装,也**不续期**
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
if (existing) clearTimeout(existing.timer)
|
|
48
|
+
|
|
49
|
+
const limit = Number(wallClockMs)
|
|
50
|
+
if (!Number.isFinite(limit) || limit <= 0) return false
|
|
51
|
+
|
|
52
|
+
const timer = setTimeout(() => this.#onExpiry(taskID, record.generation), limit)
|
|
53
|
+
timer.unref?.()
|
|
54
|
+
this.#timers.set(taskID, { generation: record.generation, timer, phase: 'wall' })
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 终态发布后拆除计时器。 */
|
|
59
|
+
disarm(taskID) {
|
|
60
|
+
const entry = this.#timers.get(taskID)
|
|
61
|
+
if (!entry) return
|
|
62
|
+
clearTimeout(entry.timer)
|
|
63
|
+
this.#timers.delete(taskID)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 生成新 generation 时重新武装(例如重试)。
|
|
68
|
+
* **注意**:activity / busy 信号**不**走这里 —— 它们不该续期。
|
|
69
|
+
*/
|
|
70
|
+
rearmIfNewGeneration(taskID, wallClockMs) {
|
|
71
|
+
const record = this.ledger.get(taskID)
|
|
72
|
+
if (!record) return false
|
|
73
|
+
const entry = this.#timers.get(taskID)
|
|
74
|
+
if (entry && entry.generation === record.generation) return false
|
|
75
|
+
return this.arm(taskID, wallClockMs)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async #onExpiry(taskID, generation) {
|
|
79
|
+
const record = this.ledger.get(taskID)
|
|
80
|
+
if (!record) return
|
|
81
|
+
// 迟到的定时器:generation 变了或已终态 —— 丢弃,不要覆盖
|
|
82
|
+
if (record.generation !== generation) return
|
|
83
|
+
if (record.terminalPublished) return
|
|
84
|
+
|
|
85
|
+
this.logger?.debug?.(`[reeve] 墙钟到期:${taskID}(子会话 ${record.childSessionID})`)
|
|
86
|
+
|
|
87
|
+
// 先武装宽限定时器,再发中断 —— 顺序不能反
|
|
88
|
+
const graceTimer = setTimeout(() => this.#onGraceExpiry(taskID, generation), this.abortGraceMs)
|
|
89
|
+
graceTimer.unref?.()
|
|
90
|
+
this.#timers.set(taskID, { generation, timer: graceTimer, phase: 'grace' })
|
|
91
|
+
|
|
92
|
+
// 恰好一次。**发之前先打标**:宿主会因这次中断回 `execution.interrupted`,
|
|
93
|
+
// 事件处理器要靠 interruptReason 把它归因成"墙钟超时"而不是"宿主中断"。
|
|
94
|
+
if (record.interruptSent) return
|
|
95
|
+
this.ledger.update(taskID, { interruptSent: true, interruptReason: 'wall_clock' })
|
|
96
|
+
try {
|
|
97
|
+
await this.interrupt(record)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.logger?.debug?.(`[reeve] 中断失败(${taskID}):${String(err)}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async #onGraceExpiry(taskID, generation) {
|
|
104
|
+
const record = this.ledger.get(taskID)
|
|
105
|
+
if (!record) return
|
|
106
|
+
if (record.generation !== generation) return
|
|
107
|
+
if (record.terminalPublished) return
|
|
108
|
+
|
|
109
|
+
// 宽限到期仍未确认 —— 如实标"不确定",并落超时终态。
|
|
110
|
+
this.ledger.commitTerminal(taskID, STATE.ERROR, {
|
|
111
|
+
source: 'wall-clock',
|
|
112
|
+
reason: REASON.WALL_CLOCK_TIMEOUT,
|
|
113
|
+
timedOut: true,
|
|
114
|
+
statusUncertain: true,
|
|
115
|
+
terminalUnreconciled: true,
|
|
116
|
+
})
|
|
117
|
+
this.#timers.delete(taskID)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
stopAll() {
|
|
121
|
+
for (const entry of this.#timers.values()) clearTimeout(entry.timer)
|
|
122
|
+
this.#timers.clear()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// 终态判定门 —— "静默死亡"的边界。
|
|
2
|
+
//
|
|
3
|
+
// 没有"子任务死了"这个事件,全靠推断。三处证据:
|
|
4
|
+
// ① 宿主事件(session.execution.*)—— 有就是有,可靠
|
|
5
|
+
// ② 子会话转录的分类(classifyTranscript)—— 判定终态 / 无终态 / 未知
|
|
6
|
+
// ③ 宿主 live-status 轮询(本实现不用轮询,靠 ①②)
|
|
7
|
+
//
|
|
8
|
+
// **关键设计(抄 omo-slim,MIT)**:`session.idle` 只是**候选**,不立即判定终止;
|
|
9
|
+
// 只有当转录判定为 absent **且**静默已持续超过宽限期,才落 stopped。
|
|
10
|
+
// 否则会把每个正常完成的子任务都误报成死亡。
|
|
11
|
+
|
|
12
|
+
import { STOP_CONFIRMATION_GRACE_MS } from './constants.js'
|
|
13
|
+
|
|
14
|
+
export const VERDICT = Object.freeze({
|
|
15
|
+
/** 转录里有明确的终态结果。 */
|
|
16
|
+
TERMINAL: 'terminal',
|
|
17
|
+
/** 转录里没有终态结果,且看起来已经不动了 —— 停止候选。 */
|
|
18
|
+
ABSENT: 'absent',
|
|
19
|
+
/** 看不出来(读不到、形状不认识等)—— 一律 fail-open,保持 running。 */
|
|
20
|
+
UNKNOWN: 'unknown',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 判定"静默是否已经稳定到可以下结论"。
|
|
25
|
+
*
|
|
26
|
+
* 用 max(最后一次观测到静默的时刻, 本次尝试的开始时刻) 作为基准 ——
|
|
27
|
+
* 前者防止把"刚开始跑"误判成"停了很久",后者防止把"正常完成的最后一刻"误判成死亡。
|
|
28
|
+
*/
|
|
29
|
+
export function isStopStable({ quiescentSince, attemptStartedAt, graceMs, now = Date.now() }) {
|
|
30
|
+
const base = Math.max(quiescentSince ?? now, attemptStartedAt ?? 0)
|
|
31
|
+
return now - base >= graceMs
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 决定是否落 stopped。
|
|
36
|
+
* @returns {{ stop: boolean, reason: string }}
|
|
37
|
+
*/
|
|
38
|
+
export function decideStop({
|
|
39
|
+
verdict,
|
|
40
|
+
quiescentSince = null,
|
|
41
|
+
attemptStartedAt = 0,
|
|
42
|
+
graceMs = STOP_CONFIRMATION_GRACE_MS,
|
|
43
|
+
now = Date.now(),
|
|
44
|
+
}) {
|
|
45
|
+
if (verdict !== VERDICT.ABSENT) {
|
|
46
|
+
return { stop: false, reason: `verdict=${verdict}` }
|
|
47
|
+
}
|
|
48
|
+
const stable = isStopStable({ quiescentSince, attemptStartedAt, graceMs, now })
|
|
49
|
+
return stable
|
|
50
|
+
? { stop: true, reason: 'transcript_absent_and_quiescent' }
|
|
51
|
+
: { stop: false, reason: 'still_within_grace' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 从子会话转录里分类出终态证据。
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ **输入形状未完全验证**:转录由 `ctx.session.context({ sessionID })` 取(施工图 §6),
|
|
58
|
+
* 其返回结构尚未实测。这里只做**形状容错**的保守判定:
|
|
59
|
+
* - 认不出形状 → UNKNOWN(fail-open,保持 running,绝不下结论)
|
|
60
|
+
* - 看到明确的失败/中断标记 → TERMINAL
|
|
61
|
+
* - 看到 assistant 产出了内容 → TERMINAL(宿主会另行发 execution.succeeded)
|
|
62
|
+
* - 什么都没有且消息列表为空 → ABSENT(停止候选)
|
|
63
|
+
*/
|
|
64
|
+
export function classifyTranscript(transcript) {
|
|
65
|
+
if (transcript == null) return VERDICT.UNKNOWN
|
|
66
|
+
|
|
67
|
+
const messages = pickMessages(transcript)
|
|
68
|
+
if (!messages) return VERDICT.UNKNOWN
|
|
69
|
+
if (messages.length === 0) return VERDICT.ABSENT
|
|
70
|
+
|
|
71
|
+
let sawAssistantContent = false
|
|
72
|
+
for (const m of messages) {
|
|
73
|
+
const role = m?.role ?? m?.info?.role
|
|
74
|
+
const text = extractText(m)
|
|
75
|
+
if (role === 'assistant' && text && text.length > 0) sawAssistantContent = true
|
|
76
|
+
const err = m?.error ?? m?.info?.error
|
|
77
|
+
if (err) return VERDICT.TERMINAL
|
|
78
|
+
}
|
|
79
|
+
return sawAssistantContent ? VERDICT.TERMINAL : VERDICT.ABSENT
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pickMessages(transcript) {
|
|
83
|
+
if (Array.isArray(transcript)) return transcript
|
|
84
|
+
if (Array.isArray(transcript.messages)) return transcript.messages
|
|
85
|
+
if (Array.isArray(transcript.history)) return transcript.history
|
|
86
|
+
if (Array.isArray(transcript.context?.messages)) return transcript.context.messages
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractText(m) {
|
|
91
|
+
const parts = m?.parts ?? m?.content ?? null
|
|
92
|
+
if (typeof parts === 'string') return parts
|
|
93
|
+
if (!Array.isArray(parts)) return null
|
|
94
|
+
return parts
|
|
95
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
96
|
+
.map((p) => p.text ?? '')
|
|
97
|
+
.join('')
|
|
98
|
+
}
|