@swarmclawai/swarmclaw 1.3.5 → 1.4.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.
Files changed (103) hide show
  1. package/README.md +37 -1
  2. package/package.json +10 -3
  3. package/src/.env.local +4 -0
  4. package/src/app/api/.well-known/agent-card/route.ts +46 -0
  5. package/src/app/api/a2a/route.ts +56 -0
  6. package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
  7. package/src/app/api/chats/[id]/deploy/route.ts +2 -2
  8. package/src/app/api/openclaw/sync/route.ts +1 -1
  9. package/src/app/api/swarmfeed/channels/route.ts +14 -0
  10. package/src/app/api/swarmfeed/posts/route.ts +60 -0
  11. package/src/app/api/swarmfeed/route.ts +37 -0
  12. package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
  13. package/src/app/protocols/page.tsx +16 -7
  14. package/src/app/swarmfeed/page.tsx +7 -0
  15. package/src/cli/index.js +19 -0
  16. package/src/cli/spec.js +8 -0
  17. package/src/components/agents/agent-avatar.tsx +2 -5
  18. package/src/components/agents/agent-sheet.tsx +10 -0
  19. package/src/components/auth/access-key-gate.tsx +25 -0
  20. package/src/components/layout/sidebar-rail.tsx +52 -0
  21. package/src/components/protocols/builder/edge-editor.tsx +43 -0
  22. package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
  23. package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
  24. package/src/components/protocols/builder/edge-types/index.ts +3 -0
  25. package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
  26. package/src/components/protocols/builder/node-inspector.tsx +227 -0
  27. package/src/components/protocols/builder/node-palette.tsx +97 -0
  28. package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
  29. package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
  30. package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
  31. package/src/components/protocols/builder/node-types/index.ts +9 -0
  32. package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
  33. package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
  34. package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
  35. package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
  36. package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
  37. package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
  38. package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
  39. package/src/components/protocols/builder/run-overlay.tsx +29 -0
  40. package/src/components/protocols/builder/template-gallery.tsx +53 -0
  41. package/src/components/protocols/builder/validation-panel.tsx +57 -0
  42. package/src/components/skills/skills-workspace.tsx +1 -9
  43. package/src/features/protocols/builder/hooks/index.ts +2 -0
  44. package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
  45. package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
  46. package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
  47. package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
  48. package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
  49. package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
  50. package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
  51. package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
  52. package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
  53. package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
  54. package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
  55. package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
  56. package/src/features/swarmfeed/compose-post.tsx +139 -0
  57. package/src/features/swarmfeed/feed-page.tsx +136 -0
  58. package/src/features/swarmfeed/post-card.tsx +114 -0
  59. package/src/features/swarmfeed/queries.ts +28 -0
  60. package/src/lib/a2a/agent-card.ts +61 -0
  61. package/src/lib/a2a/auth.ts +54 -0
  62. package/src/lib/a2a/client.ts +133 -0
  63. package/src/lib/a2a/discovery.ts +116 -0
  64. package/src/lib/a2a/handlers.ts +176 -0
  65. package/src/lib/a2a/json-rpc-router.ts +38 -0
  66. package/src/lib/a2a/types.ts +95 -0
  67. package/src/lib/app/navigation.ts +1 -0
  68. package/src/lib/app/view-constants.ts +9 -1
  69. package/src/lib/providers/anthropic.ts +111 -107
  70. package/src/lib/providers/openai.ts +146 -142
  71. package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
  72. package/src/lib/server/agents/main-agent-loop.ts +377 -41
  73. package/src/lib/server/chat-execution/chat-execution-disabled.test.ts +14 -31
  74. package/src/lib/server/chat-execution/chat-execution-eval-history.test.ts +11 -34
  75. package/src/lib/server/chat-execution/chat-execution-grounding.test.ts +15 -34
  76. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +35 -36
  77. package/src/lib/server/chat-execution/chat-execution.ts +12 -7
  78. package/src/lib/server/extensions.ts +11 -0
  79. package/src/lib/server/knowledge-sources.test.ts +46 -0
  80. package/src/lib/server/knowledge-sources.ts +34 -16
  81. package/src/lib/server/openclaw/sync.ts +4 -4
  82. package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
  83. package/src/lib/server/protocols/protocol-normalization.ts +1 -0
  84. package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
  85. package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
  86. package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
  87. package/src/lib/server/protocols/protocol-types.ts +1 -0
  88. package/src/lib/server/session-tools/delegate.ts +151 -77
  89. package/src/lib/server/storage-auth.ts +10 -2
  90. package/src/lib/server/storage-normalization.ts +11 -0
  91. package/src/lib/server/storage.ts +100 -0
  92. package/src/lib/server/test-utils/run-with-temp-data-dir.ts +15 -2
  93. package/src/lib/server/working-state/service.test.ts +2 -3
  94. package/src/lib/server/working-state/service.ts +37 -6
  95. package/src/lib/swarmfeed-client.ts +157 -0
  96. package/src/lib/validation/schemas.ts +1 -1
  97. package/src/stores/slices/data-slice.ts +3 -0
  98. package/src/stores/use-approval-store.ts +4 -1
  99. package/src/types/agent.ts +31 -1
  100. package/src/types/index.ts +1 -0
  101. package/src/types/protocol.ts +19 -0
  102. package/src/types/session.ts +1 -1
  103. package/src/types/swarmfeed.ts +30 -0
@@ -1,40 +1,23 @@
1
1
  import assert from 'node:assert/strict'
2
- import fs from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { spawnSync } from 'node:child_process'
6
2
  import test from 'node:test'
3
+ import { runWithTempDataDir as runWithSharedTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
7
4
 
8
- const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
-
10
- function runWithTempDataDir(script: string) {
11
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-chat-disabled-'))
12
- try {
13
- const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
- cwd: repoRoot,
15
- env: {
16
- ...process.env,
17
- DATA_DIR: path.join(tempDir, 'data'),
18
- WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
- BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
20
- },
21
- encoding: 'utf-8',
22
- })
23
- assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
24
- const lines = (result.stdout || '')
25
- .trim()
26
- .split('\n')
27
- .map((line) => line.trim())
28
- .filter(Boolean)
29
- const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
30
- return JSON.parse(jsonLine || '{}')
31
- } finally {
32
- fs.rmSync(tempDir, { recursive: true, force: true })
33
- }
5
+ function runWithTempDataDir<T = unknown>(script: string): T {
6
+ return runWithSharedTempDataDir<T>(script, {
7
+ prefix: 'swarmclaw-chat-disabled-',
8
+ dataDir: 'data',
9
+ browserProfilesDir: 'browser-profiles',
10
+ })
34
11
  }
35
12
 
36
13
  test('executeSessionChatTurn persists a visible error for disabled agents', () => {
37
- const output = runWithTempDataDir(`
14
+ const output = runWithTempDataDir<{
15
+ error: string | null
16
+ text: string | null
17
+ persisted: boolean
18
+ lastRole: string | null
19
+ lastText: string | null
20
+ }>(`
38
21
  const storageMod = await import('@/lib/server/storage')
39
22
  const storage = storageMod.default || storageMod['module.exports'] || storageMod
40
23
  const threadMod = await import('@/lib/server/agents/agent-thread-session')
@@ -1,40 +1,13 @@
1
1
  import assert from 'node:assert/strict'
2
- import fs from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { spawnSync } from 'node:child_process'
6
2
  import test from 'node:test'
7
-
8
- const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
-
10
- function runWithTempDataDir(script: string) {
11
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-chat-eval-history-'))
12
- try {
13
- const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
- cwd: repoRoot,
15
- env: {
16
- ...process.env,
17
- DATA_DIR: path.join(tempDir, 'data'),
18
- WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
- BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
20
- },
21
- encoding: 'utf-8',
22
- })
23
- assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
24
- const lines = (result.stdout || '')
25
- .trim()
26
- .split('\n')
27
- .map((line) => line.trim())
28
- .filter(Boolean)
29
- const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
30
- return JSON.parse(jsonLine || '{}')
31
- } finally {
32
- fs.rmSync(tempDir, { recursive: true, force: true })
33
- }
34
- }
3
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
35
4
 
36
5
  test('executeSessionChatTurn persists internal eval user turns for same-thread recall', () => {
37
- const output = runWithTempDataDir(`
6
+ const output = runWithTempDataDir<{
7
+ recallText: string | null
8
+ roles: string[]
9
+ texts: string[]
10
+ }>(`
38
11
  const storageMod = await import('@/lib/server/storage')
39
12
  const storage = storageMod.default || storageMod['module.exports'] || storageMod
40
13
  const providersMod = await import('@/lib/providers/index')
@@ -103,7 +76,11 @@ test('executeSessionChatTurn persists internal eval user turns for same-thread r
103
76
  roles: storedSession.messages.map((entry) => entry.role),
104
77
  texts: storedSession.messages.map((entry) => entry.text),
105
78
  }))
106
- `)
79
+ `, {
80
+ prefix: 'swarmclaw-chat-eval-history-',
81
+ dataDir: 'data',
82
+ browserProfilesDir: 'browser-profiles',
83
+ })
107
84
 
108
85
  assert.match(String(output.recallText || ''), /Sunbird/)
109
86
  assert.deepEqual(output.roles, ['user', 'assistant', 'user', 'assistant'])
@@ -1,40 +1,17 @@
1
1
  import assert from 'node:assert/strict'
2
- import fs from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { spawnSync } from 'node:child_process'
6
2
  import test from 'node:test'
7
-
8
- const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
-
10
- function runWithTempDataDir(script: string) {
11
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-chat-grounding-'))
12
- try {
13
- const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
- cwd: repoRoot,
15
- env: {
16
- ...process.env,
17
- DATA_DIR: path.join(tempDir, 'data'),
18
- WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
- BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
20
- },
21
- encoding: 'utf-8',
22
- })
23
- assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
24
- const lines = (result.stdout || '')
25
- .trim()
26
- .split('\n')
27
- .map((line) => line.trim())
28
- .filter(Boolean)
29
- const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
30
- return JSON.parse(jsonLine || '{}')
31
- } finally {
32
- fs.rmSync(tempDir, { recursive: true, force: true })
33
- }
34
- }
3
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
35
4
 
36
5
  test('executeSessionChatTurn persists citations and retrieval traces on grounded assistant messages', () => {
37
- const output = runWithTempDataDir(`
6
+ const output = runWithTempDataDir<{
7
+ persisted: boolean
8
+ resultCitationCount: number
9
+ resultSelectorStatus: string | null
10
+ messageCitationCount: number
11
+ messageTraceHitCount: number
12
+ messageSelectorStatus: string | null
13
+ messageSourceTitle: string | null
14
+ }>(`
38
15
  const storageMod = await import('@/lib/server/storage')
39
16
  const providersMod = await import('@/lib/providers/index')
40
17
  const threadMod = await import('@/lib/server/agents/agent-thread-session')
@@ -115,7 +92,11 @@ test('executeSessionChatTurn persists citations and retrieval traces on grounded
115
92
  messageSelectorStatus: lastMessage?.retrievalTrace?.selectorStatus || null,
116
93
  messageSourceTitle: lastMessage?.citations?.[0]?.sourceTitle || null,
117
94
  }))
118
- `)
95
+ `, {
96
+ prefix: 'swarmclaw-chat-grounding-',
97
+ dataDir: 'data',
98
+ browserProfilesDir: 'browser-profiles',
99
+ })
119
100
 
120
101
  assert.equal(output.persisted, true)
121
102
  assert.equal(output.resultCitationCount >= 1, true)
@@ -1,40 +1,24 @@
1
1
  import assert from 'node:assert/strict'
2
- import fs from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { spawnSync } from 'node:child_process'
6
2
  import test from 'node:test'
7
-
8
- const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
-
10
- function runWithTempDataDir(script: string) {
11
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-chat-session-sync-'))
12
- try {
13
- const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
- cwd: repoRoot,
15
- env: {
16
- ...process.env,
17
- DATA_DIR: path.join(tempDir, 'data'),
18
- WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
- BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
20
- },
21
- encoding: 'utf-8',
22
- })
23
- assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
24
- const lines = (result.stdout || '')
25
- .trim()
26
- .split('\n')
27
- .map((line) => line.trim())
28
- .filter(Boolean)
29
- const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
30
- return JSON.parse(jsonLine || '{}')
31
- } finally {
32
- fs.rmSync(tempDir, { recursive: true, force: true })
33
- }
3
+ import { runWithTempDataDir as runWithSharedTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
4
+
5
+ function runWithTempDataDir<T = unknown>(script: string): T {
6
+ return runWithSharedTempDataDir<T>(script, {
7
+ prefix: 'swarmclaw-chat-session-sync-',
8
+ dataDir: 'data',
9
+ browserProfilesDir: 'browser-profiles',
10
+ })
34
11
  }
35
12
 
36
13
  test('executeSessionChatTurn syncs updated agent runtime fields onto its thread session', () => {
37
- const output = runWithTempDataDir(`
14
+ const output = runWithTempDataDir<{
15
+ provider: string | null
16
+ model: string | null
17
+ extensions: string[]
18
+ heartbeatEnabled: boolean | null
19
+ heartbeatIntervalSec: number | null
20
+ connectorContext: Record<string, unknown> | null
21
+ }>(`
38
22
  const storageMod = await import('@/lib/server/storage')
39
23
  const storage = storageMod.default || storageMod['module.exports'] || storageMod
40
24
  const providersMod = await import('@/lib/providers/index')
@@ -126,7 +110,12 @@ test('executeSessionChatTurn syncs updated agent runtime fields onto its thread
126
110
  })
127
111
 
128
112
  test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thread history and clears stale connector state', () => {
129
- const output = runWithTempDataDir(`
113
+ const output = runWithTempDataDir<{
114
+ connectorContext: Record<string, unknown> | null
115
+ messageCount: number
116
+ lastMessageText: string | null
117
+ heartbeatKinds: number
118
+ }>(`
130
119
  const storageMod = await import('@/lib/server/storage')
131
120
  const providersMod = await import('@/lib/providers/index')
132
121
  const execMod = await import('@/lib/server/chat-execution/chat-execution')
@@ -232,7 +221,11 @@ test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thr
232
221
  })
233
222
 
234
223
  test('executeSessionChatTurn hides internal main-loop followup output from the visible transcript', () => {
235
- const output = runWithTempDataDir(`
224
+ const output = runWithTempDataDir<{
225
+ messageCount: number
226
+ lastMessageText: string | null
227
+ hasStreamingArtifacts: boolean
228
+ }>(`
236
229
  const storageMod = await import('@/lib/server/storage')
237
230
  const providersMod = await import('@/lib/providers/index')
238
231
  const execMod = await import('@/lib/server/chat-execution/chat-execution')
@@ -324,7 +317,10 @@ test('executeSessionChatTurn hides internal main-loop followup output from the v
324
317
  })
325
318
 
326
319
  test('executeSessionChatTurn forces external connector sessions onto session-scoped memory', () => {
327
- const output = runWithTempDataDir(`
320
+ const output = runWithTempDataDir<{
321
+ memoryScopeMode: string | null
322
+ connectorContext: { isOwnerConversation?: boolean } | null
323
+ }>(`
328
324
  const storageMod = await import('@/lib/server/storage')
329
325
  const providersMod = await import('@/lib/providers/index')
330
326
  const execMod = await import('@/lib/server/chat-execution/chat-execution')
@@ -418,7 +414,10 @@ test('executeSessionChatTurn forces external connector sessions onto session-sco
418
414
  })
419
415
 
420
416
  test('executeSessionChatTurn applies lifecycle hooks for model resolution and message persistence', () => {
421
- const output = runWithTempDataDir(`
417
+ const output = runWithTempDataDir<{
418
+ lastMessageText: string
419
+ marks: string[]
420
+ }>(`
422
421
  const storageMod = await import('@/lib/server/storage')
423
422
  const providersMod = await import('@/lib/providers/index')
424
423
  const extMod = await import('@/lib/server/extensions')
@@ -89,14 +89,19 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
89
89
  return preflight.terminalResult
90
90
  }
91
91
 
92
- const streamResult = await executePreparedChatTurn({
93
- input,
94
- prepared: preparedTurn,
95
- partialPersistence,
96
- preflightToolRoutingResult: preflight?.directMemoryResult || null,
97
- })
92
+ let streamResult: Awaited<ReturnType<typeof executePreparedChatTurn>>
93
+ try {
94
+ streamResult = await executePreparedChatTurn({
95
+ input,
96
+ prepared: preparedTurn,
97
+ partialPersistence,
98
+ preflightToolRoutingResult: preflight?.directMemoryResult || null,
99
+ })
98
100
 
99
- await partialPersistence.awaitIdle()
101
+ await partialPersistence.awaitIdle()
102
+ } finally {
103
+ partialPersistence.stop()
104
+ }
100
105
 
101
106
  if (!streamResult.errorMessage) {
102
107
  markProviderSuccess(preparedTurn.providerType, preparedTurn.sessionForRun.credentialId)
@@ -874,6 +874,17 @@ class ExtensionManager {
874
874
  try {
875
875
  const parsed = JSON.parse(fs.readFileSync(EXTENSION_FAILURES, 'utf8')) as Record<string, ExtensionFailureRecord>
876
876
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
877
+ // Prune records older than 7 days
878
+ const maxAgeMs = 7 * 24 * 60 * 60 * 1000
879
+ const now = Date.now()
880
+ let pruned = false
881
+ for (const key of Object.keys(parsed)) {
882
+ if (now - (parsed[key].lastFailedAt || 0) > maxAgeMs) {
883
+ delete parsed[key]
884
+ pruned = true
885
+ }
886
+ }
887
+ if (pruned) this.writeFailureState(parsed)
877
888
  return parsed
878
889
  } catch {
879
890
  return {}
@@ -213,3 +213,49 @@ test('runKnowledgeHygieneMaintenance reindexes stale file sources and archives e
213
213
  assert.ok(output.recentActionKinds.includes('archive'))
214
214
  assert.ok(output.recentActionKinds.includes('reindex') || output.recentActionKinds.includes('sync'))
215
215
  })
216
+
217
+ test('runKnowledgeHygieneMaintenance keeps same-content sources separate when visibility differs', () => {
218
+ const output = runWithTempDataDir<{
219
+ globalArchived: boolean
220
+ agentArchived: boolean
221
+ agent1Hits: number
222
+ agent2Hits: number
223
+ }>(`
224
+ const knowledgeMod = await import('./src/lib/server/knowledge-sources.ts')
225
+ const knowledge = knowledgeMod.default || knowledgeMod
226
+
227
+ const globalSource = await knowledge.createKnowledgeSource({
228
+ kind: 'manual',
229
+ title: 'Global Policy',
230
+ content: 'identical content',
231
+ scope: 'global',
232
+ })
233
+
234
+ const agentSource = await knowledge.createKnowledgeSource({
235
+ kind: 'manual',
236
+ title: 'Scoped Policy',
237
+ content: 'identical content',
238
+ scope: 'agent',
239
+ agentIds: ['agent-1'],
240
+ })
241
+
242
+ await knowledge.runKnowledgeHygieneMaintenance()
243
+
244
+ const globalDetail = await knowledge.getKnowledgeSourceDetail(globalSource.source.id)
245
+ const agentDetail = await knowledge.getKnowledgeSourceDetail(agentSource.source.id)
246
+ const agent1Hits = await knowledge.searchKnowledgeHits({ query: 'identical', viewerAgentId: 'agent-1' })
247
+ const agent2Hits = await knowledge.searchKnowledgeHits({ query: 'identical', viewerAgentId: 'agent-2' })
248
+
249
+ console.log(JSON.stringify({
250
+ globalArchived: !!globalDetail?.source?.archivedAt,
251
+ agentArchived: !!agentDetail?.source?.archivedAt,
252
+ agent1Hits: agent1Hits.length,
253
+ agent2Hits: agent2Hits.length,
254
+ }))
255
+ `, { prefix: 'swarmclaw-knowledge-visibility-' })
256
+
257
+ assert.equal(output.globalArchived, false)
258
+ assert.equal(output.agentArchived, false)
259
+ assert.equal(output.agent1Hits, 2)
260
+ assert.equal(output.agent2Hits, 1)
261
+ })
@@ -1056,6 +1056,38 @@ function sameSourceOrigin(left: KnowledgeSource, right: KnowledgeSource): boolea
1056
1056
  return false
1057
1057
  }
1058
1058
 
1059
+ function duplicateOriginFingerprint(source: KnowledgeSource): string {
1060
+ if (source.sourceUrl) return `url:${source.sourceUrl}`
1061
+ if (source.sourcePath) return `path:${source.sourcePath}`
1062
+ return `kind:${source.kind}`
1063
+ }
1064
+
1065
+ function duplicateGroupKey(source: KnowledgeSource): string | null {
1066
+ if (!source.sourceHash) return null
1067
+ const sortedAgentIds = [...source.agentIds].sort()
1068
+ const sortedTags = [...source.tags].map((tag) => tag.toLowerCase()).sort()
1069
+ return [
1070
+ source.sourceHash,
1071
+ source.kind,
1072
+ source.scope,
1073
+ sortedAgentIds.join(','),
1074
+ sortedTags.join(','),
1075
+ duplicateOriginFingerprint(source),
1076
+ ].join('|')
1077
+ }
1078
+
1079
+ function collectDuplicateGroups(sources: KnowledgeSource[]): Map<string, KnowledgeSource[]> {
1080
+ const duplicateGroups = new Map<string, KnowledgeSource[]>()
1081
+ for (const source of sources) {
1082
+ const groupKey = duplicateGroupKey(source)
1083
+ if (!groupKey) continue
1084
+ const group = duplicateGroups.get(groupKey) || []
1085
+ group.push(source)
1086
+ duplicateGroups.set(groupKey, group)
1087
+ }
1088
+ return duplicateGroups
1089
+ }
1090
+
1059
1091
  function canonicalSourceForGroup(group: KnowledgeSource[]): KnowledgeSource {
1060
1092
  return [...group].sort((left, right) => {
1061
1093
  const archiveDelta = Number(sourceIsExcludedByDefault(left)) - Number(sourceIsExcludedByDefault(right))
@@ -1072,14 +1104,7 @@ function buildHygieneSummary(sources: KnowledgeSource[]): KnowledgeHygieneSummar
1072
1104
  const pushFinding = (finding: KnowledgeHygieneFinding) => {
1073
1105
  if (findings.length < MAX_HYGIENE_FINDINGS) findings.push(finding)
1074
1106
  }
1075
-
1076
- const duplicateGroups = new Map<string, KnowledgeSource[]>()
1077
- for (const source of sources) {
1078
- if (!source.sourceHash) continue
1079
- const group = duplicateGroups.get(source.sourceHash) || []
1080
- group.push(source)
1081
- duplicateGroups.set(source.sourceHash, group)
1082
- }
1107
+ const duplicateGroups = collectDuplicateGroups(sources)
1083
1108
 
1084
1109
  for (const source of sources) {
1085
1110
  if (sourceIsArchived(source)) {
@@ -1185,14 +1210,7 @@ export async function getKnowledgeHygieneSummary(): Promise<KnowledgeHygieneSumm
1185
1210
  export async function runKnowledgeHygieneMaintenance(): Promise<KnowledgeHygieneSummary> {
1186
1211
  await ensureLegacyKnowledgeBackfill()
1187
1212
  const sources = listStoredSources()
1188
-
1189
- const duplicateGroups = new Map<string, KnowledgeSource[]>()
1190
- for (const source of sources) {
1191
- if (!source.sourceHash) continue
1192
- const group = duplicateGroups.get(source.sourceHash) || []
1193
- group.push(source)
1194
- duplicateGroups.set(source.sourceHash, group)
1195
- }
1213
+ const duplicateGroups = collectDuplicateGroups(sources)
1196
1214
 
1197
1215
  for (const source of sources) {
1198
1216
  if (sourceIsExcludedByDefault(source)) continue
@@ -381,7 +381,7 @@ export function syncExtensionsFromOpenClaw(): { imported: number } {
381
381
  const openclawExtensionDir = path.join(config.workspacePath, 'plugins')
382
382
  if (!fs.existsSync(openclawExtensionDir)) return { imported: 0 }
383
383
 
384
- const localExtensionDir = path.join(DATA_DIR, 'plugins')
384
+ const localExtensionDir = path.join(DATA_DIR, 'extensions')
385
385
  ensureDir(localExtensionDir)
386
386
 
387
387
  const files = fs.readdirSync(openclawExtensionDir).filter((f) => f.endsWith('.js'))
@@ -432,7 +432,7 @@ export function setSharedDeviceToken(token: string): void {
432
432
 
433
433
  // --- Unified Sync Entry Point ---
434
434
 
435
- export type SyncType = 'memory' | 'workspace' | 'schedules' | 'credentials' | 'plugins'
435
+ export type SyncType = 'memory' | 'workspace' | 'schedules' | 'credentials' | 'extensions'
436
436
 
437
437
  export interface SyncResult {
438
438
  type: SyncType
@@ -467,7 +467,7 @@ export async function runSync(params: {
467
467
  case 'credentials':
468
468
  results.push({ type, action: 'push', result: pushCredentialsToOpenClaw() })
469
469
  break
470
- case 'plugins':
470
+ case 'extensions':
471
471
  // Extensions only pull from OpenClaw
472
472
  break
473
473
  }
@@ -492,7 +492,7 @@ export async function runSync(params: {
492
492
  case 'credentials':
493
493
  results.push({ type, action: 'pull', result: await pullCredentialsFromOpenClaw() })
494
494
  break
495
- case 'plugins':
495
+ case 'extensions':
496
496
  results.push({ type, action: 'pull', result: syncExtensionsFromOpenClaw() })
497
497
  break
498
498
  }
@@ -0,0 +1,135 @@
1
+ import { genId } from '@/lib/id'
2
+ import { log } from '@/lib/server/logger'
3
+ import { errorMessage } from '@/lib/shared-utils'
4
+ import { upsertTask } from '@/lib/server/tasks/task-repository'
5
+ import { notify } from '@/lib/server/ws-hub'
6
+ import { callA2AAgent } from '@/lib/a2a/client'
7
+ import { loadExternalAgents } from '@/lib/server/storage'
8
+ import { appendProtocolEvent, persistRun } from '@/lib/server/protocols/protocol-agent-turn'
9
+ import { now } from '@/lib/server/protocols/protocol-types'
10
+ import type { ProtocolRunDeps } from '@/lib/server/protocols/protocol-types'
11
+ import type { ProtocolPhaseDefinition, ProtocolRun, ProtocolRunPhaseState } from '@/types/protocol'
12
+ import type { BoardTask } from '@/types/task'
13
+
14
+ const TAG = 'protocol-a2a-delegate'
15
+
16
+ /**
17
+ * Process an a2a_delegate phase: call a remote A2A agent and wait for the result.
18
+ *
19
+ * Follows the same pattern as processDispatchDelegationPhase:
20
+ * 1. Create a BoardTask for tracking (with protocolRunId so wakeProtocolRunFromTaskCompletion fires)
21
+ * 2. Call the remote agent via HTTP
22
+ * 3. Set protocol run to 'waiting'
23
+ * 4. When the HTTP call completes, update the task → wake machinery resumes the run
24
+ */
25
+ export function processA2ADelegatePhase(
26
+ run: ProtocolRun,
27
+ phase: ProtocolPhaseDefinition,
28
+ deps?: ProtocolRunDeps,
29
+ ): ProtocolRun {
30
+ const config = phase.a2aDelegateConfig
31
+ if (!config?.taskName || !config?.taskMessage) {
32
+ appendProtocolEvent(run.id, {
33
+ type: 'failed',
34
+ phaseId: phase.id,
35
+ summary: `a2a_delegate phase "${phase.label}" missing a2aDelegateConfig`,
36
+ }, deps)
37
+ return persistRun({
38
+ ...run,
39
+ status: 'failed',
40
+ lastError: `a2a_delegate phase "${phase.label}" missing a2aDelegateConfig`,
41
+ endedAt: run.endedAt || now(deps),
42
+ updatedAt: now(deps),
43
+ })
44
+ }
45
+
46
+ // Resolve target URL
47
+ let targetUrl = config.targetUrl
48
+ if (!targetUrl && config.targetExternalAgentId) {
49
+ const externalAgents = loadExternalAgents()
50
+ const ea = externalAgents[config.targetExternalAgentId]
51
+ if (ea?.endpoint) {
52
+ targetUrl = ea.endpoint
53
+ }
54
+ }
55
+
56
+ if (!targetUrl) {
57
+ appendProtocolEvent(run.id, {
58
+ type: 'failed',
59
+ phaseId: phase.id,
60
+ summary: `a2a_delegate phase "${phase.label}" — no target URL resolved`,
61
+ }, deps)
62
+ return persistRun({
63
+ ...run,
64
+ status: 'failed',
65
+ lastError: `a2a_delegate phase "${phase.label}" — could not resolve target A2A agent URL`,
66
+ endedAt: run.endedAt || now(deps),
67
+ updatedAt: now(deps),
68
+ })
69
+ }
70
+
71
+ // Create a BoardTask for tracking
72
+ const taskId = genId()
73
+ const taskData: BoardTask = {
74
+ id: taskId,
75
+ title: `A2A: ${config.taskName}`,
76
+ description: config.taskMessage,
77
+ status: 'queued',
78
+ agentId: run.facilitatorAgentId || run.participantAgentIds?.[0] || '',
79
+ protocolRunId: run.id,
80
+ sourceType: 'delegation',
81
+ externalSource: { source: 'a2a', id: taskId },
82
+ queuedAt: now(deps),
83
+ createdAt: now(deps),
84
+ updatedAt: now(deps),
85
+ }
86
+ upsertTask(taskId, taskData)
87
+ notify('tasks')
88
+
89
+ appendProtocolEvent(run.id, {
90
+ type: 'delegation_dispatched',
91
+ summary: `Dispatched A2A delegation to ${targetUrl}: ${config.taskName}`,
92
+ phaseId: phase.id,
93
+ taskId,
94
+ }, deps)
95
+
96
+ log.info(TAG, `Calling remote A2A agent at ${targetUrl}`, { taskName: config.taskName, taskId })
97
+
98
+ // Fire the HTTP call asynchronously — when it completes, update the task
99
+ // The existing wakeProtocolRunFromTaskCompletion machinery will resume the run
100
+ const resolvedUrl = targetUrl
101
+ callA2AAgent(resolvedUrl, 'executeTask', {
102
+ taskId,
103
+ taskName: config.taskName,
104
+ message: config.taskMessage,
105
+ }, {
106
+ timeout: config.timeoutMs ?? 300_000,
107
+ credentialId: config.credentialId,
108
+ }).then(result => {
109
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result)
110
+ upsertTask(taskId, { ...taskData, status: 'completed', result: resultStr, updatedAt: Date.now(), completedAt: Date.now() })
111
+ notify('tasks')
112
+ log.info(TAG, `A2A delegation completed for task ${taskId}`)
113
+ // Dynamic import to break circular dependency (protocol-step-processors → protocol-a2a-delegate → protocol-run-lifecycle → protocol-step-processors)
114
+ import('@/lib/server/protocols/protocol-run-lifecycle').then(m => m.wakeProtocolRunFromTaskCompletion(taskId))
115
+ }).catch(err => {
116
+ log.error(TAG, `A2A delegation failed for task ${taskId}: ${errorMessage(err)}`)
117
+ if (config.onFailure === 'advance_with_warning') {
118
+ upsertTask(taskId, { ...taskData, status: 'completed', result: `A2A delegation failed: ${errorMessage(err)}`, error: errorMessage(err), updatedAt: Date.now(), completedAt: Date.now() })
119
+ } else {
120
+ upsertTask(taskId, { ...taskData, status: 'failed', error: errorMessage(err), updatedAt: Date.now() })
121
+ }
122
+ notify('tasks')
123
+ import('@/lib/server/protocols/protocol-run-lifecycle').then(m => m.wakeProtocolRunFromTaskCompletion(taskId))
124
+ })
125
+
126
+ const createdTaskIds = [...(run.createdTaskIds || []), taskId]
127
+ return persistRun({
128
+ ...run,
129
+ status: 'waiting',
130
+ waitingReason: `Waiting for A2A delegation: ${config.taskName}`,
131
+ createdTaskIds,
132
+ phaseState: { ...(run.phaseState || { phaseId: phase.id }), dispatchedTaskId: taskId } as ProtocolRunPhaseState,
133
+ updatedAt: now(deps),
134
+ })
135
+ }
@@ -171,6 +171,7 @@ function isDiscussionStepKindLocal(kind: string): boolean {
171
171
  'wait',
172
172
  'dispatch_task',
173
173
  'dispatch_delegation',
174
+ 'a2a_delegate',
174
175
  ].includes(kind)
175
176
  }
176
177
 
@@ -48,7 +48,7 @@ describe('protocol-step-helpers', () => {
48
48
  const kinds = [
49
49
  'present', 'collect_independent_inputs', 'round_robin',
50
50
  'compare', 'decide', 'summarize', 'emit_tasks', 'wait',
51
- 'dispatch_task', 'dispatch_delegation',
51
+ 'dispatch_task', 'dispatch_delegation', 'a2a_delegate',
52
52
  ]
53
53
  for (const kind of kinds) {
54
54
  const step = { id: `step-${kind}`, kind, label: kind } as never
@@ -58,6 +58,7 @@ export function phaseFromStep(step: ProtocolStepDefinition): ProtocolPhaseDefini
58
58
  completionCriteria: step.completionCriteria || null,
59
59
  taskConfig: step.taskConfig || null,
60
60
  delegationConfig: step.delegationConfig || null,
61
+ a2aDelegateConfig: step.a2aDelegateConfig || null,
61
62
  }
62
63
  }
63
64
 
@@ -23,6 +23,7 @@ import type * as ProtocolRunLifecycle from '@/lib/server/protocols/protocol-run-
23
23
  import { processForEachStep } from '@/lib/server/protocols/protocol-foreach'
24
24
  import { processSubflowStep } from '@/lib/server/protocols/protocol-subflow'
25
25
  import { processSwarmStep } from '@/lib/server/protocols/protocol-swarm'
26
+ import { processA2ADelegatePhase } from '@/lib/server/protocols/protocol-a2a-delegate'
26
27
  import { findRunStep } from '@/lib/server/protocols/protocol-normalization'
27
28
  import {
28
29
  appendProtocolEvent,
@@ -708,6 +709,7 @@ export async function stepProtocolRun(run: ProtocolRun, deps?: ProtocolRunDeps):
708
709
  if (phase.kind === 'emit_tasks') return processEmitTasksPhase(started, phase, deps)
709
710
  if (phase.kind === 'dispatch_task') return processDispatchTaskPhase(started, phase, deps)
710
711
  if (phase.kind === 'dispatch_delegation') return processDispatchDelegationPhase(started, phase, deps)
712
+ if (phase.kind === 'a2a_delegate') return processA2ADelegatePhase(started, phase, deps)
711
713
  return processWaitPhase(started, phase, deps)
712
714
  }
713
715
  if (step.kind === 'branch') return processBranchStep(run, step, deps)