neuralos 3.1.4 → 3.1.6
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/bin/gybackend.cjs +43 -19
- package/package.json +2 -2
- package/plugins/synapse-bridge/index.mjs +143 -7
- package/plugins/synapse-bridge/plugin.json +8 -2
- package/plugins/synapse-bridge/synapse-bridge.extreme.spec.mjs +48 -3
- package/plugins/synapse-bridge/synapseAgent.extreme.spec.mjs +236 -0
- package/plugins/synapse-bridge/synapseAgent.mjs +254 -0
package/bin/gybackend.cjs
CHANGED
|
@@ -285128,24 +285128,41 @@ var init_natsEventBus = __esm({
|
|
|
285128
285128
|
if (a.tlsCa) tls.ca = a.tlsCa;
|
|
285129
285129
|
return tls;
|
|
285130
285130
|
}
|
|
285131
|
-
/** Connect to NATS (idempotent). Wires lifecycle event handlers.
|
|
285131
|
+
/** Connect to NATS (idempotent). Wires lifecycle event handlers.
|
|
285132
|
+
* Concurrent callers share one in-flight attempt; a failed attempt clears the
|
|
285133
|
+
* slot so the next call retries (no permanent half-connected state). */
|
|
285134
|
+
connectPromise = null;
|
|
285132
285135
|
async connect() {
|
|
285133
|
-
if (this.conn) return;
|
|
285134
|
-
|
|
285135
|
-
|
|
285136
|
-
|
|
285137
|
-
|
|
285138
|
-
|
|
285139
|
-
|
|
285140
|
-
|
|
285141
|
-
|
|
285142
|
-
|
|
285143
|
-
|
|
285144
|
-
|
|
285145
|
-
|
|
285146
|
-
|
|
285147
|
-
|
|
285148
|
-
|
|
285136
|
+
if (this.conn && !this.conn.isClosed()) return;
|
|
285137
|
+
if (this.conn) {
|
|
285138
|
+
this.conn = null;
|
|
285139
|
+
}
|
|
285140
|
+
if (this.connectPromise) return this.connectPromise;
|
|
285141
|
+
const attempt = (async () => {
|
|
285142
|
+
const connectFn = this.opts.connectFn ?? ((o) => (0, import_transport_node.connect)(o));
|
|
285143
|
+
const authenticator = this.buildAuthenticator();
|
|
285144
|
+
const tls = this.buildTls();
|
|
285145
|
+
const copts = {
|
|
285146
|
+
servers: this.opts.servers,
|
|
285147
|
+
name: this.opts.name ?? "rterm-backend",
|
|
285148
|
+
...authenticator ? { authenticator } : {},
|
|
285149
|
+
...tls ? { tls } : {},
|
|
285150
|
+
...this.opts.maxReconnectAttempts !== void 0 ? { maxReconnectAttempts: this.opts.maxReconnectAttempts } : {},
|
|
285151
|
+
...this.opts.reconnectTimeWait !== void 0 ? { reconnectTimeWait: this.opts.reconnectTimeWait } : {},
|
|
285152
|
+
...this.opts.timeout !== void 0 ? { timeout: this.opts.timeout } : {}
|
|
285153
|
+
};
|
|
285154
|
+
const c = await connectFn(copts);
|
|
285155
|
+
if (c.isClosed()) throw new Error("NATS connection closed immediately after connect");
|
|
285156
|
+
this.conn = c;
|
|
285157
|
+
this.wireStatusHandlers(c);
|
|
285158
|
+
this.log(`[nats] connected to ${Array.isArray(this.opts.servers) ? this.opts.servers.join(",") : this.opts.servers}`);
|
|
285159
|
+
})();
|
|
285160
|
+
this.connectPromise = attempt;
|
|
285161
|
+
try {
|
|
285162
|
+
await attempt;
|
|
285163
|
+
} finally {
|
|
285164
|
+
this.connectPromise = null;
|
|
285165
|
+
}
|
|
285149
285166
|
}
|
|
285150
285167
|
/** Attach reconnect/disconnect/error/lame-duck handlers (best-effort). */
|
|
285151
285168
|
wireStatusHandlers(conn) {
|
|
@@ -289757,6 +289774,8 @@ function constructPort(Ctor, path32, opts) {
|
|
|
289757
289774
|
}
|
|
289758
289775
|
var SerialBackend = class {
|
|
289759
289776
|
instances = /* @__PURE__ */ new Map();
|
|
289777
|
+
/** ids whose port errored (removed from instances but remembered as 'failed'). */
|
|
289778
|
+
failedIds = /* @__PURE__ */ new Set();
|
|
289760
289779
|
/** For tests: inject a fake serialport constructor/factory. */
|
|
289761
289780
|
static setSerialModuleForTest(mod) {
|
|
289762
289781
|
injectedSerial = mod;
|
|
@@ -289811,7 +289830,10 @@ var SerialBackend = class {
|
|
|
289811
289830
|
port.on("error", (err) => {
|
|
289812
289831
|
instance.dataCallback?.(`\x1B[31m\u2718 Serial error: ${err.message}\x1B[0m\r
|
|
289813
289832
|
`);
|
|
289833
|
+
instance.failed = true;
|
|
289814
289834
|
instance.exitCallback?.(-1);
|
|
289835
|
+
this.instances.delete(ptyId);
|
|
289836
|
+
this.failedIds.add(ptyId);
|
|
289815
289837
|
});
|
|
289816
289838
|
return Promise.resolve(ptyId);
|
|
289817
289839
|
}
|
|
@@ -289822,6 +289844,7 @@ var SerialBackend = class {
|
|
|
289822
289844
|
resize(_ptyId, _cols, _rows) {
|
|
289823
289845
|
}
|
|
289824
289846
|
kill(ptyId) {
|
|
289847
|
+
this.failedIds.delete(ptyId);
|
|
289825
289848
|
const inst = this.instances.get(ptyId);
|
|
289826
289849
|
if (!inst) return;
|
|
289827
289850
|
this.instances.delete(ptyId);
|
|
@@ -289853,8 +289876,9 @@ var SerialBackend = class {
|
|
|
289853
289876
|
}
|
|
289854
289877
|
getInitializationState(ptyId) {
|
|
289855
289878
|
const inst = this.instances.get(ptyId);
|
|
289856
|
-
if (
|
|
289857
|
-
|
|
289879
|
+
if (inst) return inst.ready ? "ready" : void 0;
|
|
289880
|
+
if (this.failedIds.has(ptyId)) return "failed";
|
|
289881
|
+
return void 0;
|
|
289858
289882
|
}
|
|
289859
289883
|
// --- Serial-specific controls (v3.0.5) ---
|
|
289860
289884
|
/** Send a BREAK signal (Cisco password recovery / ROMMON). Default 500ms. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuralos",
|
|
3
|
-
"version": "3.1.
|
|
4
|
-
"description": "Headless AI-native backend for RTerm / neuralOS — v3.1.
|
|
3
|
+
"version": "3.1.6",
|
|
4
|
+
"description": "Headless AI-native backend for RTerm / neuralOS — v3.1.6: full-duplex Synapse agent (responder, emit/subscribe, EXT-REPUTATION scoring, EXT-GOVERNANCE approvals) — RTerm speaks all six Synapse primitives + both extensions. 11 plugins. Transports (SSH/serial/local), SQLite, and NATS libs install automatically.",
|
|
5
5
|
"main": "bin/gybackend.cjs",
|
|
6
6
|
"bin": { "gybackend": "bin/gybackend.cjs" },
|
|
7
7
|
"license": "MIT",
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
|
|
20
20
|
import { createRequire } from 'node:module'
|
|
21
21
|
import { randomUUID } from 'node:crypto'
|
|
22
|
+
import {
|
|
23
|
+
startResponder, buildRespond, executeSkill,
|
|
24
|
+
emitEvent, subscribeEvents,
|
|
25
|
+
ReputationStore, computeScore, updateRecord, newRecord,
|
|
26
|
+
requestApproval, respondApproval, startApprover,
|
|
27
|
+
} from './synapseAgent.mjs'
|
|
22
28
|
|
|
23
29
|
const require = createRequire(import.meta.url)
|
|
24
30
|
const enc = new TextEncoder()
|
|
@@ -75,20 +81,47 @@ function buildAuthenticator(t, auth) {
|
|
|
75
81
|
return undefined
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
|
|
84
|
+
// Connection cache keyed by config fingerprint — a settings change (different
|
|
85
|
+
// server/auth/agentId) opens a NEW connection instead of reusing a stale one to
|
|
86
|
+
// the wrong server. A failed connect clears the slot so the next call retries.
|
|
87
|
+
const _conns = new Map() // key -> Promise<conn> | conn
|
|
88
|
+
function _configKey(cfg) {
|
|
89
|
+
const servers = Array.isArray(cfg.servers) ? cfg.servers.join(',') : cfg.servers
|
|
90
|
+
const authKeys = cfg.auth ? Object.keys(cfg.auth).sort().join(',') : ''
|
|
91
|
+
return `${servers}|${cfg.agentId}|${authKeys}`
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
async function connectMesh(ctx) {
|
|
80
|
-
if (_conn && !_conn.isClosed()) return _conn
|
|
81
95
|
const cfg = resolveConfig(ctx)
|
|
96
|
+
const key = _configKey(cfg)
|
|
97
|
+
const existing = _conns.get(key)
|
|
98
|
+
if (existing) {
|
|
99
|
+
const c = await existing
|
|
100
|
+
if (c && !c.isClosed()) return c
|
|
101
|
+
_conns.delete(key) // stale/closed — fall through and reconnect
|
|
102
|
+
}
|
|
82
103
|
const t = loadTransport()
|
|
83
104
|
const auth = buildAuthenticator(t, resolveAuth(ctx, cfg.auth))
|
|
84
105
|
const copts = { servers: cfg.servers, name: cfg.agentId, ...(auth ? { authenticator: auth } : {}) }
|
|
85
106
|
const connectFn = (typeof ctx.natsConnect === 'function') ? ctx.natsConnect : (o) => t.connect(o)
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
const p = (async () => {
|
|
108
|
+
try {
|
|
109
|
+
return await connectFn(copts)
|
|
110
|
+
} catch (e) {
|
|
111
|
+
_conns.delete(key) // don't cache a failed attempt — allow retry
|
|
112
|
+
throw e
|
|
113
|
+
}
|
|
114
|
+
})()
|
|
115
|
+
_conns.set(key, p)
|
|
116
|
+
return p
|
|
88
117
|
}
|
|
89
118
|
|
|
90
|
-
/** Test hook: inject a fake connection. */
|
|
91
|
-
export function __setConnForTest(c) {
|
|
119
|
+
/** Test hook: inject a fake connection for a given config (or clear all with null). */
|
|
120
|
+
export function __setConnForTest(c, cfg) {
|
|
121
|
+
if (c === null || c === undefined) { _conns.clear(); return }
|
|
122
|
+
const key = _configKey(cfg ?? resolveConfig({ settings: {} }))
|
|
123
|
+
_conns.set(key, Promise.resolve(c))
|
|
124
|
+
}
|
|
92
125
|
|
|
93
126
|
// ─── Synapse envelope ───────────────────────────────────────────────────────
|
|
94
127
|
|
|
@@ -228,6 +261,109 @@ export function register(ctx) {
|
|
|
228
261
|
}, log),
|
|
229
262
|
})
|
|
230
263
|
|
|
264
|
+
// ─── full-duplex agent capabilities (responder, emit/subscribe, reputation, governance) ───
|
|
265
|
+
const repStore = new ReputationStore()
|
|
266
|
+
let responderStop = null
|
|
267
|
+
|
|
268
|
+
registerTool({
|
|
269
|
+
name: 'synapse_serve',
|
|
270
|
+
description: 'Start RTerm as a full Synapse agent: listen on mesh.agent.{id}.inbox and respond() to incoming Synapse requests by mapping them to RTerm skills (playbooks/tools via ctx.rtermSkills / getRtermSkills). Bidirectional federation — other agents can now task RTerm.',
|
|
271
|
+
params: {
|
|
272
|
+
skills: { type: 'object', description: 'Map of skillId -> async (input, ctx) => output, the skills RTerm serves', optional: true },
|
|
273
|
+
},
|
|
274
|
+
handler: async (p) => guarded(async () => {
|
|
275
|
+
const nc = await connectMesh(ctx)
|
|
276
|
+
const serveCtx = { ...ctx, rtermSkills: p?.skills ?? ctx.rtermSkills ?? {} }
|
|
277
|
+
if (responderStop) responderStop() // idempotent: restart with fresh skills
|
|
278
|
+
responderStop = await startResponder(nc, cfg, serveCtx, log)
|
|
279
|
+
const skillIds = Object.keys(serveCtx.rtermSkills)
|
|
280
|
+
return { serving: true, inbox: `${cfg.prefix}.agent.${cfg.agentId}.inbox`, skills: skillIds, note: 'RTerm is now a full Synapse agent (responder live)' }
|
|
281
|
+
}, log),
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
registerTool({
|
|
285
|
+
name: 'synapse_emit',
|
|
286
|
+
description: 'Emit a formal Synapse event on mesh.event.{type} (fire-and-forget broadcast to subscribers).',
|
|
287
|
+
params: {
|
|
288
|
+
type: { type: 'string', description: 'Event type, e.g. ops.change.committed' },
|
|
289
|
+
payload: { type: 'object', description: 'Event payload' },
|
|
290
|
+
},
|
|
291
|
+
handler: async (p) => guarded(async () => {
|
|
292
|
+
if (!p?.type) return { error: 'synapse_emit needs a type' }
|
|
293
|
+
const nc = await connectMesh(ctx)
|
|
294
|
+
emitEvent(nc, cfg, p.type, p.payload ?? {})
|
|
295
|
+
return { emitted: `${cfg.prefix}.event.${p.type}` }
|
|
296
|
+
}, log),
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
registerTool({
|
|
300
|
+
name: 'synapse_subscribe',
|
|
301
|
+
description: 'Subscribe to Synapse event/task subjects (supports wildcards, e.g. mesh.event.> or mesh.task.>.update). Events feed the local reputation store and the synapse_mesh_event trigger.',
|
|
302
|
+
params: {
|
|
303
|
+
subject: { type: 'string', description: 'Subject pattern, e.g. mesh.event.> or mesh.task.>.update' },
|
|
304
|
+
},
|
|
305
|
+
handler: async (p) => guarded(async () => {
|
|
306
|
+
const subject = p?.subject ?? `${cfg.prefix}.task.>.update`
|
|
307
|
+
const nc = await connectMesh(ctx)
|
|
308
|
+
const stop = await subscribeEvents(nc, subject, (env, subj) => {
|
|
309
|
+
repStore.handleTaskUpdate(env)
|
|
310
|
+
if (typeof ctx.emitEvent === 'function') ctx.emitEvent({ source: 'synapse', subject: subj, env })
|
|
311
|
+
})
|
|
312
|
+
return { subscribed: subject, note: 'events feed the reputation store + synapse_mesh_event trigger' }
|
|
313
|
+
}, log),
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
registerTool({
|
|
317
|
+
name: 'synapse_reputation',
|
|
318
|
+
description: 'Read the local Synapse reputation store (EXT-REPUTATION): per (agent, skill) success_rate, speed_score, freshness, composite score. Optionally record an outcome or list ranked agents.',
|
|
319
|
+
params: {
|
|
320
|
+
agent: { type: 'string', optional: true },
|
|
321
|
+
skill: { type: 'string', optional: true },
|
|
322
|
+
minScore: { type: 'number', description: 'Only agents at/above this score (discover-ranked)', optional: true },
|
|
323
|
+
},
|
|
324
|
+
handler: async (p) => guarded(async () => {
|
|
325
|
+
if (p?.agent && p?.skill) {
|
|
326
|
+
const rec = repStore.get(p.agent, p.skill)
|
|
327
|
+
return rec ?? { error: `no record for ${p.agent}::${p.skill}` }
|
|
328
|
+
}
|
|
329
|
+
const ranked = repStore.ranked(p?.minScore ?? 0)
|
|
330
|
+
return { count: ranked.length, agents: ranked.map((r) => ({ agent: r.agent_id, skill: r.skill, score: Number(r.score.toFixed(3)), successRate: Number(r.success_rate.toFixed(3)), speedScore: Number(r.speed_score.toFixed(3)), confidence: r.confidence })) }
|
|
331
|
+
}, log),
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
registerTool({
|
|
335
|
+
name: 'synapse_request_approval',
|
|
336
|
+
description: 'Request governance approval for a gated action (EXT-GOVERNANCE): publish mesh.approval.{taskId}.request and await the response. Use before a high-risk dispatched task.',
|
|
337
|
+
params: {
|
|
338
|
+
originalRequest: { type: 'object', description: 'The original request payload being gated' },
|
|
339
|
+
policyId: { type: 'string', optional: true },
|
|
340
|
+
ruleId: { type: 'string', optional: true },
|
|
341
|
+
reason: { type: 'string', description: 'Why approval is required' },
|
|
342
|
+
taskId: { type: 'string', optional: true },
|
|
343
|
+
timeout: { type: 'number', optional: true },
|
|
344
|
+
},
|
|
345
|
+
handler: async (p) => guarded(async () => {
|
|
346
|
+
const nc = await connectMesh(ctx)
|
|
347
|
+
const r = await requestApproval(nc, cfg, { taskId: p?.taskId, originalRequest: p?.originalRequest, policyId: p?.policyId, ruleId: p?.ruleId, reason: p?.reason ?? 'RTerm action requires mesh approval', timeout: p?.timeout })
|
|
348
|
+
return r.approved ? { approved: true, approver: r.approver, taskId: r.taskId } : { approved: false, taskId: r.taskId, note: 'denied or timed out' }
|
|
349
|
+
}, log),
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
registerTool({
|
|
353
|
+
name: 'synapse_approve',
|
|
354
|
+
description: 'Act as a governance approver (EXT-GOVERNANCE): listen on mesh.approval.*.request and answer each per a policy (allow-all, deny-all, or a decide map). Other agents route their gated actions through RTerm for approval.',
|
|
355
|
+
params: {
|
|
356
|
+
policy: { type: 'string', description: 'allow-all | deny-all (default allow-all)', optional: true },
|
|
357
|
+
},
|
|
358
|
+
handler: async (p) => guarded(async () => {
|
|
359
|
+
const nc = await connectMesh(ctx)
|
|
360
|
+
const policy = p?.policy ?? 'allow-all'
|
|
361
|
+
const decide = async () => ({ approved: policy !== 'deny-all', approver: `did:mesh:${cfg.agentId}` })
|
|
362
|
+
await startApprover(nc, cfg, decide, log)
|
|
363
|
+
return { approver: cfg.agentId, policy, listening: `${cfg.prefix}.approval.*.request` }
|
|
364
|
+
}, log),
|
|
365
|
+
})
|
|
366
|
+
|
|
231
367
|
registerTrigger({
|
|
232
368
|
name: 'synapse_mesh_event',
|
|
233
369
|
description: 'Fires when a Synapse mesh event (task failure, reputation penalty, approval request) is observed. Use for cross-mesh remediation.',
|
|
@@ -246,7 +382,7 @@ export function register(ctx) {
|
|
|
246
382
|
},
|
|
247
383
|
})
|
|
248
384
|
|
|
249
|
-
log(`[synapse] synapse-bridge registered:
|
|
385
|
+
log(`[synapse] synapse-bridge registered: 11 tools, 1 trigger, 1 panel (agent=${cfg.agentId}, prefix=${cfg.prefix}, full-duplex)`)
|
|
250
386
|
}
|
|
251
387
|
|
|
252
388
|
export default { register, resolveConfig, envelope, discoverAgents, dispatchTask, registerSelf }
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synapse-bridge",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"description": "Synapse mesh bridge for RTerm — discover live
|
|
4
|
+
"description": "Synapse mesh bridge for RTerm — full-duplex Synapse agent: discover live agents, dispatch tasks, register RTerm as a mesh agent, AND serve as one (respond to inbound requests), emit/subscribe mesh events, EXT-REPUTATION scoring, and EXT-GOVERNANCE approvals. Speaks the Synapse protocol (v0.3.0) over a shared NATS server using the v3.1.2 auth/request-reply/JetStream transport. Config in Settings (synapse block); secrets via the vault.",
|
|
5
5
|
"entry": "index.mjs",
|
|
6
6
|
"tools": [
|
|
7
7
|
"synapse_health",
|
|
8
8
|
"synapse_discover",
|
|
9
9
|
"synapse_dispatch",
|
|
10
10
|
"synapse_register",
|
|
11
|
-
"synapse_agents_summary"
|
|
11
|
+
"synapse_agents_summary",
|
|
12
|
+
"synapse_serve",
|
|
13
|
+
"synapse_emit",
|
|
14
|
+
"synapse_subscribe",
|
|
15
|
+
"synapse_reputation",
|
|
16
|
+
"synapse_request_approval",
|
|
17
|
+
"synapse_approve"
|
|
12
18
|
],
|
|
13
19
|
"triggers": [
|
|
14
20
|
"synapse_mesh_event"
|
|
@@ -69,12 +69,12 @@ test('envelope has Synapse v0.3.0 shape', () => {
|
|
|
69
69
|
|
|
70
70
|
// ─── register wiring ────────────────────────────────────────────────────────
|
|
71
71
|
|
|
72
|
-
test('register wires
|
|
72
|
+
test('register wires 11 tools, 1 trigger, 1 panel (full-duplex)', () => {
|
|
73
73
|
const conn = fakeConn()
|
|
74
74
|
const { tools, triggers, panels, ctx } = mkCtx({}, conn)
|
|
75
75
|
register(ctx)
|
|
76
|
-
eq(tools.size,
|
|
77
|
-
for (const n of ['synapse_health', 'synapse_discover', 'synapse_dispatch', 'synapse_register', 'synapse_agents_summary']) assert(tools.has(n), `missing ${n}`)
|
|
76
|
+
eq(tools.size, 11, 'tool count')
|
|
77
|
+
for (const n of ['synapse_health', 'synapse_discover', 'synapse_dispatch', 'synapse_register', 'synapse_agents_summary', 'synapse_serve', 'synapse_emit', 'synapse_subscribe', 'synapse_reputation', 'synapse_request_approval', 'synapse_approve']) assert(tools.has(n), `missing ${n}`)
|
|
78
78
|
eq(triggers.length, 1, 'trigger count')
|
|
79
79
|
eq(triggers[0].name, 'synapse_mesh_event', 'trigger name')
|
|
80
80
|
eq(panels.length, 1, 'panel count')
|
|
@@ -161,6 +161,51 @@ test('synapse_mesh_event trigger matches only synapse-source events', () => {
|
|
|
161
161
|
assert(!t.match({}), 'rejects empty')
|
|
162
162
|
})
|
|
163
163
|
|
|
164
|
+
// ─── connection cache (config-keyed, the stale-connection bug fix) ──────────
|
|
165
|
+
|
|
166
|
+
test('connection is keyed by config — a settings change opens a NEW connection (no stale reuse)', async () => {
|
|
167
|
+
__setConnForTest(null)
|
|
168
|
+
const connA = fakeConn()
|
|
169
|
+
const connB = fakeConn()
|
|
170
|
+
const connsMade = []
|
|
171
|
+
// ctx whose natsConnect returns a different fake per call, tracking which config connected
|
|
172
|
+
const mkCtxMulti = (settings) => ({
|
|
173
|
+
settings: { synapse: settings },
|
|
174
|
+
natsConnect: async (copts) => { connsMade.push(copts); return connsMade.length === 1 ? connA : connB },
|
|
175
|
+
registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
|
|
176
|
+
})
|
|
177
|
+
// connect with config A (server A)
|
|
178
|
+
const { discoverAgents: dA } = await import('./index.mjs')
|
|
179
|
+
connA._on('mesh.registry.discover', () => [])
|
|
180
|
+
await dA(mkCtxMulti({ url: 'nats://a:4222' }), {})
|
|
181
|
+
if (connsMade.length !== 1) throw new Error(`expected 1 connection for config A, got ${connsMade.length}`)
|
|
182
|
+
// same config A again — must REUSE (no new connection)
|
|
183
|
+
await dA(mkCtxMulti({ url: 'nats://a:4222' }), {})
|
|
184
|
+
if (connsMade.length !== 1) throw new Error(`expected reuse for same config A, got ${connsMade.length} connections`)
|
|
185
|
+
// config B (different server) — must open a NEW connection (the bug was reusing A's)
|
|
186
|
+
connB._on('mesh.registry.discover', () => [])
|
|
187
|
+
await dA(mkCtxMulti({ url: 'nats://b:4222' }), {})
|
|
188
|
+
if (connsMade.length !== 2) throw new Error(`expected a NEW connection for config B, got ${connsMade.length}`)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('a failed connect is not cached — the next call retries', async () => {
|
|
192
|
+
__setConnForTest(null)
|
|
193
|
+
const conn = fakeConn()
|
|
194
|
+
conn._on('mesh.registry.discover', () => [])
|
|
195
|
+
let attempts = 0
|
|
196
|
+
const ctx = {
|
|
197
|
+
settings: { synapse: { url: 'nats://a:4222' } },
|
|
198
|
+
natsConnect: async () => { attempts++; if (attempts === 1) throw new Error('down'); return conn },
|
|
199
|
+
registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
|
|
200
|
+
}
|
|
201
|
+
const { discoverAgents } = await import('./index.mjs')
|
|
202
|
+
let threw = false
|
|
203
|
+
try { await discoverAgents(ctx, {}) } catch { threw = true }
|
|
204
|
+
if (!threw) throw new Error('expected first attempt to throw')
|
|
205
|
+
await discoverAgents(ctx, {}) // retry succeeds
|
|
206
|
+
if (attempts !== 2) throw new Error(`expected 2 attempts (fail + retry), got ${attempts}`)
|
|
207
|
+
})
|
|
208
|
+
|
|
164
209
|
// ─── runner ─────────────────────────────────────────────────────────────────
|
|
165
210
|
async function main() {
|
|
166
211
|
let pass = 0, fail = 0
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildRespond, executeSkill, startResponder,
|
|
3
|
+
emitEvent, subscribeEvents,
|
|
4
|
+
ReputationStore, computeScore, updateRecord, newRecord, REP_WEIGHTS,
|
|
5
|
+
requestApproval, respondApproval, startApprover,
|
|
6
|
+
} from './synapseAgent.mjs'
|
|
7
|
+
|
|
8
|
+
const cases = []
|
|
9
|
+
function test(n, r) { cases.push({ name: n, run: r }) }
|
|
10
|
+
function assert(c, m) { if (!c) throw new Error(m ?? 'assertion failed') }
|
|
11
|
+
function eq(a, b, m) { if (a !== b) throw new Error(`${m ?? 'eq'}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`) }
|
|
12
|
+
|
|
13
|
+
const enc = new TextEncoder()
|
|
14
|
+
const dec = new TextDecoder()
|
|
15
|
+
const CFG = { agentId: 'rterm-001', prefix: 'mesh' }
|
|
16
|
+
|
|
17
|
+
// ─── fake NATS connection (pub/sub + request/reply) ─────────────────────────
|
|
18
|
+
// NATS wildcard grammar: '*' matches one token, '>' matches one or more (suffix).
|
|
19
|
+
function subjectMatches(pattern, subject) {
|
|
20
|
+
if (pattern === subject) return true
|
|
21
|
+
const p = pattern.split('.')
|
|
22
|
+
const s = subject.split('.')
|
|
23
|
+
for (let i = 0; i < p.length; i++) {
|
|
24
|
+
if (p[i] === '>') return true
|
|
25
|
+
if (i >= s.length) return false
|
|
26
|
+
if (p[i] !== '*' && p[i] !== s[i]) return false
|
|
27
|
+
}
|
|
28
|
+
return p.length === s.length
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fakeConn() {
|
|
32
|
+
const published = []
|
|
33
|
+
const subs = new Map()
|
|
34
|
+
const requests = new Map()
|
|
35
|
+
const deliver = (subject, data, respondFn) => {
|
|
36
|
+
for (const [pattern, fns] of subs) {
|
|
37
|
+
if (subjectMatches(pattern, subject)) {
|
|
38
|
+
for (const fn of fns) fn({ data, subject, respond: respondFn })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
isClosed: () => false,
|
|
44
|
+
published,
|
|
45
|
+
publish(subject, data) {
|
|
46
|
+
const env = JSON.parse(dec.decode(data))
|
|
47
|
+
published.push({ subject, env })
|
|
48
|
+
deliver(subject, data, (p) => { published.push({ subject: '_reply', env: JSON.parse(dec.decode(p)) }) })
|
|
49
|
+
},
|
|
50
|
+
subscribe(subject) {
|
|
51
|
+
return {
|
|
52
|
+
async *[Symbol.asyncIterator]() {
|
|
53
|
+
while (true) {
|
|
54
|
+
const m = await new Promise((res) => {
|
|
55
|
+
const l = subs.get(subject) ?? []; subs.set(subject, l); l.push(res)
|
|
56
|
+
})
|
|
57
|
+
yield m
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
unsubscribe: () => subs.delete(subject),
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async request(subject, data, _o) {
|
|
64
|
+
const env = JSON.parse(dec.decode(data))
|
|
65
|
+
const h = requests.get(subject)
|
|
66
|
+
const reply = h ? h(env) : { payload: { approved: true, approver: 'did:mesh:approver' } }
|
|
67
|
+
return { data: enc.encode(JSON.stringify(reply)) }
|
|
68
|
+
},
|
|
69
|
+
_deliver(subject, env) {
|
|
70
|
+
const data = enc.encode(JSON.stringify(env))
|
|
71
|
+
deliver(subject, data, (p) => { published.push({ subject: '_reply', env: JSON.parse(dec.decode(p)) }) })
|
|
72
|
+
},
|
|
73
|
+
_onRequest(subject, h) { requests.set(subject, h) },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── 1. RESPONDER ────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
test('buildRespond: output envelope has to/task_id/in_reply_to + output payload', () => {
|
|
80
|
+
const req = { id: 'req-1', from: 'caller-001', task_id: 'task-9', payload: { skill: 'x' } }
|
|
81
|
+
const r = buildRespond(req, CFG, { output: { result: 42 } })
|
|
82
|
+
eq(r.type, 'respond', 'type')
|
|
83
|
+
eq(r.to, 'caller-001', 'to = original from')
|
|
84
|
+
eq(r.task_id, 'task-9', 'task_id')
|
|
85
|
+
eq(r.in_reply_to, 'req-1', 'in_reply_to = original id')
|
|
86
|
+
eq(r.payload.output.result, 42, 'output payload')
|
|
87
|
+
assert(!r.payload.error, 'no error when output present')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('buildRespond: error envelope has error payload, no output; output+error throws', () => {
|
|
91
|
+
const req = { id: 'r2', from: 'c', task_id: 't' }
|
|
92
|
+
const r = buildRespond(req, CFG, { error: { code: 3001, message: 'nf' } })
|
|
93
|
+
eq(r.payload.error.code, 3001, 'error code')
|
|
94
|
+
assert(!r.payload.output, 'no output when error present')
|
|
95
|
+
let threw = false
|
|
96
|
+
try { buildRespond(req, CFG, { output: {}, error: { code: 1 } }) } catch { threw = true }
|
|
97
|
+
assert(threw, 'output+error must throw')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('executeSkill: known skill returns output; unknown returns 3001 SKILL_NOT_FOUND', async () => {
|
|
101
|
+
const ctx = { rtermSkills: { greet: async (inp) => ({ hello: inp.name }) } }
|
|
102
|
+
const ok = await executeSkill('greet', { name: 'mesh' }, ctx)
|
|
103
|
+
eq(ok.output.hello, 'mesh', 'skill output')
|
|
104
|
+
const nf = await executeSkill('nope', {}, ctx)
|
|
105
|
+
eq(nf.error.code, 3001, 'SKILL_NOT_FOUND code')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('startResponder: inbound request executes skill + responds on reply inbox', async () => {
|
|
109
|
+
const nc = fakeConn()
|
|
110
|
+
const ctx = { rtermSkills: { status: async () => ({ up: true }) } }
|
|
111
|
+
const stop = await startResponder(nc, CFG, ctx)
|
|
112
|
+
nc._deliver('mesh.agent.rterm-001.inbox', { id: 'q1', from: 'caller-001', task_id: 't1', payload: { skill: 'status', input: {} } })
|
|
113
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
114
|
+
const reply = nc.published.find((p) => p.subject === '_reply')
|
|
115
|
+
assert(reply, 'expected a respond on the reply inbox')
|
|
116
|
+
eq(reply.env.type, 'respond', 'respond type')
|
|
117
|
+
eq(reply.env.to, 'caller-001', 'respond to caller')
|
|
118
|
+
eq(reply.env.payload.output.up, true, 'skill output delivered')
|
|
119
|
+
stop()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// ─── 2. EMIT / SUBSCRIBE ─────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
test('emitEvent publishes a formal emit envelope on mesh.event.{type}', () => {
|
|
125
|
+
const nc = fakeConn()
|
|
126
|
+
emitEvent(nc, CFG, 'ops.change.committed', { changeId: 'chg-1' })
|
|
127
|
+
const pub = nc.published.find((p) => p.subject === 'mesh.event.ops.change.committed')
|
|
128
|
+
assert(pub, 'expected emit publish')
|
|
129
|
+
eq(pub.env.type, 'emit', 'emit type')
|
|
130
|
+
eq(pub.env.payload.changeId, 'chg-1', 'emit payload')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('subscribeEvents invokes handler for each event on the subject', async () => {
|
|
134
|
+
const nc = fakeConn()
|
|
135
|
+
const seen = []
|
|
136
|
+
const stop = await subscribeEvents(nc, 'mesh.event.>', (env, subj) => seen.push({ env, subj }))
|
|
137
|
+
nc._deliver('mesh.event.>', { type: 'emit', payload: { n: 1 } })
|
|
138
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
139
|
+
eq(seen.length, 1, 'one event seen')
|
|
140
|
+
stop()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ─── 3. REPUTATION ───────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
test('computeScore: perfect record scores high', () => {
|
|
146
|
+
const rec = newRecord('a1', 'respond')
|
|
147
|
+
for (let i = 0; i < 5; i++) updateRecord(rec, { status: 'completed', latencyMs: 100 })
|
|
148
|
+
const s = computeScore(rec)
|
|
149
|
+
eq(s.success_rate, 1, 'success_rate 1')
|
|
150
|
+
assert(s.score > 0.8, `high score expected, got ${s.score}`)
|
|
151
|
+
eq(s.confidence, 1.0, 'full confidence at >=min samples')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('updateRecord: failures lower success_rate + score', () => {
|
|
155
|
+
const rec = newRecord('a2', 'respond')
|
|
156
|
+
updateRecord(rec, { status: 'completed', latencyMs: 100 })
|
|
157
|
+
updateRecord(rec, { status: 'failed' })
|
|
158
|
+
updateRecord(rec, { status: 'timeout' })
|
|
159
|
+
const s = computeScore(rec)
|
|
160
|
+
assert(s.success_rate < 0.5, `success_rate should be low, got ${s.success_rate}`)
|
|
161
|
+
assert(s.score < 0.7, `score should be lower, got ${s.score}`)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('reputation: 3 consecutive SKILL_NOT_FOUND flags lying-agent + zeroes score', () => {
|
|
165
|
+
const rec = newRecord('liar', 'respond')
|
|
166
|
+
updateRecord(rec, { status: 'skill_not_found' })
|
|
167
|
+
updateRecord(rec, { status: 'skill_not_found' })
|
|
168
|
+
updateRecord(rec, { status: 'skill_not_found' })
|
|
169
|
+
assert(rec.flags.misleading_capabilities, 'lying-agent flag set')
|
|
170
|
+
eq(rec.flags.penalty_reason.includes('SKILL_NOT_FOUND'), true, 'penalty reason')
|
|
171
|
+
const s = computeScore(rec)
|
|
172
|
+
eq(s.score, 0, 'lying penalty zeroes the score')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('ReputationStore: observe + ranked + handleTaskUpdate', () => {
|
|
176
|
+
const store = new ReputationStore()
|
|
177
|
+
store.observe('a1', 'respond', { status: 'completed', latencyMs: 50 })
|
|
178
|
+
store.observe('a1', 'respond', { status: 'completed', latencyMs: 60 })
|
|
179
|
+
store.observe('a2', 'respond', { status: 'failed' })
|
|
180
|
+
const rec = store.get('a1', 'respond')
|
|
181
|
+
eq(rec.successes, 2, 'two successes recorded')
|
|
182
|
+
// task_update event path
|
|
183
|
+
store.handleTaskUpdate({ payload: { agent: 'a1', skill: 'respond', status: 'completed', latencyMs: 40 } })
|
|
184
|
+
eq(store.get('a1', 'respond').successes, 3, 'task_update fed the store')
|
|
185
|
+
const ranked = store.ranked(0)
|
|
186
|
+
assert(ranked.length >= 2, 'ranked returns all')
|
|
187
|
+
eq(ranked[0].agent_id, 'a1', 'a1 (higher score) ranks first')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ─── 4. GOVERNANCE ───────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
test('requestApproval publishes approval_request + returns approved response', async () => {
|
|
193
|
+
const nc = fakeConn()
|
|
194
|
+
let captured
|
|
195
|
+
nc._onRequest('mesh.approval.task-1.request', (env) => { captured = env; return { payload: { approved: true, approver: 'did:mesh:approver-001' } } })
|
|
196
|
+
const r = await requestApproval(nc, CFG, { taskId: 'task-1', originalRequest: { skill: 'pay' }, reason: 'payment needs approval' })
|
|
197
|
+
eq(captured.type, 'approval_request', 'request type')
|
|
198
|
+
eq(captured.task_id, 'task-1', 'request task_id')
|
|
199
|
+
eq(captured.payload.reason, 'payment needs approval', 'reason')
|
|
200
|
+
eq(r.approved, true, 'approved')
|
|
201
|
+
eq(r.approver, 'did:mesh:approver-001', 'approver')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('respondApproval publishes approval_response on mesh.approval.{taskId}.response', () => {
|
|
205
|
+
const nc = fakeConn()
|
|
206
|
+
const req = { id: 'ar-1', from: 'agent-bob-001', task_id: 'task-7' }
|
|
207
|
+
respondApproval(nc, CFG, req, { approved: false, approver: 'did:mesh:rterm-001' })
|
|
208
|
+
const pub = nc.published.find((p) => p.subject === 'mesh.approval.task-7.response')
|
|
209
|
+
assert(pub, 'expected approval_response publish')
|
|
210
|
+
eq(pub.env.type, 'approval_response', 'response type')
|
|
211
|
+
eq(pub.env.to, 'agent-bob-001', 'to original requester')
|
|
212
|
+
eq(pub.env.payload.approved, false, 'denied')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('startApprover answers inbound approval requests per the decide fn', async () => {
|
|
216
|
+
const nc = fakeConn()
|
|
217
|
+
const stop = await startApprover(nc, CFG, async () => ({ approved: true, approver: 'did:mesh:rterm-001' }))
|
|
218
|
+
nc._deliver('mesh.approval.task-9.request', { id: 'ar-9', from: 'agent-x', task_id: 'task-9', type: 'approval_request', payload: {} })
|
|
219
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
220
|
+
const pub = nc.published.find((p) => p.subject === 'mesh.approval.task-9.response')
|
|
221
|
+
assert(pub, 'expected approver to answer')
|
|
222
|
+
eq(pub.env.payload.approved, true, 'approved by decide fn')
|
|
223
|
+
stop()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// ─── runner ─────────────────────────────────────────────────────────────────
|
|
227
|
+
async function main() {
|
|
228
|
+
let pass = 0, fail = 0
|
|
229
|
+
for (const c of cases) {
|
|
230
|
+
try { await c.run(); pass++; console.log(`PASS ${c.name}`) }
|
|
231
|
+
catch (e) { fail++; console.log(`FAIL ${c.name}: ${e?.message ?? e}`) }
|
|
232
|
+
}
|
|
233
|
+
console.log(`\n${pass}/${pass + fail} passed, ${fail} failed`)
|
|
234
|
+
if (fail > 0) process.exit(1)
|
|
235
|
+
}
|
|
236
|
+
main()
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* synapseAgent — full-duplex Synapse agent capabilities for RTerm/neuralOS.
|
|
3
|
+
*
|
|
4
|
+
* Implements the four pieces that turn RTerm from a Synapse *client* into a full
|
|
5
|
+
* Synapse *agent* (EXT-GOVERNANCE + EXT-REPUTATION aware):
|
|
6
|
+
*
|
|
7
|
+
* 1. RESPONDER — listen on mesh.agent.{id}.inbox and respond() to incoming
|
|
8
|
+
* Synapse requests by mapping them to RTerm skills (playbooks/tools).
|
|
9
|
+
* 2. EMIT/SUBSCRIBE — formal Synapse `emit` on mesh.event.{type} + wildcard
|
|
10
|
+
* subscribe to mesh.event.* / mesh.task.* streams.
|
|
11
|
+
* 3. REPUTATION (EXT-REPUTATION) — observe task outcomes and maintain a local
|
|
12
|
+
* ReputationRecord per (agent, skill): success_rate, speed_score, freshness,
|
|
13
|
+
* composite score with lying-penalty + confidence, per Formula 11.5.
|
|
14
|
+
* 4. GOVERNANCE (EXT-GOVERNANCE) — speak mesh.approval.{task_id}.request/.response:
|
|
15
|
+
* request approval for gated actions and answer approval requests (approver side).
|
|
16
|
+
*
|
|
17
|
+
* Transport is the injected NATS connection from the plugin (connectMesh). All pure
|
|
18
|
+
* logic (score formula, response building, record updates) is dependency-free + testable.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomUUID } from 'node:crypto'
|
|
22
|
+
import { envelope } from './index.mjs'
|
|
23
|
+
|
|
24
|
+
const enc = new TextEncoder()
|
|
25
|
+
const dec = new TextDecoder()
|
|
26
|
+
const j = (v) => enc.encode(JSON.stringify(v))
|
|
27
|
+
const uj = (b) => JSON.parse(dec.decode(b))
|
|
28
|
+
|
|
29
|
+
// ─── 1. RESPONDER ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Build a Synapse respond envelope (payload has output XOR error). */
|
|
32
|
+
export function buildRespond(requestEnv, cfg, { output, error } = {}) {
|
|
33
|
+
if (output && error) throw new Error('respond must contain output OR error, not both')
|
|
34
|
+
const payload = error ? { error } : { output: output ?? {} }
|
|
35
|
+
return envelope('respond', payload, cfg, {
|
|
36
|
+
to: requestEnv?.from,
|
|
37
|
+
task_id: requestEnv?.task_id,
|
|
38
|
+
in_reply_to: requestEnv?.id,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Map a Synapse skill id to an RTerm handler. The plugin supplies handlers for
|
|
43
|
+
* the skills RTerm advertises (playbooks/tools). Returns {output} or {error}. */
|
|
44
|
+
export async function executeSkill(skillId, input, ctx) {
|
|
45
|
+
const skills = (typeof ctx.getRtermSkills === 'function' ? ctx.getRtermSkills() : ctx.rtermSkills) || {}
|
|
46
|
+
const handler = skills[skillId]
|
|
47
|
+
if (!handler) {
|
|
48
|
+
return { error: { code: 3001, message: `SKILL_NOT_FOUND: ${skillId}`, retryable: false } }
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const output = await handler(input ?? {}, ctx)
|
|
52
|
+
return { output: output ?? {} }
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return { error: { code: 5000, message: String(e?.message ?? e), retryable: true } }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Start the responder loop: subscribe to mesh.agent.{id}.inbox, execute each
|
|
59
|
+
* request, and respond on the reply inbox. Returns a stop function. */
|
|
60
|
+
export async function startResponder(nc, cfg, ctx, log = () => {}) {
|
|
61
|
+
const inbox = `${cfg.prefix}.agent.${cfg.agentId}.inbox`
|
|
62
|
+
const sub = nc.subscribe(inbox)
|
|
63
|
+
let stopped = false
|
|
64
|
+
const loop = (async () => {
|
|
65
|
+
for await (const msg of sub) {
|
|
66
|
+
if (stopped) break
|
|
67
|
+
;(async () => {
|
|
68
|
+
try {
|
|
69
|
+
const req = uj(msg.data)
|
|
70
|
+
const skillId = req?.payload?.skill
|
|
71
|
+
const input = req?.payload?.input
|
|
72
|
+
log(`[synapse] inbound request from ${req?.from} skill=${skillId} task=${req?.task_id}`)
|
|
73
|
+
const result = await executeSkill(skillId, input, ctx)
|
|
74
|
+
const respond = buildRespond(req, cfg, result)
|
|
75
|
+
msg.respond(j(respond))
|
|
76
|
+
} catch (e) {
|
|
77
|
+
try { msg.respond(j({ error: { code: 5000, message: String(e?.message ?? e), retryable: true } })) } catch { /* best-effort */ }
|
|
78
|
+
}
|
|
79
|
+
})()
|
|
80
|
+
}
|
|
81
|
+
})()
|
|
82
|
+
loop.catch(() => {})
|
|
83
|
+
return () => { stopped = true; try { sub.unsubscribe() } catch {} }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── 2. EMIT / SUBSCRIBE ─────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** Emit a formal Synapse event on mesh.event.{type}. */
|
|
89
|
+
export function emitEvent(nc, cfg, eventType, payload, extra = {}) {
|
|
90
|
+
nc.publish(`${cfg.prefix}.event.${eventType}`, j(envelope('emit', payload, cfg, extra)))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Subscribe to a Synapse event/task subject (supports wildcards). Returns stop fn. */
|
|
94
|
+
export async function subscribeEvents(nc, subject, handler) {
|
|
95
|
+
const sub = nc.subscribe(subject)
|
|
96
|
+
const loop = (async () => {
|
|
97
|
+
for await (const msg of sub) {
|
|
98
|
+
try { handler(uj(msg.data), msg.subject) } catch { /* ignore malformed */ }
|
|
99
|
+
}
|
|
100
|
+
})()
|
|
101
|
+
loop.catch(() => {})
|
|
102
|
+
return () => { try { sub.unsubscribe() } catch {} }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── 3. REPUTATION (EXT-REPUTATION) ──────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export const REP_WEIGHTS = { success: 0.7, speed: 0.2, freshness: 0.1 }
|
|
108
|
+
export const REP_DEFAULTS = { maxLatencyMs: 30000, freshnessHalfLifeHours: 24, minSampleSize: 3 }
|
|
109
|
+
|
|
110
|
+
/** A ReputationRecord for one (agent, skill) pair. */
|
|
111
|
+
export function newRecord(agentId, skill) {
|
|
112
|
+
return {
|
|
113
|
+
agent_id: agentId, skill,
|
|
114
|
+
total: 0, successes: 0, failures: 0, timeouts: 0,
|
|
115
|
+
skill_not_found: 0, overloaded: 0, rate_limited: 0,
|
|
116
|
+
latencies_ms: [],
|
|
117
|
+
success_rate: 0, speed_score: 0, freshness: 1, score: 0, confidence: 0.5,
|
|
118
|
+
flags: { misleading_capabilities: false, consecutive_skill_not_found: 0, last_penalty_at: null, penalty_reason: null },
|
|
119
|
+
last_seen: null,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Compute composite score per Formula 11.5. Pure. */
|
|
124
|
+
export function computeScore(rec, weights = REP_WEIGHTS, defaults = REP_DEFAULTS, now = Date.now()) {
|
|
125
|
+
const outcomes = rec.successes + rec.failures + rec.timeouts
|
|
126
|
+
const success_rate = rec.successes / Math.max(1, outcomes)
|
|
127
|
+
const avgLatency = rec.latencies_ms.length ? rec.latencies_ms.reduce((a, b) => a + b, 0) / rec.latencies_ms.length : 0
|
|
128
|
+
const speed_score = success_rate > 0 ? 1 - Math.min(1, Math.max(0, avgLatency / defaults.maxLatencyMs)) : 0
|
|
129
|
+
const hoursSince = rec.last_seen ? (now - rec.last_seen) / 3600000 : 0
|
|
130
|
+
const freshness = Math.exp(-hoursSince / defaults.freshnessHalfLifeHours)
|
|
131
|
+
const confidence = outcomes >= defaults.minSampleSize ? 1.0 : 0.5
|
|
132
|
+
const lying_penalty = rec.flags.misleading_capabilities ? 0.0 : 1.0
|
|
133
|
+
const raw = weights.success * success_rate + weights.speed * speed_score + weights.freshness * freshness
|
|
134
|
+
const score = raw * lying_penalty * confidence
|
|
135
|
+
return { success_rate, speed_score, freshness, confidence, score }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Update a record from an observed task outcome. Pure-ish (returns the mutated record).
|
|
139
|
+
* outcome: { status: 'completed'|'failed'|'timeout'|'skill_not_found'|'overloaded'|'rate_limited', latencyMs? } */
|
|
140
|
+
export function updateRecord(rec, outcome, defaults = REP_DEFAULTS, now = Date.now()) {
|
|
141
|
+
rec.total += 1
|
|
142
|
+
rec.last_seen = now
|
|
143
|
+
const st = outcome.status
|
|
144
|
+
if (st === 'completed') {
|
|
145
|
+
rec.successes += 1
|
|
146
|
+
rec.flags.consecutive_skill_not_found = 0
|
|
147
|
+
if (typeof outcome.latencyMs === 'number') rec.latencies_ms.push(outcome.latencyMs)
|
|
148
|
+
} else if (st === 'failed') {
|
|
149
|
+
rec.failures += 1
|
|
150
|
+
rec.flags.consecutive_skill_not_found = 0
|
|
151
|
+
} else if (st === 'timeout') {
|
|
152
|
+
rec.timeouts += 1
|
|
153
|
+
rec.flags.consecutive_skill_not_found = 0
|
|
154
|
+
} else if (st === 'skill_not_found') {
|
|
155
|
+
rec.skill_not_found += 1
|
|
156
|
+
rec.flags.consecutive_skill_not_found += 1
|
|
157
|
+
if (rec.flags.consecutive_skill_not_found >= 3) {
|
|
158
|
+
rec.flags.misleading_capabilities = true
|
|
159
|
+
rec.flags.penalty_reason = 'repeated SKILL_NOT_FOUND (lying-agent)'
|
|
160
|
+
rec.flags.last_penalty_at = new Date(now).toISOString()
|
|
161
|
+
}
|
|
162
|
+
} else if (st === 'overloaded') {
|
|
163
|
+
rec.overloaded += 1 // recorded, not scored
|
|
164
|
+
} else if (st === 'rate_limited') {
|
|
165
|
+
rec.rate_limited += 1 // recorded, not scored
|
|
166
|
+
}
|
|
167
|
+
const s = computeScore(rec, REP_WEIGHTS, defaults, now)
|
|
168
|
+
rec.success_rate = s.success_rate
|
|
169
|
+
rec.speed_score = s.speed_score
|
|
170
|
+
rec.freshness = s.freshness
|
|
171
|
+
rec.confidence = s.confidence
|
|
172
|
+
rec.score = s.score
|
|
173
|
+
return rec
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Local ReputationStore: keyed {agent}::{skill}, fed by task_update events. */
|
|
177
|
+
export class ReputationStore {
|
|
178
|
+
constructor(defaults = REP_DEFAULTS) {
|
|
179
|
+
this.defaults = defaults
|
|
180
|
+
this.records = new Map()
|
|
181
|
+
}
|
|
182
|
+
key(agentId, skill) { return `${agentId}::${skill}` }
|
|
183
|
+
get(agentId, skill) { return this.records.get(this.key(agentId, skill)) }
|
|
184
|
+
observe(agentId, skill, outcome, now = Date.now()) {
|
|
185
|
+
const k = this.key(agentId, skill)
|
|
186
|
+
let rec = this.records.get(k)
|
|
187
|
+
if (!rec) { rec = newRecord(agentId, skill); this.records.set(k, rec) }
|
|
188
|
+
return updateRecord(rec, outcome, this.defaults, now)
|
|
189
|
+
}
|
|
190
|
+
/** discover-ranked: all records at/above minScore, sorted by score desc. */
|
|
191
|
+
ranked(minScore = 0) {
|
|
192
|
+
return [...this.records.values()].filter((r) => r.score >= minScore).sort((a, b) => b.score - a.score)
|
|
193
|
+
}
|
|
194
|
+
/** Feed a mesh.task.{id}.update event (type task_update with payload {agent, skill, status, latencyMs?}). */
|
|
195
|
+
handleTaskUpdate(env, now = Date.now()) {
|
|
196
|
+
const p = env?.payload ?? {}
|
|
197
|
+
if (!p.agent || !p.skill || !p.status) return null
|
|
198
|
+
return this.observe(p.agent, p.skill, { status: p.status, latencyMs: p.latencyMs }, now)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── 4. GOVERNANCE (EXT-GOVERNANCE) ──────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/** Request approval for a gated action: publish mesh.approval.{taskId}.request and
|
|
205
|
+
* await the response. Returns { approved, approver } or { approved: false, error }. */
|
|
206
|
+
export async function requestApproval(nc, cfg, { taskId, originalRequest, policyId, ruleId, reason, timeout = 30000 }) {
|
|
207
|
+
const tid = taskId ?? randomUUID()
|
|
208
|
+
const env = envelope('approval_request', {
|
|
209
|
+
original_request: originalRequest ?? {},
|
|
210
|
+
policy_id: policyId,
|
|
211
|
+
rule_id: ruleId,
|
|
212
|
+
reason,
|
|
213
|
+
}, cfg, { task_id: tid })
|
|
214
|
+
const msg = await nc.request(`${cfg.prefix}.approval.${tid}.request`, j(env), { timeout })
|
|
215
|
+
const reply = uj(msg.data)
|
|
216
|
+
const p = reply?.payload ?? reply
|
|
217
|
+
return { approved: p?.approved === true, approver: p?.approver, taskId: tid, raw: reply }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Answer an approval request (approver side): publish mesh.approval.{taskId}.response. */
|
|
221
|
+
export function respondApproval(nc, cfg, requestEnv, { approved, approver }) {
|
|
222
|
+
const tid = requestEnv?.task_id
|
|
223
|
+
const env = envelope('approval_response', { approved: approved === true, approver }, cfg, {
|
|
224
|
+
to: requestEnv?.from,
|
|
225
|
+
task_id: tid,
|
|
226
|
+
in_reply_to: requestEnv?.id,
|
|
227
|
+
})
|
|
228
|
+
nc.publish(`${cfg.prefix}.approval.${tid}.response`, j(env))
|
|
229
|
+
return { answered: tid, approved: approved === true }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Start an approver loop: subscribe to mesh.approval.*.request and answer each per
|
|
233
|
+
* the supplied decide() function ({approved, approver} or a policy name). Returns stop fn. */
|
|
234
|
+
export async function startApprover(nc, cfg, decide, log = () => {}) {
|
|
235
|
+
const sub = nc.subscribe(`${cfg.prefix}.approval.*.request`)
|
|
236
|
+
let stopped = false
|
|
237
|
+
const loop = (async () => {
|
|
238
|
+
for await (const msg of sub) {
|
|
239
|
+
if (stopped) break
|
|
240
|
+
;(async () => {
|
|
241
|
+
try {
|
|
242
|
+
const req = uj(msg.data)
|
|
243
|
+
const decision = await decide(req)
|
|
244
|
+
respondApproval(nc, cfg, req, decision)
|
|
245
|
+
log(`[synapse] approval ${decision.approved ? 'granted' : 'denied'} for task ${req?.task_id}`)
|
|
246
|
+
} catch (e) {
|
|
247
|
+
log(`[synapse] approval handling error: ${e?.message ?? e}`)
|
|
248
|
+
}
|
|
249
|
+
})()
|
|
250
|
+
}
|
|
251
|
+
})()
|
|
252
|
+
loop.catch(() => {})
|
|
253
|
+
return () => { stopped = true; try { sub.unsubscribe() } catch {} }
|
|
254
|
+
}
|