@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
@@ -0,0 +1,105 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { ToolLoopTracker, hashToolInput, hashToolOutput } from './tool-loop-detection'
4
+
5
+ describe('ToolLoopTracker', () => {
6
+ it('returns null for normal non-repeating tool calls', () => {
7
+ const tracker = new ToolLoopTracker()
8
+ assert.equal(tracker.record('web_search', { query: 'weather london' }, 'Sunny, 20C'), null)
9
+ assert.equal(tracker.record('files', { action: 'write', path: '/tmp/test.json' }, 'OK'), null)
10
+ assert.equal(tracker.record('web_search', { query: 'weather paris' }, 'Cloudy, 15C'), null)
11
+ assert.equal(tracker.size, 3)
12
+ })
13
+
14
+ it('detects generic repeat at warning threshold', () => {
15
+ const tracker = new ToolLoopTracker({ repeatWarn: 3, repeatCritical: 6 })
16
+ for (let i = 0; i < 2; i++) {
17
+ assert.equal(tracker.record('web_search', { query: 'same query' }, `result ${i}`), null)
18
+ }
19
+ const result = tracker.record('web_search', { query: 'same query' }, 'result 2')
20
+ assert.ok(result)
21
+ assert.equal(result.severity, 'warning')
22
+ assert.equal(result.detector, 'generic_repeat')
23
+ })
24
+
25
+ it('detects generic repeat at critical threshold', () => {
26
+ const tracker = new ToolLoopTracker({ repeatWarn: 3, repeatCritical: 5, toolFrequencyWarn: 100, toolFrequencyCritical: 100 })
27
+ for (let i = 0; i < 4; i++) {
28
+ tracker.record('web_search', { query: 'same' }, `result ${i}`)
29
+ }
30
+ const result = tracker.record('web_search', { query: 'same' }, 'result 4')
31
+ assert.ok(result)
32
+ assert.equal(result.severity, 'critical')
33
+ assert.equal(result.detector, 'generic_repeat')
34
+ })
35
+
36
+ it('detects polling stall when same tool returns identical output', () => {
37
+ const tracker = new ToolLoopTracker({ pollWarn: 3, pollCritical: 5 })
38
+ // Different inputs but same output = polling stall
39
+ for (let i = 0; i < 2; i++) {
40
+ assert.equal(tracker.record('process', { action: 'poll', id: `run-${i}` }, 'status: running'), null)
41
+ }
42
+ const result = tracker.record('process', { action: 'poll', id: 'run-2' }, 'status: running')
43
+ assert.ok(result)
44
+ assert.equal(result.severity, 'warning')
45
+ assert.equal(result.detector, 'polling_stall')
46
+ })
47
+
48
+ it('detects ping-pong between two tools', () => {
49
+ const tracker = new ToolLoopTracker({ pingPongWarn: 2, pingPongCritical: 4 })
50
+ // Simulate A-B-A-B with identical outputs
51
+ for (let i = 0; i < 2; i++) {
52
+ tracker.record('web_search', { query: 'find it' }, 'no results found')
53
+ tracker.record('web_fetch', { url: 'https://example.com' }, '404 not found')
54
+ }
55
+ // One more A to complete the 3rd pair-start
56
+ const result = tracker.record('web_search', { query: 'find it' }, 'no results found')
57
+ // The ping-pong detector checks the last pair against previous pairs
58
+ // After 4 calls (A-B-A-B) + 1 more A, we have 2 full A-B cycles with identical results
59
+ if (result) {
60
+ assert.equal(result.detector, 'ping_pong')
61
+ }
62
+ })
63
+
64
+ it('circuit breaker fires at absolute cap', () => {
65
+ const tracker = new ToolLoopTracker({ circuitBreaker: 5, repeatWarn: 100, repeatCritical: 100, toolFrequencyWarn: 100, toolFrequencyCritical: 100 })
66
+ for (let i = 0; i < 4; i++) {
67
+ tracker.record('shell', { command: 'curl http://stuck.com' }, `err ${i}`)
68
+ }
69
+ const result = tracker.record('shell', { command: 'curl http://stuck.com' }, 'err 4')
70
+ assert.ok(result)
71
+ assert.equal(result.severity, 'critical')
72
+ assert.equal(result.detector, 'circuit_breaker')
73
+ })
74
+
75
+ it('does not fire for varied tool calls even with many total calls', () => {
76
+ const tracker = new ToolLoopTracker({ toolFrequencyWarn: 100, toolFrequencyCritical: 100 })
77
+ for (let i = 0; i < 20; i++) {
78
+ const result = tracker.record('web_search', { query: `query ${i}` }, `result ${i}`)
79
+ assert.equal(result, null, `Unexpected detection at call ${i}`)
80
+ }
81
+ assert.equal(tracker.size, 20)
82
+ })
83
+
84
+ it('detects tool frequency when same tool is called too many times (any input)', () => {
85
+ const tracker = new ToolLoopTracker({ toolFrequencyWarn: 3, toolFrequencyCritical: 5 })
86
+ for (let i = 0; i < 2; i++) {
87
+ assert.equal(tracker.record('web_search', { query: `q${i}` }, `r${i}`), null)
88
+ }
89
+ const warn = tracker.record('web_search', { query: 'q2' }, 'r2')
90
+ assert.ok(warn)
91
+ assert.equal(warn.severity, 'warning')
92
+ assert.equal(warn.detector, 'tool_frequency')
93
+ })
94
+ })
95
+
96
+ describe('hash helpers', () => {
97
+ it('produces consistent hashes for same input', () => {
98
+ assert.equal(hashToolInput({ query: 'test' }), hashToolInput({ query: 'test' }))
99
+ assert.equal(hashToolOutput('hello world'), hashToolOutput('hello world'))
100
+ })
101
+
102
+ it('produces different hashes for different input', () => {
103
+ assert.notEqual(hashToolInput({ query: 'a' }), hashToolInput({ query: 'b' }))
104
+ })
105
+ })
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Tool loop detection — modelled after OpenClaw's approach.
3
+ *
4
+ * Four detectors run on every on_tool_end event:
5
+ * 1. Generic repeat — same (name, inputHash) seen N+ times
6
+ * 2. Polling stall — repeated poll-like calls with identical output
7
+ * 3. Ping-pong — two tools alternating with identical results
8
+ * 4. Circuit breaker — absolute cap on identical calls regardless of type
9
+ *
10
+ * Each detector returns a severity: 'ok' | 'warning' | 'critical'.
11
+ * The caller decides what to do (log, inject guidance, abort).
12
+ */
13
+
14
+ import { createHash } from 'crypto'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface ToolCallRecord {
21
+ name: string
22
+ inputHash: string
23
+ outputHash: string
24
+ /** first 200 chars of output for logging */
25
+ outputPreview: string
26
+ timestamp: number
27
+ }
28
+
29
+ export type LoopSeverity = 'ok' | 'warning' | 'critical'
30
+
31
+ export interface LoopDetectionResult {
32
+ severity: LoopSeverity
33
+ detector: 'generic_repeat' | 'polling_stall' | 'ping_pong' | 'circuit_breaker' | 'tool_frequency'
34
+ message: string
35
+ }
36
+
37
+ export interface LoopDetectionThresholds {
38
+ /** Generic repeat: warn after this many identical (name, input) calls. Default 6. */
39
+ repeatWarn: number
40
+ /** Generic repeat: critical after this many. Default 12. */
41
+ repeatCritical: number
42
+ /** Polling stall: warn after N poll-like calls with identical output. Default 4. */
43
+ pollWarn: number
44
+ /** Polling stall: critical after this many. Default 8. */
45
+ pollCritical: number
46
+ /** Ping-pong: how many alternating-pair cycles trigger warning. Default 3. */
47
+ pingPongWarn: number
48
+ /** Ping-pong: critical after this many cycles. Default 5. */
49
+ pingPongCritical: number
50
+ /** Circuit breaker: absolute cap on any identical call. Default 20. */
51
+ circuitBreaker: number
52
+ /** Per-tool frequency: warn after this many calls to the same tool (any input). Default 5. */
53
+ toolFrequencyWarn: number
54
+ /** Per-tool frequency: critical after this many calls to the same tool (any input). Default 8. */
55
+ toolFrequencyCritical: number
56
+ }
57
+
58
+ const DEFAULT_THRESHOLDS: LoopDetectionThresholds = {
59
+ repeatWarn: 6,
60
+ repeatCritical: 12,
61
+ pollWarn: 4,
62
+ pollCritical: 8,
63
+ pingPongWarn: 3,
64
+ pingPongCritical: 5,
65
+ circuitBreaker: 20,
66
+ toolFrequencyWarn: 3,
67
+ toolFrequencyCritical: 5,
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Hash helpers
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function quickHash(input: string): string {
75
+ return createHash('sha256').update(input).digest('hex').slice(0, 16)
76
+ }
77
+
78
+ export function hashToolInput(input: unknown): string {
79
+ const str = typeof input === 'string' ? input : JSON.stringify(input ?? '')
80
+ return quickHash(str)
81
+ }
82
+
83
+ export function hashToolOutput(output: unknown): string {
84
+ const str = typeof output === 'string' ? output : JSON.stringify(output ?? '')
85
+ return quickHash(str)
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Tracker
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export class ToolLoopTracker {
93
+ private history: ToolCallRecord[] = []
94
+ private thresholds: LoopDetectionThresholds
95
+
96
+ constructor(thresholds?: Partial<LoopDetectionThresholds>) {
97
+ this.thresholds = { ...DEFAULT_THRESHOLDS, ...thresholds }
98
+ }
99
+
100
+ /** Record a completed tool call and run all detectors. */
101
+ record(name: string, input: unknown, output: unknown): LoopDetectionResult | null {
102
+ const inputHash = hashToolInput(input)
103
+ const outputStr = typeof output === 'string' ? output : JSON.stringify(output ?? '')
104
+ const outputHash = hashToolOutput(output)
105
+ const record: ToolCallRecord = {
106
+ name,
107
+ inputHash,
108
+ outputHash,
109
+ outputPreview: outputStr.slice(0, 200),
110
+ timestamp: Date.now(),
111
+ }
112
+ this.history.push(record)
113
+
114
+ // Run detectors in severity order (most severe first)
115
+ return this.checkCircuitBreaker(record)
116
+ ?? this.checkToolFrequency(record)
117
+ ?? this.checkGenericRepeat(record)
118
+ ?? this.checkPollingStall(record)
119
+ ?? this.checkPingPong()
120
+ ?? null
121
+ }
122
+
123
+ /** Get the full call history (for diagnostics). */
124
+ getHistory(): ReadonlyArray<ToolCallRecord> {
125
+ return this.history
126
+ }
127
+
128
+ /** Total recorded calls. */
129
+ get size(): number {
130
+ return this.history.length
131
+ }
132
+
133
+ // -------------------------------------------------------------------------
134
+ // Detectors
135
+ // -------------------------------------------------------------------------
136
+
137
+ private checkToolFrequency(current: ToolCallRecord): LoopDetectionResult | null {
138
+ let count = 0
139
+ for (const r of this.history) {
140
+ if (r.name === current.name) count++
141
+ }
142
+ if (count >= this.thresholds.toolFrequencyCritical) {
143
+ return {
144
+ severity: 'critical',
145
+ detector: 'tool_frequency',
146
+ message: `Tool "${current.name}" called ${count} times this turn. Excessive repetition — wrap up with available results.`,
147
+ }
148
+ }
149
+ if (count >= this.thresholds.toolFrequencyWarn) {
150
+ return {
151
+ severity: 'warning',
152
+ detector: 'tool_frequency',
153
+ message: `Tool "${current.name}" called ${count} times. Consider whether more calls are needed.`,
154
+ }
155
+ }
156
+ return null
157
+ }
158
+
159
+ private checkCircuitBreaker(current: ToolCallRecord): LoopDetectionResult | null {
160
+ const key = `${current.name}:${current.inputHash}`
161
+ let count = 0
162
+ for (const r of this.history) {
163
+ if (`${r.name}:${r.inputHash}` === key) count++
164
+ }
165
+ if (count >= this.thresholds.circuitBreaker) {
166
+ return {
167
+ severity: 'critical',
168
+ detector: 'circuit_breaker',
169
+ message: `Circuit breaker: "${current.name}" called ${count} times with identical input. Halting to prevent runaway.`,
170
+ }
171
+ }
172
+ return null
173
+ }
174
+
175
+ private checkGenericRepeat(current: ToolCallRecord): LoopDetectionResult | null {
176
+ const key = `${current.name}:${current.inputHash}`
177
+ let count = 0
178
+ for (const r of this.history) {
179
+ if (`${r.name}:${r.inputHash}` === key) count++
180
+ }
181
+ if (count >= this.thresholds.repeatCritical) {
182
+ return {
183
+ severity: 'critical',
184
+ detector: 'generic_repeat',
185
+ message: `Tool "${current.name}" has been called ${count} times with the same input. This appears to be a stuck loop.`,
186
+ }
187
+ }
188
+ if (count >= this.thresholds.repeatWarn) {
189
+ return {
190
+ severity: 'warning',
191
+ detector: 'generic_repeat',
192
+ message: `Tool "${current.name}" has been called ${count} times with the same input. Consider a different approach.`,
193
+ }
194
+ }
195
+ return null
196
+ }
197
+
198
+ private checkPollingStall(current: ToolCallRecord): LoopDetectionResult | null {
199
+ // Look for recent sequential calls to the same tool with identical output
200
+ const recent = this.history.slice(-this.thresholds.pollCritical)
201
+ const pollRuns = recent.filter(
202
+ (r) => r.name === current.name && r.outputHash === current.outputHash,
203
+ )
204
+ if (pollRuns.length >= this.thresholds.pollCritical) {
205
+ return {
206
+ severity: 'critical',
207
+ detector: 'polling_stall',
208
+ message: `Polling stall: "${current.name}" returned identical output ${pollRuns.length} times consecutively. The polled resource is not changing.`,
209
+ }
210
+ }
211
+ if (pollRuns.length >= this.thresholds.pollWarn) {
212
+ return {
213
+ severity: 'warning',
214
+ detector: 'polling_stall',
215
+ message: `Polling stall: "${current.name}" returned identical output ${pollRuns.length} times. The state may not be progressing.`,
216
+ }
217
+ }
218
+ return null
219
+ }
220
+
221
+ private checkPingPong(): LoopDetectionResult | null {
222
+ const len = this.history.length
223
+ if (len < 4) return null
224
+
225
+ // Check if the last N calls form an A-B-A-B pattern with identical results
226
+ const last = this.history[len - 1]
227
+ const prev = this.history[len - 2]
228
+ if (last.name === prev.name) return null // same tool — not ping-pong
229
+
230
+ let cycles = 0
231
+ for (let i = len - 2; i >= 1; i -= 2) {
232
+ const a = this.history[i]
233
+ const b = this.history[i - 1]
234
+ if (
235
+ a.name === last.name && a.outputHash === last.outputHash
236
+ && b.name === prev.name && b.outputHash === prev.outputHash
237
+ ) {
238
+ cycles++
239
+ } else {
240
+ break
241
+ }
242
+ }
243
+
244
+ if (cycles >= this.thresholds.pingPongCritical) {
245
+ return {
246
+ severity: 'critical',
247
+ detector: 'ping_pong',
248
+ message: `Ping-pong: "${prev.name}" and "${last.name}" are alternating with identical results (${cycles} cycles). Breaking the loop.`,
249
+ }
250
+ }
251
+ if (cycles >= this.thresholds.pingPongWarn) {
252
+ return {
253
+ severity: 'warning',
254
+ detector: 'ping_pong',
255
+ message: `Ping-pong: "${prev.name}" and "${last.name}" may be stuck in an alternating loop (${cycles} cycles).`,
256
+ }
257
+ }
258
+ return null
259
+ }
260
+ }
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { getPluginManager } from './plugins'
4
+ import { getEnabledToolPlanningView, getToolsForCapability, TOOL_CAPABILITY } from './tool-planning'
5
+
6
+ let seq = 0
7
+
8
+ function uniquePluginId(prefix: string): string {
9
+ seq += 1
10
+ return `${prefix}_${Date.now()}_${seq}`
11
+ }
12
+
13
+ describe('tool-planning', () => {
14
+ it('collects core planning metadata for aliased built-in tools', () => {
15
+ const view = getEnabledToolPlanningView(['web_search', 'web_fetch', 'browser', 'manage_connectors'])
16
+
17
+ assert.deepEqual(view.displayToolIds, ['browser', 'manage_connectors', 'web'])
18
+ assert.deepEqual(getToolsForCapability(['web_search'], TOOL_CAPABILITY.researchSearch), ['web_search'])
19
+ assert.deepEqual(getToolsForCapability(['manage_connectors'], TOOL_CAPABILITY.deliveryVoiceNote), ['connector_message_tool'])
20
+ })
21
+
22
+ it('collects planning metadata from custom plugin tools', () => {
23
+ const pluginId = uniquePluginId('planner_plugin')
24
+ getPluginManager().registerBuiltin(pluginId, {
25
+ name: 'Planner Plugin',
26
+ tools: [
27
+ {
28
+ name: 'custom_media_sender',
29
+ description: 'Send rendered media somewhere special.',
30
+ planning: {
31
+ capabilities: ['delivery.media', 'delivery.voice_note'],
32
+ disciplineGuidance: ['Use `custom_media_sender` for bespoke outbound media delivery.'],
33
+ },
34
+ parameters: { type: 'object', properties: {} },
35
+ execute: async () => 'ok',
36
+ },
37
+ ],
38
+ })
39
+
40
+ const view = getEnabledToolPlanningView([pluginId])
41
+ assert.deepEqual(getToolsForCapability([pluginId], TOOL_CAPABILITY.deliveryMedia), ['custom_media_sender'])
42
+ assert.equal(view.disciplineGuidance.includes('Use `custom_media_sender` for bespoke outbound media delivery.'), true)
43
+ })
44
+ })
@@ -0,0 +1,271 @@
1
+ import type { PluginToolPlanning } from '@/types'
2
+ import { getPluginManager } from './plugins'
3
+ import { canonicalizePluginId, expandPluginIds } from './tool-aliases'
4
+
5
+ export const TOOL_CAPABILITY = {
6
+ researchSearch: 'research.search',
7
+ researchFetch: 'research.fetch',
8
+ browserNavigate: 'browser.navigate',
9
+ browserCapture: 'browser.capture',
10
+ artifactPdf: 'artifact.pdf',
11
+ deliveryMessage: 'delivery.message',
12
+ deliveryMedia: 'delivery.media',
13
+ deliveryVoiceNote: 'delivery.voice_note',
14
+ walletInspect: 'wallet.inspect',
15
+ walletExecute: 'wallet.execute',
16
+ } as const
17
+
18
+ export interface ToolPlanningEntry {
19
+ toolName: string
20
+ capabilities: string[]
21
+ disciplineGuidance: string[]
22
+ requestMatchers: NonNullable<PluginToolPlanning['requestMatchers']>
23
+ }
24
+
25
+ export interface ToolPlanningView {
26
+ displayToolIds: string[]
27
+ expandedPluginIds: string[]
28
+ entries: ToolPlanningEntry[]
29
+ disciplineGuidance: string[]
30
+ capabilityToTools: Map<string, string[]>
31
+ }
32
+
33
+ const CORE_TOOL_PLANNING: Record<string, ToolPlanningEntry[]> = {
34
+ files: [
35
+ {
36
+ toolName: 'files',
37
+ capabilities: ['artifact.files'],
38
+ disciplineGuidance: [
39
+ 'For `files`, include an explicit action whenever possible. Common patterns: `{"action":"list","dirPath":"."}`, `{"action":"read","filePath":"path/to/file.md"}`, and `{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}`.',
40
+ ],
41
+ requestMatchers: [],
42
+ },
43
+ ],
44
+ shell: [
45
+ {
46
+ toolName: 'shell',
47
+ capabilities: ['runtime.shell'],
48
+ disciplineGuidance: [
49
+ 'For `shell`, use `{"action":"execute","command":"..."}` for commands and `{"action":"status","processId":"..."}` or `{"action":"log","processId":"..."}` for long-lived processes.',
50
+ ],
51
+ requestMatchers: [],
52
+ },
53
+ ],
54
+ web: [
55
+ {
56
+ toolName: 'web_search',
57
+ capabilities: [TOOL_CAPABILITY.researchSearch],
58
+ disciplineGuidance: [
59
+ 'For `web_search`, use `{"query":"..."}` to research fresh information. For current events, breaking news, or "latest" requests, start with `web_search` before summarizing.',
60
+ ],
61
+ requestMatchers: [
62
+ {
63
+ capability: TOOL_CAPABILITY.researchSearch,
64
+ patterns: ['research', 'look up', 'find out', 'search for', 'compare', 'latest', 'news', 'headline', 'current event', 'recent update', 'update', 'updates', 'breaking', 'developments', 'keep watching', 'watch for', 'watching for', 'monitor', 'track', "what's new", 'what happened'],
65
+ forbidLiteralUrl: true,
66
+ },
67
+ ],
68
+ },
69
+ {
70
+ toolName: 'web_fetch',
71
+ capabilities: [TOOL_CAPABILITY.researchFetch],
72
+ disciplineGuidance: [
73
+ 'For `web_fetch`, use `{"url":"https://..."}` to read a specific page or article after you know the URL.',
74
+ ],
75
+ requestMatchers: [
76
+ {
77
+ capability: TOOL_CAPABILITY.researchFetch,
78
+ patterns: ['read', 'summarize', 'summarise', 'analyze', 'analyse', 'extract', 'review', 'article', 'page', 'url', 'link'],
79
+ requireLiteralUrl: true,
80
+ },
81
+ ],
82
+ },
83
+ ],
84
+ browser: [
85
+ {
86
+ toolName: 'browser',
87
+ capabilities: [TOOL_CAPABILITY.browserNavigate, TOOL_CAPABILITY.browserCapture, TOOL_CAPABILITY.artifactPdf],
88
+ disciplineGuidance: [
89
+ 'For `browser`, when the task includes a literal URL, pass that exact URL string to `{"action":"navigate","url":"..."}`. Do not invent placeholder URLs like `[Your URL]`, `Example_URL`, or `MockMailPage_URL`.',
90
+ 'For `browser` form work, prefer `{"action":"fill_form","fields":[{"element":"#email","value":"user@example.com"},{"element":"#password","value":"..."}]}`. A shorthand `form` object keyed by input id/name also works for simple forms.',
91
+ 'Use `browser` when the user asks for screenshots, visual proof, page capture, PDFs, or a rendered view of a page. `navigate` alone is not a screenshot.',
92
+ ],
93
+ requestMatchers: [
94
+ {
95
+ capability: TOOL_CAPABILITY.browserNavigate,
96
+ patterns: ['browser', 'click', 'fill form', 'log in', 'login', 'navigate'],
97
+ requireLiteralUrl: true,
98
+ },
99
+ {
100
+ capability: TOOL_CAPABILITY.browserCapture,
101
+ patterns: ['screenshot', 'screen shot', 'snapshot', 'page capture', 'visual proof', 'capture the page', 'rendered view'],
102
+ },
103
+ {
104
+ capability: TOOL_CAPABILITY.artifactPdf,
105
+ patterns: ['pdf', 'save as pdf', 'export pdf'],
106
+ },
107
+ ],
108
+ },
109
+ ],
110
+ manage_connectors: [
111
+ {
112
+ toolName: 'connector_message_tool',
113
+ capabilities: [TOOL_CAPABILITY.deliveryMessage, TOOL_CAPABILITY.deliveryMedia, TOOL_CAPABILITY.deliveryVoiceNote],
114
+ disciplineGuidance: [
115
+ 'For outbound delivery, inspect available channels with `connector_message_tool` using `{"action":"list_running"}` before claiming something cannot be sent.',
116
+ 'Use `connector_message_tool` with `{"action":"send","message":"...","mediaPath":"..."}` for text/media and `{"action":"send_voice_note","voiceText":"..."}` for voice notes.',
117
+ 'If no channel or recipient is configured, explain that connector/channel setup is missing rather than claiming the capability does not exist.',
118
+ ],
119
+ requestMatchers: [
120
+ {
121
+ capability: TOOL_CAPABILITY.deliveryMessage,
122
+ patterns: ['send', 'share', 'deliver', 'message'],
123
+ },
124
+ {
125
+ capability: TOOL_CAPABILITY.deliveryMedia,
126
+ patterns: ['screenshot', 'screen shot', 'snapshot', 'image', 'photo', 'send file', 'send a file', 'pdf', 'attachment'],
127
+ },
128
+ {
129
+ capability: TOOL_CAPABILITY.deliveryVoiceNote,
130
+ patterns: ['voice note', 'voice-note', 'voicenote', 'voice memo', 'voice message', 'audio note', 'audio update', 'ptt'],
131
+ },
132
+ ],
133
+ },
134
+ ],
135
+ http_request: [
136
+ {
137
+ toolName: 'http_request',
138
+ capabilities: ['network.http'],
139
+ disciplineGuidance: [
140
+ 'For `http_request`, send exact literal URLs from the task or from prior tool results. Keep JSON request bodies as raw JSON strings.',
141
+ ],
142
+ requestMatchers: [],
143
+ },
144
+ ],
145
+ email: [
146
+ {
147
+ toolName: 'email',
148
+ capabilities: ['delivery.email'],
149
+ disciplineGuidance: [
150
+ 'For `email`, send mail with `{"action":"send","to":"user@example.com","subject":"...","body":"..."}`. If delivery depends on SMTP setup, check `{"action":"status"}` before claiming success.',
151
+ ],
152
+ requestMatchers: [],
153
+ },
154
+ ],
155
+ ask_human: [
156
+ {
157
+ toolName: 'ask_human',
158
+ capabilities: ['human.input'],
159
+ disciplineGuidance: [
160
+ 'For `ask_human`, when a workflow needs a code, approval, or out-of-band value from a person, do not guess or keep re-submitting blank forms. Use `{"action":"request_input","question":"..."}` and, for durable pauses, `{"action":"wait_for_reply","correlationId":"..."}`.',
161
+ ],
162
+ requestMatchers: [],
163
+ },
164
+ ],
165
+ }
166
+
167
+ function dedupeStrings(values: string[]): string[] {
168
+ return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim())))
169
+ }
170
+
171
+ function normalizePlanningEntry(toolName: string, planning: PluginToolPlanning | null | undefined): ToolPlanningEntry | null {
172
+ if (!planning) return null
173
+ const capabilities = dedupeStrings(Array.isArray(planning.capabilities) ? planning.capabilities : [])
174
+ const disciplineGuidance = dedupeStrings(Array.isArray(planning.disciplineGuidance) ? planning.disciplineGuidance : [])
175
+ const requestMatchers = Array.isArray(planning.requestMatchers)
176
+ ? planning.requestMatchers
177
+ .map((matcher) => ({
178
+ capability: typeof matcher?.capability === 'string' ? matcher.capability.trim() : '',
179
+ patterns: dedupeStrings(Array.isArray(matcher?.patterns) ? matcher.patterns : []),
180
+ requireLiteralUrl: matcher?.requireLiteralUrl === true,
181
+ forbidLiteralUrl: matcher?.forbidLiteralUrl === true,
182
+ }))
183
+ .filter((matcher) => matcher.capability || matcher.patterns.length > 0)
184
+ : []
185
+ if (!capabilities.length && !disciplineGuidance.length && !requestMatchers.length) return null
186
+ return {
187
+ toolName,
188
+ capabilities,
189
+ disciplineGuidance,
190
+ requestMatchers,
191
+ }
192
+ }
193
+
194
+ export function getEnabledToolPlanningView(enabledPlugins: string[]): ToolPlanningView {
195
+ const displayToolIds = dedupeStrings(enabledPlugins.map((toolId) => canonicalizePluginId(toolId))).sort()
196
+ const expandedPluginIds = dedupeStrings(expandPluginIds(enabledPlugins)).sort()
197
+ const entries: ToolPlanningEntry[] = []
198
+
199
+ for (const pluginId of expandedPluginIds) {
200
+ const coreEntries = CORE_TOOL_PLANNING[pluginId] || []
201
+ for (const entry of coreEntries) {
202
+ entries.push({
203
+ toolName: entry.toolName,
204
+ capabilities: [...entry.capabilities],
205
+ disciplineGuidance: [...entry.disciplineGuidance],
206
+ requestMatchers: [...entry.requestMatchers],
207
+ })
208
+ }
209
+ }
210
+
211
+ for (const entry of getPluginManager().getTools(expandedPluginIds)) {
212
+ const planningEntry = normalizePlanningEntry(entry.tool.name, entry.tool.planning)
213
+ if (planningEntry) entries.push(planningEntry)
214
+ }
215
+
216
+ const disciplineSet = new Set<string>()
217
+ const capabilityToTools = new Map<string, Set<string>>()
218
+ for (const entry of entries) {
219
+ for (const line of entry.disciplineGuidance) disciplineSet.add(line)
220
+ for (const capability of entry.capabilities) {
221
+ const current = capabilityToTools.get(capability) || new Set<string>()
222
+ current.add(entry.toolName)
223
+ capabilityToTools.set(capability, current)
224
+ }
225
+ }
226
+
227
+ return {
228
+ displayToolIds,
229
+ expandedPluginIds,
230
+ entries,
231
+ disciplineGuidance: Array.from(disciplineSet),
232
+ capabilityToTools: new Map(
233
+ Array.from(capabilityToTools.entries()).map(([capability, toolNames]) => [capability, Array.from(toolNames)]),
234
+ ),
235
+ }
236
+ }
237
+
238
+ export function getToolsForCapability(enabledPlugins: string[], capability: string): string[] {
239
+ return getEnabledToolPlanningView(enabledPlugins).capabilityToTools.get(capability) || []
240
+ }
241
+
242
+ export function getFirstToolForCapability(enabledPlugins: string[], capability: string): string | null {
243
+ return getToolsForCapability(enabledPlugins, capability)[0] || null
244
+ }
245
+
246
+ export function matchToolCapabilitiesForMessage(
247
+ enabledPlugins: string[],
248
+ message: string,
249
+ ): Map<string, string[]> {
250
+ const text = String(message || '').toLowerCase()
251
+ const hasLiteralUrl = /https?:\/\/[^\s<>"')]+/i.test(message)
252
+ const matches = new Map<string, Set<string>>()
253
+
254
+ for (const entry of getEnabledToolPlanningView(enabledPlugins).entries) {
255
+ for (const matcher of entry.requestMatchers) {
256
+ const patterns = Array.isArray(matcher.patterns) ? matcher.patterns : []
257
+ if (matcher.requireLiteralUrl === true && !hasLiteralUrl) continue
258
+ if (matcher.forbidLiteralUrl === true && hasLiteralUrl) continue
259
+ if (!patterns.length) continue
260
+ const matched = patterns.some((pattern) => text.includes(pattern.toLowerCase()))
261
+ if (!matched) continue
262
+ const capability = matcher.capability || entry.capabilities[0] || ''
263
+ if (!capability) continue
264
+ const current = matches.get(capability) || new Set<string>()
265
+ current.add(entry.toolName)
266
+ matches.set(capability, current)
267
+ }
268
+ }
269
+
270
+ return new Map(Array.from(matches.entries()).map(([capability, toolNames]) => [capability, Array.from(toolNames)]))
271
+ }