@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
@@ -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
+ }
@@ -11,6 +11,8 @@ export const TOOL_CAPABILITY = {
11
11
  deliveryMessage: 'delivery.message',
12
12
  deliveryMedia: 'delivery.media',
13
13
  deliveryVoiceNote: 'delivery.voice_note',
14
+ walletInspect: 'wallet.inspect',
15
+ walletExecute: 'wallet.execute',
14
16
  } as const
15
17
 
16
18
  export interface ToolPlanningEntry {
@@ -59,7 +61,7 @@ const CORE_TOOL_PLANNING: Record<string, ToolPlanningEntry[]> = {
59
61
  requestMatchers: [
60
62
  {
61
63
  capability: TOOL_CAPABILITY.researchSearch,
62
- patterns: ['research', 'look up', 'find out', 'search for', 'compare', 'latest', 'news', 'headline', 'current event', 'recent update', "what's new", 'what happened'],
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'],
63
65
  forbidLiteralUrl: true,
64
66
  },
65
67
  ],
@@ -121,7 +123,7 @@ const CORE_TOOL_PLANNING: Record<string, ToolPlanningEntry[]> = {
121
123
  },
122
124
  {
123
125
  capability: TOOL_CAPABILITY.deliveryMedia,
124
- patterns: ['screenshot', 'screen shot', 'snapshot', 'image', 'photo', 'file', 'pdf', 'attachment'],
126
+ patterns: ['screenshot', 'screen shot', 'snapshot', 'image', 'photo', 'send file', 'send a file', 'pdf', 'attachment'],
125
127
  },
126
128
  {
127
129
  capability: TOOL_CAPABILITY.deliveryVoiceNote,
@@ -0,0 +1,198 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { after, before, describe, it } from 'node:test'
6
+
7
+ import type { AgentWallet } from '@/types'
8
+
9
+ const originalEnv = {
10
+ DATA_DIR: process.env.DATA_DIR,
11
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
12
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
13
+ CREDENTIAL_SECRET: process.env.CREDENTIAL_SECRET,
14
+ }
15
+
16
+ let tempDir = ''
17
+ let encryptKey: typeof import('./storage').encryptKey
18
+ let callEthereumContract: typeof import('./ethereum').callEthereumContract
19
+ let encodeEthereumContractCall: typeof import('./ethereum').encodeEthereumContractCall
20
+ let prepareEvmSwapPlan: typeof import('./evm-swap').prepareEvmSwapPlan
21
+ let signEthereumMessage: typeof import('./ethereum').signEthereumMessage
22
+ let signEthereumTypedData: typeof import('./ethereum').signEthereumTypedData
23
+ let generateSolanaKeypair: typeof import('./solana').generateSolanaKeypair
24
+ let signSolanaMessage: typeof import('./solana').signSolanaMessage
25
+ let signSolanaTransaction: typeof import('./solana').signSolanaTransaction
26
+ let TransactionCtor: typeof import('@solana/web3.js').Transaction
27
+ let SystemProgramNs: typeof import('@solana/web3.js').SystemProgram
28
+ let PublicKeyCtor: typeof import('@solana/web3.js').PublicKey
29
+
30
+ before(async () => {
31
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-wallet-exec-'))
32
+ process.env.DATA_DIR = path.join(tempDir, 'data')
33
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
34
+ process.env.SWARMCLAW_BUILD_MODE = '1'
35
+ process.env.CREDENTIAL_SECRET = '11'.repeat(32)
36
+ fs.mkdirSync(process.env.DATA_DIR, { recursive: true })
37
+ fs.mkdirSync(process.env.WORKSPACE_DIR, { recursive: true })
38
+
39
+ ;({ encryptKey } = await import('./storage'))
40
+ ;({ callEthereumContract, encodeEthereumContractCall, signEthereumMessage, signEthereumTypedData } = await import('./ethereum'))
41
+ ;({ prepareEvmSwapPlan } = await import('./evm-swap'))
42
+ ;({ generateSolanaKeypair, signSolanaMessage, signSolanaTransaction } = await import('./solana'))
43
+ ;({ Transaction: TransactionCtor, SystemProgram: SystemProgramNs, PublicKey: PublicKeyCtor } = await import('@solana/web3.js'))
44
+ })
45
+
46
+ after(() => {
47
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
48
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
49
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
50
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
51
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
52
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
53
+ if (originalEnv.CREDENTIAL_SECRET === undefined) delete process.env.CREDENTIAL_SECRET
54
+ else process.env.CREDENTIAL_SECRET = originalEnv.CREDENTIAL_SECRET
55
+ fs.rmSync(tempDir, { recursive: true, force: true })
56
+ })
57
+
58
+ describe('wallet execution helpers', () => {
59
+ it('encodes ERC-20 contract calldata and signs EVM payloads', async () => {
60
+ const privateKey = '0x59c6995e998f97a5a004497e5d4ab3d89165b0def05d6d33923995df83329538'
61
+ const encrypted = encryptKey(privateKey)
62
+
63
+ const encoded = encodeEthereumContractCall(
64
+ ['function approve(address spender,uint256 amount)'],
65
+ 'approve',
66
+ ['0x000000000000000000000000000000000000dEaD', '1000'],
67
+ )
68
+ assert.equal(encoded.data.startsWith('0x095ea7b3'), true)
69
+
70
+ const encodedFromNamedArgs = encodeEthereumContractCall(
71
+ ['function approve(address spender,uint256 amount)'],
72
+ 'approve',
73
+ { spender: '0x000000000000000000000000000000000000dEaD', amount: '1000' },
74
+ )
75
+ assert.equal(encodedFromNamedArgs.data, encoded.data)
76
+
77
+ const encodedTupleArg = encodeEthereumContractCall(
78
+ ['function quoteExactInputSingle((address tokenIn,address tokenOut,uint256 amountIn,uint24 fee,uint160 sqrtPriceLimitX96) params) returns (uint256 amountOut)'],
79
+ 'quoteExactInputSingle',
80
+ {
81
+ tokenIn: '0x0000000000000000000000000000000000000001',
82
+ tokenOut: '0x0000000000000000000000000000000000000002',
83
+ amountIn: '1000000',
84
+ fee: 500,
85
+ sqrtPriceLimitX96: '0',
86
+ },
87
+ )
88
+ assert.equal(encodedTupleArg.data.startsWith('0xc6a5026a'), true)
89
+
90
+ const signedMessage = await signEthereumMessage(encrypted, { message: 'hello world' })
91
+ assert.equal(signedMessage.address.length, 42)
92
+ assert.equal(signedMessage.signature.startsWith('0x'), true)
93
+
94
+ const signedTypedData = await signEthereumTypedData(encrypted, {
95
+ domain: {
96
+ name: 'SwarmClaw',
97
+ version: '1',
98
+ chainId: 1,
99
+ },
100
+ types: {
101
+ Login: [
102
+ { name: 'wallet', type: 'address' },
103
+ { name: 'nonce', type: 'uint256' },
104
+ ],
105
+ },
106
+ value: {
107
+ wallet: signedMessage.address,
108
+ nonce: '7',
109
+ },
110
+ })
111
+ assert.equal(signedTypedData.signature.startsWith('0x'), true)
112
+
113
+ const called = await callEthereumContract(encrypted, {
114
+ contractAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
115
+ abi: ['function name() view returns (string)'],
116
+ functionName: 'name',
117
+ }, {
118
+ network: 'ethereum',
119
+ rpcUrl: 'https://ethereum-rpc.publicnode.com',
120
+ })
121
+ assert.equal(called.decoded, 'Wrapped Ether')
122
+
123
+ const allowance = await callEthereumContract(encrypted, {
124
+ contractAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
125
+ abi: ['function allowance(address owner,address spender) view returns (uint256)'],
126
+ functionName: 'allowance',
127
+ args: {
128
+ owner: signedMessage.address,
129
+ spender: '0x000000000000000000000000000000000000dEaD',
130
+ },
131
+ }, {
132
+ network: 'ethereum',
133
+ rpcUrl: 'https://ethereum-rpc.publicnode.com',
134
+ })
135
+ assert.equal(typeof allowance.decoded, 'string')
136
+ })
137
+
138
+ it('builds a generic ParaSwap-backed swap plan for Arbitrum without a venue-specific adapter', async () => {
139
+ const privateKey = '0x59c6995e998f97a5a004497e5d4ab3d89165b0def05d6d33923995df83329538'
140
+ const encrypted = encryptKey(privateKey)
141
+ const walletAddress = (await signEthereumMessage(encrypted, { message: 'derive address' })).address
142
+ const wallet: AgentWallet = {
143
+ id: 'wallet_swap_plan',
144
+ agentId: 'agent_wallet',
145
+ chain: 'ethereum',
146
+ publicKey: walletAddress,
147
+ encryptedPrivateKey: encrypted,
148
+ spendingLimitAtomic: '1000000000000000000',
149
+ dailyLimitAtomic: '10000000000000000000',
150
+ requireApproval: true,
151
+ createdAt: Date.now(),
152
+ updatedAt: Date.now(),
153
+ }
154
+
155
+ const plan = await prepareEvmSwapPlan({
156
+ wallet,
157
+ network: 'arbitrum',
158
+ sellToken: 'USDC',
159
+ buyToken: 'ETH',
160
+ sellAmountDisplay: '1',
161
+ skipBalanceCheck: true,
162
+ })
163
+
164
+ assert.equal(plan.provider, 'paraswap')
165
+ assert.equal(plan.network.id, 'arbitrum')
166
+ assert.equal(plan.sellToken.symbol, 'USDC')
167
+ assert.equal(plan.buyToken.symbol, 'ETH')
168
+ assert.equal(plan.sellAmountAtomic, '1000000')
169
+ assert.equal(plan.approvalRequired, true)
170
+ assert.equal(typeof plan.spenderAddress, 'string')
171
+ assert.equal(typeof plan.swapTransaction.to, 'string')
172
+ assert.equal(String(plan.swapTransaction.data || '').startsWith('0x'), true)
173
+ })
174
+
175
+ it('signs Solana messages and legacy transactions offline', async () => {
176
+ const sender = generateSolanaKeypair()
177
+ const recipient = generateSolanaKeypair()
178
+
179
+ const signedMessage = await signSolanaMessage(sender.encryptedPrivateKey, { message: 'solana hello' })
180
+ assert.equal(signedMessage.publicKey, sender.publicKey)
181
+ assert.equal(signedMessage.signature.length > 40, true)
182
+
183
+ const tx = new TransactionCtor()
184
+ tx.feePayer = new PublicKeyCtor(sender.publicKey)
185
+ tx.recentBlockhash = generateSolanaKeypair().publicKey
186
+ tx.add(SystemProgramNs.transfer({
187
+ fromPubkey: new PublicKeyCtor(sender.publicKey),
188
+ toPubkey: new PublicKeyCtor(recipient.publicKey),
189
+ lamports: 1_234,
190
+ }))
191
+
192
+ const unsignedBase64 = Buffer.from(tx.serialize({ requireAllSignatures: false, verifySignatures: false })).toString('base64')
193
+ const signedTx = await signSolanaTransaction(sender.encryptedPrivateKey, unsignedBase64)
194
+ assert.equal(signedTx.publicKey, sender.publicKey)
195
+ assert.equal(signedTx.signatures.length > 0, true)
196
+ assert.equal(typeof signedTx.signedTransactionBase64, 'string')
197
+ })
198
+ })
@@ -0,0 +1,98 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import type { AgentWallet } from '@/types'
4
+
5
+ import {
6
+ buildLogDiscoveryRanges,
7
+ estimateDiscoveryStartBlock,
8
+ getKnownEvmTokenContracts,
9
+ parseMetaplexMetadataFields,
10
+ buildEmptyWalletPortfolio,
11
+ resolveWalletPortfolioWithTimeout,
12
+ } from './wallet-portfolio'
13
+
14
+ describe('wallet portfolio helpers', () => {
15
+ it('splits large log discovery requests into provider-safe chunks', () => {
16
+ assert.deepEqual(buildLogDiscoveryRanges(10, 10, 50_000), [{ fromBlock: 10, toBlock: 10 }])
17
+ assert.deepEqual(buildLogDiscoveryRanges(1, 120_000, 50_000), [
18
+ { fromBlock: 1, toBlock: 50_000 },
19
+ { fromBlock: 50_001, toBlock: 100_000 },
20
+ { fromBlock: 100_001, toBlock: 120_000 },
21
+ ])
22
+ })
23
+
24
+ it('always checks canonical USDC contracts on supported EVM networks', () => {
25
+ assert.equal(
26
+ getKnownEvmTokenContracts('arbitrum').map((address) => address.toLowerCase()).includes('0xaf88d065e77c8cc2239327c5edb3a432268e5831'),
27
+ true,
28
+ )
29
+ assert.equal(
30
+ getKnownEvmTokenContracts('base').map((address) => address.toLowerCase()).includes('0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'),
31
+ true,
32
+ )
33
+ assert.equal(
34
+ getKnownEvmTokenContracts('ethereum').map((address) => address.toLowerCase()).includes('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'),
35
+ true,
36
+ )
37
+ })
38
+
39
+ it('scans from wallet age rather than capping discovery to a fixed recent window', () => {
40
+ const now = Date.UTC(2026, 2, 8)
41
+ const latestBlock = 10_000_000
42
+ const newerWalletStart = estimateDiscoveryStartBlock({
43
+ latestBlock,
44
+ walletCreatedAt: now - (7 * 24 * 60 * 60 * 1000),
45
+ avgBlockMs: 12_000,
46
+ maxDiscoveryBlocks: 5_000_000,
47
+ now,
48
+ })
49
+ const olderWalletStart = estimateDiscoveryStartBlock({
50
+ latestBlock,
51
+ walletCreatedAt: now - (30 * 24 * 60 * 60 * 1000),
52
+ avgBlockMs: 12_000,
53
+ maxDiscoveryBlocks: 5_000_000,
54
+ now,
55
+ })
56
+
57
+ assert.equal(olderWalletStart < newerWalletStart, true)
58
+ })
59
+
60
+ it('parses metaplex metadata name and symbol for arbitrary SPL mints', () => {
61
+ const data = Buffer.alloc(1 + 32 + 32 + 32 + 10)
62
+ Buffer.from('Example Token').copy(data, 1 + 32 + 32)
63
+ Buffer.from('EXMPL').copy(data, 1 + 32 + 32 + 32)
64
+
65
+ assert.deepEqual(parseMetaplexMetadataFields(data), {
66
+ name: 'Example Token',
67
+ symbol: 'EXMPL',
68
+ })
69
+ })
70
+
71
+ it('returns stale portfolio data when a live portfolio lookup times out', async () => {
72
+ const wallet: AgentWallet = {
73
+ id: 'wallet-timeout',
74
+ agentId: 'agent-timeout',
75
+ chain: 'ethereum',
76
+ publicKey: '0x0000000000000000000000000000000000000001',
77
+ encryptedPrivateKey: 'secret',
78
+ requireApproval: true,
79
+ spendingLimitAtomic: '1',
80
+ dailyLimitAtomic: '1',
81
+ createdAt: 1,
82
+ updatedAt: 1,
83
+ }
84
+ const stale = buildEmptyWalletPortfolio(wallet)
85
+ stale.balanceAtomic = '123'
86
+ stale.balanceFormatted = '0.000000000000000123'
87
+ stale.balanceDisplay = `${stale.balanceFormatted} ETH`
88
+
89
+ const result = await resolveWalletPortfolioWithTimeout({
90
+ load: () => new Promise<ReturnType<typeof buildEmptyWalletPortfolio>>(() => {}),
91
+ timeoutMs: 5,
92
+ stale,
93
+ label: 'wallet portfolio timeout test',
94
+ })
95
+
96
+ assert.equal(result.balanceAtomic, '123')
97
+ })
98
+ })