@swarmclawai/swarmclaw 1.2.6 → 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 (112) 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/doctor/route.ts +4 -4
  18. package/src/components/agents/agent-chat-list.tsx +23 -1
  19. package/src/components/agents/inspector-panel.tsx +165 -48
  20. package/src/components/chat/chat-area.tsx +38 -9
  21. package/src/components/chat/message-list.tsx +33 -19
  22. package/src/components/gateways/gateway-sheet.tsx +5 -2
  23. package/src/lib/agent-execute-defaults.test.ts +24 -0
  24. package/src/lib/agent-execute-defaults.ts +62 -0
  25. package/src/lib/chat/queued-message-queue.test.ts +134 -1
  26. package/src/lib/chat/queued-message-queue.ts +77 -2
  27. package/src/lib/server/agents/agent-service.ts +5 -0
  28. package/src/lib/server/builtin-extensions.ts +1 -0
  29. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +1 -1
  30. package/src/lib/server/chat-execution/chat-execution-tool-events.test.ts +1 -0
  31. package/src/lib/server/chat-execution/chat-execution-utils.ts +2 -2
  32. package/src/lib/server/chat-execution/chat-turn-preparation.ts +79 -42
  33. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -0
  34. package/src/lib/server/chat-execution/continuation-evaluator.ts +8 -0
  35. package/src/lib/server/chat-execution/memory-mutation-tools.ts +1 -1
  36. package/src/lib/server/chat-execution/message-classifier.ts +11 -1
  37. package/src/lib/server/chat-execution/prompt-builder.test.ts +28 -0
  38. package/src/lib/server/chat-execution/prompt-builder.ts +14 -1
  39. package/src/lib/server/chat-execution/prompt-mode.test.ts +24 -0
  40. package/src/lib/server/chat-execution/prompt-mode.ts +5 -1
  41. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -4
  42. package/src/lib/server/chat-execution/stream-agent-chat.ts +45 -16
  43. package/src/lib/server/chatrooms/chatroom-routing.test.ts +4 -0
  44. package/src/lib/server/connectors/discord.ts +2 -2
  45. package/src/lib/server/connectors/matrix.ts +3 -2
  46. package/src/lib/server/connectors/signal.ts +5 -4
  47. package/src/lib/server/connectors/slack.ts +10 -9
  48. package/src/lib/server/connectors/teams.ts +3 -2
  49. package/src/lib/server/connectors/telegram.ts +4 -4
  50. package/src/lib/server/connectors/whatsapp.ts +2 -2
  51. package/src/lib/server/daemon/controller.ts +7 -0
  52. package/src/lib/server/gateways/gateway-profile-service.ts +19 -1
  53. package/src/lib/server/messages/message-repository.test.ts +70 -0
  54. package/src/lib/server/messages/message-repository.ts +11 -6
  55. package/src/lib/server/openclaw/deploy.ts +32 -2
  56. package/src/lib/server/plugins-advanced.test.ts +1 -2
  57. package/src/lib/server/provider-health.ts +1 -1
  58. package/src/lib/server/runtime/process-manager.ts +13 -9
  59. package/src/lib/server/runtime/session-run-manager/queries.ts +15 -0
  60. package/src/lib/server/runtime/session-run-manager.test.ts +58 -0
  61. package/src/lib/server/sandbox/session-runtime.test.ts +18 -1
  62. package/src/lib/server/sandbox/session-runtime.ts +40 -28
  63. package/src/lib/server/session-tools/autonomy-tools.test.ts +7 -9
  64. package/src/lib/server/session-tools/context.ts +1 -1
  65. package/src/lib/server/session-tools/credential-env.ts +109 -0
  66. package/src/lib/server/session-tools/crud.ts +3 -3
  67. package/src/lib/server/session-tools/edit_file.ts +3 -2
  68. package/src/lib/server/session-tools/execute.test.ts +58 -0
  69. package/src/lib/server/session-tools/execute.ts +334 -0
  70. package/src/lib/server/session-tools/files-tool.ts +635 -0
  71. package/src/lib/server/session-tools/index.ts +14 -4
  72. package/src/lib/server/session-tools/memory-tool.ts +242 -0
  73. package/src/lib/server/session-tools/memory.ts +1 -1
  74. package/src/lib/server/session-tools/openclaw-nodes.ts +3 -2
  75. package/src/lib/server/session-tools/openclaw-workspace.ts +3 -2
  76. package/src/lib/server/session-tools/platform-tool.ts +617 -0
  77. package/src/lib/server/session-tools/session-info.ts +3 -2
  78. package/src/lib/server/session-tools/session-tools-wiring.test.ts +3 -4
  79. package/src/lib/server/session-tools/shell.ts +7 -122
  80. package/src/lib/server/session-tools/skills-tool.ts +396 -0
  81. package/src/lib/server/session-tools/web.ts +2 -2
  82. package/src/lib/server/storage-normalization.ts +2 -0
  83. package/src/lib/server/tool-aliases.ts +2 -1
  84. package/src/lib/server/tool-capability-policy-advanced.test.ts +9 -2
  85. package/src/lib/server/tool-capability-policy.test.ts +2 -1
  86. package/src/lib/server/tool-capability-policy.ts +60 -33
  87. package/src/lib/server/tool-planning.ts +11 -0
  88. package/src/lib/setup-defaults.ts +5 -0
  89. package/src/lib/tool-definitions.ts +1 -0
  90. package/src/lib/validation/schemas.test.ts +16 -0
  91. package/src/lib/validation/schemas.ts +16 -0
  92. package/src/stores/use-chat-store.test.ts +231 -0
  93. package/src/stores/use-chat-store.ts +62 -13
  94. package/src/types/agent.ts +348 -0
  95. package/src/types/app-settings.ts +175 -0
  96. package/src/types/approval.ts +27 -0
  97. package/src/types/connector.ts +187 -0
  98. package/src/types/extension.ts +386 -0
  99. package/src/types/index.ts +16 -3555
  100. package/src/types/message.ts +57 -0
  101. package/src/types/misc.ts +739 -0
  102. package/src/types/mission.ts +185 -0
  103. package/src/types/protocol.ts +422 -0
  104. package/src/types/provider.ts +52 -0
  105. package/src/types/run.ts +183 -0
  106. package/src/types/schedule.ts +59 -0
  107. package/src/types/session.ts +265 -0
  108. package/src/types/skill.ts +157 -0
  109. package/src/types/task.ts +140 -0
  110. package/src/types/working-state.ts +211 -0
  111. package/src/views/settings/section-heartbeat.tsx +2 -2
  112. 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,
@@ -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',
@@ -76,6 +76,7 @@ import {
76
76
  import { checkAgentBudgetLimits } from '@/lib/server/cost'
77
77
  import {
78
78
  classifyMessage,
79
+ type MessageClassification,
79
80
  toMessageSemanticsSummary,
80
81
  } from '@/lib/server/chat-execution/message-classifier'
81
82
  import {
@@ -87,6 +88,7 @@ import {
87
88
  } from '@/lib/server/chat-execution/chat-execution-utils'
88
89
  import { loadEstopState } from '@/lib/server/runtime/estop'
89
90
  import { buildToolSection, joinPromptSegments } from '@/lib/server/chat-execution/prompt-builder'
91
+ import { resolvePromptMode, type PromptMode } from '@/lib/server/chat-execution/prompt-mode'
90
92
  import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
91
93
  import type { ExecuteChatTurnInput } from '@/lib/server/chat-execution/chat-execution'
92
94
 
@@ -320,13 +322,17 @@ function buildLightHeartbeatSystemPrompt(session: Session): string | undefined {
320
322
  return parts.join('\n\n')
321
323
  }
322
324
 
323
- function buildAgentSystemPrompt(session: Session): string | undefined {
325
+ function buildAgentSystemPrompt(
326
+ session: Session,
327
+ options?: { lightweightDirectChat?: boolean },
328
+ ): string | undefined {
324
329
  if (!session.agentId) return undefined
325
330
  const agent = getAgent(session.agentId)
326
331
  if (!agent) return undefined
327
332
 
328
333
  const settings = loadSettings()
329
334
  const allowSilentReplies = isDirectConnectorSession(session)
335
+ const lightweightDirectChat = options?.lightweightDirectChat === true
330
336
  const parts: string[] = []
331
337
  const enabledExtensions = listUniversalToolAccessExtensionIds(
332
338
  getEnabledCapabilityIds(session).length > 0 ? getEnabledCapabilityIds(session) : getEnabledCapabilityIds(agent),
@@ -341,7 +347,7 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
341
347
  if (agent.description) identityLines.push(`Description: ${agent.description}`)
342
348
  identityLines.push('I should always embody this identity. I am not just an "Assistant" — I am a persistent companion with my own soul and presence.')
343
349
  parts.push(identityLines.join('\n'))
344
- const continuityBlock = buildIdentityContinuityContext(session, agent)
350
+ const continuityBlock = lightweightDirectChat ? null : buildIdentityContinuityContext(session, agent)
345
351
  if (continuityBlock) parts.push(continuityBlock)
346
352
 
347
353
  const runtimeLines = [
@@ -358,50 +364,57 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
358
364
  if (agent.soul) parts.push(`## Soul\n${agent.soul}`)
359
365
  if (agent.systemPrompt) parts.push(`## System Prompt\n${agent.systemPrompt}`)
360
366
 
361
- try {
362
- const runtimeSkills = resolveRuntimeSkills({
363
- cwd: session.cwd,
364
- enabledExtensions,
365
- agentId: agent.id,
366
- sessionId: session.id,
367
- userId: session.user,
368
- agentSkillIds: agent.skillIds || [],
369
- storedSkills: loadSkills(),
370
- selectedSkillId: session.skillRuntimeState?.selectedSkillId || null,
371
- })
372
- parts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
373
- } catch {
374
- // Runtime skills are non-critical during prompt assembly.
375
- }
367
+ if (!lightweightDirectChat) {
368
+ try {
369
+ const runtimeSkills = resolveRuntimeSkills({
370
+ cwd: session.cwd,
371
+ enabledExtensions,
372
+ agentId: agent.id,
373
+ sessionId: session.id,
374
+ userId: session.user,
375
+ agentSkillIds: agent.skillIds || [],
376
+ storedSkills: loadSkills(),
377
+ selectedSkillId: session.skillRuntimeState?.selectedSkillId || null,
378
+ })
379
+ parts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
380
+ } catch {
381
+ // Runtime skills are non-critical during prompt assembly.
382
+ }
376
383
 
377
- try {
378
- const wsCtx = buildWorkspaceContext({ cwd: session.cwd })
379
- if (wsCtx.block) parts.push(wsCtx.block)
380
- } catch {
381
- // Workspace context is non-critical.
384
+ try {
385
+ const wsCtx = buildWorkspaceContext({ cwd: session.cwd })
386
+ if (wsCtx.block) parts.push(wsCtx.block)
387
+ } catch {
388
+ // Workspace context is non-critical.
389
+ }
382
390
  }
383
391
 
384
392
  const thinkingHint = [
385
393
  '## Output Format',
386
394
  'If your model supports internal reasoning/thinking, put all internal analysis inside <think>...</think> tags.',
387
395
  'Your final response to the user should be clear and concise.',
396
+ ...(lightweightDirectChat
397
+ ? ['This is a lightweight direct chat turn. Reply naturally in 1-3 short sentences. Do not delegate, plan, or narrate tools unless the user adds a concrete task that needs that escalation.']
398
+ : []),
388
399
  allowSilentReplies
389
400
  ? 'When you truly have nothing to say, respond with ONLY: NO_MESSAGE'
390
401
  : 'For direct user chats, always send a visible reply. Never answer with NO_MESSAGE or HEARTBEAT_OK unless this is an explicit heartbeat poll.',
391
402
  ]
392
403
  parts.push(thinkingHint.join('\n'))
393
404
 
394
- if (enabledExtensions.length === 0) {
395
- parts.push(buildNoToolsGuidance().join('\n'))
396
- } else {
397
- parts.push(buildEnabledToolsAutonomyGuidance().join('\n'))
405
+ if (!lightweightDirectChat) {
406
+ if (enabledExtensions.length === 0) {
407
+ parts.push(buildNoToolsGuidance().join('\n'))
408
+ } else {
409
+ parts.push(buildEnabledToolsAutonomyGuidance().join('\n'))
410
+ }
411
+ const toolSectionLines = buildToolSection(enabledExtensions)
412
+ if (toolSectionLines.length > 0) parts.push(['## Tool Discipline', ...toolSectionLines].join('\n'))
413
+ const operatingGuidance = collectCapabilityOperatingGuidance(enabledExtensions)
414
+ if (operatingGuidance.length > 0) parts.push(['## Tool Guidance', ...operatingGuidance].join('\n'))
415
+ const capabilityLines = collectCapabilityDescriptions(enabledExtensions)
416
+ if (capabilityLines.length > 0) parts.push(['## Tool Capabilities', ...capabilityLines].join('\n'))
398
417
  }
399
- const toolSectionLines = buildToolSection(enabledExtensions)
400
- if (toolSectionLines.length > 0) parts.push(['## Tool Discipline', ...toolSectionLines].join('\n'))
401
- const operatingGuidance = collectCapabilityOperatingGuidance(enabledExtensions)
402
- if (operatingGuidance.length > 0) parts.push(['## Tool Guidance', ...operatingGuidance].join('\n'))
403
- const capabilityLines = collectCapabilityDescriptions(enabledExtensions)
404
- if (capabilityLines.length > 0) parts.push(['## Tool Capabilities', ...capabilityLines].join('\n'))
405
418
 
406
419
  parts.push([
407
420
  '## Heartbeats',
@@ -470,6 +483,8 @@ export interface PreparedExecutableChatTurn {
470
483
  runStartedAt: number
471
484
  runMessageStartIndex: number
472
485
  toolPolicy: ReturnType<typeof resolveSessionToolPolicy>
486
+ classification: MessageClassification | null
487
+ promptMode: PromptMode
473
488
  }
474
489
 
475
490
  export type PreparedChatTurn = PreparedBlockedChatTurn | PreparedExecutableChatTurn
@@ -620,6 +635,24 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
620
635
  }
621
636
  }
622
637
 
638
+ const turnHistory = getMessages(sessionId)
639
+ const classification = !internal
640
+ ? await classifyMessage({
641
+ sessionId,
642
+ agentId: session.agentId || null,
643
+ message,
644
+ history: turnHistory,
645
+ }).catch(() => null as MessageClassification | null)
646
+ : null
647
+ const lightweightDirectChat = classification?.isLightweightDirectChat === true
648
+ && !internal
649
+ && source === 'chat'
650
+ && !isDirectConnectorSession(sessionForRun)
651
+ const promptMode = resolvePromptMode(sessionForRun, { preferMinimalPrompt: lightweightDirectChat })
652
+ if (lightweightDirectChat && sessionForRun.thinkingLevel !== 'minimal') {
653
+ sessionForRun = { ...sessionForRun, thinkingLevel: 'minimal' }
654
+ }
655
+
623
656
  if (isHeartbeatRun && input.modelOverride) {
624
657
  sessionForRun = { ...sessionForRun, model: input.modelOverride }
625
658
  }
@@ -632,7 +665,10 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
632
665
  if (extensionsForRun.length > 0) {
633
666
  const modelResolvePrompt = heartbeatLightContext
634
667
  ? (joinSystemPromptBlocks(buildLightHeartbeatSystemPrompt(sessionForRun), executionBriefContextBlock) || '')
635
- : (joinSystemPromptBlocks(buildAgentSystemPrompt(sessionForRun), executionBriefContextBlock) || '')
668
+ : (joinSystemPromptBlocks(
669
+ buildAgentSystemPrompt(sessionForRun, { lightweightDirectChat }),
670
+ executionBriefContextBlock,
671
+ ) || '')
636
672
  const modelResolve = await runCapabilityBeforeModelResolve(
637
673
  {
638
674
  session: sessionForRun,
@@ -724,14 +760,7 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
724
760
  if (shouldPersistUserMessage) {
725
761
  const [linkAnalysis, semantics] = await Promise.all([
726
762
  !internal ? runLinkUnderstanding(message) : Promise.resolve([]),
727
- classifyMessage({
728
- sessionId,
729
- agentId: session.agentId || null,
730
- message,
731
- history: getMessages(sessionId),
732
- })
733
- .then((classification) => toMessageSemanticsSummary(classification))
734
- .catch(() => undefined),
763
+ Promise.resolve(toMessageSemanticsSummary(classification)),
735
764
  ])
736
765
  const guardedUserText = guardUntrustedText({
737
766
  text: message,
@@ -745,6 +774,7 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
745
774
  role: 'user',
746
775
  text: guardedUserText,
747
776
  time: Date.now(),
777
+ runId: lifecycleRunId,
748
778
  imagePath: imagePath || undefined,
749
779
  imageUrl: imageUrl || undefined,
750
780
  attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
@@ -805,7 +835,12 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
805
835
 
806
836
  const systemPrompt = heartbeatLightContext
807
837
  ? joinSystemPromptBlocks(buildLightHeartbeatSystemPrompt(sessionForRun), executionBriefContextBlock)
808
- : (hasExtensions ? undefined : joinSystemPromptBlocks(buildAgentSystemPrompt(sessionForRun), executionBriefContextBlock))
838
+ : (hasExtensions
839
+ ? undefined
840
+ : joinSystemPromptBlocks(
841
+ buildAgentSystemPrompt(sessionForRun, { lightweightDirectChat }),
842
+ executionBriefContextBlock,
843
+ ))
809
844
 
810
845
  return {
811
846
  kind: 'ready',
@@ -837,5 +872,7 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
837
872
  runStartedAt,
838
873
  runMessageStartIndex,
839
874
  toolPolicy,
875
+ classification,
876
+ promptMode,
840
877
  }
841
878
  }
@@ -76,6 +76,8 @@ export async function executePreparedChatTurn(params: {
76
76
  isAutoRunNoHistory,
77
77
  executionBrief,
78
78
  executionBriefContextBlock,
79
+ classification,
80
+ promptMode,
79
81
  } = prepared
80
82
 
81
83
  const emit = partialPersistence.emit
@@ -151,6 +153,8 @@ export async function executePreparedChatTurn(params: {
151
153
  history: heartbeatHistory ?? applyContextClearBoundary(getSessionMessages(sessionId)),
152
154
  signal: abortController.signal,
153
155
  source,
156
+ classification,
157
+ promptMode,
154
158
  })
155
159
  fullResponse = result.finalResponse || result.fullText
156
160
  } else {
@@ -102,6 +102,13 @@ function checkUnfinishedToolCallsPending(ctx: ContinuationContext): Continuation
102
102
  return null
103
103
  }
104
104
 
105
+ function checkLightweightDirectChat(ctx: ContinuationContext): ContinuationDecision | null {
106
+ if (ctx.classification?.isLightweightDirectChat !== true) return null
107
+ if (!ctx.state.fullText.trim()) return null
108
+ if (ctx.state.hasToolCalls || ctx.state.streamedToolEvents.length > 0) return null
109
+ return { type: false, requiredToolReminderNames: [] }
110
+ }
111
+
105
112
  function checkLoopDetection(ctx: ContinuationContext): ContinuationDecision | null {
106
113
  const isToolFrequency = (ctx.state.loopDetectionTriggered?.detector === 'tool_frequency') || ctx.state.toolFrequencyBlocked
107
114
  if (!ctx.state.loopDetectionTriggered && !isToolFrequency) return null
@@ -412,6 +419,7 @@ export function evaluateContinuation(ctx: ContinuationContext): ContinuationDeci
412
419
  const checks = [
413
420
  checkUnfinishedToolCallsPending,
414
421
  checkLoopDetection,
422
+ checkLightweightDirectChat,
415
423
  checkCoordinatorDelegation,
416
424
  checkExecutionContinuation,
417
425
  checkRequiredTools,
@@ -112,7 +112,7 @@ export function shouldTerminateOnSuccessfulMemoryMutation(params: {
112
112
  : exactToolName === 'memory_update'
113
113
  ? 'update'
114
114
  : resolveToolAction(params.toolInput)
115
- if (action !== 'store' && action !== 'update') return false
115
+ if (action !== 'store' && action !== 'update' && action !== 'write') return false
116
116
  const output = extractSuggestions(params.toolOutput || '').clean.trim()
117
117
  if (!output || /^error[:\s]/i.test(output)) return false
118
118
  if (!/^(stored|updated) memory\b/i.test(output)) return false