@swarmclawai/swarmclaw 0.9.5 → 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/chat-execution/stream-agent-chat.test.ts +42 -0
- package/src/lib/server/chat-execution/stream-agent-chat.ts +30 -1
- package/src/lib/server/chat-execution/stream-continuation.ts +46 -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 +2 -1
- package/src/views/settings/section-wallets.tsx +35 -0
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
|
|
|
155
155
|
The installer resolves the latest stable release tag and installs that version by default.
|
|
156
156
|
It also builds the production bundle so `npm run start` is ready immediately after install.
|
|
157
157
|
No Deno install is required; local sandbox execution is Docker-first with automatic host Node fallback.
|
|
158
|
-
To pin a version: `SWARMCLAW_VERSION=v0.9.
|
|
158
|
+
To pin a version: `SWARMCLAW_VERSION=v0.9.6 curl ... | bash`
|
|
159
159
|
|
|
160
160
|
Or run locally from the repo (friendly for non-technical users):
|
|
161
161
|
|
|
@@ -729,15 +729,15 @@ On `v*` tags, GitHub Actions will:
|
|
|
729
729
|
2. Create a GitHub Release
|
|
730
730
|
3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
|
|
731
731
|
|
|
732
|
-
#### v0.9.
|
|
732
|
+
#### v0.9.6 Release Readiness Notes
|
|
733
733
|
|
|
734
|
-
Before shipping `v0.9.
|
|
734
|
+
Before shipping `v0.9.6`, confirm the following user-facing changes are reflected in docs:
|
|
735
735
|
|
|
736
|
-
1.
|
|
737
|
-
2.
|
|
738
|
-
3. Skills docs still explain that local skills are discoverable by default, pinned skills stay always-on, and `use_skill` is the runtime path for selection/loading/dispatch.
|
|
739
|
-
4. Site and README install/version strings are updated to `v0.9.
|
|
740
|
-
5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.
|
|
736
|
+
1. Wallet docs explain the new global wallet approval override in Settings/Wallets, and note that per-wallet approval toggles remain stored but are ignored when the global switch is off.
|
|
737
|
+
2. Runtime/autonomy docs mention the continuation hardening for long-running tasks: intent-only kickoff replies now get one bounded followthrough, and chat-originated progress runs can schedule a bounded main-loop continuation without waiting for another user ping.
|
|
738
|
+
3. Skills/runtime docs still explain that local skills are discoverable by default, pinned skills stay always-on, and `use_skill` is the runtime path for selection/loading/dispatch.
|
|
739
|
+
4. Site and README install/version strings are updated to `v0.9.6`, including release notes index text and any pinned install snippets.
|
|
740
|
+
5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.6`) versus the git tag (`v0.9.6`).
|
|
741
741
|
|
|
742
742
|
## CLI
|
|
743
743
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -129,6 +129,7 @@ export async function PUT(req: Request) {
|
|
|
129
129
|
settings.taskQualityGateRequireReport = parseBoolSetting(settings.taskQualityGateRequireReport, false)
|
|
130
130
|
settings.taskManagementEnabled = parseBoolSetting(settings.taskManagementEnabled, true)
|
|
131
131
|
settings.projectManagementEnabled = parseBoolSetting(settings.projectManagementEnabled, true)
|
|
132
|
+
settings.walletApprovalsEnabled = parseBoolSetting(settings.walletApprovalsEnabled, true)
|
|
132
133
|
settings.integrityMonitorEnabled = parseBoolSetting(settings.integrityMonitorEnabled, true)
|
|
133
134
|
settings.daemonAutostartEnabled = parseBoolSetting(settings.daemonAutostartEnabled, true)
|
|
134
135
|
settings.sessionResetMode = settings.sessionResetMode === 'daily' ? 'daily' : settings.sessionResetMode === 'idle' ? 'idle' : null
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { genId } from '@/lib/id'
|
|
3
|
-
import { loadWallets, upsertWalletTransaction } from '@/lib/server/storage'
|
|
3
|
+
import { loadSettings, loadWallets, upsertWalletTransaction } from '@/lib/server/storage'
|
|
4
4
|
import { notify } from '@/lib/server/ws-hub'
|
|
5
5
|
import type { AgentWallet, WalletTransaction } from '@/types'
|
|
6
6
|
import {
|
|
7
7
|
normalizeAtomicString,
|
|
8
8
|
} from '@/lib/wallet/wallet'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
isValidWalletAddress,
|
|
11
|
+
sendWalletNativeAsset,
|
|
12
|
+
validateWalletSendLimits,
|
|
13
|
+
walletRequiresApproval,
|
|
14
|
+
} from '@/lib/server/wallet/wallet-service'
|
|
10
15
|
import { errorMessage } from '@/lib/shared-utils'
|
|
11
16
|
export const dynamic = 'force-dynamic'
|
|
12
17
|
|
|
@@ -15,6 +20,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
15
20
|
const wallets = loadWallets() as Record<string, AgentWallet>
|
|
16
21
|
const wallet = wallets[id]
|
|
17
22
|
if (!wallet) return NextResponse.json({ error: 'Wallet not found' }, { status: 404 })
|
|
23
|
+
const settings = loadSettings()
|
|
18
24
|
|
|
19
25
|
const body = await req.json()
|
|
20
26
|
const toAddress = typeof body.toAddress === 'string' ? body.toAddress.trim() : ''
|
|
@@ -32,8 +38,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
32
38
|
const txId = genId(8)
|
|
33
39
|
const now = Date.now()
|
|
34
40
|
|
|
35
|
-
//
|
|
36
|
-
if (wallet
|
|
41
|
+
// When approvals are enabled globally and for this wallet, create a pending request instead of sending.
|
|
42
|
+
if (walletRequiresApproval(wallet, settings)) {
|
|
37
43
|
const pendingTx: WalletTransaction = {
|
|
38
44
|
id: txId,
|
|
39
45
|
walletId: id,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { loadAgents, loadWallets } from '@/lib/server/storage'
|
|
2
|
+
import { loadAgents, loadSettings, loadWallets } from '@/lib/server/storage'
|
|
3
3
|
import { createAgentWallet, getAgentActiveWalletId, getWalletPortfolioSnapshot, stripWalletPrivateKey } from '@/lib/server/wallet/wallet-service'
|
|
4
4
|
import { buildEmptyWalletPortfolio } from '@/lib/server/wallet/wallet-portfolio'
|
|
5
5
|
import type { AgentWallet, WalletPortfolioSummary } from '@/types'
|
|
@@ -62,13 +62,16 @@ export async function GET(req: Request) {
|
|
|
62
62
|
|
|
63
63
|
export async function POST(req: Request) {
|
|
64
64
|
const body = await req.json()
|
|
65
|
+
const settings = loadSettings()
|
|
65
66
|
try {
|
|
66
67
|
const wallet = createAgentWallet({
|
|
67
68
|
agentId: body.agentId,
|
|
68
69
|
chain: body.chain,
|
|
69
70
|
provider: body.provider,
|
|
70
71
|
label: body.label,
|
|
71
|
-
requireApproval: body.requireApproval
|
|
72
|
+
requireApproval: typeof body.requireApproval === 'boolean'
|
|
73
|
+
? body.requireApproval
|
|
74
|
+
: settings.walletApprovalsEnabled !== false,
|
|
72
75
|
spendingLimitAtomic: body.spendingLimitAtomic ?? body.spendingLimitLamports,
|
|
73
76
|
dailyLimitAtomic: body.dailyLimitAtomic ?? body.dailyLimitLamports,
|
|
74
77
|
})
|
|
@@ -11,6 +11,7 @@ import { ThemeSection } from '@/views/settings/section-theme'
|
|
|
11
11
|
import { OrchestratorSection } from '@/views/settings/section-orchestrator'
|
|
12
12
|
import { RuntimeLoopSection } from '@/views/settings/section-runtime-loop'
|
|
13
13
|
import { CapabilityPolicySection } from '@/views/settings/section-capability-policy'
|
|
14
|
+
import { WalletsSection } from '@/views/settings/section-wallets'
|
|
14
15
|
import { StorageSection } from '@/views/settings/section-storage'
|
|
15
16
|
import { VoiceSection } from '@/views/settings/section-voice'
|
|
16
17
|
import { WebSearchSection } from '@/views/settings/section-web-search'
|
|
@@ -147,6 +148,14 @@ export default function SettingsRoute() {
|
|
|
147
148
|
keywords: ['storage', 'uploads', 'disk', 'cleanup', 'files'],
|
|
148
149
|
render: () => <StorageSection {...sectionProps} />,
|
|
149
150
|
},
|
|
151
|
+
{
|
|
152
|
+
id: 'wallets',
|
|
153
|
+
tabId: 'general',
|
|
154
|
+
title: 'Wallets',
|
|
155
|
+
description: 'Control global wallet approval behavior and auto-execution defaults.',
|
|
156
|
+
keywords: ['wallet', 'wallets', 'approval', 'approvals', 'crypto', 'send'],
|
|
157
|
+
render: () => <WalletsSection {...sectionProps} />,
|
|
158
|
+
},
|
|
150
159
|
{
|
|
151
160
|
id: 'theme',
|
|
152
161
|
tabId: 'appearance',
|
|
@@ -114,6 +114,7 @@ function suggestCreateChain(wallets: SafeWallet[], agentId?: string | null): Wal
|
|
|
114
114
|
|
|
115
115
|
export function WalletPanel() {
|
|
116
116
|
const agents = useAppStore((s) => s.agents)
|
|
117
|
+
const appSettings = useAppStore((s) => s.appSettings)
|
|
117
118
|
const walletPanelAgentId = useAppStore((s) => s.walletPanelAgentId)
|
|
118
119
|
const setWalletPanelAgentId = useAppStore((s) => s.setWalletPanelAgentId)
|
|
119
120
|
const navigateTo = useNavigate()
|
|
@@ -354,6 +355,7 @@ export function WalletPanel() {
|
|
|
354
355
|
})
|
|
355
356
|
const selectedWalletMeta = selectedWallet ? getWalletChainMeta(selectedWallet.chain) : null
|
|
356
357
|
const selectedWalletSymbol = selectedWallet ? getWalletAssetSymbol(selectedWallet.chain) : null
|
|
358
|
+
const walletApprovalsEnabled = appSettings.walletApprovalsEnabled !== false
|
|
357
359
|
const selectedWalletBalance = selectedWallet ? walletBalanceLabel(selectedWallet) : null
|
|
358
360
|
const selectedWalletAssets = (selectedWallet?.assets || []).filter((asset) => BigInt(asset.balanceAtomic) > BigInt(0))
|
|
359
361
|
const selectedAgent = selectedWallet ? agents[selectedWallet.agentId] as Agent | undefined : undefined
|
|
@@ -807,6 +809,11 @@ export function WalletPanel() {
|
|
|
807
809
|
</button>
|
|
808
810
|
<span className="text-[11px] text-text-3">Require approval for sends</span>
|
|
809
811
|
</div>
|
|
812
|
+
{!walletApprovalsEnabled && (
|
|
813
|
+
<p className="text-[10px] text-amber-300/80">
|
|
814
|
+
Global wallet approvals are currently off in Settings, so this per-wallet toggle is ignored until they are turned back on.
|
|
815
|
+
</p>
|
|
816
|
+
)}
|
|
810
817
|
<div className="flex gap-2 pt-1">
|
|
811
818
|
<button
|
|
812
819
|
type="button"
|
|
@@ -839,7 +846,11 @@ export function WalletPanel() {
|
|
|
839
846
|
</div>
|
|
840
847
|
<div className="flex justify-between">
|
|
841
848
|
<span className="text-text-3/70">Approval</span>
|
|
842
|
-
<span className="text-text-2">
|
|
849
|
+
<span className="text-text-2">
|
|
850
|
+
{!walletApprovalsEnabled
|
|
851
|
+
? 'Disabled globally'
|
|
852
|
+
: (selectedWallet.requireApproval ? 'Required' : 'Auto-send')}
|
|
853
|
+
</span>
|
|
843
854
|
</div>
|
|
844
855
|
</div>
|
|
845
856
|
)}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
4
|
import { api } from '@/lib/app/api-client'
|
|
5
5
|
import { copyTextToClipboard } from '@/lib/clipboard'
|
|
6
|
+
import { useAppStore } from '@/stores/use-app-store'
|
|
6
7
|
import type { AgentWallet, WalletAssetBalance, WalletPortfolioSummary, WalletChain } from '@/types'
|
|
7
8
|
import { toast } from 'sonner'
|
|
8
9
|
import { errorMessage } from '@/lib/shared-utils'
|
|
@@ -32,6 +33,7 @@ interface WalletSectionProps {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export function WalletSection({ agentId, wallets, activeWalletId, onWalletCreated }: WalletSectionProps) {
|
|
36
|
+
const appSettings = useAppStore((s) => s.appSettings)
|
|
35
37
|
const [creating, setCreating] = useState(false)
|
|
36
38
|
const [activatingWalletId, setActivatingWalletId] = useState<string | null>(null)
|
|
37
39
|
const [error, setError] = useState<string | null>(null)
|
|
@@ -53,6 +55,7 @@ export function WalletSection({ agentId, wallets, activeWalletId, onWalletCreate
|
|
|
53
55
|
)
|
|
54
56
|
|
|
55
57
|
const [chain, setChain] = useState<WalletChain>(availableChains[0] || 'solana')
|
|
58
|
+
const walletApprovalsEnabled = appSettings.walletApprovalsEnabled !== false
|
|
56
59
|
|
|
57
60
|
useEffect(() => {
|
|
58
61
|
if (availableChains.length === 0) return
|
|
@@ -204,7 +207,7 @@ export function WalletSection({ agentId, wallets, activeWalletId, onWalletCreate
|
|
|
204
207
|
<div className="flex flex-wrap items-center gap-3 text-[10px] text-text-3/60">
|
|
205
208
|
<span>Limit: {perTxLimit} {walletMeta.symbol}/tx</span>
|
|
206
209
|
<span>Daily: {dailyLimit} {walletMeta.symbol}</span>
|
|
207
|
-
<span>{wallet.requireApproval ? 'Approval required' : 'Auto-send'}</span>
|
|
210
|
+
<span>{!walletApprovalsEnabled ? 'Approvals off globally' : (wallet.requireApproval ? 'Approval required' : 'Auto-send')}</span>
|
|
208
211
|
{wallet.portfolioSummary?.nonZeroAssets ? (
|
|
209
212
|
<span>{wallet.portfolioSummary.nonZeroAssets} asset{wallet.portfolioSummary.nonZeroAssets === 1 ? '' : 's'} detected</span>
|
|
210
213
|
) : null}
|
|
@@ -195,6 +195,41 @@ describe('main-agent-loop advanced', () => {
|
|
|
195
195
|
assert.equal(output.followupOk, null, 'no followup on terminal ack')
|
|
196
196
|
})
|
|
197
197
|
|
|
198
|
+
it('allows a bounded followup for chat-originated runs when structured progress is still active', () => {
|
|
199
|
+
const progressMeta = heartbeatMetaLine('progress', 'buy nft', 'prepare the first safe wallet step')
|
|
200
|
+
const output = runWithTempDataDir(`
|
|
201
|
+
${sessionSetupScript()}
|
|
202
|
+
|
|
203
|
+
const followup = mainLoop.handleMainLoopRunResult({
|
|
204
|
+
sessionId: 'main',
|
|
205
|
+
message: 'Try buy one NFT and show me what happened.',
|
|
206
|
+
internal: false,
|
|
207
|
+
source: 'chat',
|
|
208
|
+
resultText: \`I found the contract and I am moving to the first safe execution step.\\n${progressMeta}\`,
|
|
209
|
+
toolEvents: [{
|
|
210
|
+
name: 'wallet_tool',
|
|
211
|
+
input: '{"action":"balance"}',
|
|
212
|
+
output: '{"status":"ok"}',
|
|
213
|
+
}],
|
|
214
|
+
})
|
|
215
|
+
const state = mainLoop.getMainLoopStateForSession('main')
|
|
216
|
+
|
|
217
|
+
console.log(JSON.stringify({
|
|
218
|
+
hasFollowup: followup !== null,
|
|
219
|
+
followupMessage: followup?.message ?? null,
|
|
220
|
+
chain: state?.followupChainCount ?? -1,
|
|
221
|
+
status: state?.status ?? null,
|
|
222
|
+
nextAction: state?.nextAction ?? null,
|
|
223
|
+
}))
|
|
224
|
+
`)
|
|
225
|
+
|
|
226
|
+
assert.equal(output.hasFollowup, true, 'chat run should queue one bounded followup')
|
|
227
|
+
assert.equal(output.chain, 1, 'chat followup starts the chain at 1')
|
|
228
|
+
assert.equal(output.status, 'progress')
|
|
229
|
+
assert.equal(output.nextAction, 'prepare the first safe wallet step')
|
|
230
|
+
assert.match(String(output.followupMessage || ''), /Resume from this next action/)
|
|
231
|
+
})
|
|
232
|
+
|
|
198
233
|
it('persists and upgrades a skill blocker across recommend/install steps', () => {
|
|
199
234
|
const output = runWithTempDataDir(`
|
|
200
235
|
${sessionSetupScript()}
|
|
@@ -894,9 +894,20 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
|
|
|
894
894
|
|
|
895
895
|
const needsReplan = review?.needs_replan === true || ((review?.confidence ?? 1) < 0.45)
|
|
896
896
|
const limit = followupLimit()
|
|
897
|
+
const allowChatOriginFollowup = !input.internal
|
|
898
|
+
&& input.source === 'chat'
|
|
899
|
+
&& !input.error
|
|
900
|
+
&& !waitingForExternal
|
|
901
|
+
&& !gotTerminalAck
|
|
902
|
+
&& (
|
|
903
|
+
needsReplan
|
|
904
|
+
|| heartbeat?.status === 'progress'
|
|
905
|
+
|| !!heartbeat?.nextAction
|
|
906
|
+
|| (!!plan?.current_step && toolNames.length > 0)
|
|
907
|
+
)
|
|
897
908
|
|
|
898
909
|
let followup: MainLoopFollowupRequest | null = null
|
|
899
|
-
if (!input.internal
|
|
910
|
+
if (!input.internal && !allowChatOriginFollowup) {
|
|
900
911
|
state.followupChainCount = 0
|
|
901
912
|
} else if (input.error || waitingForExternal || gotTerminalAck) {
|
|
902
913
|
state.followupChainCount = 0
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveFinalStreamResponseText,
|
|
15
15
|
shouldSkipToolSummaryForShortResponse,
|
|
16
16
|
shouldForceAttachmentFollowthrough,
|
|
17
|
+
shouldForceExternalExecutionKickoffFollowthrough,
|
|
17
18
|
shouldForceRecoverableToolErrorFollowthrough,
|
|
18
19
|
shouldTerminateOnSuccessfulMemoryMutation,
|
|
19
20
|
shouldForceDeliverableFollowthrough,
|
|
@@ -209,6 +210,12 @@ describe('buildToolDisciplineLines', () => {
|
|
|
209
210
|
assert.ok(streamAgentChatSource.includes('did not start the required workspace tool step'))
|
|
210
211
|
})
|
|
211
212
|
|
|
213
|
+
it('wires a bounded execution-kickoff continuation for intent-only live task replies', () => {
|
|
214
|
+
assert.ok(streamSources.includes('execution_kickoff_followthrough'))
|
|
215
|
+
assert.ok(streamAgentChatSource.includes('shouldForceExternalExecutionKickoffFollowthrough'))
|
|
216
|
+
assert.ok(streamAgentChatSource.includes('externalExecutionKickoff'))
|
|
217
|
+
})
|
|
218
|
+
|
|
212
219
|
it('adds current-thread recall guidance and immediate memory routes in the system prompt', () => {
|
|
213
220
|
assert.ok(streamAgentChatSource.includes('## Current Thread Recall'))
|
|
214
221
|
assert.ok(streamAgentChatSource.includes('## Immediate Memory Routes'))
|
|
@@ -624,6 +631,41 @@ describe('shouldForceExternalExecutionFollowthrough', () => {
|
|
|
624
631
|
})
|
|
625
632
|
})
|
|
626
633
|
|
|
634
|
+
describe('shouldForceExternalExecutionKickoffFollowthrough', () => {
|
|
635
|
+
it('forces a bounded continuation when an execution task stops at an intent-only kickoff', () => {
|
|
636
|
+
assert.equal(
|
|
637
|
+
shouldForceExternalExecutionKickoffFollowthrough({
|
|
638
|
+
userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
|
|
639
|
+
finalResponse: 'Let me try to interact directly with the NFT contract and see if I can mint one:',
|
|
640
|
+
hasToolCalls: false,
|
|
641
|
+
toolEvents: [],
|
|
642
|
+
}),
|
|
643
|
+
true,
|
|
644
|
+
)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('does not force kickoff when the model already surfaced a real blocker or asked a blocking question', () => {
|
|
648
|
+
assert.equal(
|
|
649
|
+
shouldForceExternalExecutionKickoffFollowthrough({
|
|
650
|
+
userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
|
|
651
|
+
finalResponse: 'Exact blocker: this wallet cannot complete the required signature in the current runtime.',
|
|
652
|
+
hasToolCalls: false,
|
|
653
|
+
toolEvents: [],
|
|
654
|
+
}),
|
|
655
|
+
false,
|
|
656
|
+
)
|
|
657
|
+
assert.equal(
|
|
658
|
+
shouldForceExternalExecutionKickoffFollowthrough({
|
|
659
|
+
userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
|
|
660
|
+
finalResponse: 'Which collection do you want me to target?',
|
|
661
|
+
hasToolCalls: false,
|
|
662
|
+
toolEvents: [],
|
|
663
|
+
}),
|
|
664
|
+
false,
|
|
665
|
+
)
|
|
666
|
+
})
|
|
667
|
+
})
|
|
668
|
+
|
|
627
669
|
describe('shouldForceAttachmentFollowthrough', () => {
|
|
628
670
|
it('forces a retry for attachment-backed research turns that still skipped tools', () => {
|
|
629
671
|
assert.equal(
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
looksLikeBoundedExternalExecutionTask,
|
|
39
39
|
looksLikeOpenEndedDeliverableTask,
|
|
40
40
|
shouldForceRecoverableToolErrorFollowthrough,
|
|
41
|
+
shouldForceExternalExecutionKickoffFollowthrough,
|
|
41
42
|
shouldForceExternalExecutionFollowthrough,
|
|
42
43
|
shouldForceDeliverableFollowthrough,
|
|
43
44
|
hasStateChangingWalletEvidence,
|
|
@@ -88,6 +89,7 @@ export {
|
|
|
88
89
|
getExplicitRequiredToolNames,
|
|
89
90
|
isWalletSimulationResult,
|
|
90
91
|
looksLikeOpenEndedDeliverableTask,
|
|
92
|
+
shouldForceExternalExecutionKickoffFollowthrough,
|
|
91
93
|
shouldForceRecoverableToolErrorFollowthrough,
|
|
92
94
|
shouldForceExternalExecutionFollowthrough,
|
|
93
95
|
shouldForceDeliverableFollowthrough,
|
|
@@ -1181,6 +1183,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1181
1183
|
const MAX_TRANSIENT_RETRIES = 3
|
|
1182
1184
|
const MAX_REQUIRED_TOOL_CONTINUES = 2
|
|
1183
1185
|
let MAX_EXECUTION_FOLLOWTHROUGHS = 1
|
|
1186
|
+
const MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS = 1
|
|
1184
1187
|
let MAX_ATTACHMENT_FOLLOWTHROUGHS = 1
|
|
1185
1188
|
let MAX_DELIVERABLE_FOLLOWTHROUGHS = 2
|
|
1186
1189
|
let MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS = 2
|
|
@@ -1202,6 +1205,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1202
1205
|
let pendingRetryAfterMs: number | null = null
|
|
1203
1206
|
let requiredToolContinueCount = 0
|
|
1204
1207
|
let executionFollowthroughCount = 0
|
|
1208
|
+
let executionKickoffFollowthroughCount = 0
|
|
1205
1209
|
let attachmentFollowthroughCount = 0
|
|
1206
1210
|
let deliverableFollowthroughCount = 0
|
|
1207
1211
|
let unfinishedToolFollowthroughCount = 0
|
|
@@ -1215,7 +1219,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1215
1219
|
let terminalToolResponse = ''
|
|
1216
1220
|
|
|
1217
1221
|
try {
|
|
1218
|
-
const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES + MAX_EXECUTION_FOLLOWTHROUGHS + MAX_DELIVERABLE_FOLLOWTHROUGHS + MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS + MAX_TOOL_ERROR_FOLLOWTHROUGHS + MAX_TOOL_SUMMARY_RETRIES
|
|
1222
|
+
const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES + MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS + MAX_EXECUTION_FOLLOWTHROUGHS + MAX_DELIVERABLE_FOLLOWTHROUGHS + MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS + MAX_TOOL_ERROR_FOLLOWTHROUGHS + MAX_TOOL_SUMMARY_RETRIES
|
|
1219
1223
|
for (let iteration = 0; iteration <= maxIterations; iteration++) {
|
|
1220
1224
|
let shouldContinue: ContinuationType = false
|
|
1221
1225
|
let requiredToolReminderNames: string[] = []
|
|
@@ -1766,6 +1770,31 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1766
1770
|
})}\n\n`)
|
|
1767
1771
|
}
|
|
1768
1772
|
|
|
1773
|
+
if (!shouldContinue
|
|
1774
|
+
&& executionKickoffFollowthroughCount < MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS
|
|
1775
|
+
&& shouldForceExternalExecutionKickoffFollowthrough({
|
|
1776
|
+
userMessage: message,
|
|
1777
|
+
finalResponse: resolveFinalStreamResponseText({
|
|
1778
|
+
fullText,
|
|
1779
|
+
lastSegment,
|
|
1780
|
+
lastSettledSegment,
|
|
1781
|
+
hasToolCalls,
|
|
1782
|
+
toolEvents: streamedToolEvents,
|
|
1783
|
+
}),
|
|
1784
|
+
hasToolCalls,
|
|
1785
|
+
toolEvents: streamedToolEvents,
|
|
1786
|
+
})) {
|
|
1787
|
+
shouldContinue = 'execution_kickoff_followthrough'
|
|
1788
|
+
executionKickoffFollowthroughCount++
|
|
1789
|
+
write(`data: ${JSON.stringify({
|
|
1790
|
+
t: 'status',
|
|
1791
|
+
text: JSON.stringify({
|
|
1792
|
+
externalExecutionKickoff: executionKickoffFollowthroughCount,
|
|
1793
|
+
maxFollowthroughs: MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS,
|
|
1794
|
+
}),
|
|
1795
|
+
})}\n\n`)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1769
1798
|
if (!shouldContinue
|
|
1770
1799
|
&& executionFollowthroughCount < MAX_EXECUTION_FOLLOWTHROUGHS
|
|
1771
1800
|
&& shouldForceExternalExecutionFollowthrough({
|
|
@@ -20,6 +20,7 @@ export type ContinuationType =
|
|
|
20
20
|
| 'transient'
|
|
21
21
|
| 'required_tool'
|
|
22
22
|
| 'attachment_followthrough'
|
|
23
|
+
| 'execution_kickoff_followthrough'
|
|
23
24
|
| 'execution_followthrough'
|
|
24
25
|
| 'deliverable_followthrough'
|
|
25
26
|
| 'unfinished_tool_followthrough'
|
|
@@ -237,6 +238,28 @@ export function shouldForceExternalExecutionFollowthrough(params: {
|
|
|
237
238
|
return /(let me|i'll|i will|trying|research|query|check|look|promising|now let me|good -|good,)/i.test(trimmed) || trimmed.length < 500
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
export function shouldForceExternalExecutionKickoffFollowthrough(params: {
|
|
242
|
+
userMessage: string
|
|
243
|
+
finalResponse: string
|
|
244
|
+
hasToolCalls: boolean
|
|
245
|
+
toolEvents: MessageToolEvent[]
|
|
246
|
+
}): boolean {
|
|
247
|
+
if (!looksLikeBoundedExternalExecutionTask(params.userMessage)) return false
|
|
248
|
+
if (params.hasToolCalls || params.toolEvents.length > 0) return false
|
|
249
|
+
|
|
250
|
+
const trimmed = params.finalResponse.trim()
|
|
251
|
+
if (!trimmed) return true
|
|
252
|
+
if (/^(?:HEARTBEAT_OK|NO_MESSAGE)\b/i.test(trimmed)) return false
|
|
253
|
+
if (/\?\s*$/.test(trimmed)) return false
|
|
254
|
+
if (/\b(last reversible step|exact blocker|blocked|cannot|can't|missing capability|need approval|requires approval|approval boundary|requires human|ask_human|credential|authentication|login|2fa|mfa|captcha)\b/i.test(trimmed)) {
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
if (/\b(done|completed|finished|sent|broadcast|minted|purchased|bought|swapped|claimed)\b/i.test(trimmed)) {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
return looksLikeIncompleteDeliverableResponse(trimmed) || trimmed.length < 220
|
|
261
|
+
}
|
|
262
|
+
|
|
240
263
|
export function shouldForceDeliverableFollowthrough(params: {
|
|
241
264
|
userMessage: string
|
|
242
265
|
finalResponse: string
|
|
@@ -360,6 +383,23 @@ function buildExternalExecutionFollowthroughPrompt(params: {
|
|
|
360
383
|
].join('\n')
|
|
361
384
|
}
|
|
362
385
|
|
|
386
|
+
function buildExternalExecutionKickoffPrompt(params: {
|
|
387
|
+
userMessage: string
|
|
388
|
+
fullText: string
|
|
389
|
+
}): string {
|
|
390
|
+
return [
|
|
391
|
+
'The previous iteration stopped after an intent update before taking the first concrete execution step.',
|
|
392
|
+
'Do not send another preamble like "let me check", "I will try", or "I\'m going to".',
|
|
393
|
+
'Continue immediately from the same objective and take the first concrete reversible step now using the available tools.',
|
|
394
|
+
'If a real blocker appears before any safe action, state the exact blocker with evidence instead of narrating your plan.',
|
|
395
|
+
'Do not ask the user to repeat the task. Either act now or report the blocker.',
|
|
396
|
+
'',
|
|
397
|
+
`Objective:\n${params.userMessage}`,
|
|
398
|
+
'',
|
|
399
|
+
`Previous response:\n${params.fullText || '(none)'}`,
|
|
400
|
+
].join('\n')
|
|
401
|
+
}
|
|
402
|
+
|
|
363
403
|
function buildDeliverableFollowthroughPrompt(params: {
|
|
364
404
|
userMessage: string
|
|
365
405
|
fullText: string
|
|
@@ -578,6 +618,12 @@ export function buildContinuationPrompt(params: {
|
|
|
578
618
|
fullText: params.fullText,
|
|
579
619
|
})
|
|
580
620
|
|
|
621
|
+
case 'execution_kickoff_followthrough':
|
|
622
|
+
return buildExternalExecutionKickoffPrompt({
|
|
623
|
+
userMessage: params.message,
|
|
624
|
+
fullText: params.fullText,
|
|
625
|
+
})
|
|
626
|
+
|
|
581
627
|
case 'execution_followthrough':
|
|
582
628
|
return buildExternalExecutionFollowthroughPrompt({
|
|
583
629
|
userMessage: params.message,
|
|
@@ -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
|
@@ -904,7 +904,7 @@ export interface AgentWallet {
|
|
|
904
904
|
spendingLimitLamports?: number
|
|
905
905
|
/** @deprecated Use dailyLimitAtomic */
|
|
906
906
|
dailyLimitLamports?: number
|
|
907
|
-
requireApproval: boolean // default true
|
|
907
|
+
requireApproval: boolean // default true; can be globally overridden by app settings
|
|
908
908
|
createdAt: number
|
|
909
909
|
updatedAt: number
|
|
910
910
|
}
|
|
@@ -1457,6 +1457,7 @@ export interface AppSettings {
|
|
|
1457
1457
|
safetyRequireApprovalForOutbound?: boolean
|
|
1458
1458
|
safetyMaxDailySpendUsd?: number | null
|
|
1459
1459
|
safetyBlockedTools?: string[]
|
|
1460
|
+
walletApprovalsEnabled?: boolean
|
|
1460
1461
|
capabilityPolicyMode?: 'permissive' | 'balanced' | 'strict'
|
|
1461
1462
|
capabilityBlockedTools?: string[]
|
|
1462
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
|
+
}
|