@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.
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ // modules/agents/lib/answer-hold.js — where a server-side fire's answer waits for
4
+ // the caller who asked for it (task 1002493).
5
+ //
6
+ // THE PROBLEM. `POST /agents/:name/invoke` takes 60-120 seconds and costs about a
7
+ // dollar, so it cannot be a synchronous request — the edge proxy times out around
8
+ // 100s and the caller would pay for an answer nobody receives. So it returns 202
9
+ // with a run id and the fire continues behind it. That leaves one question: where
10
+ // does the answer go?
11
+ //
12
+ // NOT THE LEDGER. `agents_runs.output_ref` is a POINTER by the schema's own rule,
13
+ // never the output — agent text rendered to whoever reads the ledger would make
14
+ // that table a delivery path for whatever the agent read. And there is no blob
15
+ // store in this project to point AT.
16
+ //
17
+ // SO: THE ANSWER IS HELD, NOT STORED. In memory, bounded, for a few minutes,
18
+ // keyed by run id, and handed to the first caller who asks for it. The honest
19
+ // reading of this is a pickup counter, not a database:
20
+ //
21
+ // - it does not survive a restart, and nothing pretends otherwise — the LEDGER
22
+ // survives, so "the fire happened, cost this, and succeeded" is durable even
23
+ // when the text is gone;
24
+ // - it is capped at MAX_HELD entries and evicts the oldest, so a loop of
25
+ // invocations cannot grow the process;
26
+ // - it expires, because an answer nobody collected is a copy of whatever the
27
+ // agent read sitting in memory for no one.
28
+ //
29
+ // A caller that needs the answer durably passes `--out` to
30
+ // `scripts/gds/agent-invoke.js` and owns the file. That is the deliberate split:
31
+ // the CLI delivers to the operator's disk, the route delivers to the operator's
32
+ // next request, and neither writes agent prose into a table.
33
+ //
34
+ // Requires nothing (ADR 0083). The clock is injected so the expiry is testable
35
+ // without waiting for it.
36
+
37
+ const MAX_HELD = 50;
38
+ const TTL_MS = 15 * 60 * 1000;
39
+
40
+ function createAnswerHold({ maxHeld = MAX_HELD, ttlMs = TTL_MS, now = () => Date.now() } = {}) {
41
+ // Insertion-ordered, which is what makes "evict the oldest" a first key rather
42
+ // than a sort.
43
+ const held = new Map();
44
+
45
+ function sweep() {
46
+ const cutoff = now() - ttlMs;
47
+ for (const [key, entry] of held) {
48
+ if (entry.at <= cutoff) held.delete(key);
49
+ }
50
+ }
51
+
52
+ return {
53
+ /** Hold one answer for the caller who asked for it. */
54
+ put(runId, answer) {
55
+ const key = String(runId);
56
+ if (key === 'null' || key === 'undefined' || key === '') return false;
57
+ sweep();
58
+ // Delete-then-set so a re-put moves the entry to the END of the insertion
59
+ // order; without it a refreshed entry would keep its original eviction
60
+ // position and could be dropped while it was the newest thing here.
61
+ held.delete(key);
62
+ held.set(key, { answer, at: now() });
63
+ while (held.size > maxHeld) {
64
+ const oldest = held.keys().next().value;
65
+ held.delete(oldest);
66
+ }
67
+ return true;
68
+ },
69
+
70
+ /**
71
+ * Collect an answer. Returns null when it was never held, has expired, or was
72
+ * evicted — three different histories with the same remedy, and the LEDGER is
73
+ * where a caller finds out which.
74
+ */
75
+ take(runId) {
76
+ sweep();
77
+ const key = String(runId);
78
+ const entry = held.get(key);
79
+ if (!entry) return null;
80
+ // Reading does NOT remove it: a caller polling while the fire is still in
81
+ // flight would otherwise consume the answer with its first successful poll
82
+ // and see nothing on the next. Expiry is what removes things here.
83
+ return entry.answer;
84
+ },
85
+
86
+ /** For the tests and for a future /status: what is waiting to be collected. */
87
+ size() { sweep(); return held.size; },
88
+ };
89
+ }
90
+
91
+ module.exports = { createAnswerHold, MAX_HELD, TTL_MS };
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ // modules/agents/lib/authoring.js — the decisions behind the agent WRITE surface
4
+ // (task 1002493, goal 1000038 Phase 2a).
5
+ //
6
+ // The routes do HTTP; this does the thinking, so every rule below is testable
7
+ // without a server, a database or a session. Requires nothing, for the reason
8
+ // validate.js and gate.js require nothing: `modules/**` may import only
9
+ // src/module-api.js (ADR 0083), and importing nothing clears that bar outright.
10
+ //
11
+ // FOUR RULES, and each is a way an authoring surface turns into an escalation.
12
+ //
13
+ // 1. AUTHORITY FIELDS ARE NEVER READ FROM THE REQUEST. `author_rank`, `source`,
14
+ // `provenance`, `enabled`, `scope_violation` and the file-provenance columns
15
+ // are decided by the server or by a dedicated route, never by a body key. A
16
+ // request that names one is REFUSED rather than quietly stripped: silently
17
+ // ignoring a field called `author_rank` teaches the next caller that it
18
+ // worked, and the difference only surfaces as an authority bug much later.
19
+ //
20
+ // 2. THE STAMPED RANK IS THE AUTHOR'S LIVE RANK, so it can never exceed it.
21
+ // That is the declaration clamp: a builder may declare a scope, a trigger
22
+ // and a tier, and may not declare the authority those are judged against.
23
+ // Editing re-stamps to the EDITOR's live rank, which can only move the row
24
+ // down the ladder or leave it — the safe direction — and the scope wall then
25
+ // re-runs against the new, lower rank.
26
+ //
27
+ // WHAT IT IS NOT: a grant of runtime authority. An agent's authority is its
28
+ // tool set intersected with its (absent, in v1) service principal — the
29
+ // spawn path is fixed read-only. author_rank clamps what may be DECLARED.
30
+ //
31
+ // 3. A DB-AUTHORED DEFINITION MAY NOT REACH A PROTECTED SURFACE AT ALL —
32
+ // not at Metic, not at Archon. This is deliberately STRICTER than
33
+ // validate.js's Metic+ floor, and the asymmetry is the point: a committed
34
+ // definition reached the registry through a branch, a grader panel and CI,
35
+ // while one authored here reached it through a single HTTP call. The rank
36
+ // floor is the right wall for the reviewed path; for the unreviewed one the
37
+ // answer is no.
38
+ //
39
+ // 4. THE SYNC OWNS ITS OWN ROWS. A `source='file'` definition is rewritten from
40
+ // the committed file on every deploy, so editing or deleting one here would
41
+ // be undone by the next reconcile — a write that silently reverts is worse
42
+ // than a refusal. Arming is the exception, and the only one: `enabled` is
43
+ // explicitly an operator decision that agents-sync carries forward
44
+ // untouched (its rule 4), which is what makes enable/disable the act that
45
+ // turns a committed definition on.
46
+
47
+ // Body keys a caller may set. Anything else is refused — see rule 1.
48
+ const AUTHORABLE_FIELDS = Object.freeze([
49
+ 'name', 'title', 'persona', 'trigger_type', 'trigger_spec', 'model_tier',
50
+ 'scope_modules', 'scope_paths',
51
+ ]);
52
+
53
+ // Keys that would be an escalation if they were honoured, named explicitly so a
54
+ // refusal can say WHICH one and why rather than "unknown field".
55
+ const SERVER_OWNED_FIELDS = Object.freeze({
56
+ author_rank: 'the author rank is taken from your live DB rank, never from the request (ADR 0016)',
57
+ author_builder_id: 'the author is taken from your session, never from the request',
58
+ source: "'db' for anything authored here; only agents-sync writes 'file' rows",
59
+ provenance: "'instance' for anything authored here",
60
+ enabled: 'use POST /agents/:name/enable or /disable — arming is its own act, with its own audit line',
61
+ scope_violation: 'set by the scope wall, never declared',
62
+ source_path: 'file provenance belongs to agents-sync',
63
+ source_sha256: 'file provenance belongs to agents-sync',
64
+ last_synced_at: 'file provenance belongs to agents-sync',
65
+ id: 'assigned by the database',
66
+ });
67
+
68
+ function isPlainObject(v) {
69
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
70
+ }
71
+
72
+ /**
73
+ * Rule 1. Which forbidden keys did this body name?
74
+ *
75
+ * @returns {Array<{field: string, reason: string}>} empty when the body is clean.
76
+ */
77
+ function forbiddenFields(body) {
78
+ if (!isPlainObject(body)) return [];
79
+ return Object.keys(body)
80
+ .filter((k) => Object.prototype.hasOwnProperty.call(SERVER_OWNED_FIELDS, k))
81
+ .map((field) => ({ field, reason: SERVER_OWNED_FIELDS[field] }));
82
+ }
83
+
84
+ /**
85
+ * Rule 3. Does this candidate's declared scope reach a protected surface?
86
+ *
87
+ * Fail-closed in the same three places the validator's wall is (task 1002492):
88
+ * a missing matcher counts as protected, a throwing matcher counts as protected,
89
+ * and only an explicit `false` clears a path. Ambiguity reads as protected here
90
+ * too — loud and fixable beats silent and admitted.
91
+ *
92
+ * @returns {{protected: boolean, modules: string[], paths: string[]}}
93
+ */
94
+ function protectedScopeHits(candidate, { protectedModules = [], isProtectedPath = null } = {}) {
95
+ const declaredModules = Array.isArray(candidate && candidate.scope_modules) ? candidate.scope_modules : [];
96
+ const declaredPaths = Array.isArray(candidate && candidate.scope_paths) ? candidate.scope_paths : [];
97
+ const modules = declaredModules.filter((m) => protectedModules.includes(m));
98
+ let paths = [];
99
+ if (declaredPaths.length > 0) {
100
+ if (typeof isProtectedPath !== 'function') {
101
+ paths = [...declaredPaths];
102
+ } else {
103
+ paths = declaredPaths.filter((p) => {
104
+ try { return isProtectedPath(p) !== false; } catch { return true; }
105
+ });
106
+ }
107
+ }
108
+ return { protected: modules.length > 0 || paths.length > 0, modules, paths };
109
+ }
110
+
111
+ /**
112
+ * Rule 4. May this existing row be edited or deleted through the API?
113
+ *
114
+ * @returns {{ok: boolean, reason: string|null}}
115
+ */
116
+ function writableByApi(row) {
117
+ if (!row) return { ok: false, reason: 'no such agent in the registry' };
118
+ if (row.source === 'file') {
119
+ return {
120
+ ok: false,
121
+ reason: `this definition is owned by ${row.source_path || 'a committed file'} — agents-sync rewrites it on every deploy, so an edit here would be silently undone. Change the file. Arming is the exception: enable/disable work on a file-sourced definition and the sync carries your choice forward.`,
122
+ };
123
+ }
124
+ return { ok: true, reason: null };
125
+ }
126
+
127
+ /**
128
+ * Merge a PATCH over the row it edits, so the validator judges the WHOLE
129
+ * definition rather than the delta.
130
+ *
131
+ * A partial update that only validated its own keys is how a definition ends up
132
+ * in a state no single request ever asked for: switch `trigger_type` to 'event'
133
+ * in one call and the missing `trigger_spec.event` is never noticed, because the
134
+ * call that would have noticed did not send that field.
135
+ */
136
+ function mergeForValidation(row, patch) {
137
+ const base = {};
138
+ for (const f of AUTHORABLE_FIELDS) {
139
+ if (row && row[f] !== undefined && row[f] !== null) base[f] = row[f];
140
+ }
141
+ const over = isPlainObject(patch) ? patch : {};
142
+ for (const f of AUTHORABLE_FIELDS) {
143
+ if (Object.prototype.hasOwnProperty.call(over, f)) base[f] = over[f];
144
+ }
145
+ return base;
146
+ }
147
+
148
+ module.exports = {
149
+ AUTHORABLE_FIELDS,
150
+ SERVER_OWNED_FIELDS,
151
+ forbiddenFields,
152
+ protectedScopeHits,
153
+ writableByApi,
154
+ mergeForValidation,
155
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // modules/agents/lib/fire-budget.js — the per-builder ceiling on how often an
4
+ // agent can be fired (task 1002493).
5
+ //
6
+ // WHY. `POST /agents/:name/invoke` is self-gated at requireBuilder, deliberately:
7
+ // which agents exist at all is an operator decision, and a v1 agent is
8
+ // hard-read-only. What that reasoning does NOT bound is SPEND. A fire costs real
9
+ // money — roughly $0.90 and 60-120 seconds for a historian question — and the
10
+ // only thing between a builder and a loop of them was that they had to type it.
11
+ // An async 202 made that worse, not better: the caller does not even wait.
12
+ //
13
+ // So: a sliding window per BUILDER, not per IP. The existing limiters in
14
+ // src/bongos/middleware/rate-limit.js key on `req.ip` because they guard surfaces
15
+ // reachable before anyone is identified; every fire is attributable by
16
+ // construction (ADR 0016, and the ledger's whole point), and a builder's IP is
17
+ // incidental — two builders behind one office NAT must not share a spend ceiling,
18
+ // and one builder on two machines must not get two.
19
+ //
20
+ // A CEILING, NOT A BUDGET. This bounds the blast radius of a loop; it does not
21
+ // decide what agents may cost. The real accounting is `agents_runs.cost_usd`
22
+ // billed through the shared LLM cost cache, and a per-instance $/month cap reads
23
+ // from there. Do not grow this into a budget — a ceiling that silently became the
24
+ // accounting would be a second, wrong answer to "what did agents cost".
25
+ //
26
+ // ONE INSTANCE, module-level, for the reason ADR 0209 gives about
27
+ // `publicProjectFeedRateLimit`: two call sites that each build their own from
28
+ // matching config get two budgets, which looks identical in review and hands a
29
+ // caller double the rate. (The answer hold is per-router on purpose and that is
30
+ // not a contradiction — a delivery buffer splitting just means an answer is
31
+ // collected from the router that produced it, while a ceiling splitting means
32
+ // there is no ceiling.)
33
+ //
34
+ // Requires nothing (ADR 0083). The clock is injected so the window is testable
35
+ // without waiting for it.
36
+
37
+ // Deliberately generous: a builder working through a question genuinely asks
38
+ // several in a session, and a limit that bites during ordinary use gets raised by
39
+ // whoever hits it rather than respected. Ten an hour is ~$9/hour/builder worst
40
+ // case, which is a bad afternoon rather than a bad month.
41
+ const DEFAULT_LIMIT = 10;
42
+ const DEFAULT_WINDOW_MS = 60 * 60 * 1000;
43
+
44
+ // Bounded so a large instance's bucket map cannot grow without limit. Eviction is
45
+ // safe here in a way it would not be for a security check: dropping the OLDEST
46
+ // bucket forgives the least recent spender, never the busiest one.
47
+ const MAX_TRACKED_BUILDERS = 5000;
48
+
49
+ function envInt(name, fallback) {
50
+ const raw = process.env[name];
51
+ if (!raw) return fallback;
52
+ const n = Number(raw);
53
+ return Number.isInteger(n) && n > 0 ? n : fallback;
54
+ }
55
+
56
+ function createFireBudget({
57
+ limit = envInt('BONGOS_AGENTS_FIRE_LIMIT', DEFAULT_LIMIT),
58
+ windowMs = envInt('BONGOS_AGENTS_FIRE_WINDOW_MS', DEFAULT_WINDOW_MS),
59
+ now = () => Date.now(),
60
+ } = {}) {
61
+ const buckets = new Map();
62
+
63
+ return {
64
+ limit,
65
+ windowMs,
66
+
67
+ /**
68
+ * May this builder fire right now? Records the fire when the answer is yes,
69
+ * so the caller cannot forget to.
70
+ *
71
+ * FAIL-CLOSED ON AN UNIDENTIFIED CALLER: no builder id means no bucket to
72
+ * spend from, and an unattributable fire is exactly what the ledger exists to
73
+ * prevent — so it is refused rather than admitted under a shared 'unknown'
74
+ * key, which one caller could exhaust for everyone.
75
+ *
76
+ * @returns {{ok: boolean, remaining: number, retryAfterSeconds: number}}
77
+ */
78
+ check(builderId) {
79
+ const key = builderId === null || builderId === undefined ? '' : String(builderId);
80
+ if (key === '') return { ok: false, remaining: 0, retryAfterSeconds: 0 };
81
+ const at = now();
82
+ const cutoff = at - windowMs;
83
+ const bucket = (buckets.get(key) || []).filter((t) => t > cutoff);
84
+ if (bucket.length >= limit) {
85
+ buckets.set(key, bucket);
86
+ return {
87
+ ok: false,
88
+ remaining: 0,
89
+ retryAfterSeconds: Math.max(1, Math.ceil((bucket[0] + windowMs - at) / 1000)),
90
+ };
91
+ }
92
+ bucket.push(at);
93
+ // Delete-then-set keeps insertion order meaningful, so the eviction below
94
+ // drops the builder who has been quiet longest rather than an arbitrary one.
95
+ buckets.delete(key);
96
+ buckets.set(key, bucket);
97
+ while (buckets.size > MAX_TRACKED_BUILDERS) {
98
+ buckets.delete(buckets.keys().next().value);
99
+ }
100
+ return { ok: true, remaining: limit - bucket.length, retryAfterSeconds: 0 };
101
+ },
102
+
103
+ /** Test seam — a module-level singleton otherwise carries state between cases. */
104
+ reset() { buckets.clear(); },
105
+ size() { return buckets.size; },
106
+ };
107
+ }
108
+
109
+ module.exports = { createFireBudget, DEFAULT_LIMIT, DEFAULT_WINDOW_MS, MAX_TRACKED_BUILDERS };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ // modules/agents/lib/gate.js — may this definition be fired ON DEMAND, and if
4
+ // not, what does the ledger row say (task 1002493).
5
+ //
6
+ // WHY IT IS ITS OWN FILE. Two callers ask the same question from opposite sides
7
+ // of the trust boundary: `scripts/gds/agent-invoke.js` (task 1002491) runs where
8
+ // the operator is, and `POST /agents/:name/invoke` runs on the server. A gate
9
+ // with two implementations is a gate with two behaviours the day one of them is
10
+ // edited — and the interesting half of this answer is the REASON, which is the
11
+ // entire content of the no-go row a refusal writes. One rule, one wording, one
12
+ // place to change it.
13
+ //
14
+ // Requires NOTHING, for the same reason validate.js does: `modules/**` may import
15
+ // only src/module-api.js (ADR 0083), and importing nothing clears that bar by
16
+ // construction while letting the CLI require it directly.
17
+ //
18
+ // THE VERDICT IS ALWAYS A FULL SENTENCE, never a code. A caller that wants a code
19
+ // has `decision`; the reason is written to be read by whoever is wondering why
20
+ // the agent they asked for did not answer.
21
+
22
+ // Mirrors agents_definitions_trigger_type_chk. Spelled here rather than imported
23
+ // from validate.js only to keep this file dependency-free; the routes test pins
24
+ // the two against each other so they cannot drift.
25
+ const ON_DEMAND = 'on-demand';
26
+
27
+ function isNonEmptyString(v) {
28
+ return typeof v === 'string' && v.trim().length > 0;
29
+ }
30
+
31
+ /**
32
+ * Decide whether a registry row may be invoked on demand.
33
+ *
34
+ * PURE and total: a missing row, a hostile row and a well-formed row all return
35
+ * the same shape, and nothing here throws into a caller that was only asking.
36
+ *
37
+ * @param {object|null} row the agents_definitions row as read from the registry.
38
+ * @returns {{decision: 'go'|'no-go', reason: string|null}}
39
+ */
40
+ function gateFor(row) {
41
+ if (!row) return { decision: 'no-go', reason: 'no such agent in the registry' };
42
+ if (row.trigger_type !== ON_DEMAND) {
43
+ return {
44
+ decision: 'no-go',
45
+ reason: `trigger_type is '${row.trigger_type}', not '${ON_DEMAND}' — this agent is dispatched by an event, not asked a question`,
46
+ };
47
+ }
48
+ // Checked before `enabled` even though agents_definitions_flagged_not_armed_chk
49
+ // makes a flagged row necessarily disabled: the two states share a verdict and
50
+ // have completely different fixes, and the reason is what the caller acts on.
51
+ if (isNonEmptyString(row.scope_violation)) {
52
+ return { decision: 'no-go', reason: `disarmed by the scope wall: ${row.scope_violation}` };
53
+ }
54
+ // THE HALF THAT MAKES `disable` MEAN SOMETHING. The dispatch index is partial
55
+ // (`WHERE enabled`), so an event agent switched off is simply never selected;
56
+ // on demand there is no selection step, so the check has to be here or
57
+ // disabling would stop autonomous firing and leave the agent askable.
58
+ if (row.enabled !== true) {
59
+ return { decision: 'no-go', reason: 'the definition is present in the registry but not enabled' };
60
+ }
61
+ return { decision: 'go', reason: null };
62
+ }
63
+
64
+ module.exports = { gateFor, ON_DEMAND };