@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.
Files changed (54) hide show
  1. package/README.md +30 -3
  2. package/package.json +1 -1
  3. package/public/provider-logos/hermes-agent.png +0 -0
  4. package/public/provider-logos/openrouter.png +0 -0
  5. package/src/app/api/setup/check-provider/route.ts +18 -2
  6. package/src/app/api/swarmfeed/actions/route.ts +101 -0
  7. package/src/app/api/swarmfeed/bookmarks/route.ts +30 -0
  8. package/src/app/api/swarmfeed/notifications/route.ts +30 -0
  9. package/src/app/api/swarmfeed/posts/[postId]/replies/route.ts +23 -0
  10. package/src/app/api/swarmfeed/posts/[postId]/route.ts +20 -0
  11. package/src/app/api/swarmfeed/posts/route.ts +12 -52
  12. package/src/app/api/swarmfeed/profiles/[agentId]/posts/route.ts +25 -0
  13. package/src/app/api/swarmfeed/profiles/[agentId]/route.ts +32 -0
  14. package/src/app/api/swarmfeed/route.ts +15 -13
  15. package/src/app/api/swarmfeed/search/route.ts +30 -0
  16. package/src/app/api/swarmfeed/suggested/route.ts +25 -0
  17. package/src/cli/index.js +11 -0
  18. package/src/components/agents/agent-sheet.tsx +10 -3
  19. package/src/components/auth/setup-wizard/step-connect.tsx +6 -0
  20. package/src/components/auth/setup-wizard/utils.test.ts +2 -0
  21. package/src/features/swarmfeed/agent-social-settings.tsx +7 -1
  22. package/src/features/swarmfeed/compose-post.tsx +72 -87
  23. package/src/features/swarmfeed/feed-page.tsx +607 -76
  24. package/src/features/swarmfeed/post-card.tsx +205 -73
  25. package/src/features/swarmfeed/post-thread-sheet.tsx +155 -0
  26. package/src/features/swarmfeed/profile-sheet.tsx +179 -0
  27. package/src/features/swarmfeed/queries.ts +191 -8
  28. package/src/lib/app/view-constants.ts +1 -1
  29. package/src/lib/orchestrator-config.test.ts +1 -0
  30. package/src/lib/orchestrator-config.ts +1 -0
  31. package/src/lib/provider-sets.ts +6 -3
  32. package/src/lib/providers/index.ts +35 -0
  33. package/src/lib/providers/openai.ts +5 -4
  34. package/src/lib/server/agents/agent-availability.ts +2 -2
  35. package/src/lib/server/agents/agent-swarm-registration.ts +31 -8
  36. package/src/lib/server/chat-execution/chat-turn-preparation.ts +3 -1
  37. package/src/lib/server/provider-health.test.ts +9 -2
  38. package/src/lib/server/provider-health.ts +8 -3
  39. package/src/lib/server/provider-model-discovery.test.ts +20 -0
  40. package/src/lib/server/runtime/heartbeat-service.ts +8 -1
  41. package/src/lib/server/runtime/queue/core.ts +2 -0
  42. package/src/lib/server/session-tools/swarmfeed.ts +226 -63
  43. package/src/lib/server/storage-normalization.ts +1 -0
  44. package/src/lib/server/storage.ts +1 -1
  45. package/src/lib/server/swarmfeed-runtime.test.ts +188 -0
  46. package/src/lib/server/swarmfeed-runtime.ts +131 -0
  47. package/src/lib/server/tasks/task-route-service.ts +2 -0
  48. package/src/lib/setup-defaults.test.ts +10 -0
  49. package/src/lib/setup-defaults.ts +42 -1
  50. package/src/lib/swarmfeed-client.ts +130 -28
  51. package/src/lib/tool-definitions.ts +1 -1
  52. package/src/types/agent.ts +1 -0
  53. package/src/types/provider.ts +1 -1
  54. 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 { SwarmFeedPost, SwarmFeedChannel, FeedType } from '@/types/swarmfeed'
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<{ posts: SwarmFeedPost[]; nextCursor?: string }> {
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<{ posts: SwarmFeedPost[]; nextCursor?: string }>('GET', `/swarmfeed?${searchParams.toString()}`)
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 submitPost(agentId: string, content: string, channelId?: string, parentId?: string): Promise<SwarmFeedPost> {
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 Claude, OpenAI, Anthropic, and Ollama', 'Add custom OpenAI-compatible providers (OpenRouter, Together, Groq)', 'Configure base URLs, models, and API keys per provider', 'Custom providers work seamlessly with all features'],
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
 
@@ -5,6 +5,7 @@ export const NON_ORCHESTRATOR_PROVIDERS = new Set([
5
5
  'codex-cli',
6
6
  'opencode-cli',
7
7
  'openclaw',
8
+ 'hermes',
8
9
  ])
9
10
 
10
11
  export type OrchestratorGovernance = 'autonomous' | 'approval-required' | 'notify-only'
@@ -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 with native tool/capability support (CLI providers + OpenClaw). */
5
- export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw'])
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} is a CLI-based agent and cannot ${action}. CLI agents can only be used for direct chats and delegation.`
31
- return `${name} is a CLI-based agent and cannot join chatrooms. CLI agents can only be used for direct chats and delegation.`
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
- * 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
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
- log.info('swarm-registration', `Agent "${agent.name}" registered on SwarmFeed as ${reg.agentId}`)
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.startsWith('https://'))
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: { authorization: `Bearer ${apiKey}` },
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 = (session.id ? getRecentMessages(session.id, 5) : []) as HeartbeatPromptMessage[]
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