@bongos/core 1.19.710 → 1.19.711
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/.bongos-core.json +59 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +16 -0
- package/clients/bongos-client/index.cjs +16 -0
- package/clients/bongos-client/index.d.ts +24 -0
- package/clients/bongos-client/index.mjs +16 -0
- package/docs/api/openapi.json +506 -3
- package/docs/api-reference.md +14 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/agents/lib/answer-hold.js +91 -0
- package/modules/agents/lib/authoring.js +155 -0
- package/modules/agents/lib/fire-budget.js +109 -0
- package/modules/agents/lib/gate.js +64 -0
- package/modules/agents/routes/agents.js +494 -0
- package/modules/agents/spawn.js +12 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +16 -0
- package/modules/government/catalog.js +10 -0
- package/modules/government/migrations/government_013_agent_atoms.sql +66 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/agent-invoke.js +5 -27
- package/src/module-api.js +1 -1
- package/tests/agents_authoring.mjs +306 -0
- package/tests/agents_routes.mjs +76 -15
- package/tests/agents_write_routes.mjs +461 -0
|
@@ -35,14 +35,62 @@
|
|
|
35
35
|
// Same idioms as modules/copy-desk/routes/copy-desk.js: doorway-only requires,
|
|
36
36
|
// factory export mounted from module.json `contributes.routes`, res.fail codes.
|
|
37
37
|
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// THE WRITE SURFACE (task 1002493, Phase 2a) was added below the reads above.
|
|
40
|
+
//
|
|
41
|
+
// A Metic+ builder can now create, edit, arm, disarm, delete and fire an agent
|
|
42
|
+
// with no Archon file edit — and ENFORCEMENT IS DB-SIDE, so nothing a caller
|
|
43
|
+
// writes grants anything. The rules it turns on live in ../lib/authoring.js,
|
|
44
|
+
// beside the reasoning for each; the gate a fire passes lives in ../lib/gate.js
|
|
45
|
+
// so the CLI and this route cannot drift apart. Read those two before editing
|
|
46
|
+
// anything here: this file is the HTTP shell around decisions made there.
|
|
47
|
+
//
|
|
48
|
+
// The verbs and their gates:
|
|
49
|
+
// POST /agents agent.author create (source='db', never armed)
|
|
50
|
+
// PATCH /agents/:name agent.author edit IN PLACE — never a second row
|
|
51
|
+
// DELETE /agents/:name agent.author remove a db-authored definition
|
|
52
|
+
// POST /agents/:name/enable agent.arm arm — the operator act rule 4 reserves
|
|
53
|
+
// POST /agents/:name/disable agent.arm disarm — and that stops dispatch
|
|
54
|
+
// POST /agents/:name/invoke requireBuilder fire on demand (202 + run id)
|
|
55
|
+
// GET /agent-runs/:id requireBuilder what one fire did, and its answer
|
|
56
|
+
//
|
|
57
|
+
// THE WRITES GATE ON AUTHORITY ATOMS, NOT A RANK (ADR 0151 / BV1.R105 — and
|
|
58
|
+
// tests/government_require_permission.mjs fails the build for a modules/*/routes
|
|
59
|
+
// gate that still spells requireRank). Both atoms seed at the Metic floor, so
|
|
60
|
+
// this admits exactly who `requireRank('metic', 'archon')` would have; what it
|
|
61
|
+
// adds is that an instance can delegate them from the Government tab without
|
|
62
|
+
// moving anyone's rank.
|
|
63
|
+
//
|
|
64
|
+
// TWO atoms rather than one, because the two acts differ in consequence.
|
|
65
|
+
// `agent.author` writes a definition; `agent.arm` turns a piece of text into
|
|
66
|
+
// something that spends money and reads the repo — and arming is the only write
|
|
67
|
+
// that works on a committed, file-sourced definition, so that atom IS the act
|
|
68
|
+
// that lets a committed agent run at all.
|
|
69
|
+
//
|
|
70
|
+
// WHY /agent-runs AND NOT /agents/runs/:id. `runs` is a legal agent name
|
|
71
|
+
// (NAME_RE admits it), so nesting the ledger under /agents would put a reserved
|
|
72
|
+
// word in a namespace the validator does not reserve. A sibling path costs one
|
|
73
|
+
// line of prose and closes the question permanently.
|
|
74
|
+
|
|
38
75
|
'use strict';
|
|
39
76
|
|
|
40
77
|
const express = require('express');
|
|
41
78
|
const api = require('../../../src/module-api');
|
|
42
79
|
const validate = require('../lib/validate');
|
|
80
|
+
const authoring = require('../lib/authoring');
|
|
81
|
+
const { gateFor } = require('../lib/gate');
|
|
82
|
+
const { createAnswerHold } = require('../lib/answer-hold');
|
|
83
|
+
const { createFireBudget } = require('../lib/fire-budget');
|
|
84
|
+
const { spawnAgent } = require('../spawn');
|
|
43
85
|
|
|
44
86
|
const log = api.logger('agents');
|
|
45
87
|
|
|
88
|
+
// ONE instance for the module, deliberately NOT per-router: a ceiling that splits
|
|
89
|
+
// when a second router is built is not a ceiling. The answer hold below is
|
|
90
|
+
// per-router for the opposite reason — it is a delivery buffer, and a split one
|
|
91
|
+
// only means an answer is collected where it was produced.
|
|
92
|
+
const fireBudget = createFireBudget();
|
|
93
|
+
|
|
46
94
|
// The columns the serializer needs. Spelled out rather than `SELECT *` so that a
|
|
47
95
|
// column added later (an author's email, a raw credential) cannot reach a
|
|
48
96
|
// response by default — the read surface widens only when someone edits this
|
|
@@ -104,8 +152,119 @@ function serializeDefinition(row, { includePersona = false } = {}) {
|
|
|
104
152
|
return out;
|
|
105
153
|
}
|
|
106
154
|
|
|
155
|
+
// PURE. One ledger row → the wire shape. `output_ref` is served as the pointer
|
|
156
|
+
// it is; the answer, when one is still being held, arrives beside it under its
|
|
157
|
+
// own key so nobody can mistake the row for the place the text lives.
|
|
158
|
+
function serializeRun(row, { answer = null } = {}) {
|
|
159
|
+
if (!row) return null;
|
|
160
|
+
return {
|
|
161
|
+
id: String(row.id),
|
|
162
|
+
agent: row.agent_name,
|
|
163
|
+
trigger: { type: row.trigger_type, ref: row.trigger_ref || null },
|
|
164
|
+
gate: { decision: row.gate_decision, reason: row.gate_reason || null },
|
|
165
|
+
status: row.status,
|
|
166
|
+
error_code: row.error_code || null,
|
|
167
|
+
model: row.model || null,
|
|
168
|
+
cost_usd: row.cost_usd === null || row.cost_usd === undefined ? null : Number(row.cost_usd),
|
|
169
|
+
duration_ms: row.duration_ms === null || row.duration_ms === undefined ? null : Number(row.duration_ms),
|
|
170
|
+
output_ref: row.output_ref || null,
|
|
171
|
+
created_at: row.created_at,
|
|
172
|
+
updated_at: row.updated_at,
|
|
173
|
+
// null means one of three histories with one remedy: never held, expired, or
|
|
174
|
+
// evicted. The row above is where a caller finds out which.
|
|
175
|
+
answer,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The scope context every write is judged against, read the way every other
|
|
180
|
+
// enforcer reads it: the module roster and the protected-surface registry, both
|
|
181
|
+
// synchronously from the checkout, no DB and no HTTP (ADR 0043). Resolved per
|
|
182
|
+
// request rather than cached so a registry edit takes effect on the next write
|
|
183
|
+
// instead of at the next restart — the same no-caching property the rank check
|
|
184
|
+
// has, and for the same reason.
|
|
185
|
+
function scopeContext() {
|
|
186
|
+
const scopeMap = api.moduleScopeMap;
|
|
187
|
+
const permissionPaths = api.permissionPathCheck;
|
|
188
|
+
return {
|
|
189
|
+
allowedModules: scopeMap.moduleKeys(),
|
|
190
|
+
protectedModules: scopeMap.protectedModules(),
|
|
191
|
+
isProtectedPath: (p) => permissionPaths.surfaceFor(p) !== null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The body a caller may send. Bounds mirror the schema's CHECKs, and `strict`
|
|
196
|
+
// turns a key the schema does not name into a 400 — so `author_rank` in a body
|
|
197
|
+
// is a loud refusal rather than a silent drop (authoring rule 1).
|
|
198
|
+
function writeSchema({ nameRequired }) {
|
|
199
|
+
return {
|
|
200
|
+
name: { required: nameRequired, type: 'string', maxLength: validate.NAME_MAX, minLength: 1 },
|
|
201
|
+
title: { type: 'string', maxLength: api.LIMITS.TITLE },
|
|
202
|
+
persona: { required: nameRequired, type: 'string', maxLength: 32768, minLength: 1 },
|
|
203
|
+
trigger_type: { required: nameRequired, type: 'string', maxLength: 32 },
|
|
204
|
+
trigger_spec: { type: 'object' },
|
|
205
|
+
model_tier: { type: 'string', maxLength: 32 },
|
|
206
|
+
scope_modules: { type: 'array', itemsType: 'string', maxItems: 64 },
|
|
207
|
+
scope_paths: { type: 'array', itemsType: 'string', maxItems: 64 },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Refuse a body that names a server-owned field, BY NAME and with the reason.
|
|
212
|
+
// Runs before the schema check so the answer is "you may not set author_rank and
|
|
213
|
+
// here is why", not "unknown_field".
|
|
214
|
+
function refusedServerOwned(req, res) {
|
|
215
|
+
const forbidden = authoring.forbiddenFields(req.body);
|
|
216
|
+
if (forbidden.length === 0) return false;
|
|
217
|
+
res.fail('server_owned_field', { status: 400, message: `these fields are decided server-side: ${forbidden.map((f) => `${f.field} (${f.reason})`).join('; ')}` });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Validate a whole candidate against the author's LIVE rank, then apply the
|
|
222
|
+
// stricter DB-authoring wall on top (authoring rule 3). Returns the normalized
|
|
223
|
+
// row, or responds and returns null.
|
|
224
|
+
function validatedOrRejected(candidate, req, res) {
|
|
225
|
+
const ctx = scopeContext();
|
|
226
|
+
const verdict = validate.validateAgentDefinition(candidate, {
|
|
227
|
+
// Rule 2: the rank is the caller's LIVE rank, so a declaration can never
|
|
228
|
+
// exceed the authority behind it.
|
|
229
|
+
authorRank: req.builder.rank,
|
|
230
|
+
allowedModules: ctx.allowedModules,
|
|
231
|
+
protectedModules: ctx.protectedModules,
|
|
232
|
+
isProtectedPath: ctx.isProtectedPath,
|
|
233
|
+
});
|
|
234
|
+
if (!verdict.ok) {
|
|
235
|
+
res.fail('agent_definition_invalid', { status: 400, message: verdict.errors.map((e) => e.message).join(' · ') });
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const hits = authoring.protectedScopeHits(verdict.value, ctx);
|
|
239
|
+
if (hits.protected) {
|
|
240
|
+
const named = [...hits.modules, ...hits.paths].map((s) => JSON.stringify(s)).join(', ');
|
|
241
|
+
res.fail('scope_reaches_protected_surface', {
|
|
242
|
+
status: 403,
|
|
243
|
+
message: `scope: ${named} reaches a protected surface. A definition authored over HTTP may not, at any rank — a committed one reaches the registry through a branch, a grader panel and CI, and this one reaches it through a single call. Commit it to .claude/agents/ instead.`,
|
|
244
|
+
});
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return verdict.value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Fire an agent without waiting for it. Returns the run id as soon as the ledger
|
|
251
|
+
// row exists, plus the promise that settles when the fire does — see the
|
|
252
|
+
// onLedgered note in ../spawn.js for why the route does not insert its own row.
|
|
253
|
+
function fireInBackground(args, deps) {
|
|
254
|
+
let announce;
|
|
255
|
+
const ledgered = new Promise((resolve) => { announce = resolve; });
|
|
256
|
+
const done = spawnAgent(args, { ...deps, onLedgered: (id) => announce(id) });
|
|
257
|
+
// A ledger write that FAILED never calls onLedgered, so the await above would
|
|
258
|
+
// hang forever without this. Resolving twice is a no-op.
|
|
259
|
+
done.then((fired) => announce(fired.runId ?? null), () => announce(null));
|
|
260
|
+
return { ledgered, done };
|
|
261
|
+
}
|
|
262
|
+
|
|
107
263
|
module.exports = function agentsRoutes() {
|
|
108
264
|
const router = express.Router();
|
|
265
|
+
// Per-router, so a test gets a fresh counter and two mounted instances never
|
|
266
|
+
// hand each other's answers out.
|
|
267
|
+
const hold = createAnswerHold();
|
|
109
268
|
|
|
110
269
|
// GET /agents — the whole registry. Unpaginated on purpose: agents are
|
|
111
270
|
// instance CONFIGURATION reconciled from committed files, so the row count is
|
|
@@ -169,9 +328,344 @@ module.exports = function agentsRoutes() {
|
|
|
169
328
|
}
|
|
170
329
|
});
|
|
171
330
|
|
|
331
|
+
// POST /agents — author a definition against the DB, no file edit.
|
|
332
|
+
//
|
|
333
|
+
// It lands DISARMED, always. Creating and arming are separate acts with
|
|
334
|
+
// separate audit lines, and a create that armed would let one call both invent
|
|
335
|
+
// an agent and switch it on — the same reasoning agents-sync rule 4 applies to
|
|
336
|
+
// a commit, applied to an HTTP request.
|
|
337
|
+
router.post('/agents', api.requireBuilder, api.requirePermission('agent.author'), async (req, res) => {
|
|
338
|
+
if (refusedServerOwned(req, res)) return undefined;
|
|
339
|
+
if (api.validateOrRespond(req, res, writeSchema({ nameRequired: true }), { strict: true })) return undefined;
|
|
340
|
+
const value = validatedOrRejected(req.body, req, res);
|
|
341
|
+
if (!value) return undefined;
|
|
342
|
+
try {
|
|
343
|
+
const { rows } = await api.pool.query(
|
|
344
|
+
`INSERT INTO agents_definitions
|
|
345
|
+
(name, title, persona, trigger_type, trigger_spec, model_tier,
|
|
346
|
+
scope_modules, scope_paths, source, provenance, author_rank,
|
|
347
|
+
author_builder_id, enabled)
|
|
348
|
+
VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,$8,'db','instance',$9,$10,false)
|
|
349
|
+
RETURNING ${COLUMNS}, persona`,
|
|
350
|
+
[value.name, value.title, value.persona, value.trigger_type,
|
|
351
|
+
JSON.stringify(value.trigger_spec), value.model_tier,
|
|
352
|
+
value.scope_modules, value.scope_paths, req.builder.rank, req.builder.id]
|
|
353
|
+
);
|
|
354
|
+
return res.status(201).json({ ok: true, agent: serializeDefinition(rows[0], { includePersona: true }) });
|
|
355
|
+
} catch (err) {
|
|
356
|
+
// agents_definitions_name_uniq. A name collision is the caller's answer,
|
|
357
|
+
// not a server error — and it is the whole reason PATCH exists.
|
|
358
|
+
if (err && err.code === '23505') {
|
|
359
|
+
return res.fail('agent_name_taken', { status: 409, message: `an agent named ${JSON.stringify(req.body.name)} already exists — PATCH it instead of creating a second one` });
|
|
360
|
+
}
|
|
361
|
+
log.error({ err }, 'POST /agents failed');
|
|
362
|
+
if (!res.headersSent) return res.fail('agent_create_failed', 500);
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// PATCH /agents/:name — edit IN PLACE. One row per name, before and after.
|
|
368
|
+
//
|
|
369
|
+
// The patch is merged over the stored row and the WHOLE definition is
|
|
370
|
+
// re-validated (authoring.mergeForValidation says why), against the EDITOR's
|
|
371
|
+
// live rank — which re-stamps author_rank downward if a lower-ranked builder
|
|
372
|
+
// edits, and re-runs the scope wall against that new rank.
|
|
373
|
+
router.patch('/agents/:name', api.requireBuilder, api.requirePermission('agent.author'), async (req, res) => {
|
|
374
|
+
const name = String(req.params.name || '');
|
|
375
|
+
if (!validate.NAME_RE.test(name) || name.length > validate.NAME_MAX) return res.fail('invalid_agent_name', 400);
|
|
376
|
+
if (refusedServerOwned(req, res)) return undefined;
|
|
377
|
+
// A rename would make the URL and the row disagree about which agent this
|
|
378
|
+
// is, and `name` is the identity every other surface keys on.
|
|
379
|
+
if (Object.prototype.hasOwnProperty.call(req.body || {}, 'name')) {
|
|
380
|
+
return res.fail('agent_rename_unsupported', { status: 400, message: 'the name is the identity — delete and recreate rather than renaming' });
|
|
381
|
+
}
|
|
382
|
+
if (api.validateOrRespond(req, res, writeSchema({ nameRequired: false }), { strict: true })) return undefined;
|
|
383
|
+
try {
|
|
384
|
+
const { rows: existing } = await api.pool.query(
|
|
385
|
+
`SELECT ${COLUMNS}, persona FROM agents_definitions WHERE name = $1`, [name]
|
|
386
|
+
);
|
|
387
|
+
if (!existing.length) return res.fail('agent_not_found', 404);
|
|
388
|
+
const writable = authoring.writableByApi(existing[0]);
|
|
389
|
+
if (!writable.ok) return res.fail('agent_owned_by_file', { status: 409, message: writable.reason });
|
|
390
|
+
|
|
391
|
+
const merged = authoring.mergeForValidation(existing[0], req.body);
|
|
392
|
+
merged.name = name;
|
|
393
|
+
const value = validatedOrRejected(merged, req, res);
|
|
394
|
+
if (!value) return undefined;
|
|
395
|
+
|
|
396
|
+
const { rows } = await api.pool.query(
|
|
397
|
+
`UPDATE agents_definitions SET
|
|
398
|
+
title = $2, persona = $3, trigger_type = $4, trigger_spec = $5::jsonb,
|
|
399
|
+
model_tier = $6, scope_modules = $7, scope_paths = $8,
|
|
400
|
+
author_rank = $9, author_builder_id = $10, updated_at = now()
|
|
401
|
+
WHERE name = $1 AND source = 'db'
|
|
402
|
+
RETURNING ${COLUMNS}, persona`,
|
|
403
|
+
[name, value.title, value.persona, value.trigger_type,
|
|
404
|
+
JSON.stringify(value.trigger_spec), value.model_tier,
|
|
405
|
+
value.scope_modules, value.scope_paths, req.builder.rank, req.builder.id]
|
|
406
|
+
);
|
|
407
|
+
// `AND source = 'db'` repeats the check above as a STRUCTURAL guard: a row
|
|
408
|
+
// that turned file-sourced between the read and the write is not
|
|
409
|
+
// overwritten by the race either. Same shape as agents-sync's own upsert.
|
|
410
|
+
if (!rows.length) return res.fail('agent_owned_by_file', { status: 409, message: 'this definition became file-owned while the edit was in flight' });
|
|
411
|
+
return res.json({ ok: true, agent: serializeDefinition(rows[0], { includePersona: true }) });
|
|
412
|
+
} catch (err) {
|
|
413
|
+
log.error({ err, name }, 'PATCH /agents/:name failed');
|
|
414
|
+
if (!res.headersSent) return res.fail('agent_update_failed', 500);
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// DELETE /agents/:name — only a db-authored definition, and only ever one.
|
|
420
|
+
//
|
|
421
|
+
// The LEDGER is untouched: agents_runs.definition_id is ON DELETE SET NULL and
|
|
422
|
+
// carries a denormalized agent_name, so what this agent spent and did survives
|
|
423
|
+
// the definition it pointed at (the schema header says why).
|
|
424
|
+
router.delete('/agents/:name', api.requireBuilder, api.requirePermission('agent.author'), async (req, res) => {
|
|
425
|
+
const name = String(req.params.name || '');
|
|
426
|
+
if (!validate.NAME_RE.test(name) || name.length > validate.NAME_MAX) return res.fail('invalid_agent_name', 400);
|
|
427
|
+
try {
|
|
428
|
+
const { rows: existing } = await api.pool.query(
|
|
429
|
+
'SELECT name, source, source_path FROM agents_definitions WHERE name = $1', [name]
|
|
430
|
+
);
|
|
431
|
+
if (!existing.length) return res.fail('agent_not_found', 404);
|
|
432
|
+
const writable = authoring.writableByApi(existing[0]);
|
|
433
|
+
if (!writable.ok) return res.fail('agent_owned_by_file', { status: 409, message: writable.reason });
|
|
434
|
+
const { rowCount } = await api.pool.query(
|
|
435
|
+
"DELETE FROM agents_definitions WHERE name = $1 AND source = 'db'", [name]
|
|
436
|
+
);
|
|
437
|
+
if (!rowCount) return res.fail('agent_owned_by_file', { status: 409, message: 'this definition became file-owned while the delete was in flight' });
|
|
438
|
+
return res.json({ ok: true, deleted: name });
|
|
439
|
+
} catch (err) {
|
|
440
|
+
log.error({ err, name }, 'DELETE /agents/:name failed');
|
|
441
|
+
if (!res.headersSent) return res.fail('agent_delete_failed', 500);
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// POST /agents/:name/enable — THE OPERATOR ACT.
|
|
447
|
+
//
|
|
448
|
+
// This is the one write that works on a FILE-sourced definition, and it is the
|
|
449
|
+
// whole reason a committed agent can ever run: agents-sync deliberately never
|
|
450
|
+
// arms anything (its rule 4), and carries an operator's `enabled` forward
|
|
451
|
+
// untouched on every later deploy. So arming here is durable, and landing a
|
|
452
|
+
// commit still cannot arm anything.
|
|
453
|
+
//
|
|
454
|
+
// Two refusals it cannot talk its way past, both enforced by the schema as
|
|
455
|
+
// well as here: a flagged definition stays disarmed, and a definition with no
|
|
456
|
+
// author_rank has no authority to act with.
|
|
457
|
+
router.post('/agents/:name/enable', api.requireBuilder, api.requirePermission('agent.arm'), async (req, res) => {
|
|
458
|
+
const name = String(req.params.name || '');
|
|
459
|
+
if (!validate.NAME_RE.test(name) || name.length > validate.NAME_MAX) return res.fail('invalid_agent_name', 400);
|
|
460
|
+
try {
|
|
461
|
+
const { rows: existing } = await api.pool.query(
|
|
462
|
+
`SELECT ${COLUMNS} FROM agents_definitions WHERE name = $1`, [name]
|
|
463
|
+
);
|
|
464
|
+
if (!existing.length) return res.fail('agent_not_found', 404);
|
|
465
|
+
const row = existing[0];
|
|
466
|
+
if (row.scope_violation) {
|
|
467
|
+
return res.fail('agent_scope_flagged', { status: 409, message: `this definition tripped the scope wall and cannot be armed: ${row.scope_violation}` });
|
|
468
|
+
}
|
|
469
|
+
if (!row.author_rank) {
|
|
470
|
+
return res.fail('agent_has_no_author', { status: 409, message: 'this definition carries no author_rank, so it has no authority to act with — authority comes from a live rank, never from the definition (ADR 0016)' });
|
|
471
|
+
}
|
|
472
|
+
const { rows } = await api.pool.query(
|
|
473
|
+
`UPDATE agents_definitions SET enabled = true, updated_at = now()
|
|
474
|
+
WHERE name = $1 AND scope_violation IS NULL AND author_rank IS NOT NULL
|
|
475
|
+
RETURNING ${COLUMNS}`,
|
|
476
|
+
[name]
|
|
477
|
+
);
|
|
478
|
+
if (!rows.length) return res.fail('agent_not_armable', { status: 409, message: 'the definition stopped being armable while the request was in flight' });
|
|
479
|
+
return res.json({ ok: true, agent: serializeDefinition(rows[0]) });
|
|
480
|
+
} catch (err) {
|
|
481
|
+
log.error({ err, name }, 'POST /agents/:name/enable failed');
|
|
482
|
+
if (!res.headersSent) return res.fail('agent_enable_failed', 500);
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// POST /agents/:name/disable — and THIS is what stops dispatch.
|
|
488
|
+
//
|
|
489
|
+
// Two halves, because there are two ways an agent fires. The event dispatch
|
|
490
|
+
// index is partial (`WHERE enabled`), so an event agent switched off stops
|
|
491
|
+
// being selected at all; an on-demand agent has no selection step, so gate.js
|
|
492
|
+
// refuses it at invoke. Off means off on both paths, which is the property
|
|
493
|
+
// this route exists to give an operator.
|
|
494
|
+
router.post('/agents/:name/disable', api.requireBuilder, api.requirePermission('agent.arm'), async (req, res) => {
|
|
495
|
+
const name = String(req.params.name || '');
|
|
496
|
+
if (!validate.NAME_RE.test(name) || name.length > validate.NAME_MAX) return res.fail('invalid_agent_name', 400);
|
|
497
|
+
try {
|
|
498
|
+
const { rows } = await api.pool.query(
|
|
499
|
+
`UPDATE agents_definitions SET enabled = false, updated_at = now()
|
|
500
|
+
WHERE name = $1 RETURNING ${COLUMNS}`,
|
|
501
|
+
[name]
|
|
502
|
+
);
|
|
503
|
+
if (!rows.length) return res.fail('agent_not_found', 404);
|
|
504
|
+
return res.json({ ok: true, agent: serializeDefinition(rows[0]) });
|
|
505
|
+
} catch (err) {
|
|
506
|
+
log.error({ err, name }, 'POST /agents/:name/disable failed');
|
|
507
|
+
if (!res.headersSent) return res.fail('agent_disable_failed', 500);
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// POST /agents/:name/invoke — fire an on-demand agent. 202, never 200.
|
|
513
|
+
//
|
|
514
|
+
// A fire takes 60-120 seconds and costs about a dollar, which is past the edge
|
|
515
|
+
// proxy's patience — so the ledger row IS the response and the run continues
|
|
516
|
+
// behind it. Collect the answer from GET /agent-runs/:id.
|
|
517
|
+
//
|
|
518
|
+
// SELF-GATED at requireBuilder, deliberately, matching the reads: which agents
|
|
519
|
+
// exist at all is an operator decision, and a v1 agent is hard-read-only.
|
|
520
|
+
//
|
|
521
|
+
// The thing that reasoning does NOT bound is SPEND, so a per-builder ceiling
|
|
522
|
+
// does (../lib/fire-budget.js). It is a blast radius, not a budget: the real
|
|
523
|
+
// accounting is agents_runs.cost_usd through the shared LLM cost cache.
|
|
524
|
+
router.post('/agents/:name/invoke', api.requireBuilder, async (req, res) => {
|
|
525
|
+
const name = String(req.params.name || '');
|
|
526
|
+
if (!validate.NAME_RE.test(name) || name.length > validate.NAME_MAX) return res.fail('invalid_agent_name', 400);
|
|
527
|
+
if (api.validateOrRespond(req, res, { input: { type: 'string', maxLength: 8192 } }, { strict: true })) return undefined;
|
|
528
|
+
|
|
529
|
+
// THE SPEND CEILING, before anything is read or written. A fire costs real
|
|
530
|
+
// money and an async 202 means the caller does not even wait for it, so the
|
|
531
|
+
// loop this bounds is cheap to write and expensive to run. Per BUILDER, not
|
|
532
|
+
// per IP — see ../lib/fire-budget.js for why, and for why this is a blast
|
|
533
|
+
// radius and not an accounting.
|
|
534
|
+
const budget = fireBudget.check(req.builder.id);
|
|
535
|
+
if (!budget.ok) {
|
|
536
|
+
if (budget.retryAfterSeconds > 0) res.set('Retry-After', String(budget.retryAfterSeconds));
|
|
537
|
+
return res.fail('rate_limited', {
|
|
538
|
+
status: 429,
|
|
539
|
+
message: `agent fires are capped at ${fireBudget.limit} per builder per ${Math.round(fireBudget.windowMs / 60000)} minutes — each one spends real money`,
|
|
540
|
+
details: { scope: 'agent-fire', retry_after_seconds: budget.retryAfterSeconds },
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const { rows } = await api.pool.query(
|
|
545
|
+
`SELECT ${COLUMNS}, persona FROM agents_definitions WHERE name = $1`, [name]
|
|
546
|
+
);
|
|
547
|
+
if (!rows.length) return res.fail('agent_not_found', 404);
|
|
548
|
+
const definition = rows[0];
|
|
549
|
+
const gate = gateFor(definition);
|
|
550
|
+
const trigger = { type: 'on-demand', ref: `on-demand:${req.builder.github_login || req.builder.id}` };
|
|
551
|
+
|
|
552
|
+
// A REFUSAL IS A LEDGER ROW. spawnAgent writes the no-go and returns
|
|
553
|
+
// without spawning, so this await is a database round trip, not a model
|
|
554
|
+
// call — the reason it can stay synchronous while the go path cannot.
|
|
555
|
+
if (gate.decision !== 'go') {
|
|
556
|
+
const skipped = await spawnAgent({
|
|
557
|
+
definition, trigger, gate, requestedByBuilderId: req.builder.id,
|
|
558
|
+
}, {});
|
|
559
|
+
return res.fail('agent_refused', {
|
|
560
|
+
status: 409,
|
|
561
|
+
message: gate.reason,
|
|
562
|
+
// The run id is the point: the refusal is ON THE RECORD, and this is
|
|
563
|
+
// how the caller reads the row that says so.
|
|
564
|
+
details: { run_id: skipped.runId ? String(skipped.runId) : null },
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let captured = null;
|
|
569
|
+
const { ledgered, done } = fireInBackground({
|
|
570
|
+
definition,
|
|
571
|
+
input: req.body && req.body.input ? String(req.body.input) : null,
|
|
572
|
+
trigger,
|
|
573
|
+
gate,
|
|
574
|
+
requestedByBuilderId: req.builder.id,
|
|
575
|
+
}, {
|
|
576
|
+
// Wrap the port's runner to keep the reply, which spawnAgent does not
|
|
577
|
+
// return — it records what a fire DID, not what it said.
|
|
578
|
+
runSubagentCached: async (args) => {
|
|
579
|
+
const grade = api.resolveOptional ? api.resolveOptional('grade', null) : null;
|
|
580
|
+
if (!grade || typeof grade.runSubagentCached !== 'function') {
|
|
581
|
+
// Let spawnAgent's own grade_port_unavailable path handle it.
|
|
582
|
+
throw Object.assign(new Error('grade port unavailable'), { code: 'grade_port_unavailable' });
|
|
583
|
+
}
|
|
584
|
+
const out = await grade.runSubagentCached(args);
|
|
585
|
+
captured = out && typeof out.stdout === 'string' ? out.stdout : null;
|
|
586
|
+
return out;
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// The rejection arm is NOT dead code, though spawnAgent's doc-comment says
|
|
591
|
+
// it never throws. That guarantee covers the FIRE — everything inside its
|
|
592
|
+
// try — and the work before it (resolving the tier through branding) is
|
|
593
|
+
// outside. This promise is detached from the request, so an unhandled
|
|
594
|
+
// rejection there is a process-level crash under Node's default, taking
|
|
595
|
+
// every other in-flight request with it.
|
|
596
|
+
done.then((fired) => {
|
|
597
|
+
if (fired && fired.ok && fired.runId && captured) hold.put(fired.runId, captured);
|
|
598
|
+
}, (err) => { log.error({ err, name }, 'agent fire rejected'); });
|
|
599
|
+
|
|
600
|
+
const runId = await ledgered;
|
|
601
|
+
if (runId === null) return res.fail('agent_ledger_write_failed', { status: 500, message: 'the fire was refused because it could not be recorded — an unledgered fire is the one thing this table exists to prevent' });
|
|
602
|
+
return res.status(202).json({
|
|
603
|
+
ok: true,
|
|
604
|
+
run_id: String(runId),
|
|
605
|
+
poll: `/agent-runs/${runId}`,
|
|
606
|
+
message: 'the agent is running; collect the answer from the poll URL',
|
|
607
|
+
});
|
|
608
|
+
} catch (err) {
|
|
609
|
+
log.error({ err, name }, 'POST /agents/:name/invoke failed');
|
|
610
|
+
if (!res.headersSent) return res.fail('agent_invoke_failed', 500);
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// GET /agent-runs/:id — COLLECT THE ANSWER TO A FIRE YOU STARTED.
|
|
616
|
+
//
|
|
617
|
+
// OWN RUNS ONLY, enforced in the WHERE clause rather than after the read. The
|
|
618
|
+
// id is a sequential integer primary key, so without this any authenticated
|
|
619
|
+
// builder could walk the ledger and read another builder's held answer — text
|
|
620
|
+
// generated from THEIR free-text question, out of a repo the agent read — plus
|
|
621
|
+
// the github_login in `trigger_ref`. That is the textbook IDOR, and it is worse
|
|
622
|
+
// here than the usual shape because the interesting field is model output
|
|
623
|
+
// derived from someone else's prompt.
|
|
624
|
+
//
|
|
625
|
+
// Filtering in SQL, not in JS, is deliberate: a post-read comparison is one
|
|
626
|
+
// early `return` away from leaking, and it would still have handed the row to
|
|
627
|
+
// `hold.take()` on the way past.
|
|
628
|
+
//
|
|
629
|
+
// A run with a NULL requester (an event-triggered fire — nobody asked for it)
|
|
630
|
+
// is therefore readable by nobody here, which is correct for a route whose job
|
|
631
|
+
// is "collect MY answer". An operator's view of the whole ledger is a different
|
|
632
|
+
// surface with a different gate, and it is not built: when it is, it belongs
|
|
633
|
+
// behind `agent.arm`, not behind a widening of this route.
|
|
634
|
+
//
|
|
635
|
+
// See ../lib/answer-hold.js for why the text is held and not stored.
|
|
636
|
+
router.get('/agent-runs/:id', api.requireBuilder, async (req, res) => {
|
|
637
|
+
// parseId RESPONDS on a bad id and returns null, so returning here is the
|
|
638
|
+
// whole handling. No fallback: a defensive `Number(...)` path that skipped
|
|
639
|
+
// the respond would leave the request hanging instead of answering 400.
|
|
640
|
+
const id = api.parseId(req, res, { param: 'id', positiveInt: true });
|
|
641
|
+
if (id === null) return undefined;
|
|
642
|
+
try {
|
|
643
|
+
const { rows } = await api.pool.query(
|
|
644
|
+
`SELECT id, agent_name, trigger_ref, trigger_type, gate_decision, gate_reason,
|
|
645
|
+
status, error_code, model, cost_usd, duration_ms, output_ref,
|
|
646
|
+
created_at, updated_at
|
|
647
|
+
FROM agents_runs WHERE id = $1 AND requested_by_builder_id = $2`,
|
|
648
|
+
[id, req.builder.id]
|
|
649
|
+
);
|
|
650
|
+
// 404, not 403: a run that is not yours is not a run you get to learn
|
|
651
|
+
// exists. Distinguishing the two would make the id space enumerable again,
|
|
652
|
+
// one bit at a time.
|
|
653
|
+
if (!rows.length) return res.fail('agent_run_not_found', 404);
|
|
654
|
+
return res.json({ ok: true, run: serializeRun(rows[0], { answer: hold.take(id) }) });
|
|
655
|
+
} catch (err) {
|
|
656
|
+
log.error({ err, id }, 'GET /agent-runs/:id failed');
|
|
657
|
+
if (!res.headersSent) return res.fail('agent_run_read_failed', 500);
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
172
662
|
return router;
|
|
173
663
|
};
|
|
174
664
|
|
|
175
665
|
// Exported for the unit tests, which drive the projection with fabricated rows
|
|
176
666
|
// rather than standing up Postgres.
|
|
177
667
|
module.exports.serializeDefinition = serializeDefinition;
|
|
668
|
+
module.exports.serializeRun = serializeRun;
|
|
669
|
+
// The module-level ceiling, exported so a test can reset it between cases —
|
|
670
|
+
// a singleton otherwise carries one case's spend into the next.
|
|
671
|
+
module.exports.fireBudget = fireBudget;
|
package/modules/agents/spawn.js
CHANGED
|
@@ -146,6 +146,18 @@ async function spawnAgent({
|
|
|
146
146
|
let runId = null;
|
|
147
147
|
try {
|
|
148
148
|
runId = await insertRun(pool, row);
|
|
149
|
+
// deps.onLedgered — "the row exists; here is its id", fired the moment the
|
|
150
|
+
// ledger write lands and long before the fire finishes (task 1002493).
|
|
151
|
+
//
|
|
152
|
+
// A synchronous caller does not need this; POST /agents/:name/invoke does,
|
|
153
|
+
// because it answers 202 with the run id while the spawn runs on behind it,
|
|
154
|
+
// and the alternative was for the route to insert its own row — a second
|
|
155
|
+
// copy of the ledger contract, which is exactly how two ledgers start
|
|
156
|
+
// disagreeing. Swallowed on throw: a caller's bookkeeping must never abort a
|
|
157
|
+
// fire that is already recorded.
|
|
158
|
+
if (typeof deps.onLedgered === 'function') {
|
|
159
|
+
try { deps.onLedgered(runId); } catch (_) { /* the caller's problem, not this fire's */ }
|
|
160
|
+
}
|
|
149
161
|
} catch (e) {
|
|
150
162
|
// The ledger write itself failing is the one case with nowhere to record it.
|
|
151
163
|
// Say so and refuse to spawn: an unledgered fire is exactly what this table
|
|
@@ -99,11 +99,27 @@ function createClient(opts = {}) {
|
|
|
99
99
|
// GET /achievements — rank: any-builder — GET /achievements
|
|
100
100
|
getAchievements: (args) => request("GET", "/achievements", { hasBody: false }, args),
|
|
101
101
|
},
|
|
102
|
+
"agentRuns": {
|
|
103
|
+
// GET /agent-runs/{id} — rank: any-builder — GET /agent-runs/:id
|
|
104
|
+
getAgentRunsId: (args) => request("GET", "/agent-runs/{id}", { hasBody: false }, args),
|
|
105
|
+
},
|
|
102
106
|
"agents": {
|
|
103
107
|
// GET /agents — rank: any-builder — GET /agents
|
|
104
108
|
getAgents: (args) => request("GET", "/agents", { hasBody: false }, args),
|
|
109
|
+
// POST /agents — rank: metic+archon — POST /agents
|
|
110
|
+
postAgents: (args) => request("POST", "/agents", { hasBody: true }, args),
|
|
111
|
+
// DELETE /agents/{name} — rank: metic+archon — DELETE /agents/:name
|
|
112
|
+
deleteAgentsName: (args) => request("DELETE", "/agents/{name}", { hasBody: false }, args),
|
|
105
113
|
// GET /agents/{name} — rank: any-builder — GET /agents/:name
|
|
106
114
|
getAgentsName: (args) => request("GET", "/agents/{name}", { hasBody: false }, args),
|
|
115
|
+
// PATCH /agents/{name} — rank: metic+archon — PATCH /agents/:name
|
|
116
|
+
patchAgentsName: (args) => request("PATCH", "/agents/{name}", { hasBody: true }, args),
|
|
117
|
+
// POST /agents/{name}/disable — rank: metic+archon — POST /agents/:name/disable
|
|
118
|
+
postAgentsNameDisable: (args) => request("POST", "/agents/{name}/disable", { hasBody: true }, args),
|
|
119
|
+
// POST /agents/{name}/enable — rank: metic+archon — POST /agents/:name/enable
|
|
120
|
+
postAgentsNameEnable: (args) => request("POST", "/agents/{name}/enable", { hasBody: true }, args),
|
|
121
|
+
// POST /agents/{name}/invoke — rank: any-builder — POST /agents/:name/invoke
|
|
122
|
+
postAgentsNameInvoke: (args) => request("POST", "/agents/{name}/invoke", { hasBody: true }, args),
|
|
107
123
|
},
|
|
108
124
|
"analytics": {
|
|
109
125
|
// GET /analytics/builder/{id} — rank: any-builder — GET /analytics/builder/:id
|
|
@@ -180,6 +180,16 @@ const PERMISSIONS = [
|
|
|
180
180
|
{ key: 'backup.manage', system: false, floor: 'metic', guards: 'GET /backup/status, POST /backup/trigger' },
|
|
181
181
|
{ key: 'source.clone.local', system: false, floor: 'metic', guards: 'Metic+ local clone / collaborator invite (ADR 0035)' },
|
|
182
182
|
{ key: 'box.source.full', system: false, floor: 'metic', guards: 'GET /box/source-access → full (vs starter); boxScopeForRank' },
|
|
183
|
+
// Agent authoring, split in two because the two acts differ in consequence
|
|
184
|
+
// (task 1002493). Authoring writes a definition; ARMING is what turns a piece
|
|
185
|
+
// of text into something that spends money and reads the repo, and it is the
|
|
186
|
+
// only write that works on a committed, file-sourced definition — agents-sync
|
|
187
|
+
// deliberately never arms anything, so this atom IS the act that lets a
|
|
188
|
+
// committed agent run. Separate keys let an instance delegate the first
|
|
189
|
+
// without the second. Metic by the module.enable analogue: configuring what
|
|
190
|
+
// the instance runs, not deciding who holds authority.
|
|
191
|
+
{ key: 'agent.author', system: false, floor: 'metic', guards: 'POST /agents, PATCH|DELETE /agents/:name' },
|
|
192
|
+
{ key: 'agent.arm', system: false, floor: 'metic', guards: 'POST /agents/:name/{enable,disable}' },
|
|
183
193
|
|
|
184
194
|
// ── delegated archon→metic by ADR 0157 (owner decision, 2026-08-05) ───────────
|
|
185
195
|
//
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
-- government_013_agent_atoms.sql — grant the two new agent-authoring atoms to the
|
|
2
|
+
-- Metic and Archon rank-roles (task 1002493, goal 1000038 Phase 2a).
|
|
3
|
+
--
|
|
4
|
+
-- The agents module's WRITE surface landed in this task: create, edit, delete,
|
|
5
|
+
-- arm, disarm. BV1.R105 says a modules/*/routes gate is a permission atom and
|
|
6
|
+
-- never a rank — tests/government_require_permission.mjs fails the build on a new
|
|
7
|
+
-- `requireRank`, and the two files still spelling it that way are documented
|
|
8
|
+
-- holdouts rather than a pattern to copy. So the routes gate on:
|
|
9
|
+
--
|
|
10
|
+
-- agent.author — POST /agents, PATCH|DELETE /agents/:name
|
|
11
|
+
-- agent.arm — POST /agents/:name/{enable,disable}
|
|
12
|
+
--
|
|
13
|
+
-- TWO KEYS, NOT ONE, because the acts differ in consequence. Authoring writes a
|
|
14
|
+
-- definition; ARMING is what turns a piece of text into something that spends
|
|
15
|
+
-- money and reads the repo, and it is the only write that works on a committed,
|
|
16
|
+
-- file-sourced definition — agents-sync deliberately never arms anything (its
|
|
17
|
+
-- rule 4), so `agent.arm` IS the act that lets a committed agent run at all.
|
|
18
|
+
-- Separate keys let an instance delegate the first without the second.
|
|
19
|
+
--
|
|
20
|
+
-- FLOOR `metic` for both, by the `module.enable` analogue: this is configuring
|
|
21
|
+
-- what the instance RUNS, not deciding who holds authority. Two things bound it.
|
|
22
|
+
-- A v1 agent is fixed read-only by plumbing rather than by policy (spawn.js
|
|
23
|
+
-- constraint 3 — the grade port hardcodes `--tools Read`), so holding these
|
|
24
|
+
-- cannot produce a write principal. And a definition authored over HTTP may not
|
|
25
|
+
-- declare a scope reaching any protected surface AT ALL, at any rank
|
|
26
|
+
-- (modules/agents/lib/authoring.js rule 3) — stricter than the validator's own
|
|
27
|
+
-- Metic floor, because a committed definition reaches the registry through a
|
|
28
|
+
-- branch, a grader panel and CI while this one reaches it through a single call.
|
|
29
|
+
--
|
|
30
|
+
-- BRAND-NEW keys: no rank holds them yet, so BOTH rungs the floor implies are
|
|
31
|
+
-- granted here. RANK_SEED is a cumulative superset — floor `metic` means
|
|
32
|
+
-- {metic, archon} — and the drift guard (tests/government_seed.mjs) compares the
|
|
33
|
+
-- UNION of the run-once seed plus every later grant migration against that
|
|
34
|
+
-- floor-derived seed, per rank. Minting the catalog rows WITHOUT this migration
|
|
35
|
+
-- is exactly what that guard caught on this task.
|
|
36
|
+
--
|
|
37
|
+
-- Target table is `government_rank_permissions` with a `rank_key` column — NOT
|
|
38
|
+
-- the pre-rename `governance_role_permissions`/`role_key` the `governance_*`
|
|
39
|
+
-- migrations write to (government_001_rename_from_governance dropped those with
|
|
40
|
+
-- no compatibility shim). Copy government_009_mingle_manage.sql, not a
|
|
41
|
+
-- governance_* file.
|
|
42
|
+
--
|
|
43
|
+
-- Additive only: two new permission keys granted to two ranks; nothing is
|
|
44
|
+
-- revoked. Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
|
|
45
|
+
--
|
|
46
|
+
-- One block PER RANK, in the government_004 shape: the drift guard scans for
|
|
47
|
+
-- `SELECT '<rank>', unnest(ARRAY[…])` literally, so a combined
|
|
48
|
+
-- `unnest(ARRAY['metic','archon']), 'key'` form parses as zero grants and reds it.
|
|
49
|
+
|
|
50
|
+
BEGIN;
|
|
51
|
+
|
|
52
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
53
|
+
SELECT 'metic', unnest(ARRAY[
|
|
54
|
+
'agent.author',
|
|
55
|
+
'agent.arm'
|
|
56
|
+
])
|
|
57
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
58
|
+
|
|
59
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
60
|
+
SELECT 'archon', unnest(ARRAY[
|
|
61
|
+
'agent.author',
|
|
62
|
+
'agent.arm'
|
|
63
|
+
])
|
|
64
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
65
|
+
|
|
66
|
+
COMMIT;
|