lumniverse 0.3.1 → 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 +3 -3
- package/bin/lumni.mjs +9 -0
- package/bin/try.mjs +35 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ npx lumniverse --domain acme.com # run it on a server, with HTTPS at app.acme
|
|
|
8
8
|
npx lumniverse --ip 203.0.113.7 # on a server without a domain yet
|
|
9
9
|
npx lumniverse init # connect the agent in this folder (opens your browser)
|
|
10
10
|
npx lumniverse demo # the console on a made-up day: no Docker, no account
|
|
11
|
+
npx lumniverse try # sample agents act out twelve problems in your workspace
|
|
11
12
|
```
|
|
12
13
|
|
|
13
14
|
`npx lumniverse` runs the same installer as `curl -fsSL https://get.lumniverse.com | sh`, with the files bundled in this package. It writes `~/lumni`: the Compose file, a `.env` with secrets generated on your machine (kept on later runs), and a `./lumni` helper. Then it starts the API, console, worker and Postgres, and opens the console, where the first visit creates the owner account.
|
|
@@ -93,6 +93,7 @@ async function show(code) {
|
|
|
93
93
|
if (l.status === 'denied') return ended('This login was cancelled', 'Nothing was connected. Run the command again if you meant to connect.');
|
|
94
94
|
if (l.status !== 'pending') return connected(l, false);
|
|
95
95
|
|
|
96
|
+
if (l.purpose === 'try') return showTry(l);
|
|
96
97
|
box.innerHTML = `${head('Is this you?', 'A terminal is asking to connect to your Lumni workspace.')}
|
|
97
98
|
${codeChip(l.user_code)}
|
|
98
99
|
<div class="match">Check it matches the code in your terminal</div>
|
|
@@ -124,6 +125,42 @@ async function show(code) {
|
|
|
124
125
|
};
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/** lumniverse try: sample agents, with a key that can do everything until the run ends. */
|
|
129
|
+
function showTry(l) {
|
|
130
|
+
box.innerHTML = `${head('Run the sample agents here?', 'A terminal wants to act out twelve short scenarios in this workspace, so you can see Lumni at work.')}
|
|
131
|
+
${codeChip(l.user_code)}
|
|
132
|
+
<div class="match">Check it matches the code in your terminal</div>
|
|
133
|
+
<dl class="facts">
|
|
134
|
+
<div><dt>Machine</dt><dd>${E(l.host ?? 'Not given')}</dd></div>
|
|
135
|
+
<div><dt>From</dt><dd>lumniverse try${l.address && l.address !== 'peer' ? `<small>${E(l.address)}</small>` : ''}</dd></div>
|
|
136
|
+
<div><dt>Asked</dt><dd>${ago(l.created_at)}<small>${left(l.expires_at)}</small></dd></div>
|
|
137
|
+
</dl>
|
|
138
|
+
<p class="muted" style="margin-top:14px">About a dozen sample agents (a refund agent, a price setter, a quote bot…) and one sample person will appear in this workspace with their actions, a checker and a research limit. Only demo apps are touched. The run gets a <b>full-access key</b> that the command revokes when it finishes.</p>
|
|
139
|
+
<div class="acts"><button class="btn accent go" id="ok">Run them</button><button class="btn" id="no">Cancel</button></div>
|
|
140
|
+
<div class="warn"><span>⚠︎</span><span><b>Only approve a command you just ran.</b> If someone sent you this link, cancel: approving gives their machine full access to your workspace.</span></div>
|
|
141
|
+
<div class="err" id="err"></div>`;
|
|
142
|
+
$('#ok').onclick = async () => {
|
|
143
|
+
$('#ok').disabled = $('#no').disabled = true;
|
|
144
|
+
$('#ok').textContent = 'Starting…';
|
|
145
|
+
const out = await Shell.write('POST', `/v1/console/device/${encodeURIComponent(l.user_code)}/approve`, {}, { reload: false });
|
|
146
|
+
if (!out) {
|
|
147
|
+
$('#ok').disabled = $('#no').disabled = false;
|
|
148
|
+
$('#ok').textContent = 'Run them';
|
|
149
|
+
$('#err').textContent = Shell.lastError?.message ?? 'It couldn’t be started.';
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
box.innerHTML = `<div class="done">
|
|
153
|
+
<div class="tick"><svg viewBox="0 0 40 40" fill="none"><path d="M10 21l7 7 13-15" stroke="#fff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
|
154
|
+
<h1>Running</h1>
|
|
155
|
+
<p class="muted" style="margin-top:8px">The sample agents are starting in your terminal. Watch them arrive.</p>
|
|
156
|
+
<div class="acts"><a class="btn accent" href="/today">Watch Today</a><a class="btn" href="/flow">Flow</a></div></div>`;
|
|
157
|
+
};
|
|
158
|
+
$('#no').onclick = async () => {
|
|
159
|
+
await Shell.write('POST', `/v1/console/device/${encodeURIComponent(l.user_code)}/deny`, {}, { reload: false });
|
|
160
|
+
ended('Cancelled', 'Nothing ran. The command in your terminal stops.');
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
127
164
|
function connected(l, now) {
|
|
128
165
|
box.innerHTML = `<div class="done">
|
|
129
166
|
<div class="tick"><svg viewBox="0 0 40 40" fill="none"><path d="M10 21l7 7 13-15" stroke="#fff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
|
@@ -388,8 +388,9 @@ async function watchCode() {
|
|
|
388
388
|
if (!S.codeId) {
|
|
389
389
|
if (S.key) return;
|
|
390
390
|
try {
|
|
391
|
-
const r = await Shell.api.get(`/v1/console/device?since=${S.since - 60_000}`);
|
|
391
|
+
const r = await Shell.api.get(`/v1/console/device?since=${S.since - 10 * 60_000}`);
|
|
392
392
|
const l = (r.items ?? []).at(-1);
|
|
393
|
+
if (l) S.login = { at: Date.parse(l.at), project: l.project };
|
|
393
394
|
if (l) tick('ran', `${l.host ? `On ${l.host}` : 'On your machine'}${l.project ? `, in ${l.project}` : ''}, connected in the browser`);
|
|
394
395
|
} catch {}
|
|
395
396
|
return;
|
|
@@ -477,8 +478,15 @@ async function pollOnce() {
|
|
|
477
478
|
const from = encodeURIComponent(new Date(S.since - 120_000).toISOString());
|
|
478
479
|
const actors = (await Shell.api.get(S.baseline ? `/v1/actors?from=${from}&type=AGENT&limit=200` : '/v1/actors?limit=1000')).items ?? [];
|
|
479
480
|
if (!S.baseline) {
|
|
480
|
-
|
|
481
|
-
|
|
481
|
+
// The command may have finished before this page opened: the agent it
|
|
482
|
+
// named after the folder, made around that login, is this one, not an old one.
|
|
483
|
+
const named = S.login?.project && S.login.project.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '');
|
|
484
|
+
const justMade = (a) => named && a.id === named && (!a.created_at || Date.parse(a.created_at) >= S.login.at - 10 * 60_000);
|
|
485
|
+
S.baseline = new Set(actors.filter((a) => !justMade(a)).map((a) => a.id));
|
|
486
|
+
// Its hello check came then too, so look for checks from the login on.
|
|
487
|
+
if (actors.some(justMade)) S.since = Math.min(S.since, S.login.at);
|
|
488
|
+
const old = S.baseline.size;
|
|
489
|
+
$('#listen-meta').innerHTML = old ? `<a class="linky" href="/flow" title="Already set up? See them on Flow">${old} ${old === 1 ? 'agent' : 'agents'} already here</a>` : '';
|
|
482
490
|
}
|
|
483
491
|
const fresh = actors.filter((a) => !S.baseline.has(a.id) && a.type !== 'HUMAN');
|
|
484
492
|
if (fresh.length && !S.done.agent) {
|
|
@@ -489,7 +497,8 @@ async function pollOnce() {
|
|
|
489
497
|
tick('agent', `${S.agentName}${a.runtime ? `, ${a.runtime}` : ''}`);
|
|
490
498
|
}
|
|
491
499
|
if (S.done.agent) {
|
|
492
|
-
const
|
|
500
|
+
const since = encodeURIComponent(new Date(S.since - 120_000).toISOString());
|
|
501
|
+
const rows = ((await Shell.api.get(`/v1/actions?actor_id=${encodeURIComponent(S.agentId)}&from=${since}&limit=20`)).items ?? []).filter((r) => r.actor_id === S.agentId);
|
|
493
502
|
for (const r of rows.slice(0, 5).reverse()) {
|
|
494
503
|
if (S.seen.has(r.id)) continue;
|
|
495
504
|
S.seen.add(r.id);
|
|
@@ -608,7 +617,7 @@ function reset() {
|
|
|
608
617
|
S.timers.forEach(clearTimeout);
|
|
609
618
|
S.timers = [];
|
|
610
619
|
clearTimeout(S.pollTimer);
|
|
611
|
-
Object.assign(S, { madeBy: null, code: null, codeId: null, codeExp: 0, key: null, prefix: null, done: {}, at: {}, feed: [], seen: new Set(), baseline: null, since: Date.now(), agentName: null, agentId: null, cinematic: false });
|
|
620
|
+
Object.assign(S, { login: null, madeBy: null, code: null, codeId: null, codeExp: 0, key: null, prefix: null, done: {}, at: {}, feed: [], seen: new Set(), baseline: null, since: Date.now(), agentName: null, agentId: null, cinematic: false });
|
|
612
621
|
$('#feed').innerHTML = '';
|
|
613
622
|
$('#listen').classList.remove('connected', 'done');
|
|
614
623
|
$('#s1').classList.remove('done');
|
package/assets/deploy/install.sh
CHANGED
|
@@ -148,7 +148,13 @@ case "${1:-help}" in
|
|
|
148
148
|
backup)
|
|
149
149
|
stamp=$(date +%Y%m%d-%H%M%S)
|
|
150
150
|
dc exec -T postgres pg_dump -U lumni -d lumni | gzip > "backups/postgres-$stamp.sql.gz"
|
|
151
|
-
|
|
151
|
+
# A snapshot of the accounts database (copying the live file could catch it
|
|
152
|
+
# mid-write). Consoles from before 0.4 lack the script: copy the file there.
|
|
153
|
+
dc exec -T console sh -c 'if [ -f scripts/backup-db.ts ]; then bun scripts/backup-db.ts /tmp/console-backup.db && cat /tmp/console-backup.db && rm -f /tmp/console-backup.db; else cat /data/console.db; fi' > "backups/console-$stamp.db"
|
|
154
|
+
if ! gzip -t "backups/postgres-$stamp.sql.gz" 2>/dev/null || [ "$(gzip -dc "backups/postgres-$stamp.sql.gz" | head -c 1 | wc -c)" -eq 0 ] || [ ! -s "backups/console-$stamp.db" ]; then
|
|
155
|
+
rm -f "backups/postgres-$stamp.sql.gz" "backups/console-$stamp.db"
|
|
156
|
+
echo "The backup failed; nothing was kept." >&2; exit 1
|
|
157
|
+
fi
|
|
152
158
|
echo "Saved backups/postgres-$stamp.sql.gz and backups/console-$stamp.db" ;;
|
|
153
159
|
url) url ;;
|
|
154
160
|
*) echo "./lumni up | down | update | status | logs [service] | backup | url" ;;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Pretend apps the agents change: Stripe, Salesforce, a billing system,
|
|
2
|
+
// Zendesk and a web-research API. They live in memory, nothing here calls a
|
|
3
|
+
// real service, and they write down every problem a real app would have, so
|
|
4
|
+
// you can see what Lumni prevented (or, in watch-only mode, didn't).
|
|
5
|
+
|
|
6
|
+
import { writeFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
const now = () => new Date().toISOString();
|
|
10
|
+
/** $42, or $40.85 when there are cents. */
|
|
11
|
+
export const usd = (n) => `$${Number.isInteger(n) ? n.toLocaleString('en-US') : n.toFixed(2)}`;
|
|
12
|
+
|
|
13
|
+
/** `kept`: what a previous run left (the price list), so versions keep moving forward. */
|
|
14
|
+
export function createApps(kept = {}) {
|
|
15
|
+
const state = {
|
|
16
|
+
stripe: { refunds: [], charges: [] },
|
|
17
|
+
salesforce: { priceList: kept.priceList ?? { name: 'Enterprise', seatPrice: 42, version: 1, history: [] }, accounts: { Globex: { edits: [] } }, opportunities: {} },
|
|
18
|
+
billing: { subscriptions: { sub_umbrella: { customer: 'Umbrella', plan: 'Pro', status: 'active' }, sub_initech: { customer: 'Initech', plan: 'Pro', status: 'active' } } },
|
|
19
|
+
zendesk: { replies: [], closed: [] },
|
|
20
|
+
quotes: [],
|
|
21
|
+
research: { calls: 0, maxAtOnce: 0, inFlight: 0 },
|
|
22
|
+
/** Things that went wrong, in plain words. */
|
|
23
|
+
problems: [],
|
|
24
|
+
};
|
|
25
|
+
const problem = (scenario, text) => state.problems.push({ scenario, text, at: now() });
|
|
26
|
+
let editing = 0;
|
|
27
|
+
|
|
28
|
+
const customers = () => ({ sub_umbrella: { customer: 'Umbrella', plan: 'Pro', status: 'active' }, sub_initech: { customer: 'Initech', plan: 'Pro', status: 'active' } });
|
|
29
|
+
state.billing.subscriptions = customers();
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
state,
|
|
33
|
+
|
|
34
|
+
/** Each round, the customers are back on Pro and active. */
|
|
35
|
+
resetCustomers() {
|
|
36
|
+
state.billing.subscriptions = customers();
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
stripe: {
|
|
40
|
+
/** `drop`: Stripe says yes and loses it (the false-completion case). */
|
|
41
|
+
async refund({ charge, amount, by, drop = false, ms = 150, scenario }) {
|
|
42
|
+
await pause(ms);
|
|
43
|
+
const id = `re_${state.stripe.refunds.length + 1}`;
|
|
44
|
+
if (drop) return { id, charge, amount, status: 'succeeded' };
|
|
45
|
+
if (state.stripe.refunds.some((r) => r.charge === charge)) problem(scenario, `${charge} was refunded twice`);
|
|
46
|
+
state.stripe.refunds.push({ id, charge, amount, by, at: now() });
|
|
47
|
+
return { id, charge, amount, status: 'succeeded' };
|
|
48
|
+
},
|
|
49
|
+
refundsFor: (charge) => state.stripe.refunds.filter((r) => r.charge === charge),
|
|
50
|
+
async charge({ customer, amount }) {
|
|
51
|
+
await pause(150);
|
|
52
|
+
const c = { id: `ch_${state.stripe.charges.length + 1}`, customer, amount, at: now() };
|
|
53
|
+
state.stripe.charges.push(c);
|
|
54
|
+
return c;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
salesforce: {
|
|
59
|
+
readPriceList() {
|
|
60
|
+
const p = state.salesforce.priceList;
|
|
61
|
+
return { seatPrice: p.seatPrice, version: `v${p.version}` };
|
|
62
|
+
},
|
|
63
|
+
/** `read`: the version the writer based this on; writing over a newer one loses a change. */
|
|
64
|
+
async setSeatPrice(price, by, read, scenario) {
|
|
65
|
+
await pause(120);
|
|
66
|
+
const p = state.salesforce.priceList;
|
|
67
|
+
if (read && read !== `v${p.version}`) problem(scenario, `${by} wrote ${usd(price)} over a price list that changed after it read it`);
|
|
68
|
+
p.history.push({ from: p.seatPrice, to: price, by, version: p.version + 1, at: now() });
|
|
69
|
+
p.seatPrice = price;
|
|
70
|
+
p.version += 1;
|
|
71
|
+
return { seatPrice: price, version: `v${p.version}` };
|
|
72
|
+
},
|
|
73
|
+
async editAccount(account, field, value, by, scenario) {
|
|
74
|
+
editing++;
|
|
75
|
+
if (editing === 2) problem(scenario, `Two agents edited ${account} at the same time`);
|
|
76
|
+
await pause(300);
|
|
77
|
+
state.salesforce.accounts[account] ??= { edits: [] };
|
|
78
|
+
state.salesforce.accounts[account].edits.push({ field, value, by, at: now() });
|
|
79
|
+
editing--;
|
|
80
|
+
},
|
|
81
|
+
/** `fail`: Salesforce is down (the partial-completion case). */
|
|
82
|
+
async closeOpportunity(id, { fail = false } = {}) {
|
|
83
|
+
await pause(150);
|
|
84
|
+
if (fail) throw new Error('Salesforce answered 503');
|
|
85
|
+
state.salesforce.opportunities[id] = { stage: 'Closed Won', at: now() };
|
|
86
|
+
return { id, stage: 'Closed Won' };
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
billing: {
|
|
91
|
+
subscription: (id) => ({ ...state.billing.subscriptions[id] }),
|
|
92
|
+
async upgrade(id, plan, scenario) {
|
|
93
|
+
await pause(150);
|
|
94
|
+
const s = state.billing.subscriptions[id];
|
|
95
|
+
if (s.status !== 'active') problem(scenario, `${s.customer} was upgraded to ${plan} while cancelling`);
|
|
96
|
+
s.plan = plan;
|
|
97
|
+
return { ...s };
|
|
98
|
+
},
|
|
99
|
+
async cancel(id, ms = 900) {
|
|
100
|
+
const s = state.billing.subscriptions[id];
|
|
101
|
+
s.status = 'cancelling';
|
|
102
|
+
await pause(ms);
|
|
103
|
+
s.status = 'cancelled';
|
|
104
|
+
return { ...s };
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
zendesk: {
|
|
109
|
+
async reply(ticket, text) {
|
|
110
|
+
await pause(80);
|
|
111
|
+
state.zendesk.replies.push({ ticket, text, at: now() });
|
|
112
|
+
},
|
|
113
|
+
async close(ticket, { refundFor, scenario } = {}) {
|
|
114
|
+
await pause(80);
|
|
115
|
+
if (refundFor && !state.stripe.refunds.some((r) => r.charge === refundFor)) problem(scenario, `Ticket #${ticket} was closed before its refund landed`);
|
|
116
|
+
state.zendesk.closed.push({ ticket, at: now() });
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
quotes: {
|
|
121
|
+
async send(account, kind, by, scenario) {
|
|
122
|
+
await pause(100);
|
|
123
|
+
if (state.quotes.some((q) => q.account === account && q.kind === kind)) problem(scenario, `${account} got the same ${kind} quote twice`);
|
|
124
|
+
state.quotes.push({ account, kind, by, at: now() });
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
research: {
|
|
129
|
+
async fetch(topic, cap, scenario) {
|
|
130
|
+
const r = state.research;
|
|
131
|
+
r.calls++;
|
|
132
|
+
r.inFlight++;
|
|
133
|
+
r.maxAtOnce = Math.max(r.maxAtOnce, r.inFlight);
|
|
134
|
+
if (r.inFlight === (cap ?? 5) + 1) problem(scenario, `More than ${cap ?? 5} research calls ran at once`);
|
|
135
|
+
await pause(500);
|
|
136
|
+
r.inFlight--;
|
|
137
|
+
return { topic, pages: 3 };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
save(file) {
|
|
142
|
+
writeFileSync(file, JSON.stringify(state, null, 2) + '\n');
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// How every agent in this fleet talks to Lumni.
|
|
2
|
+
//
|
|
3
|
+
// The rule from docs/sdk.md, in one place (`guard`):
|
|
4
|
+
// ALLOW, WARN and every WOULD_* go ahead (WOULD_* means watch-only mode)
|
|
5
|
+
// WAIT wait retry_after_ms, then ask again
|
|
6
|
+
// REPLAN read the thing again, then ask again;
|
|
7
|
+
// or stop, when the work itself was superseded
|
|
8
|
+
// REQUIRE_APPROVAL wait for a person, then ask again
|
|
9
|
+
// DEDUPLICATE it's already done: don't do it twice
|
|
10
|
+
// REJECT don't do it
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
/** The published SDK when it's installed; until then, the one in this repository. */
|
|
20
|
+
async function loadSdk() {
|
|
21
|
+
try {
|
|
22
|
+
return await import('@lumniverse/sdk');
|
|
23
|
+
} catch {
|
|
24
|
+
return import('../../sdk/typescript/dist/index.js');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const sdk = await loadSdk();
|
|
28
|
+
export const { LumniClient, shouldExecute } = sdk;
|
|
29
|
+
|
|
30
|
+
/** KEY=value lines from .env, which `lumni init` writes. The environment wins. */
|
|
31
|
+
export function settings(env = process.env, dir = here) {
|
|
32
|
+
const file = join(dir, '.env');
|
|
33
|
+
const dot = {};
|
|
34
|
+
if (existsSync(file)) {
|
|
35
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
36
|
+
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
|
|
37
|
+
if (m) dot[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
url: (env.LUMNI_URL ?? dot.LUMNI_URL ?? 'http://localhost:8080').replace(/\/+$/, ''),
|
|
42
|
+
key: env.LUMNI_API_KEY ?? dot.LUMNI_API_KEY ?? '',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
export const shortId = () => randomUUID().slice(0, 8);
|
|
48
|
+
|
|
49
|
+
/** Why, in plain words. */
|
|
50
|
+
const WHY = {
|
|
51
|
+
LEASE_BUSY: 'another agent is working on it',
|
|
52
|
+
CONFLICTING_ACTIVE_MUTATION: 'another agent is changing it',
|
|
53
|
+
WRITE_WRITE_CONFLICT: 'another agent is changing it',
|
|
54
|
+
DELETE_WRITE_CONFLICT: 'another agent is deleting it',
|
|
55
|
+
DOMAIN_OPERATION_CONFLICT: 'it clashes with work already under way',
|
|
56
|
+
CONCURRENCY_LIMIT: 'as many as you allow are already running',
|
|
57
|
+
RATE_LIMITED: 'it hit the rate you set',
|
|
58
|
+
BUDGET_PACING: 'it’s spending ahead of the daily limit',
|
|
59
|
+
STALE_OBSERVATION: 'what it read is out of date',
|
|
60
|
+
VERSION_SOURCE_CHANGED: 'it read the thing from somewhere else',
|
|
61
|
+
EXACT_EFFECT_ALREADY_COMMITTED: 'it was already done',
|
|
62
|
+
EXACT_DUPLICATE_ACTIVE: 'the same thing is already under way',
|
|
63
|
+
DEPENDENCY_INCOMPLETE: 'what it depends on isn’t finished',
|
|
64
|
+
ACTION_RESULT_UNKNOWN: 'an earlier try may have done it; Lumni is finding out',
|
|
65
|
+
WORK_SUPERSEDED: 'the work it was doing was superseded',
|
|
66
|
+
WORK_CANCELLED: 'the work it was doing was cancelled',
|
|
67
|
+
ACTION_SUPERSEDED: 'this step was superseded',
|
|
68
|
+
ACTOR_PAUSED: 'you paused this agent',
|
|
69
|
+
POLICY_REJECTED: 'a rule doesn’t allow it',
|
|
70
|
+
APPROVAL_DENIED: 'a person said no',
|
|
71
|
+
APPROVAL_EXPIRED: 'nobody answered in time',
|
|
72
|
+
};
|
|
73
|
+
export const why = (code) => WHY[code] ?? String(code ?? 'no reason given').toLowerCase().replace(/_/g, ' ');
|
|
74
|
+
|
|
75
|
+
const WOULD = {
|
|
76
|
+
WAIT: 'would have made it wait',
|
|
77
|
+
REPLAN: 'would have sent it back',
|
|
78
|
+
DEDUPLICATE: 'would have stopped it',
|
|
79
|
+
REQUIRE_APPROVAL: 'would have asked a person first',
|
|
80
|
+
REJECT: 'would have refused it',
|
|
81
|
+
};
|
|
82
|
+
/** REPLAN reasons that mean the work is no longer wanted: stop, don't re-read. */
|
|
83
|
+
const GONE = new Set(['WORK_SUPERSEDED', 'WORK_CANCELLED', 'ACTION_SUPERSEDED']);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check an action with Lumni, and do it only when Lumni says so.
|
|
87
|
+
*
|
|
88
|
+
* input what the agent wants to do (actor_id, work_id, operation, resources, …)
|
|
89
|
+
* act the side effect, run once Lumni allows it; its return value is reported as the result
|
|
90
|
+
* onReplan re-read what went stale; returns the new input
|
|
91
|
+
* seen a Set that collects each decision ("ALLOW", "WAIT:LEASE_BUSY", …)
|
|
92
|
+
*
|
|
93
|
+
* Waits (WAIT) are bounded by time, since a queue or a dependency can take a
|
|
94
|
+
* while; everything else by a count.
|
|
95
|
+
*
|
|
96
|
+
* Resolves with { done, skipped?, refused?, superseded?, output?, decision }.
|
|
97
|
+
*/
|
|
98
|
+
export async function guard(lumni, input, act, { say = () => {}, onReplan, seen, approvalWaitMs = 120_000, pollMs = 3000, waitBudgetMs = 90_000, maxTries = 8 } = {}) {
|
|
99
|
+
let ask = { ...input, action_id: input.action_id ?? `${input.actor_id}-${shortId()}` };
|
|
100
|
+
const until = Date.now() + waitBudgetMs;
|
|
101
|
+
let tries = 0;
|
|
102
|
+
let lastWait = '';
|
|
103
|
+
while (tries < maxTries) {
|
|
104
|
+
const d = await lumni.checkAction(ask);
|
|
105
|
+
const raw = String(d.decision);
|
|
106
|
+
const kind = raw.replace(/^WOULD_/, '');
|
|
107
|
+
seen?.add(raw === 'ALLOW' ? 'ALLOW' : `${raw}:${d.reason_code}`);
|
|
108
|
+
|
|
109
|
+
if (shouldExecute(d)) {
|
|
110
|
+
if (raw.startsWith('WOULD_') && WOULD[kind]) say(`Lumni ${WOULD[kind]}: ${why(d.reason_code)} (watch-only, so it goes ahead)`);
|
|
111
|
+
if (kind === 'WARN') say(`Lumni warns: ${why(d.reason_code)}`);
|
|
112
|
+
return { done: true, output: await perform(lumni, d, act, say), decision: d };
|
|
113
|
+
}
|
|
114
|
+
if (kind === 'DEDUPLICATE') {
|
|
115
|
+
say(`Lumni stopped it: ${why(d.reason_code || 'EXACT_EFFECT_ALREADY_COMMITTED')}`);
|
|
116
|
+
return { done: false, skipped: true, decision: d };
|
|
117
|
+
}
|
|
118
|
+
if (kind === 'WAIT') {
|
|
119
|
+
if (Date.now() > until) break;
|
|
120
|
+
// Say it once per reason, not on every retry.
|
|
121
|
+
if (lastWait !== d.reason_code) say(`Lumni says wait: ${why(d.reason_code)}`);
|
|
122
|
+
lastWait = d.reason_code;
|
|
123
|
+
await sleep(Math.min(5_000, Math.max(300, d.retry_after_ms ?? 1000)));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
tries++;
|
|
127
|
+
if (kind === 'REPLAN') {
|
|
128
|
+
if (GONE.has(d.reason_code)) {
|
|
129
|
+
say(`Lumni stopped it: ${why(d.reason_code)}`);
|
|
130
|
+
return { done: false, superseded: true, decision: d };
|
|
131
|
+
}
|
|
132
|
+
say(`Lumni sent it back: ${why(d.reason_code)}. Reading again`);
|
|
133
|
+
if (!onReplan) return { done: false, refused: true, decision: d };
|
|
134
|
+
ask = { ...(await onReplan(ask)), action_id: `${input.actor_id}-${shortId()}` };
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (kind === 'REQUIRE_APPROVAL') {
|
|
138
|
+
say('Waiting for a person to decide (open Needs you in the console)');
|
|
139
|
+
const status = await waitForPerson(lumni, d.approval_id, approvalWaitMs, pollMs);
|
|
140
|
+
if (status !== 'APPROVED') {
|
|
141
|
+
say(status === 'DENIED' ? 'A person said no. Not doing it' : 'Nobody decided in time. Not doing it');
|
|
142
|
+
return { done: false, refused: true, decision: d };
|
|
143
|
+
}
|
|
144
|
+
say('A person approved it');
|
|
145
|
+
continue; // the same action_id, checked again, is now allowed
|
|
146
|
+
}
|
|
147
|
+
say(`Lumni refused it: ${why(d.reason_code)}`);
|
|
148
|
+
return { done: false, refused: true, decision: d };
|
|
149
|
+
}
|
|
150
|
+
say('Gave up waiting');
|
|
151
|
+
return { done: false, refused: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function waitForPerson(lumni, approvalId, waitMs, pollMs) {
|
|
155
|
+
if (!approvalId) return 'UNKNOWN';
|
|
156
|
+
const until = Date.now() + waitMs;
|
|
157
|
+
while (Date.now() < until) {
|
|
158
|
+
const a = await lumni.approval(approvalId).catch(() => null);
|
|
159
|
+
const status = String(a?.status ?? 'PENDING').toUpperCase();
|
|
160
|
+
if (status !== 'PENDING') return status;
|
|
161
|
+
await sleep(pollMs);
|
|
162
|
+
}
|
|
163
|
+
return 'TIMED_OUT';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Start, do it, and say how it went. A thrown error is reported as FAILED. */
|
|
167
|
+
async function perform(lumni, d, act, say) {
|
|
168
|
+
const lease = d.lease_token;
|
|
169
|
+
await lumni.startAction(d.action_id, lease).catch((e) => say(`(couldn’t mark it started: ${e.message})`));
|
|
170
|
+
try {
|
|
171
|
+
const output = await act(d);
|
|
172
|
+
await lumni.reportResult(d.action_id, { status: 'SUCCEEDED', lease_token: lease, output: output ?? {} }).catch((e) => say(`(couldn’t report the result: ${e.message})`));
|
|
173
|
+
return output;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
await lumni.reportResult(d.action_id, { status: 'FAILED', lease_token: lease, output: { error: e.message } }).catch(() => {});
|
|
176
|
+
return { failed: e.message };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Lumni's id for a thing, so its version can be recorded. */
|
|
181
|
+
export async function resourceId(lumni, ref) {
|
|
182
|
+
const r = await lumni.request('POST', '/v1/resources/resolve', ref, true);
|
|
183
|
+
return r.id;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What a model call cost, so Lumni's FinOps can tell useful spend from waste. */
|
|
187
|
+
export function spend(lumni, { actor, work, action, usd, model = 'gpt-4.1', tokens = 0 }) {
|
|
188
|
+
return lumni.recordCost({
|
|
189
|
+
actor_id: actor, work_id: work, ...(action ? { action_id: action } : {}),
|
|
190
|
+
provider: 'openai', model, cost_type: 'MODEL', input_units: tokens, output_units: Math.round(tokens / 4),
|
|
191
|
+
amount_micros_usd: Math.round(usd * 1e6),
|
|
192
|
+
}).catch(() => {});
|
|
193
|
+
}
|