@swarmclawai/swarmclaw 0.7.7 → 0.8.0
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 +12 -14
- package/next.config.ts +13 -2
- package/package.json +4 -2
- package/src/app/api/agents/[id]/thread/route.ts +9 -0
- package/src/app/api/agents/route.ts +4 -0
- package/src/app/api/agents/thread-route.test.ts +133 -0
- package/src/app/api/approvals/route.test.ts +148 -0
- package/src/app/api/canvas/[sessionId]/route.ts +3 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
- package/src/app/api/chats/[id]/devserver/route.ts +48 -7
- package/src/app/api/chats/[id]/messages/route.ts +42 -18
- package/src/app/api/chats/[id]/route.ts +1 -1
- package/src/app/api/chats/[id]/stop/route.ts +5 -4
- package/src/app/api/chats/route.ts +23 -2
- package/src/app/api/clawhub/install/route.ts +28 -8
- package/src/app/api/connectors/[id]/route.ts +46 -3
- package/src/app/api/connectors/route.ts +12 -8
- package/src/app/api/external-agents/route.test.ts +165 -0
- package/src/app/api/gateways/[id]/health/route.ts +27 -12
- package/src/app/api/gateways/[id]/route.ts +2 -0
- package/src/app/api/gateways/health-route.test.ts +135 -0
- package/src/app/api/gateways/route.ts +2 -0
- package/src/app/api/mcp-servers/route.test.ts +130 -0
- package/src/app/api/openclaw/deploy/route.ts +38 -5
- package/src/app/api/plugins/install/route.ts +46 -6
- package/src/app/api/plugins/marketplace/route.ts +48 -15
- package/src/app/api/preview-server/route.ts +26 -11
- package/src/app/api/projects/[id]/route.ts +6 -2
- package/src/app/api/projects/route.ts +4 -3
- package/src/app/api/schedules/[id]/run/route.ts +4 -0
- package/src/app/api/schedules/route.test.ts +86 -0
- package/src/app/api/schedules/route.ts +6 -1
- package/src/app/api/secrets/[id]/route.ts +1 -0
- package/src/app/api/secrets/route.ts +2 -1
- package/src/app/api/settings/route.ts +2 -0
- package/src/app/api/setup/check-provider/route.test.ts +19 -0
- package/src/app/api/setup/check-provider/route.ts +40 -10
- package/src/app/api/skills/[id]/route.ts +12 -0
- package/src/app/api/skills/import/route.ts +14 -12
- package/src/app/api/skills/route.ts +13 -1
- package/src/app/api/tasks/[id]/route.ts +10 -1
- package/src/app/api/tasks/import/github/route.test.ts +65 -0
- package/src/app/api/tasks/import/github/route.ts +337 -0
- package/src/app/api/wallets/[id]/approve/route.ts +17 -3
- package/src/app/api/wallets/[id]/route.ts +79 -33
- package/src/app/api/wallets/[id]/send/route.ts +19 -33
- package/src/app/api/wallets/route.ts +78 -61
- package/src/app/api/webhooks/[id]/route.ts +33 -6
- package/src/app/api/webhooks/route.test.ts +272 -0
- package/src/cli/index.js +1 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-card.tsx +9 -2
- package/src/components/agents/agent-chat-list.tsx +18 -2
- package/src/components/agents/agent-list.tsx +1 -0
- package/src/components/agents/agent-sheet.tsx +257 -38
- package/src/components/agents/inspector-panel.tsx +41 -0
- package/src/components/canvas/canvas-panel.tsx +236 -65
- package/src/components/chat/chat-area.tsx +36 -19
- package/src/components/chat/chat-card.tsx +36 -13
- package/src/components/chat/chat-header.tsx +48 -16
- package/src/components/chat/chat-list.tsx +28 -4
- package/src/components/chat/checkpoint-timeline.tsx +50 -34
- package/src/components/chat/delegation-banner.test.ts +14 -1
- package/src/components/chat/delegation-banner.tsx +1 -1
- package/src/components/chat/message-bubble.tsx +208 -145
- package/src/components/chat/message-list.tsx +48 -19
- package/src/components/chatrooms/chatroom-message.tsx +2 -2
- package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
- package/src/components/connectors/connector-health.tsx +1 -1
- package/src/components/connectors/connector-list.tsx +7 -2
- package/src/components/connectors/connector-sheet.tsx +337 -148
- package/src/components/gateways/gateway-sheet.tsx +2 -2
- package/src/components/layout/app-layout.tsx +40 -23
- package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
- package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
- package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
- package/src/components/plugins/plugin-list.tsx +45 -9
- package/src/components/plugins/plugin-sheet.tsx +55 -7
- package/src/components/projects/project-detail.tsx +217 -0
- package/src/components/projects/project-sheet.tsx +176 -4
- package/src/components/providers/provider-list.tsx +2 -1
- package/src/components/providers/provider-sheet.tsx +21 -2
- package/src/components/schedules/schedule-card.tsx +25 -1
- package/src/components/schedules/schedule-sheet.tsx +44 -2
- package/src/components/secrets/secret-sheet.tsx +21 -2
- package/src/components/shared/agent-switch-dialog.tsx +12 -1
- package/src/components/shared/bottom-sheet.tsx +13 -3
- package/src/components/shared/command-palette.tsx +8 -1
- package/src/components/shared/confirm-dialog.tsx +19 -4
- package/src/components/shared/connector-platform-icon.test.ts +28 -0
- package/src/components/shared/connector-platform-icon.tsx +39 -6
- package/src/components/shared/settings/plugin-manager.tsx +29 -6
- package/src/components/shared/settings/section-capability-policy.tsx +45 -3
- package/src/components/shared/settings/section-voice.tsx +11 -3
- package/src/components/skills/skill-list.tsx +25 -0
- package/src/components/skills/skill-sheet.tsx +84 -12
- package/src/components/tasks/approvals-panel.tsx +289 -34
- package/src/components/tasks/task-board.tsx +410 -25
- package/src/components/tasks/task-card.tsx +66 -8
- package/src/components/tasks/task-sheet.tsx +16 -4
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
- package/src/components/wallets/wallet-panel.tsx +435 -90
- package/src/components/wallets/wallet-section.tsx +198 -48
- package/src/components/webhooks/webhook-sheet.tsx +22 -2
- package/src/lib/approval-display.ts +20 -0
- package/src/lib/canvas-content.ts +198 -0
- package/src/lib/chat-artifact-summary.ts +165 -0
- package/src/lib/chat-display.test.ts +91 -0
- package/src/lib/chat-display.ts +58 -0
- package/src/lib/chat-streaming-state.test.ts +47 -1
- package/src/lib/chat-streaming-state.ts +42 -0
- package/src/lib/ollama-model.ts +10 -0
- package/src/lib/openclaw-endpoint.test.ts +8 -0
- package/src/lib/openclaw-endpoint.ts +6 -1
- package/src/lib/plugin-install-cors.ts +46 -0
- package/src/lib/plugin-sources.test.ts +43 -0
- package/src/lib/plugin-sources.ts +77 -0
- package/src/lib/providers/ollama.ts +16 -6
- package/src/lib/providers/openclaw.test.ts +54 -0
- package/src/lib/providers/openclaw.ts +127 -11
- package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
- package/src/lib/schedule-dedupe.test.ts +66 -1
- package/src/lib/schedule-dedupe.ts +169 -12
- package/src/lib/schedule-origin.test.ts +20 -0
- package/src/lib/schedule-origin.ts +15 -0
- package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
- package/src/lib/server/agent-availability.ts +16 -0
- package/src/lib/server/agent-runtime-config.ts +12 -4
- package/src/lib/server/agent-thread-session.test.ts +51 -0
- package/src/lib/server/agent-thread-session.ts +7 -0
- package/src/lib/server/approval-match.ts +205 -0
- package/src/lib/server/approvals-auto-approve.test.ts +538 -1
- package/src/lib/server/approvals.ts +214 -1
- package/src/lib/server/assistant-control.test.ts +29 -0
- package/src/lib/server/assistant-control.ts +23 -0
- package/src/lib/server/build-llm.test.ts +79 -0
- package/src/lib/server/build-llm.ts +14 -4
- package/src/lib/server/canvas-content.test.ts +32 -0
- package/src/lib/server/canvas-content.ts +6 -0
- package/src/lib/server/capability-router.test.ts +33 -0
- package/src/lib/server/capability-router.ts +80 -19
- package/src/lib/server/chat-execution-advanced.test.ts +651 -0
- package/src/lib/server/chat-execution-disabled.test.ts +94 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
- package/src/lib/server/chat-execution.ts +378 -73
- package/src/lib/server/clawhub-client.test.ts +14 -8
- package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
- package/src/lib/server/connectors/manager.test.ts +1147 -0
- package/src/lib/server/connectors/manager.ts +461 -137
- package/src/lib/server/connectors/pairing.ts +26 -5
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.test.ts +134 -0
- package/src/lib/server/connectors/whatsapp.ts +271 -47
- package/src/lib/server/context-manager.ts +6 -1
- package/src/lib/server/daemon-state.ts +84 -47
- package/src/lib/server/data-dir.test.ts +37 -0
- package/src/lib/server/data-dir.ts +20 -1
- package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
- package/src/lib/server/devserver-launch.test.ts +60 -0
- package/src/lib/server/devserver-launch.ts +85 -0
- package/src/lib/server/elevenlabs.test.ts +247 -1
- package/src/lib/server/elevenlabs.ts +147 -43
- package/src/lib/server/ethereum.ts +590 -0
- package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
- package/src/lib/server/eval/agent-regression.test.ts +18 -1
- package/src/lib/server/eval/agent-regression.ts +383 -11
- package/src/lib/server/evm-swap.ts +475 -0
- package/src/lib/server/execution-log.ts +1 -0
- package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
- package/src/lib/server/heartbeat-service.ts +20 -11
- package/src/lib/server/heartbeat-wake.test.ts +112 -0
- package/src/lib/server/heartbeat-wake.ts +338 -57
- package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
- package/src/lib/server/main-agent-loop.test.ts +260 -0
- package/src/lib/server/main-agent-loop.ts +559 -14
- package/src/lib/server/mcp-client.test.ts +16 -0
- package/src/lib/server/mcp-client.ts +25 -0
- package/src/lib/server/memory-integration.test.ts +719 -0
- package/src/lib/server/memory-policy.test.ts +43 -0
- package/src/lib/server/memory-policy.ts +132 -0
- package/src/lib/server/memory-tiers.test.ts +60 -0
- package/src/lib/server/memory-tiers.ts +16 -0
- package/src/lib/server/ollama-runtime.ts +58 -0
- package/src/lib/server/openclaw-deploy.test.ts +109 -1
- package/src/lib/server/openclaw-deploy.ts +557 -81
- package/src/lib/server/openclaw-gateway.test.ts +131 -0
- package/src/lib/server/openclaw-gateway.ts +10 -4
- package/src/lib/server/openclaw-health.test.ts +35 -0
- package/src/lib/server/openclaw-health.ts +215 -47
- package/src/lib/server/orchestrator-lg.ts +3 -2
- package/src/lib/server/orchestrator.ts +2 -0
- package/src/lib/server/plugins-advanced.test.ts +351 -0
- package/src/lib/server/plugins.ts +211 -6
- package/src/lib/server/project-context.ts +162 -0
- package/src/lib/server/project-utils.ts +150 -0
- package/src/lib/server/queue-advanced.test.ts +528 -0
- package/src/lib/server/queue-followups.test.ts +409 -2
- package/src/lib/server/queue-reconcile.test.ts +128 -0
- package/src/lib/server/queue.ts +527 -68
- package/src/lib/server/scheduler.ts +29 -1
- package/src/lib/server/session-note.test.ts +36 -0
- package/src/lib/server/session-note.ts +42 -0
- package/src/lib/server/session-run-manager.ts +83 -4
- package/src/lib/server/session-tools/canvas.ts +14 -12
- package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
- package/src/lib/server/session-tools/connector.test.ts +138 -0
- package/src/lib/server/session-tools/connector.ts +366 -54
- package/src/lib/server/session-tools/context.ts +17 -3
- package/src/lib/server/session-tools/crud.ts +484 -84
- package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
- package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
- package/src/lib/server/session-tools/delegate.ts +102 -10
- package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
- package/src/lib/server/session-tools/discovery.ts +80 -12
- package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
- package/src/lib/server/session-tools/file.ts +43 -4
- package/src/lib/server/session-tools/human-loop.ts +35 -5
- package/src/lib/server/session-tools/index.ts +44 -9
- package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
- package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
- package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
- package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
- package/src/lib/server/session-tools/memory.test.ts +93 -0
- package/src/lib/server/session-tools/memory.ts +554 -75
- package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
- package/src/lib/server/session-tools/platform-access.test.ts +58 -0
- package/src/lib/server/session-tools/platform.ts +60 -19
- package/src/lib/server/session-tools/plugin-creator.ts +57 -1
- package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
- package/src/lib/server/session-tools/schedule.ts +6 -1
- package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
- package/src/lib/server/session-tools/shell.ts +22 -3
- package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
- package/src/lib/server/session-tools/wallet.ts +1374 -139
- package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
- package/src/lib/server/session-tools/web.ts +621 -70
- package/src/lib/server/skill-discovery.ts +128 -0
- package/src/lib/server/skill-eligibility.test.ts +84 -0
- package/src/lib/server/skill-eligibility.ts +95 -0
- package/src/lib/server/skill-prompt-budget.test.ts +102 -0
- package/src/lib/server/skill-prompt-budget.ts +125 -0
- package/src/lib/server/skills-normalize.test.ts +54 -0
- package/src/lib/server/skills-normalize.ts +372 -26
- package/src/lib/server/solana.ts +214 -29
- package/src/lib/server/storage.ts +65 -36
- package/src/lib/server/stream-agent-chat.test.ts +437 -2
- package/src/lib/server/stream-agent-chat.ts +957 -79
- package/src/lib/server/system-events.ts +1 -1
- package/src/lib/server/tool-aliases.ts +2 -0
- package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
- package/src/lib/server/tool-capability-policy.test.ts +24 -0
- package/src/lib/server/tool-capability-policy.ts +29 -1
- package/src/lib/server/tool-loop-detection.test.ts +105 -0
- package/src/lib/server/tool-loop-detection.ts +260 -0
- package/src/lib/server/tool-planning.test.ts +44 -0
- package/src/lib/server/tool-planning.ts +271 -0
- package/src/lib/server/wallet-execution.test.ts +198 -0
- package/src/lib/server/wallet-portfolio.test.ts +98 -0
- package/src/lib/server/wallet-portfolio.ts +724 -0
- package/src/lib/server/wallet-service.test.ts +57 -0
- package/src/lib/server/wallet-service.ts +213 -0
- package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
- package/src/lib/server/watch-jobs.ts +17 -2
- package/src/lib/server/workspace-context.ts +111 -0
- package/src/lib/skill-save-payload.test.ts +39 -0
- package/src/lib/skill-save-payload.ts +37 -0
- package/src/lib/tasks.ts +28 -0
- package/src/lib/tool-definitions.ts +2 -1
- package/src/lib/tool-event-summary.test.ts +30 -0
- package/src/lib/tool-event-summary.ts +37 -0
- package/src/lib/validation/schemas.ts +1 -0
- package/src/lib/wallet-transactions.test.ts +75 -0
- package/src/lib/wallet-transactions.ts +43 -0
- package/src/lib/wallet.test.ts +17 -0
- package/src/lib/wallet.ts +183 -0
- package/src/proxy.test.ts +31 -0
- package/src/proxy.ts +34 -2
- package/src/stores/use-chat-store.ts +15 -1
- package/src/types/index.ts +249 -14
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { Contract, JsonRpcProvider, getAddress, isAddress } from 'ethers'
|
|
2
|
+
|
|
3
|
+
import { formatAtomicAmount, normalizeAtomicString, parseDisplayAmountToAtomic } from '@/lib/wallet'
|
|
4
|
+
import type { AgentWallet, WalletAssetBalance } from '@/types'
|
|
5
|
+
|
|
6
|
+
import { getEvmNetworkConfig, getProviderForNetwork, type EvmNetworkId } from './ethereum'
|
|
7
|
+
import { getWalletPortfolioSnapshot } from './wallet-service'
|
|
8
|
+
|
|
9
|
+
const PARASWAP_API_BASE = 'https://api.paraswap.io'
|
|
10
|
+
const PARASWAP_VERSION = '6.2'
|
|
11
|
+
const PARASWAP_NATIVE_TOKEN = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'
|
|
12
|
+
const ERC20_ALLOWANCE_ABI = [
|
|
13
|
+
'function allowance(address owner, address spender) view returns (uint256)',
|
|
14
|
+
'function balanceOf(address owner) view returns (uint256)',
|
|
15
|
+
'function approve(address spender, uint256 amount) returns (bool)',
|
|
16
|
+
'function decimals() view returns (uint8)',
|
|
17
|
+
'function symbol() view returns (string)',
|
|
18
|
+
'function name() view returns (string)',
|
|
19
|
+
] as const
|
|
20
|
+
const TOKEN_LIST_TTL_MS = 10 * 60 * 1000
|
|
21
|
+
const FETCH_TIMEOUT_MS = 15_000
|
|
22
|
+
|
|
23
|
+
interface CachedTokenList {
|
|
24
|
+
expiresAt: number
|
|
25
|
+
assets: ResolvedEvmSwapAsset[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const paraswapTokenListCache = new Map<EvmNetworkId, CachedTokenList>()
|
|
29
|
+
|
|
30
|
+
export interface ResolvedEvmSwapAsset {
|
|
31
|
+
address: string
|
|
32
|
+
symbol: string
|
|
33
|
+
name: string
|
|
34
|
+
decimals: number
|
|
35
|
+
isNative: boolean
|
|
36
|
+
source: 'native' | 'portfolio' | 'paraswap' | 'onchain'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PreparedEvmSwapPlan {
|
|
40
|
+
provider: 'paraswap'
|
|
41
|
+
network: ReturnType<typeof getEvmNetworkConfig>
|
|
42
|
+
walletAddress: string
|
|
43
|
+
recipient: string
|
|
44
|
+
sellToken: ResolvedEvmSwapAsset
|
|
45
|
+
buyToken: ResolvedEvmSwapAsset
|
|
46
|
+
sellAmountAtomic: string
|
|
47
|
+
sellAmountDisplay: string
|
|
48
|
+
buyAmountAtomic: string
|
|
49
|
+
buyAmountDisplay: string
|
|
50
|
+
slippageBps: number
|
|
51
|
+
spenderAddress: string | null
|
|
52
|
+
approvalRequired: boolean
|
|
53
|
+
approvalTransaction: Record<string, unknown> | null
|
|
54
|
+
swapTransaction: Record<string, unknown>
|
|
55
|
+
routeSummary: string
|
|
56
|
+
priceRoute: Record<string, unknown>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PrepareEvmSwapPlanInput {
|
|
60
|
+
wallet: AgentWallet
|
|
61
|
+
network: EvmNetworkId | string
|
|
62
|
+
sellToken: unknown
|
|
63
|
+
buyToken: unknown
|
|
64
|
+
sellAmountAtomic?: unknown
|
|
65
|
+
sellAmountDisplay?: unknown
|
|
66
|
+
slippageBps?: unknown
|
|
67
|
+
recipient?: unknown
|
|
68
|
+
rpcUrl?: string | null
|
|
69
|
+
skipBalanceCheck?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeText(value: unknown): string {
|
|
73
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function looksLikeEvmAddress(value: string): boolean {
|
|
77
|
+
return isAddress(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeLowerAddress(value: string): string {
|
|
81
|
+
return getAddress(value).toLowerCase()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function makeNativeEthAsset(): ResolvedEvmSwapAsset {
|
|
85
|
+
return {
|
|
86
|
+
address: PARASWAP_NATIVE_TOKEN,
|
|
87
|
+
symbol: 'ETH',
|
|
88
|
+
name: 'Ether',
|
|
89
|
+
decimals: 18,
|
|
90
|
+
isNative: true,
|
|
91
|
+
source: 'native',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeSlippageBps(value: unknown): number {
|
|
96
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
97
|
+
if (value > 0 && value <= 10) return Math.round(value * 100)
|
|
98
|
+
return Math.max(1, Math.min(5_000, Math.trunc(value)))
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === 'string') {
|
|
101
|
+
const trimmed = value.trim()
|
|
102
|
+
if (!trimmed) return 100
|
|
103
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
104
|
+
const parsed = Number.parseFloat(trimmed)
|
|
105
|
+
if (parsed > 0 && parsed <= 10) return Math.round(parsed * 100)
|
|
106
|
+
return Math.max(1, Math.min(5_000, Math.trunc(parsed)))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return 100
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function fetchJson(url: string, init?: RequestInit): Promise<unknown> {
|
|
113
|
+
const controller = new AbortController()
|
|
114
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
115
|
+
try {
|
|
116
|
+
const response = await fetch(url, {
|
|
117
|
+
...init,
|
|
118
|
+
signal: controller.signal,
|
|
119
|
+
headers: {
|
|
120
|
+
Accept: 'application/json',
|
|
121
|
+
...(init?.headers || {}),
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
const text = await response.text()
|
|
125
|
+
const payload = text ? JSON.parse(text) as unknown : null
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
const message = payload && typeof payload === 'object' && payload && 'error' in payload
|
|
128
|
+
? String((payload as { error?: unknown }).error)
|
|
129
|
+
: `${response.status} ${response.statusText}`.trim()
|
|
130
|
+
throw new Error(`ParaSwap API request failed: ${message}`)
|
|
131
|
+
}
|
|
132
|
+
return payload
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function getParaswapTokenList(network: EvmNetworkId): Promise<ResolvedEvmSwapAsset[]> {
|
|
139
|
+
const cached = paraswapTokenListCache.get(network)
|
|
140
|
+
if (cached && cached.expiresAt > Date.now()) return cached.assets
|
|
141
|
+
|
|
142
|
+
const config = getEvmNetworkConfig(network)
|
|
143
|
+
const response = await fetchJson(`${PARASWAP_API_BASE}/tokens/${config.chainId}`) as {
|
|
144
|
+
tokens?: Array<{
|
|
145
|
+
address?: string
|
|
146
|
+
symbol?: string
|
|
147
|
+
name?: string
|
|
148
|
+
decimals?: number
|
|
149
|
+
}>
|
|
150
|
+
}
|
|
151
|
+
const assets = (Array.isArray(response?.tokens) ? response.tokens : [])
|
|
152
|
+
.flatMap((token) => {
|
|
153
|
+
const address = normalizeText(token?.address)
|
|
154
|
+
const symbol = normalizeText(token?.symbol)
|
|
155
|
+
if (!address || !symbol) return []
|
|
156
|
+
if (address.toLowerCase() === PARASWAP_NATIVE_TOKEN.toLowerCase()) return [makeNativeEthAsset()]
|
|
157
|
+
if (!looksLikeEvmAddress(address)) return []
|
|
158
|
+
return [{
|
|
159
|
+
address: getAddress(address),
|
|
160
|
+
symbol,
|
|
161
|
+
name: normalizeText(token?.name) || symbol,
|
|
162
|
+
decimals: typeof token?.decimals === 'number' ? token.decimals : 18,
|
|
163
|
+
isNative: false,
|
|
164
|
+
source: 'paraswap' as const,
|
|
165
|
+
}]
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const deduped = Array.from(new Map(
|
|
169
|
+
assets.map((asset) => [asset.address.toLowerCase(), asset]),
|
|
170
|
+
).values())
|
|
171
|
+
paraswapTokenListCache.set(network, {
|
|
172
|
+
expiresAt: Date.now() + TOKEN_LIST_TTL_MS,
|
|
173
|
+
assets: deduped,
|
|
174
|
+
})
|
|
175
|
+
return deduped
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getPortfolioAssetCandidates(
|
|
179
|
+
walletAssets: WalletAssetBalance[],
|
|
180
|
+
networkId: EvmNetworkId,
|
|
181
|
+
tokenRef: string,
|
|
182
|
+
): ResolvedEvmSwapAsset[] {
|
|
183
|
+
const normalized = tokenRef.trim().toLowerCase()
|
|
184
|
+
return walletAssets
|
|
185
|
+
.filter((asset) => asset.chain === 'ethereum' && asset.networkId === networkId)
|
|
186
|
+
.flatMap((asset) => {
|
|
187
|
+
const address = asset.isNative ? PARASWAP_NATIVE_TOKEN : normalizeText(asset.contractAddress)
|
|
188
|
+
if (!address) return []
|
|
189
|
+
const symbol = normalizeText(asset.symbol)
|
|
190
|
+
const name = normalizeText(asset.name)
|
|
191
|
+
const matchesAddress = !asset.isNative && looksLikeEvmAddress(tokenRef) && address.toLowerCase() === normalized
|
|
192
|
+
const matchesSymbol = symbol.toLowerCase() === normalized
|
|
193
|
+
const matchesName = name.toLowerCase() === normalized
|
|
194
|
+
const matchesNative = asset.isNative && ['eth', 'native', PARASWAP_NATIVE_TOKEN.toLowerCase()].includes(normalized)
|
|
195
|
+
if (!matchesAddress && !matchesSymbol && !matchesName && !matchesNative) return []
|
|
196
|
+
return [{
|
|
197
|
+
address: asset.isNative ? PARASWAP_NATIVE_TOKEN : getAddress(address),
|
|
198
|
+
symbol: symbol || (asset.isNative ? 'ETH' : 'TOKEN'),
|
|
199
|
+
name: name || symbol || 'Token',
|
|
200
|
+
decimals: typeof asset.decimals === 'number' ? asset.decimals : (asset.isNative ? 18 : 18),
|
|
201
|
+
isNative: asset.isNative === true,
|
|
202
|
+
source: 'portfolio' as const,
|
|
203
|
+
}]
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function resolveTokenByAddress(
|
|
208
|
+
provider: JsonRpcProvider,
|
|
209
|
+
address: string,
|
|
210
|
+
): Promise<ResolvedEvmSwapAsset> {
|
|
211
|
+
const normalizedAddress = getAddress(address)
|
|
212
|
+
const contract = new Contract(normalizedAddress, ERC20_ALLOWANCE_ABI, provider)
|
|
213
|
+
const [decimalsRaw, symbolRaw, nameRaw] = await Promise.all([
|
|
214
|
+
contract.decimals().catch(() => 18),
|
|
215
|
+
contract.symbol().catch(() => 'TOKEN'),
|
|
216
|
+
contract.name().catch(() => 'Token'),
|
|
217
|
+
])
|
|
218
|
+
return {
|
|
219
|
+
address: normalizedAddress,
|
|
220
|
+
symbol: normalizeText(symbolRaw) || 'TOKEN',
|
|
221
|
+
name: normalizeText(nameRaw) || normalizeText(symbolRaw) || 'Token',
|
|
222
|
+
decimals: typeof decimalsRaw === 'number' ? decimalsRaw : Number(decimalsRaw ?? 18),
|
|
223
|
+
isNative: false,
|
|
224
|
+
source: 'onchain',
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function resolveEvmSwapAsset(input: {
|
|
229
|
+
wallet: AgentWallet
|
|
230
|
+
network: EvmNetworkId | string
|
|
231
|
+
token: unknown
|
|
232
|
+
rpcUrl?: string | null
|
|
233
|
+
}): Promise<ResolvedEvmSwapAsset> {
|
|
234
|
+
const tokenRef = normalizeText(input.token)
|
|
235
|
+
if (!tokenRef) throw new Error('Token is required')
|
|
236
|
+
|
|
237
|
+
const network = getEvmNetworkConfig(input.network).id
|
|
238
|
+
const normalized = tokenRef.toLowerCase()
|
|
239
|
+
if (['eth', 'native', PARASWAP_NATIVE_TOKEN.toLowerCase()].includes(normalized)) {
|
|
240
|
+
return makeNativeEthAsset()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const portfolio = await getWalletPortfolioSnapshot(input.wallet)
|
|
244
|
+
const portfolioMatches = getPortfolioAssetCandidates(portfolio.assets, network, tokenRef)
|
|
245
|
+
if (portfolioMatches.length === 1) return portfolioMatches[0]
|
|
246
|
+
|
|
247
|
+
const tokenList = await getParaswapTokenList(network)
|
|
248
|
+
if (looksLikeEvmAddress(tokenRef)) {
|
|
249
|
+
const addressMatch = tokenList.find((asset) => asset.address.toLowerCase() === normalized.toLowerCase())
|
|
250
|
+
if (addressMatch) return addressMatch
|
|
251
|
+
return resolveTokenByAddress(getProviderForNetwork(network, input.rpcUrl), tokenRef)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const symbolMatches = tokenList.filter((asset) => asset.symbol.toLowerCase() === normalized)
|
|
255
|
+
if (symbolMatches.length === 1) return symbolMatches[0]
|
|
256
|
+
if (portfolioMatches.length > 1) {
|
|
257
|
+
throw new Error(`Token "${tokenRef}" matches multiple wallet assets on ${network}. Use the contract address instead.`)
|
|
258
|
+
}
|
|
259
|
+
if (symbolMatches.length > 1) {
|
|
260
|
+
throw new Error(`Token "${tokenRef}" matches multiple ParaSwap assets on ${network}. Use the token contract address instead.`)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const nameMatch = tokenList.find((asset) => asset.name.toLowerCase() === normalized)
|
|
264
|
+
if (nameMatch) return nameMatch
|
|
265
|
+
|
|
266
|
+
throw new Error(`Could not resolve token "${tokenRef}" on ${network}. Use a symbol like USDC/ETH or a token contract address.`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseSellAmountAtomic(input: {
|
|
270
|
+
sellAmountAtomic?: unknown
|
|
271
|
+
sellAmountDisplay?: unknown
|
|
272
|
+
decimals: number
|
|
273
|
+
}): string {
|
|
274
|
+
const atomic = normalizeAtomicString(input.sellAmountAtomic, '')
|
|
275
|
+
if (atomic) {
|
|
276
|
+
if (BigInt(atomic) <= BigInt(0)) throw new Error('Swap amount must be positive')
|
|
277
|
+
return atomic
|
|
278
|
+
}
|
|
279
|
+
const displayRaw = input.sellAmountDisplay
|
|
280
|
+
if (
|
|
281
|
+
displayRaw === undefined
|
|
282
|
+
|| displayRaw === null
|
|
283
|
+
|| (typeof displayRaw === 'string' && displayRaw.trim() === '')
|
|
284
|
+
) {
|
|
285
|
+
throw new Error('sellAmountAtomic or sellAmountDisplay is required for swap')
|
|
286
|
+
}
|
|
287
|
+
const display = typeof displayRaw === 'number' || typeof displayRaw === 'string'
|
|
288
|
+
? displayRaw
|
|
289
|
+
: String(displayRaw)
|
|
290
|
+
const parsed = parseDisplayAmountToAtomic(display, input.decimals)
|
|
291
|
+
if (BigInt(parsed) <= BigInt(0)) throw new Error('Swap amount must be positive')
|
|
292
|
+
return parsed
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function getTokenBalanceAtomic(
|
|
296
|
+
provider: JsonRpcProvider,
|
|
297
|
+
walletAddress: string,
|
|
298
|
+
token: ResolvedEvmSwapAsset,
|
|
299
|
+
): Promise<bigint> {
|
|
300
|
+
if (token.isNative) {
|
|
301
|
+
return provider.getBalance(walletAddress)
|
|
302
|
+
}
|
|
303
|
+
const contract = new Contract(token.address, ERC20_ALLOWANCE_ABI, provider)
|
|
304
|
+
const balance = await contract.balanceOf(walletAddress)
|
|
305
|
+
return BigInt(balance.toString())
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function getTokenAllowanceAtomic(
|
|
309
|
+
provider: JsonRpcProvider,
|
|
310
|
+
walletAddress: string,
|
|
311
|
+
spenderAddress: string,
|
|
312
|
+
token: ResolvedEvmSwapAsset,
|
|
313
|
+
): Promise<bigint> {
|
|
314
|
+
if (token.isNative) return BigInt(0)
|
|
315
|
+
const contract = new Contract(token.address, ERC20_ALLOWANCE_ABI, provider)
|
|
316
|
+
const allowance = await contract.allowance(walletAddress, spenderAddress)
|
|
317
|
+
return BigInt(allowance.toString())
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function collectRouteExchanges(priceRoute: Record<string, unknown>): string[] {
|
|
321
|
+
const bestRoute = Array.isArray(priceRoute.bestRoute) ? priceRoute.bestRoute : []
|
|
322
|
+
const exchanges = new Set<string>()
|
|
323
|
+
for (const route of bestRoute) {
|
|
324
|
+
const swaps = Array.isArray((route as { swaps?: unknown[] }).swaps) ? (route as { swaps: unknown[] }).swaps : []
|
|
325
|
+
for (const swap of swaps) {
|
|
326
|
+
const swapExchanges = Array.isArray((swap as { swapExchanges?: unknown[] }).swapExchanges)
|
|
327
|
+
? (swap as { swapExchanges: unknown[] }).swapExchanges
|
|
328
|
+
: []
|
|
329
|
+
for (const entry of swapExchanges) {
|
|
330
|
+
const exchange = normalizeText((entry as { exchange?: unknown }).exchange)
|
|
331
|
+
if (exchange) exchanges.add(exchange)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return [...exchanges]
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function toComparableTransaction(transaction: Record<string, unknown>, network: ReturnType<typeof getEvmNetworkConfig>): Record<string, unknown> {
|
|
339
|
+
const normalized: Record<string, unknown> = {}
|
|
340
|
+
const to = normalizeText(transaction.to)
|
|
341
|
+
const data = normalizeText(transaction.data)
|
|
342
|
+
const value = transaction.value
|
|
343
|
+
if (to) normalized.to = getAddress(to)
|
|
344
|
+
if (data) normalized.data = data
|
|
345
|
+
if (value !== undefined && value !== null && String(value).trim() !== '') normalized.value = String(value).trim()
|
|
346
|
+
normalized.chainId = network.chainId
|
|
347
|
+
return normalized
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function prepareEvmSwapPlan(input: PrepareEvmSwapPlanInput): Promise<PreparedEvmSwapPlan> {
|
|
351
|
+
if (input.wallet.chain !== 'ethereum') {
|
|
352
|
+
throw new Error('Generic swap is currently supported only for Ethereum-compatible wallets')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const network = getEvmNetworkConfig(input.network)
|
|
356
|
+
const provider = getProviderForNetwork(network.id, input.rpcUrl || undefined)
|
|
357
|
+
const walletAddress = getAddress(input.wallet.publicKey)
|
|
358
|
+
const recipient = normalizeText(input.recipient) ? getAddress(normalizeText(input.recipient)) : walletAddress
|
|
359
|
+
const sellToken = await resolveEvmSwapAsset({
|
|
360
|
+
wallet: input.wallet,
|
|
361
|
+
network: network.id,
|
|
362
|
+
token: input.sellToken,
|
|
363
|
+
rpcUrl: input.rpcUrl,
|
|
364
|
+
})
|
|
365
|
+
const buyToken = await resolveEvmSwapAsset({
|
|
366
|
+
wallet: input.wallet,
|
|
367
|
+
network: network.id,
|
|
368
|
+
token: input.buyToken,
|
|
369
|
+
rpcUrl: input.rpcUrl,
|
|
370
|
+
})
|
|
371
|
+
if (sellToken.address.toLowerCase() === buyToken.address.toLowerCase()) {
|
|
372
|
+
throw new Error('Swap sellToken and buyToken must be different')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const sellAmountAtomic = parseSellAmountAtomic({
|
|
376
|
+
sellAmountAtomic: input.sellAmountAtomic,
|
|
377
|
+
sellAmountDisplay: input.sellAmountDisplay,
|
|
378
|
+
decimals: sellToken.decimals,
|
|
379
|
+
})
|
|
380
|
+
if (input.skipBalanceCheck !== true) {
|
|
381
|
+
const sellBalanceAtomic = await getTokenBalanceAtomic(provider, walletAddress, sellToken)
|
|
382
|
+
if (sellBalanceAtomic < BigInt(sellAmountAtomic)) {
|
|
383
|
+
const available = formatAtomicAmount(sellBalanceAtomic.toString(), sellToken.decimals, { maxFractionDigits: 6 })
|
|
384
|
+
throw new Error(`Insufficient ${sellToken.symbol} balance on ${network.label}. Available ${available} ${sellToken.symbol}.`)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const priceUrl = new URL(`${PARASWAP_API_BASE}/prices`)
|
|
389
|
+
priceUrl.searchParams.set('srcToken', sellToken.address)
|
|
390
|
+
priceUrl.searchParams.set('destToken', buyToken.address)
|
|
391
|
+
priceUrl.searchParams.set('amount', sellAmountAtomic)
|
|
392
|
+
priceUrl.searchParams.set('srcDecimals', String(sellToken.decimals))
|
|
393
|
+
priceUrl.searchParams.set('destDecimals', String(buyToken.decimals))
|
|
394
|
+
priceUrl.searchParams.set('side', 'SELL')
|
|
395
|
+
priceUrl.searchParams.set('network', String(network.chainId))
|
|
396
|
+
priceUrl.searchParams.set('version', PARASWAP_VERSION)
|
|
397
|
+
const priceResponse = await fetchJson(priceUrl.toString()) as { priceRoute?: Record<string, unknown> }
|
|
398
|
+
const priceRoute = priceResponse?.priceRoute
|
|
399
|
+
if (!priceRoute || typeof priceRoute !== 'object') {
|
|
400
|
+
throw new Error('ParaSwap did not return a price route')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const transactionsUrl = `${PARASWAP_API_BASE}/transactions/${network.chainId}?ignoreChecks=true`
|
|
404
|
+
const transactionsRequest = {
|
|
405
|
+
srcToken: sellToken.address,
|
|
406
|
+
destToken: buyToken.address,
|
|
407
|
+
srcAmount: sellAmountAtomic,
|
|
408
|
+
userAddress: walletAddress,
|
|
409
|
+
srcDecimals: sellToken.decimals,
|
|
410
|
+
destDecimals: buyToken.decimals,
|
|
411
|
+
priceRoute,
|
|
412
|
+
receiver: recipient,
|
|
413
|
+
slippage: normalizeSlippageBps(input.slippageBps),
|
|
414
|
+
}
|
|
415
|
+
const swapResponse = await fetchJson(transactionsUrl, {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: { 'Content-Type': 'application/json' },
|
|
418
|
+
body: JSON.stringify(transactionsRequest),
|
|
419
|
+
}) as Record<string, unknown>
|
|
420
|
+
|
|
421
|
+
const rawTo = normalizeText(swapResponse.to)
|
|
422
|
+
const rawData = normalizeText(swapResponse.data)
|
|
423
|
+
if (!rawTo || !rawData) {
|
|
424
|
+
throw new Error('ParaSwap did not return executable transaction calldata')
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const spenderAddress = normalizeText((priceRoute as { tokenTransferProxy?: unknown }).tokenTransferProxy)
|
|
428
|
+
|| normalizeText((priceRoute as { contractAddress?: unknown }).contractAddress)
|
|
429
|
+
|| rawTo
|
|
430
|
+
|
|
431
|
+
let approvalRequired = false
|
|
432
|
+
let approvalTransaction: Record<string, unknown> | null = null
|
|
433
|
+
if (!sellToken.isNative) {
|
|
434
|
+
const allowance = await getTokenAllowanceAtomic(provider, walletAddress, getAddress(spenderAddress), sellToken)
|
|
435
|
+
approvalRequired = allowance < BigInt(sellAmountAtomic)
|
|
436
|
+
if (approvalRequired) {
|
|
437
|
+
approvalTransaction = {
|
|
438
|
+
to: getAddress(sellToken.address),
|
|
439
|
+
data: new Contract(sellToken.address, ERC20_ALLOWANCE_ABI, provider).interface.encodeFunctionData('approve', [
|
|
440
|
+
getAddress(spenderAddress),
|
|
441
|
+
BigInt(sellAmountAtomic),
|
|
442
|
+
]),
|
|
443
|
+
value: '0',
|
|
444
|
+
chainId: network.chainId,
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const buyAmountAtomic = normalizeAtomicString((priceRoute as { destAmount?: unknown }).destAmount, '0')
|
|
450
|
+
const exchanges = collectRouteExchanges(priceRoute)
|
|
451
|
+
return {
|
|
452
|
+
provider: 'paraswap',
|
|
453
|
+
network,
|
|
454
|
+
walletAddress,
|
|
455
|
+
recipient,
|
|
456
|
+
sellToken,
|
|
457
|
+
buyToken,
|
|
458
|
+
sellAmountAtomic,
|
|
459
|
+
sellAmountDisplay: `${formatAtomicAmount(sellAmountAtomic, sellToken.decimals, { maxFractionDigits: 6 })} ${sellToken.symbol}`,
|
|
460
|
+
buyAmountAtomic,
|
|
461
|
+
buyAmountDisplay: `${formatAtomicAmount(buyAmountAtomic, buyToken.decimals, { maxFractionDigits: 6 })} ${buyToken.symbol}`,
|
|
462
|
+
slippageBps: normalizeSlippageBps(input.slippageBps),
|
|
463
|
+
spenderAddress: spenderAddress ? getAddress(spenderAddress) : null,
|
|
464
|
+
approvalRequired,
|
|
465
|
+
approvalTransaction,
|
|
466
|
+
swapTransaction: toComparableTransaction(swapResponse, network),
|
|
467
|
+
routeSummary: exchanges.length > 0 ? exchanges.join(', ') : 'ParaSwap route',
|
|
468
|
+
priceRoute,
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function isLikelyRetryableSwapError(err: unknown): boolean {
|
|
473
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
474
|
+
return /rate|price|slippage|expired|call exception|execution reverted|insufficient output/i.test(message)
|
|
475
|
+
}
|
|
@@ -20,6 +20,7 @@ export type LogCategory =
|
|
|
20
20
|
| 'mission_checkpoint' // periodic mission state snapshot
|
|
21
21
|
| 'mission_complete' // mission reached ok status
|
|
22
22
|
| 'budget_warning' // mission approaching or exceeding budget
|
|
23
|
+
| 'loop_detection' // repeated tool call pattern detected
|
|
23
24
|
|
|
24
25
|
export interface ExecutionLogEntry {
|
|
25
26
|
id: string
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { describe, it } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
|
|
9
|
+
|
|
10
|
+
function runWithTempDataDir(script: string) {
|
|
11
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-heartbeat-timer-'))
|
|
12
|
+
try {
|
|
13
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
DATA_DIR: path.join(tempDir, 'data'),
|
|
18
|
+
WORKSPACE_DIR: path.join(tempDir, 'workspace'),
|
|
19
|
+
BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
|
|
20
|
+
},
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
})
|
|
23
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
24
|
+
const lines = (result.stdout || '')
|
|
25
|
+
.trim()
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((line) => line.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
30
|
+
return JSON.parse(jsonLine || '{}')
|
|
31
|
+
} finally {
|
|
32
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('heartbeat-service scheduling', () => {
|
|
37
|
+
it('does not fire periodic heartbeats for agents that are explicitly off', () => {
|
|
38
|
+
const output = runWithTempDataDir(`
|
|
39
|
+
const { setTimeout: delay } = await import('node:timers/promises')
|
|
40
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
41
|
+
const heartbeatMod = await import('./src/lib/server/heartbeat-service.ts')
|
|
42
|
+
const runsMod = await import('./src/lib/server/session-run-manager.ts')
|
|
43
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
44
|
+
const heartbeat = heartbeatMod.default || heartbeatMod['module.exports'] || heartbeatMod
|
|
45
|
+
const runs = runsMod.default || runsMod['module.exports'] || runsMod
|
|
46
|
+
|
|
47
|
+
const now = Date.now()
|
|
48
|
+
storage.saveSettings({ loopMode: 'bounded' })
|
|
49
|
+
storage.saveAgents({
|
|
50
|
+
probe: {
|
|
51
|
+
id: 'probe',
|
|
52
|
+
name: 'Probe',
|
|
53
|
+
description: 'Heartbeat probe',
|
|
54
|
+
provider: 'openai',
|
|
55
|
+
model: 'gpt-test',
|
|
56
|
+
credentialId: null,
|
|
57
|
+
apiEndpoint: null,
|
|
58
|
+
fallbackCredentialIds: [],
|
|
59
|
+
heartbeatEnabled: false,
|
|
60
|
+
heartbeatIntervalSec: 1,
|
|
61
|
+
createdAt: now,
|
|
62
|
+
updatedAt: now,
|
|
63
|
+
plugins: [],
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
storage.saveSessions({
|
|
67
|
+
main: {
|
|
68
|
+
id: 'main',
|
|
69
|
+
name: 'Probe Main',
|
|
70
|
+
shortcutForAgentId: 'probe',
|
|
71
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
72
|
+
user: 'tester',
|
|
73
|
+
provider: 'openai',
|
|
74
|
+
model: 'gpt-test',
|
|
75
|
+
claudeSessionId: null,
|
|
76
|
+
messages: [{ role: 'user', text: 'Old task', time: now - 20_000 }],
|
|
77
|
+
createdAt: now - 20_000,
|
|
78
|
+
lastActiveAt: now - 20_000,
|
|
79
|
+
sessionType: 'human',
|
|
80
|
+
agentId: 'probe',
|
|
81
|
+
heartbeatEnabled: false,
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
heartbeat.startHeartbeatService()
|
|
86
|
+
await delay(6_500)
|
|
87
|
+
const later = runs.listRuns({ sessionId: 'main', limit: 20 })
|
|
88
|
+
heartbeat.stopHeartbeatService()
|
|
89
|
+
|
|
90
|
+
console.log(JSON.stringify({
|
|
91
|
+
laterCount: later.length,
|
|
92
|
+
laterSources: later.map((run) => run.source),
|
|
93
|
+
}))
|
|
94
|
+
`)
|
|
95
|
+
|
|
96
|
+
assert.equal(output.laterCount, 0)
|
|
97
|
+
assert.deepEqual(output.laterSources, [])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('fires periodic heartbeats only after the service tick window when enabled', () => {
|
|
101
|
+
const output = runWithTempDataDir(`
|
|
102
|
+
const { setTimeout: delay } = await import('node:timers/promises')
|
|
103
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
104
|
+
const heartbeatMod = await import('./src/lib/server/heartbeat-service.ts')
|
|
105
|
+
const runsMod = await import('./src/lib/server/session-run-manager.ts')
|
|
106
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
107
|
+
const heartbeat = heartbeatMod.default || heartbeatMod['module.exports'] || heartbeatMod
|
|
108
|
+
const runs = runsMod.default || runsMod['module.exports'] || runsMod
|
|
109
|
+
|
|
110
|
+
const now = Date.now()
|
|
111
|
+
storage.saveSettings({ loopMode: 'bounded' })
|
|
112
|
+
storage.saveAgents({
|
|
113
|
+
probe: {
|
|
114
|
+
id: 'probe',
|
|
115
|
+
name: 'Probe',
|
|
116
|
+
description: 'Heartbeat probe',
|
|
117
|
+
provider: 'openai',
|
|
118
|
+
model: 'gpt-test',
|
|
119
|
+
credentialId: null,
|
|
120
|
+
apiEndpoint: null,
|
|
121
|
+
fallbackCredentialIds: [],
|
|
122
|
+
heartbeatEnabled: true,
|
|
123
|
+
heartbeatIntervalSec: 1,
|
|
124
|
+
heartbeatInterval: '1s',
|
|
125
|
+
heartbeatPrompt: 'Reply HEARTBEAT_OK if idle.',
|
|
126
|
+
createdAt: now,
|
|
127
|
+
updatedAt: now,
|
|
128
|
+
plugins: [],
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
storage.saveSessions({
|
|
132
|
+
main: {
|
|
133
|
+
id: 'main',
|
|
134
|
+
name: 'Probe Main',
|
|
135
|
+
shortcutForAgentId: 'probe',
|
|
136
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
137
|
+
user: 'tester',
|
|
138
|
+
provider: 'openai',
|
|
139
|
+
model: 'gpt-test',
|
|
140
|
+
claudeSessionId: null,
|
|
141
|
+
messages: [{ role: 'user', text: 'Old task', time: now - 20_000 }],
|
|
142
|
+
createdAt: now - 20_000,
|
|
143
|
+
lastActiveAt: now - 20_000,
|
|
144
|
+
sessionType: 'human',
|
|
145
|
+
agentId: 'probe',
|
|
146
|
+
heartbeatEnabled: true,
|
|
147
|
+
heartbeatIntervalSec: 1,
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const startedAt = Date.now()
|
|
152
|
+
heartbeat.startHeartbeatService()
|
|
153
|
+
await delay(2_500)
|
|
154
|
+
const early = runs.listRuns({ sessionId: 'main', limit: 20 })
|
|
155
|
+
await delay(4_500)
|
|
156
|
+
const later = runs.listRuns({ sessionId: 'main', limit: 20 })
|
|
157
|
+
heartbeat.stopHeartbeatService()
|
|
158
|
+
|
|
159
|
+
console.log(JSON.stringify({
|
|
160
|
+
earlyCount: early.length,
|
|
161
|
+
laterCount: later.length,
|
|
162
|
+
laterSources: later.map((run) => run.source),
|
|
163
|
+
firstQueuedDeltaMs: later[0]?.queuedAt ? later[0].queuedAt - startedAt : null,
|
|
164
|
+
}))
|
|
165
|
+
`)
|
|
166
|
+
|
|
167
|
+
assert.equal(output.earlyCount, 0, 'no periodic heartbeat before the 5s service tick')
|
|
168
|
+
assert.ok(output.laterCount >= 1, 'expected at least one periodic heartbeat run after the tick window')
|
|
169
|
+
assert.ok((output.laterSources || []).includes('heartbeat'))
|
|
170
|
+
assert.equal(typeof output.firstQueuedDeltaMs, 'number')
|
|
171
|
+
assert.ok(output.firstQueuedDeltaMs >= 4_500, `expected first heartbeat after timer window, got ${output.firstQueuedDeltaMs}`)
|
|
172
|
+
})
|
|
173
|
+
})
|