@swarmclawai/swarmclaw 1.4.6 → 1.4.8
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 +30 -3
- package/package.json +1 -1
- package/public/provider-logos/hermes-agent.png +0 -0
- package/public/provider-logos/openrouter.png +0 -0
- package/src/app/api/setup/check-provider/route.ts +18 -2
- package/src/app/api/swarmfeed/actions/route.ts +101 -0
- package/src/app/api/swarmfeed/bookmarks/route.ts +30 -0
- package/src/app/api/swarmfeed/notifications/route.ts +30 -0
- package/src/app/api/swarmfeed/posts/[postId]/replies/route.ts +23 -0
- package/src/app/api/swarmfeed/posts/[postId]/route.ts +20 -0
- package/src/app/api/swarmfeed/posts/route.ts +12 -52
- package/src/app/api/swarmfeed/profiles/[agentId]/posts/route.ts +25 -0
- package/src/app/api/swarmfeed/profiles/[agentId]/route.ts +32 -0
- package/src/app/api/swarmfeed/route.ts +15 -13
- package/src/app/api/swarmfeed/search/route.ts +30 -0
- package/src/app/api/swarmfeed/suggested/route.ts +25 -0
- package/src/cli/index.js +11 -0
- package/src/components/agents/agent-sheet.tsx +10 -3
- package/src/components/auth/setup-wizard/step-connect.tsx +6 -0
- package/src/components/auth/setup-wizard/utils.test.ts +2 -0
- package/src/features/swarmfeed/agent-social-settings.tsx +7 -1
- package/src/features/swarmfeed/compose-post.tsx +72 -87
- package/src/features/swarmfeed/feed-page.tsx +607 -76
- package/src/features/swarmfeed/post-card.tsx +205 -73
- package/src/features/swarmfeed/post-thread-sheet.tsx +155 -0
- package/src/features/swarmfeed/profile-sheet.tsx +179 -0
- package/src/features/swarmfeed/queries.ts +191 -8
- package/src/lib/app/view-constants.ts +1 -1
- package/src/lib/orchestrator-config.test.ts +1 -0
- package/src/lib/orchestrator-config.ts +1 -0
- package/src/lib/provider-sets.ts +6 -3
- package/src/lib/providers/index.ts +35 -0
- package/src/lib/providers/openai.ts +5 -4
- package/src/lib/server/agents/agent-availability.ts +2 -2
- package/src/lib/server/agents/agent-swarm-registration.ts +31 -8
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +3 -1
- package/src/lib/server/provider-health.test.ts +9 -2
- package/src/lib/server/provider-health.ts +8 -3
- package/src/lib/server/provider-model-discovery.test.ts +20 -0
- package/src/lib/server/runtime/heartbeat-service.ts +8 -1
- package/src/lib/server/runtime/queue/core.ts +2 -0
- package/src/lib/server/session-tools/swarmfeed.ts +226 -63
- package/src/lib/server/storage-normalization.ts +1 -0
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/swarmfeed-runtime.test.ts +188 -0
- package/src/lib/server/swarmfeed-runtime.ts +131 -0
- package/src/lib/server/tasks/task-route-service.ts +2 -0
- package/src/lib/setup-defaults.test.ts +10 -0
- package/src/lib/setup-defaults.ts +42 -1
- package/src/lib/swarmfeed-client.ts +130 -28
- package/src/lib/tool-definitions.ts +1 -1
- package/src/types/agent.ts +1 -0
- package/src/types/provider.ts +1 -1
- package/src/types/swarmfeed.ts +105 -5
|
@@ -1,16 +1,43 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
1
2
|
import { api } from '@/lib/app/api-client'
|
|
2
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
CreatePostInput,
|
|
5
|
+
FeedType,
|
|
6
|
+
SwarmFeedChannel,
|
|
7
|
+
SwarmFeedFeedResponse,
|
|
8
|
+
SwarmFeedNotification,
|
|
9
|
+
SwarmFeedNotificationsResponse,
|
|
10
|
+
SwarmFeedPost,
|
|
11
|
+
SwarmFeedProfile,
|
|
12
|
+
SwarmFeedSearchResponse,
|
|
13
|
+
SwarmFeedSearchType,
|
|
14
|
+
SwarmFeedSuggestedResponse,
|
|
15
|
+
} from '@/types/swarmfeed'
|
|
16
|
+
|
|
17
|
+
export const swarmFeedQueryKeys = {
|
|
18
|
+
all: ['swarmfeed'] as const,
|
|
19
|
+
channels: () => ['swarmfeed', 'channels'] as const,
|
|
20
|
+
feed: (params: { type: FeedType; agentId?: string; channelId?: string }) => ['swarmfeed', 'feed', params] as const,
|
|
21
|
+
bookmarks: (agentId: string) => ['swarmfeed', 'bookmarks', agentId] as const,
|
|
22
|
+
notifications: (agentId: string) => ['swarmfeed', 'notifications', agentId] as const,
|
|
23
|
+
suggested: (agentId?: string) => ['swarmfeed', 'suggested', agentId || 'public'] as const,
|
|
24
|
+
search: (params: { query: string; type?: SwarmFeedSearchType }) => ['swarmfeed', 'search', params] as const,
|
|
25
|
+
profile: (agentId: string, viewerAgentId?: string) => ['swarmfeed', 'profile', agentId, viewerAgentId || null] as const,
|
|
26
|
+
profilePosts: (agentId: string) => ['swarmfeed', 'profile-posts', agentId] as const,
|
|
27
|
+
thread: (postId: string) => ['swarmfeed', 'thread', postId] as const,
|
|
28
|
+
}
|
|
3
29
|
|
|
4
30
|
export async function fetchFeed(
|
|
5
31
|
type: FeedType,
|
|
6
|
-
params?: { channelId?: string; cursor?: string; limit?: number },
|
|
7
|
-
): Promise<
|
|
32
|
+
params?: { agentId?: string; channelId?: string; cursor?: string; limit?: number },
|
|
33
|
+
): Promise<SwarmFeedFeedResponse> {
|
|
8
34
|
const searchParams = new URLSearchParams()
|
|
9
35
|
searchParams.set('type', type)
|
|
36
|
+
if (params?.agentId) searchParams.set('agentId', params.agentId)
|
|
10
37
|
if (params?.channelId) searchParams.set('channelId', params.channelId)
|
|
11
38
|
if (params?.cursor) searchParams.set('cursor', params.cursor)
|
|
12
39
|
if (params?.limit) searchParams.set('limit', String(params.limit))
|
|
13
|
-
return api<
|
|
40
|
+
return api<SwarmFeedFeedResponse>('GET', `/swarmfeed?${searchParams.toString()}`)
|
|
14
41
|
}
|
|
15
42
|
|
|
16
43
|
export async function fetchChannels(): Promise<SwarmFeedChannel[]> {
|
|
@@ -18,11 +45,167 @@ export async function fetchChannels(): Promise<SwarmFeedChannel[]> {
|
|
|
18
45
|
return result.channels
|
|
19
46
|
}
|
|
20
47
|
|
|
21
|
-
export async function
|
|
48
|
+
export async function fetchBookmarks(agentId: string): Promise<SwarmFeedPost[]> {
|
|
49
|
+
const result = await api<SwarmFeedFeedResponse>('GET', `/swarmfeed/bookmarks?agentId=${encodeURIComponent(agentId)}`)
|
|
50
|
+
return result.posts
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function fetchNotifications(agentId: string): Promise<SwarmFeedNotification[]> {
|
|
54
|
+
const result = await api<SwarmFeedNotificationsResponse>('GET', `/swarmfeed/notifications?agentId=${encodeURIComponent(agentId)}`)
|
|
55
|
+
return result.notifications
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function fetchSuggestedFollows(agentId?: string): Promise<SwarmFeedSuggestedResponse> {
|
|
59
|
+
const query = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ''
|
|
60
|
+
return api<SwarmFeedSuggestedResponse>('GET', `/swarmfeed/suggested${query}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function searchFeed(query: string, type?: SwarmFeedSearchType): Promise<SwarmFeedSearchResponse> {
|
|
64
|
+
const params = new URLSearchParams({ q: query })
|
|
65
|
+
if (type) params.set('type', type)
|
|
66
|
+
return api<SwarmFeedSearchResponse>('GET', `/swarmfeed/search?${params.toString()}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function submitPost(
|
|
70
|
+
agentId: string,
|
|
71
|
+
input: CreatePostInput,
|
|
72
|
+
): Promise<SwarmFeedPost> {
|
|
22
73
|
return api<SwarmFeedPost>('POST', '/swarmfeed/posts', {
|
|
23
74
|
agentId,
|
|
24
|
-
content,
|
|
25
|
-
channelId,
|
|
26
|
-
parentId,
|
|
75
|
+
content: input.content,
|
|
76
|
+
channelId: input.channelId,
|
|
77
|
+
parentId: input.parentId,
|
|
78
|
+
quotedPostId: input.quotedPostId,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function fetchProfile(agentId: string, viewerAgentId?: string): Promise<SwarmFeedProfile> {
|
|
83
|
+
const params = new URLSearchParams()
|
|
84
|
+
if (viewerAgentId) params.set('viewerAgentId', viewerAgentId)
|
|
85
|
+
const query = params.toString()
|
|
86
|
+
return api<SwarmFeedProfile>('GET', `/swarmfeed/profiles/${encodeURIComponent(agentId)}${query ? `?${query}` : ''}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function fetchProfilePosts(agentId: string): Promise<SwarmFeedPost[]> {
|
|
90
|
+
const result = await api<SwarmFeedFeedResponse>('GET', `/swarmfeed/profiles/${encodeURIComponent(agentId)}/posts?limit=20&filter=posts`)
|
|
91
|
+
return result.posts
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function fetchPostThread(postId: string): Promise<{ post: SwarmFeedPost; replies: SwarmFeedPost[] }> {
|
|
95
|
+
const [post, replies] = await Promise.all([
|
|
96
|
+
api<SwarmFeedPost>('GET', `/swarmfeed/posts/${encodeURIComponent(postId)}`),
|
|
97
|
+
api<SwarmFeedFeedResponse>('GET', `/swarmfeed/posts/${encodeURIComponent(postId)}/replies?limit=30`),
|
|
98
|
+
])
|
|
99
|
+
return { post, replies: replies.posts }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function runSwarmFeedAction(input: {
|
|
103
|
+
action: 'like' | 'unlike' | 'repost' | 'unrepost' | 'bookmark' | 'unbookmark' | 'follow' | 'unfollow' | 'quote_repost'
|
|
104
|
+
agentId: string
|
|
105
|
+
postId?: string
|
|
106
|
+
targetAgentId?: string
|
|
107
|
+
content?: string
|
|
108
|
+
channelId?: string
|
|
109
|
+
}): Promise<unknown> {
|
|
110
|
+
return api('POST', '/swarmfeed/actions', input)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useSwarmFeedFeedQuery(params: { type: FeedType; agentId?: string; channelId?: string; enabled?: boolean }) {
|
|
114
|
+
return useQuery<SwarmFeedFeedResponse>({
|
|
115
|
+
queryKey: swarmFeedQueryKeys.feed({ type: params.type, agentId: params.agentId, channelId: params.channelId }),
|
|
116
|
+
queryFn: () => fetchFeed(params.type, { agentId: params.agentId, channelId: params.channelId, limit: 50 }),
|
|
117
|
+
enabled: params.enabled,
|
|
118
|
+
staleTime: 15_000,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function useSwarmFeedChannelsQuery() {
|
|
123
|
+
return useQuery<SwarmFeedChannel[]>({
|
|
124
|
+
queryKey: swarmFeedQueryKeys.channels(),
|
|
125
|
+
queryFn: fetchChannels,
|
|
126
|
+
staleTime: 60_000,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function useSwarmFeedBookmarksQuery(agentId: string, enabled = true) {
|
|
131
|
+
return useQuery<SwarmFeedPost[]>({
|
|
132
|
+
queryKey: swarmFeedQueryKeys.bookmarks(agentId),
|
|
133
|
+
queryFn: () => fetchBookmarks(agentId),
|
|
134
|
+
enabled: enabled && !!agentId,
|
|
135
|
+
staleTime: 15_000,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function useSwarmFeedNotificationsQuery(agentId: string, enabled = true) {
|
|
140
|
+
return useQuery<SwarmFeedNotification[]>({
|
|
141
|
+
queryKey: swarmFeedQueryKeys.notifications(agentId),
|
|
142
|
+
queryFn: () => fetchNotifications(agentId),
|
|
143
|
+
enabled: enabled && !!agentId,
|
|
144
|
+
staleTime: 15_000,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function useSwarmFeedSuggestedQuery(agentId?: string, enabled = true) {
|
|
149
|
+
return useQuery<SwarmFeedSuggestedResponse>({
|
|
150
|
+
queryKey: swarmFeedQueryKeys.suggested(agentId),
|
|
151
|
+
queryFn: () => fetchSuggestedFollows(agentId),
|
|
152
|
+
enabled,
|
|
153
|
+
staleTime: 30_000,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function useSwarmFeedSearchQuery(params: { query: string; type?: SwarmFeedSearchType; enabled?: boolean }) {
|
|
158
|
+
return useQuery<SwarmFeedSearchResponse>({
|
|
159
|
+
queryKey: swarmFeedQueryKeys.search({ query: params.query, type: params.type }),
|
|
160
|
+
queryFn: () => searchFeed(params.query, params.type),
|
|
161
|
+
enabled: params.enabled && params.query.trim().length > 0,
|
|
162
|
+
staleTime: 15_000,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function useSwarmFeedProfileQuery(agentId: string, viewerAgentId?: string, enabled = true) {
|
|
167
|
+
return useQuery<SwarmFeedProfile>({
|
|
168
|
+
queryKey: swarmFeedQueryKeys.profile(agentId, viewerAgentId),
|
|
169
|
+
queryFn: () => fetchProfile(agentId, viewerAgentId),
|
|
170
|
+
enabled: enabled && !!agentId,
|
|
171
|
+
staleTime: 20_000,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function useSwarmFeedProfilePostsQuery(agentId: string, enabled = true) {
|
|
176
|
+
return useQuery<SwarmFeedPost[]>({
|
|
177
|
+
queryKey: swarmFeedQueryKeys.profilePosts(agentId),
|
|
178
|
+
queryFn: () => fetchProfilePosts(agentId),
|
|
179
|
+
enabled: enabled && !!agentId,
|
|
180
|
+
staleTime: 20_000,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function useSwarmFeedThreadQuery(postId: string, enabled = true) {
|
|
185
|
+
return useQuery<{ post: SwarmFeedPost; replies: SwarmFeedPost[] }>({
|
|
186
|
+
queryKey: swarmFeedQueryKeys.thread(postId),
|
|
187
|
+
queryFn: () => fetchPostThread(postId),
|
|
188
|
+
enabled: enabled && !!postId,
|
|
189
|
+
staleTime: 10_000,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function useSwarmFeedPostMutation() {
|
|
194
|
+
const queryClient = useQueryClient()
|
|
195
|
+
return useMutation({
|
|
196
|
+
mutationFn: ({ agentId, input }: { agentId: string; input: CreatePostInput }) => submitPost(agentId, input),
|
|
197
|
+
onSuccess: async () => {
|
|
198
|
+
await queryClient.invalidateQueries({ queryKey: swarmFeedQueryKeys.all })
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function useSwarmFeedActionMutation() {
|
|
204
|
+
const queryClient = useQueryClient()
|
|
205
|
+
return useMutation({
|
|
206
|
+
mutationFn: runSwarmFeedAction,
|
|
207
|
+
onSuccess: async () => {
|
|
208
|
+
await queryClient.invalidateQueries({ queryKey: swarmFeedQueryKeys.all })
|
|
209
|
+
},
|
|
27
210
|
})
|
|
28
211
|
}
|
|
@@ -131,7 +131,7 @@ export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { ic
|
|
|
131
131
|
icon: 'zap',
|
|
132
132
|
title: 'Providers',
|
|
133
133
|
description: 'Manage LLM providers including built-in and custom OpenAI-compatible endpoints.',
|
|
134
|
-
features: ['Built-in support for
|
|
134
|
+
features: ['Built-in support for OpenAI, OpenRouter, Anthropic, Ollama, Hermes Agent, and CLI runtimes', 'Add custom OpenAI-compatible providers for local or internal gateways', 'Configure base URLs, models, and API keys per provider', 'Built-in and custom providers work seamlessly with all features'],
|
|
135
135
|
},
|
|
136
136
|
skills: {
|
|
137
137
|
icon: 'book',
|
|
@@ -6,6 +6,7 @@ describe('orchestrator-config', () => {
|
|
|
6
6
|
it('marks CLI and OpenClaw providers as ineligible', () => {
|
|
7
7
|
assert.equal(isOrchestratorProviderEligible('openai'), true)
|
|
8
8
|
assert.equal(isOrchestratorProviderEligible('openclaw'), false)
|
|
9
|
+
assert.equal(isOrchestratorProviderEligible('hermes'), false)
|
|
9
10
|
assert.equal(isOrchestratorProviderEligible('codex-cli'), false)
|
|
10
11
|
})
|
|
11
12
|
|
package/src/lib/provider-sets.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/** CLI providers that use their own tool execution outside the shared tool-runtime path. */
|
|
2
2
|
export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli'])
|
|
3
3
|
|
|
4
|
-
/** Providers
|
|
5
|
-
export const
|
|
4
|
+
/** Providers that manage their own runtime/tool loop even when reached over an API endpoint. */
|
|
5
|
+
export const RUNTIME_MANAGED_PROVIDER_IDS = new Set(['hermes'])
|
|
6
|
+
|
|
7
|
+
/** Providers with native tool/capability support (CLI providers + OpenClaw + Hermes). */
|
|
8
|
+
export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw', 'hermes'])
|
|
6
9
|
|
|
7
10
|
/** Providers that can only act as workers — no coordinator role, no heartbeat, no advanced settings. */
|
|
8
|
-
export const WORKER_ONLY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw'])
|
|
11
|
+
export const WORKER_ONLY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw', 'hermes'])
|
|
@@ -71,6 +71,23 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
|
|
|
71
71
|
requiresEndpoint: false,
|
|
72
72
|
handler: { streamChat: streamOpenAiChat },
|
|
73
73
|
},
|
|
74
|
+
openrouter: {
|
|
75
|
+
id: 'openrouter',
|
|
76
|
+
name: 'OpenRouter',
|
|
77
|
+
models: ['openai/gpt-4.1-mini'],
|
|
78
|
+
requiresApiKey: true,
|
|
79
|
+
requiresEndpoint: false,
|
|
80
|
+
defaultEndpoint: 'https://openrouter.ai/api/v1',
|
|
81
|
+
handler: {
|
|
82
|
+
streamChat: (opts) => {
|
|
83
|
+
const patchedSession = {
|
|
84
|
+
...opts.session,
|
|
85
|
+
apiEndpoint: opts.session.apiEndpoint || 'https://openrouter.ai/api/v1',
|
|
86
|
+
}
|
|
87
|
+
return streamOpenAiChat({ ...opts, session: patchedSession })
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
74
91
|
anthropic: {
|
|
75
92
|
id: 'anthropic',
|
|
76
93
|
name: 'Anthropic',
|
|
@@ -89,6 +106,24 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
|
|
|
89
106
|
defaultEndpoint: 'http://localhost:18789',
|
|
90
107
|
handler: { streamChat: streamOpenClawChat },
|
|
91
108
|
},
|
|
109
|
+
hermes: {
|
|
110
|
+
id: 'hermes',
|
|
111
|
+
name: 'Hermes Agent',
|
|
112
|
+
models: ['hermes-agent'],
|
|
113
|
+
requiresApiKey: false,
|
|
114
|
+
optionalApiKey: true,
|
|
115
|
+
requiresEndpoint: true,
|
|
116
|
+
defaultEndpoint: 'http://127.0.0.1:8642/v1',
|
|
117
|
+
handler: {
|
|
118
|
+
streamChat: (opts) => {
|
|
119
|
+
const patchedSession = {
|
|
120
|
+
...opts.session,
|
|
121
|
+
apiEndpoint: opts.session.apiEndpoint || 'http://127.0.0.1:8642/v1',
|
|
122
|
+
}
|
|
123
|
+
return streamOpenAiChat({ ...opts, session: patchedSession })
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
92
127
|
'opencode-cli': {
|
|
93
128
|
id: 'opencode-cli',
|
|
94
129
|
name: 'OpenCode CLI',
|
|
@@ -73,6 +73,10 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
|
|
|
73
73
|
// Try with stream_options first; if the provider rejects with 400, retry without it
|
|
74
74
|
let res: Response | undefined
|
|
75
75
|
let usageEnabled = true
|
|
76
|
+
const headers: Record<string, string> = {
|
|
77
|
+
'Content-Type': contentType,
|
|
78
|
+
}
|
|
79
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
76
80
|
for (const includeStreamOptions of [true, false]) {
|
|
77
81
|
const payloadObj: Record<string, unknown> = {
|
|
78
82
|
model,
|
|
@@ -86,10 +90,7 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
|
|
|
86
90
|
|
|
87
91
|
res = await fetch(url, {
|
|
88
92
|
method: 'POST',
|
|
89
|
-
headers
|
|
90
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
91
|
-
'Content-Type': contentType,
|
|
92
|
-
},
|
|
93
|
+
headers,
|
|
93
94
|
body: payload,
|
|
94
95
|
signal: abortController.signal,
|
|
95
96
|
})
|
|
@@ -27,6 +27,6 @@ export function buildWorkerOnlyAgentMessage(
|
|
|
27
27
|
const name = typeof agent?.name === 'string' && agent.name.trim()
|
|
28
28
|
? agent.name.trim()
|
|
29
29
|
: 'This agent'
|
|
30
|
-
if (action) return `${name}
|
|
31
|
-
return `${name}
|
|
30
|
+
if (action) return `${name} uses a runtime-managed provider and cannot ${action}. Runtime-managed agents can only be used for direct chats and delegation.`
|
|
31
|
+
return `${name} uses a runtime-managed provider and cannot join chatrooms. Runtime-managed agents can only be used for direct chats and delegation.`
|
|
32
32
|
}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { registerAgent } from '@/lib/swarmfeed-client'
|
|
2
|
-
import { patchAgent } from '@/lib/server/agents/agent-repository'
|
|
2
|
+
import { getAgent, patchAgent } from '@/lib/server/agents/agent-repository'
|
|
3
3
|
import { log } from '@/lib/server/logger'
|
|
4
4
|
import type { Agent } from '@/types'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export async function
|
|
11
|
-
|
|
6
|
+
type EnsureSwarmFeedAgentOptions = {
|
|
7
|
+
requireEnabled?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function ensureSwarmFeedAgent(
|
|
11
|
+
agentId: string,
|
|
12
|
+
options: EnsureSwarmFeedAgentOptions = {},
|
|
13
|
+
): Promise<Agent> {
|
|
14
|
+
const agent = getAgent(agentId) as Agent | undefined
|
|
15
|
+
if (!agent) throw new Error('Agent not found')
|
|
16
|
+
if (options.requireEnabled !== false && !agent.swarmfeedEnabled) {
|
|
17
|
+
throw new Error('SwarmFeed is not enabled for this agent')
|
|
18
|
+
}
|
|
19
|
+
if (agent.swarmfeedApiKey && agent.swarmfeedAgentId) return agent
|
|
12
20
|
|
|
13
21
|
log.info('swarm-registration', `Auto-registering agent "${agent.name}" on SwarmFeed`)
|
|
14
22
|
const reg = await registerAgent({
|
|
@@ -31,5 +39,20 @@ export async function tryAutoRegisterSwarmFeed(agent: Agent): Promise<void> {
|
|
|
31
39
|
}
|
|
32
40
|
})
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
const updated = getAgent(agentId) as Agent | undefined
|
|
43
|
+
if (!updated?.swarmfeedApiKey || !updated.swarmfeedAgentId) {
|
|
44
|
+
throw new Error('Registration succeeded but credentials were not saved')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
log.info('swarm-registration', `Agent "${updated.name}" registered on SwarmFeed as ${updated.swarmfeedAgentId}`)
|
|
48
|
+
return updated
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Auto-register an agent on SwarmFeed when enabled but missing API key.
|
|
53
|
+
* Fire-and-forget — called after agent save, patches agent with the returned credentials.
|
|
54
|
+
*/
|
|
55
|
+
export async function tryAutoRegisterSwarmFeed(agent: Agent): Promise<void> {
|
|
56
|
+
if (!agent.swarmfeedEnabled || (agent.swarmfeedApiKey && agent.swarmfeedAgentId)) return
|
|
57
|
+
await ensureSwarmFeedAgent(agent.id)
|
|
35
58
|
}
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
splitCapabilityIds,
|
|
45
45
|
} from '@/lib/capability-selection'
|
|
46
46
|
import { normalizeProviderEndpoint, isLocalOpenClawEndpoint } from '@/lib/openclaw/openclaw-endpoint'
|
|
47
|
-
import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
|
|
47
|
+
import { NON_LANGGRAPH_PROVIDER_IDS, RUNTIME_MANAGED_PROVIDER_IDS } from '@/lib/provider-sets'
|
|
48
48
|
import {
|
|
49
49
|
bridgeHumanReplyFromChat,
|
|
50
50
|
} from '@/lib/server/chatrooms/session-mailbox'
|
|
@@ -815,9 +815,11 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
|
|
|
815
815
|
const hideAssistantTranscript = internal && source === 'main-loop-followup'
|
|
816
816
|
|
|
817
817
|
const useLocalOpenClawNativeRuntime = providerType === 'openclaw' && isLocalOpenClawEndpoint(sessionForRun.apiEndpoint)
|
|
818
|
+
const useProviderManagedRuntime = RUNTIME_MANAGED_PROVIDER_IDS.has(providerType)
|
|
818
819
|
const enabledSessionExtensions = getEnabledCapabilityIds(sessionForRun)
|
|
819
820
|
const hasExtensions = enabledSessionExtensions.length > 0
|
|
820
821
|
&& !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
|
|
822
|
+
&& !useProviderManagedRuntime
|
|
821
823
|
&& !useLocalOpenClawNativeRuntime
|
|
822
824
|
|
|
823
825
|
const systemPrompt = heartbeatLightContext
|
|
@@ -120,6 +120,7 @@ describe('provider-health', () => {
|
|
|
120
120
|
it('OPENAI_COMPATIBLE_DEFAULTS has expected providers', () => {
|
|
121
121
|
const defaults = providerHealth.OPENAI_COMPATIBLE_DEFAULTS
|
|
122
122
|
assert.ok(defaults.openai)
|
|
123
|
+
assert.ok(defaults.openrouter)
|
|
123
124
|
assert.ok(defaults.google)
|
|
124
125
|
assert.ok(defaults.deepseek)
|
|
125
126
|
assert.ok(defaults.groq)
|
|
@@ -127,11 +128,17 @@ describe('provider-health', () => {
|
|
|
127
128
|
assert.ok(defaults.mistral)
|
|
128
129
|
assert.ok(defaults.xai)
|
|
129
130
|
assert.ok(defaults.fireworks)
|
|
131
|
+
assert.ok(defaults.hermes)
|
|
130
132
|
|
|
131
133
|
// Each entry has name and defaultEndpoint
|
|
132
|
-
for (const [, val] of Object.entries(defaults)) {
|
|
134
|
+
for (const [key, val] of Object.entries(defaults)) {
|
|
133
135
|
assert.ok(typeof val.name === 'string' && val.name.length > 0)
|
|
134
|
-
assert.ok(typeof val.defaultEndpoint === 'string' && val.defaultEndpoint.
|
|
136
|
+
assert.ok(typeof val.defaultEndpoint === 'string' && val.defaultEndpoint.length > 0)
|
|
137
|
+
if (key === 'hermes') {
|
|
138
|
+
assert.ok(val.defaultEndpoint.startsWith('http://'))
|
|
139
|
+
} else {
|
|
140
|
+
assert.ok(val.defaultEndpoint.startsWith('https://'))
|
|
141
|
+
}
|
|
135
142
|
}
|
|
136
143
|
})
|
|
137
144
|
|
|
@@ -246,6 +246,7 @@ async function parseErrorMessage(res: Response, fallback: string): Promise<strin
|
|
|
246
246
|
|
|
247
247
|
export const OPENAI_COMPATIBLE_DEFAULTS: Record<string, { name: string; defaultEndpoint: string }> = {
|
|
248
248
|
openai: { name: 'OpenAI', defaultEndpoint: 'https://api.openai.com/v1' },
|
|
249
|
+
openrouter: { name: 'OpenRouter', defaultEndpoint: 'https://openrouter.ai/api/v1' },
|
|
249
250
|
google: { name: 'Google Gemini', defaultEndpoint: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
|
250
251
|
deepseek: { name: 'DeepSeek', defaultEndpoint: 'https://api.deepseek.com/v1' },
|
|
251
252
|
groq: { name: 'Groq', defaultEndpoint: 'https://api.groq.com/openai/v1' },
|
|
@@ -255,15 +256,18 @@ export const OPENAI_COMPATIBLE_DEFAULTS: Record<string, { name: string; defaultE
|
|
|
255
256
|
fireworks: { name: 'Fireworks AI', defaultEndpoint: 'https://api.fireworks.ai/inference/v1' },
|
|
256
257
|
nebius: { name: 'Nebius', defaultEndpoint: 'https://api.tokenfactory.nebius.com/v1' },
|
|
257
258
|
deepinfra: { name: 'DeepInfra', defaultEndpoint: 'https://api.deepinfra.com/v1/openai' },
|
|
259
|
+
hermes: { name: 'Hermes Agent', defaultEndpoint: 'http://127.0.0.1:8642/v1' },
|
|
258
260
|
}
|
|
259
261
|
|
|
260
262
|
export async function pingOpenAiCompatible(
|
|
261
|
-
apiKey: string,
|
|
263
|
+
apiKey: string | undefined,
|
|
262
264
|
endpoint: string,
|
|
263
265
|
): Promise<{ ok: boolean; message: string }> {
|
|
264
266
|
const normalizedEndpoint = endpoint.replace(/\/+$/, '')
|
|
267
|
+
const headers: Record<string, string> = {}
|
|
268
|
+
if (apiKey) headers.authorization = `Bearer ${apiKey}`
|
|
265
269
|
const res = await fetch(`${normalizedEndpoint}/models`, {
|
|
266
|
-
headers:
|
|
270
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
267
271
|
signal: AbortSignal.timeout(PING_TIMEOUT_MS),
|
|
268
272
|
cache: 'no-store',
|
|
269
273
|
})
|
|
@@ -335,6 +339,7 @@ export async function pingProvider(
|
|
|
335
339
|
endpoint: string | undefined,
|
|
336
340
|
): Promise<{ ok: boolean; message: string }> {
|
|
337
341
|
const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli']
|
|
342
|
+
const OPTIONAL_OPENAI_COMPATIBLE_KEY_PROVIDERS = new Set(['hermes'])
|
|
338
343
|
if (CLI_PROVIDERS.includes(provider)) return { ok: true, message: 'CLI provider — skipped.' }
|
|
339
344
|
|
|
340
345
|
try {
|
|
@@ -352,7 +357,7 @@ export async function pingProvider(
|
|
|
352
357
|
const defaults = OPENAI_COMPATIBLE_DEFAULTS[provider]
|
|
353
358
|
const resolvedEndpoint = endpoint || defaults?.defaultEndpoint
|
|
354
359
|
if (!resolvedEndpoint) return { ok: false, message: `No endpoint for provider "${provider}".` }
|
|
355
|
-
if (!apiKey) return { ok: false, message: 'No API key configured.' }
|
|
360
|
+
if (!apiKey && !OPTIONAL_OPENAI_COMPATIBLE_KEY_PROVIDERS.has(provider)) return { ok: false, message: 'No API key configured.' }
|
|
356
361
|
return await pingOpenAiCompatible(apiKey, resolvedEndpoint)
|
|
357
362
|
} catch (err: unknown) {
|
|
358
363
|
const msg = err instanceof Error && err.name === 'TimeoutError'
|
|
@@ -208,6 +208,26 @@ test('resolveDescriptor uses explicit cloud Ollama discovery only when cloud mod
|
|
|
208
208
|
assert.equal(descriptor?.requiresApiKey, true)
|
|
209
209
|
})
|
|
210
210
|
|
|
211
|
+
test('resolveDescriptor uses OpenRouter as an OpenAI-compatible provider', () => {
|
|
212
|
+
const descriptor = resolveDescriptor({
|
|
213
|
+
providerId: 'openrouter',
|
|
214
|
+
})
|
|
215
|
+
assert.equal(descriptor?.strategy, 'openai-compatible')
|
|
216
|
+
assert.equal(descriptor?.endpoint, 'https://openrouter.ai/api/v1')
|
|
217
|
+
assert.equal(descriptor?.requiresApiKey, true)
|
|
218
|
+
assert.equal(descriptor?.optionalApiKey, false)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('resolveDescriptor uses Hermes as an OpenAI-compatible provider with optional auth', () => {
|
|
222
|
+
const descriptor = resolveDescriptor({
|
|
223
|
+
providerId: 'hermes',
|
|
224
|
+
})
|
|
225
|
+
assert.equal(descriptor?.strategy, 'openai-compatible')
|
|
226
|
+
assert.equal(descriptor?.endpoint, 'http://127.0.0.1:8642/v1')
|
|
227
|
+
assert.equal(descriptor?.requiresApiKey, false)
|
|
228
|
+
assert.equal(descriptor?.optionalApiKey, true)
|
|
229
|
+
})
|
|
230
|
+
|
|
211
231
|
// ---------------------------------------------------------------------------
|
|
212
232
|
// ttlForDescriptor
|
|
213
233
|
// ---------------------------------------------------------------------------
|
|
@@ -30,6 +30,7 @@ import { errorMessage, hmrSingleton, jitteredBackoff } from '@/lib/shared-utils'
|
|
|
30
30
|
import { logExecution } from '@/lib/server/execution-log'
|
|
31
31
|
import { createNotification } from '@/lib/server/create-notification'
|
|
32
32
|
import { WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
|
|
33
|
+
import { buildSwarmFeedHeartbeatGuidance } from '@/lib/server/swarmfeed-runtime'
|
|
33
34
|
|
|
34
35
|
const HEARTBEAT_TICK_MS = 60_000
|
|
35
36
|
const MAX_CONCURRENT_HEARTBEATS = 1
|
|
@@ -378,7 +379,11 @@ export function buildAgentHeartbeatPrompt(
|
|
|
378
379
|
const effectiveFileContent = isHeartbeatContentEffectivelyEmpty(strippedContent) ? '' : strippedContent
|
|
379
380
|
if (effectiveFileContent) sections.push(`\nHEARTBEAT.md contents:\n${effectiveFileContent.slice(0, 2000)}`)
|
|
380
381
|
|
|
381
|
-
const recentMessages = (
|
|
382
|
+
const recentMessages = (
|
|
383
|
+
Array.isArray(session.messages)
|
|
384
|
+
? session.messages.slice(-5)
|
|
385
|
+
: (session.id ? getRecentMessages(session.id, 5) : [])
|
|
386
|
+
) as HeartbeatPromptMessage[]
|
|
382
387
|
const recentContext = recentMessages
|
|
383
388
|
.map((m) => {
|
|
384
389
|
const text = (m.text || '').slice(0, 200)
|
|
@@ -416,6 +421,8 @@ export function buildAgentHeartbeatPrompt(
|
|
|
416
421
|
|
|
417
422
|
// ── Phase 5: Execution instructions ──
|
|
418
423
|
if (fallbackPrompt !== DEFAULT_HEARTBEAT_PROMPT) sections.push(`\nAgent instructions:\n${fallbackPrompt}`)
|
|
424
|
+
const swarmFeedGuidance = buildSwarmFeedHeartbeatGuidance(agent as Agent)
|
|
425
|
+
if (swarmFeedGuidance) sections.push(`\n${swarmFeedGuidance}`)
|
|
419
426
|
|
|
420
427
|
sections.push('')
|
|
421
428
|
sections.push('You are running an autonomous heartbeat tick. Review your goal and recent context.')
|
|
@@ -24,6 +24,7 @@ import { checkAgentBudgetLimits } from '@/lib/server/cost'
|
|
|
24
24
|
import { enqueueExecution } from '@/lib/server/execution-engine'
|
|
25
25
|
import { extractTaskResult, formatResultBody } from '@/lib/server/tasks/task-result'
|
|
26
26
|
import { checkoutTask } from '@/lib/server/tasks/task-checkout'
|
|
27
|
+
import { queueSwarmFeedTaskCompletionWake } from '@/lib/server/swarmfeed-runtime'
|
|
27
28
|
import {
|
|
28
29
|
classifyRuntimeFailure,
|
|
29
30
|
observeAutonomyRunOutcome,
|
|
@@ -1496,6 +1497,7 @@ export async function processNext() {
|
|
|
1496
1497
|
text: `Task completed: "${task.title}" (${taskId})`,
|
|
1497
1498
|
})
|
|
1498
1499
|
notifyOrchestrators(`Task completed: "${task.title}"`, `task-complete:${taskId}`)
|
|
1500
|
+
queueSwarmFeedTaskCompletionWake(doneTask)
|
|
1499
1501
|
handleTerminalTaskResultDeliveries(doneTask)
|
|
1500
1502
|
cleanupTerminalOneOffSchedule(doneTask)
|
|
1501
1503
|
// Clean up LangGraph checkpoints for completed tasks
|