@swarmclawai/swarmclaw 0.9.4 → 0.9.5
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/README.md +7 -7
- package/package.json +1 -1
- package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
- package/src/lib/server/agents/subagent-runtime.ts +115 -19
- package/src/lib/server/agents/subagent-swarm.ts +3 -3
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
- package/src/lib/server/chat-execution/chat-execution.ts +357 -152
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +9 -0
- package/src/lib/server/chat-execution/stream-agent-chat.ts +171 -37
- package/src/lib/server/connectors/contact-boundaries.ts +70 -8
- package/src/lib/server/connectors/manager.test.ts +129 -7
- package/src/lib/server/plugins.test.ts +263 -0
- package/src/lib/server/plugins.ts +406 -10
- package/src/lib/server/session-tools/context.ts +15 -1
- package/src/lib/server/session-tools/index.ts +42 -6
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
- package/src/lib/server/session-tools/subagent.ts +3 -3
- package/src/lib/server/tool-loop-detection.test.ts +21 -0
- package/src/lib/server/tool-loop-detection.ts +79 -0
- package/src/types/index.ts +132 -0
package/README.md
CHANGED
|
@@ -729,15 +729,15 @@ On `v*` tags, GitHub Actions will:
|
|
|
729
729
|
2. Create a GitHub Release
|
|
730
730
|
3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
|
|
731
731
|
|
|
732
|
-
#### v0.9.
|
|
732
|
+
#### v0.9.5 Release Readiness Notes
|
|
733
733
|
|
|
734
|
-
Before shipping `v0.9.
|
|
734
|
+
Before shipping `v0.9.5`, confirm the following user-facing changes are reflected in docs:
|
|
735
735
|
|
|
736
|
-
1.
|
|
737
|
-
2.
|
|
738
|
-
3.
|
|
739
|
-
4. Site and README install/version strings are updated to `v0.9.
|
|
740
|
-
5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.
|
|
736
|
+
1. Plugin/runtime docs mention the new typed lifecycle hooks: `beforePromptBuild`, `beforeToolCall`, `beforeModelResolve`, `llmInput`, `llmOutput`, `toolResultPersist`, `beforeMessageWrite`, plus session and subagent lifecycle hooks.
|
|
737
|
+
2. Connector/memory docs explain that quiet-boundary memories are matched by identifiers and agent aliases, with explicit boundary metadata support, rather than relying on hardcoded person-name fallbacks.
|
|
738
|
+
3. Skills docs still explain that local skills are discoverable by default, pinned skills stay always-on, and `use_skill` is the runtime path for selection/loading/dispatch.
|
|
739
|
+
4. Site and README install/version strings are updated to `v0.9.5`, including release notes index text and any pinned install snippets.
|
|
740
|
+
5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.5`) versus the git tag (`v0.9.5`).
|
|
741
741
|
|
|
742
742
|
## CLI
|
|
743
743
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -15,6 +15,10 @@ let runtime: typeof import('@/lib/server/agents/subagent-runtime')
|
|
|
15
15
|
let lineage: typeof import('@/lib/server/agents/subagent-lineage')
|
|
16
16
|
let delegationJobs: typeof import('@/lib/server/agents/delegation-jobs')
|
|
17
17
|
let storage: typeof import('@/lib/server/storage')
|
|
18
|
+
let pluginManager: {
|
|
19
|
+
registerBuiltin: (id: string, plugin: Record<string, unknown>) => void
|
|
20
|
+
}
|
|
21
|
+
let providers: Record<string, unknown>
|
|
18
22
|
|
|
19
23
|
before(async () => {
|
|
20
24
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-subagent-runtime-'))
|
|
@@ -26,6 +30,9 @@ before(async () => {
|
|
|
26
30
|
delegationJobs = await import('@/lib/server/agents/delegation-jobs')
|
|
27
31
|
lineage = await import('@/lib/server/agents/subagent-lineage')
|
|
28
32
|
runtime = await import('@/lib/server/agents/subagent-runtime')
|
|
33
|
+
pluginManager = (await import('@/lib/server/plugins')).getPluginManager()
|
|
34
|
+
const providersMod = await import('@/lib/providers/index')
|
|
35
|
+
providers = providersMod.PROVIDERS
|
|
29
36
|
})
|
|
30
37
|
|
|
31
38
|
after(() => {
|
|
@@ -57,9 +64,9 @@ describe('subagent-runtime', () => {
|
|
|
57
64
|
})
|
|
58
65
|
|
|
59
66
|
describe('spawnSubagent', () => {
|
|
60
|
-
it('throws for unknown agent', () => {
|
|
67
|
+
it('throws for unknown agent', async () => {
|
|
61
68
|
lineage._clearLineage()
|
|
62
|
-
assert.
|
|
69
|
+
await assert.rejects(
|
|
63
70
|
() => runtime.spawnSubagent(
|
|
64
71
|
{ agentId: 'nonexistent', message: 'hello' },
|
|
65
72
|
{ cwd: tempDir },
|
|
@@ -68,7 +75,7 @@ describe('subagent-runtime', () => {
|
|
|
68
75
|
)
|
|
69
76
|
})
|
|
70
77
|
|
|
71
|
-
it('throws when max depth is exceeded', () => {
|
|
78
|
+
it('throws when max depth is exceeded', async () => {
|
|
72
79
|
lineage._clearLineage()
|
|
73
80
|
seedAgent('depth-agent', 'Depth Agent')
|
|
74
81
|
|
|
@@ -80,7 +87,7 @@ describe('subagent-runtime', () => {
|
|
|
80
87
|
sessions['depth-s3'] = { id: 'depth-s3', parentSessionId: 'depth-s2', cwd: tempDir }
|
|
81
88
|
storage.saveSessions(sessions)
|
|
82
89
|
|
|
83
|
-
assert.
|
|
90
|
+
await assert.rejects(
|
|
84
91
|
() => runtime.spawnSubagent(
|
|
85
92
|
{ agentId: 'depth-agent', message: 'too deep' },
|
|
86
93
|
{ sessionId: 'depth-s3', cwd: tempDir },
|
|
@@ -89,13 +96,13 @@ describe('subagent-runtime', () => {
|
|
|
89
96
|
)
|
|
90
97
|
})
|
|
91
98
|
|
|
92
|
-
it('creates session, lineage node, and delegation job', () => {
|
|
99
|
+
it('creates session, lineage node, and delegation job', async () => {
|
|
93
100
|
lineage._clearLineage()
|
|
94
101
|
seedAgent('spawn-agent', 'Spawn Agent')
|
|
95
102
|
|
|
96
|
-
let handle: ReturnType<typeof runtime.spawnSubagent
|
|
103
|
+
let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
|
|
97
104
|
try {
|
|
98
|
-
handle = runtime.spawnSubagent(
|
|
105
|
+
handle = await runtime.spawnSubagent(
|
|
99
106
|
{ agentId: 'spawn-agent', message: 'test task', waitForCompletion: false },
|
|
100
107
|
{ sessionId: undefined, cwd: tempDir },
|
|
101
108
|
)
|
|
@@ -132,7 +139,7 @@ describe('subagent-runtime', () => {
|
|
|
132
139
|
}
|
|
133
140
|
})
|
|
134
141
|
|
|
135
|
-
it('tracks parent-child lineage correctly', () => {
|
|
142
|
+
it('tracks parent-child lineage correctly', async () => {
|
|
136
143
|
lineage._clearLineage()
|
|
137
144
|
seedAgent('parent-agent', 'Parent')
|
|
138
145
|
seedAgent('child-agent', 'Child')
|
|
@@ -155,9 +162,9 @@ describe('subagent-runtime', () => {
|
|
|
155
162
|
task: 'Parent task',
|
|
156
163
|
})
|
|
157
164
|
|
|
158
|
-
let handle: ReturnType<typeof runtime.spawnSubagent
|
|
165
|
+
let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
|
|
159
166
|
try {
|
|
160
|
-
handle = runtime.spawnSubagent(
|
|
167
|
+
handle = await runtime.spawnSubagent(
|
|
161
168
|
{ agentId: 'child-agent', message: 'child task', waitForCompletion: false },
|
|
162
169
|
{ sessionId: 'parent-session', cwd: tempDir },
|
|
163
170
|
)
|
|
@@ -184,6 +191,82 @@ describe('subagent-runtime', () => {
|
|
|
184
191
|
assert.equal(ancestors[0].sessionId, 'parent-session')
|
|
185
192
|
}
|
|
186
193
|
})
|
|
194
|
+
|
|
195
|
+
it('fires subagent lifecycle hooks through the native runtime', async () => {
|
|
196
|
+
lineage._clearLineage()
|
|
197
|
+
seedAgent('parent-hook-agent', 'Parent Hook Agent', ['subagent_lifecycle_test'])
|
|
198
|
+
seedAgent('child-hook-agent', 'Child Hook Agent')
|
|
199
|
+
|
|
200
|
+
const marks: string[] = []
|
|
201
|
+
pluginManager.registerBuiltin('subagent_lifecycle_test', {
|
|
202
|
+
name: 'Subagent Lifecycle Test',
|
|
203
|
+
hooks: {
|
|
204
|
+
subagentSpawning: ({ agentId }) => {
|
|
205
|
+
marks.push(`spawning:${agentId}`)
|
|
206
|
+
return { status: 'ok' }
|
|
207
|
+
},
|
|
208
|
+
subagentSpawned: ({ childSessionId }) => {
|
|
209
|
+
marks.push(`spawned:${childSessionId}`)
|
|
210
|
+
},
|
|
211
|
+
subagentEnded: ({ status }) => {
|
|
212
|
+
marks.push(`ended:${status}`)
|
|
213
|
+
},
|
|
214
|
+
sessionEnd: ({ reason }) => {
|
|
215
|
+
marks.push(`session_end:${reason}`)
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
providers['subagent-test-provider'] = {
|
|
221
|
+
id: 'subagent-test-provider',
|
|
222
|
+
name: 'Subagent Test Provider',
|
|
223
|
+
models: ['unit'],
|
|
224
|
+
requiresApiKey: false,
|
|
225
|
+
requiresEndpoint: false,
|
|
226
|
+
handler: {
|
|
227
|
+
async streamChat() {
|
|
228
|
+
return 'subagent finished'
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const agents = storage.loadAgents()
|
|
234
|
+
agents['child-hook-agent'] = {
|
|
235
|
+
id: 'child-hook-agent',
|
|
236
|
+
name: 'Child Hook Agent',
|
|
237
|
+
provider: 'subagent-test-provider',
|
|
238
|
+
model: 'unit',
|
|
239
|
+
systemPrompt: 'Child runtime test',
|
|
240
|
+
}
|
|
241
|
+
storage.saveAgents(agents)
|
|
242
|
+
|
|
243
|
+
const sessions = storage.loadSessions()
|
|
244
|
+
sessions['hook-parent-session'] = {
|
|
245
|
+
id: 'hook-parent-session',
|
|
246
|
+
cwd: tempDir,
|
|
247
|
+
parentSessionId: null,
|
|
248
|
+
agentId: 'parent-hook-agent',
|
|
249
|
+
provider: 'subagent-test-provider',
|
|
250
|
+
model: 'unit',
|
|
251
|
+
plugins: ['subagent_lifecycle_test'],
|
|
252
|
+
messages: [],
|
|
253
|
+
createdAt: Date.now(),
|
|
254
|
+
lastActiveAt: Date.now(),
|
|
255
|
+
}
|
|
256
|
+
storage.saveSessions(sessions)
|
|
257
|
+
|
|
258
|
+
const handle = await runtime.spawnSubagent(
|
|
259
|
+
{ agentId: 'child-hook-agent', message: 'finish the task', waitForCompletion: false },
|
|
260
|
+
{ sessionId: 'hook-parent-session', cwd: tempDir },
|
|
261
|
+
)
|
|
262
|
+
const result = await handle.promise
|
|
263
|
+
|
|
264
|
+
assert.equal(result.status, 'completed')
|
|
265
|
+
assert.equal(marks.includes('spawning:child-hook-agent'), true)
|
|
266
|
+
assert.equal(marks.some((mark) => mark.startsWith('spawned:')), true)
|
|
267
|
+
assert.equal(marks.includes('ended:completed'), true)
|
|
268
|
+
assert.equal(marks.includes('session_end:completed'), true)
|
|
269
|
+
})
|
|
187
270
|
})
|
|
188
271
|
|
|
189
272
|
describe('mergePlugins', () => {
|
|
@@ -230,7 +313,7 @@ describe('subagent-runtime', () => {
|
|
|
230
313
|
})
|
|
231
314
|
|
|
232
315
|
describe('plugin inheritance in spawnSubagent', () => {
|
|
233
|
-
it('child session inherits parent plugins merged with agent plugins', () => {
|
|
316
|
+
it('child session inherits parent plugins merged with agent plugins', async () => {
|
|
234
317
|
lineage._clearLineage()
|
|
235
318
|
seedAgent('inherit-agent', 'Inherit Agent', ['shell', 'memory'])
|
|
236
319
|
|
|
@@ -244,9 +327,9 @@ describe('subagent-runtime', () => {
|
|
|
244
327
|
}
|
|
245
328
|
storage.saveSessions(sessions)
|
|
246
329
|
|
|
247
|
-
let handle: ReturnType<typeof runtime.spawnSubagent
|
|
330
|
+
let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
|
|
248
331
|
try {
|
|
249
|
-
handle = runtime.spawnSubagent(
|
|
332
|
+
handle = await runtime.spawnSubagent(
|
|
250
333
|
{ agentId: 'inherit-agent', message: 'test inheritance', waitForCompletion: false },
|
|
251
334
|
{ sessionId: 'inherit-parent', cwd: tempDir },
|
|
252
335
|
)
|
|
@@ -265,7 +348,7 @@ describe('subagent-runtime', () => {
|
|
|
265
348
|
}
|
|
266
349
|
})
|
|
267
350
|
|
|
268
|
-
it('child session does not inherit when inheritPlugins is false', () => {
|
|
351
|
+
it('child session does not inherit when inheritPlugins is false', async () => {
|
|
269
352
|
lineage._clearLineage()
|
|
270
353
|
seedAgent('no-inherit-agent', 'No Inherit Agent', ['shell'])
|
|
271
354
|
|
|
@@ -278,9 +361,9 @@ describe('subagent-runtime', () => {
|
|
|
278
361
|
}
|
|
279
362
|
storage.saveSessions(sessions)
|
|
280
363
|
|
|
281
|
-
let handle: ReturnType<typeof runtime.spawnSubagent
|
|
364
|
+
let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
|
|
282
365
|
try {
|
|
283
|
-
handle = runtime.spawnSubagent(
|
|
366
|
+
handle = await runtime.spawnSubagent(
|
|
284
367
|
{ agentId: 'no-inherit-agent', message: 'no inherit', inheritPlugins: false, waitForCompletion: false },
|
|
285
368
|
{ sessionId: 'no-inherit-parent', cwd: tempDir },
|
|
286
369
|
)
|
|
@@ -13,6 +13,7 @@ import { enqueueSessionRun, type EnqueueSessionRunResult } from '@/lib/server/ru
|
|
|
13
13
|
import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
|
|
14
14
|
import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
|
|
15
15
|
import { resolveSubagentBrowserProfileId } from '@/lib/server/session-tools/subagent'
|
|
16
|
+
import { getPluginManager } from '@/lib/server/plugins'
|
|
16
17
|
import {
|
|
17
18
|
appendDelegationCheckpoint,
|
|
18
19
|
completeDelegationJob,
|
|
@@ -179,7 +180,14 @@ export function getSessionDepth(
|
|
|
179
180
|
export function spawnSubagent(
|
|
180
181
|
input: SpawnSubagentInput,
|
|
181
182
|
context: SubagentContext,
|
|
182
|
-
): SubagentHandle {
|
|
183
|
+
): Promise<SubagentHandle> {
|
|
184
|
+
return spawnSubagentImpl(input, context)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function spawnSubagentImpl(
|
|
188
|
+
input: SpawnSubagentInput,
|
|
189
|
+
context: SubagentContext,
|
|
190
|
+
): Promise<SubagentHandle> {
|
|
183
191
|
const runtime = loadRuntimeSettings()
|
|
184
192
|
const maxDepth = runtime.delegationMaxDepth || DEFAULT_DELEGATION_MAX_DEPTH
|
|
185
193
|
const agents = loadAgents()
|
|
@@ -195,6 +203,28 @@ export function spawnSubagent(
|
|
|
195
203
|
if (depth >= maxDepth) {
|
|
196
204
|
throw new Error(`Max subagent depth (${maxDepth}) reached.`)
|
|
197
205
|
}
|
|
206
|
+
const parent = context.sessionId ? sessions[context.sessionId] : null
|
|
207
|
+
const parentPlugins = (
|
|
208
|
+
Array.isArray(parent?.plugins) ? parent.plugins
|
|
209
|
+
: Array.isArray(parent?.tools) ? parent.tools
|
|
210
|
+
: []
|
|
211
|
+
) as string[]
|
|
212
|
+
const pluginManager = getPluginManager()
|
|
213
|
+
const spawningResult = await pluginManager.runSubagentSpawning(
|
|
214
|
+
{
|
|
215
|
+
parentSessionId: context.sessionId || null,
|
|
216
|
+
agentId: input.agentId,
|
|
217
|
+
agentName: agent.name,
|
|
218
|
+
message: input.message,
|
|
219
|
+
cwd: input.cwd || context.cwd,
|
|
220
|
+
mode: 'run',
|
|
221
|
+
threadRequested: false,
|
|
222
|
+
},
|
|
223
|
+
{ enabledIds: parentPlugins },
|
|
224
|
+
)
|
|
225
|
+
if (spawningResult.status === 'error') {
|
|
226
|
+
throw new Error(spawningResult.error || 'Subagent spawn blocked by plugin hook')
|
|
227
|
+
}
|
|
198
228
|
|
|
199
229
|
// 1. Create delegation job
|
|
200
230
|
const job = createDelegationJob({
|
|
@@ -209,7 +239,6 @@ export function spawnSubagent(
|
|
|
209
239
|
// 2. Create child session
|
|
210
240
|
const sid = genId()
|
|
211
241
|
const now = Date.now()
|
|
212
|
-
const parent = context.sessionId ? sessions[context.sessionId] : null
|
|
213
242
|
const browserProfileId = resolveSubagentBrowserProfileId(
|
|
214
243
|
parent,
|
|
215
244
|
sid,
|
|
@@ -276,6 +305,19 @@ export function spawnSubagent(
|
|
|
276
305
|
mode: 'followup',
|
|
277
306
|
executionGroupKey: input.executionGroupKey,
|
|
278
307
|
})
|
|
308
|
+
await pluginManager.runHook(
|
|
309
|
+
'subagentSpawned',
|
|
310
|
+
{
|
|
311
|
+
parentSessionId: context.sessionId || null,
|
|
312
|
+
childSessionId: sid,
|
|
313
|
+
agentId: agent.id,
|
|
314
|
+
agentName: agent.name,
|
|
315
|
+
runId: run.runId,
|
|
316
|
+
mode: 'run',
|
|
317
|
+
threadRequested: false,
|
|
318
|
+
},
|
|
319
|
+
{ enabledIds: parentPlugins },
|
|
320
|
+
)
|
|
279
321
|
|
|
280
322
|
// 8. Register runtime handle for cancellation
|
|
281
323
|
registerDelegationRuntime(job.id, {
|
|
@@ -290,33 +332,87 @@ export function spawnSubagent(
|
|
|
290
332
|
|
|
291
333
|
// 9. Build result promise
|
|
292
334
|
const resultPromise = run.promise
|
|
293
|
-
.then((result): SubagentResult => {
|
|
335
|
+
.then(async (result): Promise<SubagentResult> => {
|
|
294
336
|
const latest = getDelegationJob(job.id)
|
|
295
337
|
const node = getLineageNode(lineageNode.id)
|
|
338
|
+
let subagentResult: SubagentResult
|
|
296
339
|
if (latest?.status === 'cancelled' || node?.status === 'cancelled') {
|
|
297
|
-
|
|
340
|
+
subagentResult = buildResult(job.id, sid, lineageNode, agent, 'cancelled', null, null)
|
|
341
|
+
} else {
|
|
342
|
+
const responseText = (result.text || '').slice(0, 32_000)
|
|
343
|
+
completeLineageNode(lineageNode.id, responseText.slice(0, 1000))
|
|
344
|
+
appendDelegationCheckpoint(job.id, 'Child session completed', 'completed')
|
|
345
|
+
completeDelegationJob(job.id, responseText, { childSessionId: sid })
|
|
346
|
+
|
|
347
|
+
subagentResult = buildResult(job.id, sid, lineageNode, agent, 'completed', responseText, null)
|
|
298
348
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
349
|
+
await pluginManager.runHook(
|
|
350
|
+
'subagentEnded',
|
|
351
|
+
{
|
|
352
|
+
parentSessionId: context.sessionId || null,
|
|
353
|
+
childSessionId: sid,
|
|
354
|
+
agentId: agent.id,
|
|
355
|
+
agentName: agent.name,
|
|
356
|
+
status: subagentResult.status,
|
|
357
|
+
response: subagentResult.response,
|
|
358
|
+
error: subagentResult.error,
|
|
359
|
+
durationMs: subagentResult.durationMs,
|
|
360
|
+
},
|
|
361
|
+
{ enabledIds: parentPlugins },
|
|
362
|
+
)
|
|
363
|
+
await pluginManager.runHook(
|
|
364
|
+
'sessionEnd',
|
|
365
|
+
{
|
|
366
|
+
sessionId: sid,
|
|
367
|
+
session: loadSessions()[sid] || null,
|
|
368
|
+
messageCount: Array.isArray(loadSessions()[sid]?.messages) ? loadSessions()[sid].messages.length : 0,
|
|
369
|
+
durationMs: subagentResult.durationMs,
|
|
370
|
+
reason: subagentResult.status,
|
|
371
|
+
},
|
|
372
|
+
{ enabledIds: parentPlugins },
|
|
373
|
+
)
|
|
374
|
+
return subagentResult
|
|
306
375
|
})
|
|
307
|
-
.catch((err: unknown): SubagentResult => {
|
|
376
|
+
.catch(async (err: unknown): Promise<SubagentResult> => {
|
|
308
377
|
const message = errorMessage(err)
|
|
309
378
|
const latest = getDelegationJob(job.id)
|
|
310
379
|
const node = getLineageNode(lineageNode.id)
|
|
380
|
+
let subagentResult: SubagentResult
|
|
311
381
|
if (latest?.status === 'cancelled' || node?.status === 'cancelled') {
|
|
312
|
-
|
|
313
|
-
}
|
|
382
|
+
subagentResult = buildResult(job.id, sid, lineageNode, agent, 'cancelled', null, null)
|
|
383
|
+
} else {
|
|
384
|
+
failLineageNode(lineageNode.id, message)
|
|
385
|
+
appendDelegationCheckpoint(job.id, `Child session failed: ${message}`, 'failed')
|
|
386
|
+
failDelegationJob(job.id, message, { childSessionId: sid })
|
|
314
387
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
388
|
+
subagentResult = buildResult(job.id, sid, lineageNode, agent, 'failed', null, message)
|
|
389
|
+
}
|
|
390
|
+
await pluginManager.runHook(
|
|
391
|
+
'subagentEnded',
|
|
392
|
+
{
|
|
393
|
+
parentSessionId: context.sessionId || null,
|
|
394
|
+
childSessionId: sid,
|
|
395
|
+
agentId: agent.id,
|
|
396
|
+
agentName: agent.name,
|
|
397
|
+
status: subagentResult.status,
|
|
398
|
+
response: subagentResult.response,
|
|
399
|
+
error: subagentResult.error,
|
|
400
|
+
durationMs: subagentResult.durationMs,
|
|
401
|
+
},
|
|
402
|
+
{ enabledIds: parentPlugins },
|
|
403
|
+
)
|
|
404
|
+
await pluginManager.runHook(
|
|
405
|
+
'sessionEnd',
|
|
406
|
+
{
|
|
407
|
+
sessionId: sid,
|
|
408
|
+
session: loadSessions()[sid] || null,
|
|
409
|
+
messageCount: Array.isArray(loadSessions()[sid]?.messages) ? loadSessions()[sid].messages.length : 0,
|
|
410
|
+
durationMs: subagentResult.durationMs,
|
|
411
|
+
reason: subagentResult.status,
|
|
412
|
+
},
|
|
413
|
+
{ enabledIds: parentPlugins },
|
|
414
|
+
)
|
|
415
|
+
return subagentResult
|
|
320
416
|
})
|
|
321
417
|
|
|
322
418
|
const handle: SubagentHandle = {
|
|
@@ -169,10 +169,10 @@ function notifySwarmChanged() {
|
|
|
169
169
|
*
|
|
170
170
|
* const aggregate = await swarm.allSettled
|
|
171
171
|
*/
|
|
172
|
-
export function spawnSwarm(
|
|
172
|
+
export async function spawnSwarm(
|
|
173
173
|
input: BatchSpawnInput,
|
|
174
174
|
context: SubagentContext,
|
|
175
|
-
): SwarmHandle {
|
|
175
|
+
): Promise<SwarmHandle> {
|
|
176
176
|
const swarmId = genId(10)
|
|
177
177
|
const createdAt = Date.now()
|
|
178
178
|
const members: SwarmMember[] = []
|
|
@@ -190,7 +190,7 @@ export function spawnSwarm(
|
|
|
190
190
|
for (let i = 0; i < input.tasks.length; i++) {
|
|
191
191
|
const task = input.tasks[i]
|
|
192
192
|
try {
|
|
193
|
-
const handle = spawnSubagent(
|
|
193
|
+
const handle = await spawnSubagent(
|
|
194
194
|
{
|
|
195
195
|
agentId: task.agentId,
|
|
196
196
|
message: task.message,
|
|
@@ -230,3 +230,115 @@ test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thr
|
|
|
230
230
|
assert.equal(output.lastMessageText, 'seed user message')
|
|
231
231
|
assert.equal(output.heartbeatKinds, 0)
|
|
232
232
|
})
|
|
233
|
+
|
|
234
|
+
test('executeSessionChatTurn applies lifecycle hooks for model resolution and message persistence', () => {
|
|
235
|
+
const output = runWithTempDataDir(`
|
|
236
|
+
const storageMod = await import('@/lib/server/storage')
|
|
237
|
+
const providersMod = await import('@/lib/providers/index')
|
|
238
|
+
const pluginsMod = await import('@/lib/server/plugins')
|
|
239
|
+
const execMod = await import('@/lib/server/chat-execution/chat-execution')
|
|
240
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
241
|
+
const executeSessionChatTurn = execMod.executeSessionChatTurn
|
|
242
|
+
|| execMod.default?.executeSessionChatTurn
|
|
243
|
+
|| execMod['module.exports']?.executeSessionChatTurn
|
|
244
|
+
const providers = providersMod.PROVIDERS
|
|
245
|
+
|| providersMod.default?.PROVIDERS
|
|
246
|
+
|| providersMod['module.exports']?.PROVIDERS
|
|
247
|
+
const pluginManager = pluginsMod.getPluginManager
|
|
248
|
+
? pluginsMod.getPluginManager()
|
|
249
|
+
: pluginsMod.default?.getPluginManager?.()
|
|
250
|
+
|
|
251
|
+
const lifecycleMarks = []
|
|
252
|
+
pluginManager.registerBuiltin('lifecycle_hooks_test', {
|
|
253
|
+
name: 'Lifecycle Hooks Test',
|
|
254
|
+
hooks: {
|
|
255
|
+
beforeModelResolve: () => ({
|
|
256
|
+
providerOverride: 'claude-cli',
|
|
257
|
+
modelOverride: 'resolved-model',
|
|
258
|
+
}),
|
|
259
|
+
beforeMessageWrite: ({ message, phase }) => {
|
|
260
|
+
lifecycleMarks.push(phase || 'unknown')
|
|
261
|
+
return {
|
|
262
|
+
message: {
|
|
263
|
+
...message,
|
|
264
|
+
text: message.role === 'assistant' ? message.text + ' [stored]' : message.text,
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
sessionStart: () => {
|
|
269
|
+
lifecycleMarks.push('session_start')
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
providers['claude-cli'] = {
|
|
275
|
+
id: 'claude-cli',
|
|
276
|
+
name: 'Resolved Provider',
|
|
277
|
+
models: ['resolved-model'],
|
|
278
|
+
requiresApiKey: false,
|
|
279
|
+
requiresEndpoint: false,
|
|
280
|
+
handler: {
|
|
281
|
+
async streamChat(opts) {
|
|
282
|
+
lifecycleMarks.push('provider:' + opts.session.provider + ':' + opts.session.model)
|
|
283
|
+
return 'resolved response'
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const now = Date.now()
|
|
289
|
+
storage.saveAgents({
|
|
290
|
+
lifecycle: {
|
|
291
|
+
id: 'lifecycle',
|
|
292
|
+
name: 'Lifecycle Agent',
|
|
293
|
+
description: 'Lifecycle hook integration test',
|
|
294
|
+
provider: 'openai',
|
|
295
|
+
model: 'seed-model',
|
|
296
|
+
credentialId: null,
|
|
297
|
+
apiEndpoint: null,
|
|
298
|
+
fallbackCredentialIds: [],
|
|
299
|
+
disabled: false,
|
|
300
|
+
heartbeatEnabled: false,
|
|
301
|
+
heartbeatIntervalSec: null,
|
|
302
|
+
plugins: ['lifecycle_hooks_test'],
|
|
303
|
+
createdAt: now,
|
|
304
|
+
updatedAt: now,
|
|
305
|
+
},
|
|
306
|
+
})
|
|
307
|
+
storage.saveSessions({
|
|
308
|
+
lifecycle_session: {
|
|
309
|
+
id: 'lifecycle_session',
|
|
310
|
+
name: 'Lifecycle Session',
|
|
311
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
312
|
+
user: 'default',
|
|
313
|
+
provider: 'openai',
|
|
314
|
+
model: 'seed-model',
|
|
315
|
+
claudeSessionId: null,
|
|
316
|
+
messages: [],
|
|
317
|
+
createdAt: now,
|
|
318
|
+
lastActiveAt: now,
|
|
319
|
+
sessionType: 'human',
|
|
320
|
+
agentId: 'lifecycle',
|
|
321
|
+
plugins: ['lifecycle_hooks_test'],
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
await executeSessionChatTurn({
|
|
326
|
+
sessionId: 'lifecycle_session',
|
|
327
|
+
message: 'hello lifecycle',
|
|
328
|
+
runId: 'run-lifecycle-hooks',
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const persisted = storage.loadSession('lifecycle_session')
|
|
332
|
+
console.log(JSON.stringify({
|
|
333
|
+
lastMessageText: persisted?.messages?.at(-1)?.text || null,
|
|
334
|
+
marks: lifecycleMarks,
|
|
335
|
+
}))
|
|
336
|
+
`)
|
|
337
|
+
|
|
338
|
+
assert.equal(output.lastMessageText.startsWith('resolved response'), true)
|
|
339
|
+
assert.equal(output.lastMessageText.endsWith('[stored]'), true)
|
|
340
|
+
assert.equal(output.marks.includes('session_start'), true)
|
|
341
|
+
assert.equal(output.marks.includes('provider:claude-cli:resolved-model'), true)
|
|
342
|
+
assert.equal(output.marks.includes('user'), true)
|
|
343
|
+
assert.equal(output.marks.includes('assistant_final'), true)
|
|
344
|
+
})
|