@agentsquared/cli 1.0.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/LICENSE +21 -0
- package/README.md +420 -0
- package/a2_cli.mjs +1576 -0
- package/adapters/index.mjs +79 -0
- package/adapters/openclaw/adapter.mjs +1020 -0
- package/adapters/openclaw/cli.mjs +89 -0
- package/adapters/openclaw/detect.mjs +259 -0
- package/adapters/openclaw/helpers.mjs +827 -0
- package/adapters/openclaw/ws_client.mjs +740 -0
- package/bin/a2-cli.js +8 -0
- package/lib/conversation/policy.mjs +122 -0
- package/lib/conversation/store.mjs +223 -0
- package/lib/conversation/templates.mjs +419 -0
- package/lib/gateway/api.mjs +28 -0
- package/lib/gateway/inbox.mjs +344 -0
- package/lib/gateway/lifecycle.mjs +602 -0
- package/lib/gateway/runtime_state.mjs +388 -0
- package/lib/gateway/server.mjs +883 -0
- package/lib/gateway/state.mjs +175 -0
- package/lib/routing/agent_router.mjs +511 -0
- package/lib/runtime/executor.mjs +380 -0
- package/lib/runtime/keys.mjs +85 -0
- package/lib/runtime/report.mjs +302 -0
- package/lib/runtime/safety.mjs +72 -0
- package/lib/shared/paths.mjs +155 -0
- package/lib/shared/primitives.mjs +43 -0
- package/lib/transport/http_json.mjs +96 -0
- package/lib/transport/libp2p.mjs +397 -0
- package/lib/transport/peer_session.mjs +857 -0
- package/lib/transport/relay_http.mjs +110 -0
- package/package.json +53 -0
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
import { randomRequestId } from '../shared/primitives.mjs'
|
|
2
|
+
import { createConnectTicket, getAgentCard, introspectConnectTicket, postOnline, reportSession } from './relay_http.mjs'
|
|
3
|
+
import { currentPeerConnection, dialProtocol, openStreamOnExistingConnection, readJsonMessage, waitForPublishedTransport, writeLine } from './libp2p.mjs'
|
|
4
|
+
|
|
5
|
+
const RELAY_RECOVERY_RETRY_DELAY_MS = 1500
|
|
6
|
+
const RESPONSE_ACK_TIMEOUT_MS = 3 * 1000
|
|
7
|
+
const TURN_RECEIPT_TIMEOUT_MS = 20 * 1000
|
|
8
|
+
const TURN_RESPONSE_TIMEOUT_MS = 210 * 1000
|
|
9
|
+
|
|
10
|
+
export function buildJsonRpcEnvelope({ id, method, message, metadata = {} }) {
|
|
11
|
+
return {
|
|
12
|
+
jsonrpc: '2.0',
|
|
13
|
+
id: id ?? randomRequestId('a2a'),
|
|
14
|
+
method,
|
|
15
|
+
params: {
|
|
16
|
+
message,
|
|
17
|
+
metadata
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildJsonRpcAck(id) {
|
|
23
|
+
return {
|
|
24
|
+
jsonrpc: '2.0',
|
|
25
|
+
id,
|
|
26
|
+
result: {
|
|
27
|
+
ack: true
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildJsonRpcReceipt(id) {
|
|
33
|
+
return {
|
|
34
|
+
jsonrpc: '2.0',
|
|
35
|
+
id,
|
|
36
|
+
result: {
|
|
37
|
+
received: true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isJsonRpcAck(message, id = '') {
|
|
43
|
+
return Boolean(
|
|
44
|
+
message
|
|
45
|
+
&& typeof message === 'object'
|
|
46
|
+
&& `${message.jsonrpc ?? ''}`.trim() === '2.0'
|
|
47
|
+
&& `${message.id ?? ''}`.trim() === `${id ?? ''}`.trim()
|
|
48
|
+
&& message.result
|
|
49
|
+
&& typeof message.result === 'object'
|
|
50
|
+
&& message.result.ack === true
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isJsonRpcReceipt(message, id = '') {
|
|
55
|
+
return Boolean(
|
|
56
|
+
message
|
|
57
|
+
&& typeof message === 'object'
|
|
58
|
+
&& `${message.jsonrpc ?? ''}`.trim() === '2.0'
|
|
59
|
+
&& `${message.id ?? ''}`.trim() === `${id ?? ''}`.trim()
|
|
60
|
+
&& message.result
|
|
61
|
+
&& typeof message.result === 'object'
|
|
62
|
+
&& message.result.received === true
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function sendResponseAck(stream, requestId) {
|
|
67
|
+
if (!stream || !`${requestId ?? ''}`.trim()) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await writeLine(stream, JSON.stringify(buildJsonRpcAck(requestId)))
|
|
72
|
+
} catch {
|
|
73
|
+
// best-effort only; the response itself was already received locally
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function sendRequestReceipt(stream, requestId) {
|
|
78
|
+
if (!stream || !`${requestId ?? ''}`.trim()) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
await writeLine(stream, JSON.stringify(buildJsonRpcReceipt(requestId)))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function waitForOptionalAck(stream, requestId, timeoutMs = RESPONSE_ACK_TIMEOUT_MS) {
|
|
85
|
+
if (!stream || !`${requestId ?? ''}`.trim()) {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const ack = await Promise.race([
|
|
90
|
+
readJsonMessage(stream),
|
|
91
|
+
new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
|
|
92
|
+
])
|
|
93
|
+
return isJsonRpcAck(ack, requestId)
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readMessageWithTimeout(readMessageFn, stream, timeoutMs, label) {
|
|
100
|
+
try {
|
|
101
|
+
return await Promise.race([
|
|
102
|
+
readMessageFn(stream),
|
|
103
|
+
new Promise((_, reject) => setTimeout(() => {
|
|
104
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`))
|
|
105
|
+
}, timeoutMs))
|
|
106
|
+
])
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function inferPostDispatchFailureKind(error = null) {
|
|
113
|
+
const explicit = `${error?.a2FailureKind ?? ''}`.trim()
|
|
114
|
+
if (explicit) {
|
|
115
|
+
return explicit
|
|
116
|
+
}
|
|
117
|
+
const lower = `${error?.message ?? ''}`.trim().toLowerCase()
|
|
118
|
+
if (!lower) {
|
|
119
|
+
return ''
|
|
120
|
+
}
|
|
121
|
+
if (lower.includes('turn response timed out after')) {
|
|
122
|
+
return 'post-dispatch-response-timeout'
|
|
123
|
+
}
|
|
124
|
+
if (lower === 'empty json message') {
|
|
125
|
+
return 'post-dispatch-empty-response'
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
lower.includes('stream that is closed')
|
|
129
|
+
|| lower.includes('stream closed before drain')
|
|
130
|
+
|| lower.includes('stream reset')
|
|
131
|
+
|| lower.includes('connection reset')
|
|
132
|
+
|| lower.includes('connection closed')
|
|
133
|
+
) {
|
|
134
|
+
return 'post-dispatch-stream-closed'
|
|
135
|
+
}
|
|
136
|
+
return 'post-dispatch-response-unconfirmed'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function currentTransport(node, binding, options = {}) {
|
|
140
|
+
return waitForPublishedTransport(node, binding, options)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function publishGatewayPresence(apiBase, agentId, bundle, node, binding, activitySummary, {
|
|
144
|
+
availabilityStatus = 'available',
|
|
145
|
+
requireRelayReservation = false
|
|
146
|
+
} = {}) {
|
|
147
|
+
const transport = await currentTransport(node, binding, { requireRelayReservation })
|
|
148
|
+
return postOnline(apiBase, agentId, bundle, {
|
|
149
|
+
availabilityStatus,
|
|
150
|
+
activitySummary,
|
|
151
|
+
peerId: transport.peerId,
|
|
152
|
+
listenAddrs: transport.listenAddrs,
|
|
153
|
+
relayAddrs: transport.relayAddrs,
|
|
154
|
+
supportedBindings: transport.supportedBindings,
|
|
155
|
+
a2aProtocolVersion: transport.a2aProtocolVersion,
|
|
156
|
+
streamProtocol: transport.streamProtocol
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function openDirectPeerSession({
|
|
161
|
+
apiBase,
|
|
162
|
+
agentId,
|
|
163
|
+
bundle,
|
|
164
|
+
node,
|
|
165
|
+
binding,
|
|
166
|
+
targetAgentId,
|
|
167
|
+
skillName,
|
|
168
|
+
method,
|
|
169
|
+
message,
|
|
170
|
+
metadata = null,
|
|
171
|
+
activitySummary,
|
|
172
|
+
report,
|
|
173
|
+
sessionStore = null,
|
|
174
|
+
allowTrustedReuse = true,
|
|
175
|
+
_deps = null
|
|
176
|
+
}) {
|
|
177
|
+
const deps = {
|
|
178
|
+
currentPeerConnectionFn: currentPeerConnection,
|
|
179
|
+
exchangeOverTransportFn: exchangeOverTransport,
|
|
180
|
+
currentTransportFn: currentTransport,
|
|
181
|
+
createConnectTicketWithRecoveryFn: createConnectTicketWithRecovery,
|
|
182
|
+
...(_deps && typeof _deps === 'object' ? _deps : {})
|
|
183
|
+
}
|
|
184
|
+
const metadataPayload = metadata && typeof metadata === 'object' ? metadata : {}
|
|
185
|
+
const conversationKey = `${metadataPayload.conversationKey ?? ''}`.trim()
|
|
186
|
+
if (!conversationKey) {
|
|
187
|
+
throw Object.assign(new Error('conversationKey is required for outbound AgentSquared conversations'), { code: 400 })
|
|
188
|
+
}
|
|
189
|
+
// Each AgentSquared turn always opens a fresh libp2p stream.
|
|
190
|
+
// When a trusted peer session is available, we only reuse the underlying
|
|
191
|
+
// peer connection and session metadata, not the previous stream itself.
|
|
192
|
+
const cachedSession = sessionStore?.trustedSessionByConversation?.(conversationKey)
|
|
193
|
+
?? null
|
|
194
|
+
const liveConnection = cachedSession?.remotePeerId ? deps.currentPeerConnectionFn(node, cachedSession.remotePeerId) : null
|
|
195
|
+
|
|
196
|
+
let ticket = null
|
|
197
|
+
let peerSessionId = `${cachedSession?.peerSessionId ?? ''}`.trim()
|
|
198
|
+
let targetTransport = null
|
|
199
|
+
let reusedPeerConnection = false
|
|
200
|
+
let ambiguousTrustedDispatchError = null
|
|
201
|
+
const reusableTransport = allowTrustedReuse && cachedSession
|
|
202
|
+
? mergeTargetTransport({
|
|
203
|
+
primary: cachedSession.remoteTransport,
|
|
204
|
+
secondary: cachedSession?.remotePeerId
|
|
205
|
+
? {
|
|
206
|
+
peerId: cachedSession.remotePeerId,
|
|
207
|
+
streamProtocol: binding.streamProtocol
|
|
208
|
+
}
|
|
209
|
+
: null,
|
|
210
|
+
streamProtocol: binding.streamProtocol
|
|
211
|
+
})
|
|
212
|
+
: null
|
|
213
|
+
const requestId = randomRequestId('a2a')
|
|
214
|
+
const buildRequest = ({
|
|
215
|
+
relayConnectTicket = '',
|
|
216
|
+
peerSessionId: nextPeerSessionId = ''
|
|
217
|
+
} = {}) => buildJsonRpcEnvelope({
|
|
218
|
+
id: requestId,
|
|
219
|
+
method,
|
|
220
|
+
message,
|
|
221
|
+
metadata: {
|
|
222
|
+
...metadataPayload,
|
|
223
|
+
relayConnectTicket,
|
|
224
|
+
peerSessionId: `${nextPeerSessionId ?? ''}`.trim(),
|
|
225
|
+
skillHint: `${skillName ?? ''}`.trim(),
|
|
226
|
+
from: agentId,
|
|
227
|
+
to: targetAgentId
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
if (reusableTransport?.peerId && reusableTransport?.streamProtocol && liveConnection) {
|
|
232
|
+
try {
|
|
233
|
+
sessionStore?.touchTrustedSession?.(cachedSession.peerSessionId)
|
|
234
|
+
const reusedResponse = await deps.exchangeOverTransportFn({
|
|
235
|
+
node,
|
|
236
|
+
transport: reusableTransport,
|
|
237
|
+
request: buildRequest({
|
|
238
|
+
relayConnectTicket: '',
|
|
239
|
+
peerSessionId
|
|
240
|
+
}),
|
|
241
|
+
reuseExistingConnection: true
|
|
242
|
+
})
|
|
243
|
+
targetTransport = reusableTransport
|
|
244
|
+
reusedPeerConnection = true
|
|
245
|
+
|
|
246
|
+
if (peerSessionId && targetTransport?.peerId) {
|
|
247
|
+
sessionStore?.rememberTrustedSession?.({
|
|
248
|
+
peerSessionId,
|
|
249
|
+
conversationKey,
|
|
250
|
+
remoteAgentId: targetAgentId,
|
|
251
|
+
remotePeerId: targetTransport.peerId,
|
|
252
|
+
remoteTransport: targetTransport,
|
|
253
|
+
skillHint: `${skillName ?? ''}`.trim()
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
ticket,
|
|
259
|
+
peerSessionId,
|
|
260
|
+
response: reusedResponse,
|
|
261
|
+
sessionReport: null,
|
|
262
|
+
reusedPeerConnection,
|
|
263
|
+
// Backward-compatible alias for existing callers.
|
|
264
|
+
reusedSession: reusedPeerConnection
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (!error?.a2DeliveryStatusKnown && `${error?.a2DispatchStage ?? ''}` === 'post-dispatch') {
|
|
268
|
+
ambiguousTrustedDispatchError = error
|
|
269
|
+
} else if (!isTrustedSessionRetryable(error)) {
|
|
270
|
+
throw error
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let response
|
|
276
|
+
try {
|
|
277
|
+
const transport = await deps.currentTransportFn(node, binding, { requireRelayReservation: true })
|
|
278
|
+
const relayAttempt = await deps.createConnectTicketWithRecoveryFn({
|
|
279
|
+
apiBase,
|
|
280
|
+
agentId,
|
|
281
|
+
bundle,
|
|
282
|
+
node,
|
|
283
|
+
binding,
|
|
284
|
+
targetAgentId,
|
|
285
|
+
skillName,
|
|
286
|
+
transport,
|
|
287
|
+
cachedTransport: cachedSession?.remoteTransport ?? null
|
|
288
|
+
})
|
|
289
|
+
ticket = relayAttempt.ticket
|
|
290
|
+
targetTransport = relayAttempt.targetTransport
|
|
291
|
+
peerSessionId = peerSessionId || parseConnectTicketId(ticket.ticket) || randomRequestId('peer')
|
|
292
|
+
|
|
293
|
+
response = await deps.exchangeOverTransportFn({
|
|
294
|
+
node,
|
|
295
|
+
transport: targetTransport,
|
|
296
|
+
request: buildRequest({
|
|
297
|
+
relayConnectTicket: ticket?.ticket ?? '',
|
|
298
|
+
peerSessionId
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (ambiguousTrustedDispatchError && !ambiguousTrustedDispatchError.a2DeliveryStatusKnown) {
|
|
303
|
+
const followUpDetail = `${error?.message ?? ''}`.trim()
|
|
304
|
+
if (followUpDetail && !ambiguousTrustedDispatchError.message.includes(followUpDetail)) {
|
|
305
|
+
ambiguousTrustedDispatchError.message = `${ambiguousTrustedDispatchError.message} Fresh relay retry also failed: ${followUpDetail}`
|
|
306
|
+
}
|
|
307
|
+
throw ambiguousTrustedDispatchError
|
|
308
|
+
}
|
|
309
|
+
throw error
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (peerSessionId && targetTransport?.peerId) {
|
|
313
|
+
sessionStore?.rememberTrustedSession?.({
|
|
314
|
+
peerSessionId,
|
|
315
|
+
conversationKey,
|
|
316
|
+
remoteAgentId: targetAgentId,
|
|
317
|
+
remotePeerId: targetTransport.peerId,
|
|
318
|
+
remoteTransport: targetTransport,
|
|
319
|
+
skillHint: `${skillName ?? ''}`.trim()
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let sessionReport = null
|
|
324
|
+
if (report && ticket?.ticket) {
|
|
325
|
+
sessionReport = await reportSession(apiBase, agentId, bundle, {
|
|
326
|
+
ticket: ticket.ticket,
|
|
327
|
+
taskId: report.taskId,
|
|
328
|
+
status: report.status ?? 'completed',
|
|
329
|
+
summary: report.summary,
|
|
330
|
+
publicSummary: report.publicSummary ?? ''
|
|
331
|
+
}, await bestEffortCurrentTransport(node, binding))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
ticket,
|
|
336
|
+
peerSessionId,
|
|
337
|
+
response,
|
|
338
|
+
sessionReport,
|
|
339
|
+
reusedPeerConnection,
|
|
340
|
+
reusedSession: reusedPeerConnection
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function createConnectTicketWithRecovery({
|
|
345
|
+
apiBase,
|
|
346
|
+
agentId,
|
|
347
|
+
bundle,
|
|
348
|
+
node,
|
|
349
|
+
binding,
|
|
350
|
+
targetAgentId,
|
|
351
|
+
skillName,
|
|
352
|
+
transport,
|
|
353
|
+
cachedTransport = null,
|
|
354
|
+
republishPresence = null,
|
|
355
|
+
retryDelayMs = RELAY_RECOVERY_RETRY_DELAY_MS
|
|
356
|
+
}) {
|
|
357
|
+
const attempt = async () => {
|
|
358
|
+
const latestAgentCard = await bestEffortAgentCard(apiBase, agentId, bundle, targetAgentId, transport)
|
|
359
|
+
const ticket = await createConnectTicket(apiBase, agentId, bundle, targetAgentId, skillName, transport)
|
|
360
|
+
const targetTransport = mergeTargetTransport({
|
|
361
|
+
primary: latestAgentCard?.preferredTransport ?? null,
|
|
362
|
+
secondary: ticket.targetTransport ?? ticket.agentCard?.preferredTransport ?? null,
|
|
363
|
+
tertiary: cachedTransport,
|
|
364
|
+
streamProtocol: binding.streamProtocol
|
|
365
|
+
})
|
|
366
|
+
return { ticket, targetTransport }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
return await attempt()
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (!isRelayPresenceRetryable(error)) {
|
|
373
|
+
throw error
|
|
374
|
+
}
|
|
375
|
+
if (typeof republishPresence === 'function') {
|
|
376
|
+
await republishPresence(error)
|
|
377
|
+
} else {
|
|
378
|
+
await bestEffortRepublishPresence(apiBase, agentId, bundle, node, binding)
|
|
379
|
+
}
|
|
380
|
+
await sleep(retryDelayMs)
|
|
381
|
+
return attempt()
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function attachInboundRouter({
|
|
386
|
+
apiBase,
|
|
387
|
+
agentId,
|
|
388
|
+
bundle,
|
|
389
|
+
node,
|
|
390
|
+
binding,
|
|
391
|
+
handler,
|
|
392
|
+
sessionStore
|
|
393
|
+
}) {
|
|
394
|
+
node.handle(binding.streamProtocol, async (eventOrStream, maybeConnection) => {
|
|
395
|
+
const { stream, connection } = normalizeInboundStreamContext(eventOrStream, maybeConnection)
|
|
396
|
+
const remotePeerId = connection?.remotePeer?.toString?.()
|
|
397
|
+
?? stream?.stat?.connection?.remotePeer?.toString?.()
|
|
398
|
+
?? ''
|
|
399
|
+
let request = null
|
|
400
|
+
let peerSessionId = ''
|
|
401
|
+
let receiptSent = false
|
|
402
|
+
try {
|
|
403
|
+
request = await readJsonMessage(stream)
|
|
404
|
+
const metadata = request?.params?.metadata ?? {}
|
|
405
|
+
const conversationKey = `${metadata.conversationKey ?? ''}`.trim()
|
|
406
|
+
if (!conversationKey) {
|
|
407
|
+
await writeLine(stream, JSON.stringify({
|
|
408
|
+
jsonrpc: '2.0',
|
|
409
|
+
id: request?.id ?? randomRequestId('invalid'),
|
|
410
|
+
error: { code: 400, message: 'conversationKey is required for inbound AgentSquared conversations' }
|
|
411
|
+
}))
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
const relayConnectTicket = `${metadata.relayConnectTicket ?? ''}`.trim()
|
|
415
|
+
const requestedPeerSessionId = `${metadata.peerSessionId ?? ''}`.trim()
|
|
416
|
+
let ticketView = null
|
|
417
|
+
peerSessionId = requestedPeerSessionId
|
|
418
|
+
let remoteAgentId = `${metadata.from ?? ''}`.trim()
|
|
419
|
+
let suggestedSkill = `${metadata.skillHint ?? ''}`.trim()
|
|
420
|
+
|
|
421
|
+
if (relayConnectTicket) {
|
|
422
|
+
ticketView = await introspectConnectTicket(
|
|
423
|
+
apiBase,
|
|
424
|
+
agentId,
|
|
425
|
+
bundle,
|
|
426
|
+
relayConnectTicket,
|
|
427
|
+
await bestEffortCurrentTransport(node, binding)
|
|
428
|
+
)
|
|
429
|
+
peerSessionId = peerSessionId || ticketView.ticketId
|
|
430
|
+
remoteAgentId = remoteAgentId || ticketView.initiatorAgentId
|
|
431
|
+
suggestedSkill = suggestedSkill || `${ticketView.skillName ?? ''}`.trim()
|
|
432
|
+
const remoteTransport = buildInboundRemoteTransport({
|
|
433
|
+
connection,
|
|
434
|
+
remotePeerId,
|
|
435
|
+
binding
|
|
436
|
+
})
|
|
437
|
+
sessionStore?.rememberTrustedSession?.({
|
|
438
|
+
peerSessionId,
|
|
439
|
+
conversationKey,
|
|
440
|
+
remoteAgentId,
|
|
441
|
+
remotePeerId,
|
|
442
|
+
remoteTransport,
|
|
443
|
+
ticketView,
|
|
444
|
+
skillHint: suggestedSkill
|
|
445
|
+
})
|
|
446
|
+
} else {
|
|
447
|
+
const trustedSession = sessionStore?.trustedSessionById?.(peerSessionId)
|
|
448
|
+
if (!trustedSession || trustedSession.remotePeerId !== remotePeerId) {
|
|
449
|
+
await writeLine(stream, JSON.stringify({
|
|
450
|
+
jsonrpc: '2.0',
|
|
451
|
+
id: request?.id ?? randomRequestId('invalid'),
|
|
452
|
+
error: { code: 401, message: 'relayConnectTicket or a trusted peerSessionId is required' }
|
|
453
|
+
}))
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
sessionStore?.touchTrustedSession?.(trustedSession.peerSessionId)
|
|
457
|
+
if (conversationKey) {
|
|
458
|
+
sessionStore?.rememberTrustedSession?.({
|
|
459
|
+
peerSessionId: trustedSession.peerSessionId,
|
|
460
|
+
conversationKey,
|
|
461
|
+
remoteAgentId: trustedSession.remoteAgentId,
|
|
462
|
+
remotePeerId: trustedSession.remotePeerId,
|
|
463
|
+
remoteTransport: trustedSession.remoteTransport,
|
|
464
|
+
ticketView: trustedSession.ticketView,
|
|
465
|
+
skillHint: trustedSession.skillHint
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
remoteAgentId = remoteAgentId || trustedSession.remoteAgentId
|
|
469
|
+
ticketView = trustedSession.ticketView ?? null
|
|
470
|
+
suggestedSkill = suggestedSkill || trustedSession.skillHint || 'friend-im'
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const cachedHandledResponse = sessionStore?.handledRequestResponse?.(peerSessionId, request?.id)
|
|
474
|
+
if (cachedHandledResponse) {
|
|
475
|
+
await sendRequestReceipt(stream, request?.id)
|
|
476
|
+
receiptSent = true
|
|
477
|
+
await writeLine(stream, JSON.stringify(cachedHandledResponse))
|
|
478
|
+
await waitForOptionalAck(stream, request?.id)
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const inbound = await sessionStore.enqueueInbound({
|
|
483
|
+
request,
|
|
484
|
+
ticketView,
|
|
485
|
+
remotePeerId,
|
|
486
|
+
remoteAgentId,
|
|
487
|
+
peerSessionId,
|
|
488
|
+
suggestedSkill,
|
|
489
|
+
defaultSkill: 'friend-im'
|
|
490
|
+
})
|
|
491
|
+
await sendRequestReceipt(stream, request?.id)
|
|
492
|
+
receiptSent = true
|
|
493
|
+
const result = await inbound.responsePromise
|
|
494
|
+
const finalResult = typeof result === 'object' && result != null
|
|
495
|
+
? {
|
|
496
|
+
...result,
|
|
497
|
+
metadata: {
|
|
498
|
+
...(result.metadata ?? {}),
|
|
499
|
+
peerSessionId
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
: { value: result, metadata: { peerSessionId } }
|
|
503
|
+
await writeLine(stream, JSON.stringify({
|
|
504
|
+
jsonrpc: '2.0',
|
|
505
|
+
id: request.id,
|
|
506
|
+
result: finalResult
|
|
507
|
+
}))
|
|
508
|
+
sessionStore?.rememberHandledRequest?.({
|
|
509
|
+
peerSessionId,
|
|
510
|
+
requestId: request?.id,
|
|
511
|
+
response: {
|
|
512
|
+
jsonrpc: '2.0',
|
|
513
|
+
id: request.id,
|
|
514
|
+
result: finalResult
|
|
515
|
+
}
|
|
516
|
+
})
|
|
517
|
+
await waitForOptionalAck(stream, request?.id)
|
|
518
|
+
} catch (error) {
|
|
519
|
+
const errorResponse = {
|
|
520
|
+
jsonrpc: '2.0',
|
|
521
|
+
id: request?.id ?? randomRequestId('error'),
|
|
522
|
+
error: { code: Number.parseInt(`${error.code ?? 500}`, 10) || 500, message: error.message }
|
|
523
|
+
}
|
|
524
|
+
if (peerSessionId && request?.id) {
|
|
525
|
+
sessionStore?.rememberHandledRequest?.({
|
|
526
|
+
peerSessionId,
|
|
527
|
+
requestId: request.id,
|
|
528
|
+
response: errorResponse
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
if (request?.id && !receiptSent) {
|
|
532
|
+
await sendRequestReceipt(stream, request?.id)
|
|
533
|
+
receiptSent = true
|
|
534
|
+
}
|
|
535
|
+
await writeLine(stream, JSON.stringify(errorResponse))
|
|
536
|
+
await waitForOptionalAck(stream, request?.id)
|
|
537
|
+
} finally {
|
|
538
|
+
await stream.close()
|
|
539
|
+
}
|
|
540
|
+
}, { runOnLimitedConnection: true })
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizeInboundStreamContext(eventOrStream, maybeConnection) {
|
|
544
|
+
if (maybeConnection) {
|
|
545
|
+
return {
|
|
546
|
+
stream: eventOrStream,
|
|
547
|
+
connection: maybeConnection
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
stream: eventOrStream?.stream ?? eventOrStream,
|
|
553
|
+
connection: eventOrStream?.connection ?? null
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function bestEffortRepublishPresence(apiBase, agentId, bundle, node, binding) {
|
|
558
|
+
try {
|
|
559
|
+
await publishGatewayPresence(apiBase, agentId, bundle, node, binding, 'Refreshing relay presence after a transient delivery failure.', {
|
|
560
|
+
requireRelayReservation: true
|
|
561
|
+
})
|
|
562
|
+
} catch {
|
|
563
|
+
// best-effort only; retry path will still surface the original relay error if recovery did not help
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function bestEffortCurrentTransport(node, binding) {
|
|
568
|
+
try {
|
|
569
|
+
return await currentTransport(node, binding)
|
|
570
|
+
} catch {
|
|
571
|
+
return null
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function exchangeOverTransport({
|
|
576
|
+
node,
|
|
577
|
+
transport,
|
|
578
|
+
request,
|
|
579
|
+
reuseExistingConnection = false,
|
|
580
|
+
openStreamFn = openTransportStream,
|
|
581
|
+
writeLineFn = writeLine,
|
|
582
|
+
readMessageFn = readJsonMessage,
|
|
583
|
+
turnReceiptTimeoutMs = TURN_RECEIPT_TIMEOUT_MS,
|
|
584
|
+
turnResponseTimeoutMs = TURN_RESPONSE_TIMEOUT_MS
|
|
585
|
+
}) {
|
|
586
|
+
let lastError = null
|
|
587
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
588
|
+
let stream = null
|
|
589
|
+
let dispatchStage = 'pre-dispatch'
|
|
590
|
+
let receiptConfirmed = false
|
|
591
|
+
try {
|
|
592
|
+
stream = await openStreamFn(node, transport, {
|
|
593
|
+
reuseExistingConnection,
|
|
594
|
+
allowDialFallback: attempt > 0
|
|
595
|
+
})
|
|
596
|
+
await writeLineFn(stream, JSON.stringify(request))
|
|
597
|
+
const firstMessage = await readMessageWithTimeout(
|
|
598
|
+
readMessageFn,
|
|
599
|
+
stream,
|
|
600
|
+
turnReceiptTimeoutMs,
|
|
601
|
+
'request receipt'
|
|
602
|
+
)
|
|
603
|
+
let response = firstMessage
|
|
604
|
+
if (isJsonRpcReceipt(firstMessage, request?.id)) {
|
|
605
|
+
receiptConfirmed = true
|
|
606
|
+
dispatchStage = 'post-dispatch'
|
|
607
|
+
response = await readMessageWithTimeout(
|
|
608
|
+
readMessageFn,
|
|
609
|
+
stream,
|
|
610
|
+
turnResponseTimeoutMs,
|
|
611
|
+
'turn response'
|
|
612
|
+
)
|
|
613
|
+
} else if (isJsonRpcAck(firstMessage, request?.id)) {
|
|
614
|
+
const error = new Error('unexpected response acknowledgement before request receipt')
|
|
615
|
+
error.a2DeliveryStatusKnown = true
|
|
616
|
+
throw error
|
|
617
|
+
} else {
|
|
618
|
+
receiptConfirmed = true
|
|
619
|
+
dispatchStage = 'post-dispatch'
|
|
620
|
+
}
|
|
621
|
+
await sendResponseAck(stream, request?.id)
|
|
622
|
+
if (response.error) {
|
|
623
|
+
throw buildJsonRpcError(response.error)
|
|
624
|
+
}
|
|
625
|
+
return response
|
|
626
|
+
} catch (error) {
|
|
627
|
+
error.a2DispatchStage = error.a2DispatchStage || dispatchStage
|
|
628
|
+
if (shouldRetryBeforeReceipt(error, attempt, receiptConfirmed)) {
|
|
629
|
+
lastError = error
|
|
630
|
+
continue
|
|
631
|
+
}
|
|
632
|
+
if (shouldRetryEmptyPostDispatch(error, attempt)) {
|
|
633
|
+
lastError = error
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
if (lastError && `${lastError?.a2DispatchStage ?? ''}` === 'post-dispatch' && !lastError?.a2DeliveryStatusKnown) {
|
|
637
|
+
lastError.a2FailureKind = inferPostDispatchFailureKind(lastError)
|
|
638
|
+
if (!/delivery status is unknown/i.test(`${lastError.message ?? ''}`)) {
|
|
639
|
+
lastError.message = `delivery status is unknown after the request was dispatched: ${lastError.message ?? 'response could not be confirmed'}`
|
|
640
|
+
}
|
|
641
|
+
throw lastError
|
|
642
|
+
}
|
|
643
|
+
if (dispatchStage === 'post-dispatch' && !error.a2DeliveryStatusKnown) {
|
|
644
|
+
error.a2DeliveryStatusKnown = false
|
|
645
|
+
error.a2FailureKind = inferPostDispatchFailureKind(error)
|
|
646
|
+
if (!/delivery status is unknown/i.test(`${error.message ?? ''}`)) {
|
|
647
|
+
error.message = `delivery status is unknown after the request was dispatched: ${error.message ?? 'response could not be confirmed'}`
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
throw error
|
|
651
|
+
} finally {
|
|
652
|
+
await stream?.close?.()
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (lastError) {
|
|
657
|
+
if (!lastError.a2DeliveryStatusKnown) {
|
|
658
|
+
lastError.a2DeliveryStatusKnown = false
|
|
659
|
+
}
|
|
660
|
+
lastError.a2FailureKind = inferPostDispatchFailureKind(lastError)
|
|
661
|
+
if (!/delivery status is unknown/i.test(`${lastError.message ?? ''}`)) {
|
|
662
|
+
lastError.message = `delivery status is unknown after the request was dispatched: ${lastError.message ?? 'response could not be confirmed'}`
|
|
663
|
+
}
|
|
664
|
+
throw lastError
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
throw new Error('delivery status is unknown after the request was dispatched: response could not be confirmed')
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function openTransportStream(node, transport, {
|
|
671
|
+
reuseExistingConnection = false,
|
|
672
|
+
allowDialFallback = false
|
|
673
|
+
} = {}) {
|
|
674
|
+
if (!reuseExistingConnection) {
|
|
675
|
+
return dialProtocol(node, transport, { requireDirect: false })
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
return await openStreamOnExistingConnection(node, transport)
|
|
679
|
+
} catch (error) {
|
|
680
|
+
if (!allowDialFallback) {
|
|
681
|
+
throw error
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return dialProtocol(node, transport, { requireDirect: false })
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function shouldRetryEmptyPostDispatch(error, attempt) {
|
|
688
|
+
if (attempt > 0) {
|
|
689
|
+
return false
|
|
690
|
+
}
|
|
691
|
+
if (`${error?.a2DispatchStage ?? ''}` !== 'post-dispatch') {
|
|
692
|
+
return false
|
|
693
|
+
}
|
|
694
|
+
return `${error?.message ?? ''}`.trim().toLowerCase() === 'empty json message'
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function shouldRetryBeforeReceipt(error, attempt, receiptConfirmed) {
|
|
698
|
+
if (attempt > 0 || receiptConfirmed) {
|
|
699
|
+
return false
|
|
700
|
+
}
|
|
701
|
+
if (`${error?.a2DispatchStage ?? ''}` !== 'pre-dispatch') {
|
|
702
|
+
return false
|
|
703
|
+
}
|
|
704
|
+
const lower = `${error?.message ?? ''}`.trim().toLowerCase()
|
|
705
|
+
return (
|
|
706
|
+
lower === 'empty json message'
|
|
707
|
+
|| lower.includes('request receipt timed out after')
|
|
708
|
+
|| lower.includes('stream that is closed')
|
|
709
|
+
|| lower.includes('stream closed before drain')
|
|
710
|
+
|| lower.includes('stream reset')
|
|
711
|
+
|| lower.includes('connection reset')
|
|
712
|
+
|| lower.includes('connection closed')
|
|
713
|
+
|| lower.includes('no existing peer connection is available')
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function buildJsonRpcError(error = {}) {
|
|
718
|
+
const out = new Error(`${error.message ?? 'remote peer returned an error'}`)
|
|
719
|
+
out.code = Number.parseInt(`${error.code ?? 500}`, 10) || 500
|
|
720
|
+
return out
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function bestEffortAgentCard(apiBase, agentId, bundle, targetAgentId, transport) {
|
|
724
|
+
try {
|
|
725
|
+
return await getAgentCard(apiBase, agentId, bundle, targetAgentId, transport)
|
|
726
|
+
} catch {
|
|
727
|
+
return null
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function isTrustedSessionRetryable(error) {
|
|
732
|
+
const message = `${error?.message ?? ''}`.trim()
|
|
733
|
+
const lower = message.toLowerCase()
|
|
734
|
+
const code = Number.parseInt(`${error?.code ?? 0}`, 10) || 0
|
|
735
|
+
if (code === 401 || message.includes('relayConnectTicket or a trusted peerSessionId is required')) {
|
|
736
|
+
return true
|
|
737
|
+
}
|
|
738
|
+
if (`${error?.a2DispatchStage ?? ''}` !== 'pre-dispatch') {
|
|
739
|
+
return false
|
|
740
|
+
}
|
|
741
|
+
return [
|
|
742
|
+
'target transport is missing dialaddrs',
|
|
743
|
+
'target transport is missing peerid',
|
|
744
|
+
'target transport is missing streamprotocol',
|
|
745
|
+
'no connection was available',
|
|
746
|
+
'no existing peer connection is available',
|
|
747
|
+
'direct p2p upgrade did not complete',
|
|
748
|
+
'connection refused',
|
|
749
|
+
'connection reset',
|
|
750
|
+
'connection closed',
|
|
751
|
+
'stream reset',
|
|
752
|
+
'stream closed before drain',
|
|
753
|
+
'stream that is closed',
|
|
754
|
+
'empty json message',
|
|
755
|
+
'request receipt timed out after',
|
|
756
|
+
'the operation was aborted',
|
|
757
|
+
'already aborted',
|
|
758
|
+
'dial timeout',
|
|
759
|
+
'timed out'
|
|
760
|
+
].some((pattern) => lower.includes(pattern))
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function isRelayPresenceRetryable(error) {
|
|
764
|
+
const message = `${error?.message ?? ''}`.trim().toLowerCase()
|
|
765
|
+
return (
|
|
766
|
+
message.startsWith('409 target agent is not currently online') ||
|
|
767
|
+
message.startsWith('409 target agent presence is invalid or stale') ||
|
|
768
|
+
message.startsWith('409 target agent has not published a current peer identity for direct p2p contact') ||
|
|
769
|
+
message.startsWith('409 target agent has not published a current relay reservation or public direct dial address for p2p contact')
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function sleep(ms) {
|
|
774
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function mergeTargetTransport({
|
|
778
|
+
primary = null,
|
|
779
|
+
secondary = null,
|
|
780
|
+
tertiary = null,
|
|
781
|
+
streamProtocol = ''
|
|
782
|
+
} = {}) {
|
|
783
|
+
const sources = [primary, secondary, tertiary].filter((value) => value && typeof value === 'object')
|
|
784
|
+
const peerId = firstNonEmpty(sources.map((value) => value.peerId))
|
|
785
|
+
const protocol = firstNonEmpty(sources.map((value) => value.streamProtocol).concat(streamProtocol))
|
|
786
|
+
const dialAddrs = unique(
|
|
787
|
+
sources.flatMap((value) => value.dialAddrs ?? [])
|
|
788
|
+
)
|
|
789
|
+
const listenAddrs = unique(
|
|
790
|
+
sources.flatMap((value) => value.listenAddrs ?? [])
|
|
791
|
+
)
|
|
792
|
+
const relayAddrs = unique(
|
|
793
|
+
sources.flatMap((value) => value.relayAddrs ?? [])
|
|
794
|
+
)
|
|
795
|
+
const supportedBindings = unique(
|
|
796
|
+
sources.flatMap((value) => value.supportedBindings ?? [])
|
|
797
|
+
)
|
|
798
|
+
const a2aProtocolVersion = firstNonEmpty(sources.map((value) => value.a2aProtocolVersion))
|
|
799
|
+
|
|
800
|
+
if (!peerId || !protocol) {
|
|
801
|
+
return null
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return {
|
|
805
|
+
peerId,
|
|
806
|
+
streamProtocol: protocol,
|
|
807
|
+
dialAddrs,
|
|
808
|
+
listenAddrs,
|
|
809
|
+
relayAddrs,
|
|
810
|
+
supportedBindings,
|
|
811
|
+
a2aProtocolVersion
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function buildInboundRemoteTransport({
|
|
816
|
+
connection,
|
|
817
|
+
remotePeerId,
|
|
818
|
+
binding
|
|
819
|
+
} = {}) {
|
|
820
|
+
const remoteAddr = cleanAddr(connection?.remoteAddr?.toString?.())
|
|
821
|
+
const dialAddrs = unique(remoteAddr ? [remoteAddr] : [])
|
|
822
|
+
return {
|
|
823
|
+
peerId: `${remotePeerId ?? ''}`.trim(),
|
|
824
|
+
streamProtocol: `${binding?.streamProtocol ?? ''}`.trim(),
|
|
825
|
+
dialAddrs,
|
|
826
|
+
listenAddrs: dialAddrs
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function cleanAddr(value) {
|
|
831
|
+
return `${value ?? ''}`.trim()
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function firstNonEmpty(values = []) {
|
|
835
|
+
for (const value of values) {
|
|
836
|
+
const cleaned = `${value ?? ''}`.trim()
|
|
837
|
+
if (cleaned) {
|
|
838
|
+
return cleaned
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return ''
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function unique(values = []) {
|
|
845
|
+
return [...new Set(values.map((value) => `${value}`.trim()).filter(Boolean))]
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function parseConnectTicketId(token) {
|
|
849
|
+
const parts = `${token ?? ''}`.trim().split('.')
|
|
850
|
+
if (parts.length < 2) return ''
|
|
851
|
+
try {
|
|
852
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
|
|
853
|
+
return `${payload.tid ?? payload.jti ?? ''}`.trim()
|
|
854
|
+
} catch {
|
|
855
|
+
return ''
|
|
856
|
+
}
|
|
857
|
+
}
|