lumniverse 0.3.0 → 0.4.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/README.md +1 -0
- package/assets/console/device.html +37 -0
- package/assets/console/start.html +14 -5
- package/assets/deploy/install.sh +7 -1
- package/assets/fleet/apps.mjs +145 -0
- package/assets/fleet/lumni.mjs +193 -0
- package/assets/fleet/run.mjs +207 -0
- package/assets/fleet/scenarios/01-duplicate-effect.mjs +39 -0
- package/assets/fleet/scenarios/02-stale-reasoning.mjs +70 -0
- package/assets/fleet/scenarios/03-human-collision.mjs +30 -0
- package/assets/fleet/scenarios/04-resource-contention.mjs +37 -0
- package/assets/fleet/scenarios/05-cross-agent-conflict.mjs +50 -0
- package/assets/fleet/scenarios/06-superseded-work.mjs +52 -0
- package/assets/fleet/scenarios/07-duplicate-work.mjs +45 -0
- package/assets/fleet/scenarios/08-dependency.mjs +49 -0
- package/assets/fleet/scenarios/09-false-completion.mjs +48 -0
- package/assets/fleet/scenarios/10-partial-completion.mjs +46 -0
- package/assets/fleet/scenarios/11-cost-waste.mjs +41 -0
- package/assets/fleet/scenarios/12-runaway-fleet.mjs +46 -0
- package/assets/fleet/scenarios/extra-approval.mjs +35 -0
- package/assets/fleet/scenarios/extra-retry-loop.mjs +31 -0
- package/assets/fleet/scenarios/kit.mjs +38 -0
- package/assets/lumni-init.mjs +5 -3
- package/bin/lumni.mjs +9 -0
- package/bin/try.mjs +35 -0
- package/package.json +6 -3
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run the fleet: twelve scenarios, one for each problem Lumni coordinates,
|
|
3
|
+
// acted out by small agents that check with Lumni before they change anything.
|
|
4
|
+
//
|
|
5
|
+
// node run.mjs every scenario, once
|
|
6
|
+
// node run.mjs --admin-key KEY also the ones that need an admin key
|
|
7
|
+
// node run.mjs --scenario stale-reasoning one scenario (comma-separate for more)
|
|
8
|
+
// node run.mjs --list what's here
|
|
9
|
+
// node run.mjs --rounds 3 | --forever again and again (Ctrl-C to stop)
|
|
10
|
+
//
|
|
11
|
+
// Reads LUMNI_URL and LUMNI_API_KEY from the environment or ./.env (lumni init writes it).
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import { LumniClient, guard, settings, shortId, sleep as wait, spend, why } from './lumni.mjs';
|
|
17
|
+
import { createApps } from './apps.mjs';
|
|
18
|
+
import { recordPriceVersion } from './scenarios/kit.mjs';
|
|
19
|
+
import { scenario as s01 } from './scenarios/01-duplicate-effect.mjs';
|
|
20
|
+
import { scenario as s02 } from './scenarios/02-stale-reasoning.mjs';
|
|
21
|
+
import { scenario as s03 } from './scenarios/03-human-collision.mjs';
|
|
22
|
+
import { scenario as s04 } from './scenarios/04-resource-contention.mjs';
|
|
23
|
+
import { scenario as s05 } from './scenarios/05-cross-agent-conflict.mjs';
|
|
24
|
+
import { scenario as s06 } from './scenarios/06-superseded-work.mjs';
|
|
25
|
+
import { scenario as s07 } from './scenarios/07-duplicate-work.mjs';
|
|
26
|
+
import { scenario as s08 } from './scenarios/08-dependency.mjs';
|
|
27
|
+
import { scenario as s09 } from './scenarios/09-false-completion.mjs';
|
|
28
|
+
import { scenario as s10 } from './scenarios/10-partial-completion.mjs';
|
|
29
|
+
import { scenario as s11 } from './scenarios/11-cost-waste.mjs';
|
|
30
|
+
import { scenario as s12 } from './scenarios/12-runaway-fleet.mjs';
|
|
31
|
+
import { scenario as loop } from './scenarios/extra-retry-loop.mjs';
|
|
32
|
+
import { scenario as approval } from './scenarios/extra-approval.mjs';
|
|
33
|
+
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
export const SCENARIOS = [s01, s02, s03, s04, s05, s06, s07, s08, s09, s10, s11, s12, loop, approval];
|
|
36
|
+
|
|
37
|
+
function args(argv) {
|
|
38
|
+
const o = {};
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
if (!argv[i].startsWith('--')) continue;
|
|
41
|
+
const k = argv[i].slice(2);
|
|
42
|
+
if (argv[i + 1] && !argv[i + 1].startsWith('--')) o[k] = argv[++i];
|
|
43
|
+
else o[k] = true;
|
|
44
|
+
}
|
|
45
|
+
return o;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tty = process.stdout.isTTY;
|
|
49
|
+
const paint = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
50
|
+
const PALETTE = [99, 214, 39, 170, 76, 203, 45, 141, 208, 118];
|
|
51
|
+
const colorOf = new Map();
|
|
52
|
+
const tag = (actor) => {
|
|
53
|
+
if (!colorOf.has(actor)) colorOf.set(actor, PALETTE[colorOf.size % PALETTE.length]);
|
|
54
|
+
return paint(`38;5;${colorOf.get(actor)}`, actor.padEnd(18));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** "WAIT:LEASE_BUSY" → "wait (another agent is working on it)". */
|
|
58
|
+
const decisionWords = (s) => {
|
|
59
|
+
const [d, r] = s.split(':');
|
|
60
|
+
return `${d.replace(/^WOULD_/, '').toLowerCase().replace(/_/g, ' ')}${r ? ` (${why(r)})` : ''}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** What happened in one scenario, from Lumni's decisions, its own notes and the apps' problems. */
|
|
64
|
+
export function verdict(sc, seen, notes, problems) {
|
|
65
|
+
const real = [...seen].filter((s) => s !== 'ALLOW' && !s.startsWith('WOULD_'));
|
|
66
|
+
const would = [...seen].filter((s) => s.startsWith('WOULD_'));
|
|
67
|
+
const noted = notes.find((n) => n.kind === 'stepped-in' || n.kind === 'joined' || n.kind === 'noted');
|
|
68
|
+
let kind;
|
|
69
|
+
let text;
|
|
70
|
+
if (real.length) {
|
|
71
|
+
kind = 'stepped-in';
|
|
72
|
+
text = `Lumni stepped in: ${[...new Set(real.map(decisionWords))].join(', ')}`;
|
|
73
|
+
} else if (noted) {
|
|
74
|
+
kind = noted.kind;
|
|
75
|
+
text = noted.text;
|
|
76
|
+
} else if (would.length) {
|
|
77
|
+
kind = 'would-have';
|
|
78
|
+
text = `Watch-only, so it went ahead. Lumni would have said: ${[...new Set(would.map(decisionWords))].join(', ')}`;
|
|
79
|
+
} else {
|
|
80
|
+
kind = 'nothing';
|
|
81
|
+
text = notes.find((n) => n.kind === 'no-limit')?.text ?? 'Nothing for Lumni to do';
|
|
82
|
+
}
|
|
83
|
+
if (noted && real.length) text += `; ${noted.text}`;
|
|
84
|
+
if (problems.length) text += `. ${problems.length} ${problems.length === 1 ? 'problem' : 'problems'} in the apps`;
|
|
85
|
+
return { kind, text };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function main(argv, env = process.env, { fetchFn = fetch, log = console.log, sleep = wait, dir = here } = {}) {
|
|
89
|
+
const o = args(argv);
|
|
90
|
+
if (o.help) {
|
|
91
|
+
log('node run.mjs [--scenario a,b] [--admin-key KEY] [--rounds N | --forever] [--list]');
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (o.list) {
|
|
95
|
+
for (const s of SCENARIOS) log(`${s.id.padEnd(22)} ${s.problem.padEnd(26)} ${s.story}${s.needsAdmin ? ' (needs --admin-key)' : ''}${s.onlyWhenAsked ? ' (only with --scenario)' : ''}`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
const { url, key } = settings(env, dir);
|
|
99
|
+
if (!key) {
|
|
100
|
+
log('No Lumni key here. In the console, open Start, press Make a code, and run its command in this folder.');
|
|
101
|
+
log('Or set LUMNI_URL and LUMNI_API_KEY.');
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
const lumni = new LumniClient(url, key, undefined, fetchFn);
|
|
105
|
+
try {
|
|
106
|
+
await lumni.request('GET', '/v1/actors?limit=1');
|
|
107
|
+
} catch (e) {
|
|
108
|
+
log(e.status === 401 || e.status === 403 ? `Lumni at ${url} doesn’t accept this key (${e.message}).` : `Lumni can’t be reached at ${url}: ${e.message}`);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
const admin = o['admin-key'] ? new LumniClient(url, String(o['admin-key']), undefined, fetchFn) : null;
|
|
112
|
+
|
|
113
|
+
const asked = typeof o.scenario === 'string' ? o.scenario.split(',').map((s) => s.trim()) : null;
|
|
114
|
+
const chosen = asked ? SCENARIOS.filter((s) => asked.includes(s.id)) : SCENARIOS.filter((s) => !s.onlyWhenAsked);
|
|
115
|
+
if (!chosen.length) {
|
|
116
|
+
log(`No scenario called ${o.scenario}. Run with --list to see them.`);
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
const rounds = o.forever ? Infinity : Math.max(1, Math.min(100, Number(o.rounds ?? 1) || 1));
|
|
120
|
+
// The apps remember their price list between runs (state.json), as real ones would.
|
|
121
|
+
let kept = {};
|
|
122
|
+
try {
|
|
123
|
+
if (!o.fresh && existsSync(join(dir, 'state.json'))) kept = { priceList: JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8')).salesforce?.priceList };
|
|
124
|
+
} catch {}
|
|
125
|
+
const apps = createApps(kept);
|
|
126
|
+
|
|
127
|
+
const actors = new Map();
|
|
128
|
+
for (const s of chosen) for (const a of s.actors) actors.set(a.id, a);
|
|
129
|
+
for (const a of actors.values()) {
|
|
130
|
+
await lumni.request('POST', '/v1/actors', { id: a.id, type: a.type ?? 'AGENT', display_name: a.name, runtime: a.type === 'HUMAN' ? undefined : 'node', metadata: { source: 'agent-fleet' } });
|
|
131
|
+
}
|
|
132
|
+
await recordPriceVersion(lumni, apps.salesforce.readPriceList());
|
|
133
|
+
const people = [...actors.values()].filter((a) => a.type === 'HUMAN').length;
|
|
134
|
+
log(`${actors.size - people} agents${people ? ` and ${people} ${people === 1 ? 'person' : 'people'}` : ''}, ${chosen.length} scenarios, checked by Lumni at ${url}.${admin ? '' : ' (No --admin-key: scenarios that need one are skipped.)'}`);
|
|
135
|
+
|
|
136
|
+
let stop = false;
|
|
137
|
+
const onStop = () => { stop = true; log('\nStopping after this scenario…'); };
|
|
138
|
+
if (o.forever) process.once('SIGINT', onStop);
|
|
139
|
+
|
|
140
|
+
const results = [];
|
|
141
|
+
for (let round = 0; round < rounds && !stop; round++) {
|
|
142
|
+
const runTag = shortId().slice(0, 4);
|
|
143
|
+
apps.resetCustomers();
|
|
144
|
+
if (rounds > 1) log(paint('1', `\n═══ Round ${round + 1}${rounds === Infinity ? '' : ` of ${rounds}`} ═══`));
|
|
145
|
+
for (const sc of chosen) {
|
|
146
|
+
if (stop) break;
|
|
147
|
+
log(`\n${paint('1', `▸ ${sc.problem}`)} ${paint('2', sc.story)}`);
|
|
148
|
+
if (sc.needsAdmin && !admin) {
|
|
149
|
+
log(` ${paint('2', 'Skipped: needs --admin-key (the checker posts evidence with the verification scope)')}`);
|
|
150
|
+
results.push({ sc, kind: 'skipped', text: 'Skipped: needs --admin-key' });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
const notes = [];
|
|
155
|
+
const before = apps.state.problems.length;
|
|
156
|
+
const say = (actor, text) => log(` ${tag(actor)} ${text}`);
|
|
157
|
+
const ctx = {
|
|
158
|
+
lumni, admin, apps, say,
|
|
159
|
+
// Fresh ids each run, so a second run against the same Lumni tells the same story.
|
|
160
|
+
run: runTag,
|
|
161
|
+
note: (kind, text) => notes.push({ kind, text }),
|
|
162
|
+
spend: (x) => spend(lumni, x),
|
|
163
|
+
// Lumni identifies a change by its operation, target and arguments, so the same
|
|
164
|
+
// change in a later run would rightly be "already done". Each run's changes carry
|
|
165
|
+
// the run, the way real work carries a request or ticket id; repeats within a run
|
|
166
|
+
// are still repeats.
|
|
167
|
+
guard: (input, act, opts = {}) => guard(lumni, { ...input, arguments: { ...(input.arguments ?? {}), batch: runTag } }, act, { seen, ...opts, say: opts.quietWaits ? () => {} : (t) => say(input.actor_id, t) }),
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
await sc.run.call(sc, ctx);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
say('fleet', paint('31', `failed: ${e.message}`));
|
|
173
|
+
notes.push({ kind: 'noted', text: `Failed: ${e.message}` });
|
|
174
|
+
}
|
|
175
|
+
const problems = apps.state.problems.slice(before);
|
|
176
|
+
for (const p of problems) log(` ${paint('31', '✗')} ${p.text}`);
|
|
177
|
+
const v = verdict(sc, seen, notes, problems);
|
|
178
|
+
log(` ${v.kind === 'stepped-in' || v.kind === 'joined' ? paint('38;5;99', '●') : paint('2', '○')} ${v.text}`);
|
|
179
|
+
results.push({ sc, ...v, problems: problems.length });
|
|
180
|
+
}
|
|
181
|
+
if (round + 1 < rounds && !stop) await sleep(o.forever ? 20_000 : 3000);
|
|
182
|
+
}
|
|
183
|
+
process.off?.('SIGINT', onStop);
|
|
184
|
+
|
|
185
|
+
// The table, once per scenario (the last round's verdict).
|
|
186
|
+
const last = new Map(results.map((r) => [r.sc.id, r]));
|
|
187
|
+
log(paint('1', '\nCoverage'));
|
|
188
|
+
for (const r of last.values()) {
|
|
189
|
+
const mark = r.kind === 'stepped-in' || r.kind === 'joined' ? paint('38;5;99', '●') : r.kind === 'skipped' ? paint('2', '–') : paint('2', '○');
|
|
190
|
+
log(` ${mark} ${r.sc.problem.padEnd(26)} ${r.text}`);
|
|
191
|
+
}
|
|
192
|
+
const all = apps.state.problems;
|
|
193
|
+
log(paint('1', `\nProblems in the apps: ${all.length ? all.length : 'none'}`));
|
|
194
|
+
for (const p of all) log(` ${paint('31', '✗')} ${p.text}`);
|
|
195
|
+
if (!o['no-save']) {
|
|
196
|
+
apps.save(join(dir, 'state.json'));
|
|
197
|
+
log(paint('2', '\nEverything the apps saw is in state.json. The console shows Lumni’s side: Today, Needs you, Tower, Context, Runtime, Money.'));
|
|
198
|
+
}
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
203
|
+
main(process.argv.slice(2)).then((c) => process.exit(c), (e) => {
|
|
204
|
+
console.error(e.message);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Duplicate effect: two agents refund the same payment.
|
|
2
|
+
//
|
|
3
|
+
// The refund agent refunds a disputed charge, Stripe times out, and it tries
|
|
4
|
+
// again. Meanwhile the billing bot, working the same dispute from the finance
|
|
5
|
+
// queue, refunds it too. Each refund names its effect ("refund this charge"),
|
|
6
|
+
// so Lumni can tell all three are one refund and lets only the first through.
|
|
7
|
+
|
|
8
|
+
import { shortId } from '../lumni.mjs';
|
|
9
|
+
import { startWork } from './kit.mjs';
|
|
10
|
+
|
|
11
|
+
export const scenario = {
|
|
12
|
+
id: 'duplicate-effect',
|
|
13
|
+
problem: 'Duplicate effect',
|
|
14
|
+
story: 'Two agents refund the same payment.',
|
|
15
|
+
expect: 'DEDUPLICATE',
|
|
16
|
+
actors: [{ id: 'refund-agent', name: 'Refund agent' }, { id: 'billing-bot', name: 'Billing bot' }],
|
|
17
|
+
async run({ lumni, apps, guard, say }) {
|
|
18
|
+
const charge = `ch_${shortId()}`;
|
|
19
|
+
const refund = (actor, work, key) => ({
|
|
20
|
+
work_id: work, actor_id: actor, operation: 'stripe.refund', risk: 'MEDIUM',
|
|
21
|
+
arguments: { charge, amount: 60 },
|
|
22
|
+
idempotency_key: key,
|
|
23
|
+
effect_key: `stripe.refund:${charge}`,
|
|
24
|
+
resources: [{ type: 'charge', namespace: 'stripe', external_id: charge, mode: 'WRITE' }],
|
|
25
|
+
metadata: { summary: `Refund $60 on ${charge}`, customer: 'Hooli' },
|
|
26
|
+
});
|
|
27
|
+
const w1 = await startWork(lumni, 'refund-agent', `Refund the disputed charge ${charge}`);
|
|
28
|
+
const key = `refund-agent:${charge}`;
|
|
29
|
+
if ((await guard(refund('refund-agent', w1, key), () => apps.stripe.refund({ charge, amount: 60, by: 'refund-agent', scenario: this.id }))).done) say('refund-agent', `Refunded $60 on ${charge}`);
|
|
30
|
+
say('refund-agent', 'Stripe timed out (pretend), so it tries the same refund again');
|
|
31
|
+
if ((await guard(refund('refund-agent', w1, key), () => apps.stripe.refund({ charge, amount: 60, by: 'refund-agent', scenario: this.id }))).done) say('refund-agent', 'Refunded it again');
|
|
32
|
+
|
|
33
|
+
const w2 = await startWork(lumni, 'billing-bot', `Resolve the dispute on ${charge}`);
|
|
34
|
+
say('billing-bot', `Working the same dispute from the finance queue: refunding ${charge}`);
|
|
35
|
+
if ((await guard(refund('billing-bot', w2, `billing-bot:${charge}`), () => apps.stripe.refund({ charge, amount: 60, by: 'billing-bot', scenario: this.id }))).done) say('billing-bot', 'Refunded it too');
|
|
36
|
+
await lumni.completeWork(w1).catch(() => {});
|
|
37
|
+
await lumni.completeWork(w2).catch(() => {});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Stale reasoning: the agent decides on reality from a second ago.
|
|
2
|
+
//
|
|
3
|
+
// The price setter reads the Enterprise price list (v41, say), thinks, and
|
|
4
|
+
// writes a 5% discount. While it thought, pricing sync copied the supplier's
|
|
5
|
+
// new cost in (v42). Lumni sees it read an old version and sends it back.
|
|
6
|
+
|
|
7
|
+
import { sleep } from '../lumni.mjs';
|
|
8
|
+
import { usd } from '../apps.mjs';
|
|
9
|
+
import { priceListWrite, recordPriceVersion, startWork } from './kit.mjs';
|
|
10
|
+
|
|
11
|
+
export const scenario = {
|
|
12
|
+
id: 'stale-reasoning',
|
|
13
|
+
problem: 'Stale reasoning',
|
|
14
|
+
story: 'The agent decides using reality from a moment ago.',
|
|
15
|
+
expect: 'REPLAN',
|
|
16
|
+
actors: [{ id: 'price-setter', name: 'Price setter' }, { id: 'pricing-sync', name: 'Pricing sync' }],
|
|
17
|
+
async run(ctx) {
|
|
18
|
+
await Promise.all([discount(ctx, 'Hooli', this.id), supplierSync(ctx)]);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Also used by the human-collision scenario. */
|
|
23
|
+
export async function discount({ lumni, apps, guard, say }, account, scenario) {
|
|
24
|
+
const work = await startWork(lumni, 'price-setter', `5% off for ${account}`);
|
|
25
|
+
const ask = (read) => {
|
|
26
|
+
const price = Math.round(read.seatPrice * 0.95 * 100) / 100;
|
|
27
|
+
return {
|
|
28
|
+
work_id: work, actor_id: 'price-setter', operation: 'salesforce.update_price', risk: 'MEDIUM',
|
|
29
|
+
arguments: { price_list: 'Enterprise', account, seat_price: price, change_pct: -5 },
|
|
30
|
+
resources: [priceListWrite(read)],
|
|
31
|
+
metadata: { summary: `5% off for ${account}: seat price ${usd(price)}`, customer: account },
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
let read = apps.salesforce.readPriceList();
|
|
35
|
+
say('price-setter', `Read the price list: ${usd(read.seatPrice)} (${read.version}). Thinking…`);
|
|
36
|
+
await sleep(1500);
|
|
37
|
+
const r = await guard(ask(read), async (d) => {
|
|
38
|
+
const price = Math.round(read.seatPrice * 0.95 * 100) / 100;
|
|
39
|
+
const after = await apps.salesforce.setSeatPrice(price, 'price-setter', read.version, scenario);
|
|
40
|
+
await recordPriceVersion(lumni, after, 'price-setter', d.action_id);
|
|
41
|
+
return after;
|
|
42
|
+
}, {
|
|
43
|
+
onReplan: async () => {
|
|
44
|
+
read = apps.salesforce.readPriceList();
|
|
45
|
+
say('price-setter', `Read it again: ${usd(read.seatPrice)} (${read.version})`);
|
|
46
|
+
return ask(read);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
if (r.done) say('price-setter', `${account} gets ${usd(Math.round(read.seatPrice * 0.95 * 100) / 100)} a seat`);
|
|
50
|
+
await lumni.completeWork(work).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function supplierSync({ lumni, apps, guard, say }) {
|
|
54
|
+
const work = await startWork(lumni, 'pricing-sync', 'Copy supplier costs');
|
|
55
|
+
await sleep(500);
|
|
56
|
+
const read = apps.salesforce.readPriceList();
|
|
57
|
+
const next = read.seatPrice + 1;
|
|
58
|
+
const r = await guard({
|
|
59
|
+
work_id: work, actor_id: 'pricing-sync', operation: 'salesforce.update_price', risk: 'LOW',
|
|
60
|
+
arguments: { price_list: 'Enterprise', seat_price: next, change_pct: Math.round((1 / read.seatPrice) * 1000) / 10 },
|
|
61
|
+
resources: [priceListWrite(read)],
|
|
62
|
+
metadata: { summary: `Supplier cost: Enterprise seat price to ${usd(next)}` },
|
|
63
|
+
}, async (d) => {
|
|
64
|
+
const after = await apps.salesforce.setSeatPrice(next, 'pricing-sync', read.version);
|
|
65
|
+
await recordPriceVersion(lumni, after, 'pricing-sync', d.action_id);
|
|
66
|
+
return after;
|
|
67
|
+
});
|
|
68
|
+
if (r.done) say('pricing-sync', `Supplier cost changed: seat price is now ${usd(next)}`);
|
|
69
|
+
await lumni.completeWork(work).catch(() => {});
|
|
70
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Human/agent collision: a person fixes something while an AI is still on it.
|
|
2
|
+
//
|
|
3
|
+
// Jess, in sales ops, corrects the Enterprise seat price in Salesforce by hand
|
|
4
|
+
// while the price setter is thinking. Salesforce tells Lumni about the new
|
|
5
|
+
// version, and the agent is sent back instead of writing over her fix.
|
|
6
|
+
|
|
7
|
+
import { sleep } from '../lumni.mjs';
|
|
8
|
+
import { usd } from '../apps.mjs';
|
|
9
|
+
import { recordPriceVersion } from './kit.mjs';
|
|
10
|
+
import { discount } from './02-stale-reasoning.mjs';
|
|
11
|
+
|
|
12
|
+
export const scenario = {
|
|
13
|
+
id: 'human-collision',
|
|
14
|
+
problem: 'Human/agent collision',
|
|
15
|
+
story: 'A human fixes something while an AI is still trying to fix it.',
|
|
16
|
+
expect: 'REPLAN',
|
|
17
|
+
actors: [{ id: 'price-setter', name: 'Price setter' }, { id: 'jess', name: 'Jess Park', type: 'HUMAN' }],
|
|
18
|
+
async run(ctx) {
|
|
19
|
+
const jess = async () => {
|
|
20
|
+
await sleep(600);
|
|
21
|
+
const read = ctx.apps.salesforce.readPriceList();
|
|
22
|
+
const fixed = read.seatPrice + 3;
|
|
23
|
+
const after = await ctx.apps.salesforce.setSeatPrice(fixed, 'jess', read.version);
|
|
24
|
+
// Salesforce's webhook tells Lumni; no agent involved.
|
|
25
|
+
await recordPriceVersion(ctx.lumni, after, 'jess');
|
|
26
|
+
ctx.say('jess', `(a person) Corrected the seat price in Salesforce to ${usd(fixed)}`);
|
|
27
|
+
};
|
|
28
|
+
await Promise.all([discount(ctx, 'Initech', this.id), jess()]);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Resource contention: five agents start editing the same account.
|
|
2
|
+
//
|
|
3
|
+
// The SDR bot, renewal bot, support agent, billing bot and enrichment bot all
|
|
4
|
+
// update the Globex account in Salesforce at once. Lumni lets one in at a
|
|
5
|
+
// time, so no edit is lost.
|
|
6
|
+
|
|
7
|
+
import { startWork } from './kit.mjs';
|
|
8
|
+
|
|
9
|
+
const EDITORS = [
|
|
10
|
+
['sdr-bot', 'SDR bot', 'next_step', 'Book a demo'],
|
|
11
|
+
['renewal-bot', 'Renewal bot', 'renewal_date', '2027-01-01'],
|
|
12
|
+
['support-agent', 'Support agent', 'health', 'At risk'],
|
|
13
|
+
['billing-bot', 'Billing bot', 'billing_email', 'ap@globex.example'],
|
|
14
|
+
['enrich-bot', 'Enrichment bot', 'employees', '1,240'],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export const scenario = {
|
|
18
|
+
id: 'resource-contention',
|
|
19
|
+
problem: 'Resource contention',
|
|
20
|
+
story: 'Five agents start editing the same account.',
|
|
21
|
+
expect: 'WAIT',
|
|
22
|
+
actors: EDITORS.map(([id, name]) => ({ id, name })),
|
|
23
|
+
async run({ lumni, apps, guard, say }) {
|
|
24
|
+
await Promise.all(EDITORS.map(async ([actor, , field, value], i) => {
|
|
25
|
+
const work = await startWork(lumni, actor, `Update Globex: ${field}`);
|
|
26
|
+
await new Promise((r) => setTimeout(r, i * 40));
|
|
27
|
+
const r = await guard({
|
|
28
|
+
work_id: work, actor_id: actor, operation: 'salesforce.update_account', risk: 'LOW',
|
|
29
|
+
arguments: { account: 'Globex', field, value },
|
|
30
|
+
resources: [{ type: 'account', namespace: 'salesforce', external_id: 'globex', mode: 'WRITE' }],
|
|
31
|
+
metadata: { summary: `Globex ${field.replace(/_/g, ' ')}: ${value}`, customer: 'Globex' },
|
|
32
|
+
}, () => apps.salesforce.editAccount('Globex', field, value, actor, this.id));
|
|
33
|
+
if (r.done) say(actor, `Updated Globex ${field.replace(/_/g, ' ')}`);
|
|
34
|
+
await lumni.completeWork(work).catch(() => {});
|
|
35
|
+
}));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Cross-agent conflict: two agents change the same customer differently.
|
|
2
|
+
//
|
|
3
|
+
// Umbrella asks to cancel. The churn agent starts cancelling their
|
|
4
|
+
// subscription, and at the same moment the upsell agent tries to upgrade it.
|
|
5
|
+
// Lumni makes the upgrade wait until the cancellation is done; the upsell
|
|
6
|
+
// agent then sees the subscription is cancelled and drops the upgrade.
|
|
7
|
+
|
|
8
|
+
import { sleep } from '../lumni.mjs';
|
|
9
|
+
import { startWork } from './kit.mjs';
|
|
10
|
+
|
|
11
|
+
const SUB = 'sub_umbrella';
|
|
12
|
+
const subscription = { type: 'subscription', namespace: 'billing', external_id: SUB, mode: 'WRITE' };
|
|
13
|
+
|
|
14
|
+
export const scenario = {
|
|
15
|
+
id: 'cross-agent-conflict',
|
|
16
|
+
problem: 'Cross-agent conflict',
|
|
17
|
+
story: 'Two agents try to change the same customer differently.',
|
|
18
|
+
expect: 'WAIT',
|
|
19
|
+
actors: [{ id: 'churn-agent', name: 'Churn agent' }, { id: 'upsell-agent', name: 'Upsell agent' }],
|
|
20
|
+
async run({ lumni, apps, guard, say }) {
|
|
21
|
+
const cancel = async () => {
|
|
22
|
+
const work = await startWork(lumni, 'churn-agent', 'Umbrella asked to cancel');
|
|
23
|
+
const r = await guard({
|
|
24
|
+
work_id: work, actor_id: 'churn-agent', operation: 'billing.cancel_subscription', risk: 'HIGH',
|
|
25
|
+
arguments: { subscription: SUB }, resources: [subscription],
|
|
26
|
+
metadata: { summary: 'Cancel Umbrella’s subscription', customer: 'Umbrella' },
|
|
27
|
+
}, () => apps.billing.cancel(SUB));
|
|
28
|
+
if (r.done) say('churn-agent', 'Cancelled Umbrella’s subscription');
|
|
29
|
+
await lumni.completeWork(work).catch(() => {});
|
|
30
|
+
};
|
|
31
|
+
const upgrade = async () => {
|
|
32
|
+
const work = await startWork(lumni, 'upsell-agent', 'Upgrade Umbrella to Enterprise');
|
|
33
|
+
await sleep(200);
|
|
34
|
+
const r = await guard({
|
|
35
|
+
work_id: work, actor_id: 'upsell-agent', operation: 'billing.change_plan', risk: 'MEDIUM',
|
|
36
|
+
arguments: { subscription: SUB, plan: 'Enterprise' }, resources: [subscription],
|
|
37
|
+
metadata: { summary: 'Upgrade Umbrella to Enterprise', customer: 'Umbrella' },
|
|
38
|
+
}, async () => {
|
|
39
|
+
// It acts on the subscription as it is now, which is why waiting matters.
|
|
40
|
+
const now = apps.billing.subscription(SUB);
|
|
41
|
+
if (now.status === 'cancelled') return { dropped: 'cancelled' };
|
|
42
|
+
return apps.billing.upgrade(SUB, 'Enterprise', this.id);
|
|
43
|
+
});
|
|
44
|
+
if (r.output?.dropped) say('upsell-agent', 'Umbrella has cancelled, so it drops the upgrade');
|
|
45
|
+
else if (r.done) say('upsell-agent', 'Upgraded Umbrella to Enterprise');
|
|
46
|
+
await lumni.completeWork(work).catch(() => {});
|
|
47
|
+
};
|
|
48
|
+
await Promise.all([cancel(), upgrade()]);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Superseded work: the customer changes their mind while the agent works.
|
|
2
|
+
//
|
|
3
|
+
// The upsell agent spends a while researching an upgrade offer for Initech.
|
|
4
|
+
// Initech cancels first. The churn agent cancels them and tells Lumni the
|
|
5
|
+
// upgrade work is superseded, so when the upsell agent comes to apply it,
|
|
6
|
+
// Lumni stops it.
|
|
7
|
+
|
|
8
|
+
import { sleep } from '../lumni.mjs';
|
|
9
|
+
import { startWork } from './kit.mjs';
|
|
10
|
+
|
|
11
|
+
const SUB = 'sub_initech';
|
|
12
|
+
|
|
13
|
+
export const scenario = {
|
|
14
|
+
id: 'superseded-work',
|
|
15
|
+
problem: 'Superseded work',
|
|
16
|
+
story: 'The customer changes their mind while the agent is still working.',
|
|
17
|
+
expect: 'REPLAN (work superseded)',
|
|
18
|
+
actors: [{ id: 'upsell-agent', name: 'Upsell agent' }, { id: 'churn-agent', name: 'Churn agent' }],
|
|
19
|
+
async run({ lumni, apps, guard, say, spend }) {
|
|
20
|
+
const upgradeWork = await startWork(lumni, 'upsell-agent', 'Upgrade Initech to Enterprise');
|
|
21
|
+
const upsell = async () => {
|
|
22
|
+
say('upsell-agent', 'Researching an upgrade offer for Initech…');
|
|
23
|
+
await spend({ actor: 'upsell-agent', work: upgradeWork, usd: 1.8, tokens: 60_000 });
|
|
24
|
+
await sleep(1500);
|
|
25
|
+
const r = await guard({
|
|
26
|
+
work_id: upgradeWork, actor_id: 'upsell-agent', operation: 'billing.change_plan', risk: 'MEDIUM',
|
|
27
|
+
arguments: { subscription: SUB, plan: 'Enterprise' },
|
|
28
|
+
resources: [{ type: 'subscription', namespace: 'billing', external_id: SUB, mode: 'WRITE' }],
|
|
29
|
+
metadata: { summary: 'Upgrade Initech to Enterprise', customer: 'Initech' },
|
|
30
|
+
}, () => apps.billing.upgrade(SUB, 'Enterprise', this.id));
|
|
31
|
+
if (r.done) say('upsell-agent', 'Upgraded Initech to Enterprise');
|
|
32
|
+
};
|
|
33
|
+
const churn = async () => {
|
|
34
|
+
await sleep(300);
|
|
35
|
+
const work = await startWork(lumni, 'churn-agent', 'Initech asked to cancel');
|
|
36
|
+
const r = await guard({
|
|
37
|
+
work_id: work, actor_id: 'churn-agent', operation: 'billing.cancel_subscription', risk: 'HIGH',
|
|
38
|
+
arguments: { subscription: SUB },
|
|
39
|
+
resources: [{ type: 'subscription', namespace: 'billing', external_id: SUB, mode: 'WRITE' }],
|
|
40
|
+
metadata: { summary: 'Cancel Initech’s subscription', customer: 'Initech' },
|
|
41
|
+
}, async () => {
|
|
42
|
+
const out = await apps.billing.cancel(SUB, 400);
|
|
43
|
+
// Anything still working toward an upgrade is now pointless.
|
|
44
|
+
await lumni.request('POST', `/v1/work/${encodeURIComponent(upgradeWork)}/supersede`, {});
|
|
45
|
+
return out;
|
|
46
|
+
});
|
|
47
|
+
if (r.done) say('churn-agent', 'Cancelled Initech, and marked the upgrade work superseded');
|
|
48
|
+
await lumni.completeWork(work).catch(() => {});
|
|
49
|
+
};
|
|
50
|
+
await Promise.all([upsell(), churn()]);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Duplicate work: two agents unknowingly solve the same problem.
|
|
2
|
+
//
|
|
3
|
+
// One email about a damaged order lands in two inboxes. The support agent and
|
|
4
|
+
// the triage agent both pick it up. Both name the work after the goal
|
|
5
|
+
// ("refund order 8812"), so the second one finds the first already owns it in
|
|
6
|
+
// Lumni, and joins it instead of starting again.
|
|
7
|
+
//
|
|
8
|
+
// Lumni's part is the shared record of who is on what (the work id). Joining
|
|
9
|
+
// is the agent's choice; the API has no JOIN decision. If it didn't join,
|
|
10
|
+
// the refund's effect key would still stop the second refund (duplicate effect).
|
|
11
|
+
|
|
12
|
+
import { sleep } from '../lumni.mjs';
|
|
13
|
+
|
|
14
|
+
const ORDER = 8812;
|
|
15
|
+
|
|
16
|
+
export const scenario = {
|
|
17
|
+
id: 'duplicate-work',
|
|
18
|
+
problem: 'Duplicate work',
|
|
19
|
+
story: 'Two agents unknowingly solve the same problem.',
|
|
20
|
+
expect: 'JOIN (shared work id)',
|
|
21
|
+
actors: [{ id: 'support-agent', name: 'Support agent' }, { id: 'triage-agent', name: 'Triage agent' }],
|
|
22
|
+
async run({ lumni, apps, guard, say, note, run }) {
|
|
23
|
+
const workId = `order-${ORDER}-refund-${run}`;
|
|
24
|
+
const handle = async (actor, delay) => {
|
|
25
|
+
await sleep(delay);
|
|
26
|
+
const w = await lumni.startWork({ id: workId, actor_id: actor, goal: `Refund damaged order #${ORDER}`, risk: 'MEDIUM' });
|
|
27
|
+
if (w.actor_id && w.actor_id !== actor) {
|
|
28
|
+
say(actor, `${w.actor_id} already owns “Refund damaged order #${ORDER}”: joining instead of starting again`);
|
|
29
|
+
note('joined', `${actor} joined ${w.actor_id}’s work instead of doing it twice`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
say(actor, `Picked up order #${ORDER}`);
|
|
33
|
+
const charge = `ch_order_${ORDER}_${run}`;
|
|
34
|
+
const r = await guard({
|
|
35
|
+
work_id: workId, actor_id: actor, operation: 'stripe.refund', risk: 'MEDIUM',
|
|
36
|
+
arguments: { charge, amount: 45 }, effect_key: `stripe.refund:${charge}`,
|
|
37
|
+
resources: [{ type: 'charge', namespace: 'stripe', external_id: charge, mode: 'WRITE' }],
|
|
38
|
+
metadata: { summary: `Refund damaged order #${ORDER}`, customer: 'Soylent' },
|
|
39
|
+
}, () => apps.stripe.refund({ charge, amount: 45, by: actor, scenario: this.id }));
|
|
40
|
+
if (r.done) say(actor, `Refunded order #${ORDER}`);
|
|
41
|
+
};
|
|
42
|
+
await Promise.all([handle('support-agent', 0), handle('triage-agent', 250)]);
|
|
43
|
+
await lumni.completeWork(workId).catch(() => {});
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Dependency: one agent finishes step 3 before another finishes step 2.
|
|
2
|
+
//
|
|
3
|
+
// A customer is owed a refund on ticket #5120. The support agent wants to
|
|
4
|
+
// close the ticket, but only once the refund agent's refund is done. It tells
|
|
5
|
+
// Lumni its work depends on the refund work, and Lumni holds the close until
|
|
6
|
+
// the refund completes.
|
|
7
|
+
|
|
8
|
+
import { shortId, sleep } from '../lumni.mjs';
|
|
9
|
+
import { startWork } from './kit.mjs';
|
|
10
|
+
|
|
11
|
+
const TICKET = 5120;
|
|
12
|
+
|
|
13
|
+
export const scenario = {
|
|
14
|
+
id: 'dependency',
|
|
15
|
+
problem: 'Dependency',
|
|
16
|
+
story: 'One agent finishes step 3 before another finishes step 2.',
|
|
17
|
+
expect: 'WAIT (dependency)',
|
|
18
|
+
actors: [{ id: 'refund-agent', name: 'Refund agent' }, { id: 'support-agent', name: 'Support agent' }],
|
|
19
|
+
async run({ lumni, apps, guard, say }) {
|
|
20
|
+
const charge = `ch_ticket_${TICKET}_${shortId()}`;
|
|
21
|
+
const refundWork = await startWork(lumni, 'refund-agent', `Refund for ticket #${TICKET}`);
|
|
22
|
+
const closeWork = await startWork(lumni, 'support-agent', `Close ticket #${TICKET}`);
|
|
23
|
+
await lumni.request('POST', '/v1/dependencies', { id: `dep_${closeWork}`, entity_type: 'work', source_id: closeWork, target_id: refundWork, relationship: 'DEPENDS_ON' });
|
|
24
|
+
say('support-agent', `Told Lumni: closing #${TICKET} depends on the refund`);
|
|
25
|
+
|
|
26
|
+
const refund = async () => {
|
|
27
|
+
const r = await guard({
|
|
28
|
+
work_id: refundWork, actor_id: 'refund-agent', operation: 'stripe.refund', risk: 'MEDIUM',
|
|
29
|
+
arguments: { charge, amount: 120 }, effect_key: `stripe.refund:${charge}`,
|
|
30
|
+
resources: [{ type: 'charge', namespace: 'stripe', external_id: charge, mode: 'WRITE' }],
|
|
31
|
+
metadata: { summary: `Refund $120 for ticket #${TICKET}`, customer: 'Hooli' },
|
|
32
|
+
}, () => apps.stripe.refund({ charge, amount: 120, by: 'refund-agent', ms: 1200, scenario: this.id }));
|
|
33
|
+
if (r.done) say('refund-agent', 'Refunded $120 (Stripe was slow)');
|
|
34
|
+
await lumni.completeWork(refundWork).catch(() => {});
|
|
35
|
+
};
|
|
36
|
+
const close = async () => {
|
|
37
|
+
await sleep(200);
|
|
38
|
+
const r = await guard({
|
|
39
|
+
work_id: closeWork, actor_id: 'support-agent', operation: 'zendesk.close_ticket', risk: 'LOW',
|
|
40
|
+
arguments: { ticket: TICKET },
|
|
41
|
+
resources: [{ type: 'ticket', namespace: 'zendesk', external_id: String(TICKET), mode: 'WRITE' }],
|
|
42
|
+
metadata: { summary: `Close ticket #${TICKET}` },
|
|
43
|
+
}, () => apps.zendesk.close(TICKET, { refundFor: charge, scenario: this.id }));
|
|
44
|
+
if (r.done) say('support-agent', `Closed ticket #${TICKET}`);
|
|
45
|
+
await lumni.completeWork(closeWork).catch(() => {});
|
|
46
|
+
};
|
|
47
|
+
await Promise.all([refund(), close()]);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// False completion: the agent says “done”, the app says no.
|
|
2
|
+
//
|
|
3
|
+
// The refund agent refunds $75. Stripe answers “succeeded”, then loses it.
|
|
4
|
+
// The agent reports success, as it should. A checker (Stripe's webhook, in
|
|
5
|
+
// real life) reads Stripe afterwards, finds no refund, and tells Lumni, which
|
|
6
|
+
// opens an incident. A second refund that did land is marked verified.
|
|
7
|
+
//
|
|
8
|
+
// Needs --admin-key: registering the checker and posting what it found need
|
|
9
|
+
// the `verification` scope, which agent keys don't have.
|
|
10
|
+
|
|
11
|
+
import { shortId } from '../lumni.mjs';
|
|
12
|
+
import { ensureVerifier, evidence, startWork } from './kit.mjs';
|
|
13
|
+
|
|
14
|
+
export const scenario = {
|
|
15
|
+
id: 'false-completion',
|
|
16
|
+
problem: 'False completion',
|
|
17
|
+
story: 'The agent says “done.” The app says “no.”',
|
|
18
|
+
expect: 'INCIDENT (outcome mismatch)',
|
|
19
|
+
needsAdmin: true,
|
|
20
|
+
actors: [{ id: 'refund-agent', name: 'Refund agent' }],
|
|
21
|
+
async run({ lumni, admin, apps, guard, say, note }) {
|
|
22
|
+
await ensureVerifier(admin, 'stripe-refunds', 'Stripe refunds (agent fleet)');
|
|
23
|
+
const work = await startWork(lumni, 'refund-agent', 'Refund two late deliveries');
|
|
24
|
+
for (const [amount, lost] of [[75, true], [30, false]]) {
|
|
25
|
+
const charge = `ch_${shortId()}`;
|
|
26
|
+
const r = await guard({
|
|
27
|
+
work_id: work, actor_id: 'refund-agent', operation: 'stripe.refund', risk: 'MEDIUM',
|
|
28
|
+
arguments: { charge, amount }, effect_key: `stripe.refund:${charge}`,
|
|
29
|
+
resources: [{ type: 'charge', namespace: 'stripe', external_id: charge, mode: 'WRITE' }],
|
|
30
|
+
verification: { verifier_id: 'stripe-refunds', expected: { charge, amount } },
|
|
31
|
+
metadata: { summary: `Refund $${amount} on ${charge}`, customer: 'Hooli' },
|
|
32
|
+
}, () => apps.stripe.refund({ charge, amount, by: 'refund-agent', drop: lost, scenario: this.id }));
|
|
33
|
+
if (!r.done) continue;
|
|
34
|
+
say('refund-agent', `Stripe said the $${amount} refund succeeded. Reported it done`);
|
|
35
|
+
// The checker reads Stripe itself.
|
|
36
|
+
const found = apps.stripe.refundsFor(charge);
|
|
37
|
+
if (found.length) {
|
|
38
|
+
await evidence(admin, r.decision.action_id, 'VERIFIED', { refunds: found.map((x) => x.id) }, 'Stripe shows the refund');
|
|
39
|
+
say('stripe-checker', `Checked Stripe: the $${amount} refund is there. Verified`);
|
|
40
|
+
} else {
|
|
41
|
+
await evidence(admin, r.decision.action_id, 'MISMATCH', { refunds: [] }, 'Stripe has no refund for this charge');
|
|
42
|
+
say('stripe-checker', `Checked Stripe: there is no $${amount} refund. Lumni opens an incident`);
|
|
43
|
+
note('stepped-in', 'Lumni opened an incident for the lost refund; the real one is verified');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
await lumni.completeWork(work).catch(() => {});
|
|
47
|
+
},
|
|
48
|
+
};
|