@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.
Files changed (147) hide show
  1. package/README.md +47 -40
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +17 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +3 -1
  12. package/src/app/api/agents/route.ts +23 -1
  13. package/src/app/api/auth/route.ts +1 -1
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/route.ts +12 -0
  19. package/src/app/api/chats/heartbeat/route.ts +2 -1
  20. package/src/app/api/chats/route.ts +7 -1
  21. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  22. package/src/app/api/external-agents/[id]/route.ts +31 -0
  23. package/src/app/api/external-agents/register/route.ts +3 -0
  24. package/src/app/api/external-agents/route.ts +66 -0
  25. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  26. package/src/app/api/gateways/[id]/route.ts +79 -0
  27. package/src/app/api/gateways/route.ts +57 -0
  28. package/src/app/api/openclaw/gateway/route.ts +10 -7
  29. package/src/app/api/openclaw/skills/route.ts +1 -1
  30. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  31. package/src/app/api/schedules/[id]/route.ts +38 -9
  32. package/src/app/api/schedules/route.ts +51 -28
  33. package/src/app/api/settings/route.ts +6 -10
  34. package/src/app/api/setup/doctor/route.ts +6 -4
  35. package/src/app/api/tasks/[id]/route.ts +2 -1
  36. package/src/app/api/tasks/bulk/route.ts +2 -2
  37. package/src/app/page.tsx +126 -15
  38. package/src/cli/binary.test.js +142 -0
  39. package/src/cli/index.js +34 -11
  40. package/src/cli/index.test.js +195 -0
  41. package/src/cli/index.ts +20 -4
  42. package/src/cli/server-cmd.test.js +59 -0
  43. package/src/cli/spec.js +20 -2
  44. package/src/components/agents/agent-sheet.tsx +249 -7
  45. package/src/components/agents/inspector-panel.tsx +3 -2
  46. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  47. package/src/components/auth/setup-wizard.tsx +970 -275
  48. package/src/components/chat/chat-area.tsx +41 -14
  49. package/src/components/chat/chat-card.tsx +2 -1
  50. package/src/components/chat/chat-header.tsx +8 -13
  51. package/src/components/chat/chat-list.tsx +58 -20
  52. package/src/components/chat/message-list.tsx +142 -18
  53. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  54. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  55. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  56. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +157 -86
  59. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  60. package/src/components/gateways/gateway-sheet.tsx +567 -0
  61. package/src/components/input/chat-input.tsx +135 -86
  62. package/src/components/layout/app-layout.tsx +2 -0
  63. package/src/components/memory/memory-browser.tsx +71 -6
  64. package/src/components/memory/memory-card.tsx +18 -0
  65. package/src/components/memory/memory-detail.tsx +58 -31
  66. package/src/components/memory/memory-sheet.tsx +32 -4
  67. package/src/components/projects/project-detail.tsx +7 -2
  68. package/src/components/providers/provider-list.tsx +158 -2
  69. package/src/components/providers/provider-sheet.tsx +81 -70
  70. package/src/components/shared/bottom-sheet.tsx +31 -15
  71. package/src/components/shared/confirm-dialog.tsx +45 -30
  72. package/src/components/shared/model-combobox.tsx +90 -8
  73. package/src/components/shared/settings/section-heartbeat.tsx +11 -6
  74. package/src/components/shared/settings/section-orchestrator.tsx +3 -0
  75. package/src/components/shared/settings/settings-page.tsx +5 -3
  76. package/src/components/tasks/approvals-panel.tsx +7 -1
  77. package/src/components/ui/dialog.tsx +2 -2
  78. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  79. package/src/lib/heartbeat-defaults.ts +48 -0
  80. package/src/lib/memory-presentation.ts +59 -0
  81. package/src/lib/provider-model-discovery-client.ts +29 -0
  82. package/src/lib/providers/index.ts +12 -5
  83. package/src/lib/runtime-loop.ts +105 -3
  84. package/src/lib/safe-storage.ts +6 -1
  85. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  86. package/src/lib/server/agent-runtime-config.ts +277 -0
  87. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  88. package/src/lib/server/build-llm.test.ts +13 -5
  89. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  90. package/src/lib/server/chat-execution.ts +159 -71
  91. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  92. package/src/lib/server/chatroom-helpers.ts +99 -6
  93. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  94. package/src/lib/server/connectors/manager.ts +89 -61
  95. package/src/lib/server/connectors/slack.ts +1 -1
  96. package/src/lib/server/daemon-state.ts +3 -2
  97. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  98. package/src/lib/server/eval/agent-regression.ts +1742 -0
  99. package/src/lib/server/eval/runner.ts +11 -1
  100. package/src/lib/server/eval/store.ts +2 -1
  101. package/src/lib/server/heartbeat-service.ts +10 -4
  102. package/src/lib/server/main-agent-loop.ts +13 -6
  103. package/src/lib/server/openclaw-exec-config.ts +4 -2
  104. package/src/lib/server/openclaw-gateway.ts +123 -36
  105. package/src/lib/server/orchestrator-lg.ts +1 -2
  106. package/src/lib/server/orchestrator.ts +3 -2
  107. package/src/lib/server/plugins.test.ts +9 -1
  108. package/src/lib/server/plugins.ts +12 -2
  109. package/src/lib/server/provider-model-discovery.ts +481 -0
  110. package/src/lib/server/queue.ts +1 -1
  111. package/src/lib/server/runtime-settings.test.ts +119 -0
  112. package/src/lib/server/runtime-settings.ts +12 -92
  113. package/src/lib/server/schedule-normalization.ts +187 -0
  114. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  115. package/src/lib/server/session-tools/crud.ts +27 -3
  116. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  117. package/src/lib/server/session-tools/discovery.ts +18 -8
  118. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  119. package/src/lib/server/session-tools/file.ts +8 -2
  120. package/src/lib/server/session-tools/http.ts +9 -3
  121. package/src/lib/server/session-tools/index.ts +31 -1
  122. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  123. package/src/lib/server/session-tools/monitor.ts +14 -7
  124. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  125. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  126. package/src/lib/server/session-tools/platform.ts +1 -1
  127. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  128. package/src/lib/server/session-tools/sandbox.ts +51 -92
  129. package/src/lib/server/session-tools/session-info.ts +22 -1
  130. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  131. package/src/lib/server/session-tools/shell.ts +2 -2
  132. package/src/lib/server/session-tools/subagent.ts +3 -1
  133. package/src/lib/server/session-tools/web.ts +73 -30
  134. package/src/lib/server/storage.ts +29 -3
  135. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  136. package/src/lib/server/stream-agent-chat.ts +139 -4
  137. package/src/lib/server/structured-extract.ts +1 -1
  138. package/src/lib/server/task-mention.ts +0 -1
  139. package/src/lib/server/tool-aliases.ts +37 -6
  140. package/src/lib/server/tool-capability-policy.ts +1 -1
  141. package/src/lib/setup-defaults.ts +352 -11
  142. package/src/lib/tool-definitions.ts +3 -4
  143. package/src/lib/validation/schemas.ts +55 -1
  144. package/src/stores/use-app-store.ts +43 -1
  145. package/src/stores/use-chatroom-store.ts +153 -26
  146. package/src/types/index.ts +189 -6
  147. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
@@ -0,0 +1,29 @@
1
+ import { api } from './api-client'
2
+ import type { ProviderModelDiscoveryResult } from '@/types'
3
+
4
+ export interface DiscoverProviderModelsParams {
5
+ providerId: string
6
+ credentialId?: string | null
7
+ endpoint?: string | null
8
+ force?: boolean
9
+ requiresApiKey?: boolean
10
+ }
11
+
12
+ export function buildProviderModelDiscoveryPath(params: DiscoverProviderModelsParams): string {
13
+ const searchParams = new URLSearchParams()
14
+ if (params.credentialId) searchParams.set('credentialId', params.credentialId)
15
+ if (params.endpoint?.trim()) searchParams.set('endpoint', params.endpoint.trim())
16
+ if (params.force) searchParams.set('force', '1')
17
+ if (typeof params.requiresApiKey === 'boolean') {
18
+ searchParams.set('requiresApiKey', params.requiresApiKey ? '1' : '0')
19
+ }
20
+ const query = searchParams.toString()
21
+ const encodedProviderId = encodeURIComponent(params.providerId)
22
+ return `/providers/${encodedProviderId}/discover-models${query ? `?${query}` : ''}`
23
+ }
24
+
25
+ export function fetchProviderModelDiscovery(
26
+ params: DiscoverProviderModelsParams,
27
+ ): Promise<ProviderModelDiscoveryResult> {
28
+ return api<ProviderModelDiscoveryResult>('GET', buildProviderModelDiscoveryPath(params))
29
+ }
@@ -256,11 +256,16 @@ export function getProviderList(): ProviderInfo[] {
256
256
  const overrides = getModelOverrides()
257
257
  const builtins = Object.values(PROVIDERS)
258
258
  .filter(({ id }) => id !== 'openclaw')
259
- .map(({ handler, ...info }) => ({
260
- ...info,
261
- models: overrides[info.id] || info.models,
262
- defaultModels: info.models,
263
- }))
259
+ .map((provider) => {
260
+ const { handler, ...info } = provider
261
+ void handler
262
+ return {
263
+ ...info,
264
+ models: overrides[info.id] || info.models,
265
+ defaultModels: info.models,
266
+ supportsModelDiscovery: !['claude-cli', 'codex-cli', 'opencode-cli', 'fireworks'].includes(info.id),
267
+ }
268
+ })
264
269
 
265
270
  const customs: ProviderInfo[] = Object.values(getCustomProviders())
266
271
  .filter((c) => c.isEnabled)
@@ -269,6 +274,7 @@ export function getProviderList(): ProviderInfo[] {
269
274
  name: c.name,
270
275
  models: c.models,
271
276
  defaultModels: c.models,
277
+ supportsModelDiscovery: !!(c.baseUrl && c.baseUrl.trim()),
272
278
  requiresApiKey: c.requiresApiKey,
273
279
  requiresEndpoint: false as boolean,
274
280
  defaultEndpoint: c.baseUrl,
@@ -283,6 +289,7 @@ export function getProviderList(): ProviderInfo[] {
283
289
  name: String(p.name),
284
290
  models: p.models as string[],
285
291
  defaultModels: p.models as string[],
292
+ supportsModelDiscovery: Boolean(p.supportsModelDiscovery),
286
293
  requiresApiKey: Boolean(p.requiresApiKey),
287
294
  requiresEndpoint: Boolean(p.requiresEndpoint),
288
295
  defaultEndpoint: p.defaultEndpoint as string | undefined,
@@ -3,9 +3,28 @@ import type { LoopMode } from '@/types'
3
3
  export const DEFAULT_LOOP_MODE: LoopMode = 'bounded'
4
4
 
5
5
  // Loop limits
6
- export const DEFAULT_AGENT_LOOP_RECURSION_LIMIT = 30
7
- export const DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT = 40
8
- export const DEFAULT_LEGACY_ORCHESTRATOR_MAX_TURNS = 10
6
+ export const AGENT_LOOP_RECURSION_LIMIT_MIN = 1
7
+ export const AGENT_LOOP_RECURSION_LIMIT_MAX = 200
8
+ export const ORCHESTRATOR_LOOP_RECURSION_LIMIT_MIN = 1
9
+ export const ORCHESTRATOR_LOOP_RECURSION_LIMIT_MAX = 300
10
+ export const LEGACY_ORCHESTRATOR_MAX_TURNS_MIN = 1
11
+ export const LEGACY_ORCHESTRATOR_MAX_TURNS_MAX = 300
12
+ export const ONGOING_LOOP_MAX_ITERATIONS_MIN = 10
13
+ export const ONGOING_LOOP_MAX_ITERATIONS_MAX = 5000
14
+ export const ONGOING_LOOP_MAX_RUNTIME_MINUTES_MIN = 0
15
+ export const ONGOING_LOOP_MAX_RUNTIME_MINUTES_MAX = 1440
16
+ export const DELEGATION_MAX_DEPTH_MIN = 1
17
+ export const DELEGATION_MAX_DEPTH_MAX = 12
18
+ export const SHELL_COMMAND_TIMEOUT_SEC_MIN = 1
19
+ export const SHELL_COMMAND_TIMEOUT_SEC_MAX = 600
20
+ export const CLAUDE_CODE_TIMEOUT_SEC_MIN = 5
21
+ export const CLAUDE_CODE_TIMEOUT_SEC_MAX = 7200
22
+ export const CLI_PROCESS_TIMEOUT_SEC_MIN = 10
23
+ export const CLI_PROCESS_TIMEOUT_SEC_MAX = 7200
24
+
25
+ export const DEFAULT_AGENT_LOOP_RECURSION_LIMIT = 60
26
+ export const DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT = 80
27
+ export const DEFAULT_LEGACY_ORCHESTRATOR_MAX_TURNS = 16
9
28
  export const DEFAULT_ONGOING_LOOP_MAX_ITERATIONS = 250
10
29
  export const DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES = 60
11
30
  export const DEFAULT_DELEGATION_MAX_DEPTH = 3
@@ -14,3 +33,86 @@ export const DEFAULT_DELEGATION_MAX_DEPTH = 3
14
33
  export const DEFAULT_SHELL_COMMAND_TIMEOUT_SEC = 30
15
34
  export const DEFAULT_CLAUDE_CODE_TIMEOUT_SEC = 1800
16
35
  export const DEFAULT_CLI_PROCESS_TIMEOUT_SEC = 1800
36
+
37
+ function parseIntSetting(value: unknown, fallback: number, min: number, max: number): number {
38
+ const parsed = typeof value === 'number'
39
+ ? value
40
+ : typeof value === 'string'
41
+ ? Number.parseInt(value, 10)
42
+ : Number.NaN
43
+ if (!Number.isFinite(parsed)) return fallback
44
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
45
+ }
46
+
47
+ export interface NormalizedRuntimeSettingFields {
48
+ loopMode: LoopMode
49
+ agentLoopRecursionLimit: number
50
+ orchestratorLoopRecursionLimit: number
51
+ legacyOrchestratorMaxTurns: number
52
+ delegationMaxDepth: number
53
+ ongoingLoopMaxIterations: number
54
+ ongoingLoopMaxRuntimeMinutes: number
55
+ shellCommandTimeoutSec: number
56
+ claudeCodeTimeoutSec: number
57
+ cliProcessTimeoutSec: number
58
+ }
59
+
60
+ export function normalizeRuntimeSettingFields(settings: Record<string, unknown>): NormalizedRuntimeSettingFields {
61
+ return {
62
+ loopMode: settings.loopMode === 'ongoing' ? 'ongoing' : DEFAULT_LOOP_MODE,
63
+ agentLoopRecursionLimit: parseIntSetting(
64
+ settings.agentLoopRecursionLimit,
65
+ DEFAULT_AGENT_LOOP_RECURSION_LIMIT,
66
+ AGENT_LOOP_RECURSION_LIMIT_MIN,
67
+ AGENT_LOOP_RECURSION_LIMIT_MAX,
68
+ ),
69
+ orchestratorLoopRecursionLimit: parseIntSetting(
70
+ settings.orchestratorLoopRecursionLimit,
71
+ DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT,
72
+ ORCHESTRATOR_LOOP_RECURSION_LIMIT_MIN,
73
+ ORCHESTRATOR_LOOP_RECURSION_LIMIT_MAX,
74
+ ),
75
+ legacyOrchestratorMaxTurns: parseIntSetting(
76
+ settings.legacyOrchestratorMaxTurns,
77
+ DEFAULT_LEGACY_ORCHESTRATOR_MAX_TURNS,
78
+ LEGACY_ORCHESTRATOR_MAX_TURNS_MIN,
79
+ LEGACY_ORCHESTRATOR_MAX_TURNS_MAX,
80
+ ),
81
+ delegationMaxDepth: parseIntSetting(
82
+ settings.delegationMaxDepth,
83
+ DEFAULT_DELEGATION_MAX_DEPTH,
84
+ DELEGATION_MAX_DEPTH_MIN,
85
+ DELEGATION_MAX_DEPTH_MAX,
86
+ ),
87
+ ongoingLoopMaxIterations: parseIntSetting(
88
+ settings.ongoingLoopMaxIterations,
89
+ DEFAULT_ONGOING_LOOP_MAX_ITERATIONS,
90
+ ONGOING_LOOP_MAX_ITERATIONS_MIN,
91
+ ONGOING_LOOP_MAX_ITERATIONS_MAX,
92
+ ),
93
+ ongoingLoopMaxRuntimeMinutes: parseIntSetting(
94
+ settings.ongoingLoopMaxRuntimeMinutes,
95
+ DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES,
96
+ ONGOING_LOOP_MAX_RUNTIME_MINUTES_MIN,
97
+ ONGOING_LOOP_MAX_RUNTIME_MINUTES_MAX,
98
+ ),
99
+ shellCommandTimeoutSec: parseIntSetting(
100
+ settings.shellCommandTimeoutSec,
101
+ DEFAULT_SHELL_COMMAND_TIMEOUT_SEC,
102
+ SHELL_COMMAND_TIMEOUT_SEC_MIN,
103
+ SHELL_COMMAND_TIMEOUT_SEC_MAX,
104
+ ),
105
+ claudeCodeTimeoutSec: parseIntSetting(
106
+ settings.claudeCodeTimeoutSec,
107
+ DEFAULT_CLAUDE_CODE_TIMEOUT_SEC,
108
+ CLAUDE_CODE_TIMEOUT_SEC_MIN,
109
+ CLAUDE_CODE_TIMEOUT_SEC_MAX,
110
+ ),
111
+ cliProcessTimeoutSec: parseIntSetting(
112
+ settings.cliProcessTimeoutSec,
113
+ DEFAULT_CLI_PROCESS_TIMEOUT_SEC,
114
+ CLI_PROCESS_TIMEOUT_SEC_MIN,
115
+ CLI_PROCESS_TIMEOUT_SEC_MAX,
116
+ ),
117
+ }
118
+ }
@@ -1,5 +1,10 @@
1
1
  function canUseLocalStorage(): boolean {
2
- return typeof window !== 'undefined' && !!window.localStorage
2
+ if (typeof window === 'undefined') return false
3
+ try {
4
+ return !!window.localStorage
5
+ } catch {
6
+ return false
7
+ }
3
8
  }
4
9
 
5
10
  export function safeStorageGet(key: string): string | null {
@@ -0,0 +1,141 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import type { Agent, GatewayProfile } from '@/types'
4
+ import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
5
+ import {
6
+ applyResolvedRoute,
7
+ resolveAgentRouteCandidatesWithProfiles,
8
+ } from './agent-runtime-config'
9
+
10
+ function makeGateway(overrides: Partial<GatewayProfile> = {}): GatewayProfile {
11
+ const now = Date.now()
12
+ return {
13
+ id: 'gateway-default',
14
+ name: 'Gateway Default',
15
+ provider: 'openclaw',
16
+ endpoint: 'https://gateway.example.com/v1',
17
+ wsUrl: 'wss://gateway.example.com',
18
+ credentialId: 'cred-gateway',
19
+ status: 'healthy',
20
+ tags: [],
21
+ isDefault: true,
22
+ createdAt: now,
23
+ updatedAt: now,
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ function makeAgent(overrides: Partial<Agent> = {}): Agent {
29
+ const now = Date.now()
30
+ return {
31
+ id: 'agent-1',
32
+ name: 'OpenClaw Ops',
33
+ description: '',
34
+ systemPrompt: '',
35
+ provider: 'openclaw',
36
+ model: '',
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ ...overrides,
40
+ }
41
+ }
42
+
43
+ test('resolveAgentRouteCandidatesWithProfiles applies the default OpenClaw gateway profile to base agents', () => {
44
+ const gateways = [
45
+ makeGateway(),
46
+ makeGateway({
47
+ id: 'gateway-secondary',
48
+ name: 'Gateway Secondary',
49
+ endpoint: 'https://secondary.example.com/v1',
50
+ wsUrl: 'wss://secondary.example.com',
51
+ credentialId: 'cred-secondary',
52
+ isDefault: false,
53
+ }),
54
+ ]
55
+
56
+ const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent(), gateways)
57
+ assert.ok(route)
58
+ assert.equal(route.provider, 'openclaw')
59
+ assert.equal(route.model, 'default')
60
+ assert.equal(route.gatewayProfileId, 'gateway-default')
61
+ assert.equal(route.credentialId, 'cred-gateway')
62
+ assert.equal(route.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://gateway.example.com/v1'))
63
+ })
64
+
65
+ test('resolveAgentRouteCandidatesWithProfiles respects routing strategy but deprioritizes cooling providers', () => {
66
+ const gateways = [
67
+ makeGateway({
68
+ id: 'gateway-economy',
69
+ name: 'Economy Gateway',
70
+ endpoint: 'https://economy.example.com/v1',
71
+ wsUrl: 'wss://economy.example.com',
72
+ credentialId: 'cred-economy',
73
+ isDefault: false,
74
+ }),
75
+ ]
76
+
77
+ const agent = makeAgent({
78
+ provider: 'openai',
79
+ model: 'gpt-4o',
80
+ gatewayProfileId: null,
81
+ routingStrategy: 'economy',
82
+ routingTargets: [
83
+ {
84
+ id: 'economy-route',
85
+ label: 'Economy',
86
+ provider: 'openclaw',
87
+ model: 'default',
88
+ gatewayProfileId: 'gateway-economy',
89
+ role: 'economy',
90
+ },
91
+ {
92
+ id: 'premium-route',
93
+ label: 'Premium',
94
+ provider: 'openai',
95
+ model: 'gpt-5',
96
+ role: 'premium',
97
+ },
98
+ ],
99
+ })
100
+
101
+ const preferred = resolveAgentRouteCandidatesWithProfiles(agent, gateways)
102
+ assert.equal(preferred[0]?.id, 'economy-route')
103
+ assert.equal(preferred[0]?.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://economy.example.com/v1'))
104
+
105
+ const cooled = resolveAgentRouteCandidatesWithProfiles(agent, gateways, undefined, (providerId) => providerId === 'openclaw')
106
+ assert.equal(cooled[0]?.id, 'base')
107
+ assert.equal(cooled[0]?.provider, 'openai')
108
+ })
109
+
110
+ test('applyResolvedRoute copies gateway, endpoint, and fallback credentials onto a target session-like object', () => {
111
+ const target = {
112
+ provider: 'claude-cli' as const,
113
+ model: 'claude-sonnet-4-5',
114
+ credentialId: null,
115
+ fallbackCredentialIds: [] as string[],
116
+ apiEndpoint: null,
117
+ gatewayProfileId: null,
118
+ }
119
+
120
+ const next = applyResolvedRoute(target, {
121
+ id: 'route-1',
122
+ label: 'Gateway route',
123
+ provider: 'openclaw',
124
+ model: 'default',
125
+ credentialId: 'cred-1',
126
+ fallbackCredentialIds: ['cred-2', 'cred-3'],
127
+ apiEndpoint: 'https://gateway.example.com/v1',
128
+ gatewayProfileId: 'gateway-1',
129
+ priority: 0,
130
+ source: 'routing-target',
131
+ })
132
+
133
+ assert.deepEqual(next, {
134
+ provider: 'openclaw',
135
+ model: 'default',
136
+ credentialId: 'cred-1',
137
+ fallbackCredentialIds: ['cred-2', 'cred-3'],
138
+ apiEndpoint: 'https://gateway.example.com/v1',
139
+ gatewayProfileId: 'gateway-1',
140
+ })
141
+ })
@@ -0,0 +1,277 @@
1
+ import type {
2
+ Agent,
3
+ AgentRoutingStrategy,
4
+ AgentRoutingTarget,
5
+ GatewayProfile,
6
+ ProviderType,
7
+ } from '@/types'
8
+ import { deriveOpenClawWsUrl, normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
9
+ import { loadGatewayProfiles } from './storage'
10
+ import { isProviderCoolingDown } from './provider-health'
11
+
12
+ const DEFAULT_OPENCLAW_ENDPOINT = 'http://localhost:18789/v1'
13
+ const DEFAULT_OPENCLAW_MODEL = 'default'
14
+
15
+ export interface ResolvedAgentRoute {
16
+ id: string
17
+ label: string
18
+ provider: ProviderType
19
+ model: string
20
+ credentialId?: string | null
21
+ fallbackCredentialIds: string[]
22
+ apiEndpoint?: string | null
23
+ gatewayProfileId?: string | null
24
+ role?: NonNullable<AgentRoutingTarget['role']>
25
+ priority: number
26
+ source: 'agent' | 'routing-target'
27
+ }
28
+
29
+ interface RouteSeed {
30
+ id: string
31
+ label?: string
32
+ provider?: ProviderType | null
33
+ model?: string | null
34
+ credentialId?: string | null
35
+ fallbackCredentialIds?: string[]
36
+ apiEndpoint?: string | null
37
+ gatewayProfileId?: string | null
38
+ role?: AgentRoutingTarget['role']
39
+ priority?: number
40
+ source: ResolvedAgentRoute['source']
41
+ }
42
+
43
+ function ensureStringArray(value: unknown): string[] {
44
+ if (!Array.isArray(value)) return []
45
+ return value
46
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
47
+ .filter(Boolean)
48
+ }
49
+
50
+ function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
51
+ if (!raw || typeof raw !== 'object') return null
52
+ const gateway = raw as Partial<GatewayProfile> & Record<string, unknown>
53
+ const endpoint = normalizeProviderEndpoint(
54
+ 'openclaw',
55
+ typeof gateway.endpoint === 'string' && gateway.endpoint.trim()
56
+ ? gateway.endpoint
57
+ : DEFAULT_OPENCLAW_ENDPOINT,
58
+ )
59
+ if (!endpoint) return null
60
+ return {
61
+ id,
62
+ name: typeof gateway.name === 'string' && gateway.name.trim() ? gateway.name.trim() : id,
63
+ provider: 'openclaw',
64
+ endpoint,
65
+ wsUrl: typeof gateway.wsUrl === 'string' && gateway.wsUrl.trim() ? gateway.wsUrl.trim() : deriveOpenClawWsUrl(endpoint),
66
+ credentialId: typeof gateway.credentialId === 'string' && gateway.credentialId.trim() ? gateway.credentialId.trim() : null,
67
+ status: gateway.status === 'healthy' || gateway.status === 'degraded' || gateway.status === 'offline' || gateway.status === 'pending' ? gateway.status : 'unknown',
68
+ notes: typeof gateway.notes === 'string' ? gateway.notes : null,
69
+ tags: ensureStringArray(gateway.tags),
70
+ lastError: typeof gateway.lastError === 'string' ? gateway.lastError : null,
71
+ lastCheckedAt: typeof gateway.lastCheckedAt === 'number' ? gateway.lastCheckedAt : null,
72
+ lastModelCount: typeof gateway.lastModelCount === 'number' ? gateway.lastModelCount : null,
73
+ discoveredHost: typeof gateway.discoveredHost === 'string' ? gateway.discoveredHost : null,
74
+ discoveredPort: typeof gateway.discoveredPort === 'number' ? gateway.discoveredPort : null,
75
+ isDefault: gateway.isDefault === true,
76
+ createdAt: typeof gateway.createdAt === 'number' ? gateway.createdAt : Date.now(),
77
+ updatedAt: typeof gateway.updatedAt === 'number' ? gateway.updatedAt : Date.now(),
78
+ }
79
+ }
80
+
81
+ function findGatewayProfile(
82
+ gatewayProfiles: GatewayProfile[],
83
+ profileId?: string | null,
84
+ ): GatewayProfile | null {
85
+ const id = typeof profileId === 'string' ? profileId.trim() : ''
86
+ if (!id) return null
87
+ return gatewayProfiles.find((profile) => profile.id === id) || null
88
+ }
89
+
90
+ export function getGatewayProfiles(provider: GatewayProfile['provider'] | null = null): GatewayProfile[] {
91
+ const all = loadGatewayProfiles()
92
+ return Object.entries(all)
93
+ .map(([id, value]) => normalizeGateway(value, id))
94
+ .filter((value): value is GatewayProfile => Boolean(value))
95
+ .filter((value) => !provider || value.provider === provider)
96
+ .sort((a, b) => {
97
+ if ((a.isDefault === true) !== (b.isDefault === true)) return a.isDefault ? -1 : 1
98
+ return a.name.localeCompare(b.name)
99
+ })
100
+ }
101
+
102
+ export function getGatewayProfile(profileId?: string | null): GatewayProfile | null {
103
+ return findGatewayProfile(getGatewayProfiles(), profileId)
104
+ }
105
+
106
+ function defaultGatewayProfile(gatewayProfiles: GatewayProfile[]): GatewayProfile | null {
107
+ return gatewayProfiles.find((profile) => profile.isDefault) || gatewayProfiles[0] || null
108
+ }
109
+
110
+ function roleWeight(strategy: AgentRoutingStrategy, role?: AgentRoutingTarget['role']): number {
111
+ const normalized = role || 'primary'
112
+ const matrix: Record<AgentRoutingStrategy, Record<string, number>> = {
113
+ single: { primary: 0, backup: 10, premium: 20, reasoning: 30, economy: 40 },
114
+ balanced: { primary: 0, premium: 4, economy: 4, reasoning: 6, backup: 12 },
115
+ economy: { economy: 0, primary: 10, backup: 18, premium: 28, reasoning: 36 },
116
+ premium: { premium: 0, reasoning: 4, primary: 10, backup: 18, economy: 28 },
117
+ reasoning: { reasoning: 0, premium: 4, primary: 10, backup: 18, economy: 28 },
118
+ }
119
+ return matrix[strategy][normalized] ?? 50
120
+ }
121
+
122
+ function dedupeCredentialIds(primary: string | null | undefined, candidates: string[] | undefined): string[] {
123
+ const seen = new Set<string>()
124
+ const normalizedPrimary = typeof primary === 'string' && primary.trim() ? primary.trim() : null
125
+ const result: string[] = []
126
+ for (const value of ensureStringArray(candidates)) {
127
+ if (normalizedPrimary && value === normalizedPrimary) continue
128
+ if (seen.has(value)) continue
129
+ seen.add(value)
130
+ result.push(value)
131
+ }
132
+ return result
133
+ }
134
+
135
+ function buildRouteFromSeed(
136
+ seed: RouteSeed,
137
+ gatewayProfiles: GatewayProfile[],
138
+ agentGatewayProfileId?: string | null,
139
+ ): ResolvedAgentRoute | null {
140
+ const provider = (seed.provider || 'claude-cli') as ProviderType
141
+ let gatewayProfileId = seed.gatewayProfileId ?? null
142
+ if (!gatewayProfileId && provider === 'openclaw') {
143
+ gatewayProfileId = agentGatewayProfileId ?? defaultGatewayProfile(gatewayProfiles)?.id ?? null
144
+ }
145
+ const gatewayProfile = findGatewayProfile(gatewayProfiles, gatewayProfileId)
146
+
147
+ const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
148
+ const apiEndpoint = normalizeProviderEndpoint(
149
+ providerFromGateway,
150
+ seed.apiEndpoint ?? gatewayProfile?.endpoint ?? null,
151
+ )
152
+ const model = (seed.model || '').trim() || (providerFromGateway === 'openclaw' ? DEFAULT_OPENCLAW_MODEL : '')
153
+ if (!providerFromGateway || !model) return null
154
+
155
+ const credentialId = seed.credentialId ?? gatewayProfile?.credentialId ?? null
156
+ return {
157
+ id: seed.id,
158
+ label: seed.label?.trim() || (gatewayProfile?.name || `${providerFromGateway}:${model}`),
159
+ provider: providerFromGateway,
160
+ model,
161
+ credentialId,
162
+ fallbackCredentialIds: dedupeCredentialIds(credentialId, seed.fallbackCredentialIds),
163
+ apiEndpoint,
164
+ gatewayProfileId,
165
+ role: seed.role,
166
+ priority: typeof seed.priority === 'number' ? seed.priority : 100,
167
+ source: seed.source,
168
+ }
169
+ }
170
+
171
+ function dedupeRoutes(routes: ResolvedAgentRoute[]): ResolvedAgentRoute[] {
172
+ const seen = new Set<string>()
173
+ const deduped: ResolvedAgentRoute[] = []
174
+ for (const route of routes) {
175
+ const key = [
176
+ route.provider,
177
+ route.model,
178
+ route.credentialId || '',
179
+ route.apiEndpoint || '',
180
+ route.gatewayProfileId || '',
181
+ ].join('::')
182
+ if (seen.has(key)) continue
183
+ seen.add(key)
184
+ deduped.push(route)
185
+ }
186
+ return deduped
187
+ }
188
+
189
+ export function resolveAgentRouteCandidates(
190
+ agent: Agent | null | undefined,
191
+ preferredStrategy?: AgentRoutingStrategy | null,
192
+ ): ResolvedAgentRoute[] {
193
+ return resolveAgentRouteCandidatesWithProfiles(agent, getGatewayProfiles('openclaw'), preferredStrategy)
194
+ }
195
+
196
+ export function resolveAgentRouteCandidatesWithProfiles(
197
+ agent: Agent | null | undefined,
198
+ gatewayProfiles: GatewayProfile[],
199
+ preferredStrategy?: AgentRoutingStrategy | null,
200
+ isCoolingDown: (providerId: string) => boolean = isProviderCoolingDown,
201
+ ): ResolvedAgentRoute[] {
202
+ if (!agent) return []
203
+ const strategy = preferredStrategy || agent.routingStrategy || 'single'
204
+ const seeds: RouteSeed[] = [
205
+ {
206
+ id: 'base',
207
+ label: agent.name,
208
+ provider: agent.provider,
209
+ model: agent.model,
210
+ credentialId: agent.credentialId ?? null,
211
+ fallbackCredentialIds: agent.fallbackCredentialIds || [],
212
+ apiEndpoint: agent.apiEndpoint ?? null,
213
+ gatewayProfileId: agent.gatewayProfileId ?? null,
214
+ role: 'primary',
215
+ priority: 0,
216
+ source: 'agent',
217
+ },
218
+ ...((agent.routingTargets || []).map((target, index) => ({
219
+ id: target.id || `route-${index + 1}`,
220
+ label: target.label,
221
+ provider: target.provider,
222
+ model: target.model,
223
+ credentialId: target.credentialId ?? null,
224
+ fallbackCredentialIds: target.fallbackCredentialIds || [],
225
+ apiEndpoint: target.apiEndpoint ?? null,
226
+ gatewayProfileId: target.gatewayProfileId ?? null,
227
+ role: target.role,
228
+ priority: typeof target.priority === 'number' ? target.priority : index + 1,
229
+ source: 'routing-target' as const,
230
+ }))),
231
+ ]
232
+
233
+ return dedupeRoutes(
234
+ seeds
235
+ .map((seed) => buildRouteFromSeed(seed, gatewayProfiles, agent.gatewayProfileId ?? null))
236
+ .filter((route): route is ResolvedAgentRoute => Boolean(route)),
237
+ ).sort((left, right) => {
238
+ const leftCooling = isCoolingDown(left.provider)
239
+ const rightCooling = isCoolingDown(right.provider)
240
+ if (leftCooling !== rightCooling) return leftCooling ? 1 : -1
241
+ const leftRole = roleWeight(strategy, left.role)
242
+ const rightRole = roleWeight(strategy, right.role)
243
+ if (leftRole !== rightRole) return leftRole - rightRole
244
+ if (left.priority !== right.priority) return left.priority - right.priority
245
+ return left.label.localeCompare(right.label)
246
+ })
247
+ }
248
+
249
+ export function resolvePrimaryAgentRoute(
250
+ agent: Agent | null | undefined,
251
+ preferredStrategy?: AgentRoutingStrategy | null,
252
+ ): ResolvedAgentRoute | null {
253
+ return resolveAgentRouteCandidates(agent, preferredStrategy)[0] || null
254
+ }
255
+
256
+ export function applyResolvedRoute<T extends {
257
+ provider: ProviderType
258
+ model: string
259
+ credentialId?: string | null
260
+ fallbackCredentialIds?: string[]
261
+ apiEndpoint?: string | null
262
+ gatewayProfileId?: string | null
263
+ }>(
264
+ target: T,
265
+ route: ResolvedAgentRoute | null | undefined,
266
+ ): T {
267
+ if (!route) return target
268
+ return {
269
+ ...target,
270
+ provider: route.provider,
271
+ model: route.model,
272
+ credentialId: route.credentialId ?? null,
273
+ fallbackCredentialIds: [...route.fallbackCredentialIds],
274
+ apiEndpoint: route.apiEndpoint ?? null,
275
+ gatewayProfileId: route.gatewayProfileId ?? null,
276
+ }
277
+ }
@@ -202,4 +202,63 @@ describe('approval auto-approve', () => {
202
202
  assert.match(output.lastMessage.text, /\"type\":\"plugin_request\"/)
203
203
  assert.match(output.lastMessage.text, /\"pluginId\":\"shell\"/)
204
204
  })
205
+
206
+ it('applies tool access after a manual approval decision', () => {
207
+ const output = runWithTempDataDir(`
208
+ const storageMod = await import('./src/lib/server/storage.ts')
209
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
210
+ const storage = storageMod.default || storageMod
211
+ const approvals = approvalsMod.default || approvalsMod
212
+
213
+ const now = Date.now()
214
+ storage.saveSettings({
215
+ approvalsEnabled: true,
216
+ approvalAutoApproveCategories: [],
217
+ })
218
+ storage.saveSessions({
219
+ session_manual: {
220
+ id: 'session_manual',
221
+ name: 'Manual Approval Test',
222
+ cwd: process.cwd(),
223
+ user: 'tester',
224
+ provider: 'openai',
225
+ model: 'gpt-test',
226
+ credentialId: null,
227
+ apiEndpoint: null,
228
+ claudeSessionId: null,
229
+ codexThreadId: null,
230
+ opencodeSessionId: null,
231
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
232
+ messages: [],
233
+ createdAt: now,
234
+ lastActiveAt: now,
235
+ sessionType: 'human',
236
+ agentId: 'default',
237
+ plugins: [],
238
+ },
239
+ })
240
+
241
+ const approval = await approvals.requestApprovalMaybeAutoApprove({
242
+ category: 'tool_access',
243
+ title: 'Enable Plugin: shell',
244
+ description: 'Need shell access for a task.',
245
+ data: { toolId: 'shell', pluginId: 'shell' },
246
+ sessionId: 'session_manual',
247
+ agentId: 'default',
248
+ })
249
+ await approvals.submitDecision(approval.id, true)
250
+
251
+ const storedApproval = storage.loadApprovals()[approval.id]
252
+ const session = storage.loadSessions().session_manual
253
+ console.log(JSON.stringify({
254
+ initialStatus: approval.status,
255
+ finalStatus: storedApproval?.status || null,
256
+ plugins: session.plugins || [],
257
+ }))
258
+ `)
259
+
260
+ assert.equal(output.initialStatus, 'pending')
261
+ assert.equal(output.finalStatus, 'approved')
262
+ assert.equal(output.plugins.includes('shell'), true)
263
+ })
205
264
  })