@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
@@ -120,6 +120,24 @@ export class ToolLoopTracker {
120
120
  ?? null
121
121
  }
122
122
 
123
+ /**
124
+ * Preview whether the next tool call should be warned or blocked before it executes.
125
+ * Pre-call checks only use detectors that do not depend on tool output.
126
+ */
127
+ preview(name: string, input: unknown): LoopDetectionResult | null {
128
+ const current: ToolCallRecord = {
129
+ name,
130
+ inputHash: hashToolInput(input),
131
+ outputHash: '',
132
+ outputPreview: '',
133
+ timestamp: Date.now(),
134
+ }
135
+ return this.checkCircuitBreakerPreview(current)
136
+ ?? this.checkToolFrequencyPreview(current)
137
+ ?? this.checkGenericRepeatPreview(current)
138
+ ?? null
139
+ }
140
+
123
141
  /** Get the full call history (for diagnostics). */
124
142
  getHistory(): ReadonlyArray<ToolCallRecord> {
125
143
  return this.history
@@ -156,6 +174,28 @@ export class ToolLoopTracker {
156
174
  return null
157
175
  }
158
176
 
177
+ private checkToolFrequencyPreview(current: ToolCallRecord): LoopDetectionResult | null {
178
+ let count = 1
179
+ for (const r of this.history) {
180
+ if (r.name === current.name) count++
181
+ }
182
+ if (count >= this.thresholds.toolFrequencyCritical) {
183
+ return {
184
+ severity: 'critical',
185
+ detector: 'tool_frequency',
186
+ message: `Tool "${current.name}" would be called ${count} times this turn. Excessive repetition — wrap up with available results.`,
187
+ }
188
+ }
189
+ if (count >= this.thresholds.toolFrequencyWarn) {
190
+ return {
191
+ severity: 'warning',
192
+ detector: 'tool_frequency',
193
+ message: `Tool "${current.name}" is nearing overuse (${count} calls this turn). Consider whether another call is needed.`,
194
+ }
195
+ }
196
+ return null
197
+ }
198
+
159
199
  private checkCircuitBreaker(current: ToolCallRecord): LoopDetectionResult | null {
160
200
  const key = `${current.name}:${current.inputHash}`
161
201
  let count = 0
@@ -172,6 +212,22 @@ export class ToolLoopTracker {
172
212
  return null
173
213
  }
174
214
 
215
+ private checkCircuitBreakerPreview(current: ToolCallRecord): LoopDetectionResult | null {
216
+ const key = `${current.name}:${current.inputHash}`
217
+ let count = 1
218
+ for (const r of this.history) {
219
+ if (`${r.name}:${r.inputHash}` === key) count++
220
+ }
221
+ if (count >= this.thresholds.circuitBreaker) {
222
+ return {
223
+ severity: 'critical',
224
+ detector: 'circuit_breaker',
225
+ message: `Circuit breaker: "${current.name}" would be called ${count} times with identical input. Halting before another runaway call.`,
226
+ }
227
+ }
228
+ return null
229
+ }
230
+
175
231
  private checkGenericRepeat(current: ToolCallRecord): LoopDetectionResult | null {
176
232
  const key = `${current.name}:${current.inputHash}`
177
233
  let count = 0
@@ -195,6 +251,29 @@ export class ToolLoopTracker {
195
251
  return null
196
252
  }
197
253
 
254
+ private checkGenericRepeatPreview(current: ToolCallRecord): LoopDetectionResult | null {
255
+ const key = `${current.name}:${current.inputHash}`
256
+ let count = 1
257
+ for (const r of this.history) {
258
+ if (`${r.name}:${r.inputHash}` === key) count++
259
+ }
260
+ if (count >= this.thresholds.repeatCritical) {
261
+ return {
262
+ severity: 'critical',
263
+ detector: 'generic_repeat',
264
+ message: `Tool "${current.name}" would repeat the same input ${count} times. Blocking before it becomes a stuck loop.`,
265
+ }
266
+ }
267
+ if (count >= this.thresholds.repeatWarn) {
268
+ return {
269
+ severity: 'warning',
270
+ detector: 'generic_repeat',
271
+ message: `Tool "${current.name}" is about to repeat the same input ${count} times. Consider a different approach.`,
272
+ }
273
+ }
274
+ return null
275
+ }
276
+
198
277
  private checkPollingStall(current: ToolCallRecord): LoopDetectionResult | null {
199
278
  // Look for recent sequential calls to the same tool with identical output
200
279
  const recent = this.history.slice(-this.thresholds.pollCritical)
@@ -3,7 +3,11 @@ import { describe, it } from 'node:test'
3
3
 
4
4
  import type { AgentWallet, WalletTransaction } from '@/types'
5
5
 
6
- import { validateWalletSendLimits } from '@/lib/server/wallet/wallet-service'
6
+ import {
7
+ validateWalletSendLimits,
8
+ walletApprovalsGloballyEnabled,
9
+ walletRequiresApproval,
10
+ } from '@/lib/server/wallet/wallet-service'
7
11
 
8
12
  function buildWallet(overrides: Partial<AgentWallet> = {}): AgentWallet {
9
13
  return {
@@ -55,3 +59,23 @@ describe('validateWalletSendLimits', () => {
55
59
  assert.match(error || '', /Daily limit exceeded/)
56
60
  })
57
61
  })
62
+
63
+ describe('wallet approval helpers', () => {
64
+ it('treats missing app setting as globally enabled', () => {
65
+ assert.equal(walletApprovalsGloballyEnabled(undefined), true)
66
+ assert.equal(walletApprovalsGloballyEnabled({}), true)
67
+ })
68
+
69
+ it('lets the global setting disable approval even for approval-required wallets', () => {
70
+ const wallet = buildWallet({ requireApproval: true })
71
+
72
+ assert.equal(walletRequiresApproval(wallet, { walletApprovalsEnabled: true }), true)
73
+ assert.equal(walletRequiresApproval(wallet, { walletApprovalsEnabled: false }), false)
74
+ })
75
+
76
+ it('still respects the per-wallet toggle when global approvals remain enabled', () => {
77
+ const wallet = buildWallet({ requireApproval: false })
78
+
79
+ assert.equal(walletRequiresApproval(wallet, { walletApprovalsEnabled: true }), false)
80
+ })
81
+ })
@@ -91,6 +91,19 @@ export function getWalletByAgentId(agentId: string, chain?: WalletChain | null):
91
91
  return wallets.find((wallet) => wallet.id === activeWalletId) ?? wallets[0] ?? null
92
92
  }
93
93
 
94
+ export function walletApprovalsGloballyEnabled(
95
+ settings?: { walletApprovalsEnabled?: boolean | null } | null,
96
+ ): boolean {
97
+ return settings?.walletApprovalsEnabled !== false
98
+ }
99
+
100
+ export function walletRequiresApproval(
101
+ wallet: Pick<AgentWallet, 'requireApproval'>,
102
+ settings?: { walletApprovalsEnabled?: boolean | null } | null,
103
+ ): boolean {
104
+ return walletApprovalsGloballyEnabled(settings) && wallet.requireApproval !== false
105
+ }
106
+
94
107
  export function createAgentWallet(input: {
95
108
  agentId: string
96
109
  chain?: WalletChain | string | null
@@ -354,12 +354,144 @@ export interface UsageRecord {
354
354
 
355
355
  // --- Plugin System ---
356
356
 
357
+ export interface PluginPromptBuildResult {
358
+ systemPrompt?: string
359
+ prependContext?: string
360
+ prependSystemContext?: string
361
+ appendSystemContext?: string
362
+ }
363
+
364
+ export interface PluginModelResolveResult {
365
+ providerOverride?: ProviderType
366
+ modelOverride?: string
367
+ apiEndpointOverride?: string | null
368
+ }
369
+
370
+ export interface PluginToolCallResult {
371
+ input?: Record<string, unknown> | null
372
+ params?: Record<string, unknown>
373
+ block?: boolean
374
+ blockReason?: string
375
+ warning?: string
376
+ }
377
+
378
+ export interface PluginMessagePersistResult {
379
+ message?: Message
380
+ }
381
+
382
+ export interface PluginBeforeMessageWriteResult extends PluginMessagePersistResult {
383
+ block?: boolean
384
+ }
385
+
386
+ export interface PluginSubagentSpawningResult {
387
+ status: 'ok' | 'error'
388
+ error?: string
389
+ }
390
+
357
391
  export interface PluginHooks {
358
392
  beforeAgentStart?: (ctx: { session: Session; message: string }) => Promise<void> | void
359
393
  afterAgentComplete?: (ctx: { session: Session; response: string }) => Promise<void> | void
394
+ beforeModelResolve?: (ctx: {
395
+ session: Session
396
+ prompt: string
397
+ message: string
398
+ provider: ProviderType
399
+ model: string
400
+ apiEndpoint?: string | null
401
+ }) => Promise<PluginModelResolveResult | void> | PluginModelResolveResult | void
360
402
  beforeToolExec?: (ctx: { toolName: string; input: Record<string, unknown> | null }) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void
403
+ beforePromptBuild?: (ctx: {
404
+ session: Session
405
+ prompt: string
406
+ message: string
407
+ history: Message[]
408
+ messages: Message[]
409
+ }) => Promise<PluginPromptBuildResult | void> | PluginPromptBuildResult | void
410
+ beforeToolCall?: (ctx: {
411
+ session: Session
412
+ toolName: string
413
+ input: Record<string, unknown> | null
414
+ runId?: string
415
+ toolCallId?: string
416
+ }) => Promise<PluginToolCallResult | Record<string, unknown> | void> | PluginToolCallResult | Record<string, unknown> | void
417
+ llmInput?: (ctx: {
418
+ session: Session
419
+ runId: string
420
+ provider: ProviderType
421
+ model: string
422
+ systemPrompt?: string
423
+ prompt: string
424
+ historyMessages: Message[]
425
+ imagesCount: number
426
+ }) => Promise<void> | void
427
+ llmOutput?: (ctx: {
428
+ session: Session
429
+ runId: string
430
+ provider: ProviderType
431
+ model: string
432
+ assistantTexts: string[]
433
+ response: string
434
+ usage?: {
435
+ input?: number
436
+ output?: number
437
+ total?: number
438
+ estimatedCost?: number
439
+ }
440
+ }) => Promise<void> | void
441
+ toolResultPersist?: (ctx: {
442
+ session: Session
443
+ message: Message
444
+ toolName?: string
445
+ toolCallId?: string
446
+ isSynthetic?: boolean
447
+ }) => Promise<PluginMessagePersistResult | Message | void> | PluginMessagePersistResult | Message | void
448
+ beforeMessageWrite?: (ctx: {
449
+ session: Session
450
+ message: Message
451
+ phase?: 'user' | 'system' | 'assistant_partial' | 'assistant_final' | 'heartbeat'
452
+ runId?: string
453
+ }) => Promise<PluginBeforeMessageWriteResult | Message | void> | PluginBeforeMessageWriteResult | Message | void
361
454
  afterToolExec?: (ctx: { session: Session; toolName: string; input: Record<string, unknown> | null; output: string }) => Promise<void> | void
362
455
  onMessage?: (ctx: { session: Session; message: Message }) => Promise<void> | void
456
+ sessionStart?: (ctx: {
457
+ session: Session
458
+ resumedFrom?: string | null
459
+ }) => Promise<void> | void
460
+ sessionEnd?: (ctx: {
461
+ sessionId: string
462
+ session?: Session | null
463
+ messageCount: number
464
+ durationMs?: number
465
+ reason?: string | null
466
+ }) => Promise<void> | void
467
+ subagentSpawning?: (ctx: {
468
+ parentSessionId?: string | null
469
+ agentId: string
470
+ agentName: string
471
+ message: string
472
+ cwd: string
473
+ mode: 'run' | 'session'
474
+ threadRequested: boolean
475
+ }) => Promise<PluginSubagentSpawningResult | void> | PluginSubagentSpawningResult | void
476
+ subagentSpawned?: (ctx: {
477
+ parentSessionId?: string | null
478
+ childSessionId: string
479
+ agentId: string
480
+ agentName: string
481
+ runId: string
482
+ mode: 'run' | 'session'
483
+ threadRequested: boolean
484
+ }) => Promise<void> | void
485
+ subagentEnded?: (ctx: {
486
+ parentSessionId?: string | null
487
+ childSessionId: string
488
+ agentId: string
489
+ agentName: string
490
+ status: 'completed' | 'failed' | 'cancelled' | 'timed_out'
491
+ response?: string | null
492
+ error?: string | null
493
+ durationMs?: number
494
+ }) => Promise<void> | void
363
495
 
364
496
  // Post-turn hook — fires after a full chat exchange (user message → agent response)
365
497
  afterChatTurn?: (ctx: {
@@ -772,7 +904,7 @@ export interface AgentWallet {
772
904
  spendingLimitLamports?: number
773
905
  /** @deprecated Use dailyLimitAtomic */
774
906
  dailyLimitLamports?: number
775
- requireApproval: boolean // default true
907
+ requireApproval: boolean // default true; can be globally overridden by app settings
776
908
  createdAt: number
777
909
  updatedAt: number
778
910
  }
@@ -1325,6 +1457,7 @@ export interface AppSettings {
1325
1457
  safetyRequireApprovalForOutbound?: boolean
1326
1458
  safetyMaxDailySpendUsd?: number | null
1327
1459
  safetyBlockedTools?: string[]
1460
+ walletApprovalsEnabled?: boolean
1328
1461
  capabilityPolicyMode?: 'permissive' | 'balanced' | 'strict'
1329
1462
  capabilityBlockedTools?: string[]
1330
1463
  capabilityBlockedCategories?: string[]
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+
3
+ import type { SettingsSectionProps } from './types'
4
+
5
+ export function WalletsSection({ appSettings, patchSettings }: SettingsSectionProps) {
6
+ const walletApprovalsEnabled = appSettings.walletApprovalsEnabled !== false
7
+
8
+ return (
9
+ <div className="mb-10">
10
+ <h3 className="font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
11
+ Wallets
12
+ </h3>
13
+ <p className="text-[12px] text-text-3 mb-5">
14
+ Global override for wallet approval prompts. Turn this off to auto-execute wallet sends and other wallet actions without creating pending approval steps.
15
+ </p>
16
+
17
+ <div className="flex items-center justify-between rounded-[14px] border border-white/[0.06] bg-white/[0.03] px-4 py-3">
18
+ <div className="pr-4">
19
+ <label className="text-[12px] font-600 text-text-2 block">Wallet Approvals</label>
20
+ <p className="text-[11px] text-text-3/60 mt-0.5">
21
+ When disabled, wallet actions bypass approval gates globally. Per-wallet approval toggles remain stored, but they are ignored until this is turned back on.
22
+ </p>
23
+ </div>
24
+ <button
25
+ type="button"
26
+ onClick={() => patchSettings({ walletApprovalsEnabled: !walletApprovalsEnabled })}
27
+ className={`relative w-9 h-5 rounded-full transition-colors ${walletApprovalsEnabled ? 'bg-accent-bright' : 'bg-white/[0.10]'}`}
28
+ style={{ fontFamily: 'inherit' }}
29
+ >
30
+ <span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${walletApprovalsEnabled ? 'translate-x-4' : ''}`} />
31
+ </button>
32
+ </div>
33
+ </div>
34
+ )
35
+ }