@swarmclawai/swarmclaw 0.7.7 → 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 (281) hide show
  1. package/README.md +12 -14
  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 +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -3,19 +3,28 @@ import { loadApprovals, upsertApproval, loadSessions, saveSessions, loadSettings
3
3
  import type { ApprovalRequest, ApprovalCategory, Message } from '@/types'
4
4
  import { notify } from './ws-hub'
5
5
  import { log } from './logger'
6
+ import { requestHeartbeatNow } from './heartbeat-wake'
7
+ import { enqueueSystemEvent } from './system-events'
8
+ import { enqueueSessionRun } from './session-run-manager'
9
+ import { buildApprovalMatchKey, buildApprovalMatchKeyFromRequest } from './approval-match'
10
+ import { getPluginManager } from './plugins'
11
+ import { addAllowedSender } from './connectors/pairing'
6
12
 
7
13
  const AUTO_APPROVABLE_CATEGORIES: ApprovalCategory[] = [
8
14
  'tool_access',
9
15
  'wallet_transfer',
16
+ 'wallet_action',
10
17
  'plugin_scaffold',
11
18
  'plugin_install',
12
19
  'task_tool',
13
20
  'human_loop',
21
+ 'connector_sender',
14
22
  ]
15
23
  const DEFAULT_APPROVAL_CONNECTOR_NOTIFY_DELAY_SEC = 300
16
24
  const MIN_APPROVAL_CONNECTOR_NOTIFY_DELAY_SEC = 30
17
25
  const MAX_APPROVAL_CONNECTOR_NOTIFY_DELAY_SEC = 86_400
18
26
  const APPROVAL_CONNECTOR_NOTIFY_RETRY_COOLDOWN_MS = 10 * 60 * 1000
27
+ const RECENT_APPROVED_APPROVAL_REUSE_WINDOW_MS = 10 * 60 * 1000
19
28
 
20
29
  interface RunningConnectorSummary {
21
30
  id: string
@@ -37,6 +46,53 @@ function trimToString(value: unknown): string {
37
46
  return typeof value === 'string' ? value.trim() : ''
38
47
  }
39
48
 
49
+ function normalizePluginList(value: unknown): string[] {
50
+ if (!Array.isArray(value)) return []
51
+ return value
52
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
53
+ .filter(Boolean)
54
+ }
55
+
56
+ function getApprovalTargetPlugins(request: ApprovalRequest): string[] {
57
+ return [
58
+ trimToString(request.data.pluginId),
59
+ trimToString(request.data.toolId),
60
+ trimToString(request.data.toolName),
61
+ ].filter(Boolean)
62
+ }
63
+
64
+ function getEnabledPluginsForApproval(request: ApprovalRequest): string[] {
65
+ const sessions = loadSessions()
66
+ const agents = loadAgents()
67
+ const sessionPlugins = request.sessionId ? normalizePluginList(sessions[request.sessionId]?.plugins) : []
68
+ const agentPlugins = request.agentId ? normalizePluginList(agents[request.agentId]?.plugins) : []
69
+ const targetPlugins = getApprovalTargetPlugins(request)
70
+ return Array.from(new Set([...sessionPlugins, ...agentPlugins, ...targetPlugins]))
71
+ }
72
+
73
+ function getApprovalGuidance(
74
+ request: ApprovalRequest,
75
+ phase: 'request' | 'resume' | 'connector_reminder',
76
+ approved?: boolean,
77
+ ): string[] {
78
+ const enabledPlugins = getEnabledPluginsForApproval(request)
79
+ if (enabledPlugins.length === 0) return []
80
+ return getPluginManager().collectApprovalGuidance(enabledPlugins, {
81
+ approval: request,
82
+ phase,
83
+ approved,
84
+ })
85
+ }
86
+
87
+ function appendGuidanceToLines(lines: string[], guidance: string[], label = 'Plugin guidance:'): string[] {
88
+ if (guidance.length === 0) return lines
89
+ lines.push(label)
90
+ for (const line of guidance) {
91
+ lines.push(`- ${line}`)
92
+ }
93
+ return lines
94
+ }
95
+
40
96
  function clampApprovalConnectorNotifyDelaySec(value: unknown): number {
41
97
  const parsed = typeof value === 'number'
42
98
  ? value
@@ -64,6 +120,39 @@ function approvalsAreDisabled(): boolean {
64
120
  return loadSettings().approvalsEnabled === false
65
121
  }
66
122
 
123
+ function canReuseApprovedDecision(category: ApprovalCategory, approval: ApprovalRequest, now: number): boolean {
124
+ if (category === 'tool_access') return true
125
+ return (now - approval.updatedAt) <= RECENT_APPROVED_APPROVAL_REUSE_WINDOW_MS
126
+ }
127
+
128
+ function findReusableApproval(params: {
129
+ category: ApprovalCategory
130
+ data: Record<string, unknown>
131
+ agentId?: string | null
132
+ sessionId?: string | null
133
+ taskId?: string | null
134
+ }): ApprovalRequest | null {
135
+ const targetKey = buildApprovalMatchKey(params)
136
+ if (!targetKey) return null
137
+
138
+ const approvals = loadApprovals() as Record<string, ApprovalRequest>
139
+ const now = Date.now()
140
+ let recentApproved: ApprovalRequest | null = null
141
+
142
+ for (const approval of Object.values(approvals)) {
143
+ if (approval.category !== params.category) continue
144
+ if (buildApprovalMatchKeyFromRequest(approval) !== targetKey) continue
145
+ if (approval.status === 'pending') return approval
146
+ if (approval.status !== 'approved') continue
147
+ if (!canReuseApprovedDecision(params.category, approval, now)) continue
148
+ if (!recentApproved || approval.updatedAt > recentApproved.updatedAt) {
149
+ recentApproved = approval
150
+ }
151
+ }
152
+
153
+ return recentApproved
154
+ }
155
+
67
156
  function getMessageSourceConnectorTarget(
68
157
  message: Record<string, unknown> | null | undefined,
69
158
  runningById: Map<string, RunningConnectorSummary>,
@@ -161,10 +250,12 @@ function buildApprovalConnectorReminderText(request: ApprovalRequest): string {
161
250
  if (description) lines.push(`Details: ${description.slice(0, 500)}`)
162
251
  lines.push(`Pending for about ${ageMin} minute${ageMin === 1 ? '' : 's'}.`)
163
252
  lines.push('Open the Approvals panel to approve or reject it.')
253
+ appendGuidanceToLines(lines, getApprovalGuidance(request, 'connector_reminder'), 'Agent guidance:')
164
254
  return lines.join('\n')
165
255
  }
166
256
 
167
257
  function buildApprovalChatMessage(request: ApprovalRequest): string {
258
+ const guidance = getApprovalGuidance(request, 'request')
168
259
  const targetId = getApprovalTargetId(request.data)
169
260
  switch (request.category) {
170
261
  case 'tool_access':
@@ -175,6 +266,7 @@ function buildApprovalChatMessage(request: ApprovalRequest): string {
175
266
  toolId: targetId || '',
176
267
  reason: trimToString(request.description),
177
268
  message: `Plugin access request sent to user for "${targetId || 'requested tool'}". Once granted, I'll automatically continue.`,
269
+ guidance: guidance.length > 0 ? guidance : undefined,
178
270
  })
179
271
  case 'plugin_scaffold':
180
272
  return JSON.stringify({
@@ -182,6 +274,7 @@ function buildApprovalChatMessage(request: ApprovalRequest): string {
182
274
  approvalId: request.id,
183
275
  filename: trimToString(request.data.filename),
184
276
  message: `I've submitted a request to create plugin "${trimToString(request.data.filename) || 'plugin.js'}". The user needs to approve it via the Approvals page or the approval card in chat. Once approved, the plugin file will be written automatically — no need to call this tool again.`,
277
+ guidance: guidance.length > 0 ? guidance : undefined,
185
278
  })
186
279
  case 'plugin_install':
187
280
  return JSON.stringify({
@@ -191,6 +284,7 @@ function buildApprovalChatMessage(request: ApprovalRequest): string {
191
284
  pluginId: trimToString(request.data.pluginId),
192
285
  reason: trimToString(request.description),
193
286
  message: `I'm requesting to install a new plugin${trimToString(request.data.url) ? ` from ${trimToString(request.data.url)}` : ''}. This will add new capabilities to the platform.`,
287
+ guidance: guidance.length > 0 ? guidance : undefined,
194
288
  })
195
289
  case 'wallet_transfer':
196
290
  return JSON.stringify({
@@ -200,7 +294,25 @@ function buildApprovalChatMessage(request: ApprovalRequest): string {
200
294
  toAddress: trimToString(request.data.toAddress),
201
295
  memo: trimToString(request.data.memo),
202
296
  message: `I'm requesting to send ${request.data.amountSol ?? 'funds'} to ${trimToString(request.data.toAddress) || 'the specified address'}. Please approve this transaction.`,
297
+ guidance: guidance.length > 0 ? guidance : undefined,
298
+ })
299
+ case 'wallet_action':
300
+ return JSON.stringify({
301
+ type: 'plugin_wallet_action_request',
302
+ approvalId: request.id,
303
+ action: trimToString(request.data.action),
304
+ chain: trimToString(request.data.chain),
305
+ network: trimToString(request.data.network),
306
+ summary: trimToString(request.data.summary),
307
+ message: trimToString(request.description) || `I'm requesting approval for wallet action "${trimToString(request.data.action) || request.title}".`,
308
+ guidance: guidance.length > 0 ? guidance : undefined,
203
309
  })
310
+ case 'connector_sender':
311
+ return [
312
+ `[Approval requested] ${request.title}`,
313
+ trimToString(request.description) || `Allow ${trimToString(request.data.senderName) || trimToString(request.data.senderId) || 'this sender'} on ${trimToString(request.data.connectorName) || 'the connector'}.`,
314
+ 'Approve or reject this sender in the chat approval card or the Approvals panel.',
315
+ ].filter(Boolean).join('\n')
204
316
  default: {
205
317
  const lines = [
206
318
  `[Approval requested] ${request.title}`,
@@ -208,11 +320,92 @@ function buildApprovalChatMessage(request: ApprovalRequest): string {
208
320
  const description = trimToString(request.description)
209
321
  if (description) lines.push(`Details: ${description}`)
210
322
  lines.push('Approve or reject this request in the chat approval card or the Approvals panel.')
323
+ appendGuidanceToLines(lines, guidance)
211
324
  return lines.join('\n')
212
325
  }
213
326
  }
214
327
  }
215
328
 
329
+ function buildApprovalDecisionResumeText(request: ApprovalRequest, approved: boolean): string {
330
+ const statusLabel = approved ? 'approved' : 'rejected'
331
+ const lines = [`[Approval ${statusLabel}] ${request.title}`]
332
+ const description = trimToString(request.description)
333
+ if (description) lines.push(`Details: ${description}`)
334
+ lines.push(`Approval id: ${request.id}`)
335
+ lines.push(approved
336
+ ? 'Continue the work that was blocked on this approval.'
337
+ : 'The requested action was rejected. Adjust the plan and continue safely.')
338
+ return lines.join('\n')
339
+ }
340
+
341
+ function buildApprovalDecisionResumeMessage(request: ApprovalRequest, approved: boolean): string {
342
+ const guidance = getApprovalGuidance(request, 'resume', approved)
343
+ const lines = [
344
+ 'APPROVAL_DECISION_EVENT',
345
+ `Approval id: ${request.id}`,
346
+ `Category: ${request.category}`,
347
+ `Status: ${approved ? 'approved' : 'rejected'}`,
348
+ `Title: ${request.title}`,
349
+ ]
350
+ const description = trimToString(request.description)
351
+ if (description) lines.push(`Details: ${description}`)
352
+
353
+ const action = trimToString(request.data.action)
354
+ const chain = trimToString(request.data.chain)
355
+ const network = trimToString(request.data.network)
356
+ const summary = trimToString(request.data.summary)
357
+ if (action) lines.push(`Action: ${action}`)
358
+ if (chain) lines.push(`Chain: ${chain}`)
359
+ if (network) lines.push(`Network: ${network}`)
360
+ if (summary) lines.push(`Summary: ${summary}`)
361
+ if (request.category === 'wallet_action' || request.category === 'wallet_transfer') {
362
+ lines.push(`Approved payload: ${JSON.stringify(request.data)}`)
363
+ }
364
+
365
+ if (approved) {
366
+ lines.push('Resume the exact work that was blocked on this approval now.')
367
+ lines.push('Use this exact approvalId for the matching blocked tool action if the tool requires one.')
368
+ lines.push('If the exact blocked action still applies, execute it before doing more research or recomputing alternatives.')
369
+ lines.push('If tool evidence proves the approved action can no longer be executed as approved, request a fresh approval for the new exact action instead of reusing the old approvalId.')
370
+ } else {
371
+ lines.push('The requested action was rejected. Adjust the plan safely and continue without retrying the rejected action.')
372
+ }
373
+
374
+ appendGuidanceToLines(lines, guidance)
375
+ return lines.join('\n')
376
+ }
377
+
378
+ function wakeForApprovalDecision(request: ApprovalRequest, approved: boolean): void {
379
+ if (request.category === 'connector_sender') return
380
+ const reason = approved ? 'approval-approved' : 'approval-rejected'
381
+ if (request.sessionId) {
382
+ enqueueSystemEvent(
383
+ request.sessionId,
384
+ buildApprovalDecisionResumeText(request, approved),
385
+ `approval:${request.id}:${approved ? 'approved' : 'rejected'}`,
386
+ )
387
+ enqueueSessionRun({
388
+ sessionId: request.sessionId,
389
+ message: buildApprovalDecisionResumeMessage(request, approved),
390
+ internal: true,
391
+ source: 'approval-decision',
392
+ mode: 'collect',
393
+ dedupeKey: `approval-decision:${request.id}`,
394
+ })
395
+ return
396
+ }
397
+ if (request.agentId) {
398
+ requestHeartbeatNow({
399
+ agentId: request.agentId,
400
+ eventId: `approval:${request.id}:${approved ? 'approved' : 'rejected'}`,
401
+ reason,
402
+ source: `approval:${request.category}`,
403
+ resumeMessage: buildApprovalDecisionResumeText(request, approved),
404
+ detail: request.title || request.category,
405
+ })
406
+ }
407
+ }
408
+
216
409
  function pushApprovalRequestMessage(request: ApprovalRequest): void {
217
410
  const sessionId = trimToString(request.sessionId)
218
411
  if (!sessionId) return
@@ -232,6 +425,7 @@ function pushApprovalRequestMessage(request: ApprovalRequest): void {
232
425
  text,
233
426
  time: Date.now(),
234
427
  kind: 'system',
428
+ ...(request.category === 'connector_sender' ? { historyExcluded: true } : {}),
235
429
  })
236
430
  session.lastActiveAt = Date.now()
237
431
  sessions[sessionId] = session
@@ -407,6 +601,14 @@ async function applyApprovedSideEffects(request: ApprovalRequest): Promise<void>
407
601
  }
408
602
  }
409
603
  }
604
+
605
+ if (request.category === 'connector_sender') {
606
+ const connectorId = trimToString(request.data.connectorId)
607
+ const senderId = trimToString(request.data.senderId)
608
+ if (connectorId && senderId) {
609
+ addAllowedSender(connectorId, senderId)
610
+ }
611
+ }
410
612
  }
411
613
 
412
614
  async function persistApprovalDecision(request: ApprovalRequest, approved: boolean): Promise<ApprovalRequest> {
@@ -444,6 +646,14 @@ export async function requestApprovalMaybeAutoApprove(params: {
444
646
  sessionId?: string | null
445
647
  taskId?: string | null
446
648
  }): Promise<ApprovalRequest> {
649
+ const reusable = findReusableApproval(params)
650
+ if (reusable) {
651
+ if (reusable.status === 'pending' && (approvalsAreDisabled() || isApprovalCategoryAutoApproved(reusable.category))) {
652
+ return persistApprovalDecision(reusable, true)
653
+ }
654
+ return reusable
655
+ }
656
+
447
657
  const request = requestApproval(params)
448
658
  if (!approvalsAreDisabled() && !isApprovalCategoryAutoApproved(request.category)) {
449
659
  pushApprovalRequestMessage(request)
@@ -457,7 +667,10 @@ export async function submitDecision(id: string, approved: boolean): Promise<voi
457
667
  const approvals = loadApprovals() as Record<string, ApprovalRequest>
458
668
  const request = approvals[id]
459
669
  if (!request) throw new Error('Approval request not found')
460
- await persistApprovalDecision(request, approved)
670
+ if (request.status === (approved ? 'approved' : 'rejected')) return
671
+ if (request.status !== 'pending') return
672
+ const updated = await persistApprovalDecision(request, approved)
673
+ wakeForApprovalDecision(updated, approved)
461
674
  }
462
675
 
463
676
  export function listPendingApprovalsNeedingConnectorNotification(params?: {
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from './assistant-control'
4
+
5
+ describe('assistant-control', () => {
6
+ it('suppresses pure hidden control replies', () => {
7
+ assert.equal(shouldSuppressHiddenControlText('NO_MESSAGE'), true)
8
+ assert.equal(shouldSuppressHiddenControlText(' HEARTBEAT_OK '), true)
9
+ assert.equal(stripHiddenControlTokens('NO_MESSAGE'), '')
10
+ })
11
+
12
+ it('strips leaked control prefixes without suppressing real content', () => {
13
+ assert.equal(
14
+ stripHiddenControlTokens('NO_MESSAGEIt seems there was an error earlier on.'),
15
+ 'It seems there was an error earlier on.',
16
+ )
17
+ assert.equal(
18
+ shouldSuppressHiddenControlText('NO_MESSAGEIt seems there was an error earlier on.'),
19
+ false,
20
+ )
21
+ })
22
+
23
+ it('removes standalone control-token lines from mixed content', () => {
24
+ assert.equal(
25
+ stripHiddenControlTokens('Working on it.\nNO_MESSAGE\nI found the issue.'),
26
+ 'Working on it.\nI found the issue.',
27
+ )
28
+ })
29
+ })
@@ -0,0 +1,23 @@
1
+ const CONTROL_TOKEN_NAMES = ['NO_MESSAGE', 'HEARTBEAT_OK'] as const
2
+ const CONTROL_TOKEN_PREFIX_RE = /^\s*(?:NO_MESSAGE|HEARTBEAT_OK)(?:(?=[\s.,:;!?()[\]{}"'`-]|$)|(?=[A-Z]))\s*/i
3
+ const CONTROL_TOKEN_LINE_RE = /(^|\n)\s*(?:NO_MESSAGE|HEARTBEAT_OK)\s*(\n|$)/gi
4
+
5
+ export function stripHiddenControlTokens(text: string): string {
6
+ let cleaned = String(text || '')
7
+ let previous = ''
8
+
9
+ while (cleaned !== previous) {
10
+ previous = cleaned
11
+ cleaned = cleaned.replace(CONTROL_TOKEN_PREFIX_RE, '')
12
+ }
13
+
14
+ cleaned = cleaned.replace(CONTROL_TOKEN_LINE_RE, '$1')
15
+ return cleaned.replace(/\n{3,}/g, '\n\n').trim()
16
+ }
17
+
18
+ export function shouldSuppressHiddenControlText(text: string): boolean {
19
+ const raw = String(text || '').trim()
20
+ if (!raw) return false
21
+ if (!CONTROL_TOKEN_NAMES.some((token) => raw.toUpperCase().includes(token))) return false
22
+ return stripHiddenControlTokens(raw).length === 0
23
+ }
@@ -41,4 +41,83 @@ describe('buildChatModel', () => {
41
41
  assert.equal(model.caller?.maxRetries, OPENAI_COMPAT_MODEL_MAX_RETRIES)
42
42
  assert.deepEqual(model.clientConfig?.defaultHeaders, { 'Content-Type': 'text/plain' })
43
43
  })
44
+
45
+ it('routes glm-5:cloud to Ollama Cloud and strips the transport suffix', () => {
46
+ const originalKey = process.env.OLLAMA_API_KEY
47
+ process.env.OLLAMA_API_KEY = 'ollama-cloud-test-key'
48
+
49
+ try {
50
+ const llm = buildChatModel({
51
+ provider: 'ollama',
52
+ model: 'glm-5:cloud',
53
+ apiKey: null,
54
+ })
55
+ const model = llm as ChatOpenAiInternals & {
56
+ model?: string
57
+ clientConfig?: { baseURL?: string }
58
+ }
59
+
60
+ assert.equal(llm instanceof ChatOpenAI, true)
61
+ assert.equal(model.model, 'glm-5')
62
+ assert.equal(model.clientConfig?.baseURL, 'https://ollama.com/v1')
63
+ assert.equal(model.timeout, OPENAI_COMPAT_MODEL_TIMEOUT_MS)
64
+ assert.equal(model.caller?.maxRetries, OPENAI_COMPAT_MODEL_MAX_RETRIES)
65
+ } finally {
66
+ if (originalKey === undefined) delete process.env.OLLAMA_API_KEY
67
+ else process.env.OLLAMA_API_KEY = originalKey
68
+ }
69
+ })
70
+
71
+ it('keeps glm-5:cloud on the local Ollama endpoint when no cloud key is available', () => {
72
+ const originalKey = process.env.OLLAMA_API_KEY
73
+ delete process.env.OLLAMA_API_KEY
74
+
75
+ try {
76
+ const llm = buildChatModel({
77
+ provider: 'ollama',
78
+ model: 'glm-5:cloud',
79
+ apiKey: null,
80
+ })
81
+ const model = llm as ChatOpenAiInternals & {
82
+ model?: string
83
+ clientConfig?: { baseURL?: string }
84
+ }
85
+
86
+ assert.equal(llm instanceof ChatOpenAI, true)
87
+ assert.equal(model.model, 'glm-5:cloud')
88
+ assert.equal(model.clientConfig?.baseURL, 'http://localhost:11434/v1')
89
+ assert.equal(model.timeout, OPENAI_COMPAT_MODEL_TIMEOUT_MS)
90
+ assert.equal(model.caller?.maxRetries, OPENAI_COMPAT_MODEL_MAX_RETRIES)
91
+ } finally {
92
+ if (originalKey === undefined) delete process.env.OLLAMA_API_KEY
93
+ else process.env.OLLAMA_API_KEY = originalKey
94
+ }
95
+ })
96
+
97
+ it('keeps an explicit local Ollama endpoint even when a cloud key exists', () => {
98
+ const originalKey = process.env.OLLAMA_API_KEY
99
+ process.env.OLLAMA_API_KEY = 'ollama-cloud-test-key'
100
+
101
+ try {
102
+ const llm = buildChatModel({
103
+ provider: 'ollama',
104
+ model: 'glm-5:cloud',
105
+ apiKey: null,
106
+ apiEndpoint: 'http://localhost:11434',
107
+ })
108
+ const model = llm as ChatOpenAiInternals & {
109
+ model?: string
110
+ clientConfig?: { baseURL?: string }
111
+ }
112
+
113
+ assert.equal(llm instanceof ChatOpenAI, true)
114
+ assert.equal(model.model, 'glm-5:cloud')
115
+ assert.equal(model.clientConfig?.baseURL, 'http://localhost:11434/v1')
116
+ assert.equal(model.timeout, OPENAI_COMPAT_MODEL_TIMEOUT_MS)
117
+ assert.equal(model.caller?.maxRetries, OPENAI_COMPAT_MODEL_MAX_RETRIES)
118
+ } finally {
119
+ if (originalKey === undefined) delete process.env.OLLAMA_API_KEY
120
+ else process.env.OLLAMA_API_KEY = originalKey
121
+ }
122
+ })
44
123
  })
@@ -4,12 +4,18 @@ import { loadCredentials, decryptKey, loadAgents, loadSettings } from './storage
4
4
  import { getProviderList } from '../providers'
5
5
  import { normalizeOpenClawEndpoint } from '../openclaw-endpoint'
6
6
  import { NON_LANGGRAPH_PROVIDER_IDS } from '../provider-sets'
7
+ import { resolveOllamaRuntimeConfig } from './ollama-runtime'
7
8
 
8
9
  const OLLAMA_CLOUD_URL = 'https://ollama.com/v1'
9
10
  const OLLAMA_LOCAL_URL = 'http://localhost:11434/v1'
10
11
  export const OPENAI_COMPAT_MODEL_TIMEOUT_MS = 180_000
11
12
  export const OPENAI_COMPAT_MODEL_MAX_RETRIES = 0
12
13
 
14
+ function toOpenAiCompatibleBaseUrl(endpoint: string | null | undefined, fallback: string): string {
15
+ const normalized = (endpoint || fallback).replace(/\/+$/, '')
16
+ return normalized.endsWith('/v1') ? normalized : `${normalized}/v1`
17
+ }
18
+
13
19
  /**
14
20
  * Build a LangChain chat model from provider config.
15
21
  * Uses the provider registry for endpoint defaults — no hardcoded provider list.
@@ -46,12 +52,16 @@ export function buildChatModel(opts: {
46
52
  }
47
53
 
48
54
  if (provider === 'ollama') {
49
- const baseURL = apiKey && apiKey !== 'ollama'
55
+ const runtime = resolveOllamaRuntimeConfig({ model, apiKey, apiEndpoint })
56
+ if (runtime.useCloud && !runtime.apiKey) {
57
+ throw new Error('Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
58
+ }
59
+ const baseURL = runtime.useCloud
50
60
  ? OLLAMA_CLOUD_URL
51
- : (endpoint ? `${endpoint}/v1` : OLLAMA_LOCAL_URL)
61
+ : toOpenAiCompatibleBaseUrl(runtime.endpoint, OLLAMA_LOCAL_URL)
52
62
  return new ChatOpenAI({
53
- model: model || 'qwen3.5',
54
- apiKey: apiKey || 'ollama',
63
+ model: runtime.model || 'qwen3.5',
64
+ apiKey: runtime.useCloud ? runtime.apiKey || undefined : 'ollama',
55
65
  timeout: OPENAI_COMPAT_MODEL_TIMEOUT_MS,
56
66
  maxRetries: OPENAI_COMPAT_MODEL_MAX_RETRIES,
57
67
  configuration: { baseURL },
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { normalizeCanvasDocument } from './canvas-content'
5
+
6
+ describe('normalizeCanvasDocument', () => {
7
+ it('filters invalid metric rows and keeps valid metrics blocks', () => {
8
+ const result = normalizeCanvasDocument({
9
+ title: 'Smoke',
10
+ blocks: [
11
+ {
12
+ type: 'metrics',
13
+ items: [
14
+ { label: 'Healthy', value: 12, tone: 'positive' },
15
+ { label: '', value: 'skip-me' },
16
+ { value: 'missing-label' },
17
+ ],
18
+ },
19
+ ],
20
+ })
21
+
22
+ assert.ok(result)
23
+ assert.equal(result?.blocks.length, 1)
24
+ assert.deepEqual(result?.blocks[0], {
25
+ type: 'metrics',
26
+ items: [
27
+ { label: 'Healthy', value: '12', tone: 'positive', detail: undefined },
28
+ ],
29
+ title: undefined,
30
+ })
31
+ })
32
+ })
@@ -0,0 +1,6 @@
1
+ export {
2
+ isCanvasDocument,
3
+ normalizeCanvasContent,
4
+ normalizeCanvasDocument,
5
+ summarizeCanvasContent,
6
+ } from '../canvas-content'
@@ -19,3 +19,36 @@ test('routeTaskIntent keeps coding prompts prioritized over memory keywords', ()
19
19
  )
20
20
  assert.equal(decision.intent, 'coding')
21
21
  })
22
+
23
+ test('routeTaskIntent keeps hybrid research-plus-media prompts in research intent', () => {
24
+ const decision = routeTaskIntent(
25
+ 'Can you tell me more if there is any news related to the US-Iran war, and can you send me some screenshots and give me a summary and maybe send me a voice note about it?',
26
+ ['web_search', 'web_fetch', 'browser', 'manage_connectors'],
27
+ null,
28
+ )
29
+
30
+ assert.equal(decision.intent, 'research')
31
+ assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'browser', 'connector_message_tool'])
32
+ })
33
+
34
+ test('routeTaskIntent treats direct voice-note delivery as outreach', () => {
35
+ const decision = routeTaskIntent(
36
+ 'Send me a voice note over WhatsApp summarizing what changed.',
37
+ ['manage_connectors'],
38
+ null,
39
+ )
40
+
41
+ assert.equal(decision.intent, 'outreach')
42
+ assert.deepEqual(decision.preferredTools, ['connector_message_tool'])
43
+ })
44
+
45
+ test('routeTaskIntent treats keep-watching update requests as research even without explicit news keywords', () => {
46
+ const decision = routeTaskIntent(
47
+ 'Tell me about the Iran war, keep watching for meaningful updates, and avoid duplicate reminders.',
48
+ ['web_search', 'manage_schedules'],
49
+ null,
50
+ )
51
+
52
+ assert.equal(decision.intent, 'research')
53
+ assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch'])
54
+ })