@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,175 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { defaultGatewayStateFile as defaultGatewayStateFileFromLayout } from '../shared/paths.mjs'
6
+
7
+ const __filename = fileURLToPath(import.meta.url)
8
+ const __dirname = path.dirname(__filename)
9
+ const RUNTIME_GATEWAY_ROOT = path.resolve(__dirname, '../..')
10
+ const REVISION_FILE_PATHS = [
11
+ path.join(RUNTIME_GATEWAY_ROOT, 'a2_cli.mjs'),
12
+ path.join(RUNTIME_GATEWAY_ROOT, 'package.json'),
13
+ path.join(RUNTIME_GATEWAY_ROOT, 'package-lock.json')
14
+ ]
15
+ const REVISION_DIR_PATHS = [
16
+ path.join(RUNTIME_GATEWAY_ROOT, 'lib'),
17
+ path.join(RUNTIME_GATEWAY_ROOT, 'adapters'),
18
+ path.join(RUNTIME_GATEWAY_ROOT, 'bin')
19
+ ]
20
+
21
+ export function defaultGatewayStateFile(keyFile, agentId) {
22
+ return defaultGatewayStateFileFromLayout(keyFile, agentId)
23
+ }
24
+
25
+ export function readGatewayState(gatewayStateFile) {
26
+ const cleaned = path.resolve(gatewayStateFile)
27
+ if (!fs.existsSync(cleaned)) {
28
+ return null
29
+ }
30
+ return JSON.parse(fs.readFileSync(cleaned, 'utf8'))
31
+ }
32
+
33
+ export function writeGatewayState(gatewayStateFile, payload) {
34
+ const cleaned = path.resolve(gatewayStateFile)
35
+ fs.mkdirSync(path.dirname(cleaned), { recursive: true })
36
+ fs.writeFileSync(cleaned, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
37
+ fs.chmodSync(cleaned, 0o600)
38
+ }
39
+
40
+ function walkFiles(dirPath, out) {
41
+ if (!fs.existsSync(dirPath)) {
42
+ return
43
+ }
44
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
45
+ const entryPath = path.join(dirPath, entry.name)
46
+ if (entry.isDirectory()) {
47
+ walkFiles(entryPath, out)
48
+ continue
49
+ }
50
+ if (!entry.isFile()) {
51
+ continue
52
+ }
53
+ if (!['.mjs', '.json'].includes(path.extname(entry.name))) {
54
+ continue
55
+ }
56
+ out.push(entryPath)
57
+ }
58
+ }
59
+
60
+ function walkGatewayStateFiles(dirPath, out, depth = 0, maxDepth = 4) {
61
+ if (!fs.existsSync(dirPath) || depth > maxDepth) {
62
+ return
63
+ }
64
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
65
+ const entryPath = path.join(dirPath, entry.name)
66
+ if (entry.isDirectory()) {
67
+ if (entry.name === 'node_modules' || entry.name === '.git') {
68
+ continue
69
+ }
70
+ walkGatewayStateFiles(entryPath, out, depth + 1, maxDepth)
71
+ continue
72
+ }
73
+ if (!entry.isFile()) {
74
+ continue
75
+ }
76
+ if (entry.name !== 'gateway.json') {
77
+ continue
78
+ }
79
+ out.push(entryPath)
80
+ }
81
+ }
82
+
83
+ export function currentRuntimeRevision() {
84
+ const hash = crypto.createHash('sha256')
85
+ const files = []
86
+ for (const filePath of REVISION_FILE_PATHS) {
87
+ if (fs.existsSync(filePath)) {
88
+ files.push(filePath)
89
+ }
90
+ }
91
+ for (const dirPath of REVISION_DIR_PATHS) {
92
+ walkFiles(dirPath, files)
93
+ }
94
+ files.sort()
95
+ for (const filePath of files) {
96
+ hash.update(path.relative(RUNTIME_GATEWAY_ROOT, filePath))
97
+ hash.update('\n')
98
+ hash.update(fs.readFileSync(filePath))
99
+ hash.update('\n')
100
+ }
101
+ return hash.digest('hex').slice(0, 16)
102
+ }
103
+
104
+ function buildInitRequiredMessage({ stateFile, currentRevision, expectedRevision, reason }) {
105
+ const parts = [
106
+ 'The local AgentSquared gateway must be re-initialized before reuse.',
107
+ reason,
108
+ `gatewayStateFile=${stateFile}`,
109
+ `expectedRuntimeRevision=${expectedRevision}`,
110
+ `stateRuntimeRevision=${currentRevision || 'missing'}`,
111
+ 'Restart the shared gateway from the current @agentsquared/cli checkout with `a2-cli gateway --agent-id <fullName> --key-file <runtime-key-file>` and then retry the current task.'
112
+ ]
113
+ return parts.join(' ')
114
+ }
115
+
116
+ export function assertGatewayStateFresh(state, stateFile) {
117
+ const expectedRevision = currentRuntimeRevision()
118
+ const currentRevision = `${state?.runtimeRevision ?? ''}`.trim()
119
+ if (!state) {
120
+ return { expectedRevision, currentRevision: '', stale: false }
121
+ }
122
+ if (!currentRevision) {
123
+ throw new Error(buildInitRequiredMessage({
124
+ stateFile,
125
+ currentRevision,
126
+ expectedRevision,
127
+ reason: 'The discovered gateway state was written by an older runtime that does not record runtimeRevision metadata.'
128
+ }))
129
+ }
130
+ if (currentRevision !== expectedRevision) {
131
+ throw new Error(buildInitRequiredMessage({
132
+ stateFile,
133
+ currentRevision,
134
+ expectedRevision,
135
+ reason: 'The discovered gateway was started from older shared runtime code than the current @agentsquared/cli checkout.'
136
+ }))
137
+ }
138
+ return { expectedRevision, currentRevision, stale: false }
139
+ }
140
+
141
+ export function resolveGatewayBase({ gatewayBase = '', keyFile = '', agentId = '', gatewayStateFile = '' } = {}) {
142
+ const explicit = `${gatewayBase}`.trim()
143
+ if (explicit) {
144
+ return explicit
145
+ }
146
+ const envValue = `${process.env.AGENTSQUARED_GATEWAY_BASE ?? ''}`.trim()
147
+ if (envValue) {
148
+ return envValue
149
+ }
150
+ const stateFile = `${gatewayStateFile}`.trim() || (keyFile && agentId ? defaultGatewayStateFile(keyFile, agentId) : '')
151
+ if (!stateFile) {
152
+ throw new Error('gatewayBase was not provided. Pass --gateway-base or provide --agent-id and --key-file so the local gateway state file can be discovered.')
153
+ }
154
+ const state = readGatewayState(stateFile)
155
+ assertGatewayStateFresh(state, stateFile)
156
+ const discovered = `${state?.gatewayBase ?? ''}`.trim()
157
+ if (!discovered) {
158
+ throw new Error(`gateway state file does not contain a gatewayBase: ${stateFile}`)
159
+ }
160
+ return discovered
161
+ }
162
+
163
+ export function discoverGatewayStateFiles(searchRoots = []) {
164
+ const files = []
165
+ const seen = new Set()
166
+ for (const root of searchRoots) {
167
+ const resolved = `${root ?? ''}`.trim() ? path.resolve(root) : ''
168
+ if (!resolved || seen.has(resolved)) {
169
+ continue
170
+ }
171
+ seen.add(resolved)
172
+ walkGatewayStateFiles(resolved, files)
173
+ }
174
+ return Array.from(new Set(files)).sort()
175
+ }
@@ -0,0 +1,511 @@
1
+ import { normalizeConversationControl, resolveInboundConversationIdentity } from '../conversation/policy.mjs'
2
+
3
+ function clean(value) {
4
+ return `${value ?? ''}`.trim()
5
+ }
6
+
7
+ function extractConversationMetadata(item = null) {
8
+ const metadata = item?.request?.params?.metadata
9
+ return metadata && typeof metadata === 'object' ? metadata : {}
10
+ }
11
+
12
+ function synthesizeRuntimeUnavailableExecution({
13
+ item,
14
+ selectedSkill,
15
+ defaultSkill,
16
+ mailboxKey,
17
+ reject
18
+ } = {}) {
19
+ const finalSkill = clean(defaultSkill) || clean(selectedSkill) || DEFAULT_ROUTER_DEFAULT_SKILL
20
+ const remoteAgentId = clean(item?.remoteAgentId) || 'the remote agent'
21
+ const metadata = extractConversationMetadata(item)
22
+ const conversation = normalizeConversationControl(metadata, {
23
+ defaultTurnIndex: 1,
24
+ defaultDecision: 'done',
25
+ defaultStopReason: 'receiver-runtime-unavailable',
26
+ defaultFinalize: true
27
+ })
28
+ const conversationKey = clean(metadata.conversationKey)
29
+ const peerReplyText = 'My local AI runtime is temporarily unavailable right now, so I cannot continue this AgentSquared conversation. Please try again later.'
30
+ return {
31
+ peerResponse: {
32
+ message: {
33
+ kind: 'message',
34
+ role: 'agent',
35
+ parts: [{ kind: 'text', text: peerReplyText }]
36
+ },
37
+ metadata: {
38
+ selectedSkill: finalSkill,
39
+ mailboxKey: clean(mailboxKey),
40
+ conversationKey,
41
+ turnIndex: conversation.turnIndex,
42
+ decision: 'done',
43
+ stopReason: 'receiver-runtime-unavailable',
44
+ finalize: true
45
+ }
46
+ },
47
+ ownerReport: {
48
+ title: '**🅰️✌️ AgentSquared local runtime unavailable**',
49
+ summary: `AgentSquared replied to ${remoteAgentId} with a temporary runtime-unavailable message because the local AI runtime failed.`,
50
+ message: [
51
+ '**Runtime status**',
52
+ `- Remote Agent: ${remoteAgentId}`,
53
+ `- Local Skill Used: ${finalSkill}`,
54
+ ...(conversationKey ? [`- Conversation Key: ${conversationKey}`] : []),
55
+ `- Conversation Turns: ${conversation.turnIndex}`,
56
+ '- Stop Reason: receiver-runtime-unavailable',
57
+ '',
58
+ '**What happened**',
59
+ '> The local AI runtime failed while handling this inbound AgentSquared message, so AgentSquared sent a polite temporary-unavailable reply instead of leaving the peer hanging.',
60
+ '',
61
+ '**Error detail**',
62
+ `> ${clean(reject?.message) || 'local runtime execution failed'}`,
63
+ '',
64
+ 'You can retry later after the local runtime is healthy again.'
65
+ ].join('\n'),
66
+ selectedSkill: finalSkill,
67
+ runtimeAdapter: 'fallback',
68
+ conversationKey,
69
+ turnIndex: conversation.turnIndex,
70
+ decision: 'done',
71
+ stopReason: 'receiver-runtime-unavailable',
72
+ finalize: true,
73
+ error: clean(reject?.message)
74
+ }
75
+ }
76
+ }
77
+
78
+ export const DEFAULT_ROUTER_DEFAULT_SKILL = 'friend-im'
79
+ export const DEFAULT_ROUTER_SKILLS = ['friend-im', 'agent-mutual-learning']
80
+
81
+ export function normalizeRouterSkills(skills = DEFAULT_ROUTER_SKILLS) {
82
+ const seen = new Set()
83
+ const out = []
84
+ for (const value of skills) {
85
+ const skill = clean(value)
86
+ if (!skill || seen.has(skill)) {
87
+ continue
88
+ }
89
+ seen.add(skill)
90
+ out.push(skill)
91
+ }
92
+ return out.length > 0 ? out : [...DEFAULT_ROUTER_SKILLS]
93
+ }
94
+
95
+ export function extractInboundText(item) {
96
+ const parts = item?.request?.params?.message?.parts ?? []
97
+ const texts = parts
98
+ .filter((part) => clean(part?.kind) === 'text')
99
+ .map((part) => clean(part?.text))
100
+ .filter(Boolean)
101
+ return texts.join('\n').trim()
102
+ }
103
+
104
+ function buildStartNoticeConversation(item) {
105
+ const metadata = extractConversationMetadata(item)
106
+ return normalizeConversationControl({
107
+ conversationKey: clean(metadata.conversationKey),
108
+ turnIndex: Number.parseInt(`${metadata.turnIndex ?? 1}`, 10) || 1,
109
+ decision: clean(metadata.decision),
110
+ stopReason: clean(metadata.stopReason),
111
+ finalize: Boolean(metadata.finalize)
112
+ }, {
113
+ defaultTurnIndex: 1,
114
+ defaultDecision: 'continue',
115
+ defaultStopReason: '',
116
+ defaultFinalize: false
117
+ })
118
+ }
119
+
120
+ export function resolveMailboxKey(item) {
121
+ return resolveInboundConversationIdentity(item).mailboxKey
122
+ }
123
+
124
+ export function resolveConversationLockKey(item) {
125
+ return resolveInboundConversationIdentity(item).conversationKey
126
+ }
127
+
128
+ export function chooseInboundSkill(item, {
129
+ routerSkills = DEFAULT_ROUTER_SKILLS,
130
+ defaultSkill = DEFAULT_ROUTER_DEFAULT_SKILL
131
+ } = {}) {
132
+ const knownSkills = normalizeRouterSkills(routerSkills)
133
+ const knownSkillSet = new Set(knownSkills)
134
+ const suggestedSkill = clean(item?.suggestedSkill)
135
+ const requestDefaultSkill = clean(item?.defaultSkill)
136
+ const localDefaultSkill = clean(defaultSkill) || DEFAULT_ROUTER_DEFAULT_SKILL
137
+
138
+ const candidates = [
139
+ suggestedSkill,
140
+ requestDefaultSkill,
141
+ localDefaultSkill,
142
+ DEFAULT_ROUTER_DEFAULT_SKILL
143
+ ]
144
+
145
+ for (const candidate of candidates) {
146
+ if (candidate && knownSkillSet.has(candidate)) {
147
+ return candidate
148
+ }
149
+ }
150
+ return ''
151
+ }
152
+
153
+ export function createMailboxScheduler({
154
+ maxActiveMailboxes = 8,
155
+ mailboxKeyForItem = resolveMailboxKey,
156
+ conversationKeyForItem = resolveConversationLockKey,
157
+ conversationLockMs = 5 * 60 * 1000,
158
+ handleItem
159
+ } = {}) {
160
+ if (typeof handleItem !== 'function') {
161
+ throw new Error('handleItem is required')
162
+ }
163
+
164
+ const mailboxes = new Map()
165
+ const idleWaiters = []
166
+ let activeMailboxes = 0
167
+ let activeConversation = null
168
+
169
+ function clearConversationLock() {
170
+ if (activeConversation?.timer) {
171
+ clearTimeout(activeConversation.timer)
172
+ }
173
+ activeConversation = null
174
+ }
175
+
176
+ function refreshConversationLock(conversationKey) {
177
+ const normalizedConversationKey = clean(conversationKey)
178
+ if (!normalizedConversationKey) {
179
+ return
180
+ }
181
+ const expiresAt = Date.now() + conversationLockMs
182
+ if (!activeConversation || activeConversation.conversationKey !== normalizedConversationKey) {
183
+ clearConversationLock()
184
+ activeConversation = {
185
+ conversationKey: normalizedConversationKey,
186
+ expiresAt,
187
+ timer: null
188
+ }
189
+ } else {
190
+ activeConversation.expiresAt = expiresAt
191
+ if (activeConversation.timer) {
192
+ clearTimeout(activeConversation.timer)
193
+ }
194
+ }
195
+ activeConversation.timer = setTimeout(() => {
196
+ if (activeConversation?.conversationKey === normalizedConversationKey && Date.now() >= activeConversation.expiresAt) {
197
+ clearConversationLock()
198
+ pump()
199
+ flushIdleWaitersIfNeeded()
200
+ }
201
+ }, Math.max(1, conversationLockMs))
202
+ }
203
+
204
+ function isConversationLockAvailable(conversationKey) {
205
+ if (!activeConversation) {
206
+ return true
207
+ }
208
+ if (Date.now() >= activeConversation.expiresAt) {
209
+ clearConversationLock()
210
+ return true
211
+ }
212
+ return clean(conversationKey) === clean(activeConversation.conversationKey)
213
+ }
214
+
215
+ function pendingCount() {
216
+ let count = 0
217
+ for (const mailbox of mailboxes.values()) {
218
+ count += mailbox.queue.length
219
+ if (mailbox.running) {
220
+ count += 1
221
+ }
222
+ }
223
+ if (activeConversation) {
224
+ count += 1
225
+ }
226
+ return count
227
+ }
228
+
229
+ function flushIdleWaitersIfNeeded() {
230
+ if (activeMailboxes !== 0 || pendingCount() !== 0) {
231
+ return
232
+ }
233
+ while (idleWaiters.length > 0) {
234
+ const resolve = idleWaiters.shift()
235
+ resolve?.()
236
+ }
237
+ }
238
+
239
+ function runMailbox(mailboxKey, mailbox) {
240
+ const mailboxConversationKey = clean(mailbox?.conversationKey)
241
+ if (
242
+ mailbox.running
243
+ || mailbox.queue.length === 0
244
+ || activeMailboxes >= maxActiveMailboxes
245
+ || !isConversationLockAvailable(mailboxConversationKey)
246
+ ) {
247
+ return
248
+ }
249
+ refreshConversationLock(mailboxConversationKey)
250
+ mailbox.running = true
251
+ activeMailboxes += 1
252
+
253
+ const loop = async () => {
254
+ try {
255
+ while (mailbox.queue.length > 0) {
256
+ const entry = mailbox.queue.shift()
257
+ try {
258
+ const result = await handleItem(entry.item, { mailboxKey, conversationKey: mailboxConversationKey })
259
+ entry.resolve(result)
260
+ refreshConversationLock(mailboxConversationKey)
261
+ const shouldReleaseConversationLock = result?.releaseConversationLock !== false
262
+ if (shouldReleaseConversationLock && activeConversation?.conversationKey === mailboxConversationKey && mailbox.queue.length === 0) {
263
+ clearConversationLock()
264
+ }
265
+ } catch (error) {
266
+ entry.reject(error)
267
+ if (activeConversation?.conversationKey === mailboxConversationKey && mailbox.queue.length === 0) {
268
+ clearConversationLock()
269
+ }
270
+ }
271
+ }
272
+ } finally {
273
+ mailbox.running = false
274
+ activeMailboxes = Math.max(0, activeMailboxes - 1)
275
+ if (mailbox.queue.length === 0) {
276
+ mailboxes.delete(mailboxKey)
277
+ }
278
+ pump()
279
+ flushIdleWaitersIfNeeded()
280
+ }
281
+ }
282
+
283
+ loop().catch(() => {})
284
+ }
285
+
286
+ function pump() {
287
+ for (const [mailboxKey, mailbox] of mailboxes.entries()) {
288
+ if (activeMailboxes >= maxActiveMailboxes) {
289
+ return
290
+ }
291
+ runMailbox(mailboxKey, mailbox)
292
+ }
293
+ }
294
+
295
+ function enqueue(item) {
296
+ let mailboxKey
297
+ let conversationKey
298
+ try {
299
+ mailboxKey = mailboxKeyForItem(item)
300
+ conversationKey = conversationKeyForItem(item)
301
+ } catch (error) {
302
+ return Promise.reject(error)
303
+ }
304
+ if (!mailboxes.has(mailboxKey)) {
305
+ mailboxes.set(mailboxKey, { running: false, queue: [], conversationKey })
306
+ }
307
+ const mailbox = mailboxes.get(mailboxKey)
308
+ mailbox.conversationKey = conversationKey
309
+ return new Promise((resolve, reject) => {
310
+ mailbox.queue.push({ item, resolve, reject })
311
+ pump()
312
+ })
313
+ }
314
+
315
+ function whenIdle() {
316
+ if (activeMailboxes === 0 && pendingCount() === 0) {
317
+ return Promise.resolve()
318
+ }
319
+ return new Promise((resolve) => {
320
+ idleWaiters.push(resolve)
321
+ })
322
+ }
323
+
324
+ return {
325
+ enqueue,
326
+ whenIdle,
327
+ snapshot() {
328
+ return {
329
+ activeMailboxes,
330
+ activeConversationKey: activeConversation?.conversationKey ?? '',
331
+ pendingItems: pendingCount(),
332
+ mailboxes: [...mailboxes.entries()].map(([mailboxKey, mailbox]) => ({
333
+ mailboxKey,
334
+ conversationKey: mailbox.conversationKey,
335
+ running: mailbox.running,
336
+ queued: mailbox.queue.length
337
+ }))
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ export function createAgentRouter({
344
+ maxActiveMailboxes = 8,
345
+ routerSkills = DEFAULT_ROUTER_SKILLS,
346
+ defaultSkill = DEFAULT_ROUTER_DEFAULT_SKILL,
347
+ executeInbound,
348
+ notifyOwner = null,
349
+ onRespond,
350
+ onReject
351
+ } = {}) {
352
+ if (typeof executeInbound !== 'function') {
353
+ throw new Error('executeInbound is required')
354
+ }
355
+ if (typeof onRespond !== 'function') {
356
+ throw new Error('onRespond is required')
357
+ }
358
+ if (typeof onReject !== 'function') {
359
+ throw new Error('onReject is required')
360
+ }
361
+
362
+ const normalizedRouterSkills = normalizeRouterSkills(routerSkills)
363
+ const normalizedDefaultSkill = clean(defaultSkill) || DEFAULT_ROUTER_DEFAULT_SKILL
364
+
365
+ function shouldFallbackToDefaultSkill(reject = null) {
366
+ const code = Number.parseInt(`${reject?.code ?? 0}`, 10) || 0
367
+ if (code >= 500) {
368
+ return true
369
+ }
370
+ return false
371
+ }
372
+
373
+ const scheduler = createMailboxScheduler({
374
+ maxActiveMailboxes,
375
+ async handleItem(item, { mailboxKey, conversationKey }) {
376
+ const startConversation = buildStartNoticeConversation(item)
377
+ const selectedSkill = chooseInboundSkill(item, {
378
+ routerSkills: normalizedRouterSkills,
379
+ defaultSkill: normalizedDefaultSkill
380
+ })
381
+ if (!selectedSkill) {
382
+ await onReject(item, {
383
+ code: 409,
384
+ message: `no supported local skill could handle inbound request for mailbox ${mailboxKey}`
385
+ })
386
+ return { selectedSkill: '', rejected: true, releaseConversationLock: true }
387
+ }
388
+
389
+ let execution
390
+ try {
391
+ execution = await executeInbound({
392
+ item,
393
+ selectedSkill,
394
+ mailboxKey
395
+ })
396
+ } catch (error) {
397
+ execution = {
398
+ reject: {
399
+ code: Number.parseInt(`${error?.code ?? 500}`, 10) || 500,
400
+ message: clean(error?.message) || 'local runtime execution failed'
401
+ }
402
+ }
403
+ }
404
+
405
+ const canFallbackToDefault = selectedSkill !== normalizedDefaultSkill
406
+ && normalizedRouterSkills.includes(normalizedDefaultSkill)
407
+ && execution?.reject
408
+ && shouldFallbackToDefaultSkill(execution.reject)
409
+
410
+ if (canFallbackToDefault) {
411
+ try {
412
+ execution = await executeInbound({
413
+ item: {
414
+ ...item,
415
+ fallbackFromSkill: selectedSkill,
416
+ fallbackToSkill: normalizedDefaultSkill
417
+ },
418
+ selectedSkill: normalizedDefaultSkill,
419
+ mailboxKey
420
+ })
421
+ } catch (error) {
422
+ execution = {
423
+ reject: {
424
+ code: Number.parseInt(`${error?.code ?? 500}`, 10) || 500,
425
+ message: clean(error?.message) || 'local runtime execution failed'
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ if (execution?.reject) {
432
+ if (shouldFallbackToDefaultSkill(execution.reject)) {
433
+ execution = synthesizeRuntimeUnavailableExecution({
434
+ item,
435
+ selectedSkill,
436
+ defaultSkill: normalizedDefaultSkill,
437
+ mailboxKey,
438
+ reject: execution.reject
439
+ })
440
+ }
441
+ }
442
+
443
+ if (execution?.reject) {
444
+ await onReject(item, execution.reject)
445
+ return { selectedSkill, rejected: true, releaseConversationLock: true }
446
+ }
447
+
448
+ if (execution?.ownerReport != null && typeof notifyOwner === 'function') {
449
+ const rawConversation = execution?.peerResponse?.metadata && typeof execution.peerResponse.metadata === 'object'
450
+ ? execution.peerResponse.metadata
451
+ : {}
452
+ const conversation = normalizeConversationControl(rawConversation, {
453
+ defaultTurnIndex: 1,
454
+ defaultDecision: 'done',
455
+ defaultStopReason: 'single-turn',
456
+ defaultFinalize: true
457
+ })
458
+ await notifyOwner({
459
+ item,
460
+ selectedSkill,
461
+ mailboxKey,
462
+ ownerReport: execution.ownerReport,
463
+ peerResponse: execution.peerResponse,
464
+ conversation: {
465
+ ...rawConversation,
466
+ ...conversation
467
+ },
468
+ notifyOwnerNow: Boolean(conversation.finalize || conversation.decision === 'done' || conversation.decision === 'handoff')
469
+ })
470
+ }
471
+
472
+ await onRespond(item, execution.peerResponse)
473
+ const rawConversation = execution?.peerResponse?.metadata && typeof execution.peerResponse.metadata === 'object'
474
+ ? execution.peerResponse.metadata
475
+ : {}
476
+ const conversation = normalizeConversationControl(rawConversation, {
477
+ defaultTurnIndex: 1,
478
+ defaultDecision: 'done',
479
+ defaultStopReason: 'single-turn',
480
+ defaultFinalize: true
481
+ })
482
+ return {
483
+ selectedSkill: canFallbackToDefault ? normalizedDefaultSkill : selectedSkill,
484
+ rejected: false,
485
+ ownerReportDelivered: execution?.ownerReport != null && typeof notifyOwner === 'function',
486
+ releaseConversationLock: Boolean(conversation.finalize || conversation.decision === 'done' || conversation.decision === 'handoff'),
487
+ conversationKey: clean(rawConversation.conversationKey) || clean(conversationKey)
488
+ }
489
+ }
490
+ })
491
+
492
+ return {
493
+ routerSkills: normalizedRouterSkills,
494
+ defaultSkill: normalizedDefaultSkill,
495
+ enqueue(item) {
496
+ return scheduler.enqueue(item)
497
+ },
498
+ whenIdle() {
499
+ return scheduler.whenIdle()
500
+ },
501
+ snapshot() {
502
+ return {
503
+ routerSkills: normalizedRouterSkills,
504
+ defaultSkill: normalizedDefaultSkill,
505
+ executorMode: `${executeInbound?.mode ?? 'custom'}`.trim() || 'custom',
506
+ ownerNotifyMode: `${notifyOwner?.mode ?? 'custom'}`.trim() || 'custom',
507
+ scheduler: scheduler.snapshot()
508
+ }
509
+ }
510
+ }
511
+ }