rterm-backend 3.1.12 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rterm-backend",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Headless AI-native backend for RTerm / neuralOS — v3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"description": "Headless AI-native backend for RTerm / neuralOS — v3.2.0: production-ready Synapse dispatch (skip JetStream ack, multi-mesh, 600s timeout). 11 plugins, 55 plugin tools wired, full-duplex Synapse agent. 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",
|
|
@@ -46,8 +46,11 @@ export function resolveConfig(ctx = {}, env = process.env) {
|
|
|
46
46
|
agentId: block.agentId || env.SYNAPSE_AGENT_ID || 'rterm-001',
|
|
47
47
|
auth: block.auth || undefined,
|
|
48
48
|
enabled: block.enabled !== false,
|
|
49
|
-
/** auto-start the full-duplex responder on boot (default true when enabled). */
|
|
50
49
|
autoServe: block.autoServe !== false,
|
|
50
|
+
/** dispatch timeout in ms (default 600s = 10min for LLM-backed agents). */
|
|
51
|
+
dispatchTimeout: block.dispatchTimeout ?? 600000,
|
|
52
|
+
/** multiple meshes (v3.2.0). Each: {name, url/servers, auth, prefix?}. */
|
|
53
|
+
meshes: Array.isArray(block.meshes) ? block.meshes : undefined,
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -93,8 +96,8 @@ function _configKey(cfg) {
|
|
|
93
96
|
return `${servers}|${cfg.agentId}|${authKeys}`
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
async function connectMesh(ctx) {
|
|
97
|
-
const cfg = resolveConfig(ctx)
|
|
99
|
+
async function connectMesh(ctx, overrideCfg) {
|
|
100
|
+
const cfg = overrideCfg || resolveConfig(ctx)
|
|
98
101
|
const key = _configKey(cfg)
|
|
99
102
|
const existing = _conns.get(key)
|
|
100
103
|
if (existing) {
|
|
@@ -144,19 +147,67 @@ export function envelope(type, payload, cfg, extra = {}) {
|
|
|
144
147
|
|
|
145
148
|
export async function discoverAgents(ctx, filter = {}) {
|
|
146
149
|
const cfg = resolveConfig(ctx)
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
const meshes = cfg.meshes || [{ url: cfg.servers, auth: cfg.auth, prefix: cfg.prefix }]
|
|
151
|
+
const allAgents = []
|
|
152
|
+
for (const mesh of meshes) {
|
|
153
|
+
try {
|
|
154
|
+
const meshCfg = { ...cfg, servers: mesh.servers || (mesh.url ? [mesh.url] : cfg.servers), auth: mesh.auth || cfg.auth, prefix: mesh.prefix || cfg.prefix }
|
|
155
|
+
const nc = await connectMesh(ctx, meshCfg)
|
|
156
|
+
const msg = await nc.request(`${meshCfg.prefix}.registry.discover`, j(envelope('discover', filter, meshCfg)), { timeout: 5000 })
|
|
157
|
+
const reply = uj(msg.data)
|
|
158
|
+
const agents = Array.isArray(reply) ? reply : (reply.payload?.agents ?? reply.payload ?? reply)
|
|
159
|
+
if (Array.isArray(agents)) {
|
|
160
|
+
for (const a of agents) { if (typeof a === 'object') a._mesh = mesh.name || 'default' }
|
|
161
|
+
allAgents.push(...agents)
|
|
162
|
+
}
|
|
163
|
+
} catch { /* mesh unreachable — skip */ }
|
|
164
|
+
}
|
|
165
|
+
return allAgents
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
export async function dispatchTask(ctx, target, skill, input = {}, opts = {}) {
|
|
155
169
|
const cfg = resolveConfig(ctx)
|
|
170
|
+
const timeout = opts.timeout ?? cfg.dispatchTimeout ?? 600000
|
|
156
171
|
const nc = await connectMesh(ctx)
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
172
|
+
const reqEnv = envelope('request', { skill, input }, cfg, { to: target, task_id: randomUUID() })
|
|
173
|
+
const inbox = `${cfg.prefix}.agent.${target}.inbox`
|
|
174
|
+
const replySubject = `_INBOX.synapse.${randomUUID().slice(0, 12)}`
|
|
175
|
+
|
|
176
|
+
// Subscribe to the reply subject and collect messages, skipping JetStream acks.
|
|
177
|
+
// The JetStream ack is always {stream: "AGENT_INBOXES", seq: N} — skip it.
|
|
178
|
+
// The real respond is a Synapse envelope with {type: "respond", ...} — return it.
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
let settled = false
|
|
181
|
+
const sub = nc.subscribe(replySubject, { max: 10 })
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
if (!settled) {
|
|
184
|
+
settled = true
|
|
185
|
+
try { sub.unsubscribe() } catch {}
|
|
186
|
+
resolve({ error: `Agent ${target} did not respond within ${Math.round(timeout / 1000)}s. It may not have a Synapse responder running (check synapse_serve_status).`, task_id: reqEnv.task_id })
|
|
187
|
+
}
|
|
188
|
+
}, timeout)
|
|
189
|
+
|
|
190
|
+
;(async () => {
|
|
191
|
+
for await (const msg of sub) {
|
|
192
|
+
if (settled) break
|
|
193
|
+
try {
|
|
194
|
+
const parsed = uj(msg.data)
|
|
195
|
+
const isJetStreamAck = parsed.stream === 'AGENT_INBOXES' && typeof parsed.seq === 'number'
|
|
196
|
+
if (isJetStreamAck) continue // skip the ack, wait for the real respond
|
|
197
|
+
// This is the respond envelope
|
|
198
|
+
settled = true
|
|
199
|
+
clearTimeout(timer)
|
|
200
|
+
try { sub.unsubscribe() } catch {}
|
|
201
|
+
resolve(parsed)
|
|
202
|
+
} catch {
|
|
203
|
+
// non-JSON — skip
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
})().catch(() => {})
|
|
207
|
+
|
|
208
|
+
// Publish the request with the reply subject
|
|
209
|
+
nc.publish(inbox, j(reqEnv), { reply: replySubject })
|
|
210
|
+
})
|
|
160
211
|
}
|
|
161
212
|
|
|
162
213
|
export async function registerSelf(ctx, manifest = {}) {
|
|
@@ -5,16 +5,45 @@ function test(n, r) { cases.push({ name: n, run: r }) }
|
|
|
5
5
|
function assert(c, m) { if (!c) throw new Error(m ?? 'assertion failed') }
|
|
6
6
|
function eq(a, b, m) { if (a !== b) throw new Error(`${m ?? 'eq'}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`) }
|
|
7
7
|
|
|
8
|
-
// ─── fake NATS connection
|
|
8
|
+
// ─── fake NATS connection (with wildcard + JetStream ack simulation) ─────────
|
|
9
|
+
function subjectMatches(pattern, subject) {
|
|
10
|
+
if (pattern === subject) return true
|
|
11
|
+
const p = pattern.split('.')
|
|
12
|
+
const s = subject.split('.')
|
|
13
|
+
for (let i = 0; i < p.length; i++) {
|
|
14
|
+
if (p[i] === '>') return true
|
|
15
|
+
if (i >= s.length) return false
|
|
16
|
+
if (p[i] !== '*' && p[i] !== s[i]) return false
|
|
17
|
+
}
|
|
18
|
+
return p.length === s.length
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
function fakeConn() {
|
|
10
22
|
const published = []
|
|
11
|
-
const requests = new Map()
|
|
12
23
|
const subs = new Map()
|
|
24
|
+
const requests = new Map()
|
|
25
|
+
const deliver = (subject, data, respondFn) => {
|
|
26
|
+
for (const [pattern, fns] of subs) {
|
|
27
|
+
if (subjectMatches(pattern, subject)) {
|
|
28
|
+
for (const fn of fns) fn({ data, subject, respond: respondFn })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
13
32
|
return {
|
|
14
33
|
isClosed: () => false,
|
|
15
|
-
publish(subject, data
|
|
34
|
+
publish(subject, data, opts) {
|
|
35
|
+
const env = JSON.parse(new TextDecoder().decode(data))
|
|
36
|
+
published.push({ subject, env, opts })
|
|
37
|
+
// Simulate JetStream ack on the reply subject (if reply-to is set)
|
|
38
|
+
if (opts?.reply) {
|
|
39
|
+
const ack = new TextEncoder().encode(JSON.stringify({ stream: 'AGENT_INBOXES', seq: published.length }))
|
|
40
|
+
deliver(opts.reply, ack, () => {})
|
|
41
|
+
}
|
|
42
|
+
// Deliver to any matching subscribers
|
|
43
|
+
deliver(subject, data, (p) => { published.push({ subject: '_reply', env: JSON.parse(new TextDecoder().decode(p)) }) })
|
|
44
|
+
},
|
|
16
45
|
subscribe(subject) {
|
|
17
|
-
|
|
46
|
+
return {
|
|
18
47
|
async *[Symbol.asyncIterator]() {
|
|
19
48
|
while (true) {
|
|
20
49
|
const m = await new Promise((res) => {
|
|
@@ -25,17 +54,17 @@ function fakeConn() {
|
|
|
25
54
|
},
|
|
26
55
|
unsubscribe: () => subs.delete(subject),
|
|
27
56
|
}
|
|
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
57
|
},
|
|
34
58
|
async request(subject, data, _opts) {
|
|
59
|
+
const env = JSON.parse(new TextDecoder().decode(data))
|
|
35
60
|
const h = requests.get(subject)
|
|
36
|
-
const reply = h ? h(
|
|
61
|
+
const reply = h ? h(env) : { ok: true }
|
|
37
62
|
return { data: new TextEncoder().encode(JSON.stringify(reply)) }
|
|
38
63
|
},
|
|
64
|
+
_deliver(subject, env) {
|
|
65
|
+
const data = new TextEncoder().encode(JSON.stringify(env))
|
|
66
|
+
deliver(subject, data, (p) => { published.push({ subject: '_reply', env: JSON.parse(new TextDecoder().decode(p)) }) })
|
|
67
|
+
},
|
|
39
68
|
_on(subject, h) { requests.set(subject, h) },
|
|
40
69
|
published,
|
|
41
70
|
drain: async () => {},
|
|
@@ -57,217 +86,109 @@ function mkCtx(settings = {}, conn) {
|
|
|
57
86
|
|
|
58
87
|
// ─── config ─────────────────────────────────────────────────────────────────
|
|
59
88
|
|
|
60
|
-
test('resolveConfig
|
|
89
|
+
test('resolveConfig includes dispatchTimeout (default 600000) + meshes', () => {
|
|
61
90
|
const c = resolveConfig({ settings: {} }, {})
|
|
62
|
-
eq(c.
|
|
63
|
-
eq(c.
|
|
64
|
-
eq(c.agentId, 'rterm-001', 'default agentId')
|
|
65
|
-
eq(c.enabled, true, 'enabled default')
|
|
91
|
+
eq(c.dispatchTimeout, 600000, 'default dispatchTimeout 600s')
|
|
92
|
+
eq(c.meshes, undefined, 'no meshes by default')
|
|
66
93
|
})
|
|
67
94
|
|
|
68
|
-
test('resolveConfig reads settings
|
|
69
|
-
const c = resolveConfig({ settings: { synapse: {
|
|
70
|
-
eq(c.
|
|
71
|
-
|
|
72
|
-
eq(c.auth.token, 't', 'auth token')
|
|
95
|
+
test('resolveConfig reads dispatchTimeout + meshes from settings', () => {
|
|
96
|
+
const c = resolveConfig({ settings: { synapse: { dispatchTimeout: 30000, meshes: [{ name: 'prod', url: 'nats://p:4222' }] } } }, {})
|
|
97
|
+
eq(c.dispatchTimeout, 30000, 'dispatchTimeout from settings')
|
|
98
|
+
assert(Array.isArray(c.meshes) && c.meshes.length === 1, 'meshes array')
|
|
73
99
|
})
|
|
74
100
|
|
|
75
|
-
// ───
|
|
76
|
-
|
|
77
|
-
test('envelope has Synapse v0.3.0 shape', () => {
|
|
78
|
-
const cfg = { agentId: 'rterm-001' }
|
|
79
|
-
const e = envelope('discover', { capabilities: [] }, cfg)
|
|
80
|
-
eq(e.v, '0.3.0', 'protocol version')
|
|
81
|
-
eq(e.type, 'discover', 'type')
|
|
82
|
-
eq(e.from, 'rterm-001', 'from = agentId')
|
|
83
|
-
assert(e.id, 'has id')
|
|
84
|
-
assert(e.ts, 'has ts')
|
|
85
|
-
assert(e.trace?.trace_id && e.trace?.span_id, 'has trace context')
|
|
86
|
-
assert(e.payload, 'has payload')
|
|
87
|
-
})
|
|
101
|
+
// ─── dispatch: skip JetStream ack, wait for real respond ─────────────────────
|
|
88
102
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
test('register wires 12 tools, 1 trigger, 1 panel (full-duplex)', () => {
|
|
92
|
-
const conn = fakeConn()
|
|
93
|
-
const { tools, triggers, panels, ctx } = mkCtx({}, conn)
|
|
94
|
-
register(ctx)
|
|
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}`)
|
|
97
|
-
eq(triggers.length, 1, 'trigger count')
|
|
98
|
-
eq(triggers[0].name, 'synapse_mesh_event', 'trigger name')
|
|
99
|
-
eq(panels.length, 1, 'panel count')
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
// ─── discover ───────────────────────────────────────────────────────────────
|
|
103
|
-
|
|
104
|
-
test('synapse_discover returns agents from registry', async () => {
|
|
105
|
-
__setConnForTest(null)
|
|
106
|
-
const conn = fakeConn()
|
|
107
|
-
conn._on('mesh.registry.discover', () => [
|
|
108
|
-
{ id: 'grip-cli-001', name: 'Grip CLI', skills: [{ id: 'himalaya' }] },
|
|
109
|
-
{ id: 'agentspan-001', name: 'Agentspan', skills: [{ id: 'status' }] },
|
|
110
|
-
])
|
|
111
|
-
const { tools, ctx } = mkCtx({}, conn)
|
|
112
|
-
register(ctx)
|
|
113
|
-
const r = await tools.get('synapse_discover').handler({})
|
|
114
|
-
eq(r.count, 2, 'agent count')
|
|
115
|
-
eq(r.agents[0].id, 'grip-cli-001', 'first agent')
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
test('synapse_discover passes filter through to the envelope', async () => {
|
|
119
|
-
__setConnForTest(null)
|
|
120
|
-
const conn = fakeConn()
|
|
121
|
-
let captured
|
|
122
|
-
conn._on('mesh.registry.discover', (env) => { captured = env; return [] })
|
|
123
|
-
const { tools, ctx } = mkCtx({}, conn)
|
|
124
|
-
register(ctx)
|
|
125
|
-
await tools.get('synapse_discover').handler({ capabilities: ['chat'], availability: 'online' })
|
|
126
|
-
eq(captured.type, 'discover', 'envelope type')
|
|
127
|
-
eq(captured.payload.availability, 'online', 'filter availability')
|
|
128
|
-
assert(Array.isArray(captured.payload.capabilities), 'filter capabilities')
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
// ─── dispatch ───────────────────────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
test('synapse_dispatch sends request to agent inbox + returns response', async () => {
|
|
103
|
+
test('dispatchTask skips JetStream ack and returns the real respond envelope', async () => {
|
|
134
104
|
__setConnForTest(null)
|
|
135
105
|
const conn = fakeConn()
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
register(ctx)
|
|
140
|
-
const r = await tools.get('synapse_dispatch').handler({ target: 'grip-001', skill: 'respond', input: { text: 'hi' } })
|
|
141
|
-
eq(r.response.seq, 42, 'response seq')
|
|
142
|
-
eq(captured.type, 'request', 'envelope type')
|
|
143
|
-
eq(captured.to, 'grip-001', 'envelope to')
|
|
144
|
-
eq(captured.payload.skill, 'respond', 'skill')
|
|
145
|
-
eq(captured.payload.input.text, 'hi', 'input')
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
test('synapse_dispatch requires target + skill', async () => {
|
|
149
|
-
const conn = fakeConn()
|
|
150
|
-
const { tools, ctx } = mkCtx({}, conn)
|
|
151
|
-
register(ctx)
|
|
152
|
-
const r = await tools.get('synapse_dispatch').handler({})
|
|
153
|
-
assert(r.error, 'expected error for missing target/skill')
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
// ─── register self ──────────────────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
test('synapse_register publishes a register envelope to the registry', async () => {
|
|
159
|
-
__setConnForTest(null)
|
|
160
|
-
const conn = fakeConn()
|
|
161
|
-
const { tools, ctx } = mkCtx({ agentId: 'rterm-001' }, conn)
|
|
162
|
-
register(ctx)
|
|
163
|
-
const r = await tools.get('synapse_register').handler({ name: 'RTerm', capabilities: ['ops'] })
|
|
164
|
-
eq(r.registered, 'rterm-001', 'registered id')
|
|
165
|
-
const pub = conn.published.find((p) => p.subject === 'mesh.registry.register')
|
|
166
|
-
assert(pub, 'expected a register publish')
|
|
167
|
-
eq(pub.data.type, 'register', 'envelope type')
|
|
168
|
-
eq(pub.data.payload.agent_id, 'rterm-001', 'payload agent_id')
|
|
169
|
-
assert(pub.data.payload.endpoint.includes('rterm-001.inbox'), 'endpoint inbox')
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
// ─── trigger match ──────────────────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
test('synapse_mesh_event trigger matches only synapse-source events', () => {
|
|
175
|
-
const conn = fakeConn()
|
|
176
|
-
const { triggers, ctx } = mkCtx({}, conn)
|
|
177
|
-
register(ctx)
|
|
178
|
-
const t = triggers[0]
|
|
179
|
-
assert(t.match({ source: 'synapse' }), 'matches synapse source')
|
|
180
|
-
assert(!t.match({ source: 'other' }), 'rejects other source')
|
|
181
|
-
assert(!t.match({}), 'rejects empty')
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
// ─── connection cache (config-keyed, the stale-connection bug fix) ──────────
|
|
185
|
-
|
|
186
|
-
test('connection is keyed by config — a settings change opens a NEW connection (no stale reuse)', async () => {
|
|
187
|
-
__setConnForTest(null)
|
|
188
|
-
const connA = fakeConn()
|
|
189
|
-
const connB = fakeConn()
|
|
190
|
-
const connsMade = []
|
|
191
|
-
// ctx whose natsConnect returns a different fake per call, tracking which config connected
|
|
192
|
-
const mkCtxMulti = (settings) => ({
|
|
193
|
-
settings: { synapse: settings },
|
|
194
|
-
natsConnect: async (copts) => { connsMade.push(copts); return connsMade.length === 1 ? connA : connB },
|
|
106
|
+
const ctx = {
|
|
107
|
+
settings: { synapse: { url: 'nats://fake:4222', agentId: 'rterm-001', prefix: 'mesh', dispatchTimeout: 5000 } },
|
|
108
|
+
natsConnect: async () => conn,
|
|
195
109
|
registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
|
|
196
|
-
}
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
110
|
+
}
|
|
111
|
+
// Simulate the agent responding after the JetStream ack
|
|
112
|
+
// The reply subject is _INBOX.synapse.* — we need to deliver the respond there
|
|
113
|
+
// The JetStream ack is auto-delivered by fakeConn.publish (opts.reply)
|
|
114
|
+
// We need to manually deliver the real respond to the reply subject
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
// Find the reply subject from published messages
|
|
117
|
+
const pub = conn.published.find(p => p.opts?.reply && p.subject === 'mesh.agent.grip-001.inbox')
|
|
118
|
+
if (pub) {
|
|
119
|
+
const respond = { v: '0.3.0', id: 'r1', type: 'respond', from: 'grip-001', to: 'rterm-001', payload: { output: { ok: true, incidents: [] } } }
|
|
120
|
+
conn._deliver(pub.opts.reply, respond)
|
|
121
|
+
}
|
|
122
|
+
}, 50)
|
|
123
|
+
|
|
124
|
+
const result = await dispatchTask(ctx, 'grip-001', 'status', {})
|
|
125
|
+
eq(result.type, 'respond', 'should be a respond envelope')
|
|
126
|
+
eq(result.from, 'grip-001', 'from the target agent')
|
|
127
|
+
assert(result.payload?.output?.ok === true, 'output present')
|
|
209
128
|
})
|
|
210
129
|
|
|
211
|
-
test('
|
|
130
|
+
test('dispatchTask returns clear error on timeout (no respond)', async () => {
|
|
212
131
|
__setConnForTest(null)
|
|
213
132
|
const conn = fakeConn()
|
|
214
|
-
conn._on('mesh.registry.discover', () => [])
|
|
215
|
-
let attempts = 0
|
|
216
133
|
const ctx = {
|
|
217
|
-
settings: { synapse: { url: 'nats://
|
|
218
|
-
natsConnect: async () =>
|
|
134
|
+
settings: { synapse: { url: 'nats://fake:4222', agentId: 'rterm-001', prefix: 'mesh', dispatchTimeout: 500 } },
|
|
135
|
+
natsConnect: async () => conn,
|
|
219
136
|
registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
|
|
220
137
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (attempts !== 2) throw new Error(`expected 2 attempts (fail + retry), got ${attempts}`)
|
|
138
|
+
// No agent responds — should timeout
|
|
139
|
+
const result = await dispatchTask(ctx, 'no-such-agent', 'status', {})
|
|
140
|
+
assert(result.error, 'should have error on timeout')
|
|
141
|
+
assert(result.error.includes('did not respond'), 'error message mentions timeout')
|
|
142
|
+
assert(result.error.includes('synapse_serve_status'), 'error hints at serve_status')
|
|
227
143
|
})
|
|
228
144
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
test('autoServe: register() auto-starts the responder when enabled+autoServe (default skills)', async () => {
|
|
145
|
+
test('dispatchTask: JetStream ack is skipped, not returned as result', async () => {
|
|
232
146
|
__setConnForTest(null)
|
|
233
147
|
const conn = fakeConn()
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
148
|
+
const ctx = {
|
|
149
|
+
settings: { synapse: { url: 'nats://fake:4222', agentId: 'rterm-001', prefix: 'mesh', dispatchTimeout: 2000 } },
|
|
150
|
+
natsConnect: async () => conn,
|
|
151
|
+
registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
|
|
152
|
+
}
|
|
153
|
+
// The fakeConn auto-delivers a JetStream ack on the reply subject.
|
|
154
|
+
// Then we deliver the real respond after 50ms.
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
const pub = conn.published.find(p => p.opts?.reply && p.subject === 'mesh.agent.test-001.inbox')
|
|
157
|
+
if (pub) {
|
|
158
|
+
conn._deliver(pub.opts.reply, { type: 'respond', from: 'test-001', payload: { output: { data: 42 } } })
|
|
159
|
+
}
|
|
160
|
+
}, 50)
|
|
161
|
+
|
|
162
|
+
const result = await dispatchTask(ctx, 'test-001', 'compute', { n: 42 })
|
|
163
|
+
// Must NOT be the JetStream ack
|
|
164
|
+
assert(!result.stream, 'must not be a JetStream ack')
|
|
165
|
+
eq(result.type, 'respond', 'must be a respond envelope')
|
|
166
|
+
eq(result.payload.output.data, 42, 'output data correct')
|
|
244
167
|
})
|
|
245
168
|
|
|
246
|
-
|
|
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
|
-
})
|
|
169
|
+
// ─── multi-mesh discover ─────────────────────────────────────────────────────
|
|
255
170
|
|
|
256
|
-
test('
|
|
171
|
+
test('discoverAgents merges results from multiple meshes + tags _mesh', async () => {
|
|
257
172
|
__setConnForTest(null)
|
|
258
|
-
|
|
259
|
-
const
|
|
173
|
+
const connA = fakeConn()
|
|
174
|
+
const connB = fakeConn()
|
|
175
|
+
connA._on('mesh.registry.discover', () => [{ id: 'agent-a1', name: 'A1' }])
|
|
176
|
+
connB._on('mesh.registry.discover', () => [{ id: 'agent-b1', name: 'B1' }])
|
|
177
|
+
let which = 0
|
|
260
178
|
const ctx = {
|
|
261
|
-
settings: { synapse: {
|
|
262
|
-
|
|
179
|
+
settings: { synapse: { meshes: [
|
|
180
|
+
{ name: 'mesh-a', url: 'nats://a:4222' },
|
|
181
|
+
{ name: 'mesh-b', url: 'nats://b:4222' },
|
|
182
|
+
]}},
|
|
183
|
+
natsConnect: async () => { which++; return which === 1 ? connA : connB },
|
|
263
184
|
registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
|
|
264
185
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
assert(
|
|
270
|
-
|
|
186
|
+
const agents = await discoverAgents(ctx, {})
|
|
187
|
+
eq(agents.length, 2, 'merged from both meshes')
|
|
188
|
+
const a1 = agents.find(a => a.id === 'agent-a1')
|
|
189
|
+
const b1 = agents.find(a => a.id === 'agent-b1')
|
|
190
|
+
assert(a1 && a1._mesh === 'mesh-a', 'agent-a1 tagged with mesh-a')
|
|
191
|
+
assert(b1 && b1._mesh === 'mesh-b', 'agent-b1 tagged with mesh-b')
|
|
271
192
|
})
|
|
272
193
|
|
|
273
194
|
// ─── runner ─────────────────────────────────────────────────────────────────
|