@swarmclawai/swarmclaw 1.2.5 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +24 -17
  2. package/next.config.ts +1 -0
  3. package/package.json +3 -2
  4. package/scripts/easy-setup.mjs +1 -1
  5. package/scripts/postinstall.mjs +1 -1
  6. package/skills/swarmclaw.md +115 -0
  7. package/skills/tools/browser.md +131 -0
  8. package/skills/tools/execute.md +98 -0
  9. package/skills/tools/files.md +98 -0
  10. package/skills/tools/memory.md +104 -0
  11. package/skills/tools/platform.md +144 -0
  12. package/skills/tools/skills.md +83 -0
  13. package/src/app/api/chats/[id]/messages/route.ts +23 -19
  14. package/src/app/api/chats/messages-route.test.ts +105 -51
  15. package/src/app/api/mcp-servers/[id]/test/route.ts +3 -2
  16. package/src/app/api/openclaw/deploy/route.ts +2 -0
  17. package/src/app/api/setup/check-provider/route.ts +10 -2
  18. package/src/app/api/setup/doctor/route.ts +4 -4
  19. package/src/components/agents/agent-chat-list.tsx +23 -1
  20. package/src/components/agents/inspector-panel.tsx +165 -48
  21. package/src/components/chat/chat-area.tsx +38 -9
  22. package/src/components/chat/message-list.tsx +33 -19
  23. package/src/components/gateways/gateway-sheet.tsx +5 -2
  24. package/src/lib/agent-execute-defaults.test.ts +24 -0
  25. package/src/lib/agent-execute-defaults.ts +62 -0
  26. package/src/lib/chat/queued-message-queue.test.ts +134 -1
  27. package/src/lib/chat/queued-message-queue.ts +77 -2
  28. package/src/lib/providers/index.test.ts +108 -0
  29. package/src/lib/providers/index.ts +38 -15
  30. package/src/lib/server/agents/agent-service.ts +5 -0
  31. package/src/lib/server/builtin-extensions.ts +1 -0
  32. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +1 -1
  33. package/src/lib/server/chat-execution/chat-execution-tool-events.test.ts +1 -0
  34. package/src/lib/server/chat-execution/chat-execution-utils.ts +2 -2
  35. package/src/lib/server/chat-execution/chat-turn-preparation.ts +79 -42
  36. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -0
  37. package/src/lib/server/chat-execution/continuation-evaluator.ts +8 -0
  38. package/src/lib/server/chat-execution/memory-mutation-tools.ts +1 -1
  39. package/src/lib/server/chat-execution/message-classifier.ts +11 -1
  40. package/src/lib/server/chat-execution/prompt-builder.test.ts +28 -0
  41. package/src/lib/server/chat-execution/prompt-builder.ts +14 -1
  42. package/src/lib/server/chat-execution/prompt-mode.test.ts +24 -0
  43. package/src/lib/server/chat-execution/prompt-mode.ts +5 -1
  44. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -4
  45. package/src/lib/server/chat-execution/stream-agent-chat.ts +45 -16
  46. package/src/lib/server/chatrooms/chatroom-routing.test.ts +4 -0
  47. package/src/lib/server/connectors/discord.ts +2 -2
  48. package/src/lib/server/connectors/matrix.ts +3 -2
  49. package/src/lib/server/connectors/signal.ts +5 -4
  50. package/src/lib/server/connectors/slack.ts +10 -9
  51. package/src/lib/server/connectors/teams.ts +3 -2
  52. package/src/lib/server/connectors/telegram.ts +4 -4
  53. package/src/lib/server/connectors/whatsapp.ts +2 -2
  54. package/src/lib/server/daemon/controller.ts +7 -0
  55. package/src/lib/server/gateways/gateway-profile-service.ts +19 -1
  56. package/src/lib/server/messages/message-repository.test.ts +70 -0
  57. package/src/lib/server/messages/message-repository.ts +11 -6
  58. package/src/lib/server/openclaw/deploy.ts +32 -2
  59. package/src/lib/server/plugins-advanced.test.ts +1 -2
  60. package/src/lib/server/provider-health.ts +1 -1
  61. package/src/lib/server/runtime/process-manager.ts +13 -9
  62. package/src/lib/server/runtime/session-run-manager/queries.ts +15 -0
  63. package/src/lib/server/runtime/session-run-manager.test.ts +58 -0
  64. package/src/lib/server/sandbox/session-runtime.test.ts +18 -1
  65. package/src/lib/server/sandbox/session-runtime.ts +40 -28
  66. package/src/lib/server/session-tools/autonomy-tools.test.ts +7 -9
  67. package/src/lib/server/session-tools/context.ts +1 -1
  68. package/src/lib/server/session-tools/credential-env.ts +109 -0
  69. package/src/lib/server/session-tools/crud.ts +3 -3
  70. package/src/lib/server/session-tools/edit_file.ts +3 -2
  71. package/src/lib/server/session-tools/execute.test.ts +58 -0
  72. package/src/lib/server/session-tools/execute.ts +334 -0
  73. package/src/lib/server/session-tools/files-tool.ts +635 -0
  74. package/src/lib/server/session-tools/index.ts +14 -4
  75. package/src/lib/server/session-tools/memory-tool.ts +242 -0
  76. package/src/lib/server/session-tools/memory.ts +1 -1
  77. package/src/lib/server/session-tools/openclaw-nodes.ts +3 -2
  78. package/src/lib/server/session-tools/openclaw-workspace.ts +3 -2
  79. package/src/lib/server/session-tools/platform-tool.ts +617 -0
  80. package/src/lib/server/session-tools/session-info.ts +3 -2
  81. package/src/lib/server/session-tools/session-tools-wiring.test.ts +3 -4
  82. package/src/lib/server/session-tools/shell.ts +7 -122
  83. package/src/lib/server/session-tools/skills-tool.ts +396 -0
  84. package/src/lib/server/session-tools/web.ts +2 -2
  85. package/src/lib/server/storage-normalization.ts +2 -0
  86. package/src/lib/server/tool-aliases.ts +2 -1
  87. package/src/lib/server/tool-capability-policy-advanced.test.ts +9 -2
  88. package/src/lib/server/tool-capability-policy.test.ts +2 -1
  89. package/src/lib/server/tool-capability-policy.ts +60 -33
  90. package/src/lib/server/tool-planning.ts +11 -0
  91. package/src/lib/setup-defaults.ts +5 -0
  92. package/src/lib/tool-definitions.ts +1 -0
  93. package/src/lib/validation/schemas.test.ts +16 -0
  94. package/src/lib/validation/schemas.ts +16 -0
  95. package/src/stores/use-chat-store.test.ts +231 -0
  96. package/src/stores/use-chat-store.ts +62 -13
  97. package/src/types/agent.ts +348 -0
  98. package/src/types/app-settings.ts +175 -0
  99. package/src/types/approval.ts +27 -0
  100. package/src/types/connector.ts +187 -0
  101. package/src/types/extension.ts +386 -0
  102. package/src/types/index.ts +16 -3555
  103. package/src/types/message.ts +57 -0
  104. package/src/types/misc.ts +739 -0
  105. package/src/types/mission.ts +185 -0
  106. package/src/types/protocol.ts +422 -0
  107. package/src/types/provider.ts +52 -0
  108. package/src/types/run.ts +183 -0
  109. package/src/types/schedule.ts +59 -0
  110. package/src/types/session.ts +265 -0
  111. package/src/types/skill.ts +157 -0
  112. package/src/types/task.ts +140 -0
  113. package/src/types/working-state.ts +211 -0
  114. package/src/views/settings/section-heartbeat.tsx +2 -2
  115. package/src/lib/server/session-tools/sandbox.ts +0 -281
@@ -1,9 +1,11 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { describe, it } from 'node:test'
3
3
  import {
4
+ buildQueuedTranscriptMessages,
4
5
  createOptimisticQueuedMessage,
5
6
  clearQueuedMessagesForSession,
6
7
  listQueuedMessagesForSession,
8
+ mergeQueuedTranscriptMessages,
7
9
  removeQueuedMessageById,
8
10
  replaceQueuedMessagesForSession,
9
11
  snapshotToQueuedMessages,
@@ -82,12 +84,22 @@ describe('queued-message-queue', () => {
82
84
  const queued = snapshotToQueuedMessages({
83
85
  sessionId: 'session-a',
84
86
  activeRunId: 'run-active',
87
+ activeTurn: {
88
+ runId: 'run-active',
89
+ sessionId: 'session-a',
90
+ text: 'sending now',
91
+ queuedAt: 4,
92
+ position: 0,
93
+ },
85
94
  queueLength: 1,
86
95
  items: [
87
96
  { runId: 'run-queued', sessionId: 'session-a', text: 'queued', queuedAt: 5, position: 1 },
88
97
  ],
89
98
  })
90
- assert.deepEqual(queued.map((item) => item.runId), ['run-queued'])
99
+ assert.deepEqual(
100
+ queued.map((item) => [item.runId, item.sending === true]),
101
+ [['run-active', true], ['run-queued', false]],
102
+ )
91
103
  })
92
104
 
93
105
  it('preserves attachment and reply metadata from queue snapshots', () => {
@@ -123,6 +135,27 @@ describe('queued-message-queue', () => {
123
135
  })
124
136
  })
125
137
 
138
+ it('deduplicates an active turn when the snapshot also contains it in the queued items', () => {
139
+ const queued = snapshotToQueuedMessages({
140
+ sessionId: 'session-a',
141
+ activeRunId: 'run-active',
142
+ activeTurn: {
143
+ runId: 'run-active',
144
+ sessionId: 'session-a',
145
+ text: 'already running',
146
+ queuedAt: 6,
147
+ position: 0,
148
+ },
149
+ queueLength: 1,
150
+ items: [
151
+ { runId: 'run-active', sessionId: 'session-a', text: 'already running', queuedAt: 6, position: 1 },
152
+ ],
153
+ })
154
+
155
+ assert.deepEqual(queued.map((item) => item.runId), ['run-active'])
156
+ assert.equal(queued[0]?.sending, true)
157
+ })
158
+
126
159
  it('sorts queued messages by position and queued time within a session', () => {
127
160
  const unsorted: QueuedSessionMessage[] = [
128
161
  { runId: 'q4', sessionId: 'session-a', text: 'later', queuedAt: 9, position: 2 },
@@ -135,4 +168,104 @@ describe('queued-message-queue', () => {
135
168
  ['q5', 'q6', 'q4'],
136
169
  )
137
170
  })
171
+
172
+ it('builds transcript-ready user messages from sending queued turns', () => {
173
+ const transcript = buildQueuedTranscriptMessages([
174
+ { runId: 'q1', sessionId: 'session-a', text: 'sending row', queuedAt: 20, position: 0, sending: true },
175
+ { runId: 'q2', sessionId: 'session-a', text: 'pending row', queuedAt: 21, position: 1 },
176
+ { runId: 'q3', sessionId: 'session-b', text: 'other session', queuedAt: 22, position: 0, sending: true },
177
+ ], 'session-a')
178
+
179
+ assert.deepEqual(transcript, [
180
+ {
181
+ role: 'user',
182
+ text: 'sending row',
183
+ time: 20,
184
+ kind: 'chat',
185
+ clientRenderId: 'queued:q1',
186
+ imagePath: undefined,
187
+ imageUrl: undefined,
188
+ attachedFiles: undefined,
189
+ replyToId: undefined,
190
+ runId: 'q1',
191
+ },
192
+ ])
193
+ })
194
+
195
+ it('merges sending queued turns into the transcript ahead of later assistant output', () => {
196
+ const merged = mergeQueuedTranscriptMessages([
197
+ { role: 'assistant', text: 'Thinking...', time: 25, streaming: true, runId: 'run-active' },
198
+ ], [
199
+ { runId: 'run-active', sessionId: 'session-a', text: 'queued first', queuedAt: 20, position: 0, sending: true },
200
+ ], 'session-a')
201
+
202
+ assert.deepEqual(merged.map((message) => [message.role, message.text, message.runId]), [
203
+ ['user', 'queued first', 'run-active'],
204
+ ['assistant', 'Thinking...', 'run-active'],
205
+ ])
206
+ })
207
+
208
+ it('preserves existing sending items when replacing queue for a session', () => {
209
+ const queueWithSending: QueuedSessionMessage[] = [
210
+ { runId: 'sending-1', sessionId: 'session-a', text: 'already sending', queuedAt: 1, position: 0, sending: true },
211
+ { runId: 'q3', sessionId: 'session-a', text: 'queued', queuedAt: 2, position: 1 },
212
+ { runId: 'q2', sessionId: 'session-b', text: 'other', queuedAt: 3, position: 1 },
213
+ ]
214
+ const replaced = replaceQueuedMessagesForSession(queueWithSending, 'session-a', [
215
+ { runId: 'q4', sessionId: 'session-a', text: 'new queued', queuedAt: 4, position: 1 },
216
+ ], { activeRunId: null })
217
+
218
+ const forSession = listQueuedMessagesForSession(replaced, 'session-a')
219
+ assert.deepEqual(
220
+ forSession.map((item) => [item.runId, item.sending === true]),
221
+ [['sending-1', true], ['q4', false]],
222
+ )
223
+ })
224
+
225
+ it('deduplicates sending items that appear in nextItems', () => {
226
+ const queueWithSending: QueuedSessionMessage[] = [
227
+ { runId: 'run-active', sessionId: 'session-a', text: 'sending', queuedAt: 1, position: 0, sending: true },
228
+ ]
229
+ const replaced = replaceQueuedMessagesForSession(queueWithSending, 'session-a', [
230
+ { runId: 'run-active', sessionId: 'session-a', text: 'sending', queuedAt: 1, position: 0, sending: true },
231
+ { runId: 'q5', sessionId: 'session-a', text: 'next', queuedAt: 2, position: 1 },
232
+ ], { activeRunId: 'run-active' })
233
+
234
+ const forSession = listQueuedMessagesForSession(replaced, 'session-a')
235
+ assert.deepEqual(
236
+ forSession.map((item) => item.runId),
237
+ ['run-active', 'q5'],
238
+ )
239
+ })
240
+
241
+ it('inserts sending messages after last persisted message, not by timestamp', () => {
242
+ const merged = mergeQueuedTranscriptMessages([
243
+ { role: 'user', text: 'First', time: 100 },
244
+ { role: 'assistant', text: 'Reply', time: 200 },
245
+ { role: 'user', text: 'Second', time: 300 },
246
+ { role: 'assistant', text: 'Reply 2', time: 400 },
247
+ ], [
248
+ // queuedAt is earlier than the last persisted message
249
+ { runId: 'run-late', sessionId: 'session-a', text: 'queued early', queuedAt: 150, position: 0, sending: true },
250
+ ], 'session-a')
251
+
252
+ // Should appear at the END, not spliced into the middle at time=150
253
+ assert.deepEqual(merged.map((msg) => msg.text), [
254
+ 'First', 'Reply', 'Second', 'Reply 2', 'queued early',
255
+ ])
256
+ })
257
+
258
+ it('skips a sending queued turn once the persisted user message is already present', () => {
259
+ const merged = mergeQueuedTranscriptMessages([
260
+ { role: 'user', text: 'queued first', time: 20, runId: 'run-active' },
261
+ { role: 'assistant', text: 'Thinking...', time: 25, streaming: true, runId: 'run-active' },
262
+ ], [
263
+ { runId: 'run-active', sessionId: 'session-a', text: 'queued first', queuedAt: 20, position: 0, sending: true },
264
+ ], 'session-a')
265
+
266
+ assert.deepEqual(merged.map((message) => [message.role, message.text, message.runId]), [
267
+ ['user', 'queued first', 'run-active'],
268
+ ['assistant', 'Thinking...', 'run-active'],
269
+ ])
270
+ })
138
271
  })
@@ -1,4 +1,4 @@
1
- import type { SessionQueueSnapshot, SessionQueuedTurn } from '@/types'
1
+ import type { Message, SessionQueueSnapshot, SessionQueuedTurn } from '@/types'
2
2
 
3
3
  export interface QueuedSessionMessage extends SessionQueuedTurn {
4
4
  optimistic?: boolean
@@ -38,7 +38,23 @@ export function createOptimisticQueuedMessage(
38
38
  }
39
39
 
40
40
  export function snapshotToQueuedMessages(snapshot: SessionQueueSnapshot): QueuedSessionMessage[] {
41
- return snapshot.items.map((item) => ({ ...item }))
41
+ const activeRunId = typeof snapshot.activeRunId === 'string' && snapshot.activeRunId.trim()
42
+ ? snapshot.activeRunId
43
+ : null
44
+ const nextItems: QueuedSessionMessage[] = []
45
+ if (snapshot.activeTurn && activeRunId && snapshot.activeTurn.runId === activeRunId) {
46
+ nextItems.push({
47
+ ...snapshot.activeTurn,
48
+ sending: true,
49
+ })
50
+ }
51
+ const seenRunIds = new Set(nextItems.map((item) => item.runId))
52
+ for (const item of snapshot.items) {
53
+ if (seenRunIds.has(item.runId)) continue
54
+ nextItems.push({ ...item })
55
+ seenRunIds.add(item.runId)
56
+ }
57
+ return nextItems
42
58
  }
43
59
 
44
60
  interface ReplaceQueuedMessagesOptions {
@@ -60,11 +76,17 @@ export function replaceQueuedMessagesForSession(
60
76
  const activeRunId = typeof options.activeRunId === 'string' && options.activeRunId.trim()
61
77
  ? options.activeRunId
62
78
  : null
79
+ // Preserve existing "sending" items not covered by the new snapshot —
80
+ // they'll be cleaned up later by setMessages or the timeout.
81
+ const existingSending = queue.filter((item) =>
82
+ item.sessionId === sessionId && item.sending && !nextRunIds.has(item.runId),
83
+ )
63
84
  const consumed = previousForSession
64
85
  .filter((item) => !item.optimistic && !nextRunIds.has(item.runId) && activeRunId === item.runId)
65
86
  .map((item) => ({ ...item, sending: true }))
66
87
  return [
67
88
  ...otherSessions,
89
+ ...existingSending,
68
90
  ...consumed,
69
91
  ...nextItems,
70
92
  ]
@@ -80,6 +102,59 @@ export function listQueuedMessagesForSession(
80
102
  .sort((left, right) => left.position - right.position || left.queuedAt - right.queuedAt)
81
103
  }
82
104
 
105
+ export function buildQueuedTranscriptMessages(
106
+ queue: QueuedSessionMessage[],
107
+ sessionId: string | null | undefined,
108
+ ): Message[] {
109
+ return listQueuedMessagesForSession(queue, sessionId)
110
+ .filter((item) => item.sending === true)
111
+ .map((item) => ({
112
+ role: 'user',
113
+ text: item.text,
114
+ time: item.queuedAt,
115
+ kind: 'chat',
116
+ clientRenderId: `queued:${item.runId}`,
117
+ imagePath: item.imagePath,
118
+ imageUrl: item.imageUrl,
119
+ attachedFiles: item.attachedFiles,
120
+ replyToId: item.replyToId,
121
+ runId: item.runId,
122
+ }))
123
+ }
124
+
125
+ export function mergeQueuedTranscriptMessages(
126
+ messages: Message[],
127
+ queue: QueuedSessionMessage[],
128
+ sessionId: string | null | undefined,
129
+ ): Message[] {
130
+ const queuedTranscript = buildQueuedTranscriptMessages(queue, sessionId)
131
+ if (queuedTranscript.length === 0) return messages
132
+ const merged = [...messages]
133
+ for (const queuedMessage of queuedTranscript) {
134
+ const queuedRunId = typeof queuedMessage.runId === 'string' && queuedMessage.runId.trim()
135
+ ? queuedMessage.runId
136
+ : null
137
+ if (queuedRunId && merged.some((message) => message.role === 'user' && message.runId === queuedRunId)) {
138
+ continue
139
+ }
140
+ // Place queued user message before its corresponding assistant response
141
+ // (same runId), otherwise append after the last persisted message.
142
+ const sameRunAssistantIndex = queuedRunId
143
+ ? merged.findIndex((msg) => msg.role === 'assistant' && msg.runId === queuedRunId)
144
+ : -1
145
+ if (sameRunAssistantIndex >= 0) {
146
+ merged.splice(sameRunAssistantIndex, 0, queuedMessage)
147
+ } else {
148
+ const lastPersistedIndex = merged.findLastIndex(
149
+ (msg) => !msg.clientRenderId?.startsWith('queued:'),
150
+ )
151
+ const insertAt = lastPersistedIndex >= 0 ? lastPersistedIndex + 1 : merged.length
152
+ merged.splice(insertAt, 0, queuedMessage)
153
+ }
154
+ }
155
+ return merged
156
+ }
157
+
83
158
  export function removeQueuedMessageById(
84
159
  queue: QueuedSessionMessage[],
85
160
  id: string,
@@ -76,3 +76,111 @@ test('builtin provider override records do not surface as custom providers', ()
76
76
 
77
77
  assert.equal(output.openAiCount, 1)
78
78
  })
79
+
80
+ test('custom provider resolution includes defaultEndpoint and optionalApiKey', () => {
81
+ const output = runWithTempDataDir<{
82
+ defaultEndpoint: string | null
83
+ optionalApiKey: boolean | null
84
+ requiresApiKey: boolean | null
85
+ }>(`
86
+ const storageModule = await import('@/lib/server/storage')
87
+ const storage = storageModule.default || storageModule
88
+ storage.saveProviderConfigs({
89
+ 'custom-llama-server': {
90
+ id: 'custom-llama-server',
91
+ name: 'llama-server',
92
+ type: 'custom',
93
+ baseUrl: 'http://127.0.0.1:8080/v1',
94
+ models: ['my-model'],
95
+ requiresApiKey: false,
96
+ credentialId: null,
97
+ isEnabled: true,
98
+ createdAt: 1,
99
+ updatedAt: 1,
100
+ },
101
+ })
102
+
103
+ const providersModule = await import('@/lib/providers/index')
104
+ const providers = providersModule.default || providersModule
105
+ const resolved = providers.getProvider('custom-llama-server')
106
+
107
+ console.log(JSON.stringify({
108
+ defaultEndpoint: resolved?.defaultEndpoint ?? null,
109
+ optionalApiKey: resolved?.optionalApiKey ?? null,
110
+ requiresApiKey: resolved?.requiresApiKey ?? null,
111
+ }))
112
+ `)
113
+
114
+ assert.equal(output.defaultEndpoint, 'http://127.0.0.1:8080/v1')
115
+ assert.equal(output.optionalApiKey, true)
116
+ assert.equal(output.requiresApiKey, false)
117
+ })
118
+
119
+ test('custom provider with uuid-style ID resolves correctly', () => {
120
+ const output = runWithTempDataDir<{
121
+ resolvedName: string | null
122
+ hasHandler: boolean
123
+ }>(`
124
+ const storageModule = await import('@/lib/server/storage')
125
+ const storage = storageModule.default || storageModule
126
+ storage.saveProviderConfigs({
127
+ 'custom-d20b934e': {
128
+ id: 'custom-d20b934e',
129
+ name: 'My llama-server',
130
+ type: 'custom',
131
+ baseUrl: 'http://127.0.0.1:8080/v1',
132
+ models: ['llama-3.1-8b'],
133
+ requiresApiKey: false,
134
+ credentialId: null,
135
+ isEnabled: true,
136
+ createdAt: 1,
137
+ updatedAt: 1,
138
+ },
139
+ })
140
+
141
+ const providersModule = await import('@/lib/providers/index')
142
+ const providers = providersModule.default || providersModule
143
+ const resolved = providers.getProvider('custom-d20b934e')
144
+
145
+ console.log(JSON.stringify({
146
+ resolvedName: resolved?.name ?? null,
147
+ hasHandler: typeof resolved?.handler?.streamChat === 'function',
148
+ }))
149
+ `)
150
+
151
+ assert.equal(output.resolvedName, 'My llama-server')
152
+ assert.equal(output.hasHandler, true)
153
+ })
154
+
155
+ test('disabled custom providers are not resolved by getProvider', () => {
156
+ const output = runWithTempDataDir<{
157
+ resolved: boolean
158
+ }>(`
159
+ const storageModule = await import('@/lib/server/storage')
160
+ const storage = storageModule.default || storageModule
161
+ storage.saveProviderConfigs({
162
+ 'custom-disabled': {
163
+ id: 'custom-disabled',
164
+ name: 'Disabled Provider',
165
+ type: 'custom',
166
+ baseUrl: 'http://127.0.0.1:8080/v1',
167
+ models: ['test'],
168
+ requiresApiKey: false,
169
+ credentialId: null,
170
+ isEnabled: false,
171
+ createdAt: 1,
172
+ updatedAt: 1,
173
+ },
174
+ })
175
+
176
+ const providersModule = await import('@/lib/providers/index')
177
+ const providers = providersModule.default || providersModule
178
+ const resolved = providers.getProvider('custom-disabled')
179
+
180
+ console.log(JSON.stringify({
181
+ resolved: resolved !== null,
182
+ }))
183
+ `)
184
+
185
+ assert.equal(output.resolved, false)
186
+ })
@@ -286,7 +286,8 @@ function getCustomProviders(): Record<string, CustomProviderConfig> {
286
286
  return Object.fromEntries(
287
287
  Object.entries(configs).filter(([, config]) => config?.type === 'custom'),
288
288
  )
289
- } catch {
289
+ } catch (err) {
290
+ log.warn(TAG, 'Failed to load custom providers from storage', errorMessage(err))
290
291
  return {}
291
292
  }
292
293
  }
@@ -325,6 +326,7 @@ export function getProviderList(): ProviderInfo[] {
325
326
  defaultModels: c.models,
326
327
  supportsModelDiscovery: false,
327
328
  requiresApiKey: c.requiresApiKey,
329
+ optionalApiKey: !c.requiresApiKey,
328
330
  requiresEndpoint: false as boolean,
329
331
  defaultEndpoint: c.baseUrl,
330
332
  }))
@@ -348,26 +350,47 @@ export function getProviderList(): ProviderInfo[] {
348
350
  return [...builtins, ...customs, ...extensionProviders]
349
351
  }
350
352
 
353
+ function buildCustomProviderConfig(custom: CustomProviderConfig): BuiltinProviderConfig {
354
+ return {
355
+ id: custom.id as ProviderId,
356
+ name: custom.name,
357
+ models: custom.models,
358
+ requiresApiKey: custom.requiresApiKey,
359
+ optionalApiKey: !custom.requiresApiKey,
360
+ requiresEndpoint: false,
361
+ defaultEndpoint: custom.baseUrl,
362
+ handler: {
363
+ streamChat: async (opts) => {
364
+ const patchedSession = { ...opts.session, apiEndpoint: custom.baseUrl }
365
+ const { streamOpenAiChat } = await import('./openai')
366
+ return streamOpenAiChat({ ...opts, session: patchedSession })
367
+ },
368
+ },
369
+ }
370
+ }
371
+
351
372
  export function getProvider(id: string): BuiltinProviderConfig | null {
352
373
  if (PROVIDERS[id]) return PROVIDERS[id]
353
-
374
+
354
375
  // Check custom providers
355
376
  const customs = getCustomProviders()
356
377
  const custom = customs[id]
357
378
  if (custom?.isEnabled) {
358
- return {
359
- id: custom.id as ProviderId,
360
- name: custom.name,
361
- models: custom.models,
362
- requiresApiKey: custom.requiresApiKey,
363
- requiresEndpoint: false,
364
- handler: {
365
- streamChat: async (opts) => {
366
- const patchedSession = { ...opts.session, apiEndpoint: custom.baseUrl }
367
- const { streamOpenAiChat } = await import('./openai')
368
- return streamOpenAiChat({ ...opts, session: patchedSession })
369
- },
370
- },
379
+ return buildCustomProviderConfig(custom)
380
+ }
381
+
382
+ // Fallback: direct single-item DB lookup for custom-* providers
383
+ if (id.startsWith('custom-') && !custom) {
384
+ try {
385
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
386
+ const { loadStoredItem } = require('@/lib/server/storage') as typeof import('@/lib/server/storage')
387
+ const directConfig = loadStoredItem('provider_configs', id) as CustomProviderConfig | null
388
+ if (directConfig?.type === 'custom' && directConfig.isEnabled) {
389
+ log.info(TAG, `Resolved custom provider '${id}' via direct DB lookup (batch load missed it)`)
390
+ return buildCustomProviderConfig(directConfig)
391
+ }
392
+ } catch (err) {
393
+ log.warn(TAG, `Direct DB lookup failed for provider '${id}'`, errorMessage(err))
371
394
  }
372
395
  }
373
396
 
@@ -1,5 +1,6 @@
1
1
  import { genId } from '@/lib/id'
2
2
  import { resolveAgentToolSelection } from '@/lib/agent-default-tools'
3
+ import { normalizeAgentExecuteConfig } from '@/lib/agent-execute-defaults'
3
4
  import { normalizeAgentSandboxConfig } from '@/lib/agent-sandbox-defaults'
4
5
  import { normalizeCapabilitySelection } from '@/lib/capability-selection'
5
6
  import { normalizeProviderEndpoint } from '@/lib/openclaw/openclaw-endpoint'
@@ -180,6 +181,7 @@ export function createAgent(input: {
180
181
  sessionDailyResetAt: typeof body.sessionDailyResetAt === 'string' ? body.sessionDailyResetAt : null,
181
182
  sessionResetTimezone: typeof body.sessionResetTimezone === 'string' ? body.sessionResetTimezone : null,
182
183
  sandboxConfig: normalizeAgentSandboxConfig(body.sandboxConfig),
184
+ executeConfig: body.executeConfig === null ? null : normalizeAgentExecuteConfig(body.executeConfig),
183
185
  createdAt: now,
184
186
  updatedAt: now,
185
187
  }
@@ -225,6 +227,9 @@ export function updateAgent(agentId: string, body: Record<string, unknown>): Age
225
227
  if (body.sandboxConfig !== undefined) {
226
228
  agent.sandboxConfig = normalizeAgentSandboxConfig(body.sandboxConfig)
227
229
  }
230
+ if (body.executeConfig !== undefined) {
231
+ agent.executeConfig = body.executeConfig === null ? null : normalizeAgentExecuteConfig(body.executeConfig)
232
+ }
228
233
  if (
229
234
  body.provider !== undefined
230
235
  || body.orchestratorEnabled !== undefined
@@ -1,4 +1,5 @@
1
1
  import '@/lib/server/session-tools/shell'
2
+ import '@/lib/server/session-tools/execute'
2
3
  import '@/lib/server/session-tools/file'
3
4
  import '@/lib/server/session-tools/edit_file'
4
5
  import '@/lib/server/session-tools/web'
@@ -470,7 +470,7 @@ describe('hasDirectLocalCodingTools', () => {
470
470
  assert.equal(hasDirectLocalCodingTools({ tools: ['files'] }), true)
471
471
  })
472
472
 
473
- it('returns true for sandbox extension', () => {
473
+ it('returns true for legacy sandbox alias', () => {
474
474
  assert.equal(hasDirectLocalCodingTools({ tools: ['sandbox'] }), true)
475
475
  })
476
476
  })
@@ -259,6 +259,7 @@ describe('hasDirectLocalCodingTools', () => {
259
259
  it('treats shell and file tooling as local coding capability', () => {
260
260
  assert.equal(hasDirectLocalCodingTools({ extensions: ['files'] }), true)
261
261
  assert.equal(hasDirectLocalCodingTools({ extensions: ['shell'] }), true)
262
+ assert.equal(hasDirectLocalCodingTools({ extensions: ['execute'] }), true)
262
263
  assert.equal(hasDirectLocalCodingTools({ extensions: ['edit_file'] }), true)
263
264
  assert.equal(hasDirectLocalCodingTools({ extensions: ['delegate'] }), false)
264
265
  })
@@ -324,8 +324,6 @@ export function requestedToolNamesFromMessage(message: string): string[] {
324
324
  'wallet_tool',
325
325
  'http_request',
326
326
  'send_file',
327
- 'sandbox_exec',
328
- 'sandbox_list_runtimes',
329
327
  'schedule_wake',
330
328
  'spawn_subagent',
331
329
  'ask_human',
@@ -338,6 +336,7 @@ export function requestedToolNamesFromMessage(message: string): string[] {
338
336
  'browser',
339
337
  'web',
340
338
  'shell',
339
+ 'execute',
341
340
  'files',
342
341
  'edit_file',
343
342
  'canvas',
@@ -370,6 +369,7 @@ export function enabledDelegationTools(session: SessionWithTools): DelegateTool[
370
369
  export function hasDirectLocalCodingTools(session: SessionWithTools): boolean {
371
370
  return [
372
371
  'shell',
372
+ 'execute',
373
373
  'execute_command',
374
374
  'files',
375
375
  'edit_file',