@swarmclawai/swarmclaw 0.7.3 → 0.7.4
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 +47 -40
- package/bin/package-manager.js +157 -0
- package/bin/package-manager.test.js +90 -0
- package/bin/server-cmd.js +38 -7
- package/bin/swarmclaw.js +54 -4
- package/bin/update-cmd.js +48 -10
- package/bin/update-cmd.test.js +55 -0
- package/package.json +8 -3
- package/scripts/postinstall.mjs +26 -0
- package/src/app/api/agents/[id]/route.ts +17 -0
- package/src/app/api/agents/[id]/thread/route.ts +3 -1
- package/src/app/api/agents/route.ts +23 -1
- package/src/app/api/auth/route.ts +1 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
- package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/route.ts +6 -0
- package/src/app/api/chats/[id]/route.ts +12 -0
- package/src/app/api/chats/heartbeat/route.ts +2 -1
- package/src/app/api/chats/route.ts +7 -1
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
- package/src/app/api/external-agents/[id]/route.ts +31 -0
- package/src/app/api/external-agents/register/route.ts +3 -0
- package/src/app/api/external-agents/route.ts +66 -0
- package/src/app/api/gateways/[id]/health/route.ts +28 -0
- package/src/app/api/gateways/[id]/route.ts +79 -0
- package/src/app/api/gateways/route.ts +57 -0
- package/src/app/api/openclaw/gateway/route.ts +10 -7
- package/src/app/api/openclaw/skills/route.ts +1 -1
- package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
- package/src/app/api/schedules/[id]/route.ts +38 -9
- package/src/app/api/schedules/route.ts +51 -28
- package/src/app/api/settings/route.ts +6 -10
- package/src/app/api/setup/doctor/route.ts +6 -4
- package/src/app/api/tasks/[id]/route.ts +2 -1
- package/src/app/api/tasks/bulk/route.ts +2 -2
- package/src/app/page.tsx +126 -15
- package/src/cli/binary.test.js +142 -0
- package/src/cli/index.js +34 -11
- package/src/cli/index.test.js +195 -0
- package/src/cli/index.ts +20 -4
- package/src/cli/server-cmd.test.js +59 -0
- package/src/cli/spec.js +20 -2
- package/src/components/agents/agent-sheet.tsx +249 -7
- package/src/components/agents/inspector-panel.tsx +3 -2
- package/src/components/agents/sandbox-env-panel.tsx +4 -1
- package/src/components/auth/setup-wizard.tsx +970 -275
- package/src/components/chat/chat-area.tsx +41 -14
- package/src/components/chat/chat-card.tsx +2 -1
- package/src/components/chat/chat-header.tsx +8 -13
- package/src/components/chat/chat-list.tsx +58 -20
- package/src/components/chat/message-list.tsx +142 -18
- package/src/components/chatrooms/chatroom-input.tsx +96 -33
- package/src/components/chatrooms/chatroom-list.tsx +141 -72
- package/src/components/chatrooms/chatroom-message.tsx +7 -6
- package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
- package/src/components/chatrooms/chatroom-view.tsx +157 -86
- package/src/components/chatrooms/reaction-picker.tsx +38 -33
- package/src/components/gateways/gateway-sheet.tsx +567 -0
- package/src/components/input/chat-input.tsx +135 -86
- package/src/components/layout/app-layout.tsx +2 -0
- package/src/components/memory/memory-browser.tsx +71 -6
- package/src/components/memory/memory-card.tsx +18 -0
- package/src/components/memory/memory-detail.tsx +58 -31
- package/src/components/memory/memory-sheet.tsx +32 -4
- package/src/components/projects/project-detail.tsx +7 -2
- package/src/components/providers/provider-list.tsx +158 -2
- package/src/components/providers/provider-sheet.tsx +81 -70
- package/src/components/shared/bottom-sheet.tsx +31 -15
- package/src/components/shared/confirm-dialog.tsx +45 -30
- package/src/components/shared/model-combobox.tsx +90 -8
- package/src/components/shared/settings/section-heartbeat.tsx +11 -6
- package/src/components/shared/settings/section-orchestrator.tsx +3 -0
- package/src/components/shared/settings/settings-page.tsx +5 -3
- package/src/components/tasks/approvals-panel.tsx +7 -1
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
- package/src/lib/heartbeat-defaults.ts +48 -0
- package/src/lib/memory-presentation.ts +59 -0
- package/src/lib/provider-model-discovery-client.ts +29 -0
- package/src/lib/providers/index.ts +12 -5
- package/src/lib/runtime-loop.ts +105 -3
- package/src/lib/safe-storage.ts +6 -1
- package/src/lib/server/agent-runtime-config.test.ts +141 -0
- package/src/lib/server/agent-runtime-config.ts +277 -0
- package/src/lib/server/approvals-auto-approve.test.ts +59 -0
- package/src/lib/server/build-llm.test.ts +13 -5
- package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
- package/src/lib/server/chat-execution.ts +159 -71
- package/src/lib/server/chatroom-helpers.test.ts +7 -0
- package/src/lib/server/chatroom-helpers.ts +99 -6
- package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
- package/src/lib/server/connectors/manager.ts +89 -61
- package/src/lib/server/connectors/slack.ts +1 -1
- package/src/lib/server/daemon-state.ts +3 -2
- package/src/lib/server/eval/agent-regression.test.ts +47 -0
- package/src/lib/server/eval/agent-regression.ts +1742 -0
- package/src/lib/server/eval/runner.ts +11 -1
- package/src/lib/server/eval/store.ts +2 -1
- package/src/lib/server/heartbeat-service.ts +10 -4
- package/src/lib/server/main-agent-loop.ts +13 -6
- package/src/lib/server/openclaw-exec-config.ts +4 -2
- package/src/lib/server/openclaw-gateway.ts +123 -36
- package/src/lib/server/orchestrator-lg.ts +1 -2
- package/src/lib/server/orchestrator.ts +3 -2
- package/src/lib/server/plugins.test.ts +9 -1
- package/src/lib/server/plugins.ts +12 -2
- package/src/lib/server/provider-model-discovery.ts +481 -0
- package/src/lib/server/queue.ts +1 -1
- package/src/lib/server/runtime-settings.test.ts +119 -0
- package/src/lib/server/runtime-settings.ts +12 -92
- package/src/lib/server/schedule-normalization.ts +187 -0
- package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
- package/src/lib/server/session-tools/crud.ts +27 -3
- package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
- package/src/lib/server/session-tools/discovery.ts +18 -8
- package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
- package/src/lib/server/session-tools/file.ts +8 -2
- package/src/lib/server/session-tools/http.ts +9 -3
- package/src/lib/server/session-tools/index.ts +31 -1
- package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
- package/src/lib/server/session-tools/monitor.ts +14 -7
- package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
- package/src/lib/server/session-tools/platform.ts +1 -1
- package/src/lib/server/session-tools/plugin-creator.ts +9 -2
- package/src/lib/server/session-tools/sandbox.ts +51 -92
- package/src/lib/server/session-tools/session-info.ts +22 -1
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
- package/src/lib/server/session-tools/shell.ts +2 -2
- package/src/lib/server/session-tools/subagent.ts +3 -1
- package/src/lib/server/session-tools/web.ts +73 -30
- package/src/lib/server/storage.ts +29 -3
- package/src/lib/server/stream-agent-chat.test.ts +61 -0
- package/src/lib/server/stream-agent-chat.ts +139 -4
- package/src/lib/server/structured-extract.ts +1 -1
- package/src/lib/server/task-mention.ts +0 -1
- package/src/lib/server/tool-aliases.ts +37 -6
- package/src/lib/server/tool-capability-policy.ts +1 -1
- package/src/lib/setup-defaults.ts +352 -11
- package/src/lib/tool-definitions.ts +3 -4
- package/src/lib/validation/schemas.ts +55 -1
- package/src/stores/use-app-store.ts +43 -1
- package/src/stores/use-chatroom-store.ts +153 -26
- package/src/types/index.ts +189 -6
- package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import { getProviderList } from '@/lib/providers'
|
|
3
|
+
import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
|
|
4
|
+
import { decryptKey, loadCredentials } from '@/lib/server/storage'
|
|
5
|
+
import type { ProviderInfo, ProviderModelDiscoveryResult } from '@/types'
|
|
6
|
+
|
|
7
|
+
type DiscoveryStrategy = 'openai-compatible' | 'anthropic' | 'google' | 'ollama' | 'openclaw'
|
|
8
|
+
|
|
9
|
+
interface DiscoveryDescriptor {
|
|
10
|
+
providerId: string
|
|
11
|
+
providerName: string
|
|
12
|
+
strategy: DiscoveryStrategy
|
|
13
|
+
endpoint?: string
|
|
14
|
+
requiresApiKey: boolean
|
|
15
|
+
optionalApiKey: boolean
|
|
16
|
+
supportsDiscovery: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DiscoverProviderModelsInput {
|
|
20
|
+
providerId: string
|
|
21
|
+
credentialId?: string | null
|
|
22
|
+
endpoint?: string | null
|
|
23
|
+
force?: boolean
|
|
24
|
+
requiresApiKey?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DiscoveryCacheEntry {
|
|
28
|
+
expiresAt: number
|
|
29
|
+
value: ProviderModelDiscoveryResult
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CLOUD_CACHE_TTL_MS = 15 * 60_000
|
|
33
|
+
const LOCAL_CACHE_TTL_MS = 60_000
|
|
34
|
+
const ERROR_CACHE_TTL_MS = 30_000
|
|
35
|
+
const DISCOVERY_TIMEOUT_MS = 10_000
|
|
36
|
+
const gk = '__swarmclaw_provider_model_discovery__' as const
|
|
37
|
+
|
|
38
|
+
type DiscoveryGlobals = typeof globalThis & {
|
|
39
|
+
[gk]?: {
|
|
40
|
+
cache: Map<string, DiscoveryCacheEntry>
|
|
41
|
+
pending: Map<string, Promise<ProviderModelDiscoveryResult>>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const discoveryGlobals = globalThis as DiscoveryGlobals
|
|
46
|
+
const discoveryState = discoveryGlobals[gk] ?? (discoveryGlobals[gk] = {
|
|
47
|
+
cache: new Map<string, DiscoveryCacheEntry>(),
|
|
48
|
+
pending: new Map<string, Promise<ProviderModelDiscoveryResult>>(),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function clean(value: string | null | undefined): string {
|
|
52
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeEndpoint(raw: string | null | undefined, fallback = ''): string {
|
|
56
|
+
return (clean(raw) || fallback).replace(/\/+$/, '')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function supportsBuiltInModelDiscovery(providerId: string): boolean {
|
|
60
|
+
return !['claude-cli', 'codex-cli', 'opencode-cli', 'fireworks'].includes(providerId)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeGoogleModelsEndpoint(raw: string | null | undefined): string {
|
|
64
|
+
const fallback = 'https://generativelanguage.googleapis.com/v1beta'
|
|
65
|
+
const normalized = normalizeEndpoint(raw, fallback)
|
|
66
|
+
.replace(/\/openai$/i, '')
|
|
67
|
+
.replace(/\/models$/i, '')
|
|
68
|
+
return `${normalized}/models`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveProviderInfo(providerId: string): ProviderInfo | null {
|
|
72
|
+
return getProviderList().find((provider) => provider.id === providerId) || null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveDescriptor(input: DiscoverProviderModelsInput): DiscoveryDescriptor | null {
|
|
76
|
+
const providerId = clean(input.providerId)
|
|
77
|
+
const provider = resolveProviderInfo(providerId)
|
|
78
|
+
const requiresApiKeyOverride = typeof input.requiresApiKey === 'boolean' ? input.requiresApiKey : undefined
|
|
79
|
+
|
|
80
|
+
if (providerId === 'custom') {
|
|
81
|
+
const endpoint = normalizeEndpoint(input.endpoint)
|
|
82
|
+
if (!endpoint) return null
|
|
83
|
+
return {
|
|
84
|
+
providerId,
|
|
85
|
+
providerName: 'Custom Provider',
|
|
86
|
+
strategy: 'openai-compatible',
|
|
87
|
+
endpoint,
|
|
88
|
+
requiresApiKey: requiresApiKeyOverride ?? true,
|
|
89
|
+
optionalApiKey: false,
|
|
90
|
+
supportsDiscovery: true,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (providerId === 'openclaw') {
|
|
95
|
+
return {
|
|
96
|
+
providerId,
|
|
97
|
+
providerName: 'OpenClaw',
|
|
98
|
+
strategy: 'openclaw',
|
|
99
|
+
endpoint: normalizeEndpoint(input.endpoint, 'http://localhost:18789'),
|
|
100
|
+
requiresApiKey: requiresApiKeyOverride ?? false,
|
|
101
|
+
optionalApiKey: true,
|
|
102
|
+
supportsDiscovery: true,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!provider) return null
|
|
107
|
+
const supportsDiscovery = provider.supportsModelDiscovery ?? supportsBuiltInModelDiscovery(providerId)
|
|
108
|
+
if (!supportsDiscovery) {
|
|
109
|
+
return {
|
|
110
|
+
providerId,
|
|
111
|
+
providerName: provider.name,
|
|
112
|
+
strategy: 'openai-compatible',
|
|
113
|
+
endpoint: undefined,
|
|
114
|
+
requiresApiKey: provider.requiresApiKey,
|
|
115
|
+
optionalApiKey: Boolean(provider.optionalApiKey),
|
|
116
|
+
supportsDiscovery: false,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (providerId === 'anthropic') {
|
|
121
|
+
return {
|
|
122
|
+
providerId,
|
|
123
|
+
providerName: provider.name,
|
|
124
|
+
strategy: 'anthropic',
|
|
125
|
+
requiresApiKey: requiresApiKeyOverride ?? provider.requiresApiKey,
|
|
126
|
+
optionalApiKey: Boolean(provider.optionalApiKey),
|
|
127
|
+
supportsDiscovery,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (providerId === 'google') {
|
|
132
|
+
return {
|
|
133
|
+
providerId,
|
|
134
|
+
providerName: provider.name,
|
|
135
|
+
strategy: 'google',
|
|
136
|
+
endpoint: normalizeGoogleModelsEndpoint(input.endpoint || provider.defaultEndpoint || ''),
|
|
137
|
+
requiresApiKey: requiresApiKeyOverride ?? provider.requiresApiKey,
|
|
138
|
+
optionalApiKey: Boolean(provider.optionalApiKey),
|
|
139
|
+
supportsDiscovery,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (providerId === 'ollama') {
|
|
144
|
+
return {
|
|
145
|
+
providerId,
|
|
146
|
+
providerName: provider.name,
|
|
147
|
+
strategy: 'ollama',
|
|
148
|
+
endpoint: normalizeEndpoint(input.endpoint, provider.defaultEndpoint || 'http://localhost:11434'),
|
|
149
|
+
requiresApiKey: requiresApiKeyOverride ?? provider.requiresApiKey,
|
|
150
|
+
optionalApiKey: Boolean(provider.optionalApiKey),
|
|
151
|
+
supportsDiscovery,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const openAiDefault = OPENAI_COMPATIBLE_DEFAULTS[providerId as keyof typeof OPENAI_COMPATIBLE_DEFAULTS]?.defaultEndpoint
|
|
156
|
+
const endpoint = normalizeEndpoint(input.endpoint, provider.defaultEndpoint || openAiDefault || '')
|
|
157
|
+
return {
|
|
158
|
+
providerId,
|
|
159
|
+
providerName: provider.name,
|
|
160
|
+
strategy: 'openai-compatible',
|
|
161
|
+
endpoint,
|
|
162
|
+
requiresApiKey: requiresApiKeyOverride ?? provider.requiresApiKey,
|
|
163
|
+
optionalApiKey: Boolean(provider.optionalApiKey),
|
|
164
|
+
supportsDiscovery,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseErrorMessage(text: string, fallback: string): string {
|
|
169
|
+
const body = text.trim()
|
|
170
|
+
if (!body) return fallback
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(body)
|
|
173
|
+
if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim()
|
|
174
|
+
if (typeof parsed?.error === 'string' && parsed.error.trim()) return parsed.error.trim()
|
|
175
|
+
if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim()
|
|
176
|
+
if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim()
|
|
177
|
+
} catch {
|
|
178
|
+
// Ignore invalid JSON and fall back to the raw text.
|
|
179
|
+
}
|
|
180
|
+
return body.slice(0, 300) || fallback
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveCredentialApiKey(credentialId: string | null | undefined): string | null {
|
|
184
|
+
const id = clean(credentialId)
|
|
185
|
+
if (!id) return null
|
|
186
|
+
try {
|
|
187
|
+
const credentials = loadCredentials()
|
|
188
|
+
const credential = credentials[id]
|
|
189
|
+
if (!credential?.encryptedKey) return null
|
|
190
|
+
return decryptKey(credential.encryptedKey)
|
|
191
|
+
} catch {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hashApiKey(apiKey: string | null): string {
|
|
197
|
+
if (!apiKey) return 'anon'
|
|
198
|
+
return crypto.createHash('sha1').update(apiKey).digest('hex').slice(0, 12)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function dedupeModels(models: string[]): string[] {
|
|
202
|
+
const seen = new Set<string>()
|
|
203
|
+
const result: string[] = []
|
|
204
|
+
for (const model of models) {
|
|
205
|
+
const trimmed = model.trim()
|
|
206
|
+
if (!trimmed) continue
|
|
207
|
+
if (seen.has(trimmed)) continue
|
|
208
|
+
seen.add(trimmed)
|
|
209
|
+
result.push(trimmed)
|
|
210
|
+
}
|
|
211
|
+
return result
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeModelId(modelId: string, strategy: DiscoveryStrategy): string {
|
|
215
|
+
const trimmed = modelId.trim()
|
|
216
|
+
if (!trimmed) return ''
|
|
217
|
+
if (strategy === 'ollama') return trimmed.replace(/:latest$/i, '')
|
|
218
|
+
if (strategy === 'google' && trimmed.startsWith('models/')) return trimmed.slice('models/'.length)
|
|
219
|
+
return trimmed
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function looksLikeChatModel(providerId: string, modelId: string): boolean {
|
|
223
|
+
const normalized = modelId.toLowerCase()
|
|
224
|
+
if (!normalized) return false
|
|
225
|
+
|
|
226
|
+
const universalExclusions = [
|
|
227
|
+
'embedding',
|
|
228
|
+
'rerank',
|
|
229
|
+
'moderation',
|
|
230
|
+
'whisper',
|
|
231
|
+
'transcribe',
|
|
232
|
+
'transcription',
|
|
233
|
+
'tts',
|
|
234
|
+
'speech',
|
|
235
|
+
'text-to-speech',
|
|
236
|
+
'stable-diffusion',
|
|
237
|
+
'sdxl',
|
|
238
|
+
'flux',
|
|
239
|
+
'playground-v2',
|
|
240
|
+
'pix2pix',
|
|
241
|
+
'clip',
|
|
242
|
+
]
|
|
243
|
+
if (universalExclusions.some((token) => normalized.includes(token))) return false
|
|
244
|
+
|
|
245
|
+
if (providerId === 'openai') return /^(gpt-|o1($|-)|o3($|-)|o4($|-)|chatgpt-)/.test(normalized)
|
|
246
|
+
if (providerId === 'anthropic') return normalized.startsWith('claude-')
|
|
247
|
+
if (providerId === 'google') return normalized.startsWith('gemini-')
|
|
248
|
+
if (providerId === 'deepseek') return normalized.startsWith('deepseek-')
|
|
249
|
+
if (providerId === 'xai') return normalized.startsWith('grok-')
|
|
250
|
+
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function extractCandidateModelIds(payload: unknown, strategy: DiscoveryStrategy): string[] {
|
|
255
|
+
const source = payload as {
|
|
256
|
+
data?: unknown[]
|
|
257
|
+
models?: unknown[]
|
|
258
|
+
}
|
|
259
|
+
const candidates: string[] = []
|
|
260
|
+
const append = (value: unknown) => {
|
|
261
|
+
if (typeof value === 'string' && value.trim()) candidates.push(value.trim())
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const readCollection = (items: unknown[] | undefined) => {
|
|
265
|
+
if (!Array.isArray(items)) return
|
|
266
|
+
for (const item of items) {
|
|
267
|
+
if (typeof item === 'string') {
|
|
268
|
+
append(item)
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
if (!item || typeof item !== 'object') continue
|
|
272
|
+
const record = item as { id?: unknown; name?: unknown; model?: unknown; baseModelId?: unknown }
|
|
273
|
+
append(record.id)
|
|
274
|
+
append(record.name)
|
|
275
|
+
append(record.model)
|
|
276
|
+
append(record.baseModelId)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (Array.isArray(payload)) readCollection(payload)
|
|
281
|
+
readCollection(source.data)
|
|
282
|
+
readCollection(source.models)
|
|
283
|
+
|
|
284
|
+
const normalized = candidates
|
|
285
|
+
.map((candidate) => normalizeModelId(candidate, strategy))
|
|
286
|
+
.filter(Boolean)
|
|
287
|
+
return dedupeModels(normalized)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function extractDiscoveredModels(
|
|
291
|
+
providerId: string,
|
|
292
|
+
strategy: DiscoveryStrategy,
|
|
293
|
+
payload: unknown,
|
|
294
|
+
): { models: string[]; rawCount: number } {
|
|
295
|
+
const candidates = extractCandidateModelIds(payload, strategy)
|
|
296
|
+
const filtered = strategy === 'ollama'
|
|
297
|
+
? candidates
|
|
298
|
+
: candidates.filter((candidate) => looksLikeChatModel(providerId, candidate))
|
|
299
|
+
return {
|
|
300
|
+
models: dedupeModels(filtered),
|
|
301
|
+
rawCount: candidates.length,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function ttlForDescriptor(descriptor: DiscoveryDescriptor, ok: boolean): number {
|
|
306
|
+
if (!ok) return ERROR_CACHE_TTL_MS
|
|
307
|
+
if (descriptor.strategy === 'ollama' || descriptor.strategy === 'openclaw') return LOCAL_CACHE_TTL_MS
|
|
308
|
+
return CLOUD_CACHE_TTL_MS
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function buildCacheKey(
|
|
312
|
+
descriptor: DiscoveryDescriptor,
|
|
313
|
+
credentialId: string | null | undefined,
|
|
314
|
+
apiKey: string | null,
|
|
315
|
+
): string {
|
|
316
|
+
return [
|
|
317
|
+
descriptor.providerId,
|
|
318
|
+
descriptor.strategy,
|
|
319
|
+
descriptor.endpoint || '',
|
|
320
|
+
clean(credentialId),
|
|
321
|
+
hashApiKey(apiKey),
|
|
322
|
+
].join('::')
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function fetchModelsFromProvider(
|
|
326
|
+
descriptor: DiscoveryDescriptor,
|
|
327
|
+
apiKey: string | null,
|
|
328
|
+
): Promise<{ ok: boolean; models: string[]; message: string }> {
|
|
329
|
+
const headers: Record<string, string> = {}
|
|
330
|
+
let url = descriptor.endpoint || ''
|
|
331
|
+
|
|
332
|
+
if (descriptor.strategy === 'anthropic') {
|
|
333
|
+
url = 'https://api.anthropic.com/v1/models'
|
|
334
|
+
if (apiKey) headers['x-api-key'] = apiKey
|
|
335
|
+
headers['anthropic-version'] = '2023-06-01'
|
|
336
|
+
} else if (descriptor.strategy === 'google') {
|
|
337
|
+
url = descriptor.endpoint || normalizeGoogleModelsEndpoint('')
|
|
338
|
+
if (apiKey) {
|
|
339
|
+
const searchParams = new URLSearchParams({ key: apiKey })
|
|
340
|
+
url = `${url}?${searchParams.toString()}`
|
|
341
|
+
}
|
|
342
|
+
} else if (descriptor.strategy === 'ollama') {
|
|
343
|
+
url = `${descriptor.endpoint}/api/tags`
|
|
344
|
+
} else {
|
|
345
|
+
url = `${descriptor.endpoint}/models`
|
|
346
|
+
if (apiKey) headers.authorization = `Bearer ${apiKey}`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const res = await fetch(url, {
|
|
350
|
+
headers,
|
|
351
|
+
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
|
352
|
+
cache: 'no-store',
|
|
353
|
+
})
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
const text = await res.text().catch(() => '')
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
models: [],
|
|
359
|
+
message: parseErrorMessage(text, `${descriptor.providerName} returned ${res.status}.`),
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const payload = await res.json().catch(() => ({}))
|
|
364
|
+
const { models, rawCount } = extractDiscoveredModels(descriptor.providerId, descriptor.strategy, payload)
|
|
365
|
+
if (models.length === 0) {
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
models: [],
|
|
369
|
+
message: rawCount > 0
|
|
370
|
+
? `${descriptor.providerName} returned ${rawCount} model(s), but none looked chat-capable.`
|
|
371
|
+
: `${descriptor.providerName} did not report any models.`,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const message = rawCount > models.length
|
|
376
|
+
? `${descriptor.providerName} returned ${rawCount} model(s); showing ${models.length} likely chat models.`
|
|
377
|
+
: `${descriptor.providerName} returned ${models.length} live model(s).`
|
|
378
|
+
return { ok: true, models, message }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildResult(
|
|
382
|
+
descriptor: DiscoveryDescriptor,
|
|
383
|
+
data: Partial<ProviderModelDiscoveryResult> & Pick<ProviderModelDiscoveryResult, 'ok' | 'models'>,
|
|
384
|
+
): ProviderModelDiscoveryResult {
|
|
385
|
+
return {
|
|
386
|
+
ok: data.ok,
|
|
387
|
+
providerId: descriptor.providerId,
|
|
388
|
+
providerName: descriptor.providerName,
|
|
389
|
+
models: data.models,
|
|
390
|
+
cached: Boolean(data.cached),
|
|
391
|
+
fetchedAt: data.fetchedAt ?? Date.now(),
|
|
392
|
+
cacheTtlMs: data.cacheTtlMs ?? ttlForDescriptor(descriptor, data.ok),
|
|
393
|
+
supportsDiscovery: descriptor.supportsDiscovery,
|
|
394
|
+
missingCredential: data.missingCredential,
|
|
395
|
+
message: data.message,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function discoverProviderModels(
|
|
400
|
+
input: DiscoverProviderModelsInput,
|
|
401
|
+
): Promise<ProviderModelDiscoveryResult> {
|
|
402
|
+
const descriptor = resolveDescriptor(input)
|
|
403
|
+
if (!descriptor) {
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
providerId: clean(input.providerId) || 'unknown',
|
|
407
|
+
providerName: undefined,
|
|
408
|
+
models: [],
|
|
409
|
+
cached: false,
|
|
410
|
+
fetchedAt: Date.now(),
|
|
411
|
+
cacheTtlMs: ERROR_CACHE_TTL_MS,
|
|
412
|
+
supportsDiscovery: false,
|
|
413
|
+
message: 'Live model discovery is not available for this provider configuration.',
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!descriptor.supportsDiscovery) {
|
|
418
|
+
return buildResult(descriptor, {
|
|
419
|
+
ok: false,
|
|
420
|
+
models: [],
|
|
421
|
+
message: 'This provider does not expose a live model catalog here. You can still type a model name manually.',
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const apiKey = resolveCredentialApiKey(input.credentialId)
|
|
426
|
+
if (descriptor.requiresApiKey && !apiKey) {
|
|
427
|
+
return buildResult(descriptor, {
|
|
428
|
+
ok: false,
|
|
429
|
+
models: [],
|
|
430
|
+
missingCredential: true,
|
|
431
|
+
message: 'Add an API key to fetch the live model list. Manual model entry still works.',
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const cacheKey = buildCacheKey(descriptor, input.credentialId, apiKey)
|
|
436
|
+
const now = Date.now()
|
|
437
|
+
if (!input.force) {
|
|
438
|
+
const cached = discoveryState.cache.get(cacheKey)
|
|
439
|
+
if (cached && cached.expiresAt > now) {
|
|
440
|
+
return { ...cached.value, cached: true }
|
|
441
|
+
}
|
|
442
|
+
const pending = discoveryState.pending.get(cacheKey)
|
|
443
|
+
if (pending) return pending
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const promise = (async () => {
|
|
447
|
+
const fetchedAt = Date.now()
|
|
448
|
+
try {
|
|
449
|
+
const result = await fetchModelsFromProvider(descriptor, apiKey)
|
|
450
|
+
const built = buildResult(descriptor, {
|
|
451
|
+
ok: result.ok,
|
|
452
|
+
models: result.models,
|
|
453
|
+
message: result.message,
|
|
454
|
+
fetchedAt,
|
|
455
|
+
})
|
|
456
|
+
discoveryState.cache.set(cacheKey, {
|
|
457
|
+
expiresAt: fetchedAt + ttlForDescriptor(descriptor, result.ok),
|
|
458
|
+
value: built,
|
|
459
|
+
})
|
|
460
|
+
return built
|
|
461
|
+
} catch (error) {
|
|
462
|
+
const message = error instanceof Error ? error.message : 'Failed to fetch live models.'
|
|
463
|
+
const built = buildResult(descriptor, {
|
|
464
|
+
ok: false,
|
|
465
|
+
models: [],
|
|
466
|
+
message,
|
|
467
|
+
fetchedAt,
|
|
468
|
+
})
|
|
469
|
+
discoveryState.cache.set(cacheKey, {
|
|
470
|
+
expiresAt: fetchedAt + ERROR_CACHE_TTL_MS,
|
|
471
|
+
value: built,
|
|
472
|
+
})
|
|
473
|
+
return built
|
|
474
|
+
} finally {
|
|
475
|
+
discoveryState.pending.delete(cacheKey)
|
|
476
|
+
}
|
|
477
|
+
})()
|
|
478
|
+
|
|
479
|
+
discoveryState.pending.set(cacheKey, promise)
|
|
480
|
+
return promise
|
|
481
|
+
}
|
package/src/lib/server/queue.ts
CHANGED
|
@@ -550,7 +550,7 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
|
|
|
550
550
|
if (task.status !== 'completed' && task.status !== 'failed') return
|
|
551
551
|
|
|
552
552
|
const sessions = loadSessions()
|
|
553
|
-
|
|
553
|
+
void resolveTaskOwnerUser(scheduleTask, sessions as Record<string, SessionLike>)
|
|
554
554
|
const scheduleNameRaw = typeof scheduleTask.sourceScheduleName === 'string'
|
|
555
555
|
? scheduleTask.sourceScheduleName.trim()
|
|
556
556
|
: ''
|
|
@@ -0,0 +1,119 @@
|
|
|
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 { describe, it } 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-runtime-settings-'))
|
|
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: tempDir,
|
|
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
|
+
describe('runtime settings defaults', () => {
|
|
36
|
+
it('backfills explicit runtime defaults for clean installs', () => {
|
|
37
|
+
const output = runWithTempDataDir(`
|
|
38
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
39
|
+
const runtimeMod = await import('./src/lib/server/runtime-settings.ts')
|
|
40
|
+
const storage = storageMod.default || storageMod
|
|
41
|
+
const runtime = runtimeMod.default || runtimeMod
|
|
42
|
+
console.log(JSON.stringify({
|
|
43
|
+
settings: storage.loadSettings(),
|
|
44
|
+
runtime: runtime.loadRuntimeSettings(),
|
|
45
|
+
}))
|
|
46
|
+
`)
|
|
47
|
+
|
|
48
|
+
assert.equal(output.settings.loopMode, 'bounded')
|
|
49
|
+
assert.equal(output.settings.agentLoopRecursionLimit, 60)
|
|
50
|
+
assert.equal(output.settings.orchestratorLoopRecursionLimit, 80)
|
|
51
|
+
assert.equal(output.settings.legacyOrchestratorMaxTurns, 16)
|
|
52
|
+
assert.equal(output.settings.ongoingLoopMaxIterations, 250)
|
|
53
|
+
assert.equal(output.settings.ongoingLoopMaxRuntimeMinutes, 60)
|
|
54
|
+
assert.equal(output.settings.delegationMaxDepth, 3)
|
|
55
|
+
assert.equal(output.settings.shellCommandTimeoutSec, 30)
|
|
56
|
+
assert.equal(output.settings.claudeCodeTimeoutSec, 1800)
|
|
57
|
+
assert.equal(output.settings.cliProcessTimeoutSec, 1800)
|
|
58
|
+
assert.equal(output.settings.heartbeatIntervalSec, 1800)
|
|
59
|
+
assert.equal(output.settings.heartbeatAckMaxChars, 300)
|
|
60
|
+
assert.equal(output.settings.heartbeatShowOk, false)
|
|
61
|
+
assert.equal(output.settings.heartbeatShowAlerts, true)
|
|
62
|
+
assert.equal(output.settings.heartbeatTarget, null)
|
|
63
|
+
assert.equal(output.settings.heartbeatPrompt, null)
|
|
64
|
+
assert.equal(output.runtime.agentLoopRecursionLimit, 60)
|
|
65
|
+
assert.equal(output.runtime.orchestratorLoopRecursionLimit, 80)
|
|
66
|
+
assert.equal(output.runtime.legacyOrchestratorMaxTurns, 16)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('clamps invalid persisted runtime settings into the supported range', () => {
|
|
70
|
+
const output = runWithTempDataDir(`
|
|
71
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
72
|
+
const runtimeMod = await import('./src/lib/server/runtime-settings.ts')
|
|
73
|
+
const storage = storageMod.default || storageMod
|
|
74
|
+
const runtime = runtimeMod.default || runtimeMod
|
|
75
|
+
|
|
76
|
+
storage.saveSettings({
|
|
77
|
+
loopMode: 'invalid',
|
|
78
|
+
agentLoopRecursionLimit: 999,
|
|
79
|
+
orchestratorLoopRecursionLimit: -5,
|
|
80
|
+
legacyOrchestratorMaxTurns: 0,
|
|
81
|
+
ongoingLoopMaxIterations: 999999,
|
|
82
|
+
ongoingLoopMaxRuntimeMinutes: -1,
|
|
83
|
+
delegationMaxDepth: 99,
|
|
84
|
+
shellCommandTimeoutSec: 0,
|
|
85
|
+
claudeCodeTimeoutSec: 999999,
|
|
86
|
+
cliProcessTimeoutSec: 'abc',
|
|
87
|
+
heartbeatIntervalSec: 999999,
|
|
88
|
+
heartbeatAckMaxChars: -50,
|
|
89
|
+
heartbeatShowOk: 'yes',
|
|
90
|
+
heartbeatShowAlerts: 'off',
|
|
91
|
+
heartbeatTarget: ' ',
|
|
92
|
+
heartbeatPrompt: ' ',
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
console.log(JSON.stringify({
|
|
96
|
+
settings: storage.loadSettings(),
|
|
97
|
+
runtime: runtime.loadRuntimeSettings(),
|
|
98
|
+
}))
|
|
99
|
+
`)
|
|
100
|
+
|
|
101
|
+
assert.equal(output.settings.loopMode, 'bounded')
|
|
102
|
+
assert.equal(output.settings.agentLoopRecursionLimit, 200)
|
|
103
|
+
assert.equal(output.settings.orchestratorLoopRecursionLimit, 1)
|
|
104
|
+
assert.equal(output.settings.legacyOrchestratorMaxTurns, 1)
|
|
105
|
+
assert.equal(output.settings.ongoingLoopMaxIterations, 5000)
|
|
106
|
+
assert.equal(output.settings.ongoingLoopMaxRuntimeMinutes, 0)
|
|
107
|
+
assert.equal(output.settings.delegationMaxDepth, 12)
|
|
108
|
+
assert.equal(output.settings.shellCommandTimeoutSec, 1)
|
|
109
|
+
assert.equal(output.settings.claudeCodeTimeoutSec, 7200)
|
|
110
|
+
assert.equal(output.settings.cliProcessTimeoutSec, 1800)
|
|
111
|
+
assert.equal(output.settings.heartbeatIntervalSec, 86400)
|
|
112
|
+
assert.equal(output.settings.heartbeatAckMaxChars, 0)
|
|
113
|
+
assert.equal(output.settings.heartbeatShowOk, true)
|
|
114
|
+
assert.equal(output.settings.heartbeatShowAlerts, false)
|
|
115
|
+
assert.equal(output.settings.heartbeatTarget, null)
|
|
116
|
+
assert.equal(output.settings.heartbeatPrompt, null)
|
|
117
|
+
assert.equal(output.runtime.ongoingLoopMaxRuntimeMs, null)
|
|
118
|
+
})
|
|
119
|
+
})
|