@swarmclawai/swarmclaw 1.4.0 → 1.4.3
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 +13 -71
- package/next.config.ts +9 -4
- package/package.json +10 -8
- package/scripts/build-bootstrap-env.mjs +24 -0
- package/scripts/run-next-build.mjs +120 -0
- package/scripts/run-next-typegen.mjs +61 -0
- package/src/app/api/approvals/route.test.ts +29 -3
- package/src/app/api/approvals/route.ts +13 -7
- package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
- package/src/app/api/chats/[id]/chat/route.ts +24 -8
- package/src/app/api/chats/chat-route.test.ts +68 -0
- package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
- package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
- package/src/app/api/logs/route.test.ts +61 -0
- package/src/app/api/logs/route.ts +35 -0
- package/src/app/api/swarmdock/route.ts +25 -0
- package/src/app/api/swarmfeed/posts/route.ts +44 -6
- package/src/app/api/swarmfeed/route.ts +4 -0
- package/src/app/api/tts/route.test.ts +82 -0
- package/src/app/api/tts/route.ts +13 -6
- package/src/app/api/tts/stream/route.ts +12 -5
- package/src/app/error.tsx +32 -0
- package/src/app/global-error.tsx +33 -0
- package/src/app/marketplace/page.tsx +7 -0
- package/src/cli/index.js +10 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-sheet.tsx +10 -0
- package/src/components/layout/error-boundary.tsx +12 -30
- package/src/components/layout/error-fallback.tsx +61 -0
- package/src/components/layout/sidebar-rail.tsx +5 -0
- package/src/features/swarmdock/agent-marketplace-settings.tsx +303 -0
- package/src/features/swarmdock/marketplace-page.tsx +189 -0
- package/src/features/swarmfeed/feed-page.tsx +3 -33
- package/src/features/swarmfeed/queries.ts +3 -3
- package/src/lib/app/navigation.ts +1 -0
- package/src/lib/app/report-client-error.ts +52 -0
- package/src/lib/app/view-constants.ts +9 -1
- package/src/lib/providers/anthropic.ts +9 -1
- package/src/lib/providers/ollama.ts +34 -14
- package/src/lib/providers/openai.ts +9 -1
- package/src/lib/providers/openclaw.ts +3 -3
- package/src/lib/server/agents/agent-service.ts +18 -0
- package/src/lib/server/agents/agent-swarm-registration.ts +35 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
- package/src/lib/server/connectors/swarmdock.ts +29 -7
- package/src/lib/server/messages/message-repository.ts +31 -0
- package/src/lib/server/provider-health.ts +19 -3
- package/src/lib/server/safe-parse-body.test.ts +32 -0
- package/src/lib/server/safe-parse-body.ts +20 -3
- package/src/lib/server/session-tools/index.ts +4 -0
- package/src/lib/server/session-tools/swarmdock.ts +104 -0
- package/src/lib/server/session-tools/swarmfeed.ts +150 -0
- package/src/lib/server/storage-normalization.ts +10 -0
- package/src/lib/server/storage.ts +13 -4
- package/src/lib/swarmfeed-client.ts +1 -1
- package/src/lib/tool-definitions.ts +2 -0
- package/src/types/agent.ts +23 -0
- package/src/types/session.ts +1 -1
- package/tsconfig.json +1 -2
- package/src/.env.local +0 -4
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { useCallback, useEffect, useState } from 'react'
|
|
4
4
|
import { fetchFeed } from './queries'
|
|
5
5
|
import { PostCard } from './post-card'
|
|
6
|
-
import { ComposePost } from './compose-post'
|
|
7
6
|
import { MainContent } from '@/components/layout/main-content'
|
|
8
7
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
9
8
|
import type { SwarmFeedPost, FeedType } from '@/types/swarmfeed'
|
|
@@ -19,8 +18,6 @@ export function FeedPage() {
|
|
|
19
18
|
const [posts, setPosts] = useState<SwarmFeedPost[]>([])
|
|
20
19
|
const [loading, setLoading] = useState(true)
|
|
21
20
|
const [error, setError] = useState<string | null>(null)
|
|
22
|
-
const [showCompose, setShowCompose] = useState(false)
|
|
23
|
-
|
|
24
21
|
const loadFeed = useCallback(async (type: FeedType) => {
|
|
25
22
|
setLoading(true)
|
|
26
23
|
setError(null)
|
|
@@ -44,42 +41,15 @@ export function FeedPage() {
|
|
|
44
41
|
setActiveTab(tab)
|
|
45
42
|
}
|
|
46
43
|
|
|
47
|
-
const handlePostCreated = (post: SwarmFeedPost) => {
|
|
48
|
-
setPosts((prev) => [post, ...prev])
|
|
49
|
-
setShowCompose(false)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
44
|
return (
|
|
53
45
|
<MainContent>
|
|
54
46
|
<div className="flex-1 overflow-y-auto overscroll-contain">
|
|
55
47
|
<div className="mx-auto max-w-2xl px-4 sm:px-6 py-8">
|
|
56
|
-
<div className="
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
<p className="mt-1 text-[13px] text-text-3/75">Social updates from your AI agents</p>
|
|
60
|
-
</div>
|
|
61
|
-
<button
|
|
62
|
-
onClick={() => setShowCompose((c) => !c)}
|
|
63
|
-
className="px-4 py-2 rounded-[12px] bg-accent-bright text-white text-[13px] font-600 transition-all
|
|
64
|
-
hover:bg-accent-bright/90 border-none cursor-pointer flex items-center gap-2"
|
|
65
|
-
>
|
|
66
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
67
|
-
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
68
|
-
</svg>
|
|
69
|
-
Compose
|
|
70
|
-
</button>
|
|
48
|
+
<div className="mb-6">
|
|
49
|
+
<h1 className="font-display text-[22px] font-700 tracking-[-0.02em] text-text">Feed</h1>
|
|
50
|
+
<p className="mt-1 text-[13px] text-text-3/75">Social updates from your AI agents</p>
|
|
71
51
|
</div>
|
|
72
52
|
|
|
73
|
-
{/* Compose area */}
|
|
74
|
-
{showCompose && (
|
|
75
|
-
<div className="mb-6">
|
|
76
|
-
<ComposePost
|
|
77
|
-
onPostCreated={handlePostCreated}
|
|
78
|
-
onClose={() => setShowCompose(false)}
|
|
79
|
-
/>
|
|
80
|
-
</div>
|
|
81
|
-
)}
|
|
82
|
-
|
|
83
53
|
{/* Tab bar */}
|
|
84
54
|
<div className="flex gap-1 mb-6 rounded-[14px] border border-white/[0.06] bg-surface/50 p-1">
|
|
85
55
|
{FEED_TABS.map((tab) => (
|
|
@@ -10,16 +10,16 @@ export async function fetchFeed(
|
|
|
10
10
|
if (params?.channelId) searchParams.set('channelId', params.channelId)
|
|
11
11
|
if (params?.cursor) searchParams.set('cursor', params.cursor)
|
|
12
12
|
if (params?.limit) searchParams.set('limit', String(params.limit))
|
|
13
|
-
return api<{ posts: SwarmFeedPost[]; nextCursor?: string }>('GET', `/
|
|
13
|
+
return api<{ posts: SwarmFeedPost[]; nextCursor?: string }>('GET', `/swarmfeed?${searchParams.toString()}`)
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export async function fetchChannels(): Promise<SwarmFeedChannel[]> {
|
|
17
|
-
const result = await api<{ channels: SwarmFeedChannel[] }>('GET', '/
|
|
17
|
+
const result = await api<{ channels: SwarmFeedChannel[] }>('GET', '/swarmfeed/channels')
|
|
18
18
|
return result.channels
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function submitPost(agentId: string, content: string, channelId?: string, parentId?: string): Promise<SwarmFeedPost> {
|
|
22
|
-
return api<SwarmFeedPost>('POST', '/
|
|
22
|
+
return api<SwarmFeedPost>('POST', '/swarmfeed/posts', {
|
|
23
23
|
agentId,
|
|
24
24
|
content,
|
|
25
25
|
channelId,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
type ReportClientErrorInput = {
|
|
4
|
+
source: string
|
|
5
|
+
error: Error | string | unknown
|
|
6
|
+
componentStack?: string | null
|
|
7
|
+
digest?: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const reportedClientErrors = new Set<string>()
|
|
11
|
+
|
|
12
|
+
function truncate(value: string | null | undefined, max: number): string | undefined {
|
|
13
|
+
if (!value) return undefined
|
|
14
|
+
return value.length > max ? value.slice(0, max) : value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function reportClientError(input: ReportClientErrorInput) {
|
|
18
|
+
if (typeof window === 'undefined') return
|
|
19
|
+
|
|
20
|
+
const message = input.error instanceof Error
|
|
21
|
+
? input.error.message
|
|
22
|
+
: typeof input.error === 'string'
|
|
23
|
+
? input.error
|
|
24
|
+
: String(input.error)
|
|
25
|
+
|
|
26
|
+
const stack = input.error instanceof Error ? input.error.stack : undefined
|
|
27
|
+
const fingerprint = [
|
|
28
|
+
input.source,
|
|
29
|
+
message,
|
|
30
|
+
input.digest || '',
|
|
31
|
+
input.componentStack || '',
|
|
32
|
+
].join('|')
|
|
33
|
+
|
|
34
|
+
if (reportedClientErrors.has(fingerprint)) return
|
|
35
|
+
reportedClientErrors.add(fingerprint)
|
|
36
|
+
|
|
37
|
+
void fetch('/api/logs', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
source: input.source,
|
|
42
|
+
message: truncate(message, 1000),
|
|
43
|
+
stack: truncate(stack, 8000),
|
|
44
|
+
componentStack: truncate(input.componentStack, 8000),
|
|
45
|
+
digest: truncate(input.digest, 200),
|
|
46
|
+
url: truncate(window.location.href, 2000),
|
|
47
|
+
pathname: truncate(window.location.pathname, 1000),
|
|
48
|
+
userAgent: truncate(window.navigator.userAgent, 1000),
|
|
49
|
+
}),
|
|
50
|
+
keepalive: true,
|
|
51
|
+
}).catch(() => {})
|
|
52
|
+
}
|
|
@@ -28,6 +28,7 @@ export const VIEW_LABELS: Record<AppView, string> = {
|
|
|
28
28
|
projects: 'Projects',
|
|
29
29
|
activity: 'Activity',
|
|
30
30
|
swarmfeed: 'Feed',
|
|
31
|
+
marketplace: 'Marketplace',
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export const CREATE_LABELS: Partial<Record<AppView, string>> = {
|
|
@@ -73,6 +74,7 @@ export const VIEW_DESCRIPTIONS: Record<AppView, string> = {
|
|
|
73
74
|
projects: 'Group agents, tasks & schedules into projects',
|
|
74
75
|
activity: 'Audit trail of all entity mutations',
|
|
75
76
|
swarmfeed: 'Social feed for AI agents to post, follow, and engage',
|
|
77
|
+
marketplace: 'AI agent marketplace — browse tasks, agents, and skills on SwarmDock',
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { icon: string; title: string; description: string; features: string[] }> = {
|
|
@@ -221,10 +223,16 @@ export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { ic
|
|
|
221
223
|
description: 'A social feed where your AI agents post updates, follow each other, and engage with content.',
|
|
222
224
|
features: ['Agents post status updates and insights', 'Follow agents and browse trending content', 'Channel-based topic organization', 'Like, repost, and reply interactions'],
|
|
223
225
|
},
|
|
226
|
+
marketplace: {
|
|
227
|
+
icon: 'store',
|
|
228
|
+
title: 'Marketplace',
|
|
229
|
+
description: 'Browse the SwarmDock agent marketplace — discover tasks, agents, and skills.',
|
|
230
|
+
features: ['Browse available tasks and bid on work', 'View registered agents and their skills', 'Track task status and completions', 'USDC-based payments on Base L2'],
|
|
231
|
+
},
|
|
224
232
|
}
|
|
225
233
|
|
|
226
234
|
export const FULL_WIDTH_VIEWS = new Set<AppView>([
|
|
227
235
|
'home', 'org_chart', 'inbox', 'chatrooms', 'protocols', 'schedules', 'secrets', 'wallets', 'providers', 'skills',
|
|
228
236
|
'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'extensions',
|
|
229
|
-
'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects', 'swarmfeed',
|
|
237
|
+
'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects', 'swarmfeed', 'marketplace',
|
|
230
238
|
])
|
|
@@ -90,6 +90,7 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
let buf = ''
|
|
93
|
+
let malformedChunkLogged = false
|
|
93
94
|
apiRes.on('data', (chunk: Buffer) => {
|
|
94
95
|
if (abortController.aborted) return
|
|
95
96
|
buf += chunk.toString()
|
|
@@ -112,7 +113,14 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
112
113
|
if (parsed.type === 'message_delta' && parsed.usage) {
|
|
113
114
|
usageOutput = parsed.usage.output_tokens || 0
|
|
114
115
|
}
|
|
115
|
-
} catch {
|
|
116
|
+
} catch {
|
|
117
|
+
if (!malformedChunkLogged) {
|
|
118
|
+
malformedChunkLogged = true
|
|
119
|
+
log.warn(TAG, `[${session.id}] failed to parse Anthropic stream chunk`, {
|
|
120
|
+
sample: data.slice(0, 200),
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
116
124
|
}
|
|
117
125
|
})
|
|
118
126
|
|
|
@@ -2,6 +2,7 @@ import fs from 'fs'
|
|
|
2
2
|
import http from 'http'
|
|
3
3
|
import https from 'https'
|
|
4
4
|
import type { StreamChatOptions } from './index'
|
|
5
|
+
import { streamOpenAiChat } from './openai'
|
|
5
6
|
import { IMAGE_EXTS, TEXT_EXTS, MAX_HISTORY_MESSAGES, writeSSE } from './provider-defaults'
|
|
6
7
|
import { log } from '@/lib/server/logger'
|
|
7
8
|
import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
|
|
@@ -9,23 +10,34 @@ import { resolveImagePath } from '@/lib/server/resolve-image'
|
|
|
9
10
|
|
|
10
11
|
const TAG = 'provider-ollama'
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/** Ollama Cloud uses the OpenAI-compatible /v1 endpoint, not the native /api/chat protocol. */
|
|
14
|
+
const OLLAMA_CLOUD_OPENAI_ENDPOINT = 'https://ollama.com/v1'
|
|
15
|
+
|
|
16
|
+
export function streamOllamaChat(opts: StreamChatOptions): Promise<string> {
|
|
17
|
+
const { session, apiKey, write, active } = opts
|
|
18
|
+
const runtime = resolveOllamaRuntimeConfig({
|
|
19
|
+
model: session.model,
|
|
20
|
+
ollamaMode: session.ollamaMode,
|
|
21
|
+
apiKey,
|
|
22
|
+
apiEndpoint: session.apiEndpoint,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if (runtime.useCloud) {
|
|
26
|
+
if (!runtime.apiKey) {
|
|
27
|
+
writeSSE(write, 'err', 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
|
|
28
|
+
active.delete(session.id)
|
|
29
|
+
return Promise.resolve('')
|
|
30
|
+
}
|
|
31
|
+
// Delegate to OpenAI-compatible handler with the cloud endpoint
|
|
32
|
+
const cloudSession = { ...session, model: runtime.model || 'llama3', apiEndpoint: OLLAMA_CLOUD_OPENAI_ENDPOINT }
|
|
33
|
+
return streamOpenAiChat({ ...opts, session: cloudSession, apiKey: runtime.apiKey })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { message, imagePath, loadHistory, onUsage, signal } = opts
|
|
13
37
|
return new Promise((resolve, reject) => {
|
|
14
38
|
const messages = buildMessages(session, message, imagePath, loadHistory)
|
|
15
|
-
const runtime = resolveOllamaRuntimeConfig({
|
|
16
|
-
model: session.model,
|
|
17
|
-
ollamaMode: session.ollamaMode,
|
|
18
|
-
apiKey,
|
|
19
|
-
apiEndpoint: session.apiEndpoint,
|
|
20
|
-
})
|
|
21
39
|
const model = runtime.model || 'llama3'
|
|
22
40
|
const endpoint = runtime.endpoint
|
|
23
|
-
if (runtime.useCloud && !runtime.apiKey) {
|
|
24
|
-
writeSSE(write, 'err', 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
|
|
25
|
-
active.delete(session.id)
|
|
26
|
-
resolve('')
|
|
27
|
-
return
|
|
28
|
-
}
|
|
29
41
|
|
|
30
42
|
const parsed = new URL(endpoint)
|
|
31
43
|
const isHttps = parsed.protocol === 'https:'
|
|
@@ -81,6 +93,7 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
|
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
let buf = ''
|
|
96
|
+
let malformedChunkLogged = false
|
|
84
97
|
apiRes.on('data', (chunk: Buffer) => {
|
|
85
98
|
if (abortController.aborted) return
|
|
86
99
|
buf += chunk.toString()
|
|
@@ -103,7 +116,14 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
|
|
|
103
116
|
onUsage({ inputTokens: input, outputTokens: output })
|
|
104
117
|
}
|
|
105
118
|
}
|
|
106
|
-
} catch {
|
|
119
|
+
} catch {
|
|
120
|
+
if (!malformedChunkLogged) {
|
|
121
|
+
malformedChunkLogged = true
|
|
122
|
+
log.warn(TAG, `[${session.id}] failed to parse Ollama stream chunk`, {
|
|
123
|
+
sample: line.slice(0, 200),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
107
127
|
}
|
|
108
128
|
})
|
|
109
129
|
|
|
@@ -147,6 +147,7 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
|
|
|
147
147
|
const reader = res.body.getReader()
|
|
148
148
|
const decoder = new TextDecoder()
|
|
149
149
|
let buf = ''
|
|
150
|
+
let malformedChunkLogged = false
|
|
150
151
|
|
|
151
152
|
while (true) {
|
|
152
153
|
const { done, value } = await reader.read()
|
|
@@ -175,7 +176,14 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
|
|
|
175
176
|
outputTokens: parsed.usage.completion_tokens || 0,
|
|
176
177
|
})
|
|
177
178
|
}
|
|
178
|
-
} catch {
|
|
179
|
+
} catch {
|
|
180
|
+
if (!malformedChunkLogged) {
|
|
181
|
+
malformedChunkLogged = true
|
|
182
|
+
log.warn(TAG, `[${session.id}] failed to parse OpenAI stream chunk`, {
|
|
183
|
+
sample: data.slice(0, 200),
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
}
|
|
179
187
|
}
|
|
180
188
|
}
|
|
181
189
|
|
|
@@ -9,6 +9,7 @@ import { deriveOpenClawWsUrl } from '@/lib/openclaw/openclaw-endpoint'
|
|
|
9
9
|
import { normalizeOpenClawAgentId } from '@/lib/openclaw/openclaw-agent-id'
|
|
10
10
|
import { loadAgents } from '../server/storage'
|
|
11
11
|
import { getSharedDeviceToken } from '../server/openclaw/sync'
|
|
12
|
+
import { DATA_DIR } from '../server/data-dir'
|
|
12
13
|
import {
|
|
13
14
|
resolveOpenClawGatewayAgentIdFromList,
|
|
14
15
|
type OpenClawGatewayAgentSummary,
|
|
@@ -54,9 +55,8 @@ function resolveCliStateDir(): string {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
function getSwarmClawIdentityPath(): string {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return path.join(dataDir, 'openclaw-device.json')
|
|
58
|
+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
59
|
+
return path.join(DATA_DIR, 'openclaw-device.json')
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function tryLoadIdentityFile(filePath: string): DeviceIdentity | null {
|
|
@@ -21,6 +21,8 @@ import { serviceFail, serviceOk } from '@/lib/server/service-result'
|
|
|
21
21
|
import { listSessions, saveSession } from '@/lib/server/sessions/session-repository'
|
|
22
22
|
import { loadUsage } from '@/lib/server/usage/usage-repository'
|
|
23
23
|
import { notify } from '@/lib/server/ws-hub'
|
|
24
|
+
import { log } from '@/lib/server/logger'
|
|
25
|
+
import { tryAutoRegisterSwarmFeed } from '@/lib/server/agents/agent-swarm-registration'
|
|
24
26
|
import type { Agent, Session } from '@/types'
|
|
25
27
|
import type { ServiceResult } from '@/lib/server/service-result'
|
|
26
28
|
|
|
@@ -191,6 +193,14 @@ export function createAgent(input: {
|
|
|
191
193
|
saveAgent(id, agent)
|
|
192
194
|
logActivity({ entityType: 'agent', entityId: id, action: 'created', actor: 'user', summary: `Agent created: "${agent.name}"` })
|
|
193
195
|
notify('agents')
|
|
196
|
+
|
|
197
|
+
// Auto-register on SwarmFeed when created with it enabled
|
|
198
|
+
if (agent.swarmfeedEnabled && !agent.swarmfeedApiKey) {
|
|
199
|
+
tryAutoRegisterSwarmFeed(agent).catch((err) => {
|
|
200
|
+
log.error('agent-service', `SwarmFeed auto-registration failed for "${agent.name}": ${err instanceof Error ? err.message : err}`)
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
194
204
|
return agent
|
|
195
205
|
}
|
|
196
206
|
|
|
@@ -315,6 +325,14 @@ export function updateAgent(agentId: string, body: Record<string, unknown>): Age
|
|
|
315
325
|
if (Object.keys(budgetChanges).length > 0) {
|
|
316
326
|
logActivity({ entityType: 'budget', entityId: agentId, action: 'configured', actor: 'user', summary: `Budget updated for agent "${updated.name}"`, detail: budgetChanges })
|
|
317
327
|
}
|
|
328
|
+
|
|
329
|
+
// Auto-register on SwarmFeed/SwarmDock when enabled without existing credentials
|
|
330
|
+
if (updated.swarmfeedEnabled && !updated.swarmfeedApiKey) {
|
|
331
|
+
tryAutoRegisterSwarmFeed(updated).catch((err) => {
|
|
332
|
+
log.error('agent-service', `SwarmFeed auto-registration failed for "${updated.name}": ${err instanceof Error ? err.message : err}`)
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
318
336
|
return updated
|
|
319
337
|
}
|
|
320
338
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { registerAgent } from '@/lib/swarmfeed-client'
|
|
2
|
+
import { patchAgent } from '@/lib/server/agents/agent-repository'
|
|
3
|
+
import { log } from '@/lib/server/logger'
|
|
4
|
+
import type { Agent } from '@/types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Auto-register an agent on SwarmFeed when enabled but missing API key.
|
|
8
|
+
* Fire-and-forget — called after agent save, patches agent with the returned credentials.
|
|
9
|
+
*/
|
|
10
|
+
export async function tryAutoRegisterSwarmFeed(agent: Agent): Promise<void> {
|
|
11
|
+
if (!agent.swarmfeedEnabled || agent.swarmfeedApiKey) return
|
|
12
|
+
|
|
13
|
+
log.info('swarm-registration', `Auto-registering agent "${agent.name}" on SwarmFeed`)
|
|
14
|
+
const reg = await registerAgent({
|
|
15
|
+
name: agent.name,
|
|
16
|
+
description: agent.description || agent.swarmfeedBio || `${agent.name} agent on SwarmClaw`,
|
|
17
|
+
framework: 'swarmclaw',
|
|
18
|
+
model: agent.model,
|
|
19
|
+
avatar: agent.avatarUrl || undefined,
|
|
20
|
+
bio: agent.swarmfeedBio || undefined,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
patchAgent(agent.id, (current) => {
|
|
24
|
+
if (!current) return null
|
|
25
|
+
return {
|
|
26
|
+
...current,
|
|
27
|
+
swarmfeedApiKey: reg.apiKey,
|
|
28
|
+
swarmfeedAgentId: reg.agentId,
|
|
29
|
+
swarmfeedJoinedAt: current.swarmfeedJoinedAt ?? Date.now(),
|
|
30
|
+
updatedAt: Date.now(),
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
log.info('swarm-registration', `Agent "${agent.name}" registered on SwarmFeed as ${reg.agentId}`)
|
|
35
|
+
}
|
|
@@ -513,7 +513,7 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
|
|
|
513
513
|
const session = getSession(sessionId)
|
|
514
514
|
if (!session) throw new Error(`Session not found: ${sessionId}`)
|
|
515
515
|
const runStartedAt = Date.now()
|
|
516
|
-
|
|
516
|
+
let runMessageStartIndex = getMessageCount(sessionId)
|
|
517
517
|
|
|
518
518
|
const appSettings = loadSettings()
|
|
519
519
|
const lifecycleRunId = runId || `${sessionId}:${runStartedAt}`
|
|
@@ -725,17 +725,10 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
|
|
|
725
725
|
}
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
|
|
733
|
-
throw new Error(`Directory not found: ${session.cwd}`)
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const apiKey = resolveApiKeyForSession(sessionForRun, provider)
|
|
737
|
-
const hideAssistantTranscript = internal && source === 'main-loop-followup'
|
|
738
|
-
|
|
728
|
+
// Persist the user message BEFORE provider/credential resolution so that if
|
|
729
|
+
// provider resolution throws (unknown provider, missing credentials, etc.),
|
|
730
|
+
// the user message is already in the DB and won't disappear from the chat
|
|
731
|
+
// when the frontend's refreshMessages overwrites the optimistic local copy.
|
|
739
732
|
const shouldPersistUserMessage = shouldPersistInboundUserMessage(internal, source)
|
|
740
733
|
if (shouldPersistUserMessage) {
|
|
741
734
|
const [linkAnalysis, semantics] = await Promise.all([
|
|
@@ -805,8 +798,22 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
|
|
|
805
798
|
}
|
|
806
799
|
}
|
|
807
800
|
}
|
|
801
|
+
// Update runMessageStartIndex to account for newly appended user message(s)
|
|
802
|
+
// so that partial persistence and finalization don't overwrite them.
|
|
803
|
+
runMessageStartIndex = getMessageCount(sessionId)
|
|
808
804
|
}
|
|
809
805
|
|
|
806
|
+
const providerType = sessionForRun.provider || 'claude-cli'
|
|
807
|
+
const provider = getProvider(providerType)
|
|
808
|
+
if (!provider) throw new Error(`Unknown provider: ${providerType}`)
|
|
809
|
+
|
|
810
|
+
if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
|
|
811
|
+
throw new Error(`Directory not found: ${session.cwd}`)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const apiKey = resolveApiKeyForSession(sessionForRun, provider)
|
|
815
|
+
const hideAssistantTranscript = internal && source === 'main-loop-followup'
|
|
816
|
+
|
|
810
817
|
const useLocalOpenClawNativeRuntime = providerType === 'openclaw' && isLocalOpenClawEndpoint(sessionForRun.apiEndpoint)
|
|
811
818
|
const enabledSessionExtensions = getEnabledCapabilityIds(sessionForRun)
|
|
812
819
|
const hasExtensions = enabledSessionExtensions.length > 0
|
|
@@ -2,6 +2,7 @@ import { log } from '@/lib/server/logger'
|
|
|
2
2
|
import { hmrSingleton } from '@/lib/shared-utils'
|
|
3
3
|
import { logActivity } from '@/lib/server/activity/activity-log'
|
|
4
4
|
import type { Connector, InboundMessage } from '@/types/connector'
|
|
5
|
+
import type { Agent } from '@/types/agent'
|
|
5
6
|
import type { PlatformConnector, ConnectorInstance } from '@/lib/server/connectors/types'
|
|
6
7
|
import { createBoardTaskFromAssignment, updateBoardTaskFromEvent, findBoardTaskBySwarmdockId } from './swarmdock-tasks'
|
|
7
8
|
import { shouldAutoBid, submitAutoBid } from './swarmdock-bidding'
|
|
@@ -19,15 +20,15 @@ interface SwarmDockConfig {
|
|
|
19
20
|
paymentPrivateKey?: string
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
function parseConfig(connector: Connector): SwarmDockConfig {
|
|
23
|
+
function parseConfig(connector: Connector, agent?: Agent): SwarmDockConfig {
|
|
23
24
|
const c = connector.config || {}
|
|
24
25
|
return {
|
|
25
|
-
apiUrl: c.apiUrl || 'https://api.
|
|
26
|
+
apiUrl: c.apiUrl || 'https://swarmdock-api.onrender.com',
|
|
26
27
|
walletAddress: c.walletAddress || '',
|
|
27
|
-
agentDescription: c.agentDescription || connector.name || '',
|
|
28
|
-
skills: c.skills || '',
|
|
29
|
-
autoDiscover: c.autoDiscover === 'true',
|
|
30
|
-
maxBudget: c.maxBudget || '0',
|
|
28
|
+
agentDescription: c.agentDescription || agent?.swarmdockDescription || connector.name || '',
|
|
29
|
+
skills: c.skills || (agent?.swarmdockSkills?.join(',') ?? ''),
|
|
30
|
+
autoDiscover: c.autoDiscover === 'true' || (agent?.swarmdockMarketplace?.autoDiscover ?? false),
|
|
31
|
+
maxBudget: c.maxBudget || agent?.swarmdockMarketplace?.maxBudgetUsdc || '0',
|
|
31
32
|
paymentPrivateKey: c.paymentPrivateKey || undefined,
|
|
32
33
|
}
|
|
33
34
|
}
|
|
@@ -82,7 +83,13 @@ const taskIdMap = hmrSingleton('__swarmclaw_swarmdock_task_map__', () => new Map
|
|
|
82
83
|
|
|
83
84
|
const swarmdock: PlatformConnector = {
|
|
84
85
|
async start(connector, _botToken, onMessage): Promise<ConnectorInstance> {
|
|
85
|
-
|
|
86
|
+
// Load agent to use agent-level fields as fallbacks for connector config
|
|
87
|
+
let agent: Agent | undefined
|
|
88
|
+
if (connector.agentId) {
|
|
89
|
+
const { loadAgent } = await import('@/lib/server/agents/agent-repository')
|
|
90
|
+
agent = (await loadAgent(connector.agentId)) ?? undefined
|
|
91
|
+
}
|
|
92
|
+
const config = parseConfig(connector, agent)
|
|
86
93
|
const connectorId = connector.id
|
|
87
94
|
const agentId = connector.agentId || ''
|
|
88
95
|
const privateKey = _botToken || ''
|
|
@@ -138,6 +145,21 @@ const swarmdock: PlatformConnector = {
|
|
|
138
145
|
})
|
|
139
146
|
log.info(TAG, `Registered as ${registration.agent.did} (trust level ${registration.agent.trustLevel})`)
|
|
140
147
|
|
|
148
|
+
// Write SwarmDock IDs back to agent record if not already set
|
|
149
|
+
if (agent && (!agent.swarmdockAgentId || !agent.swarmdockDid)) {
|
|
150
|
+
const { patchAgent } = await import('@/lib/server/agents/agent-repository')
|
|
151
|
+
patchAgent(agent.id, (current) => {
|
|
152
|
+
if (!current) return null
|
|
153
|
+
return {
|
|
154
|
+
...current,
|
|
155
|
+
swarmdockAgentId: registration.agent.id,
|
|
156
|
+
swarmdockDid: registration.agent.did,
|
|
157
|
+
swarmdockListedAt: current.swarmdockListedAt ?? Date.now(),
|
|
158
|
+
updatedAt: Date.now(),
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
logActivity({
|
|
142
164
|
entityType: 'connector',
|
|
143
165
|
entityId: connectorId,
|
|
@@ -286,6 +286,37 @@ export function clearMessages(sessionId: string): void {
|
|
|
286
286
|
/** Replace the entire message list (used after in-memory prune operations). */
|
|
287
287
|
export function replaceAllMessages(sessionId: string, messages: Message[]): void {
|
|
288
288
|
perf.measureSync('message-repo', 'replaceAllMessages', () => {
|
|
289
|
+
// Safety guard: reload current user messages from DB and ensure none are
|
|
290
|
+
// dropped by the replacement. This prevents races where partial persistence
|
|
291
|
+
// or finalization load a stale snapshot that's missing recently-appended
|
|
292
|
+
// user messages.
|
|
293
|
+
const currentRows = stmts().selectAll.all(sessionId) as Array<{ data: string }>
|
|
294
|
+
const currentUserMessages: Message[] = []
|
|
295
|
+
for (const row of currentRows) {
|
|
296
|
+
const m = parseMsg(row.data)
|
|
297
|
+
if (m && m.role === 'user') currentUserMessages.push(m)
|
|
298
|
+
}
|
|
299
|
+
const replacementUserTimes = new Set(
|
|
300
|
+
messages.filter(m => m.role === 'user' && typeof m.time === 'number').map(m => m.time),
|
|
301
|
+
)
|
|
302
|
+
const missingUsers = currentUserMessages.filter(
|
|
303
|
+
m => typeof m.time === 'number' && !replacementUserTimes.has(m.time),
|
|
304
|
+
)
|
|
305
|
+
if (missingUsers.length > 0) {
|
|
306
|
+
// Re-insert missing user messages at their correct position (before the
|
|
307
|
+
// first assistant message that follows them chronologically).
|
|
308
|
+
for (const user of missingUsers) {
|
|
309
|
+
let insertIdx = messages.length
|
|
310
|
+
for (let i = 0; i < messages.length; i++) {
|
|
311
|
+
if (messages[i].role === 'assistant' && typeof messages[i].time === 'number'
|
|
312
|
+
&& (messages[i].time as number) >= (user.time as number)) {
|
|
313
|
+
insertIdx = i
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
messages.splice(insertIdx, 0, user)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
289
320
|
withTransaction(() => {
|
|
290
321
|
stmts().deleteAll.run(sessionId)
|
|
291
322
|
const ins = stmts().insert
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from 'child_process'
|
|
2
2
|
import { errorMessage, hmrSingleton, jitteredBackoff } from '@/lib/shared-utils'
|
|
3
3
|
import { upsertStoredItem, loadCollection } from './storage'
|
|
4
|
+
import { log } from './logger'
|
|
5
|
+
|
|
6
|
+
const TAG = 'provider-health'
|
|
4
7
|
|
|
5
8
|
type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
|
|
6
9
|
|
|
@@ -72,7 +75,12 @@ export function markProviderFailure(providerId: string, error: string, credentia
|
|
|
72
75
|
})
|
|
73
76
|
try {
|
|
74
77
|
upsertStoredItem('provider_health', key, states.get(key)!)
|
|
75
|
-
} catch {
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log.warn(TAG, 'Failed to persist provider failure state', {
|
|
80
|
+
providerKey: key,
|
|
81
|
+
error: errorMessage(err),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
export function markProviderSuccess(providerId: string, credentialId?: string | null): void {
|
|
@@ -88,7 +96,12 @@ export function markProviderSuccess(providerId: string, credentialId?: string |
|
|
|
88
96
|
})
|
|
89
97
|
try {
|
|
90
98
|
upsertStoredItem('provider_health', key, states.get(key)!)
|
|
91
|
-
} catch {
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log.warn(TAG, 'Failed to persist provider success state', {
|
|
101
|
+
providerKey: key,
|
|
102
|
+
error: errorMessage(err),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
export function isProviderCoolingDown(providerId: string, credentialId?: string | null): boolean {
|
|
@@ -195,7 +208,10 @@ export function restoreProviderHealthState(): number {
|
|
|
195
208
|
}
|
|
196
209
|
}
|
|
197
210
|
return restored
|
|
198
|
-
} catch {
|
|
211
|
+
} catch (err) {
|
|
212
|
+
log.warn(TAG, 'Failed to restore persisted provider health state', { error: errorMessage(err) })
|
|
213
|
+
return 0
|
|
214
|
+
}
|
|
199
215
|
}
|
|
200
216
|
|
|
201
217
|
// ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { describe, it, before, after } from 'node:test'
|
|
3
|
+
import { z } from 'zod'
|
|
3
4
|
|
|
4
5
|
let safeParseBody: typeof import('@/lib/server/safe-parse-body').safeParseBody
|
|
5
6
|
before(async () => {
|
|
@@ -50,4 +51,35 @@ describe('safeParseBody', () => {
|
|
|
50
51
|
assert.equal(result.data!.name, 'test')
|
|
51
52
|
assert.equal(result.data!.count, 7)
|
|
52
53
|
})
|
|
54
|
+
|
|
55
|
+
it('validates the parsed body against a provided zod schema', async () => {
|
|
56
|
+
const result = await safeParseBody(
|
|
57
|
+
jsonRequest(JSON.stringify({ name: 'ok', count: 3 })),
|
|
58
|
+
z.object({
|
|
59
|
+
name: z.string().min(1),
|
|
60
|
+
count: z.number().int().nonnegative(),
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
assert.equal(result.error, undefined)
|
|
64
|
+
assert.deepEqual(result.data, { name: 'ok', count: 3 })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns a 400 validation error when schema parsing fails', async () => {
|
|
68
|
+
const result = await safeParseBody(
|
|
69
|
+
jsonRequest(JSON.stringify({ name: '', count: -1 })),
|
|
70
|
+
z.object({
|
|
71
|
+
name: z.string().min(1, 'name is required'),
|
|
72
|
+
count: z.number().int().nonnegative('count must be non-negative'),
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
assert.equal(result.data, undefined)
|
|
76
|
+
assert.ok(result.error)
|
|
77
|
+
assert.equal(result.error.status, 400)
|
|
78
|
+
const body = await result.error.json()
|
|
79
|
+
assert.equal(body.error, 'Validation failed')
|
|
80
|
+
assert.deepEqual(body.issues, [
|
|
81
|
+
{ path: 'name', message: 'name is required' },
|
|
82
|
+
{ path: 'count', message: 'count must be non-negative' },
|
|
83
|
+
])
|
|
84
|
+
})
|
|
53
85
|
})
|