@bolloon/bolloon-agent 0.4.27 → 0.4.28

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.
@@ -308,6 +308,27 @@ export class ExecutionSupervisor {
308
308
  this.emit({ kind: 'retry_woke', goalId: goal.goalId, message: `到点唤醒, 第 ${(goal.continuation?.attempts || 0) + 1} 次自动继续` });
309
309
  this.log(`[supervisor] goal=${goal.goalId} retry_wait 到点 → 唤醒并清 wakeAt`);
310
310
  }
311
+ // ★ 2026-09-18 (M2 收口): `bolloon task` 建的目标走**同一个恢复决策函数**
312
+ // (`decideTaskRecovery`) —— CLI `task --resume` 与 Supervisor 不再各判一次。
313
+ // 决策说"不能动钱/已经执行过"就跳过 (不重复付款、不重复执行非幂等操作)。
314
+ if (goal.createdBy === 'cli:task' && process.env.BOLLOON_SUPERVISOR_TASK_RESUME !== '0') {
315
+ try {
316
+ const { decideTaskRecovery, resumeTask } = await import('./task/task-runner.js');
317
+ const decision = await decideTaskRecovery({ goalId: goal.goalId });
318
+ const actionable = ['retry_payment', 'deliver', 'verify'].includes(decision.action);
319
+ if (!actionable) {
320
+ report.skipped.push({ goalId: goal.goalId, reason: `任务恢复决策=${decision.action} (${decision.reason}) → 本周期不动` });
321
+ return { goalId: goal.goalId, status: 'task_no_action' };
322
+ }
323
+ const res = await resumeTask({ goalId: goal.goalId });
324
+ this.log(`[supervisor] task 目标接回: goal=${goal.goalId} decision=${decision.action} → 状态 ${res.card.status}`);
325
+ this.emit({ kind: res.ok ? 'goal_done' : 'needs_human', goalId: goal.goalId, message: `任务接回: ${decision.action} → ${res.card.status}` });
326
+ return { goalId: goal.goalId, runId: res.runId, status: res.card.status, error: res.ok ? undefined : res.reason };
327
+ }
328
+ catch (err) {
329
+ return { goalId: goal.goalId, status: 'task_resume_failed', error: String(err?.message || err).slice(0, 160) };
330
+ }
331
+ }
311
332
  const prevRunId = goal.currentRunId;
312
333
  const prevRun = prevRunId ? await readRun(prevRunId) : null;
313
334
  const plan = prevRunId ? await buildContinuationPlan(prevRunId).catch(() => null) : null;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * local-seller.ts — M1 的"卖方" (本地 Registry 节点)
3
+ *
4
+ * M1 只做**本地 Registry**: 卖方就是你自己本机的报价端点。
5
+ * 这里**不重新实现协议** —— 直接把项目真实的卖方路由
6
+ * (`src/web/routes-x402-info.ts: registerX402InfoRoutes`) 挂到一个极小的
7
+ * express 风格适配器上, 用真 HTTP 跑真 402 → 付款校验 → 签名信封。
8
+ *
9
+ * 注意: 卖方路由内部用 `os.homedir()` 找 `~/.bolloon/{identity.json, x402-info/}`,
10
+ * 所以调用方若做隔离测试, 需要在启动前设好 `process.env.HOME`。
11
+ */
12
+ import * as http from 'http';
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ function matchPath(pattern, actual) {
17
+ const p = pattern.split('/').filter(Boolean);
18
+ const a = actual.split('/').filter(Boolean);
19
+ if (pattern.endsWith('/') && p.length !== a.length)
20
+ return { hit: false, params: {} };
21
+ if (p.length !== a.length)
22
+ return { hit: false, params: {} };
23
+ const params = {};
24
+ for (let i = 0; i < p.length; i++) {
25
+ if (p[i].startsWith(':'))
26
+ params[p[i].slice(1)] = decodeURIComponent(a[i]);
27
+ else if (p[i] !== a[i])
28
+ return { hit: false, params: {} };
29
+ }
30
+ return { hit: true, params };
31
+ }
32
+ function makeRes() {
33
+ let settle;
34
+ const done = new Promise((r) => { settle = r; });
35
+ const headers = {};
36
+ const res = {
37
+ _status: 200,
38
+ status(n) { res._status = n; return res; },
39
+ set(k, v) { headers[k] = String(v); return res; },
40
+ json(obj) {
41
+ headers['content-type'] = 'application/json';
42
+ settle({ status: res._status, headers, body: JSON.stringify(obj ?? null) });
43
+ return res;
44
+ },
45
+ send(text) {
46
+ settle({ status: res._status, headers, body: String(text ?? '') });
47
+ return res;
48
+ },
49
+ };
50
+ return { res, done };
51
+ }
52
+ /** 极小的 express 风格 app (只实现卖方路由用到的 get/post/delete + res.json/status/set) */
53
+ export function createMiniApp() {
54
+ const routes = [];
55
+ const add = (method) => (p, h) => { routes.push({ method, path: p, handler: h }); };
56
+ const app = { get: add('GET'), post: add('POST'), delete: add('DELETE') };
57
+ const dispatch = async (method, url, headers, body) => {
58
+ const u = new URL(url, 'http://127.0.0.1');
59
+ for (const r of routes) {
60
+ if (r.method !== method)
61
+ continue;
62
+ const m = matchPath(r.path, u.pathname);
63
+ if (!m.hit)
64
+ continue;
65
+ const { res, done } = makeRes();
66
+ let parsed = undefined;
67
+ if (body) {
68
+ try {
69
+ parsed = JSON.parse(body);
70
+ }
71
+ catch {
72
+ parsed = undefined;
73
+ }
74
+ }
75
+ const req = {
76
+ method,
77
+ url,
78
+ headers,
79
+ params: m.params,
80
+ query: Object.fromEntries(u.searchParams.entries()),
81
+ body: parsed,
82
+ protocol: 'http',
83
+ };
84
+ try {
85
+ await r.handler(req, res);
86
+ }
87
+ catch (e) {
88
+ return { status: 500, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ error: String(e?.message || e) }) };
89
+ }
90
+ return await done;
91
+ }
92
+ return null;
93
+ };
94
+ return { app, dispatch };
95
+ }
96
+ /** 卖方的 DIAP 身份 (签发信封用) — 缺失就生成一个本机身份, 不静默跳过 */
97
+ export async function ensureSellerIdentity(home = os.homedir()) {
98
+ const dir = path.join(home, '.bolloon');
99
+ const file = path.join(dir, 'identity.json');
100
+ if (fs.existsSync(file)) {
101
+ try {
102
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
103
+ return { created: false, did: raw?.did };
104
+ }
105
+ catch {
106
+ return { created: false };
107
+ }
108
+ }
109
+ try {
110
+ const { KeyManager } = await import('@diap/sdk');
111
+ const kp = KeyManager.generate();
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ await KeyManager.saveToFile(kp, file);
114
+ return { created: true, did: kp?.did };
115
+ }
116
+ catch (e) {
117
+ return { created: false };
118
+ }
119
+ }
120
+ /** 启动本地卖方节点: 真 HTTP + 项目真实卖方路由 */
121
+ export async function startLocalSeller(opts = {}) {
122
+ const { registerX402InfoRoutes } = await import('../../web/routes-x402-info.js');
123
+ const { app, dispatch } = createMiniApp();
124
+ registerX402InfoRoutes(app);
125
+ const { listInfo } = await import('../x402/paid-info-store.js');
126
+ const server = http.createServer((req, res) => {
127
+ const chunks = [];
128
+ req.on('data', (c) => chunks.push(c));
129
+ req.on('end', async () => {
130
+ const body = Buffer.concat(chunks).toString('utf8');
131
+ const out = await dispatch(req.method || 'GET', req.url || '/', req.headers, body);
132
+ if (!out) {
133
+ res.writeHead(404, { 'content-type': 'application/json' });
134
+ res.end(JSON.stringify({ error: 'not found' }));
135
+ return;
136
+ }
137
+ res.writeHead(out.status, out.headers);
138
+ res.end(out.body);
139
+ });
140
+ });
141
+ await new Promise((resolve) => server.listen(opts.port ?? 0, '127.0.0.1', () => resolve()));
142
+ const port = server.address()?.port;
143
+ return {
144
+ url: `http://127.0.0.1:${port}`,
145
+ port,
146
+ itemCount: async () => (await listInfo()).length,
147
+ close: async () => { await new Promise((resolve) => server.close(() => resolve())); },
148
+ };
149
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * report-card.ts — M1 薄层 ③: 报告卡
3
+ *
4
+ * M1 **唯一面向人的主出口**。用户只看到这里的东西:
5
+ * 任务 / 结论 / 本次使用(Skill·版本·花费·来源) / 三项校验 / 状态(4 态) / 证据指针 / 卡在哪。
6
+ *
7
+ * 两条硬门 (leo 定, 任一不满足 → M1 失败):
8
+ * 买到 Skill 但没有执行 → 卡片必须"需要你处理", 不许变绿
9
+ * 执行成功但没有报告/证据 → 卡片必须"需要你处理", 不许变绿
10
+ *
11
+ * 内部 10 态生命周期 / 8 态结算事实**只在这里被映射成 4 个人类状态**, 不外泄术语。
12
+ */
13
+ /** 内部生命周期 → 人类 4 态 (术语不外泄) */
14
+ export function humanStatusFrom(opts) {
15
+ if (opts.verdict === 'blocked')
16
+ return '需要你处理';
17
+ const lc = String(opts.lifecycle || '');
18
+ switch (lc) {
19
+ case 'verified':
20
+ return '已完成';
21
+ case 'settled':
22
+ case 'delivered':
23
+ return '正在执行';
24
+ case 'discovered':
25
+ case 'quoted':
26
+ case 'payment_required':
27
+ case 'paying':
28
+ return '正在获取能力';
29
+ case 'policy_denied':
30
+ case 'delivery_failed':
31
+ case 'verification_failed':
32
+ case 'disputed':
33
+ case 'failed':
34
+ return '需要你处理';
35
+ default:
36
+ break;
37
+ }
38
+ switch (opts.stage) {
39
+ case 'prepare':
40
+ return '准备中';
41
+ case 'acquire':
42
+ return '正在获取能力';
43
+ case 'execute':
44
+ return '正在执行';
45
+ default:
46
+ return '已完成';
47
+ }
48
+ }
49
+ /**
50
+ * 组装报告卡。两条硬门在这里落地 (不是靠调用方记得检查):
51
+ * 只要"付了没执行"或"执行了没证据", 一律 `需要你处理` + 结论降级为"证据不足"。
52
+ */
53
+ export function buildReportCard(input) {
54
+ const checks = {
55
+ outputContract: input.outputContract ?? '未执行',
56
+ resourceVerified: input.resourceVerified ?? '未执行',
57
+ taskEvidence: input.evidenceComplete ? '完整' : '不完整',
58
+ };
59
+ let status = humanStatusFrom({ stage: input.stage, lifecycle: input.lifecycle, verdict: input.blocker ? 'blocked' : 'ok' });
60
+ let hardGate;
61
+ let conclusion = input.conclusion ?? (status === '已完成' ? '证据不足' : '证据不足');
62
+ let blocker = input.blocker;
63
+ if (input.paid && !input.executed) {
64
+ hardGate = 'bought_not_executed';
65
+ status = '需要你处理';
66
+ conclusion = '证据不足';
67
+ blocker = blocker || '买到了 Skill 但没有执行 —— 这一次不算完成任务 (M1 硬门)';
68
+ }
69
+ else if (input.executed && !input.evidenceComplete) {
70
+ hardGate = 'executed_without_evidence';
71
+ status = '需要你处理';
72
+ conclusion = '证据不足';
73
+ blocker = blocker || '执行了但没有留下完整证据 —— 这一次不算完成任务 (M1 硬门)';
74
+ }
75
+ else if (input.outputContract === '未通过') {
76
+ status = '需要你处理';
77
+ conclusion = '证据不足';
78
+ blocker = blocker || '输出不符合资源契约 —— 不计成功 (M1 硬门)';
79
+ }
80
+ if (status === '已完成' && (checks.taskEvidence !== '完整' || checks.resourceVerified !== '通过' || checks.outputContract !== '通过')) {
81
+ // 三项校验没全过就不许显示"已完成" (防假绿)
82
+ status = '需要你处理';
83
+ hardGate = hardGate || 'executed_without_evidence';
84
+ conclusion = '证据不足';
85
+ blocker = blocker || `三项校验未全过 (输出契约=${checks.outputContract} · 资源验证=${checks.resourceVerified} · 证据=${checks.taskEvidence})`;
86
+ }
87
+ return {
88
+ task: input.task,
89
+ conclusion,
90
+ conclusionDetail: input.conclusionDetail,
91
+ skill: input.skill,
92
+ cost: input.cost,
93
+ payment: input.payment,
94
+ sources: input.sources,
95
+ checks,
96
+ status,
97
+ executed: input.executed,
98
+ paid: input.paid,
99
+ durationMs: input.durationMs,
100
+ evidenceRef: input.evidenceRef,
101
+ blocker,
102
+ hardGate,
103
+ budgetLines: input.budgetLines,
104
+ };
105
+ }
106
+ /** CLI 文本渲染 —— M1 的唯一主出口形状 */
107
+ export function renderReportCard(card) {
108
+ const L = [];
109
+ L.push(`任务: ${card.task}`);
110
+ L.push('');
111
+ L.push(`结论: ${card.conclusion}${card.conclusionDetail ? ` (${card.conclusionDetail})` : ''}`);
112
+ L.push('');
113
+ L.push('本次使用:');
114
+ if (card.skill)
115
+ L.push(`- Skill: ${card.skill.name}${card.skill.version ? ` @ ${card.skill.version}` : ''}`);
116
+ if (card.cost)
117
+ L.push(`- 花费: ${card.cost.amount} ${card.cost.currency} (${card.cost.network})`);
118
+ if (card.payment) {
119
+ const modeZh = card.payment.mode === 'local-dev' ? '本机联调 (local-dev)' : card.payment.mode === 'facilitator' ? 'facilitator' : card.payment.mode;
120
+ L.push(`- 支付方式: ${modeZh}`);
121
+ L.push(`- 链上已验证: ${card.payment.chainSettled ? '是' : '否'}${card.payment.chainSettled ? '' : ' (本机联调不冒充链上结算)'}`);
122
+ }
123
+ if (card.sources)
124
+ L.push(`- 来源: ${card.sources.length} 个${card.sources.length ? ` (${card.sources.slice(0, 3).join(', ')}${card.sources.length > 3 ? ', …' : ''})` : ''}`);
125
+ L.push(`- 输出契约: ${card.checks.outputContract}`);
126
+ L.push(`- 资源验证: ${card.checks.resourceVerified}`);
127
+ L.push(`- 任务证据: ${card.checks.taskEvidence}`);
128
+ if (typeof card.durationMs === 'number')
129
+ L.push(`- 耗时: ${(card.durationMs / 1000).toFixed(1)} 秒`);
130
+ L.push(`- 状态: ${card.status}`);
131
+ if (card.blocker) {
132
+ L.push('');
133
+ L.push(`卡在哪: ${card.blocker}`);
134
+ }
135
+ if (card.budgetLines && card.budgetLines.length) {
136
+ L.push('');
137
+ L.push('预算:');
138
+ for (const b of card.budgetLines)
139
+ L.push(`- ${b}`);
140
+ }
141
+ L.push('');
142
+ const ref = card.evidenceRef;
143
+ const parts = [ref.goalId ? `Goal ${ref.goalId}` : null, ref.runId ? `Run ${ref.runId}` : null, ref.transactionId ? `交易 ${ref.transactionId}` : null].filter(Boolean);
144
+ L.push(parts.length ? `查看完整证据: ${parts.join(' · ')}` : '查看完整证据: (无)');
145
+ return L.join('\n');
146
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * resource-advisor.ts — M1 薄层 ②: 资源顾问
3
+ *
4
+ * 只回答三个问题:
5
+ * ① 当前任务是否缺外部能力?
6
+ * ② 本地 Registry 里哪个可执行 Skill 满足契约?
7
+ * ③ 为什么选它?
8
+ *
9
+ * M1 明确**不做**: 语义搜索 / 向量检索 / P2P 发现 / 竞价 / 推荐系统。
10
+ * 这里只有: 关键词确定性匹配 + 契约完整性检查 + 价格来源(报价)检查, 同分用名字排序 (可复现)。
11
+ *
12
+ * "本地 Registry" = 已装技能目录 (`defaultSkillPaths` + `~/.bolloon/skills`)
13
+ * × 本机 x402 报价 (`listInfo`), 两者按约定关联 (见 linkListing)。
14
+ */
15
+ import * as os from 'os';
16
+ import * as path from 'path';
17
+ import { defaultSkillPaths, loadSkillsDir } from '../skill-loader.js';
18
+ import { listInfo } from '../x402/paid-info-store.js';
19
+ import { parseResourceContract } from '../x402/resource-contract.js';
20
+ /** 中文按 bigram、英文/数字按词切; 去停用词。确定性, 无随机。 */
21
+ export function tokenizeTask(task) {
22
+ const text = String(task || '').toLowerCase();
23
+ const out = [];
24
+ for (const m of text.matchAll(/[a-z0-9][a-z0-9._-]+/g))
25
+ out.push(m[0]);
26
+ const cjk = text.replace(/[^\u4e00-\u9fff]+/g, ' ');
27
+ for (const seg of cjk.split(/\s+/)) {
28
+ if (!seg)
29
+ continue;
30
+ if (seg.length <= 2) {
31
+ out.push(seg);
32
+ continue;
33
+ }
34
+ for (let i = 0; i + 2 <= seg.length; i++)
35
+ out.push(seg.slice(i, i + 2));
36
+ }
37
+ const stop = new Set(['这个', '那个', '一个', '是否', '请问', '帮我', 'the', 'and', 'for', 'with', 'task']);
38
+ return Array.from(new Set(out.filter((t) => t.length >= 2 && !stop.has(t))));
39
+ }
40
+ /** 关键词命中打分: 只在名字 / 描述 / triggers / capability 文本里找, 命中即记 why。 */
41
+ export function scoreSkill(args) {
42
+ const fields = [
43
+ ['name', args.name || ''],
44
+ ['description', args.description || ''],
45
+ ['triggers', (args.triggers || []).join(' ')],
46
+ ['capability', args.capability || ''],
47
+ ['guarantees', (args.guarantees || []).join(' ')],
48
+ ];
49
+ const lowered = fields.map(([k, v]) => [k, v.toLowerCase()]);
50
+ const why = [];
51
+ let score = 0;
52
+ for (const tok of args.tokens) {
53
+ for (const [field, text] of lowered) {
54
+ if (!text)
55
+ continue;
56
+ if (text.includes(tok)) {
57
+ const w = field === 'name' ? 3 : field === 'capability' ? 2 : 1;
58
+ score += w;
59
+ why.push(`命中 ${field}: "${tok}" (+${w})`);
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ return { score, why };
65
+ }
66
+ /**
67
+ * 报价 ↔ 技能 的关联约定 (M1 确定性规则, 不做模糊匹配):
68
+ * ① item.id === skill 名 ② item.source.note 含 `skill=<name>` ③ item.title 含技能名
69
+ */
70
+ export function linkListing(skillName, items) {
71
+ const lower = skillName.toLowerCase();
72
+ const hit = items.find((i) => {
73
+ if (String(i.id).toLowerCase() === lower)
74
+ return true;
75
+ const note = String(i.source?.note || '').toLowerCase();
76
+ if (note.includes(`skill=${lower}`))
77
+ return true;
78
+ return String(i.title || '').toLowerCase().includes(lower);
79
+ });
80
+ if (!hit)
81
+ return undefined;
82
+ return {
83
+ itemId: hit.id,
84
+ title: String(hit.title || hit.id),
85
+ amount: String(hit.price?.amount ?? '0'),
86
+ currency: String(hit.price?.currency ?? 'USDC'),
87
+ network: String(hit.price?.network ?? 'base-sepolia'),
88
+ payTo: String(hit.price?.payTo ?? ''),
89
+ skillName: String(hit.source?.note || '').includes('skill=') ? skillName : undefined,
90
+ };
91
+ }
92
+ /**
93
+ * 顾问主函数: 扫本地 Registry → 过滤"有契约且可执行"的候选 → 确定性打分排序。
94
+ * 不猜: 一个候选都没有 → needed:false 并说明原因。
95
+ */
96
+ export async function adviseResource(opts) {
97
+ const home = opts.home ?? os.homedir();
98
+ const cwd = opts.cwd ?? process.cwd();
99
+ const notes = [];
100
+ const paths = (opts.skillPaths ?? [...defaultSkillPaths(home, cwd), path.join(home, '.bolloon', 'skills')]);
101
+ const merged = [];
102
+ for (const p of paths)
103
+ if (!merged.includes(p))
104
+ merged.push(p);
105
+ const skillDirs = [];
106
+ for (const p of merged) {
107
+ let metas = [];
108
+ try {
109
+ metas = await loadSkillsDir(p);
110
+ }
111
+ catch (e) {
112
+ notes.push(`技能目录不可读 (跳过): ${p} — ${String(e?.message || e).slice(0, 80)}`);
113
+ continue;
114
+ }
115
+ for (const meta of metas) {
116
+ const fm = (meta.frontmatter || {});
117
+ const parsed = parseResourceContract(fm, { skillName: String(meta.name), skillVersion: String(fm.version || '') });
118
+ if (!parsed.ok || !parsed.contract)
119
+ continue; // 非法契约直接不算候选
120
+ const c = parsed.contract;
121
+ if (!c.execution?.entrypoint)
122
+ continue; // 不可执行 → 不是 M1 要的资源
123
+ if (!c.inputSchema || !c.outputSchema)
124
+ continue; // 无输入/输出契约 → 不买
125
+ skillDirs.push({
126
+ name: String(meta.name),
127
+ version: String(fm.version || (c.version ?? '')),
128
+ dir: path.dirname(String(meta.sourcePath)),
129
+ description: String(meta.description || ''),
130
+ triggers: (meta.triggers || []),
131
+ contract: parsed.contract,
132
+ });
133
+ }
134
+ }
135
+ if (skillDirs.length === 0) {
136
+ return { needed: false, reason: `本地 Registry 里没有"可执行且契约完整"的 Skill (已扫 ${merged.length} 个目录)`, candidates: [], notes };
137
+ }
138
+ const items = await listInfo(home);
139
+ const tokens = tokenizeTask(opts.task);
140
+ const candidates = skillDirs.map((s) => {
141
+ const c = s.contract;
142
+ const scored = scoreSkill({
143
+ tokens,
144
+ name: s.name,
145
+ description: s.description,
146
+ triggers: s.triggers,
147
+ capability: c.capability,
148
+ guarantees: c.guarantees,
149
+ });
150
+ return {
151
+ name: s.name,
152
+ version: s.version,
153
+ dir: s.dir,
154
+ contract: s.contract,
155
+ score: scored.score,
156
+ why: scored.why,
157
+ listing: linkListing(s.name, items),
158
+ };
159
+ });
160
+ // 确定性排序: 分数降序 → 名字升序
161
+ candidates.sort((a, b) => (b.score - a.score) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
162
+ const minScore = opts.minScore ?? 1;
163
+ const top = candidates[0];
164
+ if (!top || top.score < minScore) {
165
+ return {
166
+ needed: false,
167
+ reason: `任务关键词与本地可执行 Skill 都不匹配 (最高分 ${top?.score ?? 0} < ${minScore}) — 不买不该买的东西`,
168
+ candidates,
169
+ notes,
170
+ };
171
+ }
172
+ if (!top.listing) {
173
+ notes.push(`候选 Skill "${top.name}" 没有本机报价 (listInfo 里没有对应 item) → M1 买不到, 会如实告诉你`);
174
+ }
175
+ return {
176
+ needed: true,
177
+ reason: `本地 Registry 命中 "${top.name}" (分数 ${top.score}: ${top.why.slice(0, 3).join(' / ') || '名字匹配'})`,
178
+ candidates,
179
+ chosen: top,
180
+ notes,
181
+ };
182
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * task-budget.ts — M1 预算闸 (leo 2026-09-18 冻结规则 ③)
3
+ *
4
+ * 单任务 0.05 USDC / 单次购买 0.02 USDC / 单日测试 0.10 USDC, **所有层取最小值**。
5
+ * 用户只在开始时给一次; 执行过程中**不许被 Agent 自动扩大** (assertNoExpansion 是那条红线)。
6
+ *
7
+ * 与 policy 的关系: `economic-policy.ts` 的 `单笔 $1 / 每日 $10` 是系统级闸门,
8
+ * 这里是**任务级**的第二道闸, 两层都过才放行 (trade.ts 的 taskBudget 参数即为此闸)。
9
+ */
10
+ /** M1 硬上限 (leo 定) */
11
+ export const M1_BUDGET_LIMITS = { task: 0.05, perPurchase: 0.02, daily: 0.10 };
12
+ function num(v) {
13
+ if (v === undefined || v === null || v === '')
14
+ return undefined;
15
+ const n = typeof v === 'number' ? v : Number(String(v));
16
+ if (!Number.isFinite(n))
17
+ return undefined;
18
+ return n;
19
+ }
20
+ /**
21
+ * 解析任务预算。缺省 → M1 默认值; 给多了 → **取 min 并显式记录被收紧** (不静默改成别的数)。
22
+ * 非法输入 → ok:false (不猜)。
23
+ */
24
+ export function resolveTaskBudget(opts = {}) {
25
+ const lim = opts.limits ?? M1_BUDGET_LIMITS;
26
+ const reqTask = num(opts.taskBudget);
27
+ const reqPer = num(opts.perPurchase);
28
+ const reqDaily = num(opts.daily);
29
+ for (const [k, v] of [['taskBudget', opts.taskBudget], ['perPurchase', opts.perPurchase], ['daily', opts.daily]]) {
30
+ if (v !== undefined && v !== null && String(v) !== '' && num(v) === undefined) {
31
+ return { ok: false, error: `${k} 不是合法数字: ${String(v)}` };
32
+ }
33
+ const n = num(v);
34
+ if (n !== undefined && n <= 0)
35
+ return { ok: false, error: `${k} 必须为正数: ${String(v)}` };
36
+ }
37
+ const taskBudget = Math.min(reqTask ?? lim.task, lim.task);
38
+ const perPurchase = Math.min(reqPer ?? lim.perPurchase, lim.perPurchase, taskBudget);
39
+ const daily = Math.min(reqDaily ?? lim.daily, lim.daily);
40
+ const why = [];
41
+ why.push(`任务预算 ${taskBudget} USDC (M1 硬上限 ${lim.task}${reqTask !== undefined ? `, 你给的 ${reqTask}` : ''})`);
42
+ why.push(`单次购买上限 ${perPurchase} USDC (M1 硬上限 ${lim.perPurchase}${reqPer !== undefined ? `, 你给的 ${reqPer}` : ''})`);
43
+ why.push(`当日预算 ${daily} USDC (M1 硬上限 ${lim.daily}${reqDaily !== undefined ? `, 你给的 ${reqDaily}` : ''})`);
44
+ const clamped = {
45
+ task: reqTask !== undefined && reqTask > lim.task,
46
+ perPurchase: reqPer !== undefined && reqPer > Math.min(lim.perPurchase, taskBudget),
47
+ daily: reqDaily !== undefined && reqDaily > lim.daily,
48
+ };
49
+ if (clamped.task)
50
+ why.push(`⚠️ 你给的任务预算 ${reqTask} 超过 M1 上限 ${lim.task} → 实际按 ${taskBudget} 执行 (不是 Agent 改的, 是 M1 规则)`);
51
+ if (clamped.perPurchase)
52
+ why.push(`⚠️ 你给的购买上限 ${reqPer} 被收紧到 ${perPurchase}`);
53
+ if (clamped.daily)
54
+ why.push(`⚠️ 你给的当日预算 ${reqDaily} 被收紧到 ${daily}`);
55
+ return { ok: true, plan: { taskBudget, perPurchase, daily, requested: { task: reqTask, perPurchase: reqPer, daily: reqDaily }, clamped, why } };
56
+ }
57
+ /** 逐层检查一次购买; 哪一层拦住就说清哪一层 (绝不合并成"预算不足"这种糊话)。 */
58
+ export function checkPurchaseAllowed(opts) {
59
+ const amount = num(opts.amount);
60
+ if (amount === undefined)
61
+ return { allowed: false, layer: undefined, reason: `金额不是合法数字: ${String(opts.amount)}` };
62
+ const spentInTask = opts.spentInTask ?? 0;
63
+ const spentToday = opts.spentToday ?? 0;
64
+ const remainingTask = round6(opts.plan.taskBudget - spentInTask);
65
+ const remainingDaily = round6(opts.plan.daily - spentToday);
66
+ if (amount > opts.plan.perPurchase) {
67
+ const capSource = opts.plan.perPurchase < opts.plan.requested.perPurchase
68
+ ? `来自任务预算 ${opts.plan.taskBudget}`
69
+ : opts.plan.clamped.perPurchase ? '被 M1 上限收紧后的值' : '你给的上限';
70
+ return { allowed: false, layer: 'perPurchase', reason: `单次购买上限 ${opts.plan.perPurchase} USDC (${capSource}); 这次要 ${amount} USDC`, remainingTask, remainingDaily };
71
+ }
72
+ if (amount > remainingTask) {
73
+ return { allowed: false, layer: 'taskBudget', reason: `任务预算只剩 ${remainingTask} USDC; 这次要 ${amount} USDC`, remainingTask, remainingDaily };
74
+ }
75
+ if (amount > remainingDaily) {
76
+ return { allowed: false, layer: 'daily', reason: `今日预算只剩 ${remainingDaily} USDC; 这次要 ${amount} USDC`, remainingTask, remainingDaily };
77
+ }
78
+ return { allowed: true, remainingTask, remainingDaily };
79
+ }
80
+ function round6(n) {
81
+ return Math.round(n * 1e6) / 1e6;
82
+ }
83
+ /** 购买前的"价格与预算影响"预览 (M1 验收第 5 项: 付款前必须看得到) */
84
+ export function previewPurchaseImpact(plan, amount, spentToday = 0) {
85
+ const a = num(amount);
86
+ if (a === undefined)
87
+ return [`金额非法: ${String(amount)}`];
88
+ const pctTask = plan.taskBudget > 0 ? Math.round((a / plan.taskBudget) * 100) : 100;
89
+ return [
90
+ `价格 ${a} USDC · 占任务预算 ${pctTask}%`,
91
+ `购买后: 任务剩余 ${round6(plan.taskBudget - a)} USDC · 今日剩余 ${round6(plan.daily - spentToday - a)} USDC`,
92
+ ];
93
+ }
94
+ /** 执行中不许扩大预算 (M1 红线: 用户只给一次) */
95
+ export function assertNoExpansion(prev, next) {
96
+ if (next.taskBudget > prev.taskBudget + 1e-9)
97
+ return { ok: false, reason: `任务预算被扩大: ${prev.taskBudget} → ${next.taskBudget} (M1 不允许)` };
98
+ if (next.perPurchase > prev.perPurchase + 1e-9)
99
+ return { ok: false, reason: `单次上限被扩大: ${prev.perPurchase} → ${next.perPurchase} (M1 不允许)` };
100
+ if (next.daily > prev.daily + 1e-9)
101
+ return { ok: false, reason: `当日预算被扩大: ${prev.daily} → ${next.daily} (M1 不允许)` };
102
+ return { ok: true };
103
+ }