@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
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. Runtime
|
|
738
|
-
3.
|
|
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
|
|
@@ -15,6 +15,10 @@ let runtime: typeof import('@/lib/server/agents/subagent-runtime')
|
|
|
15
15
|
let lineage: typeof import('@/lib/server/agents/subagent-lineage')
|
|
16
16
|
let delegationJobs: typeof import('@/lib/server/agents/delegation-jobs')
|
|
17
17
|
let storage: typeof import('@/lib/server/storage')
|
|
18
|
+
let pluginManager: {
|
|
19
|
+
registerBuiltin: (id: string, plugin: Record<string, unknown>) => void
|
|
20
|
+
}
|
|
21
|
+
let providers: Record<string, unknown>
|
|
18
22
|
|
|
19
23
|
before(async () => {
|
|
20
24
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-subagent-runtime-'))
|
|
@@ -26,6 +30,9 @@ before(async () => {
|
|
|
26
30
|
delegationJobs = await import('@/lib/server/agents/delegation-jobs')
|
|
27
31
|
lineage = await import('@/lib/server/agents/subagent-lineage')
|
|
28
32
|
runtime = await import('@/lib/server/agents/subagent-runtime')
|
|
33
|
+
pluginManager = (await import('@/lib/server/plugins')).getPluginManager()
|
|
34
|
+
const providersMod = await import('@/lib/providers/index')
|
|
35
|
+
providers = providersMod.PROVIDERS
|
|
29
36
|
})
|
|
30
37
|
|
|
31
38
|
after(() => {
|
|
@@ -57,9 +64,9 @@ describe('subagent-runtime', () => {
|
|
|
57
64
|
})
|
|
58
65
|
|
|
59
66
|
describe('spawnSubagent', () => {
|
|
60
|
-
it('throws for unknown agent', () => {
|
|
67
|
+
it('throws for unknown agent', async () => {
|
|
61
68
|
lineage._clearLineage()
|
|
62
|
-
assert.
|
|
69
|
+
await assert.rejects(
|
|
63
70
|
() => runtime.spawnSubagent(
|
|
64
71
|
{ agentId: 'nonexistent', message: 'hello' },
|
|
65
72
|
{ cwd: tempDir },
|
|
@@ -68,7 +75,7 @@ describe('subagent-runtime', () => {
|
|
|
68
75
|
)
|
|
69
76
|
})
|
|
70
77
|
|
|
71
|
-
it('throws when max depth is exceeded', () => {
|
|
78
|
+
it('throws when max depth is exceeded', async () => {
|
|
72
79
|
lineage._clearLineage()
|
|
73
80
|
seedAgent('depth-agent', 'Depth Agent')
|
|
74
81
|
|
|
@@ -80,7 +87,7 @@ describe('subagent-runtime', () => {
|
|
|
80
87
|
sessions['depth-s3'] = { id: 'depth-s3', parentSessionId: 'depth-s2', cwd: tempDir }
|
|
81
88
|
storage.saveSessions(sessions)
|
|
82
89
|
|
|
83
|
-
assert.
|
|
90
|
+
await assert.rejects(
|
|
84
91
|
() => runtime.spawnSubagent(
|
|
85
92
|
{ agentId: 'depth-agent', message: 'too deep' },
|
|
86
93
|
{ sessionId: 'depth-s3', cwd: tempDir },
|
|
@@ -89,13 +96,13 @@ describe('subagent-runtime', () => {
|
|
|
89
96
|
)
|
|
90
97
|
})
|
|
91
98
|
|
|
92
|
-
it('creates session, lineage node, and delegation job', () => {
|
|
99
|
+
it('creates session, lineage node, and delegation job', async () => {
|
|
93
100
|
lineage._clearLineage()
|
|
94
101
|
seedAgent('spawn-agent', 'Spawn Agent')
|
|
95
102
|
|
|
96
|
-
let handle: ReturnType<typeof runtime.spawnSubagent
|
|
103
|
+
let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
|
|
97
104
|
try {
|
|
98
|
-
handle = runtime.spawnSubagent(
|
|
105
|
+
handle = await runtime.spawnSubagent(
|
|
99
106
|
{ agentId: 'spawn-agent', message: 'test task', waitForCompletion: false },
|
|
100
107
|
{ sessionId: undefined, cwd: tempDir },
|
|
101
108
|
)
|
|
@@ -132,7 +139,7 @@ describe('subagent-runtime', () => {
|
|
|
132
139
|
}
|
|
133
140
|
})
|
|
134
141
|
|
|
135
|
-
it('tracks parent-child lineage correctly', () => {
|
|
142
|
+
it('tracks parent-child lineage correctly', async () => {
|
|
136
143
|
lineage._clearLineage()
|
|
137
144
|
seedAgent('parent-agent', 'Parent')
|
|
138
145
|
seedAgent('child-agent', 'Child')
|
|
@@ -155,9 +162,9 @@ describe('subagent-runtime', () => {
|
|
|
155
162
|
task: 'Parent task',
|
|
156
163
|
})
|
|
157
164
|
|
|
158
|
-
let handle: ReturnType<typeof runtime.spawnSubagent
|
|
165
|
+
let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
|
|
159
166
|
try {
|
|
160
|
-
handle = runtime.spawnSubagent(
|
|
167
|
+
handle = await runtime.spawnSubagent(
|
|
161
168
|
{ agentId: 'child-agent', message: 'child task', waitForCompletion: false },
|
|
162
169
|
{ sessionId: 'parent-session', cwd: tempDir },
|
|
163
170
|
)
|
|
@@ -184,6 +191,82 @@ describe('subagent-runtime', () => {
|
|
|
184
191
|
assert.equal(ancestors[0].sessionId, 'parent-session')
|
|
185
192
|
}
|
|
186
193
|
})
|
|
194
|
+
|
|
195
|
+
it('fires subagent lifecycle hooks through the native runtime', async () => {
|
|
196
|
+
lineage._clearLineage()
|
|
197
|
+
seedAgent('parent-hook-agent', 'Parent Hook Agent', ['subagent_lifecycle_test'])
|
|
198
|
+
seedAgent('child-hook-agent', 'Child Hook Agent')
|
|
199
|
+
|
|
200
|
+
const marks: string[] = []
|
|
201
|
+
pluginManager.registerBuiltin('subagent_lifecycle_test', {
|
|
202
|
+
name: 'Subagent Lifecycle Test',
|
|
203
|
+
hooks: {
|
|
204
|
+
subagentSpawning: ({ agentId }) => {
|
|
205
|
+
marks.push(`spawning:${agentId}`)
|
|
206
|
+
return { status: 'ok' }
|
|
207
|
+
},
|
|
208
|
+
subagentSpawned: ({ childSessionId }) => {
|
|
209
|
+
marks.push(`spawned:${childSessionId}`)
|
|
210
|
+
},
|
|
211
|
+
subagentEnded: ({ status }) => {
|
|
212
|
+
marks.push(`ended:${status}`)
|
|
213
|
+
},
|
|
214
|
+
sessionEnd: ({ reason }) => {
|
|
215
|
+
marks.push(`session_end:${reason}`)
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
providers['subagent-test-provider'] = {
|
|
221
|
+
id: 'subagent-test-provider',
|
|
222
|
+
name: 'Subagent Test Provider',
|
|
223
|
+
models: ['unit'],
|
|
224
|
+
requiresApiKey: false,
|
|
225
|
+
requiresEndpoint: false,
|
|
226
|
+
handler: {
|
|
227
|
+
async streamChat() {
|
|
228
|
+
return 'subagent finished'
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const agents = storage.loadAgents()
|
|
234
|
+
agents['child-hook-agent'] = {
|
|
235
|
+
id: 'child-hook-agent',
|
|
236
|
+
name: 'Child Hook Agent',
|
|
237
|
+
provider: 'subagent-test-provider',
|
|
238
|
+
model: 'unit',
|
|
239
|
+
systemPrompt: 'Child runtime test',
|
|
240
|
+
}
|
|
241
|
+
storage.saveAgents(agents)
|
|
242
|
+
|
|
243
|
+
const sessions = storage.loadSessions()
|
|
244
|
+
sessions['hook-parent-session'] = {
|
|
245
|
+
id: 'hook-parent-session',
|
|
246
|
+
cwd: tempDir,
|
|
247
|
+
parentSessionId: null,
|
|
248
|
+
agentId: 'parent-hook-agent',
|
|
249
|
+
provider: 'subagent-test-provider',
|
|
250
|
+
model: 'unit',
|
|
251
|
+
plugins: ['subagent_lifecycle_test'],
|
|
252
|
+
messages: [],
|
|
253
|
+
createdAt: Date.now(),
|
|
254
|
+
lastActiveAt: Date.now(),
|
|
255
|
+
}
|
|
256
|
+
storage.saveSessions(sessions)
|
|
257
|
+
|
|
258
|
+
const handle = await runtime.spawnSubagent(
|
|
259
|
+
{ agentId: 'child-hook-agent', message: 'finish the task', waitForCompletion: false },
|
|
260
|
+
{ sessionId: 'hook-parent-session', cwd: tempDir },
|
|
261
|
+
)
|
|
262
|
+
const result = await handle.promise
|
|
263
|
+
|
|
264
|
+
assert.equal(result.status, 'completed')
|
|
265
|
+
assert.equal(marks.includes('spawning:child-hook-agent'), true)
|
|
266
|
+
assert.equal(marks.some((mark) => mark.startsWith('spawned:')), true)
|
|
267
|
+
assert.equal(marks.includes('ended:completed'), true)
|
|
268
|
+
assert.equal(marks.includes('session_end:completed'), true)
|
|
269
|
+
})
|
|
187
270
|
})
|
|
188
271
|
|
|
189
272
|
describe('mergePlugins', () => {
|
|
@@ -230,7 +313,7 @@ describe('subagent-runtime', () => {
|
|
|
230
313
|
})
|
|
231
314
|
|
|
232
315
|
describe('plugin inheritance in spawnSubagent', () => {
|
|
233
|
-
it('child session inherits parent plugins merged with agent plugins', () => {
|
|
316
|
+
it('child session inherits parent plugins merged with agent plugins', async () => {
|
|
234
317
|
lineage._clearLineage()
|
|
235
318
|
seedAgent('inherit-agent', 'Inherit Agent', ['shell', 'memory'])
|
|
236
319
|
|
|
@@ -244,9 +327,9 @@ describe('subagent-runtime', () => {
|
|
|
244
327
|
}
|
|
245
328
|
storage.saveSessions(sessions)
|
|
246
329
|
|
|
247
|
-
let handle: ReturnType<typeof runtime.spawnSubagent
|
|
330
|
+
let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
|
|
248
331
|
try {
|
|
249
|
-
handle = runtime.spawnSubagent(
|
|
332
|
+
handle = await runtime.spawnSubagent(
|
|
250
333
|
{ agentId: 'inherit-agent', message: 'test inheritance', waitForCompletion: false },
|
|
251
334
|
{ sessionId: 'inherit-parent', cwd: tempDir },
|
|
252
335
|
)
|
|
@@ -265,7 +348,7 @@ describe('subagent-runtime', () => {
|
|
|
265
348
|
}
|
|
266
349
|
})
|
|
267
350
|
|
|
268
|
-
it('child session does not inherit when inheritPlugins is false', () => {
|
|
351
|
+
it('child session does not inherit when inheritPlugins is false', async () => {
|
|
269
352
|
lineage._clearLineage()
|
|
270
353
|
seedAgent('no-inherit-agent', 'No Inherit Agent', ['shell'])
|
|
271
354
|
|
|
@@ -278,9 +361,9 @@ describe('subagent-runtime', () => {
|
|
|
278
361
|
}
|
|
279
362
|
storage.saveSessions(sessions)
|
|
280
363
|
|
|
281
|
-
let handle: ReturnType<typeof runtime.spawnSubagent
|
|
364
|
+
let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
|
|
282
365
|
try {
|
|
283
|
-
handle = runtime.spawnSubagent(
|
|
366
|
+
handle = await runtime.spawnSubagent(
|
|
284
367
|
{ agentId: 'no-inherit-agent', message: 'no inherit', inheritPlugins: false, waitForCompletion: false },
|
|
285
368
|
{ sessionId: 'no-inherit-parent', cwd: tempDir },
|
|
286
369
|
)
|