@swarmclawai/swarmclaw 1.4.5 → 1.4.7

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 CHANGED
@@ -211,6 +211,20 @@ SwarmClaw agents can join [SwarmFeed](https://swarmfeed.ai) — a social network
211
211
 
212
212
  Read the docs at [swarmclaw.ai/docs/swarmfeed](https://swarmclaw.ai/docs/swarmfeed) and visit [swarmfeed.ai](https://swarmfeed.ai) for the platform itself.
213
213
 
214
+ ### v1.4.7 Highlights
215
+
216
+ - **Hermes Agent built-in provider**: Added first-class Hermes support through the Hermes API server, including optional auth, local or remote `/v1` endpoints, and runtime-managed agent handling.
217
+ - **OpenRouter built-in provider**: OpenRouter is now a built-in provider instead of living only behind the generic custom-provider path.
218
+ - **Runtime-managed provider handling**: Hermes now skips SwarmClaw's local extension/tool injection path so its own runtime stays in control, while setup and model discovery still work through the normal provider flow.
219
+ - **Provider docs refresh**: README and docs now reflect the new provider list, remote Hermes API-server support, and logo assets for OpenRouter and Hermes Agent.
220
+
221
+ ### v1.4.6 Highlights
222
+
223
+ - **SwarmDock startup sync**: Existing SwarmDock agents now authenticate and reconcile their live marketplace profile on connector start, updating stale description, skills, framework/model metadata, and payout wallet fields
224
+ - **Agent wallet fallback**: SwarmDock connectors now fall back to the agent's selected marketplace wallet when no connector-level wallet address is configured
225
+ - **Task filter fix**: The built-in `swarmdock` tool now uses the correct `skills=` task filter when browsing marketplace tasks from chat
226
+ - **SwarmDock SDK bump**: Updated `@swarmdock/sdk` from `0.5.2` to `0.5.3`, aligning the connector with the published metadata-sync fixes
227
+
214
228
  ### v1.4.5 Highlights
215
229
 
216
230
  - **OpenClaw 2026.4.x compatibility**: Fixed WebSocket protocol errors when connecting to OpenClaw 2026.4.2+ gateways (`profileId` was incorrectly included in RPC params)
@@ -320,7 +334,9 @@ Then open `http://localhost:3456`.
320
334
 
321
335
  ## Core Capabilities
322
336
 
323
- - **Providers**: OpenClaw, OpenAI, Anthropic, Ollama, Google, DeepSeek, Groq, Together, Mistral, xAI, Fireworks, Nebius, DeepInfra, plus compatible custom endpoints.
337
+ - **Providers**: OpenClaw, OpenAI, OpenRouter, Anthropic, Ollama, Hermes Agent, Google, DeepSeek, Groq, Together, Mistral, xAI, Fireworks, Nebius, DeepInfra, plus compatible custom endpoints.
338
+ - **OpenRouter**: <img src="public/provider-logos/openrouter.png" alt="OpenRouter logo" width="20" height="20" /> Use OpenRouter as a first-class built-in provider with its standard OpenAI-compatible endpoint and routed model IDs such as `openai/gpt-4.1-mini`.
339
+ - **Hermes Agent**: <img src="public/provider-logos/hermes-agent.png" alt="Hermes Agent logo" width="20" height="20" /> Connect Hermes through its OpenAI-compatible API server, locally or through a reachable remote `/v1` endpoint.
324
340
  - **Delegation**: built-in delegation to Claude Code, Codex CLI, OpenCode CLI, Gemini CLI, and native SwarmClaw subagents.
325
341
  - **Autonomy**: heartbeat loops, schedules, background jobs, task execution, supervisor recovery, and agent wakeups.
326
342
  - **Orchestration**: durable structured execution with branching, repeat loops, parallel branches, explicit joins, restart-safe run state, and contextual launch from chats, chatrooms, tasks, schedules, and API flows.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
4
4
  "description": "Self-hosted AI runtime for OpenClaw, delegation, autonomy, runtime skills, crypto wallets, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -73,7 +73,7 @@
73
73
  "cli": "node ./bin/swarmclaw.js",
74
74
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
75
75
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts",
76
- "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
76
+ "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
77
77
  "test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
78
78
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
79
79
  "test:e2e": "tsx .workbench/browser-e2e/run.ts",
@@ -91,7 +91,7 @@
91
91
  "@multiavatar/multiavatar": "^1.0.7",
92
92
  "@playwright/mcp": "^0.0.68",
93
93
  "@slack/bolt": "^4.6.0",
94
- "@swarmdock/sdk": "^0.5.2",
94
+ "@swarmdock/sdk": "^0.5.3",
95
95
  "@tailwindcss/postcss": "^4",
96
96
  "@tanstack/react-query": "^5.91.0",
97
97
  "@types/better-sqlite3": "^7.6.13",
@@ -7,6 +7,7 @@ import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage }
7
7
 
8
8
  type SetupProvider =
9
9
  | 'openai'
10
+ | 'openrouter'
10
11
  | 'anthropic'
11
12
  | 'google'
12
13
  | 'deepseek'
@@ -19,6 +20,7 @@ type SetupProvider =
19
20
  | 'deepinfra'
20
21
  | 'ollama'
21
22
  | 'openclaw'
23
+ | 'hermes'
22
24
 
23
25
  interface SetupCheckBody {
24
26
  provider?: string
@@ -46,13 +48,14 @@ async function checkOpenAiCompatible(
46
48
  modelHint?: string,
47
49
  ): Promise<{ ok: boolean; message: string; normalizedEndpoint: string }> {
48
50
  const normalizedEndpoint = (endpointRaw || defaultEndpoint).replace(/\/+$/, '')
51
+ const authHeaders = apiKey ? { authorization: `Bearer ${apiKey}` } : undefined
49
52
 
50
53
  // First, discover a model to test with (prefer the hint, fall back to the first available model)
51
54
  let testModel = modelHint || ''
52
55
  if (!testModel) {
53
56
  try {
54
57
  const modelsRes = await fetch(`${normalizedEndpoint}/models`, {
55
- headers: { authorization: `Bearer ${apiKey}` },
58
+ headers: authHeaders,
56
59
  signal: AbortSignal.timeout(8_000),
57
60
  cache: 'no-store',
58
61
  })
@@ -79,6 +82,8 @@ async function checkOpenAiCompatible(
79
82
  'Fireworks AI': 'accounts/fireworks/models/llama4-scout-instruct-basic',
80
83
  Nebius: 'deepseek-ai/DeepSeek-R1-0528',
81
84
  DeepInfra: 'deepseek-ai/DeepSeek-R1-0528',
85
+ OpenRouter: 'openai/gpt-4.1-mini',
86
+ 'Hermes Agent': 'hermes-agent',
82
87
  }
83
88
  testModel = fallbacks[providerName] || 'gpt-4o-mini'
84
89
  }
@@ -87,8 +92,8 @@ async function checkOpenAiCompatible(
87
92
  const res = await fetch(`${normalizedEndpoint}/chat/completions`, {
88
93
  method: 'POST',
89
94
  headers: {
90
- authorization: `Bearer ${apiKey}`,
91
95
  'content-type': 'application/json',
96
+ ...(authHeaders || {}),
92
97
  },
93
98
  body: JSON.stringify({
94
99
  model: testModel,
@@ -294,6 +299,12 @@ export async function POST(req: Request) {
294
299
  const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
295
300
  return NextResponse.json(result)
296
301
  }
302
+ case 'openrouter': {
303
+ if (!apiKey) return NextResponse.json({ ok: false, message: 'OpenRouter API key is required.' })
304
+ const info = OPENAI_COMPATIBLE_DEFAULTS.openrouter
305
+ const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
306
+ return NextResponse.json(result)
307
+ }
297
308
  case 'anthropic': {
298
309
  if (!apiKey) return NextResponse.json({ ok: false, message: 'Anthropic API key is required.' })
299
310
  const result = await checkAnthropic(apiKey, model)
@@ -313,6 +324,11 @@ export async function POST(req: Request) {
313
324
  const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
314
325
  return NextResponse.json(result)
315
326
  }
327
+ case 'hermes': {
328
+ const info = OPENAI_COMPATIBLE_DEFAULTS.hermes
329
+ const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
330
+ return NextResponse.json(result)
331
+ }
316
332
  case 'ollama': {
317
333
  const result = await checkOllama({
318
334
  endpointRaw: endpoint,
@@ -35,6 +35,7 @@ const HB_PRESETS = [1800, 3600, 7200, 21600, 43200] as const
35
35
  const FALLBACK_ELEVENLABS_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
36
36
  const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
37
37
  'openai',
38
+ 'openrouter',
38
39
  'anthropic',
39
40
  'google',
40
41
  'deepseek',
@@ -45,6 +46,7 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
45
46
  'fireworks',
46
47
  'nebius',
47
48
  'deepinfra',
49
+ 'hermes',
48
50
  'ollama',
49
51
  ])
50
52
  const CONNECTION_TEST_TIMEOUT_MS = 40_000
@@ -1645,13 +1647,16 @@ export function AgentSheet() {
1645
1647
  </div>
1646
1648
  )}
1647
1649
 
1648
- {currentProvider?.requiresEndpoint && (provider === 'openclaw' || (provider === 'ollama' && ollamaMode === 'local')) && (
1650
+ {currentProvider?.requiresEndpoint && (provider !== 'ollama' || ollamaMode === 'local') && (
1649
1651
  <div className="mb-8">
1650
- <SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : 'Endpoint'}</SectionLabel>
1652
+ <SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : provider === 'hermes' ? 'Hermes API Endpoint' : 'Endpoint'}</SectionLabel>
1651
1653
  <input type="text" value={apiEndpoint || ''} onChange={(e) => setApiEndpoint(e.target.value || null)} placeholder={currentProvider.defaultEndpoint || 'http://localhost:11434'} className={`${inputClass} font-mono text-[14px]`} />
1652
1654
  {provider === 'openclaw' && (
1653
1655
  <p className="text-[13px] text-text-3/70 mt-2">The URL of your OpenClaw gateway</p>
1654
1656
  )}
1657
+ {provider === 'hermes' && (
1658
+ <p className="text-[13px] text-text-3/70 mt-2">Point this at the Hermes API server, usually <code className="text-text-2">http://127.0.0.1:8642/v1</code>.</p>
1659
+ )}
1655
1660
  </div>
1656
1661
  )}
1657
1662
 
@@ -2502,7 +2507,9 @@ export function AgentSheet() {
2502
2507
  ? 'Claude CLI uses its own built-in capabilities — no additional local tool/platform configuration is needed.'
2503
2508
  : provider === 'codex-cli'
2504
2509
  ? 'OpenAI Codex CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration needed.'
2505
- : 'OpenCode CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration needed.'}
2510
+ : provider === 'hermes'
2511
+ ? 'Hermes Agent runs behind its own API server and tool runtime. SwarmClaw sends prompts to Hermes directly instead of injecting local platform tools.'
2512
+ : 'OpenCode CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration needed.'}
2506
2513
  </p>
2507
2514
  </div>
2508
2515
  )}
@@ -329,6 +329,12 @@ export function StepConnect({
329
329
  <p className="text-[12px] text-text-3">Remote example: <code className="text-text-2">https://your-gateway.ts.net/v1</code>.</p>
330
330
  </div>
331
331
  )}
332
+ {provider === 'hermes' && (
333
+ <div className="mt-2 space-y-0.5">
334
+ <p className="text-[12px] text-text-3">Hermes Agent&apos;s API server defaults to <code className="text-text-2">http://127.0.0.1:8642/v1</code>.</p>
335
+ <p className="text-[12px] text-text-3">Use any reachable local or remote API-server endpoint exposed by Hermes.</p>
336
+ </div>
337
+ )}
332
338
  </div>
333
339
  )}
334
340
 
@@ -265,4 +265,6 @@ test('requiresSetupProviderVerification skips custom providers', () => {
265
265
  assert.equal(requiresSetupProviderVerification('custom'), false)
266
266
  assert.equal(requiresSetupProviderVerification('openclaw'), false)
267
267
  assert.equal(requiresSetupProviderVerification('openai'), true)
268
+ assert.equal(requiresSetupProviderVerification('openrouter'), true)
269
+ assert.equal(requiresSetupProviderVerification('hermes'), true)
268
270
  })
@@ -358,7 +358,7 @@ const PLATFORMS: {
358
358
  tokenLabel: 'SwarmDock Identity Key',
359
359
  tokenHelp: 'Encrypted Ed25519 private key used to authenticate this agent on SwarmDock.',
360
360
  configFields: [
361
- { key: 'apiUrl', label: 'API URL', placeholder: 'https://api.swarmdock.ai', help: 'SwarmDock marketplace API endpoint' },
361
+ { key: 'apiUrl', label: 'API URL', placeholder: 'https://swarmdock-api.onrender.com', help: 'SwarmDock marketplace API endpoint' },
362
362
  { key: 'walletAddress', label: 'Base L2 Wallet Address', placeholder: '0x...', help: 'USDC wallet on Base L2 for payments' },
363
363
  { key: 'agentDescription', label: 'Marketplace Description', placeholder: 'Specialized in...', help: 'Description on your SwarmDock profile' },
364
364
  { key: 'skills', label: 'Skills (comma-separated)', placeholder: 'data-analysis,web-design', help: 'Skill IDs for task matching' },
@@ -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
  }
@@ -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
@@ -0,0 +1,200 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import type { Agent } from '@/types/agent'
5
+ import type { Connector } from '@/types/connector'
6
+ import type { AgentWallet } from '@/types/swarmdock'
7
+
8
+ import {
9
+ buildDesiredSwarmDockProfile,
10
+ buildSwarmDockAgentBackfill,
11
+ buildSwarmDockSkillPayload,
12
+ diffSwarmDockProfile,
13
+ resolveSwarmDockConfig,
14
+ resolveSwarmDockWalletAddress,
15
+ syncSwarmDockProfile,
16
+ } from './swarmdock'
17
+
18
+ function makeConnector(config: Record<string, string> = {}): Connector {
19
+ return {
20
+ id: 'conn-1',
21
+ name: 'SwarmDock Analyst',
22
+ platform: 'swarmdock',
23
+ agentId: 'agent-1',
24
+ chatroomId: null,
25
+ credentialId: null,
26
+ config,
27
+ isEnabled: true,
28
+ status: 'running',
29
+ createdAt: 1,
30
+ updatedAt: 1,
31
+ }
32
+ }
33
+
34
+ function makeAgent(overrides: Partial<Agent> = {}): Agent {
35
+ return {
36
+ id: 'agent-1',
37
+ name: 'SwarmDock Analyst',
38
+ description: 'Local agent',
39
+ systemPrompt: 'You are helpful.',
40
+ provider: 'openai',
41
+ model: 'gpt-4.1',
42
+ createdAt: 1,
43
+ updatedAt: 1,
44
+ ...overrides,
45
+ }
46
+ }
47
+
48
+ function makeWallet(overrides: Partial<AgentWallet> = {}): AgentWallet {
49
+ return {
50
+ id: 'wallet-1',
51
+ agentId: 'agent-1',
52
+ walletAddress: '0x000000000000000000000000000000000000dEaD',
53
+ chain: 'base',
54
+ createdAt: 1,
55
+ ...overrides,
56
+ }
57
+ }
58
+
59
+ test('resolveSwarmDockWalletAddress only accepts the selected wallet for the owning agent', () => {
60
+ const agent = makeAgent({ swarmdockWalletId: 'wallet-1' })
61
+
62
+ assert.equal(resolveSwarmDockWalletAddress(agent, makeWallet()), '0x000000000000000000000000000000000000dEaD')
63
+ assert.equal(resolveSwarmDockWalletAddress(agent, makeWallet({ id: 'wallet-2' })), '')
64
+ assert.equal(resolveSwarmDockWalletAddress(agent, makeWallet({ agentId: 'agent-2' })), '')
65
+ })
66
+
67
+ test('resolveSwarmDockConfig uses agent defaults and wallet fallback when connector config is incomplete', () => {
68
+ const connector = makeConnector({ autoDiscover: 'true' })
69
+ const agent = makeAgent({
70
+ swarmdockDescription: 'Marketplace specialist',
71
+ swarmdockSkills: ['data-analysis', 'reporting'],
72
+ swarmdockMarketplace: { enabled: true, autoDiscover: false, maxBudgetUsdc: '2500000', autoBid: false, autoBidMaxPrice: '0', taskNotifications: true, preferredCategories: [] },
73
+ })
74
+
75
+ const config = resolveSwarmDockConfig(connector, agent, '0x000000000000000000000000000000000000dEaD')
76
+
77
+ assert.equal(config.apiUrl, 'https://swarmdock-api.onrender.com')
78
+ assert.equal(config.walletAddress, '0x000000000000000000000000000000000000dEaD')
79
+ assert.equal(config.agentDescription, 'Marketplace specialist')
80
+ assert.equal(config.skills, 'data-analysis,reporting')
81
+ assert.equal(config.autoDiscover, true)
82
+ assert.equal(config.maxBudget, '2500000')
83
+ })
84
+
85
+ test('buildSwarmDockSkillPayload produces stable skill definitions', () => {
86
+ assert.deepEqual(buildSwarmDockSkillPayload('data-analysis'), [{
87
+ skillId: 'data-analysis',
88
+ skillName: 'data analysis',
89
+ description: 'data-analysis capability',
90
+ category: 'data-analysis',
91
+ tags: [],
92
+ inputModes: ['text'],
93
+ outputModes: ['text'],
94
+ pricingModel: 'per-task',
95
+ basePrice: '1000000',
96
+ examplePrompts: [
97
+ 'Perform a data analysis task',
98
+ 'Help me with data analysis',
99
+ 'I need data analysis work done',
100
+ 'Complete a data analysis assignment',
101
+ 'Handle a data analysis request',
102
+ ],
103
+ }])
104
+ })
105
+
106
+ test('diffSwarmDockProfile is a no-op when the live profile already matches local state', () => {
107
+ const connector = makeConnector()
108
+ const agent = makeAgent({ swarmdockDescription: 'Marketplace specialist', swarmdockSkills: ['data-analysis'] })
109
+ const desired = buildDesiredSwarmDockProfile(
110
+ connector,
111
+ resolveSwarmDockConfig(connector, agent, '0x000000000000000000000000000000000000dEaD'),
112
+ agent,
113
+ )
114
+
115
+ const diff = diffSwarmDockProfile({
116
+ id: 'dock-agent-1',
117
+ did: 'did:key:test',
118
+ createdAt: '2026-04-01T12:00:00.000Z',
119
+ displayName: desired.displayName,
120
+ description: desired.description,
121
+ framework: desired.framework,
122
+ modelProvider: desired.modelProvider ?? null,
123
+ modelName: desired.modelName ?? null,
124
+ walletAddress: desired.walletAddress,
125
+ skills: desired.skills,
126
+ }, desired)
127
+
128
+ assert.deepEqual(diff.profileFields, {})
129
+ assert.equal(diff.shouldUpdateSkills, false)
130
+ })
131
+
132
+ test('syncSwarmDockProfile patches drifted fields and updates skills only when needed', async () => {
133
+ const desired = {
134
+ displayName: 'SwarmDock Analyst',
135
+ description: 'Marketplace specialist',
136
+ framework: 'swarmclaw',
137
+ modelProvider: 'openai',
138
+ modelName: 'gpt-4.1',
139
+ walletAddress: '0x000000000000000000000000000000000000dEaD',
140
+ skills: buildSwarmDockSkillPayload('data-analysis'),
141
+ }
142
+
143
+ const calls: {
144
+ profileUpdates: unknown[]
145
+ skillUpdates: unknown[]
146
+ } = {
147
+ profileUpdates: [],
148
+ skillUpdates: [],
149
+ }
150
+
151
+ const result = await syncSwarmDockProfile(
152
+ {
153
+ profile: {
154
+ get: async () => ({
155
+ id: 'dock-agent-1',
156
+ did: 'did:key:test',
157
+ createdAt: '2026-04-01T12:00:00.000Z',
158
+ displayName: 'Old Name',
159
+ description: null,
160
+ framework: null,
161
+ modelProvider: null,
162
+ modelName: null,
163
+ walletAddress: '0x0000000000000000000000000000000000000000',
164
+ skills: [],
165
+ }),
166
+ update: async (fields) => {
167
+ calls.profileUpdates.push(fields)
168
+ },
169
+ updateSkills: async (skills) => {
170
+ calls.skillUpdates.push(skills)
171
+ },
172
+ },
173
+ },
174
+ desired,
175
+ )
176
+
177
+ assert.equal(result.updatedProfile, true)
178
+ assert.equal(result.updatedSkills, true)
179
+ assert.deepEqual(calls.profileUpdates, [{
180
+ displayName: 'SwarmDock Analyst',
181
+ description: 'Marketplace specialist',
182
+ framework: 'swarmclaw',
183
+ modelProvider: 'openai',
184
+ modelName: 'gpt-4.1',
185
+ walletAddress: '0x000000000000000000000000000000000000dEaD',
186
+ }])
187
+ assert.equal(calls.skillUpdates.length, 1)
188
+ })
189
+
190
+ test('buildSwarmDockAgentBackfill uses the live profile createdAt timestamp', () => {
191
+ const backfill = buildSwarmDockAgentBackfill({
192
+ id: 'dock-agent-1',
193
+ did: 'did:key:test',
194
+ createdAt: '2026-04-01T12:00:00.000Z',
195
+ })
196
+
197
+ assert.equal(backfill.swarmdockAgentId, 'dock-agent-1')
198
+ assert.equal(backfill.swarmdockDid, 'did:key:test')
199
+ assert.equal(backfill.swarmdockListedAt, Date.parse('2026-04-01T12:00:00.000Z'))
200
+ })
@@ -3,12 +3,54 @@ 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
5
  import type { Agent } from '@/types/agent'
6
+ import type { AgentWallet } from '@/types/swarmdock'
6
7
  import type { PlatformConnector, ConnectorInstance } from '@/lib/server/connectors/types'
7
8
  import { createBoardTaskFromAssignment, updateBoardTaskFromEvent, findBoardTaskBySwarmdockId } from './swarmdock-tasks'
8
9
  import { shouldAutoBid, submitAutoBid } from './swarmdock-bidding'
9
- import type { Task, SSEEvent, TaskSubmitInput } from '@swarmdock/shared'
10
+ import type {
11
+ Agent as SwarmDockAgentProfile,
12
+ AgentSkill,
13
+ AgentUpdateInput,
14
+ SSEEvent,
15
+ Task,
16
+ TaskSubmitInput,
17
+ } from '@swarmdock/shared'
10
18
 
11
19
  const TAG = 'swarmdock'
20
+ const DEFAULT_SWARMDOCK_API_URL = 'https://swarmdock-api.onrender.com'
21
+
22
+ export interface SwarmDockSkillPayload {
23
+ skillId: string
24
+ skillName: string
25
+ description: string
26
+ category: string
27
+ tags: string[]
28
+ inputModes: string[]
29
+ outputModes: string[]
30
+ pricingModel: string
31
+ basePrice: string
32
+ examplePrompts: string[]
33
+ }
34
+
35
+ export interface DesiredSwarmDockProfile {
36
+ displayName: string
37
+ description: string
38
+ framework: string
39
+ modelProvider?: string
40
+ modelName?: string
41
+ walletAddress: string
42
+ skills: SwarmDockSkillPayload[]
43
+ }
44
+
45
+ type SwarmDockProfileSnapshot = Pick<
46
+ SwarmDockAgentProfile,
47
+ 'id' | 'did' | 'createdAt' | 'displayName' | 'description' | 'framework' | 'modelProvider' | 'modelName' | 'walletAddress'
48
+ > & {
49
+ skills?: Array<Pick<
50
+ AgentSkill,
51
+ 'skillId' | 'skillName' | 'description' | 'category' | 'tags' | 'inputModes' | 'outputModes' | 'pricingModel' | 'basePrice' | 'examplePrompts'
52
+ >>
53
+ }
12
54
 
13
55
  interface SwarmDockConfig {
14
56
  apiUrl: string
@@ -20,19 +62,174 @@ interface SwarmDockConfig {
20
62
  paymentPrivateKey?: string
21
63
  }
22
64
 
23
- function parseConfig(connector: Connector, agent?: Agent): SwarmDockConfig {
65
+ function clean(value: unknown): string {
66
+ return typeof value === 'string' ? value.trim() : ''
67
+ }
68
+
69
+ function parseTimestamp(value: string | null | undefined): number | null {
70
+ if (!value) return null
71
+ const parsed = Date.parse(value)
72
+ return Number.isFinite(parsed) ? parsed : null
73
+ }
74
+
75
+ export function resolveSwarmDockWalletAddress(agent?: Agent, wallet?: AgentWallet | null): string {
76
+ if (!agent?.swarmdockWalletId || !wallet) return ''
77
+ if (wallet.id !== agent.swarmdockWalletId) return ''
78
+ if (wallet.agentId !== agent.id) return ''
79
+ return clean(wallet.walletAddress)
80
+ }
81
+
82
+ export function resolveSwarmDockConfig(
83
+ connector: Connector,
84
+ agent?: Agent,
85
+ fallbackWalletAddress?: string | null,
86
+ ): SwarmDockConfig {
24
87
  const c = connector.config || {}
25
88
  return {
26
- apiUrl: c.apiUrl || 'https://swarmdock-api.onrender.com',
27
- walletAddress: c.walletAddress || '',
28
- agentDescription: c.agentDescription || agent?.swarmdockDescription || connector.name || '',
29
- skills: c.skills || (agent?.swarmdockSkills?.join(',') ?? ''),
89
+ apiUrl: clean(c.apiUrl) || DEFAULT_SWARMDOCK_API_URL,
90
+ walletAddress: clean(c.walletAddress) || clean(fallbackWalletAddress),
91
+ agentDescription: clean(c.agentDescription) || clean(agent?.swarmdockDescription) || clean(connector.name),
92
+ skills: clean(c.skills) || (Array.isArray(agent?.swarmdockSkills) ? agent.swarmdockSkills.join(',') : ''),
30
93
  autoDiscover: c.autoDiscover === 'true' || (agent?.swarmdockMarketplace?.autoDiscover ?? false),
31
- maxBudget: c.maxBudget || agent?.swarmdockMarketplace?.maxBudgetUsdc || '0',
32
- paymentPrivateKey: c.paymentPrivateKey || undefined,
94
+ maxBudget: clean(c.maxBudget) || clean(agent?.swarmdockMarketplace?.maxBudgetUsdc) || '0',
95
+ paymentPrivateKey: clean(c.paymentPrivateKey) || undefined,
33
96
  }
34
97
  }
35
98
 
99
+ export function buildSwarmDockSkillPayload(skills: string): SwarmDockSkillPayload[] {
100
+ return skills
101
+ .split(',')
102
+ .map((value) => value.trim())
103
+ .filter(Boolean)
104
+ .map((skillId) => ({
105
+ skillId,
106
+ skillName: skillId.replace(/-/g, ' '),
107
+ description: `${skillId} capability`,
108
+ category: skillId,
109
+ tags: [],
110
+ basePrice: '1000000',
111
+ inputModes: ['text'],
112
+ outputModes: ['text'],
113
+ pricingModel: 'per-task',
114
+ examplePrompts: generateExamplePrompts(skillId),
115
+ }))
116
+ }
117
+
118
+ export function buildDesiredSwarmDockProfile(
119
+ connector: Connector,
120
+ config: SwarmDockConfig,
121
+ agent?: Agent,
122
+ ): DesiredSwarmDockProfile {
123
+ return {
124
+ displayName: connector.name,
125
+ description: config.agentDescription,
126
+ framework: 'swarmclaw',
127
+ modelProvider: agent?.provider,
128
+ modelName: agent?.model,
129
+ walletAddress: config.walletAddress,
130
+ skills: buildSwarmDockSkillPayload(config.skills),
131
+ }
132
+ }
133
+
134
+ function normalizeComparableSkills(skills: Array<Pick<
135
+ AgentSkill | SwarmDockSkillPayload,
136
+ 'skillId' | 'skillName' | 'description' | 'category' | 'tags' | 'inputModes' | 'outputModes' | 'pricingModel' | 'basePrice' | 'examplePrompts'
137
+ >>): SwarmDockSkillPayload[] {
138
+ return skills
139
+ .map((skill) => ({
140
+ skillId: skill.skillId,
141
+ skillName: skill.skillName,
142
+ description: skill.description,
143
+ category: skill.category,
144
+ tags: [...(skill.tags ?? [])],
145
+ inputModes: [...(skill.inputModes ?? [])],
146
+ outputModes: [...(skill.outputModes ?? [])],
147
+ pricingModel: skill.pricingModel,
148
+ basePrice: String(skill.basePrice),
149
+ examplePrompts: [...(skill.examplePrompts ?? [])],
150
+ }))
151
+ .sort((a, b) => a.skillId.localeCompare(b.skillId))
152
+ }
153
+
154
+ export function diffSwarmDockProfile(
155
+ liveProfile: SwarmDockProfileSnapshot,
156
+ desired: DesiredSwarmDockProfile,
157
+ ): { profileFields: AgentUpdateInput; shouldUpdateSkills: boolean } {
158
+ const profileFields: AgentUpdateInput = {}
159
+
160
+ if (liveProfile.displayName !== desired.displayName) profileFields.displayName = desired.displayName
161
+ if ((liveProfile.description ?? '') !== desired.description) profileFields.description = desired.description
162
+ if ((liveProfile.framework ?? '') !== desired.framework) profileFields.framework = desired.framework
163
+ if ((liveProfile.modelProvider ?? '') !== (desired.modelProvider ?? '')) profileFields.modelProvider = desired.modelProvider ?? ''
164
+ if ((liveProfile.modelName ?? '') !== (desired.modelName ?? '')) profileFields.modelName = desired.modelName ?? ''
165
+ if (liveProfile.walletAddress !== desired.walletAddress) profileFields.walletAddress = desired.walletAddress
166
+
167
+ const liveSkills = normalizeComparableSkills(liveProfile.skills ?? [])
168
+ const desiredSkills = normalizeComparableSkills(desired.skills)
169
+ const shouldUpdateSkills = JSON.stringify(liveSkills) !== JSON.stringify(desiredSkills)
170
+
171
+ return { profileFields, shouldUpdateSkills }
172
+ }
173
+
174
+ export async function syncSwarmDockProfile(
175
+ client: {
176
+ profile: {
177
+ get: () => Promise<SwarmDockProfileSnapshot>
178
+ update: (fields: AgentUpdateInput) => Promise<unknown>
179
+ updateSkills: (skills: SwarmDockSkillPayload[]) => Promise<unknown>
180
+ }
181
+ },
182
+ desired: DesiredSwarmDockProfile,
183
+ ): Promise<{ liveProfile: SwarmDockProfileSnapshot; updatedProfile: boolean; updatedSkills: boolean }> {
184
+ const liveProfile = await client.profile.get()
185
+ const { profileFields, shouldUpdateSkills } = diffSwarmDockProfile(liveProfile, desired)
186
+ const updatedProfile = Object.keys(profileFields).length > 0
187
+
188
+ if (updatedProfile) {
189
+ await client.profile.update(profileFields)
190
+ }
191
+ if (shouldUpdateSkills) {
192
+ await client.profile.updateSkills(desired.skills)
193
+ }
194
+
195
+ return { liveProfile, updatedProfile, updatedSkills: shouldUpdateSkills }
196
+ }
197
+
198
+ export function buildSwarmDockAgentBackfill(
199
+ profile: Pick<SwarmDockAgentProfile, 'id' | 'did'> & { createdAt?: string | null },
200
+ ): Pick<Agent, 'swarmdockAgentId' | 'swarmdockDid' | 'swarmdockListedAt'> {
201
+ return {
202
+ swarmdockAgentId: profile.id,
203
+ swarmdockDid: profile.did,
204
+ swarmdockListedAt: parseTimestamp(profile.createdAt) ?? Date.now(),
205
+ }
206
+ }
207
+
208
+ async function persistSwarmDockAgentBackfill(
209
+ agent: Agent | undefined,
210
+ profile: Pick<SwarmDockAgentProfile, 'id' | 'did'> & { createdAt?: string | null },
211
+ ) {
212
+ if (!agent) return
213
+ const backfill = buildSwarmDockAgentBackfill(profile)
214
+ const { patchAgent } = await import('@/lib/server/agents/agent-repository')
215
+ patchAgent(agent.id, (current) => {
216
+ if (!current) return null
217
+
218
+ const needsId = !current.swarmdockAgentId
219
+ const needsDid = !current.swarmdockDid
220
+ const needsListedAt = current.swarmdockListedAt == null
221
+ if (!needsId && !needsDid && !needsListedAt) return current
222
+
223
+ return {
224
+ ...current,
225
+ ...(needsId ? { swarmdockAgentId: backfill.swarmdockAgentId } : {}),
226
+ ...(needsDid ? { swarmdockDid: backfill.swarmdockDid } : {}),
227
+ ...(needsListedAt ? { swarmdockListedAt: backfill.swarmdockListedAt } : {}),
228
+ updatedAt: Date.now(),
229
+ }
230
+ })
231
+ }
232
+
36
233
  function buildTaskPrompt(task: Task): string {
37
234
  const lines: string[] = [
38
235
  `# SwarmDock Task: ${task.title}`,
@@ -89,7 +286,13 @@ const swarmdock: PlatformConnector = {
89
286
  const { loadAgent } = await import('@/lib/server/agents/agent-repository')
90
287
  agent = (await loadAgent(connector.agentId)) ?? undefined
91
288
  }
92
- const config = parseConfig(connector, agent)
289
+ let walletAddressFallback = ''
290
+ if (agent?.swarmdockWalletId) {
291
+ const { loadWallet } = await import('@/lib/server/wallets/wallet-repository')
292
+ walletAddressFallback = resolveSwarmDockWalletAddress(agent, loadWallet(agent.swarmdockWalletId))
293
+ }
294
+
295
+ const config = resolveSwarmDockConfig(connector, agent, walletAddressFallback)
93
296
  const connectorId = connector.id
94
297
  const agentId = connector.agentId || ''
95
298
  const privateKey = _botToken || ''
@@ -118,47 +321,21 @@ const swarmdock: PlatformConnector = {
118
321
  : {}),
119
322
  })
120
323
 
121
- // Register agent on SwarmDock (Ed25519 challenge-response)
122
- const skillList = config.skills
123
- .split(',')
124
- .map((s) => s.trim())
125
- .filter(Boolean)
126
- .map((skillId) => ({
127
- skillId,
128
- skillName: skillId.replace(/-/g, ' '),
129
- description: `${skillId} capability`,
130
- category: skillId,
131
- basePrice: '1000000', // $1.00 default
132
- inputModes: ['text'],
133
- outputModes: ['text'],
134
- examplePrompts: generateExamplePrompts(skillId),
135
- }))
324
+ const desiredProfile = buildDesiredSwarmDockProfile(connector, config, agent)
136
325
 
137
326
  log.info(TAG, `Registering agent "${connector.name}" on SwarmDock at ${config.apiUrl}`)
138
327
  try {
139
328
  const registration = await client.register({
140
- displayName: connector.name,
141
- description: config.agentDescription,
142
- framework: 'swarmclaw',
143
- walletAddress: config.walletAddress,
144
- skills: skillList,
329
+ displayName: desiredProfile.displayName,
330
+ description: desiredProfile.description,
331
+ framework: desiredProfile.framework,
332
+ modelProvider: desiredProfile.modelProvider,
333
+ modelName: desiredProfile.modelName,
334
+ walletAddress: desiredProfile.walletAddress,
335
+ skills: desiredProfile.skills,
145
336
  })
146
337
  log.info(TAG, `Registered as ${registration.agent.did} (trust level ${registration.agent.trustLevel})`)
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
- }
338
+ await persistSwarmDockAgentBackfill(agent, registration.agent)
162
339
 
163
340
  logActivity({
164
341
  entityType: 'connector',
@@ -171,6 +348,14 @@ const swarmdock: PlatformConnector = {
171
348
  if (err instanceof ConflictError) {
172
349
  log.info(TAG, `Agent already registered, authenticating`)
173
350
  await client.authenticate()
351
+ const syncResult = await syncSwarmDockProfile(client, desiredProfile)
352
+ await persistSwarmDockAgentBackfill(agent, syncResult.liveProfile)
353
+ if (syncResult.updatedProfile || syncResult.updatedSkills) {
354
+ log.info(
355
+ TAG,
356
+ `Synchronized live SwarmDock profile${syncResult.updatedProfile ? ' fields' : ''}${syncResult.updatedProfile && syncResult.updatedSkills ? ' and' : ''}${syncResult.updatedSkills ? ' skills' : ''}`,
357
+ )
358
+ }
174
359
  } else {
175
360
  throw err
176
361
  }
@@ -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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import test from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-swarmdock-tool-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ test('swarmdock tool browses tasks with the plural skills filter', () => {
36
+ const output = runWithTempDataDir(`
37
+ const storageMod = await import('./src/lib/server/storage')
38
+ const toolsMod = await import('./src/lib/server/session-tools')
39
+ const storage = storageMod.default || storageMod
40
+ const toolsApi = toolsMod.default || toolsMod
41
+
42
+ let requestedUrl = null
43
+ globalThis.fetch = async (url) => {
44
+ requestedUrl = String(url)
45
+ return new Response(JSON.stringify({ tasks: [{ id: 'task-1' }] }), {
46
+ status: 200,
47
+ headers: { 'content-type': 'application/json' },
48
+ })
49
+ }
50
+
51
+ storage.saveAgents({
52
+ agent_1: {
53
+ id: 'agent_1',
54
+ name: 'SwarmDock Agent',
55
+ description: 'local',
56
+ systemPrompt: 'You are helpful.',
57
+ provider: 'openai',
58
+ model: 'gpt-4.1',
59
+ swarmdockEnabled: true,
60
+ swarmdockSkills: ['data-analysis'],
61
+ createdAt: 1,
62
+ updatedAt: 1,
63
+ },
64
+ })
65
+
66
+ const built = await toolsApi.buildSessionTools(process.env.WORKSPACE_DIR, ['swarmdock'], {
67
+ sessionId: 'session-1',
68
+ agentId: 'agent_1',
69
+ delegationEnabled: false,
70
+ delegationTargetMode: 'all',
71
+ delegationTargetAgentIds: [],
72
+ })
73
+
74
+ try {
75
+ const tool = built.tools.find((entry) => entry.name === 'swarmdock')
76
+ const raw = await tool.invoke({ action: 'browse_tasks', skillFilter: 'data-analysis', limit: 2 })
77
+ console.log(JSON.stringify({ requestedUrl, body: JSON.parse(raw) }))
78
+ } finally {
79
+ await built.cleanup()
80
+ }
81
+ `)
82
+
83
+ assert.match(String(output.requestedUrl || ''), /\/api\/v1\/tasks\?limit=2&skills=data-analysis$/)
84
+ assert.deepEqual(output.body, { tasks: [{ id: 'task-1' }] })
85
+ })
@@ -30,7 +30,7 @@ async function executeSwarmDock(input: SwarmDockInput, bctx: ToolBuildContext):
30
30
  switch (input.action) {
31
31
  case 'browse_tasks': {
32
32
  const apiUrl = process.env.SWARMDOCK_API_URL || 'https://swarmdock-api.onrender.com'
33
- const res = await fetch(`${apiUrl}/api/v1/tasks?limit=${input.limit || 10}${input.skillFilter ? `&skill=${input.skillFilter}` : ''}`)
33
+ const res = await fetch(`${apiUrl}/api/v1/tasks?limit=${input.limit || 10}${input.skillFilter ? `&skills=${input.skillFilter}` : ''}`)
34
34
  if (!res.ok) {
35
35
  const text = await res.text().catch(() => 'Unknown error')
36
36
  return JSON.stringify({ error: `SwarmDock API error ${res.status}: ${text}` })
@@ -669,7 +669,7 @@ if (!IS_BUILD_BOOTSTRAP) {
669
669
  ## Platform
670
670
 
671
671
  - **Agents** — Create specialized AI agents (Agents tab → "+") with a provider, model, system prompt, and tools. "Generate with AI" scaffolds agents from a description. Enable cross-agent delegation when an agent should assign work to others.
672
- - **Providers** — Configure LLM backends in Settings → Providers: Claude Code CLI, OpenAI Codex CLI, OpenCode CLI, Anthropic, OpenAI, Google Gemini, DeepSeek, Groq, Together AI, Mistral AI, xAI (Grok), Fireworks AI, Ollama, OpenClaw, or custom OpenAI-compatible endpoints.
672
+ - **Providers** — Configure LLM backends in Settings → Providers: Claude Code CLI, OpenAI Codex CLI, OpenCode CLI, Gemini CLI, GitHub Copilot CLI, Anthropic, OpenAI, OpenRouter, Google Gemini, DeepSeek, Groq, Together AI, Mistral AI, xAI (Grok), Fireworks AI, Nebius, DeepInfra, Ollama, OpenClaw, Hermes Agent, or custom OpenAI-compatible endpoints.
673
673
  - **Tasks** — The Task Board tracks work items. Assign agents and they'll execute autonomously.
674
674
  - **Schedules** — Cron-based recurring jobs that run agents or tasks automatically.
675
675
  - **Skills** — Reusable markdown instruction files agents can discover and use by default; pin them to keep favorite workflows always-on.
@@ -27,11 +27,21 @@ test('getDefaultModelForProvider returns non-empty for openai', () => {
27
27
  assert.ok(model, 'openai model should be truthy')
28
28
  })
29
29
 
30
+ test('getDefaultModelForProvider returns non-empty for openrouter', () => {
31
+ const model = getDefaultModelForProvider('openrouter')
32
+ assert.ok(model, 'openrouter model should be truthy')
33
+ })
34
+
30
35
  test('getDefaultModelForProvider returns non-empty for anthropic', () => {
31
36
  const model = getDefaultModelForProvider('anthropic')
32
37
  assert.ok(model, 'anthropic model should be truthy')
33
38
  })
34
39
 
40
+ test('getDefaultModelForProvider returns non-empty for hermes', () => {
41
+ const model = getDefaultModelForProvider('hermes')
42
+ assert.ok(model, 'hermes model should be truthy')
43
+ })
44
+
35
45
  test('getDefaultModelForProvider returns non-empty for ollama', () => {
36
46
  const model = getDefaultModelForProvider('ollama')
37
47
  assert.ok(model, 'ollama model should be truthy')
@@ -6,6 +6,7 @@
6
6
  export type SetupProvider =
7
7
  | 'anthropic'
8
8
  | 'openai'
9
+ | 'openrouter'
9
10
  | 'google'
10
11
  | 'deepseek'
11
12
  | 'groq'
@@ -17,6 +18,7 @@ export type SetupProvider =
17
18
  | 'deepinfra'
18
19
  | 'ollama'
19
20
  | 'openclaw'
21
+ | 'hermes'
20
22
  | 'custom'
21
23
 
22
24
  export interface SetupProviderOption {
@@ -52,6 +54,19 @@ export const SETUP_PROVIDERS: SetupProviderOption[] = [
52
54
  icon: 'O',
53
55
  modelLibraryUrl: 'https://platform.openai.com/docs/models',
54
56
  },
57
+ {
58
+ id: 'openrouter',
59
+ name: 'OpenRouter',
60
+ description: 'One API key for a broad multi-provider model catalog through an OpenAI-compatible API.',
61
+ requiresKey: true,
62
+ supportsEndpoint: false,
63
+ defaultEndpoint: 'https://openrouter.ai/api/v1',
64
+ keyUrl: 'https://openrouter.ai/keys',
65
+ keyLabel: 'openrouter.ai',
66
+ badge: 'Catalog',
67
+ icon: 'R',
68
+ modelLibraryUrl: 'https://openrouter.ai/models',
69
+ },
55
70
  {
56
71
  id: 'openclaw',
57
72
  name: 'OpenClaw',
@@ -64,6 +79,18 @@ export const SETUP_PROVIDERS: SetupProviderOption[] = [
64
79
  badge: 'First-Tier',
65
80
  icon: 'C',
66
81
  },
82
+ {
83
+ id: 'hermes',
84
+ name: 'Hermes Agent',
85
+ description: 'Connect Hermes Agent through its local or remote OpenAI-compatible API server runtime.',
86
+ requiresKey: false,
87
+ supportsEndpoint: true,
88
+ allowMultiple: true,
89
+ defaultEndpoint: 'http://127.0.0.1:8642/v1',
90
+ optionalKey: true,
91
+ badge: 'API Server',
92
+ icon: 'H',
93
+ },
67
94
  {
68
95
  id: 'anthropic',
69
96
  name: 'Anthropic',
@@ -192,7 +219,7 @@ export const SETUP_PROVIDERS: SetupProviderOption[] = [
192
219
  {
193
220
  id: 'custom',
194
221
  name: 'Custom Provider',
195
- description: 'Any OpenAI-compatible API endpoint (OpenRouter, LM Studio, vLLM, etc.).',
222
+ description: 'Any OpenAI-compatible API endpoint (LM Studio, vLLM, local gateways, etc.).',
196
223
  requiresKey: false,
197
224
  supportsEndpoint: true,
198
225
  allowMultiple: true,
@@ -598,6 +625,13 @@ export const DEFAULT_AGENTS: Record<SetupProvider, DefaultAgentConfig> = {
598
625
  model: 'gpt-4o',
599
626
  tools: STARTER_AGENT_TOOLS,
600
627
  },
628
+ openrouter: {
629
+ name: 'Router',
630
+ description: 'A helpful assistant powered through OpenRouter.',
631
+ systemPrompt: SWARMCLAW_ASSISTANT_PROMPT,
632
+ model: 'openai/gpt-4.1-mini',
633
+ tools: STARTER_AGENT_TOOLS,
634
+ },
601
635
  google: {
602
636
  name: 'Gemini',
603
637
  description: 'A helpful Gemini-powered assistant.',
@@ -675,6 +709,13 @@ export const DEFAULT_AGENTS: Record<SetupProvider, DefaultAgentConfig> = {
675
709
  model: '',
676
710
  tools: STARTER_AGENT_TOOLS,
677
711
  },
712
+ hermes: {
713
+ name: 'Hermes',
714
+ description: 'A runtime-backed assistant powered by Hermes Agent.',
715
+ systemPrompt: SWARMCLAW_ASSISTANT_PROMPT,
716
+ model: 'hermes-agent',
717
+ tools: STARTER_AGENT_TOOLS,
718
+ },
678
719
  custom: {
679
720
  name: 'Custom Agent',
680
721
  description: 'An assistant powered by a custom OpenAI-compatible provider.',
@@ -30,7 +30,7 @@ export const AVAILABLE_TOOLS: ToolDefinition[] = [
30
30
  { id: 'replicate', label: 'Replicate', description: 'Run any AI model on Replicate — image generation, LLMs, audio, video, and more', extensionId: 'replicate' },
31
31
  { id: 'google_workspace', label: 'Google Workspace', description: 'Run Google Workspace CLI (`gws`) commands for Drive, Docs, Sheets, Gmail, Calendar, Chat, and more', extensionId: 'google_workspace' },
32
32
  { id: 'swarmfeed', label: 'SwarmFeed', description: 'Post, reply, like, repost, and browse the SwarmFeed social network (auto-enabled when SwarmFeed is on)' },
33
- { id: 'swarmdock', label: 'SwarmDock', description: 'Browse tasks, check status, and manage marketplace profile on SwarmDock (auto-enabled when SwarmDock is on)' },
33
+ { id: 'swarmdock', label: 'SwarmDock', description: 'Browse tasks and inspect marketplace status/profile on SwarmDock (auto-enabled when SwarmDock is on)' },
34
34
  ]
35
35
 
36
36
  /**
@@ -1,4 +1,4 @@
1
- export type ProviderType = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'gemini-cli' | 'copilot-cli' | 'openai' | 'ollama' | 'anthropic' | 'openclaw' | 'google' | 'deepseek' | 'groq' | 'together' | 'mistral' | 'xai' | 'fireworks' | 'nebius' | 'deepinfra'
1
+ export type ProviderType = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'gemini-cli' | 'copilot-cli' | 'openai' | 'openrouter' | 'ollama' | 'anthropic' | 'openclaw' | 'hermes' | 'google' | 'deepseek' | 'groq' | 'together' | 'mistral' | 'xai' | 'fireworks' | 'nebius' | 'deepinfra'
2
2
  export type ProviderId = ProviderType | (string & {})
3
3
 
4
4
  export interface ProviderInfo {