rterm-backend 3.1.5 → 3.1.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rterm-backend",
3
- "version": "3.1.5",
4
- "description": "Headless AI-native backend for RTerm / neuralOS — v3.1.5: latent connection-state bug fixes (NATS connect concurrency/DOA guard, synapse-bridge config-keyed connections, serial port error leak). 11 plugins. Transports (SSH/serial/local), SQLite, and NATS libs install automatically.",
3
+ "version": "3.1.7",
4
+ "description": "Headless AI-native backend for RTerm / neuralOS — v3.1.7: always-on full-duplex Synapse agent (auto-start responder on boot when synapse.enabled + autoServe). RTerm tasks other agents AND is tasked by them from boot. 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()
@@ -40,6 +46,8 @@ export function resolveConfig(ctx = {}, env = process.env) {
40
46
  agentId: block.agentId || env.SYNAPSE_AGENT_ID || 'rterm-001',
41
47
  auth: block.auth || undefined,
42
48
  enabled: block.enabled !== false,
49
+ /** auto-start the full-duplex responder on boot (default true when enabled). */
50
+ autoServe: block.autoServe !== false,
43
51
  }
44
52
  }
45
53
 
@@ -255,6 +263,135 @@ export function register(ctx) {
255
263
  }, log),
256
264
  })
257
265
 
266
+ // ─── full-duplex agent capabilities (responder, emit/subscribe, reputation, governance) ───
267
+ const repStore = new ReputationStore()
268
+ let responderStop = null
269
+ let servingSkills = []
270
+
271
+ /** Start (or restart) the responder with the given skills. Idempotent. */
272
+ async function serveSkills(skills) {
273
+ const nc = await connectMesh(ctx)
274
+ const serveCtx = { ...ctx, rtermSkills: skills ?? ctx.rtermSkills ?? {} }
275
+ if (responderStop) responderStop()
276
+ responderStop = await startResponder(nc, cfg, serveCtx, log)
277
+ servingSkills = Object.keys(serveCtx.rtermSkills)
278
+ return servingSkills
279
+ }
280
+
281
+ /** Default skills RTerm serves when auto-starting (status + discover + dispatch). */
282
+ function defaultServeSkills() {
283
+ return {
284
+ status: async () => ({ up: true, agent: cfg.agentId, ts: new Date().toISOString() }),
285
+ discover: async (inp) => ({ agents: await discoverAgents(ctx, inp ?? {}) }),
286
+ ...(ctx.rtermSkills ?? {}),
287
+ }
288
+ }
289
+
290
+ registerTool({
291
+ name: 'synapse_serve',
292
+ 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. Idempotent; auto-starts on boot when synapse.enabled and autoServe are true.',
293
+ params: {
294
+ skills: { type: 'object', description: 'Map of skillId -> async (input, ctx) => output, the skills RTerm serves (defaults to status+discover)', optional: true },
295
+ },
296
+ handler: async (p) => guarded(async () => {
297
+ const skills = await serveSkills(p?.skills ?? defaultServeSkills())
298
+ return { serving: true, inbox: `${cfg.prefix}.agent.${cfg.agentId}.inbox`, skills, note: 'RTerm is now a full Synapse agent (responder live)' }
299
+ }, log),
300
+ })
301
+
302
+ registerTool({
303
+ name: 'synapse_serve_status',
304
+ description: 'Report whether the Synapse responder is live (serving on mesh.agent.{id}.inbox) and which skills it serves.',
305
+ params: {},
306
+ handler: async () => guarded(async () => {
307
+ const nc = await connectMesh(ctx)
308
+ return { serving: responderStop !== null, connected: !nc.isClosed(), inbox: `${cfg.prefix}.agent.${cfg.agentId}.inbox`, skills: servingSkills, autoServe: cfg.autoServe }
309
+ }, log),
310
+ })
311
+
312
+ registerTool({
313
+ name: 'synapse_emit',
314
+ description: 'Emit a formal Synapse event on mesh.event.{type} (fire-and-forget broadcast to subscribers).',
315
+ params: {
316
+ type: { type: 'string', description: 'Event type, e.g. ops.change.committed' },
317
+ payload: { type: 'object', description: 'Event payload' },
318
+ },
319
+ handler: async (p) => guarded(async () => {
320
+ if (!p?.type) return { error: 'synapse_emit needs a type' }
321
+ const nc = await connectMesh(ctx)
322
+ emitEvent(nc, cfg, p.type, p.payload ?? {})
323
+ return { emitted: `${cfg.prefix}.event.${p.type}` }
324
+ }, log),
325
+ })
326
+
327
+ registerTool({
328
+ name: 'synapse_subscribe',
329
+ 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.',
330
+ params: {
331
+ subject: { type: 'string', description: 'Subject pattern, e.g. mesh.event.> or mesh.task.>.update' },
332
+ },
333
+ handler: async (p) => guarded(async () => {
334
+ const subject = p?.subject ?? `${cfg.prefix}.task.>.update`
335
+ const nc = await connectMesh(ctx)
336
+ const stop = await subscribeEvents(nc, subject, (env, subj) => {
337
+ repStore.handleTaskUpdate(env)
338
+ if (typeof ctx.emitEvent === 'function') ctx.emitEvent({ source: 'synapse', subject: subj, env })
339
+ })
340
+ return { subscribed: subject, note: 'events feed the reputation store + synapse_mesh_event trigger' }
341
+ }, log),
342
+ })
343
+
344
+ registerTool({
345
+ name: 'synapse_reputation',
346
+ 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.',
347
+ params: {
348
+ agent: { type: 'string', optional: true },
349
+ skill: { type: 'string', optional: true },
350
+ minScore: { type: 'number', description: 'Only agents at/above this score (discover-ranked)', optional: true },
351
+ },
352
+ handler: async (p) => guarded(async () => {
353
+ if (p?.agent && p?.skill) {
354
+ const rec = repStore.get(p.agent, p.skill)
355
+ return rec ?? { error: `no record for ${p.agent}::${p.skill}` }
356
+ }
357
+ const ranked = repStore.ranked(p?.minScore ?? 0)
358
+ 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 })) }
359
+ }, log),
360
+ })
361
+
362
+ registerTool({
363
+ name: 'synapse_request_approval',
364
+ 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.',
365
+ params: {
366
+ originalRequest: { type: 'object', description: 'The original request payload being gated' },
367
+ policyId: { type: 'string', optional: true },
368
+ ruleId: { type: 'string', optional: true },
369
+ reason: { type: 'string', description: 'Why approval is required' },
370
+ taskId: { type: 'string', optional: true },
371
+ timeout: { type: 'number', optional: true },
372
+ },
373
+ handler: async (p) => guarded(async () => {
374
+ const nc = await connectMesh(ctx)
375
+ 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 })
376
+ return r.approved ? { approved: true, approver: r.approver, taskId: r.taskId } : { approved: false, taskId: r.taskId, note: 'denied or timed out' }
377
+ }, log),
378
+ })
379
+
380
+ registerTool({
381
+ name: 'synapse_approve',
382
+ 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.',
383
+ params: {
384
+ policy: { type: 'string', description: 'allow-all | deny-all (default allow-all)', optional: true },
385
+ },
386
+ handler: async (p) => guarded(async () => {
387
+ const nc = await connectMesh(ctx)
388
+ const policy = p?.policy ?? 'allow-all'
389
+ const decide = async () => ({ approved: policy !== 'deny-all', approver: `did:mesh:${cfg.agentId}` })
390
+ await startApprover(nc, cfg, decide, log)
391
+ return { approver: cfg.agentId, policy, listening: `${cfg.prefix}.approval.*.request` }
392
+ }, log),
393
+ })
394
+
258
395
  registerTrigger({
259
396
  name: 'synapse_mesh_event',
260
397
  description: 'Fires when a Synapse mesh event (task failure, reputation penalty, approval request) is observed. Use for cross-mesh remediation.',
@@ -273,7 +410,16 @@ export function register(ctx) {
273
410
  },
274
411
  })
275
412
 
276
- log(`[synapse] synapse-bridge registered: 5 tools, 1 trigger, 1 panel (agent=${cfg.agentId}, prefix=${cfg.prefix})`)
413
+ log(`[synapse] synapse-bridge registered: 12 tools, 1 trigger, 1 panel (agent=${cfg.agentId}, prefix=${cfg.prefix}, full-duplex)`)
414
+
415
+ // ─── auto-start the full-duplex responder on boot (when enabled + autoServe) ───
416
+ // Makes "be tasked by them" always-on, not opt-in per session. Best-effort: a
417
+ // failed auto-start logs but never blocks plugin registration (server may be down).
418
+ if (cfg.enabled && cfg.autoServe) {
419
+ serveSkills(defaultServeSkills())
420
+ .then((skills) => log(`[synapse] auto-started responder on ${cfg.prefix}.agent.${cfg.agentId}.inbox (skills: ${skills.join(', ')})`))
421
+ .catch((e) => log(`[synapse] auto-serve deferred: ${e?.message ?? e} (responder will start on first synapse_serve call)`))
422
+ }
277
423
  }
278
424
 
279
425
  export default { register, resolveConfig, envelope, discoverAgents, dispatchTask, registerSelf }
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "synapse-bridge",
3
3
  "version": "1.0.0",
4
- "description": "Synapse mesh bridge for RTerm — discover live Synapse agents, dispatch tasks to them, and register RTerm itself as a mesh agent (bidirectional federation). 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.",
4
+ "description": "Synapse mesh bridge for RTerm — full-duplex Synapse agent (auto-started on boot): discover live agents, dispatch tasks, register RTerm as a mesh agent, AND serve as one (respond to inbound requests, auto-started), 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_serve_status",
14
+ "synapse_emit",
15
+ "synapse_subscribe",
16
+ "synapse_reputation",
17
+ "synapse_request_approval",
18
+ "synapse_approve"
12
19
  ],
13
20
  "triggers": [
14
21
  "synapse_mesh_event"
@@ -9,9 +9,28 @@ function eq(a, b, m) { if (a !== b) throw new Error(`${m ?? 'eq'}: expected ${JS
9
9
  function fakeConn() {
10
10
  const published = []
11
11
  const requests = new Map()
12
+ const subs = new Map()
12
13
  return {
13
14
  isClosed: () => false,
14
15
  publish(subject, data) { published.push({ subject, data: JSON.parse(new TextDecoder().decode(data)) }) },
16
+ subscribe(subject) {
17
+ const sub = {
18
+ async *[Symbol.asyncIterator]() {
19
+ while (true) {
20
+ const m = await new Promise((res) => {
21
+ const l = subs.get(subject) ?? []; subs.set(subject, l); l.push(res)
22
+ })
23
+ yield m
24
+ }
25
+ },
26
+ unsubscribe: () => subs.delete(subject),
27
+ }
28
+ return sub
29
+ },
30
+ _deliver(subject, env) {
31
+ const data = new TextEncoder().encode(JSON.stringify(env))
32
+ for (const fn of subs.get(subject) ?? []) fn({ data, subject, respond: (p) => { published.push({ subject: '_reply', env: JSON.parse(new TextDecoder().decode(p)) }) } })
33
+ },
15
34
  async request(subject, data, _opts) {
16
35
  const h = requests.get(subject)
17
36
  const reply = h ? h(JSON.parse(new TextDecoder().decode(data))) : { ok: true }
@@ -69,12 +88,12 @@ test('envelope has Synapse v0.3.0 shape', () => {
69
88
 
70
89
  // ─── register wiring ────────────────────────────────────────────────────────
71
90
 
72
- test('register wires 5 tools, 1 trigger, 1 panel', () => {
91
+ test('register wires 12 tools, 1 trigger, 1 panel (full-duplex)', () => {
73
92
  const conn = fakeConn()
74
93
  const { tools, triggers, panels, ctx } = mkCtx({}, conn)
75
94
  register(ctx)
76
- eq(tools.size, 5, 'tool count')
77
- for (const n of ['synapse_health', 'synapse_discover', 'synapse_dispatch', 'synapse_register', 'synapse_agents_summary']) assert(tools.has(n), `missing ${n}`)
95
+ eq(tools.size, 12, 'tool count')
96
+ for (const n of ['synapse_health', 'synapse_discover', 'synapse_dispatch', 'synapse_register', 'synapse_agents_summary', 'synapse_serve', 'synapse_serve_status', 'synapse_emit', 'synapse_subscribe', 'synapse_reputation', 'synapse_request_approval', 'synapse_approve']) assert(tools.has(n), `missing ${n}`)
78
97
  eq(triggers.length, 1, 'trigger count')
79
98
  eq(triggers[0].name, 'synapse_mesh_event', 'trigger name')
80
99
  eq(panels.length, 1, 'panel count')
@@ -83,6 +102,7 @@ test('register wires 5 tools, 1 trigger, 1 panel', () => {
83
102
  // ─── discover ───────────────────────────────────────────────────────────────
84
103
 
85
104
  test('synapse_discover returns agents from registry', async () => {
105
+ __setConnForTest(null)
86
106
  const conn = fakeConn()
87
107
  conn._on('mesh.registry.discover', () => [
88
108
  { id: 'grip-cli-001', name: 'Grip CLI', skills: [{ id: 'himalaya' }] },
@@ -206,6 +226,50 @@ test('a failed connect is not cached — the next call retries', async () => {
206
226
  if (attempts !== 2) throw new Error(`expected 2 attempts (fail + retry), got ${attempts}`)
207
227
  })
208
228
 
229
+ // ─── auto-start responder on boot (serveSkills + autoServe) ─────────────────
230
+
231
+ test('autoServe: register() auto-starts the responder when enabled+autoServe (default skills)', async () => {
232
+ __setConnForTest(null)
233
+ const conn = fakeConn()
234
+ conn._on('mesh.registry.discover', () => [])
235
+ const { tools, logs, ctx } = mkCtx({}, conn)
236
+ register(ctx)
237
+ // auto-start is async (serveSkills → connectMesh → startResponder) — wait for it to settle
238
+ await new Promise((r) => setTimeout(r, 100))
239
+ const r = await tools.get('synapse_serve_status').handler({})
240
+ eq(r.serving, true, 'responder auto-started (serving=true)')
241
+ assert(r.skills.includes('status'), 'default skills include status')
242
+ assert(r.skills.includes('discover'), 'default skills include discover')
243
+ eq(r.autoServe, true, 'autoServe true by default')
244
+ })
245
+
246
+ test('autoServe: disabled when synapse.autoServe=false (no auto-start)', async () => {
247
+ __setConnForTest(null)
248
+ const conn = fakeConn()
249
+ const { tools, ctx } = mkCtx({ autoServe: false }, conn)
250
+ register(ctx)
251
+ await new Promise((r) => setTimeout(r, 20))
252
+ const r = await tools.get('synapse_serve_status').handler({})
253
+ eq(r.serving, false, 'responder NOT auto-started when autoServe=false')
254
+ })
255
+
256
+ test('autoServe: a failed auto-start does not break register (best-effort)', async () => {
257
+ __setConnForTest(null)
258
+ let failConnect = true
259
+ const conn = fakeConn()
260
+ const ctx = {
261
+ settings: { synapse: { url: 'nats://down:4222' } },
262
+ natsConnect: async () => { if (failConnect) throw new Error('server down'); return conn },
263
+ registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
264
+ }
265
+ // register() must not throw even though the auto-start connection fails
266
+ const { register } = await import('./index.mjs')
267
+ let threw = false
268
+ try { register(ctx) } catch { threw = true }
269
+ assert(!threw, 'register() must not throw on auto-serve connection failure')
270
+ await new Promise((r) => setTimeout(r, 20)) // let the auto-start promise reject (handled)
271
+ })
272
+
209
273
  // ─── runner ─────────────────────────────────────────────────────────────────
210
274
  async function main() {
211
275
  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
+ }