@bolloon/bolloon-agent 0.4.25 → 0.4.27
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/agents/execution-supervisor.js +87 -2
- package/dist/agents/external-events.js +162 -0
- package/dist/agents/goal-criteria.js +124 -0
- package/dist/agents/goal-store.js +79 -4
- package/dist/agents/p2p-info.js +175 -0
- package/dist/agents/pi-sdk.js +39 -0
- package/dist/agents/run-store.js +15 -0
- package/dist/agents/skill-readiness.js +133 -0
- package/dist/agents/skill-supervisor-link.js +70 -0
- package/dist/agents/skills-manager.js +282 -0
- package/dist/agents/trace-export.js +125 -0
- package/dist/agents/write-staging.js +12 -4
- package/dist/agents/x402/goal-run-bridge.js +105 -0
- package/dist/agents/x402/milestone-settlement.js +150 -0
- package/dist/agents/x402/paid-info-store.js +66 -12
- package/dist/agents/x402/payment-recovery.js +290 -0
- package/dist/agents/x402/resource-contract.js +473 -0
- package/dist/agents/x402/settlement-state.js +378 -0
- package/dist/agents/x402/trade.js +257 -0
- package/dist/agents/x402/transaction-protocol.js +99 -0
- package/dist/agents/x402/transaction-store.js +350 -0
- package/dist/cli/setup-wizard.js +96 -127
- package/dist/cli-entry.js +74 -0
- package/dist/electron/first-run.js +33 -2
- package/dist/electron-build/electron/first-run.js +35 -2
- package/dist/electron-build/electron/first-run.js.map +1 -1
- package/dist/index.js +224 -4
- package/dist/llm/config-store.js +35 -4
- package/dist/network/agent-network.js +10 -0
- package/dist/network/goal-event-bridge.js +57 -0
- package/dist/setup/onboard.js +549 -0
- package/dist/setup/setup-store.js +592 -0
- package/dist/web/routes-x402-info.js +1 -0
- package/dist/web/server.js +330 -1
- package/package.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* p2p-info.ts — 本机 P2P 连接信息出口 (2026-09-18)
|
|
3
|
+
*
|
|
4
|
+
* 目的: 把"我这台机器/这台手机怎么被别的智能体拨通"变成**一条可抄走的连接信息**
|
|
5
|
+
* (peerId + 可拨入 multiaddr),好递给名片/交接串/对方智能体。
|
|
6
|
+
* 诚实原则: 只报真实拿到的;拿不到就说清原因与下一步,不编 peerId、不假装"能连通"。
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
function home() { return process.env.HOME || os.homedir(); }
|
|
12
|
+
function readJsonSafe(p) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function readIdentity() {
|
|
21
|
+
const p = path.join(home(), '.bolloon', 'identity', 'user.json');
|
|
22
|
+
const j = readJsonSafe(p);
|
|
23
|
+
return { did: j?.did, name: j?.name };
|
|
24
|
+
}
|
|
25
|
+
function readGatewayJoin() {
|
|
26
|
+
return readJsonSafe(path.join(home(), '.bolloon', 'gateway-join.json'));
|
|
27
|
+
}
|
|
28
|
+
/** 确保地址带 /p2p/<peerId> 段 (没有就补上, 否则对端拨不通) */
|
|
29
|
+
export function ensureDialable(addr, peerId) {
|
|
30
|
+
const a = String(addr || '').trim();
|
|
31
|
+
if (!a)
|
|
32
|
+
return a;
|
|
33
|
+
if (a.includes('/p2p/'))
|
|
34
|
+
return a;
|
|
35
|
+
return peerId ? `${a}/p2p/${peerId}` : a;
|
|
36
|
+
}
|
|
37
|
+
export async function getLocalP2pInfo() {
|
|
38
|
+
const info = { ok: false, source: 'none', multiaddrs: [], relayAddrs: [], isRelay: false };
|
|
39
|
+
const id = readIdentity();
|
|
40
|
+
info.did = id.did;
|
|
41
|
+
info.name = id.name;
|
|
42
|
+
// ① 先看本进程里节点是否真的在跑
|
|
43
|
+
try {
|
|
44
|
+
const mod = await import('../network/p2p.js');
|
|
45
|
+
const net = mod.p2pNetwork;
|
|
46
|
+
const node = net?.getNode?.();
|
|
47
|
+
if (net && node) {
|
|
48
|
+
const peerId = String(net.getNodePeerId?.() || '');
|
|
49
|
+
if (peerId) {
|
|
50
|
+
info.source = 'live';
|
|
51
|
+
info.peerId = peerId;
|
|
52
|
+
// 优先给浏览器/手机能用的 ws 地址; 没有 ws 时回退到节点真实全部地址 (不编造)
|
|
53
|
+
let raw = (net.getWsMultiaddrs?.() || []).map((m) => String(m));
|
|
54
|
+
if (!raw.length) {
|
|
55
|
+
try {
|
|
56
|
+
raw = (node.getMultiaddrs?.() || []).map((m) => String(m));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
raw = [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
info.multiaddrs = raw.map((m) => ensureDialable(m, peerId));
|
|
63
|
+
try {
|
|
64
|
+
const ra = (net.getRelayAddrs?.() || []).map((m) => String(m));
|
|
65
|
+
info.relayAddrs = ra.map((m) => ensureDialable(m, peerId));
|
|
66
|
+
}
|
|
67
|
+
catch { /* 没有中继也能继续 */ }
|
|
68
|
+
try {
|
|
69
|
+
const svc = net.getRelayServiceInfo?.();
|
|
70
|
+
if (svc) {
|
|
71
|
+
info.isRelay = !!svc.active;
|
|
72
|
+
info.relayService = {
|
|
73
|
+
active: !!svc.active,
|
|
74
|
+
protocol: svc.protocol,
|
|
75
|
+
maxReservations: svc.maxReservations,
|
|
76
|
+
reservations: svc.reservations,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { /* 忽略 */ }
|
|
81
|
+
try {
|
|
82
|
+
const nat = net.getNatStatus?.();
|
|
83
|
+
if (nat)
|
|
84
|
+
info.natStatus = String(nat.status || nat.kind || JSON.stringify(nat).slice(0, 60));
|
|
85
|
+
}
|
|
86
|
+
catch { /* 忽略 */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch { /* 客户端环境没有 p2p 模块 → 走落盘记录 */ }
|
|
91
|
+
// ② 落盘记录兜底 (CLI 一次性命令里节点通常没跑)
|
|
92
|
+
const gj = readGatewayJoin();
|
|
93
|
+
if (gj) {
|
|
94
|
+
info.joinedAt = gj.joinedAt;
|
|
95
|
+
info.capabilities = Array.isArray(gj.capabilities) ? gj.capabilities : undefined;
|
|
96
|
+
if (!info.peerId && gj.peerId) {
|
|
97
|
+
info.peerId = String(gj.peerId);
|
|
98
|
+
info.source = 'persisted';
|
|
99
|
+
}
|
|
100
|
+
if (!info.did && gj.did)
|
|
101
|
+
info.did = String(gj.did);
|
|
102
|
+
if (!info.name && gj.name)
|
|
103
|
+
info.name = String(gj.name);
|
|
104
|
+
}
|
|
105
|
+
if (info.peerId && !info.multiaddrs.length) {
|
|
106
|
+
info.note = '只拿到 peerId (落盘记录), 当前进程没有运行中的 P2P 节点 → 可拨入地址需要一个在跑的节点来生成 '
|
|
107
|
+
+ '(启动: `bolloon --web` 或让智能体执行 join_global_gateway; 之后在此重跑 `bolloon p2p`)';
|
|
108
|
+
}
|
|
109
|
+
else if (!info.peerId) {
|
|
110
|
+
info.note = '还没有本机 peerId: 先让智能体入网 (read https://bolloon.cn/bolloon-gateway-join.md) 或启动 P2P 节点, 再跑 `bolloon p2p`';
|
|
111
|
+
}
|
|
112
|
+
else if (!info.multiaddrs.length) {
|
|
113
|
+
info.note = '节点在跑, 但当前没有被拨入地址 (可能只有拨出能力); 若在手机端需先建立 relay 预约';
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
info.note = undefined;
|
|
117
|
+
}
|
|
118
|
+
info.ok = !!info.peerId;
|
|
119
|
+
return info;
|
|
120
|
+
}
|
|
121
|
+
/** 人可读 + 可直接抄进小工具/名片 (peerId 与 multiaddr 分开列, 方便逐项粘贴) */
|
|
122
|
+
export function formatP2pInfoText(info) {
|
|
123
|
+
const lines = [];
|
|
124
|
+
lines.push('── 本机 P2P 连接信息 ──────────────────────────────');
|
|
125
|
+
lines.push(`来源: ${info.source === 'live' ? '运行中的节点 (live)' : info.source === 'persisted' ? '落盘记录 (persisted)' : '无'}`);
|
|
126
|
+
if (info.name)
|
|
127
|
+
lines.push(`名称: ${info.name}`);
|
|
128
|
+
if (info.did)
|
|
129
|
+
lines.push(`身份: ${info.did}`);
|
|
130
|
+
lines.push(`peerId: ${info.peerId || '(未拿到)'}`);
|
|
131
|
+
if (info.capabilities?.length)
|
|
132
|
+
lines.push(`能力: ${info.capabilities.join(', ')}`);
|
|
133
|
+
if (info.natStatus)
|
|
134
|
+
lines.push(`NAT: ${info.natStatus}`);
|
|
135
|
+
lines.push(`本机是中继: ${info.isRelay ? '是' : '否'}${info.relayService?.active ? ` (协议 ${info.relayService.protocol || '-'}, 预约 ${info.relayService.reservations ?? '-'}/${info.relayService.maxReservations ?? '-'})` : ''}`);
|
|
136
|
+
if (info.multiaddrs.length) {
|
|
137
|
+
lines.push('可拨入地址:');
|
|
138
|
+
for (const m of info.multiaddrs.slice(0, 6))
|
|
139
|
+
lines.push(` ${m}`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
lines.push('可拨入地址: (当前没有 —— 对端暂时拨不进来)');
|
|
143
|
+
}
|
|
144
|
+
if (info.relayAddrs.length) {
|
|
145
|
+
lines.push('经中继可拨入:');
|
|
146
|
+
for (const m of info.relayAddrs.slice(0, 4))
|
|
147
|
+
lines.push(` ${m}`);
|
|
148
|
+
}
|
|
149
|
+
if (info.note)
|
|
150
|
+
lines.push(`说明: ${info.note}`);
|
|
151
|
+
lines.push('── 抄进小工具: 地址填上面的 multiaddr (含 /p2p/ 段), peerId 填上面的 peerId ──');
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
154
|
+
/** 机器可读 (给小工具/App/验收脚本用; 字段名与名片里的 p2p 结构对齐) */
|
|
155
|
+
export function formatP2pInfoJson(info) {
|
|
156
|
+
const primary = info.relayAddrs[0] || info.multiaddrs[0] || '';
|
|
157
|
+
return JSON.stringify({
|
|
158
|
+
schema: 'bolloon-p2p-info/1',
|
|
159
|
+
ok: info.ok,
|
|
160
|
+
source: info.source,
|
|
161
|
+
did: info.did || '',
|
|
162
|
+
name: info.name || '',
|
|
163
|
+
peerId: info.peerId || '',
|
|
164
|
+
multiaddr: primary,
|
|
165
|
+
multiaddrs: info.multiaddrs,
|
|
166
|
+
relayAddrs: info.relayAddrs,
|
|
167
|
+
isRelay: info.isRelay,
|
|
168
|
+
relayService: info.relayService || null,
|
|
169
|
+
natStatus: info.natStatus || null,
|
|
170
|
+
capabilities: info.capabilities || [],
|
|
171
|
+
note: info.note || null,
|
|
172
|
+
/** 直接可用的名片字段 (小工具 agent-card 的 p2p 结构) */
|
|
173
|
+
cardP2p: { peerId: info.peerId || '', multiaddr: primary, relay: info.isRelay ? (info.peerId || '') : '' },
|
|
174
|
+
}, null, 2);
|
|
175
|
+
}
|
package/dist/agents/pi-sdk.js
CHANGED
|
@@ -684,6 +684,24 @@ export class PiAgentSession {
|
|
|
684
684
|
})();
|
|
685
685
|
}
|
|
686
686
|
async prompt(input, options) {
|
|
687
|
+
// 2026-09-16 (M4 启动硬门禁): 初始化未就绪 → 不执行 Agent。
|
|
688
|
+
// fail-closed: 读不出初始化状态也按未就绪处理 (与 runnerResolver"解析不到只诊断"一致)。
|
|
689
|
+
// 测试环境 (VITEST) 跳过: 那 1800+ 用例跑在隔离 HOME 里, 本来就没有真实配置。
|
|
690
|
+
if (!process.env.VITEST) {
|
|
691
|
+
try {
|
|
692
|
+
const { getSetupGateCached } = await import('../setup/setup-store.js');
|
|
693
|
+
const { gate, state } = await getSetupGateCached();
|
|
694
|
+
if (gate !== 'ready') {
|
|
695
|
+
const why = `初始化未就绪 (${gate}, 当前阶段 ${state.stage})${state.lastError ? ` — ${state.lastError.message}` : ''}`;
|
|
696
|
+
const hint = (state.actions && state.actions[0]) || '运行 `bolloon setup` 完成初始化';
|
|
697
|
+
this.messageHistory.push({ role: 'user', content: input });
|
|
698
|
+
this.messageHistory.push({ role: 'assistant', content: `[初始化未就绪] ${why}\n${hint}` });
|
|
699
|
+
console.warn(`[PiAgent] 拒绝执行: ${why}`);
|
|
700
|
+
return `[初始化未就绪] ${why}\n${hint}`;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch { /* 门禁自身异常 → 按"不能判定"处理会阻塞一切; 这里只在能读到状态时才拦 */ }
|
|
704
|
+
}
|
|
687
705
|
this.minimaxAvailable = this.checkMinimax();
|
|
688
706
|
this.currentChannelId = options?.channelId ?? this.currentChannelId;
|
|
689
707
|
// 2026-08-08: 运行轨迹采集 (落盘 + OrbitDB, 失败静默) — 包裹 onStream 收集步骤事件
|
|
@@ -1209,6 +1227,8 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
|
|
|
1209
1227
|
/** 2026-09-16 (M2): 本次执行是"从 checkpoint 恢复"的 runId (非空 = 恢复模式, 不再新建 run) */
|
|
1210
1228
|
resumeRunId = '';
|
|
1211
1229
|
resumePlan = null;
|
|
1230
|
+
/** 2026-09-16 (2-C.4): 外部请求 id (工具层设; 回包必须带上才能精确匹配) */
|
|
1231
|
+
pendingExternalRequestId;
|
|
1212
1232
|
/** 2026-09-16 (M3): 外部等待中 (awaiting_external) —— 收到成功步骤后回 running */
|
|
1213
1233
|
awaitingExternal = false;
|
|
1214
1234
|
/** 2026-09-16 (M3): 熔断原因 (同一工具连续失败达上限 → needs_human) */
|
|
@@ -1277,6 +1297,25 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
|
|
|
1277
1297
|
if (cls === 'external_no_reply') {
|
|
1278
1298
|
this.awaitingExternal = true;
|
|
1279
1299
|
await this.safeSetRunStatus(this.currentRunId, 'awaiting_external');
|
|
1300
|
+
// 2026-09-16 (2-C.4): 把"在等什么"写成持久化事实 (来源/关联/过期), 否则真实回包到了也不知道该唤醒谁。
|
|
1301
|
+
try {
|
|
1302
|
+
if (this.currentGoalId) {
|
|
1303
|
+
const { bindExternalWait, newContinuationId, defaultWaitExpiry } = await import('./external-events.js');
|
|
1304
|
+
const isDelegate = /delegate/i.test(String(tool || ''));
|
|
1305
|
+
await bindExternalWait(this.currentGoalId, {
|
|
1306
|
+
requestId: this.pendingExternalRequestId || `${this.currentRunId}:${Date.now().toString(36)}`,
|
|
1307
|
+
continuationId: newContinuationId(this.currentGoalId),
|
|
1308
|
+
expectedSource: isDelegate ? 'delegate' : 'p2p',
|
|
1309
|
+
expectedEvent: 'result',
|
|
1310
|
+
createdAt: new Date().toISOString(),
|
|
1311
|
+
expiresAt: defaultWaitExpiry(Date.now(), Number(process.env.BOLLOON_EXTERNAL_WAIT_MS || 30 * 60_000)),
|
|
1312
|
+
note: `${isDelegate ? 'delegate 回包' : 'P2P 协作回复'} 等待中 (tool=${tool})`,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
catch (e) {
|
|
1317
|
+
await recordDegradation({ kind: 'core', op: 'pi-sdk.bindExternalWait', runId: this.currentRunId, message: String(e?.message || e) }).catch(() => { });
|
|
1318
|
+
}
|
|
1280
1319
|
return;
|
|
1281
1320
|
}
|
|
1282
1321
|
// 鉴权类: 不重试, 直接交人
|
package/dist/agents/run-store.js
CHANGED
|
@@ -485,6 +485,21 @@ export function argsDigestOf(v) {
|
|
|
485
485
|
* 追加一步 (工具调用后立即落盘) —— 崩在这里也能看到做到哪一步。
|
|
486
486
|
* 锁内读改写: 并发调用不会互相覆盖步骤。
|
|
487
487
|
*/
|
|
488
|
+
/**
|
|
489
|
+
* 追加证据到 Run (2026-09-16): 交易等"外部事实"要能进 Run 的 evidence,
|
|
490
|
+
* 而不是只留在工具内部 (支付必须可审计)。
|
|
491
|
+
*/
|
|
492
|
+
export async function addRunEvidence(runId, lines) {
|
|
493
|
+
return withRunLock(runId, () => coreWrite('addRunEvidence', runId, async () => {
|
|
494
|
+
const rec = await readRun(runId);
|
|
495
|
+
if (!rec)
|
|
496
|
+
return null;
|
|
497
|
+
const merged = Array.from(new Set([...(rec.evidence || []), ...lines.map((l) => String(l).slice(0, 300))])).slice(-50);
|
|
498
|
+
rec.evidence = merged;
|
|
499
|
+
await writeRun(rec);
|
|
500
|
+
return rec;
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
488
503
|
export async function recordStep(runId, step) {
|
|
489
504
|
return withRunLock(runId, () => coreWrite('recordStep', runId, async () => {
|
|
490
505
|
const rec = await readRun(runId);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-readiness.ts — Goal 级技能快照 + 执行前就绪门禁 (批次 2-G.2, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 定位 (与 leo 的规格一致): 这属于**执行前准备**, 由 Supervisor / runner resolver 负责 ——
|
|
5
|
+
* **不放进 PiAgentHarness** (Harness 只管"这一段能不能安全执行")。
|
|
6
|
+
*
|
|
7
|
+
* 规则:
|
|
8
|
+
* · Goal 首次执行时把 requiredSkills 解析成 snapshot (name/version/contentHash/source/resolvedAt) 并冻结;
|
|
9
|
+
* · 后续 Run 只认快照: 缺技能 / 未启用 / 损坏 / hash 漂移 / 版本变化 → **不启动 Run**, Goal → needs_human;
|
|
10
|
+
* · 前缀 '?' 的技能算可选: 缺失不阻塞, 但写一条 degradation;
|
|
11
|
+
* · **不允许静默用新版本** —— 漂移必须人工批准 (approve) 才会更新快照。
|
|
12
|
+
*/
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import { SkillsManager } from './skills-manager.js';
|
|
15
|
+
import { readGoal, setContinuation, updateGoal, addEvidence, } from './goal-store.js';
|
|
16
|
+
import { recordDegradation } from './run-store.js';
|
|
17
|
+
export function parseSkillSpecs(list = []) {
|
|
18
|
+
const required = [];
|
|
19
|
+
const optional = [];
|
|
20
|
+
for (const raw of list) {
|
|
21
|
+
const n = String(raw || '').trim();
|
|
22
|
+
if (!n)
|
|
23
|
+
continue;
|
|
24
|
+
if (n.startsWith('?'))
|
|
25
|
+
optional.push(n.slice(1).trim());
|
|
26
|
+
else
|
|
27
|
+
required.push(n);
|
|
28
|
+
}
|
|
29
|
+
return { required, optional };
|
|
30
|
+
}
|
|
31
|
+
function userHome(home) {
|
|
32
|
+
return home || process.env.HOME || os.homedir();
|
|
33
|
+
}
|
|
34
|
+
function manager(home) {
|
|
35
|
+
return new SkillsManager({ home: userHome(home), cwd: process.cwd() });
|
|
36
|
+
}
|
|
37
|
+
/** 冻结技能快照 (首次执行时调用; 已冻结则不重复解析) */
|
|
38
|
+
export async function freezeGoalSkills(goal, opts = {}) {
|
|
39
|
+
const { required } = parseSkillSpecs(goal.requiredSkills || []);
|
|
40
|
+
if (!required.length)
|
|
41
|
+
return { ok: true, snapshot: goal.skillSnapshot || [], missing: [], notEnabled: [] };
|
|
42
|
+
if (goal.skillSnapshot?.length && !opts.force)
|
|
43
|
+
return { ok: true, snapshot: goal.skillSnapshot, missing: [], notEnabled: [] };
|
|
44
|
+
const sm = manager(opts.home);
|
|
45
|
+
const res = await sm.snapshot(required, { home: userHome(opts.home) });
|
|
46
|
+
if (!res.ok || res.missing.length) {
|
|
47
|
+
return { ok: false, missing: res.missing, notEnabled: [], reason: `必需技能缺失: ${res.missing.join(', ')}` };
|
|
48
|
+
}
|
|
49
|
+
const snapshot = res.entries.map((e) => ({ name: e.name, version: e.version, contentHash: e.contentHash, source: e.source, resolvedAt: e.resolvedAt }));
|
|
50
|
+
await updateGoal(goal.goalId, { skillSnapshot: snapshot });
|
|
51
|
+
await addEvidence(goal.goalId, [`技能快照已冻结: ${snapshot.map((x) => `${x.name}@${x.version}:${x.contentHash.slice(0, 8)}`).join(', ')}`]).catch(() => { });
|
|
52
|
+
return { ok: true, snapshot, missing: [], notEnabled: [] };
|
|
53
|
+
}
|
|
54
|
+
/** 执行前就绪门禁 (Supervisor 每次准备起 Run 之前调) */
|
|
55
|
+
export async function ensureGoalSkillsReady(goal, opts = {}) {
|
|
56
|
+
const { required, optional } = parseSkillSpecs(goal.requiredSkills || []);
|
|
57
|
+
const out = { ok: true, missing: [], notEnabled: [], invalid: [], drift: [], degradations: [] };
|
|
58
|
+
if (!required.length && !optional.length)
|
|
59
|
+
return out;
|
|
60
|
+
const frozen = await freezeGoalSkills(goal, opts);
|
|
61
|
+
if (!frozen.ok) {
|
|
62
|
+
out.ok = false;
|
|
63
|
+
out.missing = frozen.missing;
|
|
64
|
+
out.reason = frozen.reason || '技能快照无法冻结';
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
const snapshot = frozen.snapshot || [];
|
|
68
|
+
out.snapshot = snapshot;
|
|
69
|
+
// 逐个校验快照 (真实 registry + 真实文件 hash)
|
|
70
|
+
const sm = manager(opts.home);
|
|
71
|
+
const home = userHome(opts.home);
|
|
72
|
+
for (const entry of snapshot) {
|
|
73
|
+
const rec = await sm.inspect(entry.name, { home });
|
|
74
|
+
if (!rec) {
|
|
75
|
+
out.missing.push(entry.name);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (rec.status !== 'enabled' && rec.status !== 'installed')
|
|
79
|
+
out.notEnabled.push(`${entry.name}(${rec.status})`);
|
|
80
|
+
if (rec.contentHash !== entry.contentHash)
|
|
81
|
+
out.drift.push({ name: entry.name, expected: entry.contentHash, actual: rec.contentHash });
|
|
82
|
+
if (rec.version !== entry.version)
|
|
83
|
+
out.drift.push({ name: entry.name, expected: entry.version, actual: rec.version });
|
|
84
|
+
const v = await sm.validate(entry.name, { home }).catch(() => null);
|
|
85
|
+
if (v && !v.ok)
|
|
86
|
+
out.invalid.push(`${entry.name}: ${(v.issues || []).slice(0, 2).join('; ')}`);
|
|
87
|
+
}
|
|
88
|
+
// 可选技能: 缺失/未启用 → 只记 degradation (不阻塞)
|
|
89
|
+
for (const name of optional) {
|
|
90
|
+
const rec = await sm.inspect(name, { home });
|
|
91
|
+
if (!rec) {
|
|
92
|
+
out.degradations.push(`可选技能 ${name} 不存在 → 继续执行 (已记降级)`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (rec.status !== 'enabled' && rec.status !== 'installed')
|
|
96
|
+
out.degradations.push(`可选技能 ${name} 未启用 (${rec.status}) → 继续执行`);
|
|
97
|
+
}
|
|
98
|
+
if (out.missing.length)
|
|
99
|
+
out.reason = `必需技能缺失: ${out.missing.join(', ')}`;
|
|
100
|
+
else if (out.notEnabled.length)
|
|
101
|
+
out.reason = `必需技能未启用: ${out.notEnabled.join(', ')}`;
|
|
102
|
+
else if (out.invalid.length)
|
|
103
|
+
out.reason = `必需技能损坏: ${out.invalid.join(', ')}`;
|
|
104
|
+
else if (out.drift.length)
|
|
105
|
+
out.reason = `技能内容漂移 (需人工批准才能升级): ${out.drift.map((d) => `${d.name} ${String(d.expected).slice(0, 8)}→${String(d.actual).slice(0, 8)}`).join(', ')}`;
|
|
106
|
+
out.ok = !(out.missing.length || out.notEnabled.length || out.invalid.length || out.drift.length);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** 门禁不过 → 写清事实并把 Goal 交给人 (不启动 Run, 不伪造失败) */
|
|
110
|
+
export async function blockGoalOnSkills(goalId, res) {
|
|
111
|
+
const reason = res.reason || '技能未就绪';
|
|
112
|
+
await setContinuation(goalId, {
|
|
113
|
+
wakeReason: 'needs_human', autoContinue: false, needsExternal: undefined,
|
|
114
|
+
skillReadiness: { ok: false, at: new Date().toISOString(), reason, missing: res.missing, drift: res.drift, degradations: res.degradations },
|
|
115
|
+
});
|
|
116
|
+
await updateGoal(goalId, { status: 'needs_human' }).catch(() => { });
|
|
117
|
+
await addEvidence(goalId, [`技能门禁拦截: ${reason}`]).catch(() => { });
|
|
118
|
+
for (const d of res.degradations)
|
|
119
|
+
await recordDegradation({ kind: 'observational', op: 'skill-readiness', message: d }).catch(() => { });
|
|
120
|
+
}
|
|
121
|
+
/** 人工批准技能升级: 重新冻结快照 (显式动作, 不隐式切换) */
|
|
122
|
+
export async function approveSkillUpgrade(goalId, opts = {}) {
|
|
123
|
+
const goal = await readGoal(goalId);
|
|
124
|
+
if (!goal)
|
|
125
|
+
return { ok: false, reason: 'Goal 不存在' };
|
|
126
|
+
const frozen = await freezeGoalSkills(goal, { ...opts, force: true });
|
|
127
|
+
if (!frozen.ok)
|
|
128
|
+
return { ok: false, reason: frozen.reason };
|
|
129
|
+
await setContinuation(goalId, { skillReadiness: { ok: true, at: new Date().toISOString(), reason: '人工批准技能升级' }, wakeReason: 'active', autoContinue: true });
|
|
130
|
+
await updateGoal(goalId, { status: 'active' }).catch(() => { });
|
|
131
|
+
await addEvidence(goalId, [`技能升级已被人工批准: ${(frozen.snapshot || []).map((s) => `${s.name}@${s.version}`).join(', ')}`]).catch(() => { });
|
|
132
|
+
return { ok: true, snapshot: frozen.snapshot };
|
|
133
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-supervisor-link.ts — 技能状态与长期执行的联动 (批次 2-G.4, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 规则 (与 leo 的规格一致):
|
|
5
|
+
* · 技能导入成功 / 又能用了 → 被它拦住的 Goal 重新冻结快照并回到 active (等 Supervisor 继续调度);
|
|
6
|
+
* · 技能被禁用 / 隔离 / 内容漂移 → **不打断正在跑的 Run**, 但下一次 Run 之前 readiness 必然失败 (2-G.2 门禁) → needs_human;
|
|
7
|
+
* · 漂移**不允许隐式切换版本**: 继续固定旧快照, 或等人工 approve 升级 (approveSkillUpgrade)。
|
|
8
|
+
*
|
|
9
|
+
* 这里只改"该不该继续"的事实, 不直接启动执行 —— 执行权始终在 Supervisor。
|
|
10
|
+
*/
|
|
11
|
+
import { listGoals } from './goal-store.js';
|
|
12
|
+
import { ensureGoalSkillsReady, blockGoalOnSkills, approveSkillUpgrade, parseSkillSpecs } from './skill-readiness.js';
|
|
13
|
+
import { SkillsManager } from './skills-manager.js';
|
|
14
|
+
import { recordDegradation } from './run-store.js';
|
|
15
|
+
/**
|
|
16
|
+
* 重评所有"因技能被拦"的 Goal。
|
|
17
|
+
* @param opts.action 触发原因 (import / enable / disable / quarantine / drift) —— 只用于事实记录
|
|
18
|
+
*/
|
|
19
|
+
export async function reconsiderSkillBlockedGoals(opts = {}) {
|
|
20
|
+
const out = { rechecked: 0, resumed: [], stillBlocked: [] };
|
|
21
|
+
const home = opts.home;
|
|
22
|
+
const goals = await listGoals({ limit: 200 });
|
|
23
|
+
const interesting = goals.filter((g) => {
|
|
24
|
+
const r = g.continuation?.skillReadiness;
|
|
25
|
+
const specs = parseSkillSpecs(g.requiredSkills || []);
|
|
26
|
+
return (r && r.ok === false) || specs.required.length > 0;
|
|
27
|
+
});
|
|
28
|
+
for (const g of interesting) {
|
|
29
|
+
// 只处理"被技能拦住"的 Goal: 其它原因等人的不要乱动
|
|
30
|
+
const blockedBySkill = g.continuation?.skillReadiness?.ok === false;
|
|
31
|
+
if (!blockedBySkill)
|
|
32
|
+
continue;
|
|
33
|
+
// 若是被人工标记 needs_human 且与技能无关, 跳过
|
|
34
|
+
out.rechecked++;
|
|
35
|
+
const res = await ensureGoalSkillsReady(g, { home });
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
// 技能恢复了 → 重新冻结快照 + 回 active (Supervisor 下一轮继续)
|
|
38
|
+
const approved = await approveSkillUpgrade(g.goalId, { home });
|
|
39
|
+
if (approved.ok) {
|
|
40
|
+
out.resumed.push(g.goalId);
|
|
41
|
+
console.log(`[skill-link] ${g.goalId} 技能已恢复 (${opts.action || 'unknown'}${opts.name ? `:${opts.name}` : ''}) → 重新冻结快照并回到 active`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
out.stillBlocked.push(g.goalId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
await blockGoalOnSkills(g.goalId, res);
|
|
49
|
+
out.stillBlocked.push(g.goalId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** 技能被禁用/隔离/漂移时, 把依赖它的活跃 Goal 标清事实 (不打断当前 Run) */
|
|
55
|
+
export async function markDependentsOfSkill(name, opts = { reason: '技能不可用' }) {
|
|
56
|
+
const sm = new SkillsManager({ home: opts.home, cwd: process.cwd() });
|
|
57
|
+
const health = await sm.health({ home: opts.home }).catch(() => null);
|
|
58
|
+
const goals = await listGoals({ limit: 200 });
|
|
59
|
+
const affected = [];
|
|
60
|
+
for (const g of goals) {
|
|
61
|
+
const { required } = parseSkillSpecs(g.requiredSkills || []);
|
|
62
|
+
if (!required.includes(name))
|
|
63
|
+
continue;
|
|
64
|
+
if (['completed', 'failed', 'abandoned'].includes(g.status))
|
|
65
|
+
continue;
|
|
66
|
+
affected.push(g.goalId);
|
|
67
|
+
await recordDegradation({ kind: 'observational', op: 'skill-link', message: `Goal ${g.goalId} 依赖的技能 ${name} 状态: ${JSON.stringify(health?.byStatus || {})} (${opts.reason}) — 下一次 Run 前会重新门禁` }).catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
return affected;
|
|
70
|
+
}
|