@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.
@@ -0,0 +1,1020 @@
1
+ import { withOpenClawGatewayClient } from './ws_client.mjs'
2
+ import { buildReceiverBaseReport, inferOwnerFacingLanguage, parseAgentSquaredOutboundEnvelope } from '../../lib/conversation/templates.mjs'
3
+ import { normalizeConversationControl, resolveInboundConversationIdentity, resolveSkillMaxTurns } from '../../lib/conversation/policy.mjs'
4
+ import { scrubOutboundText } from '../../lib/runtime/safety.mjs'
5
+ import {
6
+ buildOpenClawConversationSummaryPrompt,
7
+ buildOpenClawLocalSkillInventoryPrompt,
8
+ buildOpenClawOutboundSkillDecisionPrompt,
9
+ buildOpenClawSafetyPrompt,
10
+ buildOpenClawTaskPrompt,
11
+ formatOpenClawLocalSkillInventoryForPrompt,
12
+ latestAssistantText,
13
+ normalizeOpenClawSafetySessionKey,
14
+ normalizeOpenClawSessionKey,
15
+ normalizeSessionList,
16
+ ownerReportText,
17
+ parseOpenClawLocalSkillInventoryResult,
18
+ parseOpenClawSkillDecisionResult,
19
+ parseOpenClawSafetyResult,
20
+ parseOpenClawConversationSummaryResult,
21
+ parseOpenClawTaskResult,
22
+ peerResponseText,
23
+ readOpenClawRunId,
24
+ readOpenClawStatus,
25
+ resolveOwnerRouteFromSessions,
26
+ stableId
27
+ } from './helpers.mjs'
28
+
29
+ export {
30
+ buildOpenClawConversationSummaryPrompt,
31
+ buildOpenClawLocalSkillInventoryPrompt,
32
+ buildOpenClawOutboundSkillDecisionPrompt,
33
+ buildOpenClawSafetyPrompt,
34
+ buildOpenClawTaskPrompt,
35
+ parseOpenClawLocalSkillInventoryResult,
36
+ parseOpenClawConversationSummaryResult,
37
+ parseOpenClawSkillDecisionResult,
38
+ parseOpenClawTaskResult
39
+ }
40
+
41
+ function clean(value) {
42
+ return `${value ?? ''}`.trim()
43
+ }
44
+
45
+ function randomId(prefix = 'a2') {
46
+ return `${clean(prefix) || 'a2'}-${Date.now()}-${Math.random().toString(16).slice(2)}`
47
+ }
48
+
49
+ function nowMs() {
50
+ return Date.now()
51
+ }
52
+
53
+ function localOwnerTimeZone() {
54
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
55
+ }
56
+
57
+ function toNumber(value) {
58
+ const parsed = Number.parseInt(`${value ?? ''}`, 10)
59
+ return Number.isFinite(parsed) ? parsed : 0
60
+ }
61
+
62
+ function excerpt(text, maxLength = 140) {
63
+ const compact = clean(text).replace(/\s+/g, ' ').trim()
64
+ if (!compact) {
65
+ return ''
66
+ }
67
+ return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact
68
+ }
69
+
70
+ function buildReceiverTurnOutline(turns = [], expectedTurnCount = 1) {
71
+ const normalizedTurns = Array.isArray(turns) ? turns : []
72
+ const turnMap = new Map()
73
+ let maxSeenTurnIndex = 0
74
+ for (const turn of normalizedTurns) {
75
+ const turnIndex = Number.parseInt(`${turn?.turnIndex ?? 0}`, 10) || 0
76
+ if (turnIndex > 0) {
77
+ maxSeenTurnIndex = Math.max(maxSeenTurnIndex, turnIndex)
78
+ turnMap.set(turnIndex, turn)
79
+ }
80
+ }
81
+ const maxTurnCount = Math.max(1, Number.parseInt(`${expectedTurnCount ?? 1}`, 10) || 1, maxSeenTurnIndex)
82
+ return Array.from({ length: maxTurnCount }, (_, index) => {
83
+ const displayTurnIndex = index + 1
84
+ const turn = turnMap.get(displayTurnIndex)
85
+ if (!turn) {
86
+ return {
87
+ turnIndex: displayTurnIndex,
88
+ summary: 'Earlier turn details were not preserved in the current live transcript, but this conversation continued.'
89
+ }
90
+ }
91
+ const inbound = excerpt(turn.inboundText)
92
+ const reply = excerpt(turn.replyText)
93
+ const isFinalTurn = Boolean(turn.finalize) || ['done', 'handoff'].includes(clean(turn.decision).toLowerCase())
94
+ return {
95
+ turnIndex: displayTurnIndex,
96
+ summary: [
97
+ inbound ? `remote said "${inbound}"` : 'remote sent a message',
98
+ reply ? `I replied "${reply}"` : 'I replied',
99
+ isFinalTurn && clean(turn.stopReason) ? `(final stop: ${clean(turn.stopReason)})` : ''
100
+ ].filter(Boolean).join(' ')
101
+ }
102
+ })
103
+ }
104
+
105
+ function maxTurnIndexFromOutline(turnOutline = []) {
106
+ const normalized = Array.isArray(turnOutline) ? turnOutline : []
107
+ return normalized.reduce((maxSeen, item, index) => {
108
+ const turnIndex = Number.parseInt(`${item?.turnIndex ?? index + 1}`, 10) || (index + 1)
109
+ return Math.max(maxSeen, turnIndex)
110
+ }, 0)
111
+ }
112
+
113
+ function reframeOpenClawAgentError(error, {
114
+ openclawAgent = '',
115
+ localAgentId = ''
116
+ } = {}) {
117
+ const message = clean(error?.message)
118
+ if (!message) {
119
+ return error
120
+ }
121
+ if (message.toLowerCase().includes('unknown agent id')) {
122
+ return new Error(
123
+ `OpenClaw rejected agent id "${clean(openclawAgent)}". AgentSquared needs a real local OpenClaw agent id here, not the AgentSquared id "${clean(localAgentId)}". Configure --openclaw-agent explicitly or make sure OpenClaw exposes a default agent (usually from agents.list[]; fallback is often "main"). Original error: ${message}`
124
+ )
125
+ }
126
+ return error
127
+ }
128
+
129
+ function resolveFinalAssistantResultText({
130
+ waited = null,
131
+ history = null,
132
+ runId = '',
133
+ label = 'OpenClaw run',
134
+ sessionKey = ''
135
+ } = {}) {
136
+ const fromWaited = latestAssistantText(waited, { runId })
137
+ if (clean(fromWaited)) {
138
+ return fromWaited
139
+ }
140
+ const fromHistory = latestAssistantText(history, { runId })
141
+ if (clean(fromHistory)) {
142
+ return fromHistory
143
+ }
144
+ throw new Error(`${clean(label) || 'OpenClaw run'} did not produce a final assistant message for session ${clean(sessionKey) || 'unknown'}.`)
145
+ }
146
+
147
+ export async function resolveOpenClawOutboundSkillHint({
148
+ localAgentId,
149
+ targetAgentId,
150
+ ownerText,
151
+ openclawAgent = '',
152
+ command = 'openclaw',
153
+ cwd = '',
154
+ configPath = '',
155
+ stateDir = '',
156
+ timeoutMs = 60000,
157
+ gatewayUrl = '',
158
+ gatewayToken = '',
159
+ gatewayPassword = '',
160
+ availableSkills = ['friend-im', 'agent-mutual-learning']
161
+ } = {}) {
162
+ const agentName = clean(openclawAgent)
163
+ if (!agentName) {
164
+ throw new Error(`openclaw agent name is required for ${clean(localAgentId) || 'the local AgentSquared agent'}`)
165
+ }
166
+ return withOpenClawGatewayClient({
167
+ command,
168
+ cwd,
169
+ configPath,
170
+ stateDir,
171
+ gatewayUrl,
172
+ gatewayToken,
173
+ gatewayPassword,
174
+ requestTimeoutMs: timeoutMs
175
+ }, async (client, gatewayContext) => {
176
+ const sessionKey = stableId('agentsquared-outbound-skill-decision', localAgentId, targetAgentId, ownerText)
177
+ const prompt = buildOpenClawOutboundSkillDecisionPrompt({
178
+ localAgentId,
179
+ targetAgentId,
180
+ ownerText,
181
+ availableSkills
182
+ })
183
+ let accepted
184
+ try {
185
+ accepted = await client.request('agent', {
186
+ agentId: agentName,
187
+ sessionKey,
188
+ message: prompt,
189
+ idempotencyKey: stableId('agentsquared-outbound-skill-decision-run', localAgentId, targetAgentId, ownerText)
190
+ }, timeoutMs)
191
+ } catch (error) {
192
+ throw reframeOpenClawAgentError(error, {
193
+ openclawAgent: agentName,
194
+ localAgentId
195
+ })
196
+ }
197
+ const runId = readOpenClawRunId(accepted)
198
+ if (!runId) {
199
+ throw new Error('OpenClaw outbound skill decision did not return a runId.')
200
+ }
201
+ const waited = await client.request('agent.wait', {
202
+ runId,
203
+ timeoutMs
204
+ }, timeoutMs + 1000)
205
+ const status = readOpenClawStatus(waited).toLowerCase()
206
+ if (status && status !== 'ok' && status !== 'completed' && status !== 'done') {
207
+ throw new Error(`OpenClaw outbound skill decision returned ${status || 'an unknown status'} for run ${runId}.`)
208
+ }
209
+ const history = await client.request('chat.history', {
210
+ sessionKey,
211
+ limit: 8
212
+ }, timeoutMs)
213
+ const resultText = resolveFinalAssistantResultText({
214
+ waited,
215
+ history,
216
+ runId,
217
+ label: 'OpenClaw outbound skill decision',
218
+ sessionKey
219
+ })
220
+ const parsed = parseOpenClawSkillDecisionResult(resultText, {
221
+ availableSkills,
222
+ defaultSkill: 'friend-im'
223
+ })
224
+ return {
225
+ ...parsed,
226
+ openclawRunId: runId,
227
+ openclawSessionKey: sessionKey,
228
+ openclawGatewayUrl: gatewayContext.gatewayUrl
229
+ }
230
+ })
231
+ }
232
+
233
+ export async function summarizeOpenClawConversation({
234
+ localAgentId,
235
+ remoteAgentId,
236
+ selectedSkill = 'friend-im',
237
+ originalOwnerText = '',
238
+ turnLog = [],
239
+ localSkillInventory = '',
240
+ openclawAgent = '',
241
+ command = 'openclaw',
242
+ cwd = '',
243
+ configPath = '',
244
+ stateDir = '',
245
+ timeoutMs = 60000,
246
+ gatewayUrl = '',
247
+ gatewayToken = '',
248
+ gatewayPassword = ''
249
+ } = {}) {
250
+ const agentName = clean(openclawAgent)
251
+ if (!agentName) {
252
+ throw new Error(`openclaw agent name is required for ${clean(localAgentId) || 'the local AgentSquared agent'}`)
253
+ }
254
+ return withOpenClawGatewayClient({
255
+ command,
256
+ cwd,
257
+ configPath,
258
+ stateDir,
259
+ gatewayUrl,
260
+ gatewayToken,
261
+ gatewayPassword,
262
+ requestTimeoutMs: timeoutMs
263
+ }, async (client, gatewayContext) => {
264
+ const sessionKey = stableId(
265
+ 'agentsquared-conversation-summary',
266
+ localAgentId,
267
+ remoteAgentId,
268
+ selectedSkill,
269
+ originalOwnerText,
270
+ JSON.stringify(turnLog ?? [])
271
+ )
272
+ const prompt = buildOpenClawConversationSummaryPrompt({
273
+ localAgentId,
274
+ remoteAgentId,
275
+ selectedSkill,
276
+ originalOwnerText,
277
+ turnLog,
278
+ localSkillInventory
279
+ })
280
+ let accepted
281
+ try {
282
+ accepted = await client.request('agent', {
283
+ agentId: agentName,
284
+ sessionKey,
285
+ message: prompt,
286
+ idempotencyKey: stableId(
287
+ 'agentsquared-conversation-summary-run',
288
+ localAgentId,
289
+ remoteAgentId,
290
+ selectedSkill,
291
+ originalOwnerText,
292
+ JSON.stringify(turnLog ?? [])
293
+ )
294
+ }, timeoutMs)
295
+ } catch (error) {
296
+ throw reframeOpenClawAgentError(error, {
297
+ openclawAgent: agentName,
298
+ localAgentId
299
+ })
300
+ }
301
+ const runId = readOpenClawRunId(accepted)
302
+ if (!runId) {
303
+ throw new Error('OpenClaw conversation summary did not return a runId.')
304
+ }
305
+ const waited = await client.request('agent.wait', {
306
+ runId,
307
+ timeoutMs
308
+ }, timeoutMs + 1000)
309
+ const status = readOpenClawStatus(waited).toLowerCase()
310
+ if (status && status !== 'ok' && status !== 'completed' && status !== 'done') {
311
+ throw new Error(`OpenClaw conversation summary returned ${status || 'an unknown status'} for run ${runId}.`)
312
+ }
313
+ const history = await client.request('chat.history', {
314
+ sessionKey,
315
+ limit: 8
316
+ }, timeoutMs)
317
+ const resultText = resolveFinalAssistantResultText({
318
+ waited,
319
+ history,
320
+ runId,
321
+ label: 'OpenClaw conversation summary',
322
+ sessionKey
323
+ })
324
+ const parsed = parseOpenClawConversationSummaryResult(resultText)
325
+ return {
326
+ ...parsed,
327
+ openclawRunId: runId,
328
+ openclawSessionKey: sessionKey,
329
+ openclawGatewayUrl: gatewayContext.gatewayUrl
330
+ }
331
+ })
332
+ }
333
+
334
+ export async function inspectOpenClawLocalSkills({
335
+ localAgentId,
336
+ openclawAgent = '',
337
+ command = 'openclaw',
338
+ cwd = '',
339
+ configPath = '',
340
+ stateDir = '',
341
+ timeoutMs = 60000,
342
+ gatewayUrl = '',
343
+ gatewayToken = '',
344
+ gatewayPassword = '',
345
+ purpose = 'mutual-learning'
346
+ } = {}) {
347
+ const agentName = clean(openclawAgent)
348
+ if (!agentName) {
349
+ throw new Error(`openclaw agent name is required for ${clean(localAgentId) || 'the local AgentSquared agent'}`)
350
+ }
351
+ return withOpenClawGatewayClient({
352
+ command,
353
+ cwd,
354
+ configPath,
355
+ stateDir,
356
+ gatewayUrl,
357
+ gatewayToken,
358
+ gatewayPassword,
359
+ requestTimeoutMs: timeoutMs
360
+ }, async (client, gatewayContext) => {
361
+ const sessionKey = stableId('agentsquared-local-skill-inventory', localAgentId, purpose)
362
+ const prompt = buildOpenClawLocalSkillInventoryPrompt({
363
+ localAgentId,
364
+ purpose
365
+ })
366
+ let accepted
367
+ try {
368
+ accepted = await client.request('agent', {
369
+ agentId: agentName,
370
+ sessionKey,
371
+ message: prompt,
372
+ idempotencyKey: stableId('agentsquared-local-skill-inventory-run', localAgentId, purpose)
373
+ }, timeoutMs)
374
+ } catch (error) {
375
+ throw reframeOpenClawAgentError(error, {
376
+ openclawAgent: agentName,
377
+ localAgentId
378
+ })
379
+ }
380
+ const runId = readOpenClawRunId(accepted)
381
+ if (!runId) {
382
+ throw new Error('OpenClaw local skill inventory did not return a runId.')
383
+ }
384
+ const waited = await client.request('agent.wait', {
385
+ runId,
386
+ timeoutMs
387
+ }, timeoutMs + 1000)
388
+ const status = readOpenClawStatus(waited).toLowerCase()
389
+ if (status && status !== 'ok' && status !== 'completed' && status !== 'done') {
390
+ throw new Error(`OpenClaw local skill inventory returned ${status || 'an unknown status'} for run ${runId}.`)
391
+ }
392
+ const history = await client.request('chat.history', {
393
+ sessionKey,
394
+ limit: 8
395
+ }, timeoutMs)
396
+ const resultText = resolveFinalAssistantResultText({
397
+ waited,
398
+ history,
399
+ runId,
400
+ label: 'OpenClaw local skill inventory',
401
+ sessionKey
402
+ })
403
+ const parsed = parseOpenClawLocalSkillInventoryResult(resultText)
404
+ return {
405
+ ...parsed,
406
+ inventoryPromptText: formatOpenClawLocalSkillInventoryForPrompt(parsed),
407
+ openclawRunId: runId,
408
+ openclawSessionKey: sessionKey,
409
+ openclawGatewayUrl: gatewayContext.gatewayUrl
410
+ }
411
+ })
412
+ }
413
+
414
+ export function createOpenClawAdapter({
415
+ localAgentId,
416
+ openclawAgent = '',
417
+ conversationStore = null,
418
+ command = 'openclaw',
419
+ cwd = '',
420
+ configPath = '',
421
+ stateDir = '',
422
+ sessionPrefix = 'agentsquared:',
423
+ timeoutMs = 180000,
424
+ gatewayUrl = '',
425
+ gatewayToken = '',
426
+ gatewayPassword = ''
427
+ } = {}) {
428
+ const agentName = clean(openclawAgent)
429
+ if (!agentName) {
430
+ throw new Error(`openclaw agent name is required for ${clean(localAgentId) || 'the local AgentSquared agent'}`)
431
+ }
432
+ const peerBudget = new Map()
433
+ const budgetWindowMs = 10 * 60 * 1000
434
+ const maxWindowTurns = 30
435
+ async function withGateway(fn) {
436
+ return withOpenClawGatewayClient({
437
+ command,
438
+ cwd,
439
+ configPath,
440
+ stateDir,
441
+ gatewayUrl,
442
+ gatewayToken,
443
+ gatewayPassword,
444
+ requestTimeoutMs: timeoutMs
445
+ }, fn)
446
+ }
447
+
448
+ async function listSessions(client) {
449
+ return normalizeSessionList(await client.request('sessions.list', {}, timeoutMs))
450
+ }
451
+
452
+ async function preflight() {
453
+ return withGateway(async (client, gatewayContext) => {
454
+ const health = await client.request('health', {}, Math.min(timeoutMs, 15000))
455
+ return {
456
+ ok: Boolean(health?.ok),
457
+ gatewayUrl: gatewayContext.gatewayUrl,
458
+ authMode: gatewayContext.authMode,
459
+ health
460
+ }
461
+ })
462
+ }
463
+
464
+ async function resolveOwnerRoute(client) {
465
+ return resolveOwnerRouteFromSessions(await listSessions(client), {
466
+ agentName
467
+ })
468
+ }
469
+
470
+ async function readRelationshipSummary(client, sessionKey) {
471
+ if (!clean(sessionKey)) {
472
+ return ''
473
+ }
474
+ try {
475
+ const history = await client.request('chat.history', {
476
+ sessionKey,
477
+ limit: 12
478
+ }, timeoutMs)
479
+ return latestAssistantText(history)
480
+ } catch {
481
+ return ''
482
+ }
483
+ }
484
+
485
+ async function persistRelationshipSummary(client, {
486
+ relationSessionKey,
487
+ remoteAgentId,
488
+ selectedSkill,
489
+ transcript,
490
+ ownerSummary
491
+ } = {}) {
492
+ if (!clean(relationSessionKey) || !clean(ownerSummary)) {
493
+ return null
494
+ }
495
+ const prompt = [
496
+ `You are maintaining long-term AgentSquared relationship memory for local agent ${clean(localAgentId)} about remote agent ${clean(remoteAgentId)}.`,
497
+ `Skill context: ${clean(selectedSkill) || 'friend-im'}`,
498
+ '',
499
+ 'Store only a concise long-term summary for future conversations.',
500
+ 'Do not preserve raw turn-by-turn detail unless it matters long-term.',
501
+ 'Prefer stable facts, collaboration preferences, trust signals, and useful future follow-up notes.',
502
+ '',
503
+ 'Latest completed live conversation summary:',
504
+ clean(ownerSummary),
505
+ '',
506
+ 'Transcript excerpt from the just-finished live conversation:',
507
+ clean(transcript) || '(none)',
508
+ '',
509
+ 'Return one short memory summary.'
510
+ ].join('\n')
511
+ const accepted = await client.request('agent', {
512
+ agentId: agentName,
513
+ sessionKey: relationSessionKey,
514
+ message: prompt,
515
+ idempotencyKey: stableId('agentsquared-relationship-memory', localAgentId, remoteAgentId, ownerSummary)
516
+ }, timeoutMs)
517
+ const runId = readOpenClawRunId(accepted)
518
+ if (!runId) {
519
+ return null
520
+ }
521
+ await client.request('agent.wait', {
522
+ runId,
523
+ timeoutMs
524
+ }, timeoutMs + 1000)
525
+ return runId
526
+ }
527
+
528
+ function consumePeerBudget({
529
+ remoteAgentId = ''
530
+ } = {}) {
531
+ const key = clean(remoteAgentId).toLowerCase() || 'unknown'
532
+ const currentTime = nowMs()
533
+ const existing = peerBudget.get(key)
534
+ const recentEvents = (existing?.events ?? []).filter((event) => currentTime - event.at <= budgetWindowMs)
535
+ const nextCount = recentEvents.length + 1
536
+ recentEvents.push({ at: currentTime })
537
+ peerBudget.set(key, { events: recentEvents })
538
+ return {
539
+ windowTurns: nextCount,
540
+ overBudget: nextCount > maxWindowTurns
541
+ }
542
+ }
543
+
544
+ async function executeInbound({
545
+ item,
546
+ selectedSkill,
547
+ mailboxKey
548
+ }) {
549
+ const remoteAgentId = clean(item?.remoteAgentId)
550
+ const incomingSkillHint = clean(item?.suggestedSkill || item?.request?.params?.metadata?.skillHint)
551
+ const receivedAt = new Date().toISOString()
552
+ const inboundText = peerResponseText(item?.request?.params?.message)
553
+ const inboundMetadata = item?.request?.params?.metadata ?? {}
554
+ const parsedEnvelope = parseAgentSquaredOutboundEnvelope(inboundText)
555
+ const displayInboundText = clean(inboundMetadata.originalOwnerText) || clean(parsedEnvelope?.ownerRequest) || inboundText
556
+ const remoteSentAt = clean(inboundMetadata.sentAt) || clean(parsedEnvelope?.sentAt)
557
+ const ownerLanguage = inferOwnerFacingLanguage(displayInboundText, inboundText)
558
+ const ownerTimeZone = localOwnerTimeZone()
559
+ const conversationIdentity = resolveInboundConversationIdentity(item)
560
+ const conversationKey = clean(conversationIdentity.conversationKey)
561
+ return withGateway(async (client, gatewayContext) => {
562
+ const safetySessionKey = normalizeOpenClawSafetySessionKey(localAgentId, remoteAgentId || mailboxKey || 'unknown')
563
+ const safetyPrompt = buildOpenClawSafetyPrompt({
564
+ localAgentId,
565
+ remoteAgentId,
566
+ selectedSkill,
567
+ item
568
+ })
569
+ let safetyAccepted
570
+ try {
571
+ safetyAccepted = await client.request('agent', {
572
+ agentId: agentName,
573
+ sessionKey: safetySessionKey,
574
+ message: safetyPrompt,
575
+ idempotencyKey: `agentsquared-safety-${clean(item?.inboundId) || randomId('inbound')}`
576
+ }, timeoutMs)
577
+ } catch (error) {
578
+ throw reframeOpenClawAgentError(error, {
579
+ openclawAgent: agentName,
580
+ localAgentId
581
+ })
582
+ }
583
+ const safetyRunId = readOpenClawRunId(safetyAccepted)
584
+ if (!safetyRunId) {
585
+ throw new Error('OpenClaw safety triage did not return a runId.')
586
+ }
587
+ const safetyWaited = await client.request('agent.wait', {
588
+ runId: safetyRunId,
589
+ timeoutMs
590
+ }, timeoutMs + 1000)
591
+ const safetyStatus = readOpenClawStatus(safetyWaited).toLowerCase()
592
+ if (safetyStatus && safetyStatus !== 'ok' && safetyStatus !== 'completed' && safetyStatus !== 'done') {
593
+ throw new Error(`OpenClaw safety triage returned ${safetyStatus || 'an unknown status'} for run ${safetyRunId}.`)
594
+ }
595
+ const safetyHistory = await client.request('chat.history', {
596
+ sessionKey: safetySessionKey,
597
+ limit: 8
598
+ }, timeoutMs)
599
+ const safetyText = latestAssistantText(safetyWaited, { runId: safetyRunId }) || latestAssistantText(safetyHistory, { runId: safetyRunId })
600
+ if (!safetyText) {
601
+ throw new Error(`OpenClaw safety triage did not produce a final assistant message for session ${safetySessionKey}.`)
602
+ }
603
+ const safety = parseOpenClawSafetyResult(safetyText)
604
+ const budget = consumePeerBudget({
605
+ remoteAgentId
606
+ })
607
+ if (budget.overBudget) {
608
+ const peerReplyText = 'I am pausing this AgentSquared request because this peer has reached the recent conversation window limit. My owner can decide whether to continue later.'
609
+ const conversation = normalizeConversationControl(item?.request?.params?.metadata ?? {}, {
610
+ defaultTurnIndex: 1,
611
+ defaultDecision: 'handoff',
612
+ defaultStopReason: 'receiver-budget-limit',
613
+ defaultFinalize: true
614
+ })
615
+ const updatedConversation = conversationStore?.appendTurn?.({
616
+ conversationKey,
617
+ peerSessionId: item?.peerSessionId || '',
618
+ requestId: clean(item?.request?.id),
619
+ remoteAgentId,
620
+ selectedSkill,
621
+ turnIndex: conversation.turnIndex,
622
+ inboundText: displayInboundText,
623
+ replyText: peerReplyText,
624
+ decision: 'handoff',
625
+ stopReason: 'receiver-budget-limit',
626
+ finalize: true,
627
+ ownerSummary: `I paused this exchange because the recent peer conversation window was exceeded. Current 10-minute turn count: ${budget.windowTurns}.`
628
+ }) ?? null
629
+ const ownerReport = buildReceiverBaseReport({
630
+ localAgentId,
631
+ remoteAgentId,
632
+ incomingSkillHint,
633
+ selectedSkill,
634
+ receivedAt,
635
+ inboundText: displayInboundText,
636
+ peerReplyText,
637
+ repliedAt: new Date().toISOString(),
638
+ skillSummary: `I paused this exchange because the recent peer conversation window was exceeded. Current 10-minute turn count: ${budget.windowTurns}.`,
639
+ conversationTurns: updatedConversation?.turns?.length || conversation.turnIndex,
640
+ stopReason: 'receiver-budget-limit',
641
+ detailsAvailableInInbox: true,
642
+ remoteSentAt,
643
+ language: ownerLanguage,
644
+ timeZone: ownerTimeZone,
645
+ localTime: true
646
+ })
647
+ return {
648
+ selectedSkill,
649
+ peerResponse: {
650
+ message: {
651
+ kind: 'message',
652
+ role: 'agent',
653
+ parts: [{ kind: 'text', text: peerReplyText }]
654
+ },
655
+ metadata: {
656
+ selectedSkill,
657
+ runtimeAdapter: 'openclaw',
658
+ conversationKey,
659
+ safetyDecision: 'owner-approval',
660
+ safetyReason: 'peer-conversation-window-exceeded',
661
+ windowTurns: budget.windowTurns,
662
+ turnIndex: conversation.turnIndex,
663
+ decision: 'handoff',
664
+ stopReason: 'receiver-budget-limit',
665
+ finalize: true
666
+ }
667
+ },
668
+ ownerReport: {
669
+ ...ownerReport,
670
+ selectedSkill,
671
+ runtimeAdapter: 'openclaw',
672
+ conversationKey,
673
+ safetyDecision: 'owner-approval',
674
+ safetyReason: 'peer-conversation-window-exceeded',
675
+ windowTurns: budget.windowTurns,
676
+ turnIndex: conversation.turnIndex,
677
+ decision: 'handoff',
678
+ stopReason: 'receiver-budget-limit',
679
+ finalize: true
680
+ }
681
+ }
682
+ }
683
+ if (safety.action !== 'allow') {
684
+ const safetyStopReason = 'safety-block'
685
+ const peerReplyText = scrubOutboundText(clean(safety.peerResponse))
686
+ const conversation = normalizeConversationControl(item?.request?.params?.metadata ?? {}, {
687
+ defaultTurnIndex: 1,
688
+ defaultDecision: safety.action === 'owner-approval' ? 'handoff' : 'done',
689
+ defaultStopReason: safetyStopReason,
690
+ defaultFinalize: true
691
+ })
692
+ const updatedConversation = conversationStore?.appendTurn?.({
693
+ conversationKey,
694
+ peerSessionId: item?.peerSessionId || '',
695
+ requestId: clean(item?.request?.id),
696
+ remoteAgentId,
697
+ selectedSkill,
698
+ turnIndex: conversation.turnIndex,
699
+ inboundText: displayInboundText,
700
+ replyText: peerReplyText,
701
+ decision: conversation.decision,
702
+ stopReason: safetyStopReason,
703
+ finalize: true,
704
+ ownerSummary: clean(safety.ownerSummary)
705
+ }) ?? null
706
+ const ownerReport = buildReceiverBaseReport({
707
+ localAgentId,
708
+ remoteAgentId,
709
+ incomingSkillHint,
710
+ selectedSkill,
711
+ receivedAt,
712
+ inboundText: displayInboundText,
713
+ peerReplyText,
714
+ repliedAt: new Date().toISOString(),
715
+ skillSummary: clean(safety.ownerSummary),
716
+ conversationTurns: updatedConversation?.turns?.length || conversation.turnIndex,
717
+ stopReason: safetyStopReason,
718
+ detailsAvailableInInbox: true,
719
+ remoteSentAt,
720
+ language: ownerLanguage,
721
+ timeZone: ownerTimeZone,
722
+ localTime: true
723
+ })
724
+ return {
725
+ selectedSkill,
726
+ peerResponse: {
727
+ message: {
728
+ kind: 'message',
729
+ role: 'agent',
730
+ parts: [{ kind: 'text', text: peerReplyText }]
731
+ },
732
+ metadata: {
733
+ selectedSkill,
734
+ runtimeAdapter: 'openclaw',
735
+ conversationKey,
736
+ safetyDecision: safety.action,
737
+ safetyReason: clean(safety.reason),
738
+ turnIndex: conversation.turnIndex,
739
+ decision: conversation.decision,
740
+ stopReason: safetyStopReason,
741
+ finalize: true
742
+ }
743
+ },
744
+ ownerReport: {
745
+ ...ownerReport,
746
+ selectedSkill,
747
+ runtimeAdapter: 'openclaw',
748
+ conversationKey,
749
+ safetyDecision: safety.action,
750
+ safetyReason: clean(safety.reason),
751
+ turnIndex: conversation.turnIndex,
752
+ decision: conversation.decision,
753
+ stopReason: safetyStopReason,
754
+ finalize: true
755
+ }
756
+ }
757
+ }
758
+
759
+ const relationSessionKey = normalizeOpenClawSessionKey(localAgentId, remoteAgentId || mailboxKey || 'unknown', sessionPrefix)
760
+ const inboundConversation = normalizeConversationControl(item?.request?.params?.metadata ?? {}, {
761
+ defaultTurnIndex: 1,
762
+ defaultDecision: 'done',
763
+ defaultStopReason: '',
764
+ defaultFinalize: false
765
+ })
766
+ const metadata = item?.request?.params?.metadata ?? {}
767
+ if (inboundConversation.turnIndex === 1) {
768
+ conversationStore?.endConversation?.(conversationKey)
769
+ }
770
+ const liveConversation = conversationStore?.ensureConversation?.({
771
+ conversationKey,
772
+ peerSessionId: item?.peerSessionId || '',
773
+ remoteAgentId,
774
+ selectedSkill
775
+ }) ?? null
776
+ const conversationTranscript = conversationStore?.transcript?.(liveConversation?.conversationKey || conversationKey) ?? ''
777
+ const relationshipSummary = await readRelationshipSummary(client, relationSessionKey)
778
+ const localSkillMaxTurns = resolveSkillMaxTurns(selectedSkill, metadata?.sharedSkill ?? null)
779
+ const defaultShouldContinue = selectedSkill === 'agent-mutual-learning'
780
+ && !inboundConversation.finalize
781
+ && inboundConversation.turnIndex < localSkillMaxTurns
782
+ const sessionKey = stableId(
783
+ 'agentsquared-work',
784
+ localAgentId,
785
+ remoteAgentId,
786
+ conversationKey,
787
+ item?.request?.params?.metadata?.turnIndex || '1',
788
+ item?.inboundId
789
+ )
790
+ const prompt = buildOpenClawTaskPrompt({
791
+ localAgentId,
792
+ remoteAgentId,
793
+ selectedSkill,
794
+ item,
795
+ conversationTranscript,
796
+ relationshipSummary,
797
+ senderSkillInventory: clean(metadata?.localSkillInventory)
798
+ })
799
+
800
+ let accepted
801
+ try {
802
+ accepted = await client.request('agent', {
803
+ agentId: agentName,
804
+ sessionKey,
805
+ message: prompt,
806
+ idempotencyKey: `agentsquared-agent-${clean(item?.inboundId) || randomId('inbound')}`
807
+ }, timeoutMs)
808
+ } catch (error) {
809
+ throw reframeOpenClawAgentError(error, {
810
+ openclawAgent: agentName,
811
+ localAgentId
812
+ })
813
+ }
814
+ const runId = readOpenClawRunId(accepted)
815
+ if (!runId) {
816
+ throw new Error('OpenClaw agent call did not return a runId.')
817
+ }
818
+
819
+ const waited = await client.request('agent.wait', {
820
+ runId,
821
+ timeoutMs
822
+ }, timeoutMs + 1000)
823
+ const status = readOpenClawStatus(waited).toLowerCase()
824
+ if (status && status !== 'ok' && status !== 'completed' && status !== 'done') {
825
+ throw new Error(`OpenClaw agent.wait returned ${status || 'an unknown status'} for run ${runId}.`)
826
+ }
827
+
828
+ const history = await client.request('chat.history', {
829
+ sessionKey,
830
+ limit: 12
831
+ }, timeoutMs)
832
+ const resultText = resolveFinalAssistantResultText({
833
+ waited,
834
+ history,
835
+ runId,
836
+ label: 'OpenClaw inbound task',
837
+ sessionKey
838
+ })
839
+
840
+ const parsed = parseOpenClawTaskResult(resultText, {
841
+ defaultSkill: selectedSkill,
842
+ remoteAgentId,
843
+ inboundId: clean(item?.inboundId),
844
+ defaultTurnIndex: inboundConversation.turnIndex,
845
+ defaultDecision: defaultShouldContinue ? 'continue' : 'done',
846
+ defaultStopReason: inboundConversation.finalize ? 'peer-requested-stop' : '',
847
+ defaultFinalize: inboundConversation.finalize ? true : !defaultShouldContinue
848
+ })
849
+ const conversation = normalizeConversationControl(parsed?.peerResponse?.metadata ?? item?.request?.params?.metadata ?? {}, {
850
+ defaultTurnIndex: 1,
851
+ defaultDecision: 'done',
852
+ defaultStopReason: '',
853
+ defaultFinalize: true
854
+ })
855
+ const safePeerReplyText = scrubOutboundText(peerResponseText(parsed.peerResponse))
856
+ const safeOwnerSummary = scrubOutboundText(clean(parsed.ownerReport?.summary))
857
+ const updatedConversation = conversationStore?.appendTurn?.({
858
+ conversationKey,
859
+ peerSessionId: item?.peerSessionId || '',
860
+ requestId: clean(item?.request?.id),
861
+ remoteAgentId,
862
+ selectedSkill: parsed.selectedSkill,
863
+ turnIndex: conversation.turnIndex,
864
+ inboundText: displayInboundText,
865
+ replyText: safePeerReplyText,
866
+ decision: conversation.decision,
867
+ stopReason: conversation.stopReason,
868
+ finalize: conversation.finalize,
869
+ ownerSummary: safeOwnerSummary
870
+ }) ?? null
871
+ const turnOutline = buildReceiverTurnOutline(updatedConversation?.turns ?? [], conversation.turnIndex)
872
+ const effectiveConversationTurns = Math.max(
873
+ updatedConversation?.turns?.length || 0,
874
+ conversation.turnIndex,
875
+ maxTurnIndexFromOutline(turnOutline)
876
+ ) || 1
877
+ const ownerReport = buildReceiverBaseReport({
878
+ localAgentId,
879
+ remoteAgentId,
880
+ incomingSkillHint,
881
+ selectedSkill: parsed.selectedSkill,
882
+ conversationKey,
883
+ receivedAt,
884
+ inboundText: displayInboundText,
885
+ peerReplyText: safePeerReplyText,
886
+ repliedAt: new Date().toISOString(),
887
+ skillSummary: safeOwnerSummary,
888
+ conversationTurns: effectiveConversationTurns,
889
+ stopReason: conversation.stopReason,
890
+ turnOutline,
891
+ detailsAvailableInInbox: true,
892
+ remoteSentAt,
893
+ language: inferOwnerFacingLanguage(displayInboundText, safePeerReplyText, safeOwnerSummary),
894
+ timeZone: ownerTimeZone,
895
+ localTime: true
896
+ })
897
+ let relationshipMemoryRunId = ''
898
+ if (conversation.finalize) {
899
+ await persistRelationshipSummary(client, {
900
+ relationSessionKey,
901
+ remoteAgentId,
902
+ selectedSkill: parsed.selectedSkill,
903
+ transcript: updatedConversation?.turns?.map((turn) => [
904
+ `Turn ${turn.turnIndex}:`,
905
+ `Remote: ${turn.inboundText || '(empty)'}`,
906
+ `Reply: ${turn.replyText || '(empty)'}`,
907
+ `Decision: ${turn.decision || 'done'}`,
908
+ turn.stopReason ? `Stop Reason: ${turn.stopReason}` : ''
909
+ ].filter(Boolean).join('\n')).join('\n\n') || conversationTranscript,
910
+ ownerSummary: safeOwnerSummary
911
+ }).then((runId) => {
912
+ relationshipMemoryRunId = clean(runId)
913
+ })
914
+ conversationStore?.finalizeConversation?.(updatedConversation?.conversationKey || liveConversation?.conversationKey || conversationKey, safeOwnerSummary)
915
+ }
916
+ return {
917
+ ...parsed,
918
+ peerResponse: {
919
+ ...parsed.peerResponse,
920
+ message: {
921
+ kind: parsed.peerResponse?.message?.kind ?? 'message',
922
+ role: parsed.peerResponse?.message?.role ?? 'agent',
923
+ parts: [{ kind: 'text', text: safePeerReplyText }]
924
+ },
925
+ metadata: {
926
+ ...(parsed.peerResponse?.metadata ?? {}),
927
+ incomingSkillHint,
928
+ conversationKey,
929
+ openclawRunId: runId,
930
+ openclawSessionKey: sessionKey,
931
+ openclawRelationSessionKey: relationSessionKey,
932
+ openclawGatewayUrl: gatewayContext.gatewayUrl,
933
+ turnIndex: conversation.turnIndex,
934
+ decision: conversation.decision,
935
+ stopReason: conversation.stopReason,
936
+ finalize: conversation.finalize
937
+ }
938
+ },
939
+ ownerReport: {
940
+ ...ownerReport,
941
+ incomingSkillHint,
942
+ selectedSkill: parsed.selectedSkill,
943
+ conversationKey,
944
+ runtimeAdapter: 'openclaw',
945
+ openclawRunId: runId,
946
+ openclawSessionKey: sessionKey,
947
+ openclawRelationSessionKey: relationSessionKey,
948
+ relationshipMemoryRunId,
949
+ openclawGatewayUrl: gatewayContext.gatewayUrl,
950
+ turnIndex: conversation.turnIndex,
951
+ decision: conversation.decision,
952
+ stopReason: conversation.stopReason,
953
+ finalize: conversation.finalize
954
+ }
955
+ }
956
+ })
957
+ }
958
+
959
+ async function pushOwnerReport({
960
+ item,
961
+ selectedSkill,
962
+ ownerReport
963
+ }) {
964
+ const summary = scrubOutboundText(ownerReportText(ownerReport))
965
+ if (!summary) {
966
+ return { delivered: false, attempted: false, mode: 'openclaw', reason: 'empty-owner-report' }
967
+ }
968
+
969
+ return withGateway(async (client) => {
970
+ const ownerRoute = await resolveOwnerRoute(client)
971
+ if (!ownerRoute?.channel || !ownerRoute?.to) {
972
+ return { delivered: false, attempted: true, mode: 'openclaw', reason: 'owner-route-not-found' }
973
+ }
974
+ const conversationKey = clean(ownerReport?.conversationKey)
975
+ const reportTurnIndex = clean(ownerReport?.turnIndex)
976
+ const isFinalReport = Boolean(ownerReport?.finalize)
977
+ const idempotencyKey = stableId(
978
+ 'agentsquared-owner',
979
+ isFinalReport && conversationKey
980
+ ? `final:${conversationKey}`
981
+ : conversationKey
982
+ ? `${conversationKey}:${reportTurnIndex || clean(item?.request?.params?.metadata?.turnIndex) || clean(item?.inboundId)}`
983
+ : clean(ownerReport?.openclawRunId) || clean(item?.inboundId) || clean(selectedSkill),
984
+ clean(ownerRoute.sessionKey),
985
+ clean(ownerRoute.channel),
986
+ clean(ownerRoute.to)
987
+ )
988
+ const payload = await client.request('send', {
989
+ to: clean(ownerRoute.to),
990
+ channel: clean(ownerRoute.channel),
991
+ ...(clean(ownerRoute.accountId) ? { accountId: clean(ownerRoute.accountId) } : {}),
992
+ ...(clean(ownerRoute.threadId) ? { threadId: clean(ownerRoute.threadId) } : {}),
993
+ ...(clean(ownerRoute.sessionKey) ? { sessionKey: clean(ownerRoute.sessionKey) } : {}),
994
+ message: summary,
995
+ idempotencyKey
996
+ }, timeoutMs)
997
+ return {
998
+ delivered: true,
999
+ attempted: true,
1000
+ mode: 'openclaw',
1001
+ payload,
1002
+ ownerRoute,
1003
+ idempotencyKey
1004
+ }
1005
+ })
1006
+ }
1007
+
1008
+ return {
1009
+ id: 'openclaw',
1010
+ mode: 'openclaw',
1011
+ transport: 'gateway-ws',
1012
+ command: clean(command) || 'openclaw',
1013
+ agent: agentName,
1014
+ sessionPrefix: clean(sessionPrefix) || 'agentsquared:',
1015
+ gatewayUrl: clean(gatewayUrl),
1016
+ preflight,
1017
+ executeInbound,
1018
+ pushOwnerReport
1019
+ }
1020
+ }