@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.
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/app/api/settings/route.ts +1 -0
- package/src/app/api/wallets/[id]/send/route.ts +10 -4
- package/src/app/api/wallets/route.ts +5 -2
- package/src/app/settings/page.tsx +9 -0
- package/src/components/wallets/wallet-panel.tsx +12 -1
- package/src/components/wallets/wallet-section.tsx +4 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
- package/src/lib/server/agents/main-agent-loop.ts +12 -1
- package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
- package/src/lib/server/agents/subagent-runtime.ts +115 -19
- package/src/lib/server/agents/subagent-swarm.ts +3 -3
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
- package/src/lib/server/chat-execution/chat-execution.ts +357 -152
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
- package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
- package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
- package/src/lib/server/connectors/contact-boundaries.ts +70 -8
- package/src/lib/server/connectors/manager.test.ts +129 -7
- package/src/lib/server/plugins.test.ts +263 -0
- package/src/lib/server/plugins.ts +406 -10
- package/src/lib/server/session-tools/context.ts +15 -1
- package/src/lib/server/session-tools/index.ts +42 -6
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
- package/src/lib/server/session-tools/subagent.ts +3 -3
- package/src/lib/server/tool-loop-detection.test.ts +21 -0
- package/src/lib/server/tool-loop-detection.ts +79 -0
- package/src/lib/server/wallet/wallet-service.test.ts +25 -1
- package/src/lib/server/wallet/wallet-service.ts +13 -0
- package/src/types/index.ts +134 -1
- 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 {
|
|
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
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
}
|