@swarmclawai/swarmclaw 1.3.6 → 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 (96) hide show
  1. package/README.md +32 -1
  2. package/package.json +9 -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.ts +12 -7
  74. package/src/lib/server/extensions.ts +11 -0
  75. package/src/lib/server/openclaw/sync.ts +4 -4
  76. package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
  77. package/src/lib/server/protocols/protocol-normalization.ts +1 -0
  78. package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
  79. package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
  80. package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
  81. package/src/lib/server/protocols/protocol-types.ts +1 -0
  82. package/src/lib/server/session-tools/delegate.ts +151 -77
  83. package/src/lib/server/storage-auth.ts +10 -2
  84. package/src/lib/server/storage-normalization.ts +11 -0
  85. package/src/lib/server/storage.ts +100 -0
  86. package/src/lib/server/working-state/service.test.ts +2 -3
  87. package/src/lib/server/working-state/service.ts +37 -6
  88. package/src/lib/swarmfeed-client.ts +157 -0
  89. package/src/lib/validation/schemas.ts +1 -1
  90. package/src/stores/slices/data-slice.ts +3 -0
  91. package/src/stores/use-approval-store.ts +4 -1
  92. package/src/types/agent.ts +31 -1
  93. package/src/types/index.ts +1 -0
  94. package/src/types/protocol.ts +19 -0
  95. package/src/types/session.ts +1 -1
  96. package/src/types/swarmfeed.ts +30 -0
@@ -0,0 +1,114 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
5
+ import type { SwarmFeedPost } from '@/types/swarmfeed'
6
+
7
+ function formatTimestamp(iso: string): string {
8
+ const diff = Date.now() - new Date(iso).getTime()
9
+ const sec = Math.floor(diff / 1000)
10
+ if (sec < 60) return 'just now'
11
+ const min = Math.floor(sec / 60)
12
+ if (min < 60) return `${min}m`
13
+ const hrs = Math.floor(min / 60)
14
+ if (hrs < 24) return `${hrs}h`
15
+ const days = Math.floor(hrs / 24)
16
+ return `${days}d`
17
+ }
18
+
19
+ export function PostCard({ post, onLike, onRepost }: {
20
+ post: SwarmFeedPost
21
+ onLike?: (postId: string) => void
22
+ onRepost?: (postId: string) => void
23
+ }) {
24
+ const [liked, setLiked] = useState(false)
25
+ const [reposted, setReposted] = useState(false)
26
+ const [localLikeCount, setLocalLikeCount] = useState(post.likeCount)
27
+ const [localRepostCount, setLocalRepostCount] = useState(post.repostCount)
28
+
29
+ const handleLike = () => {
30
+ if (!liked) {
31
+ setLiked(true)
32
+ setLocalLikeCount((c) => c + 1)
33
+ onLike?.(post.id)
34
+ }
35
+ }
36
+
37
+ const handleRepost = () => {
38
+ if (!reposted) {
39
+ setReposted(true)
40
+ setLocalRepostCount((c) => c + 1)
41
+ onRepost?.(post.id)
42
+ }
43
+ }
44
+
45
+ return (
46
+ <div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-4 sm:p-5 transition-all hover:bg-surface/90">
47
+ {/* Agent header */}
48
+ <div className="flex items-center gap-3 mb-3">
49
+ <AgentAvatar
50
+ seed={post.agent?.id || post.agentId}
51
+ avatarUrl={post.agent?.avatar || null}
52
+ name={post.agent?.name || 'Agent'}
53
+ size={36}
54
+ />
55
+ <div className="min-w-0 flex-1">
56
+ <div className="flex items-center gap-2">
57
+ <span className="font-display text-[14px] font-600 text-text truncate">
58
+ {post.agent?.name || 'Unknown Agent'}
59
+ </span>
60
+ <span className="text-[12px] text-text-3/60">{formatTimestamp(post.createdAt)}</span>
61
+ </div>
62
+ {post.channelId && (
63
+ <span className="text-[11px] text-accent-bright/70 font-500">#{post.channelId}</span>
64
+ )}
65
+ </div>
66
+ </div>
67
+
68
+ {/* Content */}
69
+ <div className="text-[14px] leading-[1.65] text-text/90 whitespace-pre-wrap break-words mb-4">
70
+ {post.content}
71
+ </div>
72
+
73
+ {/* Engagement bar */}
74
+ <div className="flex items-center gap-5 text-text-3/60">
75
+ <button
76
+ onClick={handleLike}
77
+ className={`flex items-center gap-1.5 text-[12px] font-500 transition-colors bg-transparent border-none cursor-pointer
78
+ ${liked ? 'text-rose-400' : 'text-text-3/60 hover:text-rose-400'}`}
79
+ >
80
+ <svg width="15" height="15" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
81
+ <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
82
+ </svg>
83
+ {localLikeCount > 0 && <span>{localLikeCount}</span>}
84
+ </button>
85
+
86
+ <button
87
+ onClick={handleRepost}
88
+ className={`flex items-center gap-1.5 text-[12px] font-500 transition-colors bg-transparent border-none cursor-pointer
89
+ ${reposted ? 'text-emerald-400' : 'text-text-3/60 hover:text-emerald-400'}`}
90
+ >
91
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
92
+ <polyline points="17 1 21 5 17 9" /><path d="M3 11V9a4 4 0 0 1 4-4h14" />
93
+ <polyline points="7 23 3 19 7 15" /><path d="M21 13v2a4 4 0 0 1-4 4H3" />
94
+ </svg>
95
+ {localRepostCount > 0 && <span>{localRepostCount}</span>}
96
+ </button>
97
+
98
+ <div className="flex items-center gap-1.5 text-[12px] font-500 text-text-3/60">
99
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
100
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
101
+ </svg>
102
+ {post.replyCount > 0 && <span>{post.replyCount}</span>}
103
+ </div>
104
+
105
+ <div className="flex items-center gap-1.5 text-[12px] font-500 text-text-3/60">
106
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
107
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
108
+ </svg>
109
+ {post.bookmarkCount > 0 && <span>{post.bookmarkCount}</span>}
110
+ </div>
111
+ </div>
112
+ </div>
113
+ )
114
+ }
@@ -0,0 +1,28 @@
1
+ import { api } from '@/lib/app/api-client'
2
+ import type { SwarmFeedPost, SwarmFeedChannel, FeedType } from '@/types/swarmfeed'
3
+
4
+ export async function fetchFeed(
5
+ type: FeedType,
6
+ params?: { channelId?: string; cursor?: string; limit?: number },
7
+ ): Promise<{ posts: SwarmFeedPost[]; nextCursor?: string }> {
8
+ const searchParams = new URLSearchParams()
9
+ searchParams.set('type', type)
10
+ if (params?.channelId) searchParams.set('channelId', params.channelId)
11
+ if (params?.cursor) searchParams.set('cursor', params.cursor)
12
+ if (params?.limit) searchParams.set('limit', String(params.limit))
13
+ return api<{ posts: SwarmFeedPost[]; nextCursor?: string }>('GET', `/api/swarmfeed?${searchParams.toString()}`)
14
+ }
15
+
16
+ export async function fetchChannels(): Promise<SwarmFeedChannel[]> {
17
+ const result = await api<{ channels: SwarmFeedChannel[] }>('GET', '/api/swarmfeed/channels')
18
+ return result.channels
19
+ }
20
+
21
+ export async function submitPost(agentId: string, content: string, channelId?: string, parentId?: string): Promise<SwarmFeedPost> {
22
+ return api<SwarmFeedPost>('POST', '/api/swarmfeed/posts', {
23
+ agentId,
24
+ content,
25
+ channelId,
26
+ parentId,
27
+ })
28
+ }
@@ -0,0 +1,61 @@
1
+ import type { Agent } from '@/types/agent'
2
+ import type { AgentCard } from './types'
3
+
4
+ const A2A_PROTOCOL_VERSION = '0.3.0'
5
+ const SWARMCLAW_VERSION = '1.0.0'
6
+
7
+ /**
8
+ * Generate an A2A Agent Card from a SwarmClaw agent.
9
+ * Ref: https://a2a-protocol.org/v0.3.0/specification/#agent-card
10
+ */
11
+ export function generateAgentCard(agent: Agent, baseUrl: string): AgentCard {
12
+ return {
13
+ name: agent.name,
14
+ description: agent.description || `SwarmClaw agent: ${agent.name}`,
15
+ version: SWARMCLAW_VERSION,
16
+ protocolVersion: A2A_PROTOCOL_VERSION,
17
+ apiEndpoint: `${baseUrl}/api/a2a`,
18
+
19
+ capabilities: [
20
+ {
21
+ name: 'task_execution',
22
+ methods: ['executeTask', 'getStatus', 'cancelTask'],
23
+ description: 'Execute tasks and manage task lifecycle',
24
+ },
25
+ {
26
+ name: 'discovery',
27
+ methods: ['discoverAgents'],
28
+ description: 'Discover available A2A agents',
29
+ },
30
+ ...(agent.delegationEnabled ? [{
31
+ name: 'delegation',
32
+ methods: ['executeTask'],
33
+ description: 'Delegate work to other agents',
34
+ }] : []),
35
+ ],
36
+
37
+ skills: (agent.capabilities ?? []).map(cap => ({
38
+ name: cap,
39
+ description: `Agent capability: ${cap}`,
40
+ })),
41
+
42
+ authMethods: ['api_key'],
43
+ supportsStreaming: true,
44
+ supportsAsync: true,
45
+
46
+ rateLimit: {
47
+ requestsPerMinute: 60,
48
+ maxConcurrentRequests: 10,
49
+ },
50
+
51
+ extensions: [{
52
+ name: 'swarmclaw',
53
+ version: SWARMCLAW_VERSION,
54
+ }],
55
+
56
+ tags: [
57
+ ...(agent.capabilities ?? []),
58
+ 'swarmclaw',
59
+ ],
60
+ }
61
+ }
@@ -0,0 +1,54 @@
1
+ import { validateAccessKey } from '@/lib/server/storage-auth'
2
+
3
+ export interface A2AAuthResult {
4
+ valid: boolean
5
+ agentId: string | null
6
+ error?: string
7
+ }
8
+
9
+ /**
10
+ * Validate an inbound A2A request using the SwarmClaw access key.
11
+ * Checks `Authorization: Bearer <key>` or `x-a2a-access-key` header.
12
+ */
13
+ export function validateA2ARequest(req: Request): A2AAuthResult {
14
+ const authHeader = req.headers.get('authorization')
15
+ const a2aKeyHeader = req.headers.get('x-a2a-access-key')
16
+
17
+ let key: string | null = null
18
+ if (authHeader?.startsWith('Bearer ')) {
19
+ key = authHeader.slice(7)
20
+ } else if (a2aKeyHeader) {
21
+ key = a2aKeyHeader
22
+ }
23
+
24
+ if (!key) {
25
+ return { valid: false, agentId: null, error: 'Missing authentication — provide Authorization: Bearer <key> or x-a2a-access-key header' }
26
+ }
27
+
28
+ if (!validateAccessKey(key)) {
29
+ return { valid: false, agentId: null, error: 'Invalid access key' }
30
+ }
31
+
32
+ const agentId = req.headers.get('x-a2a-agent-id')
33
+ return { valid: true, agentId }
34
+ }
35
+
36
+ /**
37
+ * Extract A2A-specific headers from an inbound request.
38
+ */
39
+ export function extractA2AHeaders(req: Request): { targetAgentId: string | null; requesterAgentId: string | null } {
40
+ return {
41
+ targetAgentId: req.headers.get('x-a2a-target-agent-id'),
42
+ requesterAgentId: req.headers.get('x-a2a-agent-id'),
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Build auth headers for outbound A2A requests to remote agents.
48
+ */
49
+ export function buildA2AAuthHeaders(accessKey: string): Record<string, string> {
50
+ return {
51
+ 'Content-Type': 'application/json',
52
+ 'Authorization': `Bearer ${accessKey}`,
53
+ }
54
+ }
@@ -0,0 +1,133 @@
1
+ import { genId } from '@/lib/id'
2
+ import { log } from '@/lib/server/logger'
3
+ import { errorMessage } from '@/lib/shared-utils'
4
+ import { resolveCredentialSecret } from '@/lib/server/credentials/credential-service'
5
+ import { buildA2AAuthHeaders } from './auth'
6
+ import type { JsonRpcRequest, JsonRpcResponse, A2AClientOptions } from './types'
7
+
8
+ const TAG = 'a2a-client'
9
+
10
+ /**
11
+ * Call a remote A2A agent via JSON-RPC 2.0.
12
+ */
13
+ export async function callA2AAgent<T = unknown>(
14
+ agentUrl: string,
15
+ method: string,
16
+ params: Record<string, unknown>,
17
+ options: A2AClientOptions = {},
18
+ ): Promise<T> {
19
+ const { timeout = 30_000, credentialId, retryAttempts = 3 } = options
20
+
21
+ const accessKey = credentialId ? resolveCredentialSecret(credentialId) : null
22
+ const headers = accessKey ? buildA2AAuthHeaders(accessKey) : { 'Content-Type': 'application/json' }
23
+
24
+ const rpcRequest: JsonRpcRequest = {
25
+ jsonrpc: '2.0',
26
+ method,
27
+ params,
28
+ id: genId(8),
29
+ }
30
+
31
+ let lastError: Error | null = null
32
+
33
+ for (let attempt = 0; attempt < retryAttempts; attempt++) {
34
+ try {
35
+ const controller = new AbortController()
36
+ const timeoutId = setTimeout(() => controller.abort(), timeout)
37
+
38
+ const response = await fetch(agentUrl, {
39
+ method: 'POST',
40
+ headers,
41
+ body: JSON.stringify(rpcRequest),
42
+ signal: controller.signal,
43
+ })
44
+
45
+ clearTimeout(timeoutId)
46
+
47
+ const data = (await response.json()) as JsonRpcResponse<T>
48
+
49
+ if (data.error) {
50
+ throw new Error(`A2A RPC error (${data.error.code}): ${data.error.message}`)
51
+ }
52
+
53
+ return data.result as T
54
+ } catch (err) {
55
+ lastError = err instanceof Error ? err : new Error(errorMessage(err))
56
+
57
+ if (lastError.name === 'AbortError') {
58
+ throw new Error(`A2A request to ${agentUrl} timed out after ${timeout}ms`)
59
+ }
60
+
61
+ if (attempt < retryAttempts - 1) {
62
+ const backoff = Math.pow(2, attempt) * 1000
63
+ log.warn(TAG, `Attempt ${attempt + 1} failed for ${agentUrl}, retrying in ${backoff}ms: ${errorMessage(err)}`)
64
+ await new Promise(resolve => setTimeout(resolve, backoff))
65
+ }
66
+ }
67
+ }
68
+
69
+ throw lastError ?? new Error(`A2A request to ${agentUrl} failed after ${retryAttempts} attempts`)
70
+ }
71
+
72
+ /**
73
+ * Stream A2A responses using Server-Sent Events.
74
+ */
75
+ export async function* streamA2AResponse(
76
+ agentUrl: string,
77
+ method: string,
78
+ params: Record<string, unknown>,
79
+ options: A2AClientOptions = {},
80
+ ): AsyncGenerator<unknown> {
81
+ const { credentialId } = options
82
+ const accessKey = credentialId ? resolveCredentialSecret(credentialId) : null
83
+ const headers: Record<string, string> = {
84
+ ...(accessKey ? buildA2AAuthHeaders(accessKey) : { 'Content-Type': 'application/json' }),
85
+ 'Accept': 'text/event-stream',
86
+ }
87
+
88
+ const rpcRequest: JsonRpcRequest = {
89
+ jsonrpc: '2.0',
90
+ method,
91
+ params,
92
+ id: genId(8),
93
+ }
94
+
95
+ const response = await fetch(agentUrl, {
96
+ method: 'POST',
97
+ headers,
98
+ body: JSON.stringify(rpcRequest),
99
+ })
100
+
101
+ if (!response.ok) {
102
+ throw new Error(`A2A streaming request failed: ${response.status} ${response.statusText}`)
103
+ }
104
+
105
+ const reader = response.body?.getReader()
106
+ if (!reader) throw new Error('No response body for A2A stream')
107
+
108
+ const decoder = new TextDecoder()
109
+ let buffer = ''
110
+
111
+ try {
112
+ while (true) {
113
+ const { done, value } = await reader.read()
114
+ if (done) break
115
+
116
+ buffer += decoder.decode(value, { stream: true })
117
+ const lines = buffer.split('\n')
118
+ buffer = lines.pop() ?? ''
119
+
120
+ for (const line of lines) {
121
+ if (line.startsWith('data: ')) {
122
+ try {
123
+ yield JSON.parse(line.slice(6)) as unknown
124
+ } catch {
125
+ log.warn(TAG, `Failed to parse SSE data: ${line.slice(0, 200)}`)
126
+ }
127
+ }
128
+ }
129
+ }
130
+ } finally {
131
+ reader.releaseLock()
132
+ }
133
+ }
@@ -0,0 +1,116 @@
1
+ import { genId } from '@/lib/id'
2
+ import { log } from '@/lib/server/logger'
3
+ import { errorMessage, hmrSingleton } from '@/lib/shared-utils'
4
+ import { loadExternalAgents, saveExternalAgents } from '@/lib/server/storage'
5
+ import { notify } from '@/lib/server/ws-hub'
6
+ import { AgentCardSchema } from './types'
7
+ import type { AgentCard } from './types'
8
+ import type { ExternalAgentRuntime } from '@/types/agent'
9
+
10
+ const TAG = 'a2a-discovery'
11
+
12
+ // TTL cache for fetched agent cards (5 minutes)
13
+ interface CacheEntry { card: AgentCard; fetchedAt: number }
14
+ const cache = hmrSingleton('a2a_discovery_cache', () => new Map<string, CacheEntry>())
15
+ const CACHE_TTL_MS = 5 * 60 * 1000
16
+
17
+ /**
18
+ * Discover an A2A agent by fetching its agent card from the well-known endpoint.
19
+ * Results are cached for 5 minutes.
20
+ */
21
+ export async function discoverA2AAgent(url: string): Promise<AgentCard> {
22
+ const cached = cache.get(url)
23
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
24
+ return cached.card
25
+ }
26
+
27
+ const cardUrl = url.endsWith('/') ? `${url}.well-known/agent-card.json` : `${url}/.well-known/agent-card.json`
28
+
29
+ log.info(TAG, `Discovering A2A agent at ${cardUrl}`)
30
+
31
+ const response = await fetch(cardUrl)
32
+ if (!response.ok) {
33
+ throw new Error(`Failed to fetch agent card from ${cardUrl}: ${response.status} ${response.statusText}`)
34
+ }
35
+
36
+ const raw = await response.json()
37
+ const parsed = AgentCardSchema.safeParse(raw)
38
+ if (!parsed.success) {
39
+ throw new Error(`Invalid agent card from ${cardUrl}: ${parsed.error.message}`)
40
+ }
41
+
42
+ const card = parsed.data
43
+ cache.set(url, { card, fetchedAt: Date.now() })
44
+
45
+ return card
46
+ }
47
+
48
+ /**
49
+ * Register a discovered A2A agent in the ExternalAgentRuntime registry.
50
+ * Creates or updates the entry.
51
+ */
52
+ export function registerDiscoveredA2AAgent(card: AgentCard, endpoint: string): ExternalAgentRuntime {
53
+ const agents = loadExternalAgents()
54
+ const nowMs = Date.now()
55
+
56
+ // Check if already registered by endpoint
57
+ const existing = Object.values(agents).find(a => a.sourceType === 'a2a' && a.endpoint === endpoint)
58
+
59
+ const runtime: ExternalAgentRuntime = {
60
+ id: existing?.id ?? genId(8),
61
+ name: card.name,
62
+ sourceType: 'a2a',
63
+ status: 'online',
64
+ transport: 'http',
65
+ endpoint,
66
+ capabilities: card.capabilities.map(c => c.name),
67
+ version: card.version,
68
+ a2aCard: {
69
+ protocolVersion: card.protocolVersion,
70
+ apiEndpoint: card.apiEndpoint,
71
+ capabilities: card.capabilities,
72
+ supportsStreaming: card.supportsStreaming,
73
+ supportsAsync: card.supportsAsync,
74
+ },
75
+ lastSeenAt: nowMs,
76
+ lastHeartbeatAt: nowMs,
77
+ createdAt: existing?.createdAt ?? nowMs,
78
+ updatedAt: nowMs,
79
+ }
80
+
81
+ agents[runtime.id] = runtime
82
+ saveExternalAgents(agents)
83
+ notify('external_agents')
84
+
85
+ log.info(TAG, `Registered A2A agent: ${card.name} (${runtime.id}) at ${endpoint}`)
86
+
87
+ return runtime
88
+ }
89
+
90
+ /**
91
+ * Discover and register an A2A agent in one step.
92
+ */
93
+ export async function discoverAndRegisterA2AAgent(url: string): Promise<ExternalAgentRuntime> {
94
+ try {
95
+ const card = await discoverA2AAgent(url)
96
+ return registerDiscoveredA2AAgent(card, card.apiEndpoint || url)
97
+ } catch (err) {
98
+ log.error(TAG, `Failed to discover A2A agent at ${url}: ${errorMessage(err)}`)
99
+ throw err
100
+ }
101
+ }
102
+
103
+ /**
104
+ * List all known A2A external agents.
105
+ */
106
+ export function listA2AAgents(): ExternalAgentRuntime[] {
107
+ const agents = loadExternalAgents()
108
+ return Object.values(agents).filter(a => a.sourceType === 'a2a')
109
+ }
110
+
111
+ /**
112
+ * Clear the discovery cache.
113
+ */
114
+ export function clearDiscoveryCache(): void {
115
+ cache.clear()
116
+ }
@@ -0,0 +1,176 @@
1
+ import { genId } from '@/lib/id'
2
+ import { log } from '@/lib/server/logger'
3
+ import { getAgent, listAgents } from '@/lib/server/agents/agent-repository'
4
+ import { saveSession } from '@/lib/server/sessions/session-repository'
5
+ import { upsertTask } from '@/lib/server/tasks/task-repository'
6
+ import { enqueueTask } from '@/lib/server/runtime/queue'
7
+ import { loadTasks, loadTask } from '@/lib/server/tasks/task-repository'
8
+ import { notify } from '@/lib/server/ws-hub'
9
+ import { a2aRouter } from './json-rpc-router'
10
+ import type { A2AContext, A2ATaskStatus } from './types'
11
+ import type { Session } from '@/types/session'
12
+ import type { BoardTask, BoardTaskStatus } from '@/types/task'
13
+ import { loadExternalAgents } from '@/lib/server/storage'
14
+
15
+ const TAG = 'a2a-handlers'
16
+
17
+ // --- Status mapping ---
18
+
19
+ function mapTaskStatus(status: BoardTaskStatus): A2ATaskStatus {
20
+ switch (status) {
21
+ case 'queued': case 'backlog': return 'submitted'
22
+ case 'running': return 'working'
23
+ case 'completed': return 'completed'
24
+ case 'failed': return 'failed'
25
+ case 'cancelled': case 'archived': case 'deferred': return 'cancelled'
26
+ default: return 'submitted'
27
+ }
28
+ }
29
+
30
+ function findTaskByA2AId(a2aTaskId: string): BoardTask | null {
31
+ // Try direct lookup first (if the A2A taskId IS the SwarmClaw task ID)
32
+ const direct = loadTask(a2aTaskId)
33
+ if (direct) return direct
34
+
35
+ // Search by externalSource.id
36
+ const allTasks = loadTasks()
37
+ for (const task of Object.values(allTasks)) {
38
+ if (task.externalSource?.source === 'a2a' && task.externalSource?.id === a2aTaskId) {
39
+ return task
40
+ }
41
+ }
42
+ return null
43
+ }
44
+
45
+ // --- executeTask ---
46
+
47
+ a2aRouter.register('executeTask', async (params: Record<string, unknown>, context: A2AContext) => {
48
+ const taskId = (params.taskId as string) || genId(8)
49
+ const taskName = (params.taskName as string) || 'A2A Task'
50
+ const taskMessage = (params.message as string) || (params.description as string) || ''
51
+ const agentId = context.agentId || (params.agentId as string)
52
+
53
+ if (!agentId) {
54
+ throw new Error('No target agentId specified — provide x-a2a-target-agent-id header or agentId in params')
55
+ }
56
+
57
+ const agent = getAgent(agentId)
58
+ if (!agent) {
59
+ throw new Error(`Agent "${agentId}" not found`)
60
+ }
61
+
62
+ // Create a session for this A2A task
63
+ const sessionId = genId(8)
64
+ const nowMs = Date.now()
65
+ const session: Session = {
66
+ id: sessionId,
67
+ name: `A2A: ${taskName}`,
68
+ cwd: process.cwd(),
69
+ user: `a2a:${context.requesterId}`,
70
+ provider: agent.provider,
71
+ model: agent.model,
72
+ credentialId: agent.credentialId ?? null,
73
+ claudeSessionId: null,
74
+ messages: [],
75
+ createdAt: nowMs,
76
+ lastActiveAt: nowMs,
77
+ agentId: agent.id,
78
+ tools: agent.tools,
79
+ extensions: agent.extensions,
80
+ }
81
+ saveSession(sessionId, session)
82
+
83
+ // Create a BoardTask for tracking
84
+ const boardTaskId = genId()
85
+ const boardTask: BoardTask = {
86
+ id: boardTaskId,
87
+ title: taskName,
88
+ description: taskMessage,
89
+ status: 'queued',
90
+ agentId: agent.id,
91
+ sessionId,
92
+ externalSource: { source: 'a2a', id: taskId },
93
+ queuedAt: nowMs,
94
+ createdAt: nowMs,
95
+ updatedAt: nowMs,
96
+ }
97
+ upsertTask(boardTaskId, boardTask)
98
+ enqueueTask(boardTaskId)
99
+ notify('tasks')
100
+
101
+ log.info(TAG, `executeTask: created task ${boardTaskId} for agent ${agentId}`, { a2aTaskId: taskId, sessionId })
102
+
103
+ return {
104
+ taskId,
105
+ boardTaskId,
106
+ sessionId,
107
+ status: 'submitted' as const,
108
+ progressUrl: `/api/a2a/tasks/${boardTaskId}/status`,
109
+ }
110
+ })
111
+
112
+ // --- getStatus ---
113
+
114
+ a2aRouter.register('getStatus', async (params: Record<string, unknown>) => {
115
+ const taskId = params.taskId as string
116
+ if (!taskId) throw new Error('taskId is required')
117
+
118
+ const task = findTaskByA2AId(taskId)
119
+ if (!task) throw new Error(`Task "${taskId}" not found`)
120
+
121
+ return {
122
+ taskId,
123
+ boardTaskId: task.id,
124
+ sessionId: task.sessionId ?? null,
125
+ status: mapTaskStatus(task.status),
126
+ title: task.title,
127
+ result: task.status === 'completed' ? (task.result ?? null) : null,
128
+ error: task.status === 'failed' ? (task.error ?? null) : null,
129
+ updatedAt: task.updatedAt,
130
+ }
131
+ })
132
+
133
+ // --- cancelTask ---
134
+
135
+ a2aRouter.register('cancelTask', async (params: Record<string, unknown>) => {
136
+ const taskId = params.taskId as string
137
+ if (!taskId) throw new Error('taskId is required')
138
+
139
+ const task = findTaskByA2AId(taskId)
140
+ if (!task) throw new Error(`Task "${taskId}" not found`)
141
+
142
+ upsertTask(task.id, { ...task, status: 'cancelled', updatedAt: Date.now() })
143
+ notify('tasks')
144
+
145
+ log.info(TAG, `cancelTask: cancelled task ${task.id}`, { a2aTaskId: taskId })
146
+
147
+ return { taskId, status: 'cancelled' as const }
148
+ })
149
+
150
+ // --- discoverAgents ---
151
+
152
+ a2aRouter.register('discoverAgents', async () => {
153
+ const agents = listAgents()
154
+ const localAgents = Object.values(agents)
155
+ .filter(a => !a.disabled)
156
+ .map(a => ({
157
+ id: a.id,
158
+ name: a.name,
159
+ description: a.description,
160
+ capabilities: a.capabilities ?? [],
161
+ source: 'local' as const,
162
+ }))
163
+
164
+ // Include discovered A2A external agents
165
+ const externalAgents = Object.values(loadExternalAgents())
166
+ .filter(ea => ea.sourceType === 'a2a' && ea.status !== 'offline')
167
+ .map(ea => ({
168
+ id: ea.id,
169
+ name: ea.name,
170
+ description: ea.a2aCard?.apiEndpoint ?? ea.endpoint ?? '',
171
+ capabilities: ea.capabilities ?? [],
172
+ source: 'a2a' as const,
173
+ }))
174
+
175
+ return { agents: [...localAgents, ...externalAgents] }
176
+ })