@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,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
|
+
}
|