@swarmclawai/swarmclaw 0.9.4 → 0.9.6

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 (32) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/src/app/api/settings/route.ts +1 -0
  4. package/src/app/api/wallets/[id]/send/route.ts +10 -4
  5. package/src/app/api/wallets/route.ts +5 -2
  6. package/src/app/settings/page.tsx +9 -0
  7. package/src/components/wallets/wallet-panel.tsx +12 -1
  8. package/src/components/wallets/wallet-section.tsx +4 -1
  9. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
  10. package/src/lib/server/agents/main-agent-loop.ts +12 -1
  11. package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
  12. package/src/lib/server/agents/subagent-runtime.ts +115 -19
  13. package/src/lib/server/agents/subagent-swarm.ts +3 -3
  14. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
  15. package/src/lib/server/chat-execution/chat-execution.ts +357 -152
  16. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
  17. package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
  18. package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
  19. package/src/lib/server/connectors/contact-boundaries.ts +70 -8
  20. package/src/lib/server/connectors/manager.test.ts +129 -7
  21. package/src/lib/server/plugins.test.ts +263 -0
  22. package/src/lib/server/plugins.ts +406 -10
  23. package/src/lib/server/session-tools/context.ts +15 -1
  24. package/src/lib/server/session-tools/index.ts +42 -6
  25. package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
  26. package/src/lib/server/session-tools/subagent.ts +3 -3
  27. package/src/lib/server/tool-loop-detection.test.ts +21 -0
  28. package/src/lib/server/tool-loop-detection.ts +79 -0
  29. package/src/lib/server/wallet/wallet-service.test.ts +25 -1
  30. package/src/lib/server/wallet/wallet-service.ts +13 -0
  31. package/src/types/index.ts +134 -1
  32. package/src/views/settings/section-wallets.tsx +35 -0
@@ -994,7 +994,7 @@ describe('sanitizeConnectorOutboundContent', () => {
994
994
  storage.saveAgents({
995
995
  agent_1: {
996
996
  id: 'agent_1',
997
- name: 'Hal',
997
+ name: 'Nova',
998
998
  provider: 'test-provider',
999
999
  model: 'test-model',
1000
1000
  plugins: [],
@@ -1020,7 +1020,7 @@ describe('sanitizeConnectorOutboundContent', () => {
1020
1020
  storage.saveSessions({
1021
1021
  agent_thread: {
1022
1022
  id: 'agent_thread',
1023
- name: 'Hal',
1023
+ name: 'Nova',
1024
1024
  cwd: process.env.WORKSPACE_DIR,
1025
1025
  user: 'default',
1026
1026
  provider: 'test-provider',
@@ -1040,16 +1040,16 @@ describe('sanitizeConnectorOutboundContent', () => {
1040
1040
  sessionId: null,
1041
1041
  category: 'identity/preferences',
1042
1042
  title: 'Wife communication rule',
1043
- content: 'Wayde\\'s wife (+44 7958 047282): Do NOT respond unless she addresses Hal directly or mentions Hal directly. If unsure, verify whether the message is for Wayde or Hal before responding.',
1043
+ content: 'Riley\\'s partner (+44 7700 900111): Do NOT respond unless she addresses Nova directly or mentions Nova directly. If unsure, verify whether the message is meant for the agent before responding.',
1044
1044
  })
1045
1045
 
1046
1046
  const connector = storage.loadConnectors().conn_1
1047
1047
  const response = await manager.routeConnectorMessageForTest(connector, {
1048
1048
  platform: 'whatsapp',
1049
- channelId: '447958047282@s.whatsapp.net',
1050
- senderId: '447958047282@s.whatsapp.net',
1051
- senderName: 'Wayde Wife',
1052
- text: 'Dinner is ready for Wayde.',
1049
+ channelId: '447700900111@s.whatsapp.net',
1050
+ senderId: '447700900111@s.whatsapp.net',
1051
+ senderName: 'Riley Partner',
1052
+ text: 'Dinner is ready for Riley.',
1053
1053
  messageId: 'in-quiet-1',
1054
1054
  isGroup: false,
1055
1055
  })
@@ -1070,6 +1070,128 @@ describe('sanitizeConnectorOutboundContent', () => {
1070
1070
  assert.equal(output.mainThreadMessageCount, 0)
1071
1071
  })
1072
1072
 
1073
+ it('does not suppress other senders just because their name appears inside the matched quiet-boundary memory', () => {
1074
+ const output = runWithTempDataDir(`
1075
+ const storageMod = await import('./src/lib/server/storage')
1076
+ const managerMod = await import('./src/lib/server/connectors/manager')
1077
+ const providersMod = await import('./src/lib/providers/index')
1078
+ const pluginsMod = await import('./src/lib/server/plugins')
1079
+ const memoryDbMod = await import('./src/lib/server/memory/memory-db')
1080
+ const storage = storageMod.default || storageMod
1081
+ const manager = managerMod.default || managerMod
1082
+ const providers = providersMod.default || providersMod
1083
+ const plugins = pluginsMod.default || pluginsMod
1084
+ const memoryDbApi = memoryDbMod.default || memoryDbMod
1085
+ const memoryDb = memoryDbApi.getMemoryDb()
1086
+
1087
+ let providerCalls = 0
1088
+ const now = Date.now()
1089
+ providers.PROVIDERS['test-provider'] = {
1090
+ id: 'test-provider',
1091
+ name: 'Test Provider',
1092
+ models: ['test-model'],
1093
+ requiresApiKey: false,
1094
+ requiresEndpoint: false,
1095
+ handler: {
1096
+ streamChat: async (opts) => {
1097
+ providerCalls += 1
1098
+ opts.write('data: ' + JSON.stringify({ t: 'r', text: 'Replying to Riley normally' }) + '\\n')
1099
+ return ''
1100
+ },
1101
+ },
1102
+ }
1103
+
1104
+ plugins.getPluginManager().registerBuiltin('test-quiet-boundary-connector-plugin-allow-riley', {
1105
+ name: 'Test Quiet Boundary Connector Plugin Allow Riley',
1106
+ connectors: [{
1107
+ id: 'test-quiet-allow-riley',
1108
+ name: 'Test Quiet Allow Riley',
1109
+ description: 'Quiet boundary false-positive test connector',
1110
+ startListener: async () => async () => {},
1111
+ sendMessage: async () => ({ messageId: 'unused' }),
1112
+ }],
1113
+ })
1114
+
1115
+ storage.saveSettings({})
1116
+ storage.saveAgents({
1117
+ agent_1: {
1118
+ id: 'agent_1',
1119
+ name: 'Nova',
1120
+ provider: 'test-provider',
1121
+ model: 'test-model',
1122
+ plugins: [],
1123
+ threadSessionId: 'agent_thread',
1124
+ createdAt: now,
1125
+ updatedAt: now,
1126
+ },
1127
+ })
1128
+ storage.saveConnectors({
1129
+ conn_1: {
1130
+ id: 'conn_1',
1131
+ name: 'WhatsApp',
1132
+ platform: 'test-quiet-allow-riley',
1133
+ agentId: 'agent_1',
1134
+ credentialId: null,
1135
+ config: { inboundDebounceMs: 0 },
1136
+ isEnabled: true,
1137
+ status: 'stopped',
1138
+ createdAt: now,
1139
+ updatedAt: now,
1140
+ },
1141
+ })
1142
+ storage.saveSessions({
1143
+ agent_thread: {
1144
+ id: 'agent_thread',
1145
+ name: 'Nova',
1146
+ cwd: process.env.WORKSPACE_DIR,
1147
+ user: 'default',
1148
+ provider: 'test-provider',
1149
+ model: 'test-model',
1150
+ claudeSessionId: null,
1151
+ messages: [],
1152
+ createdAt: now,
1153
+ lastActiveAt: now,
1154
+ sessionType: 'human',
1155
+ agentId: 'agent_1',
1156
+ plugins: [],
1157
+ },
1158
+ })
1159
+
1160
+ memoryDb.add({
1161
+ agentId: 'agent_1',
1162
+ sessionId: null,
1163
+ category: 'identity/preferences',
1164
+ title: 'Wife communication rule',
1165
+ content: 'Riley\\'s partner (+44 7700 900111): Do NOT respond unless she addresses Nova directly or mentions Nova directly. If unsure, verify whether the message is meant for the agent before responding.',
1166
+ })
1167
+
1168
+ const connector = storage.loadConnectors().conn_1
1169
+ const response = await manager.routeConnectorMessageForTest(connector, {
1170
+ platform: 'whatsapp',
1171
+ channelId: '447700900123@s.whatsapp.net',
1172
+ senderId: '447700900123@s.whatsapp.net',
1173
+ senderName: 'Riley',
1174
+ text: 'Did you see the last update?',
1175
+ messageId: 'in-quiet-riley-1',
1176
+ isGroup: false,
1177
+ })
1178
+
1179
+ const sessions = storage.loadSessions()
1180
+ const directSession = Object.values(sessions).find((entry) => entry.id !== 'agent_thread')
1181
+ console.log(JSON.stringify({
1182
+ response,
1183
+ providerCalls,
1184
+ directSessionMessageCount: directSession?.messages?.length || 0,
1185
+ mainThreadMessageCount: sessions.agent_thread?.messages?.length || 0,
1186
+ }))
1187
+ `)
1188
+
1189
+ assert.equal(output.response, 'Replying to Riley normally')
1190
+ assert.equal(output.providerCalls, 1)
1191
+ assert.equal(output.directSessionMessageCount, 2)
1192
+ assert.equal(output.mainThreadMessageCount, 2)
1193
+ })
1194
+
1073
1195
  it('requires an explicit target when a shared thread only has mirrored connector history', () => {
1074
1196
  const output = runWithTempDataDir(`
1075
1197
  const fs = await import('node:fs')
@@ -90,6 +90,269 @@ describe('plugin manager hook execution', () => {
90
90
  assert.deepEqual(withEnable, { original: true, patched: true })
91
91
  })
92
92
 
93
+ it('merges beforePromptBuild context and preserves first system prompt override', async () => {
94
+ const pluginA = uniquePluginId('before_prompt_build_a')
95
+ const pluginB = uniquePluginId('before_prompt_build_b')
96
+ const session = {
97
+ id: 'prompt-hook-session',
98
+ name: 'Prompt Hook Session',
99
+ cwd: process.cwd(),
100
+ user: 'tester',
101
+ provider: 'openai',
102
+ model: 'gpt-test',
103
+ claudeSessionId: null,
104
+ messages: [],
105
+ createdAt: Date.now(),
106
+ lastActiveAt: Date.now(),
107
+ plugins: [pluginA, pluginB],
108
+ }
109
+
110
+ getPluginManager().registerBuiltin(pluginA, {
111
+ name: 'Before Prompt Build A',
112
+ hooks: {
113
+ beforePromptBuild: () => ({
114
+ systemPrompt: 'system A',
115
+ prependContext: 'context A',
116
+ prependSystemContext: 'prepend A',
117
+ }),
118
+ },
119
+ })
120
+ getPluginManager().registerBuiltin(pluginB, {
121
+ name: 'Before Prompt Build B',
122
+ hooks: {
123
+ beforePromptBuild: () => ({
124
+ systemPrompt: 'system B',
125
+ prependContext: 'context B',
126
+ appendSystemContext: 'append B',
127
+ }),
128
+ },
129
+ })
130
+
131
+ const result = await getPluginManager().runBeforePromptBuild(
132
+ {
133
+ session,
134
+ prompt: 'base prompt',
135
+ message: 'hello',
136
+ history: [],
137
+ messages: [],
138
+ },
139
+ { enabledIds: [pluginA, pluginB] },
140
+ )
141
+
142
+ assert.deepEqual(result, {
143
+ systemPrompt: 'system A',
144
+ prependContext: 'context A\n\ncontext B',
145
+ prependSystemContext: 'prepend A',
146
+ appendSystemContext: 'append B',
147
+ })
148
+ })
149
+
150
+ it('applies beforeToolCall params merges and block results before legacy beforeToolExec', async () => {
151
+ const pluginA = uniquePluginId('before_tool_call_a')
152
+ const pluginB = uniquePluginId('before_tool_call_b')
153
+ const session = {
154
+ id: 'tool-hook-session',
155
+ name: 'Tool Hook Session',
156
+ cwd: process.cwd(),
157
+ user: 'tester',
158
+ provider: 'openai',
159
+ model: 'gpt-test',
160
+ claudeSessionId: null,
161
+ messages: [],
162
+ createdAt: Date.now(),
163
+ lastActiveAt: Date.now(),
164
+ plugins: [pluginA, pluginB],
165
+ }
166
+
167
+ getPluginManager().registerBuiltin(pluginA, {
168
+ name: 'Before Tool Call A',
169
+ hooks: {
170
+ beforeToolCall: () => ({
171
+ params: { patched: true },
172
+ warning: 'tool warning',
173
+ }),
174
+ },
175
+ })
176
+ getPluginManager().registerBuiltin(pluginB, {
177
+ name: 'Before Tool Call B',
178
+ hooks: {
179
+ beforeToolCall: ({ input }) => ({
180
+ block: true,
181
+ blockReason: `blocked with patched=${String(input?.patched)}`,
182
+ }),
183
+ beforeToolExec: () => ({ shouldNotRun: true }),
184
+ },
185
+ })
186
+
187
+ const result = await getPluginManager().runBeforeToolCall(
188
+ {
189
+ session,
190
+ toolName: 'shell',
191
+ input: { original: true },
192
+ runId: 'run-1',
193
+ },
194
+ { enabledIds: [pluginA, pluginB] },
195
+ )
196
+
197
+ assert.deepEqual(result, {
198
+ input: { original: true, patched: true },
199
+ blockReason: 'blocked with patched=true',
200
+ warning: 'tool warning',
201
+ })
202
+ })
203
+
204
+ it('applies beforeModelResolve overrides in plugin order', async () => {
205
+ const pluginA = uniquePluginId('before_model_resolve_a')
206
+ const pluginB = uniquePluginId('before_model_resolve_b')
207
+ const session = {
208
+ id: 'model-resolve-session',
209
+ name: 'Model Resolve Session',
210
+ cwd: process.cwd(),
211
+ user: 'tester',
212
+ provider: 'openai',
213
+ model: 'gpt-test',
214
+ claudeSessionId: null,
215
+ messages: [],
216
+ createdAt: Date.now(),
217
+ lastActiveAt: Date.now(),
218
+ plugins: [pluginA, pluginB],
219
+ }
220
+
221
+ getPluginManager().registerBuiltin(pluginA, {
222
+ name: 'Before Model Resolve A',
223
+ hooks: {
224
+ beforeModelResolve: () => ({
225
+ providerOverride: 'ollama',
226
+ modelOverride: 'llama-a',
227
+ }),
228
+ },
229
+ })
230
+ getPluginManager().registerBuiltin(pluginB, {
231
+ name: 'Before Model Resolve B',
232
+ hooks: {
233
+ beforeModelResolve: () => ({
234
+ modelOverride: 'llama-b',
235
+ apiEndpointOverride: 'http://127.0.0.1:11434',
236
+ }),
237
+ },
238
+ })
239
+
240
+ const result = await getPluginManager().runBeforeModelResolve(
241
+ {
242
+ session,
243
+ prompt: 'base prompt',
244
+ message: 'hello',
245
+ provider: session.provider,
246
+ model: session.model,
247
+ apiEndpoint: null,
248
+ },
249
+ { enabledIds: [pluginA, pluginB] },
250
+ )
251
+
252
+ assert.deepEqual(result, {
253
+ providerOverride: 'ollama',
254
+ modelOverride: 'llama-b',
255
+ apiEndpointOverride: 'http://127.0.0.1:11434',
256
+ })
257
+ })
258
+
259
+ it('chains toolResultPersist and beforeMessageWrite hooks', async () => {
260
+ const pluginA = uniquePluginId('tool_result_persist_a')
261
+ const pluginB = uniquePluginId('before_message_write_b')
262
+ const session = {
263
+ id: 'message-write-session',
264
+ name: 'Message Write Session',
265
+ cwd: process.cwd(),
266
+ user: 'tester',
267
+ provider: 'openai',
268
+ model: 'gpt-test',
269
+ claudeSessionId: null,
270
+ messages: [],
271
+ createdAt: Date.now(),
272
+ lastActiveAt: Date.now(),
273
+ plugins: [pluginA, pluginB],
274
+ }
275
+
276
+ getPluginManager().registerBuiltin(pluginA, {
277
+ name: 'Tool Result Persist A',
278
+ hooks: {
279
+ toolResultPersist: ({ message, toolName }) => ({
280
+ ...message,
281
+ text: `${message.text} [tool:${toolName}]`,
282
+ }),
283
+ },
284
+ })
285
+ getPluginManager().registerBuiltin(pluginB, {
286
+ name: 'Before Message Write B',
287
+ hooks: {
288
+ beforeMessageWrite: ({ message }) => ({
289
+ message: {
290
+ ...message,
291
+ text: `${message.text} [persisted]`,
292
+ },
293
+ }),
294
+ },
295
+ })
296
+
297
+ const persisted = await getPluginManager().runToolResultPersist(
298
+ {
299
+ session,
300
+ message: {
301
+ role: 'assistant',
302
+ text: 'tool output',
303
+ time: Date.now(),
304
+ },
305
+ toolName: 'shell',
306
+ toolCallId: 'call-1',
307
+ },
308
+ { enabledIds: [pluginA, pluginB] },
309
+ )
310
+ const writeResult = await getPluginManager().runBeforeMessageWrite(
311
+ {
312
+ session,
313
+ message: persisted,
314
+ phase: 'assistant_final',
315
+ runId: 'run-1',
316
+ },
317
+ { enabledIds: [pluginA, pluginB] },
318
+ )
319
+
320
+ assert.equal(writeResult.block, false)
321
+ assert.equal(writeResult.message.text, 'tool output [tool:shell] [persisted]')
322
+ })
323
+
324
+ it('blocks subagent spawning when a plugin hook rejects it', async () => {
325
+ const pluginId = uniquePluginId('subagent_spawning')
326
+
327
+ getPluginManager().registerBuiltin(pluginId, {
328
+ name: 'Subagent Spawning Hook',
329
+ hooks: {
330
+ subagentSpawning: () => ({
331
+ status: 'error',
332
+ error: 'blocked by lifecycle hook',
333
+ }),
334
+ },
335
+ })
336
+
337
+ const result = await getPluginManager().runSubagentSpawning(
338
+ {
339
+ parentSessionId: 'parent-1',
340
+ agentId: 'agent-1',
341
+ agentName: 'Agent One',
342
+ message: 'do the work',
343
+ cwd: process.cwd(),
344
+ mode: 'run',
345
+ threadRequested: false,
346
+ },
347
+ { enabledIds: [pluginId] },
348
+ )
349
+
350
+ assert.deepEqual(result, {
351
+ status: 'error',
352
+ error: 'blocked by lifecycle hook',
353
+ })
354
+ })
355
+
93
356
  it('chains text transforms in plugin order', async () => {
94
357
  const pluginA = uniquePluginId('transform_a')
95
358
  const pluginB = uniquePluginId('transform_b')