@swarmclawai/swarmclaw 0.9.4 → 0.9.6

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.
Files changed (32) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/src/app/api/settings/route.ts +1 -0
  4. package/src/app/api/wallets/[id]/send/route.ts +10 -4
  5. package/src/app/api/wallets/route.ts +5 -2
  6. package/src/app/settings/page.tsx +9 -0
  7. package/src/components/wallets/wallet-panel.tsx +12 -1
  8. package/src/components/wallets/wallet-section.tsx +4 -1
  9. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
  10. package/src/lib/server/agents/main-agent-loop.ts +12 -1
  11. package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
  12. package/src/lib/server/agents/subagent-runtime.ts +115 -19
  13. package/src/lib/server/agents/subagent-swarm.ts +3 -3
  14. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
  15. package/src/lib/server/chat-execution/chat-execution.ts +357 -152
  16. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
  17. package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
  18. package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
  19. package/src/lib/server/connectors/contact-boundaries.ts +70 -8
  20. package/src/lib/server/connectors/manager.test.ts +129 -7
  21. package/src/lib/server/plugins.test.ts +263 -0
  22. package/src/lib/server/plugins.ts +406 -10
  23. package/src/lib/server/session-tools/context.ts +15 -1
  24. package/src/lib/server/session-tools/index.ts +42 -6
  25. package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
  26. package/src/lib/server/session-tools/subagent.ts +3 -3
  27. package/src/lib/server/tool-loop-detection.test.ts +21 -0
  28. package/src/lib/server/tool-loop-detection.ts +79 -0
  29. package/src/lib/server/wallet/wallet-service.test.ts +25 -1
  30. package/src/lib/server/wallet/wallet-service.ts +13 -0
  31. package/src/types/index.ts +134 -1
  32. package/src/views/settings/section-wallets.tsx +35 -0
@@ -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
- return buildResult(job.id, sid, lineageNode, agent, 'cancelled', null, null)
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
- const responseText = (result.text || '').slice(0, 32_000)
301
- completeLineageNode(lineageNode.id, responseText.slice(0, 1000))
302
- appendDelegationCheckpoint(job.id, 'Child session completed', 'completed')
303
- completeDelegationJob(job.id, responseText, { childSessionId: sid })
304
-
305
- return buildResult(job.id, sid, lineageNode, agent, 'completed', responseText, null)
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
- return buildResult(job.id, sid, lineageNode, agent, 'cancelled', null, null)
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
- failLineageNode(lineageNode.id, message)
316
- appendDelegationCheckpoint(job.id, `Child session failed: ${message}`, 'failed')
317
- failDelegationJob(job.id, message, { childSessionId: sid })
318
-
319
- return buildResult(job.id, sid, lineageNode, agent, 'failed', null, message)
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
+ })