@swarmclawai/swarmclaw 0.7.8 → 0.8.0

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 (251) hide show
  1. package/README.md +12 -15
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +22 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +26 -1
  17. package/src/app/api/external-agents/route.test.ts +165 -0
  18. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  19. package/src/app/api/gateways/[id]/route.ts +2 -0
  20. package/src/app/api/gateways/health-route.test.ts +135 -0
  21. package/src/app/api/gateways/route.ts +2 -0
  22. package/src/app/api/mcp-servers/route.test.ts +130 -0
  23. package/src/app/api/openclaw/deploy/route.ts +38 -5
  24. package/src/app/api/plugins/install/route.ts +46 -6
  25. package/src/app/api/plugins/marketplace/route.ts +48 -15
  26. package/src/app/api/preview-server/route.ts +26 -11
  27. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  28. package/src/app/api/schedules/route.test.ts +86 -0
  29. package/src/app/api/schedules/route.ts +6 -1
  30. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  31. package/src/app/api/setup/check-provider/route.ts +40 -10
  32. package/src/app/api/skills/[id]/route.ts +12 -0
  33. package/src/app/api/skills/import/route.ts +14 -12
  34. package/src/app/api/skills/route.ts +13 -1
  35. package/src/app/api/tasks/[id]/route.ts +10 -1
  36. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  37. package/src/app/api/tasks/import/github/route.ts +337 -0
  38. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  39. package/src/app/api/wallets/[id]/route.ts +79 -33
  40. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  41. package/src/app/api/wallets/route.ts +78 -61
  42. package/src/app/api/webhooks/[id]/route.ts +33 -6
  43. package/src/app/api/webhooks/route.test.ts +272 -0
  44. package/src/cli/index.js +1 -0
  45. package/src/cli/spec.js +1 -0
  46. package/src/components/agents/agent-card.tsx +9 -2
  47. package/src/components/agents/agent-chat-list.tsx +18 -2
  48. package/src/components/agents/agent-list.tsx +1 -0
  49. package/src/components/agents/agent-sheet.tsx +73 -24
  50. package/src/components/agents/inspector-panel.tsx +41 -0
  51. package/src/components/canvas/canvas-panel.tsx +236 -65
  52. package/src/components/chat/chat-card.tsx +36 -13
  53. package/src/components/chat/chat-header.tsx +44 -16
  54. package/src/components/chat/chat-list.tsx +28 -4
  55. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  56. package/src/components/chat/message-bubble.tsx +208 -145
  57. package/src/components/chat/message-list.tsx +48 -19
  58. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  59. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  60. package/src/components/connectors/connector-health.tsx +1 -1
  61. package/src/components/connectors/connector-list.tsx +7 -2
  62. package/src/components/connectors/connector-sheet.tsx +337 -148
  63. package/src/components/gateways/gateway-sheet.tsx +2 -2
  64. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  65. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  66. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  67. package/src/components/plugins/plugin-list.tsx +45 -9
  68. package/src/components/plugins/plugin-sheet.tsx +55 -7
  69. package/src/components/providers/provider-list.tsx +2 -1
  70. package/src/components/providers/provider-sheet.tsx +21 -2
  71. package/src/components/schedules/schedule-card.tsx +25 -1
  72. package/src/components/schedules/schedule-sheet.tsx +44 -2
  73. package/src/components/secrets/secret-sheet.tsx +21 -2
  74. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  75. package/src/components/shared/bottom-sheet.tsx +13 -3
  76. package/src/components/shared/command-palette.tsx +8 -1
  77. package/src/components/shared/confirm-dialog.tsx +19 -4
  78. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  79. package/src/components/shared/connector-platform-icon.tsx +39 -6
  80. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  81. package/src/components/shared/settings/section-capability-policy.tsx +7 -3
  82. package/src/components/skills/skill-list.tsx +25 -0
  83. package/src/components/skills/skill-sheet.tsx +84 -12
  84. package/src/components/tasks/approvals-panel.tsx +191 -95
  85. package/src/components/tasks/task-board.tsx +273 -2
  86. package/src/components/tasks/task-card.tsx +38 -9
  87. package/src/components/ui/dialog.tsx +2 -2
  88. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  89. package/src/components/wallets/wallet-panel.tsx +435 -90
  90. package/src/components/wallets/wallet-section.tsx +198 -48
  91. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  92. package/src/lib/approval-display.ts +20 -0
  93. package/src/lib/canvas-content.ts +198 -0
  94. package/src/lib/chat-artifact-summary.ts +165 -0
  95. package/src/lib/chat-display.test.ts +91 -0
  96. package/src/lib/chat-display.ts +58 -0
  97. package/src/lib/chat-streaming-state.test.ts +47 -1
  98. package/src/lib/chat-streaming-state.ts +42 -0
  99. package/src/lib/ollama-model.ts +10 -0
  100. package/src/lib/openclaw-endpoint.test.ts +8 -0
  101. package/src/lib/openclaw-endpoint.ts +6 -1
  102. package/src/lib/plugin-install-cors.ts +46 -0
  103. package/src/lib/plugin-sources.test.ts +43 -0
  104. package/src/lib/plugin-sources.ts +77 -0
  105. package/src/lib/providers/ollama.ts +16 -6
  106. package/src/lib/providers/openclaw.test.ts +54 -0
  107. package/src/lib/providers/openclaw.ts +127 -11
  108. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  109. package/src/lib/schedule-dedupe.test.ts +66 -1
  110. package/src/lib/schedule-dedupe.ts +169 -12
  111. package/src/lib/schedule-origin.test.ts +20 -0
  112. package/src/lib/schedule-origin.ts +15 -0
  113. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  114. package/src/lib/server/agent-availability.ts +16 -0
  115. package/src/lib/server/agent-runtime-config.ts +12 -4
  116. package/src/lib/server/agent-thread-session.test.ts +51 -0
  117. package/src/lib/server/agent-thread-session.ts +7 -0
  118. package/src/lib/server/approval-match.ts +205 -0
  119. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  120. package/src/lib/server/approvals.ts +214 -1
  121. package/src/lib/server/assistant-control.test.ts +29 -0
  122. package/src/lib/server/assistant-control.ts +23 -0
  123. package/src/lib/server/build-llm.test.ts +79 -0
  124. package/src/lib/server/build-llm.ts +14 -4
  125. package/src/lib/server/canvas-content.test.ts +32 -0
  126. package/src/lib/server/canvas-content.ts +6 -0
  127. package/src/lib/server/capability-router.test.ts +11 -0
  128. package/src/lib/server/capability-router.ts +26 -1
  129. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  130. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  131. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  132. package/src/lib/server/chat-execution.ts +353 -72
  133. package/src/lib/server/clawhub-client.test.ts +14 -8
  134. package/src/lib/server/connectors/manager.test.ts +1147 -0
  135. package/src/lib/server/connectors/manager.ts +362 -63
  136. package/src/lib/server/connectors/pairing.ts +26 -5
  137. package/src/lib/server/connectors/types.ts +2 -0
  138. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  139. package/src/lib/server/connectors/whatsapp.ts +271 -47
  140. package/src/lib/server/context-manager.ts +6 -1
  141. package/src/lib/server/daemon-state.ts +1 -1
  142. package/src/lib/server/data-dir.test.ts +37 -0
  143. package/src/lib/server/data-dir.ts +20 -1
  144. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  145. package/src/lib/server/devserver-launch.test.ts +60 -0
  146. package/src/lib/server/devserver-launch.ts +85 -0
  147. package/src/lib/server/elevenlabs.test.ts +189 -1
  148. package/src/lib/server/elevenlabs.ts +147 -43
  149. package/src/lib/server/ethereum.ts +590 -0
  150. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  151. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  152. package/src/lib/server/eval/agent-regression.ts +383 -11
  153. package/src/lib/server/evm-swap.ts +475 -0
  154. package/src/lib/server/execution-log.ts +1 -0
  155. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  156. package/src/lib/server/heartbeat-service.ts +15 -10
  157. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  158. package/src/lib/server/heartbeat-wake.ts +338 -57
  159. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  160. package/src/lib/server/mcp-client.test.ts +16 -0
  161. package/src/lib/server/mcp-client.ts +25 -0
  162. package/src/lib/server/memory-integration.test.ts +719 -0
  163. package/src/lib/server/memory-policy.test.ts +43 -0
  164. package/src/lib/server/memory-policy.ts +132 -0
  165. package/src/lib/server/memory-tiers.test.ts +60 -0
  166. package/src/lib/server/memory-tiers.ts +16 -0
  167. package/src/lib/server/ollama-runtime.ts +58 -0
  168. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  169. package/src/lib/server/openclaw-deploy.ts +557 -81
  170. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  171. package/src/lib/server/openclaw-gateway.ts +10 -4
  172. package/src/lib/server/openclaw-health.test.ts +35 -0
  173. package/src/lib/server/openclaw-health.ts +215 -47
  174. package/src/lib/server/orchestrator-lg.ts +2 -2
  175. package/src/lib/server/plugins-advanced.test.ts +351 -0
  176. package/src/lib/server/plugins.ts +205 -5
  177. package/src/lib/server/queue-advanced.test.ts +528 -0
  178. package/src/lib/server/queue-followups.test.ts +262 -0
  179. package/src/lib/server/queue-reconcile.test.ts +128 -0
  180. package/src/lib/server/queue.ts +293 -61
  181. package/src/lib/server/scheduler.ts +29 -1
  182. package/src/lib/server/session-note.test.ts +36 -0
  183. package/src/lib/server/session-note.ts +42 -0
  184. package/src/lib/server/session-run-manager.ts +52 -4
  185. package/src/lib/server/session-tools/canvas.ts +14 -12
  186. package/src/lib/server/session-tools/connector.test.ts +138 -0
  187. package/src/lib/server/session-tools/connector.ts +348 -61
  188. package/src/lib/server/session-tools/context.ts +12 -3
  189. package/src/lib/server/session-tools/crud.ts +221 -10
  190. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  191. package/src/lib/server/session-tools/delegate.ts +64 -8
  192. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  193. package/src/lib/server/session-tools/discovery.ts +80 -12
  194. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  195. package/src/lib/server/session-tools/file.ts +43 -4
  196. package/src/lib/server/session-tools/human-loop.ts +35 -5
  197. package/src/lib/server/session-tools/index.ts +44 -9
  198. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  199. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  200. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  201. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  202. package/src/lib/server/session-tools/memory.test.ts +93 -0
  203. package/src/lib/server/session-tools/memory.ts +546 -79
  204. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  205. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  206. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  207. package/src/lib/server/session-tools/schedule.ts +6 -1
  208. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  209. package/src/lib/server/session-tools/shell.ts +22 -3
  210. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  211. package/src/lib/server/session-tools/wallet.ts +1374 -139
  212. package/src/lib/server/session-tools/web-inputs.test.ts +162 -1
  213. package/src/lib/server/session-tools/web.ts +468 -64
  214. package/src/lib/server/skill-discovery.ts +128 -0
  215. package/src/lib/server/skill-eligibility.test.ts +84 -0
  216. package/src/lib/server/skill-eligibility.ts +95 -0
  217. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  218. package/src/lib/server/skill-prompt-budget.ts +125 -0
  219. package/src/lib/server/skills-normalize.test.ts +54 -0
  220. package/src/lib/server/skills-normalize.ts +372 -26
  221. package/src/lib/server/solana.ts +214 -29
  222. package/src/lib/server/storage.ts +65 -36
  223. package/src/lib/server/stream-agent-chat.test.ts +419 -9
  224. package/src/lib/server/stream-agent-chat.ts +887 -83
  225. package/src/lib/server/system-events.ts +1 -1
  226. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  227. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  228. package/src/lib/server/tool-loop-detection.ts +260 -0
  229. package/src/lib/server/tool-planning.ts +4 -2
  230. package/src/lib/server/wallet-execution.test.ts +198 -0
  231. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  232. package/src/lib/server/wallet-portfolio.ts +724 -0
  233. package/src/lib/server/wallet-service.test.ts +57 -0
  234. package/src/lib/server/wallet-service.ts +213 -0
  235. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  236. package/src/lib/server/watch-jobs.ts +17 -2
  237. package/src/lib/server/workspace-context.ts +111 -0
  238. package/src/lib/skill-save-payload.test.ts +39 -0
  239. package/src/lib/skill-save-payload.ts +37 -0
  240. package/src/lib/tasks.ts +28 -0
  241. package/src/lib/tool-event-summary.test.ts +30 -0
  242. package/src/lib/tool-event-summary.ts +37 -0
  243. package/src/lib/validation/schemas.ts +1 -0
  244. package/src/lib/wallet-transactions.test.ts +75 -0
  245. package/src/lib/wallet-transactions.ts +43 -0
  246. package/src/lib/wallet.test.ts +17 -0
  247. package/src/lib/wallet.ts +183 -0
  248. package/src/proxy.test.ts +31 -0
  249. package/src/proxy.ts +34 -2
  250. package/src/stores/use-chat-store.ts +15 -1
  251. package/src/types/index.ts +210 -14
@@ -25,7 +25,7 @@ import type { StructuredToolInterface } from '@langchain/core/tools'
25
25
  import type { Session } from '@/types'
26
26
  import { stripMainLoopMetaForPersistence } from './main-agent-loop'
27
27
  import { getPluginManager } from './plugins'
28
- import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
28
+ import { isLocalOpenClawEndpoint, normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
29
29
  import { routeTaskIntent } from './capability-router'
30
30
  import { notify } from './ws-hub'
31
31
  import { applyResolvedRoute, resolvePrimaryAgentRoute } from './agent-runtime-config'
@@ -38,6 +38,7 @@ import {
38
38
  setCachedLlmResponse,
39
39
  type LlmResponseCacheKeyInput,
40
40
  } from './llm-response-cache'
41
+ import { genId } from '@/lib/id'
41
42
  import type { Message, MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
42
43
  import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
43
44
  import { isHeartbeatSource, isInternalHeartbeatRun } from './heartbeat-source'
@@ -47,14 +48,18 @@ import { syncSessionArchiveMemory } from './session-archive-memory'
47
48
  import { evaluateSessionFreshness, resetSessionRuntime, resolveSessionResetPolicy } from './session-reset-policy'
48
49
  import { pruneStreamingAssistantArtifacts, upsertStreamingAssistantArtifact } from '@/lib/chat-streaming-state'
49
50
  import { resolveActiveProjectContext } from './project-context'
51
+ import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from './assistant-control'
52
+ import { buildToolEventAssistantSummary } from '@/lib/tool-event-summary'
53
+ import { buildAgentDisabledMessage, isAgentDisabled } from './agent-availability'
50
54
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
51
55
 
52
56
  /** Slice history from the most recent context-clear marker forward */
53
57
  function applyContextClearBoundary(messages: Message[]): Message[] {
58
+ const filterModelHistory = (items: Message[]) => items.filter((message) => message.historyExcluded !== true)
54
59
  for (let i = messages.length - 1; i >= 0; i--) {
55
- if (messages[i].kind === 'context-clear') return messages.slice(i + 1)
60
+ if (messages[i].kind === 'context-clear') return filterModelHistory(messages.slice(i + 1))
56
61
  }
57
- return messages
62
+ return filterModelHistory(messages)
58
63
  }
59
64
 
60
65
  interface SessionWithTools {
@@ -120,6 +125,7 @@ export function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
120
125
  previous
121
126
  && previous.name === (ev.toolName || 'unknown')
122
127
  && previous.input === (ev.toolInput || '')
128
+ && previous.toolCallId === (ev.toolCallId || previous.toolCallId)
123
129
  && !previous.output
124
130
  ) {
125
131
  return
@@ -127,11 +133,14 @@ export function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
127
133
  bag.push({
128
134
  name: ev.toolName || 'unknown',
129
135
  input: ev.toolInput || '',
136
+ toolCallId: ev.toolCallId,
130
137
  })
131
138
  return
132
139
  }
133
140
  if (ev.t === 'tool_result') {
134
- const idx = bag.findLastIndex((e) => e.name === (ev.toolName || 'unknown') && !e.output)
141
+ const idx = ev.toolCallId
142
+ ? bag.findLastIndex((e) => e.toolCallId === ev.toolCallId && !e.output)
143
+ : bag.findLastIndex((e) => e.name === (ev.toolName || 'unknown') && !e.output)
135
144
  if (idx === -1) return
136
145
  const output = ev.toolOutput || ''
137
146
  bag[idx] = {
@@ -142,6 +151,25 @@ export function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
142
151
  }
143
152
  }
144
153
 
154
+ function escapeRegExp(value: string): string {
155
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
156
+ }
157
+
158
+ function hasExplicitToolMention(message: string, toolName: string): boolean {
159
+ const escaped = escapeRegExp(toolName)
160
+ const negated = new RegExp(`\\b(?:do not|don't|dont|avoid|skip|without|never)\\s+(?:use\\s+|call\\s+|invoke\\s+)?(?:the\\s+)?\`?${escaped}\`?(?:\\s+tool)?\\b`, 'i')
161
+ if (negated.test(message)) return false
162
+ const boundary = new RegExp(`(^|[^a-z0-9_])\`?${escaped}\`?([^a-z0-9_]|$)`, 'i')
163
+ return boundary.test(message)
164
+ }
165
+
166
+ function hasExplicitGenericToolRequest(message: string, toolName: string): boolean {
167
+ const escaped = escapeRegExp(toolName)
168
+ const negated = new RegExp(`\\b(?:do not|don't|dont|avoid|skip|without|never)\\s+(?:use\\s+|call\\s+|invoke\\s+)?(?:the\\s+)?${escaped}(?:\\s+tool)?\\b`, 'i')
169
+ if (negated.test(message)) return false
170
+ return new RegExp(`(^|[\\s(])\`${escaped}\`([\\s).,!?]|$)|\\b${escaped}\\s+tool\\b|\\buse\\s+(?:the\\s+)?${escaped}\\b|\\bcall\\s+(?:the\\s+)?${escaped}\\b|\\binvoke\\s+(?:the\\s+)?${escaped}\\b`, 'i').test(message)
171
+ }
172
+
145
173
  export function dedupeConsecutiveToolEvents(events: MessageToolEvent[]): MessageToolEvent[] {
146
174
  const sameEvent = (left: MessageToolEvent, right: MessageToolEvent): boolean => (
147
175
  left.name === right.name
@@ -178,6 +206,26 @@ export function dedupeConsecutiveToolEvents(events: MessageToolEvent[]): Message
178
206
  return deduped
179
207
  }
180
208
 
209
+ export function deriveTerminalRunError(params: {
210
+ errorMessage?: string
211
+ fullResponse: string
212
+ streamErrors: string[]
213
+ toolEvents: MessageToolEvent[]
214
+ internal: boolean
215
+ }): string | undefined {
216
+ if (params.errorMessage) return params.errorMessage
217
+
218
+ if (params.streamErrors.length > 0 && !params.fullResponse.trim()) {
219
+ return params.streamErrors[params.streamErrors.length - 1]
220
+ }
221
+
222
+ if (!params.internal && !params.fullResponse.trim() && params.toolEvents.length === 0) {
223
+ return 'Run completed without any response text, tool calls, or explicit error details. Check the provider configuration and try again.'
224
+ }
225
+
226
+ return undefined
227
+ }
228
+
181
229
  function extractDelegateResponse(outputText: string): string | null {
182
230
  try {
183
231
  const parsed = JSON.parse(outputText) as Record<string, unknown>
@@ -358,13 +406,32 @@ function shouldReplaceRecentAssistantMessage(params: {
358
406
  return prevTools === 0
359
407
  }
360
408
 
409
+ function hasPersistableAssistantPayload(text: string, thinking: string, toolEvents: MessageToolEvent[]): boolean {
410
+ return text.trim().length > 0 || thinking.trim().length > 0 || toolEvents.length > 0
411
+ }
412
+
413
+ function getPersistedAssistantText(text: string, toolEvents: MessageToolEvent[]): string {
414
+ const trimmed = text.trim()
415
+ if (trimmed) return trimmed
416
+ return buildToolEventAssistantSummary(toolEvents)
417
+ }
418
+
419
+ function getToolEventsSnapshotKey(toolEvents: MessageToolEvent[]): string {
420
+ return JSON.stringify(toolEvents.map((event) => [
421
+ event.name,
422
+ event.input,
423
+ event.output || '',
424
+ event.error === true,
425
+ event.toolCallId || '',
426
+ ]))
427
+ }
428
+
361
429
  export function pruneSuppressedHeartbeatStreamMessage(messages: Message[]): boolean {
362
430
  return pruneStreamingAssistantArtifacts(messages)
363
431
  }
364
432
 
365
433
  export function requestedToolNamesFromMessage(message: string): string[] {
366
- const lower = message.toLowerCase()
367
- const candidates = [
434
+ const explicitCandidates = [
368
435
  'delegate_to_claude_code',
369
436
  'delegate_to_codex_cli',
370
437
  'delegate_to_opencode_cli',
@@ -392,35 +459,106 @@ export function requestedToolNamesFromMessage(message: string): string[] {
392
459
  'wallet_tool',
393
460
  'http_request',
394
461
  'send_file',
462
+ 'sandbox_exec',
463
+ 'sandbox_list_runtimes',
464
+ 'schedule_wake',
465
+ 'spawn_subagent',
466
+ 'ask_human',
467
+ 'context_status',
468
+ 'context_summarize',
469
+ 'openclaw_nodes',
470
+ 'openclaw_workspace',
471
+ ]
472
+ const genericCandidates = [
395
473
  'browser',
396
474
  'web',
397
475
  'shell',
398
476
  'files',
399
477
  'edit_file',
400
- 'sandbox_exec',
401
- 'sandbox_list_runtimes',
402
478
  'git',
403
479
  'canvas',
404
- 'schedule_wake',
405
- 'spawn_subagent',
406
480
  'mailbox',
407
- 'ask_human',
408
481
  'document',
409
482
  'extract',
410
483
  'table',
411
484
  'crawl',
412
- 'context_status',
413
- 'context_summarize',
414
- 'openclaw_nodes',
415
- 'openclaw_workspace',
485
+ 'email',
416
486
  ]
417
- const requested = candidates.filter((name) => lower.includes(name.toLowerCase()))
418
- if (/(^|[\s(])`delegate`([\s).,!?]|$)|\bdelegate tool\b|\buse delegate\b/.test(lower)) {
487
+ const requested = explicitCandidates.filter((name) => hasExplicitToolMention(message, name))
488
+ for (const name of genericCandidates) {
489
+ if (hasExplicitGenericToolRequest(message, name)) requested.push(name)
490
+ }
491
+ if (hasExplicitGenericToolRequest(message, 'delegate')) {
419
492
  requested.push('delegate')
420
493
  }
421
494
  return Array.from(new Set(requested))
422
495
  }
423
496
 
497
+ function parseToolJsonObject(raw: string): Record<string, unknown> | null {
498
+ const trimmed = raw.trim()
499
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null
500
+ try {
501
+ const parsed = JSON.parse(trimmed)
502
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
503
+ ? parsed as Record<string, unknown>
504
+ : null
505
+ } catch {
506
+ return null
507
+ }
508
+ }
509
+
510
+ function summarizeConnectorToolFailure(output: string): string {
511
+ const trimmed = output.trim()
512
+ const withoutPrefix = trimmed.replace(/^Error:\s*/i, '')
513
+ const parsed = parseToolJsonObject(withoutPrefix) || parseToolJsonObject(trimmed)
514
+ if (parsed) {
515
+ const detail = parsed.detail
516
+ if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
517
+ const detailRecord = detail as Record<string, unknown>
518
+ const message = typeof detailRecord.message === 'string' ? detailRecord.message.trim() : ''
519
+ if (message) return message
520
+ const code = typeof detailRecord.code === 'string' ? detailRecord.code.trim() : ''
521
+ const status = typeof detailRecord.status === 'string' ? detailRecord.status.trim() : ''
522
+ if (code && status) return `${code}: ${status}`
523
+ if (code) return code
524
+ if (status) return status
525
+ }
526
+ const message = typeof parsed.message === 'string' ? parsed.message.trim() : ''
527
+ if (message) return message
528
+ const error = typeof parsed.error === 'string' ? parsed.error.trim() : ''
529
+ if (error) return error
530
+ }
531
+ return withoutPrefix.replace(/\s+/g, ' ').trim() || 'Connector delivery failed.'
532
+ }
533
+
534
+ function connectorToolEventSucceeded(event: MessageToolEvent): boolean {
535
+ if (!event.output) return false
536
+ const parsed = parseToolJsonObject(event.output)
537
+ const status = typeof parsed?.status === 'string' ? parsed.status.trim().toLowerCase() : ''
538
+ return status === 'sent' || status === 'voice_sent' || status === 'scheduled'
539
+ }
540
+
541
+ const POSITIVE_CONNECTOR_DELIVERY_RE = /\b(?:i(?:'ve| have)?(?: successfully)? sent|i sent|successfully sent|sent to your|voice note (?:has been|was) sent|message (?:has been|was) sent)\b/i
542
+
543
+ export function reconcileConnectorDeliveryText(text: string, events: MessageToolEvent[]): string {
544
+ const trimmed = text.trim()
545
+ if (!trimmed || !POSITIVE_CONNECTOR_DELIVERY_RE.test(trimmed)) return text
546
+
547
+ const connectorEvents = dedupeConsecutiveToolEvents(events).filter((event) => event.name === 'connector_message_tool')
548
+ if (connectorEvents.length === 0) return text
549
+ if (connectorEvents.some((event) => connectorToolEventSucceeded(event))) return text
550
+
551
+ const latestFailure = [...connectorEvents]
552
+ .reverse()
553
+ .find((event) => event.error === true && typeof event.output === 'string' && event.output.trim())
554
+
555
+ const failureSummary = latestFailure?.output
556
+ ? summarizeConnectorToolFailure(latestFailure.output)
557
+ : 'I could not confirm that the connector actually sent anything.'
558
+
559
+ return `I couldn't send that through the configured connector. ${failureSummary}`.trim()
560
+ }
561
+
424
562
  function parseKeyValueArgs(raw: string): Record<string, string> {
425
563
  const out: Record<string, string> = {}
426
564
  const regex = /([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*("([^"]*)"|'([^']*)'|[^\s,]+)/g
@@ -563,6 +701,17 @@ function hasToolEnabled(session: SessionWithTools, toolName: string): boolean {
563
701
  return pluginIdMatches(session?.plugins || session?.tools || [], toolName)
564
702
  }
565
703
 
704
+ export function hasDirectLocalCodingTools(session: SessionWithTools): boolean {
705
+ return [
706
+ 'shell',
707
+ 'execute_command',
708
+ 'files',
709
+ 'edit_file',
710
+ 'openclaw_workspace',
711
+ 'sandbox',
712
+ ].some((toolName) => hasToolEnabled(session, toolName))
713
+ }
714
+
566
715
  function enabledDelegationTools(session: SessionWithTools): DelegateTool[] {
567
716
  const tools: DelegateTool[] = []
568
717
  if (hasToolEnabled(session, 'claude_code') || hasToolEnabled(session, 'delegate')) tools.push('delegate_to_claude_code')
@@ -682,6 +831,31 @@ function syncSessionFromAgent(sessionId: string): void {
682
831
  }
683
832
  if (session.shortcutForAgentId !== agent.id) { session.shortcutForAgentId = agent.id; changed = true }
684
833
  if (session.name !== agent.name) { session.name = agent.name; changed = true }
834
+ const desiredHeartbeatEnabled = agent.heartbeatEnabled ?? false
835
+ if ((session.heartbeatEnabled ?? false) !== desiredHeartbeatEnabled) {
836
+ session.heartbeatEnabled = desiredHeartbeatEnabled
837
+ changed = true
838
+ }
839
+ const desiredHeartbeatIntervalSec = agent.heartbeatIntervalSec ?? null
840
+ if ((session.heartbeatIntervalSec ?? null) !== desiredHeartbeatIntervalSec) {
841
+ session.heartbeatIntervalSec = desiredHeartbeatIntervalSec
842
+ changed = true
843
+ }
844
+ const desiredMemoryScopeMode = agent.memoryScopeMode ?? null
845
+ if ((((session as unknown as Record<string, unknown>).memoryScopeMode as string | null | undefined) ?? null) !== desiredMemoryScopeMode) {
846
+ ;(session as unknown as Record<string, unknown>).memoryScopeMode = desiredMemoryScopeMode
847
+ changed = true
848
+ }
849
+ const desiredMemoryTierPreference = agent.memoryTierPreference ?? null
850
+ if ((((session as unknown as Record<string, unknown>).memoryTierPreference as string | null | undefined) ?? null) !== desiredMemoryTierPreference) {
851
+ ;(session as unknown as Record<string, unknown>).memoryTierPreference = desiredMemoryTierPreference
852
+ changed = true
853
+ }
854
+ const desiredProjectId = agent.projectId ?? null
855
+ if ((session.projectId ?? null) !== desiredProjectId) {
856
+ session.projectId = desiredProjectId
857
+ changed = true
858
+ }
685
859
  }
686
860
 
687
861
  if (changed) {
@@ -737,6 +911,15 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
737
911
  }
738
912
  }
739
913
 
914
+ // 5b. Workspace context files (HEARTBEAT.md, IDENTITY.md, AGENTS.md, etc.)
915
+ try {
916
+ const { buildWorkspaceContext } = require('./workspace-context')
917
+ const wsCtx = buildWorkspaceContext({ cwd: session.cwd })
918
+ if (wsCtx.block) parts.push(wsCtx.block)
919
+ } catch {
920
+ // Workspace context is non-critical
921
+ }
922
+
740
923
  // 6. Thinking & Output Format (OpenClaw Style)
741
924
  const thinkingHint = [
742
925
  '## Output Format',
@@ -843,8 +1026,34 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
843
1026
 
844
1027
  const appSettings = loadSettings()
845
1028
  const agentForSession = session.agentId ? loadAgents()[session.agentId] : null
1029
+ if (isAgentDisabled(agentForSession)) {
1030
+ const disabledError = buildAgentDisabledMessage(agentForSession, 'run chats')
1031
+ onEvent?.({ t: 'err', text: disabledError })
1032
+
1033
+ let persisted = false
1034
+ if (!internal) {
1035
+ session.messages.push({
1036
+ role: 'assistant',
1037
+ text: disabledError,
1038
+ time: Date.now(),
1039
+ })
1040
+ session.lastActiveAt = Date.now()
1041
+ saveSessions(sessions)
1042
+ persisted = true
1043
+ }
1044
+
1045
+ return {
1046
+ runId,
1047
+ sessionId,
1048
+ text: disabledError,
1049
+ persisted,
1050
+ toolEvents: [],
1051
+ error: disabledError,
1052
+ }
1053
+ }
846
1054
  const toolPolicy = resolveSessionToolPolicy(session.plugins, appSettings)
847
1055
  const isHeartbeatRun = isInternalHeartbeatRun(internal, source)
1056
+ const isAutonomousInternalRun = internal && source !== 'chat'
848
1057
  const isAutoRunNoHistory = isHeartbeatRun
849
1058
  const heartbeatStatusOnly = false
850
1059
  if (shouldApplySessionFreshnessReset(source)) {
@@ -864,6 +1073,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
864
1073
  saveSessions(sessions)
865
1074
  }
866
1075
  }
1076
+ if (isAutonomousInternalRun) {
1077
+ try { syncSessionArchiveMemory(session, { agent: agentForSession }) } catch { /* archive sync is best-effort */ }
1078
+ }
867
1079
  const pluginsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledPlugins
868
1080
  let sessionForRun = pluginsForRun === session.plugins
869
1081
  ? session
@@ -1034,9 +1246,72 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1034
1246
 
1035
1247
  let thinkingText = ''
1036
1248
  let streamingPartialText = ''
1249
+ let lastPartialSaveAt = 0
1250
+ let lastPartialSnapshotKey = ''
1251
+ let partialSaveTimeout: ReturnType<typeof setTimeout> | null = null
1252
+
1253
+ const persistStreamingAssistantArtifact = () => {
1254
+ partialSaveTimeout = null
1255
+ const persistedToolEvents = toolEvents.length ? dedupeConsecutiveToolEvents([...toolEvents]) : []
1256
+ if (!hasPersistableAssistantPayload(streamingPartialText, thinkingText, persistedToolEvents)) return
1257
+
1258
+ const snapshotKey = JSON.stringify([
1259
+ streamingPartialText,
1260
+ thinkingText,
1261
+ getToolEventsSnapshotKey(persistedToolEvents),
1262
+ ])
1263
+ if (snapshotKey === lastPartialSnapshotKey) return
1264
+
1265
+ lastPartialSnapshotKey = snapshotKey
1266
+ lastPartialSaveAt = Date.now()
1267
+
1268
+ try {
1269
+ const fresh = loadSessions()
1270
+ const current = fresh[sessionId]
1271
+ if (!current) return
1272
+ current.messages = Array.isArray(current.messages) ? current.messages : []
1273
+ const partialMsg: Message = {
1274
+ role: 'assistant',
1275
+ text: streamingPartialText,
1276
+ time: Date.now(),
1277
+ streaming: true,
1278
+ thinking: thinkingText || undefined,
1279
+ toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
1280
+ }
1281
+ upsertStreamingAssistantArtifact(current.messages, partialMsg, {
1282
+ minIndex: runMessageStartIndex,
1283
+ minTime: runStartedAt,
1284
+ })
1285
+ fresh[sessionId] = current
1286
+ saveSessions(fresh)
1287
+ notify(`messages:${sessionId}`)
1288
+ } catch { /* partial save is best-effort */ }
1289
+ }
1290
+
1291
+ const queuePartialAssistantPersist = (immediate = false) => {
1292
+ const now = Date.now()
1293
+ const minIntervalMs = 400
1294
+ if (immediate || now - lastPartialSaveAt >= minIntervalMs) {
1295
+ if (partialSaveTimeout) {
1296
+ clearTimeout(partialSaveTimeout)
1297
+ partialSaveTimeout = null
1298
+ }
1299
+ persistStreamingAssistantArtifact()
1300
+ return
1301
+ }
1302
+ if (partialSaveTimeout) return
1303
+ partialSaveTimeout = setTimeout(() => {
1304
+ persistStreamingAssistantArtifact()
1305
+ }, minIntervalMs - (now - lastPartialSaveAt))
1306
+ }
1307
+
1037
1308
  const emit = (ev: SSEEvent) => {
1309
+ let shouldPersistPartial = false
1310
+ let immediatePartialPersist = false
1038
1311
  if (ev.t === 'd' && typeof ev.text === 'string') {
1039
1312
  streamingPartialText += ev.text
1313
+ shouldPersistPartial = true
1314
+ immediatePartialPersist = streamingPartialText.length === ev.text.length
1040
1315
  }
1041
1316
  if (ev.t === 'err' && typeof ev.text === 'string') {
1042
1317
  const trimmed = ev.text.trim()
@@ -1047,6 +1322,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1047
1322
  }
1048
1323
  if (ev.t === 'thinking' && ev.text) {
1049
1324
  thinkingText += ev.text
1325
+ shouldPersistPartial = true
1050
1326
  }
1051
1327
  if (ev.t === 'md' && ev.text) {
1052
1328
  try {
@@ -1060,36 +1336,18 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1060
1336
  } catch { /* ignore non-JSON md events */ }
1061
1337
  }
1062
1338
  collectToolEvent(ev, toolEvents)
1339
+ if (ev.t === 'tool_call' || ev.t === 'tool_result') {
1340
+ shouldPersistPartial = true
1341
+ immediatePartialPersist = true
1342
+ }
1343
+ if (shouldPersistPartial) queuePartialAssistantPersist(immediatePartialPersist)
1063
1344
  onEvent?.(ev)
1064
1345
  }
1065
1346
 
1066
1347
  // Periodic partial save so a browser refresh doesn't lose the in-flight response.
1067
- let lastPartialSaveLen = 0
1068
- const PARTIAL_SAVE_INTERVAL_MS = 5000
1348
+ const PARTIAL_SAVE_INTERVAL_MS = 2000
1069
1349
  const partialSaveTimer = setInterval(() => {
1070
- if (streamingPartialText.length > lastPartialSaveLen) {
1071
- lastPartialSaveLen = streamingPartialText.length
1072
- try {
1073
- const fresh = loadSessions()
1074
- const current = fresh[sessionId]
1075
- if (!current) return
1076
- current.messages = Array.isArray(current.messages) ? current.messages : []
1077
- const partialMsg: Message = {
1078
- role: 'assistant',
1079
- text: streamingPartialText,
1080
- time: Date.now(),
1081
- streaming: true,
1082
- toolEvents: toolEvents.length ? dedupeConsecutiveToolEvents([...toolEvents]) : undefined,
1083
- }
1084
- upsertStreamingAssistantArtifact(current.messages, partialMsg, {
1085
- minIndex: runMessageStartIndex,
1086
- minTime: runStartedAt,
1087
- })
1088
- fresh[sessionId] = current
1089
- saveSessions(fresh)
1090
- notify(`messages:${sessionId}`)
1091
- } catch { /* partial save is best-effort */ }
1092
- }
1350
+ persistStreamingAssistantArtifact()
1093
1351
  }, PARTIAL_SAVE_INTERVAL_MS)
1094
1352
 
1095
1353
  const parseAndEmit = (raw: string) => {
@@ -1122,7 +1380,10 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1122
1380
  const responseCacheConfig = resolveLlmResponseCacheConfig(appSettings)
1123
1381
  let responseCacheHit = false
1124
1382
  let responseCacheInput: LlmResponseCacheKeyInput | null = null
1125
- const hasPlugins = !!(sessionForRun.plugins?.length || sessionForRun.tools?.length) && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
1383
+ const useLocalOpenClawNativeRuntime = providerType === 'openclaw' && isLocalOpenClawEndpoint(sessionForRun.apiEndpoint)
1384
+ const hasPlugins = !!(sessionForRun.plugins?.length || sessionForRun.tools?.length)
1385
+ && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
1386
+ && !useLocalOpenClawNativeRuntime
1126
1387
 
1127
1388
  let durationMs = 0
1128
1389
  const startTs = Date.now()
@@ -1134,9 +1395,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1134
1395
  ? getSessionMessages(sessionId).slice(-6)
1135
1396
  : undefined
1136
1397
 
1137
- console.log(`[chat-execution] provider=${providerType}, hasPlugins=${hasPlugins}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, plugins=${(sessionForRun.plugins || sessionForRun.tools || []).length}`)
1398
+ console.log(`[chat-execution] provider=${providerType}, hasPlugins=${hasPlugins}, localOpenClawNative=${useLocalOpenClawNativeRuntime}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, plugins=${(sessionForRun.plugins || sessionForRun.tools || []).length}`)
1138
1399
  if (hasPlugins) {
1139
- fullResponse = (await streamAgentChat({
1400
+ const result = await streamAgentChat({
1140
1401
  session: sessionForRun,
1141
1402
  message: effectiveMessage,
1142
1403
  imagePath,
@@ -1146,7 +1407,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1146
1407
  write: (raw) => parseAndEmit(raw),
1147
1408
  history: heartbeatHistory ?? applyContextClearBoundary(getSessionMessages(sessionId)),
1148
1409
  signal: abortController.signal,
1149
- })).fullText
1410
+ })
1411
+ fullResponse = result.finalResponse || result.fullText
1150
1412
  } else {
1151
1413
  const directHistorySnapshot = isAutoRunNoHistory
1152
1414
  ? getSessionMessages(sessionId).slice(-6)
@@ -1218,6 +1480,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1218
1480
  })
1219
1481
  } finally {
1220
1482
  clearInterval(partialSaveTimer)
1483
+ if (partialSaveTimeout) clearTimeout(partialSaveTimeout)
1221
1484
  active.delete(sessionId)
1222
1485
  if (signal) signal.removeEventListener('abort', abortFromOutside)
1223
1486
  }
@@ -1300,10 +1563,11 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1300
1563
  const selectedTool = directTool || tools.find((t) => t?.name === translated.toolName) as StructuredToolInterface | undefined
1301
1564
  if (!selectedTool?.invoke) return false
1302
1565
  const toolInput = JSON.stringify(translated.args)
1303
- emit({ t: 'tool_call', toolName, toolInput })
1566
+ const toolCallId = genId()
1567
+ emit({ t: 'tool_call', toolName, toolInput, toolCallId })
1304
1568
  const toolOutput = await selectedTool.invoke(translated.args)
1305
1569
  const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput)
1306
- emit({ t: 'tool_result', toolName, toolOutput: outputText })
1570
+ emit({ t: 'tool_result', toolName, toolOutput: outputText, toolCallId })
1307
1571
  const delegateResponse = (
1308
1572
  toolName === 'delegate'
1309
1573
  || toolName.startsWith('delegate_to_')
@@ -1357,6 +1621,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1357
1621
  const shouldAutoDelegateCoding = (!internal && source === 'chat')
1358
1622
  && enabledDelegateTools.length > 0
1359
1623
  && !hasDelegationCall
1624
+ && calledNames.size === 0
1625
+ && !requestedToolNames.length
1626
+ && !hasDirectLocalCodingTools(sessionForRun)
1360
1627
  && routingDecision?.intent === 'coding'
1361
1628
 
1362
1629
  if (shouldAutoDelegateCoding) {
@@ -1445,10 +1712,28 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1445
1712
  }
1446
1713
  }
1447
1714
 
1448
- if (!errorMessage && streamErrors.length > 0 && !(fullResponse || '').trim()) {
1449
- errorMessage = streamErrors[streamErrors.length - 1]
1715
+ const terminalError = deriveTerminalRunError({
1716
+ errorMessage,
1717
+ fullResponse: fullResponse || '',
1718
+ streamErrors,
1719
+ toolEvents,
1720
+ internal,
1721
+ })
1722
+ if (terminalError && terminalError !== errorMessage) {
1723
+ if (!errorMessage) {
1724
+ log.warn('chat-run', `Run ended without a visible response for session ${sessionId}`, {
1725
+ runId,
1726
+ source,
1727
+ internal,
1728
+ provider: providerType,
1729
+ messagePreview: effectiveMessage.slice(0, 200),
1730
+ inferredError: terminalError,
1731
+ })
1732
+ }
1733
+ errorMessage = terminalError
1450
1734
  }
1451
1735
 
1736
+ const persistedToolEvents = dedupeConsecutiveToolEvents(toolEvents)
1452
1737
  let finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
1453
1738
  if (pluginsForRun.length > 0 && finalText && !isHeartbeatRun) {
1454
1739
  try {
@@ -1459,27 +1744,30 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1459
1744
  )
1460
1745
  } catch { /* outbound transforms are non-critical */ }
1461
1746
  }
1747
+ finalText = reconcileConnectorDeliveryText(finalText, persistedToolEvents)
1462
1748
  finalText = normalizeAssistantArtifactLinks(finalText, session.cwd)
1463
- const textForPersistence = stripMainLoopMetaForPersistence(finalText)
1464
- const persistedToolEvents = dedupeConsecutiveToolEvents(toolEvents)
1749
+ const rawTextForPersistence = stripMainLoopMetaForPersistence(finalText)
1750
+ const hiddenControlOnly = shouldSuppressHiddenControlText(rawTextForPersistence)
1751
+ const textForPersistence = stripHiddenControlTokens(rawTextForPersistence)
1752
+ const persistedText = getPersistedAssistantText(textForPersistence, persistedToolEvents)
1465
1753
 
1466
- if (isHeartbeatRun && finalText) {
1467
- const heartbeatStatus = extractHeartbeatStatus(finalText)
1754
+ if (isHeartbeatRun && rawTextForPersistence) {
1755
+ const heartbeatStatus = extractHeartbeatStatus(rawTextForPersistence)
1468
1756
  if (heartbeatStatus) emit({ t: 'status', text: JSON.stringify(heartbeatStatus) })
1469
1757
  }
1470
1758
 
1471
1759
  // HEARTBEAT_OK suppression
1472
1760
  const heartbeatConfig = input.heartbeatConfig
1473
1761
  let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
1474
- if (isHeartbeatRun && textForPersistence.length > 0) {
1475
- heartbeatClassification = classifyHeartbeatResponse(textForPersistence, heartbeatConfig?.ackMaxChars ?? 300, toolEvents.length > 0)
1762
+ if (isHeartbeatRun && rawTextForPersistence.length > 0) {
1763
+ heartbeatClassification = classifyHeartbeatResponse(rawTextForPersistence, heartbeatConfig?.ackMaxChars ?? 300, toolEvents.length > 0)
1476
1764
 
1477
1765
  // Deduplication logic from OpenClaw (nagging prevention)
1478
1766
  // If the model repeats itself exactly within 24h, suppress the heartbeat alert.
1479
1767
  if (heartbeatClassification !== 'suppress' && !toolEvents.length) {
1480
1768
  const prevText = session.lastHeartbeatText || ''
1481
1769
  const prevSentAt = session.lastHeartbeatSentAt || 0
1482
- const isDuplicate = prevText.trim() === textForPersistence.trim()
1770
+ const isDuplicate = prevText.trim() === persistedText.trim()
1483
1771
  && (Date.now() - prevSentAt) < 24 * 60 * 60 * 1000
1484
1772
  if (isDuplicate) {
1485
1773
  heartbeatClassification = 'suppress'
@@ -1492,7 +1780,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1492
1780
  notify(`heartbeat:agent:${session.agentId}`)
1493
1781
  }
1494
1782
 
1495
- const shouldPersistAssistant = textForPersistence.length > 0
1783
+ const shouldPersistAssistant = !hiddenControlOnly
1784
+ && hasPersistableAssistantPayload(persistedText, thinkingText, persistedToolEvents)
1496
1785
  && heartbeatClassification !== 'suppress'
1497
1786
 
1498
1787
  const normalizeResumeId = (value: unknown): string | null =>
@@ -1503,16 +1792,14 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1503
1792
  if (current) {
1504
1793
  current.messages = Array.isArray(current.messages) ? current.messages : []
1505
1794
  const currentAgent = current.agentId ? loadAgents()[current.agentId] : null
1506
- let changed = false
1507
- changed = pruneStreamingAssistantArtifacts(current.messages, {
1795
+ pruneStreamingAssistantArtifacts(current.messages, {
1508
1796
  minIndex: runMessageStartIndex,
1509
1797
  minTime: runStartedAt,
1510
- }) || changed
1798
+ })
1511
1799
  const persistField = (key: string, value: unknown) => {
1512
1800
  const normalized = normalizeResumeId(value)
1513
1801
  if ((current as Record<string, unknown>)[key] !== normalized) {
1514
1802
  ;(current as Record<string, unknown>)[key] = normalized
1515
- changed = true
1516
1803
  }
1517
1804
  }
1518
1805
 
@@ -1535,15 +1822,11 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1535
1822
  }
1536
1823
  if (JSON.stringify(currentResume) !== JSON.stringify(nextResume)) {
1537
1824
  current.delegateResumeIds = nextResume
1538
- changed = true
1539
1825
  }
1540
1826
  }
1541
1827
 
1542
1828
  if (shouldPersistAssistant) {
1543
1829
  const persistedKind = isHeartbeatRun ? 'heartbeat' : 'chat'
1544
- const persistedText = heartbeatClassification === 'strip'
1545
- ? textForPersistence.replace(/HEARTBEAT_OK/gi, '').trim()
1546
- : textForPersistence
1547
1830
  const nowTs = Date.now()
1548
1831
  const nextAssistantMessage: Message = {
1549
1832
  role: 'assistant',
@@ -1568,7 +1851,6 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1568
1851
  current.lastHeartbeatText = persistedText
1569
1852
  current.lastHeartbeatSentAt = nowTs
1570
1853
  }
1571
- changed = true
1572
1854
  try {
1573
1855
  await getPluginManager().runHook('onMessage', { session: current, message: nextAssistantMessage }, { enabledIds: pluginsForRun })
1574
1856
  } catch { /* onMessage hooks are non-critical */ }
@@ -1627,7 +1909,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1627
1909
  }
1628
1910
  }
1629
1911
  if (isHeartbeatRun && heartbeatClassification === 'suppress') {
1630
- changed = pruneSuppressedHeartbeatStreamMessage(current.messages) || changed
1912
+ pruneSuppressedHeartbeatStreamMessage(current.messages)
1631
1913
  }
1632
1914
 
1633
1915
  // Fire afterChatTurn hook for all enabled plugins (memory auto-save, logging, etc.)
@@ -1638,6 +1920,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1638
1920
  response: textForPersistence,
1639
1921
  source,
1640
1922
  internal,
1923
+ toolEvents: persistedToolEvents,
1641
1924
  }, { enabledIds: pluginsForRun })
1642
1925
  } catch { /* afterChatTurn hooks are non-critical */ }
1643
1926
 
@@ -1647,10 +1930,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1647
1930
  }
1648
1931
 
1649
1932
  refreshSessionIdentityState(current, currentAgent)
1650
- changed = true
1651
1933
  try {
1652
- const archiveSync = syncSessionArchiveMemory(current, { agent: currentAgent })
1653
- if (archiveSync.stored) changed = true
1934
+ syncSessionArchiveMemory(current, { agent: currentAgent })
1654
1935
  } catch { /* archive sync is best-effort */ }
1655
1936
  fresh[sessionId] = current
1656
1937
  saveSessions(fresh)
@@ -1660,7 +1941,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1660
1941
  return {
1661
1942
  runId,
1662
1943
  sessionId,
1663
- text: finalText,
1944
+ text: hiddenControlOnly ? '' : textForPersistence,
1664
1945
  persisted: shouldPersistAssistant,
1665
1946
  toolEvents: persistedToolEvents,
1666
1947
  error: errorMessage,