@swarmclawai/swarmclaw 0.7.3 → 0.7.5
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 +47 -40
- package/bin/package-manager.js +157 -0
- package/bin/package-manager.test.js +90 -0
- package/bin/server-cmd.js +38 -7
- package/bin/swarmclaw.js +54 -4
- package/bin/update-cmd.js +48 -10
- package/bin/update-cmd.test.js +55 -0
- package/package.json +8 -3
- package/scripts/postinstall.mjs +26 -0
- package/src/app/api/agents/[id]/route.ts +17 -0
- package/src/app/api/agents/[id]/thread/route.ts +4 -87
- package/src/app/api/agents/route.ts +23 -1
- package/src/app/api/auth/route.ts +1 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
- package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/route.ts +6 -0
- package/src/app/api/chats/[id]/route.ts +12 -0
- package/src/app/api/chats/heartbeat/route.ts +2 -1
- package/src/app/api/chats/route.ts +7 -1
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
- package/src/app/api/external-agents/[id]/route.ts +31 -0
- package/src/app/api/external-agents/register/route.ts +3 -0
- package/src/app/api/external-agents/route.ts +66 -0
- package/src/app/api/gateways/[id]/health/route.ts +28 -0
- package/src/app/api/gateways/[id]/route.ts +79 -0
- package/src/app/api/gateways/route.ts +57 -0
- package/src/app/api/openclaw/gateway/route.ts +10 -7
- package/src/app/api/openclaw/skills/route.ts +1 -1
- package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
- package/src/app/api/schedules/[id]/route.ts +38 -9
- package/src/app/api/schedules/route.ts +51 -28
- package/src/app/api/settings/route.ts +6 -10
- package/src/app/api/setup/doctor/route.ts +6 -4
- package/src/app/api/tasks/[id]/route.ts +2 -1
- package/src/app/api/tasks/bulk/route.ts +2 -2
- package/src/app/page.tsx +126 -15
- package/src/cli/binary.test.js +142 -0
- package/src/cli/index.js +34 -11
- package/src/cli/index.test.js +195 -0
- package/src/cli/index.ts +20 -4
- package/src/cli/server-cmd.test.js +59 -0
- package/src/cli/spec.js +20 -2
- package/src/components/agents/agent-sheet.tsx +249 -7
- package/src/components/agents/inspector-panel.tsx +3 -2
- package/src/components/agents/sandbox-env-panel.tsx +4 -1
- package/src/components/auth/setup-wizard.tsx +970 -275
- package/src/components/chat/chat-area.tsx +41 -14
- package/src/components/chat/chat-card.tsx +2 -1
- package/src/components/chat/chat-header.tsx +8 -13
- package/src/components/chat/chat-list.tsx +58 -20
- package/src/components/chat/message-list.tsx +142 -18
- package/src/components/chatrooms/chatroom-input.tsx +96 -33
- package/src/components/chatrooms/chatroom-list.tsx +141 -72
- package/src/components/chatrooms/chatroom-message.tsx +7 -6
- package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
- package/src/components/chatrooms/chatroom-view.tsx +157 -86
- package/src/components/chatrooms/reaction-picker.tsx +38 -33
- package/src/components/gateways/gateway-sheet.tsx +567 -0
- package/src/components/input/chat-input.tsx +135 -86
- package/src/components/layout/app-layout.tsx +2 -0
- package/src/components/memory/memory-browser.tsx +71 -6
- package/src/components/memory/memory-card.tsx +18 -0
- package/src/components/memory/memory-detail.tsx +58 -31
- package/src/components/memory/memory-sheet.tsx +32 -4
- package/src/components/projects/project-detail.tsx +7 -2
- package/src/components/providers/provider-list.tsx +158 -2
- package/src/components/providers/provider-sheet.tsx +81 -70
- package/src/components/shared/bottom-sheet.tsx +31 -15
- package/src/components/shared/confirm-dialog.tsx +45 -30
- package/src/components/shared/model-combobox.tsx +90 -8
- package/src/components/shared/settings/section-heartbeat.tsx +11 -6
- package/src/components/shared/settings/section-orchestrator.tsx +3 -0
- package/src/components/shared/settings/settings-page.tsx +5 -3
- package/src/components/tasks/approvals-panel.tsx +7 -1
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
- package/src/lib/heartbeat-defaults.ts +48 -0
- package/src/lib/memory-presentation.ts +59 -0
- package/src/lib/provider-model-discovery-client.ts +29 -0
- package/src/lib/providers/index.ts +12 -5
- package/src/lib/runtime-loop.ts +105 -3
- package/src/lib/safe-storage.ts +6 -1
- package/src/lib/server/agent-runtime-config.test.ts +141 -0
- package/src/lib/server/agent-runtime-config.ts +277 -0
- package/src/lib/server/agent-thread-session.test.ts +85 -0
- package/src/lib/server/agent-thread-session.ts +123 -0
- package/src/lib/server/approvals-auto-approve.test.ts +59 -0
- package/src/lib/server/build-llm.test.ts +13 -5
- package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
- package/src/lib/server/chat-execution.ts +159 -71
- package/src/lib/server/chatroom-helpers.test.ts +7 -0
- package/src/lib/server/chatroom-helpers.ts +99 -6
- package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
- package/src/lib/server/connectors/manager.ts +89 -61
- package/src/lib/server/connectors/slack.ts +1 -1
- package/src/lib/server/daemon-state.ts +3 -2
- package/src/lib/server/data-dir.test.ts +56 -0
- package/src/lib/server/data-dir.ts +15 -9
- package/src/lib/server/eval/agent-regression.test.ts +47 -0
- package/src/lib/server/eval/agent-regression.ts +1742 -0
- package/src/lib/server/eval/runner.ts +11 -1
- package/src/lib/server/eval/store.ts +2 -1
- package/src/lib/server/heartbeat-service.ts +23 -8
- package/src/lib/server/heartbeat-wake.ts +6 -2
- package/src/lib/server/main-agent-loop.ts +13 -6
- package/src/lib/server/openclaw-exec-config.ts +4 -2
- package/src/lib/server/openclaw-gateway.ts +123 -36
- package/src/lib/server/orchestrator-lg.ts +1 -2
- package/src/lib/server/orchestrator.ts +3 -2
- package/src/lib/server/plugins.test.ts +9 -1
- package/src/lib/server/plugins.ts +12 -2
- package/src/lib/server/provider-model-discovery.ts +481 -0
- package/src/lib/server/queue.ts +1 -1
- package/src/lib/server/runtime-settings.test.ts +119 -0
- package/src/lib/server/runtime-settings.ts +12 -92
- package/src/lib/server/schedule-normalization.ts +187 -0
- package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
- package/src/lib/server/session-tools/crud.ts +27 -3
- package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
- package/src/lib/server/session-tools/discovery.ts +18 -8
- package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
- package/src/lib/server/session-tools/file.ts +8 -2
- package/src/lib/server/session-tools/http.ts +9 -3
- package/src/lib/server/session-tools/index.ts +31 -1
- package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
- package/src/lib/server/session-tools/monitor.ts +14 -7
- package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
- package/src/lib/server/session-tools/platform.ts +1 -1
- package/src/lib/server/session-tools/plugin-creator.ts +9 -2
- package/src/lib/server/session-tools/sandbox.ts +51 -92
- package/src/lib/server/session-tools/session-info.ts +22 -1
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
- package/src/lib/server/session-tools/shell.ts +2 -2
- package/src/lib/server/session-tools/subagent.ts +3 -1
- package/src/lib/server/session-tools/web.ts +73 -30
- package/src/lib/server/storage.ts +29 -3
- package/src/lib/server/stream-agent-chat.test.ts +61 -0
- package/src/lib/server/stream-agent-chat.ts +139 -4
- package/src/lib/server/structured-extract.ts +1 -1
- package/src/lib/server/task-mention.ts +0 -1
- package/src/lib/server/tool-aliases.ts +37 -6
- package/src/lib/server/tool-capability-policy.ts +1 -1
- package/src/lib/setup-defaults.ts +352 -11
- package/src/lib/tool-definitions.ts +3 -4
- package/src/lib/validation/schemas.ts +55 -1
- package/src/stores/use-app-store.ts +43 -1
- package/src/stores/use-chatroom-store.ts +153 -26
- package/src/types/index.ts +189 -6
- package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
|
@@ -58,7 +58,7 @@ function inferFileAction(
|
|
|
58
58
|
if (getFileEntryContent(normalized) !== undefined) return 'write'
|
|
59
59
|
if (dirPath) return 'list'
|
|
60
60
|
if (filePath) return 'read'
|
|
61
|
-
return
|
|
61
|
+
return 'list'
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function normalizeFileArgs(rawArgs: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -417,7 +417,13 @@ const FilePlugin: Plugin = {
|
|
|
417
417
|
name: 'Core Files',
|
|
418
418
|
description: 'Complete file management: read, write, list, move, copy, delete, and send.',
|
|
419
419
|
hooks: {
|
|
420
|
-
getCapabilityDescription: () => 'I can
|
|
420
|
+
getCapabilityDescription: () => 'I can manage files with the unified `files` tool (actions: `read`, `write`, `list`, `copy`, `move`, `delete`) and deliver finished artifacts with `send_file`.',
|
|
421
|
+
getOperatingGuidance: () => [
|
|
422
|
+
'The `files` tool always works best with an explicit action. Use `{"action":"list","dirPath":"."}` to inspect the workspace, `{"action":"read","filePath":"path/to/file.md"}` to inspect a file, and `{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}` to create or overwrite content.',
|
|
423
|
+
'For follow-up revision requests, read the current file first, then overwrite it with the improved version or use `edit_file` for a surgical change.',
|
|
424
|
+
'If a `files` call fails, correct the arguments and retry. Do not conclude that the workspace is inaccessible until an explicit read/list/write attempt with a path fails.',
|
|
425
|
+
'When `send_file` returns a download link, copy that link exactly instead of rewriting it.',
|
|
426
|
+
],
|
|
421
427
|
} as PluginHooks,
|
|
422
428
|
tools: [
|
|
423
429
|
{
|
|
@@ -68,12 +68,18 @@ async function executeHttpAction(args: HttpRequestArgs) {
|
|
|
68
68
|
*/
|
|
69
69
|
const HttpPlugin: Plugin = {
|
|
70
70
|
name: 'Core HTTP',
|
|
71
|
-
description: 'Make direct HTTP API calls
|
|
72
|
-
hooks: {
|
|
71
|
+
description: 'Make direct HTTP API calls without generating throwaway code.',
|
|
72
|
+
hooks: {
|
|
73
|
+
getCapabilityDescription: () => 'I can make direct HTTP requests (`http_request`) without writing code. Use this for straightforward API calls or fetching JSON.',
|
|
74
|
+
getOperatingGuidance: () => [
|
|
75
|
+
'Prefer `http_request` over `sandbox_exec` for straightforward REST or JSON API calls.',
|
|
76
|
+
'Keep API keys in plugin settings or SwarmClaw secrets instead of hardcoding them in generated code.',
|
|
77
|
+
],
|
|
78
|
+
} as PluginHooks,
|
|
73
79
|
tools: [
|
|
74
80
|
{
|
|
75
81
|
name: 'http_request',
|
|
76
|
-
description: 'Make an HTTP API request.',
|
|
82
|
+
description: 'Make an HTTP API request without generating code.',
|
|
77
83
|
parameters: {
|
|
78
84
|
type: 'object',
|
|
79
85
|
properties: {
|
|
@@ -25,6 +25,7 @@ import { buildWalletTools } from './wallet'
|
|
|
25
25
|
import { buildOpenClawWorkspaceTools } from './openclaw-workspace'
|
|
26
26
|
import { buildScheduleTools } from './schedule'
|
|
27
27
|
import { buildPlatformTools } from './platform'
|
|
28
|
+
import { buildCrudTools } from './crud'
|
|
28
29
|
import { buildSessionInfoTools } from './session-info'
|
|
29
30
|
import { buildOpenClawNodeTools } from './openclaw-nodes'
|
|
30
31
|
import { buildContextTools } from './context-mgmt'
|
|
@@ -179,6 +180,12 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
|
|
|
179
180
|
tools.push(...builtTools)
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
const crudTools = buildCrudTools(bctx)
|
|
184
|
+
for (const toolEntry of crudTools) {
|
|
185
|
+
toolToPluginMap[toolEntry.name] = toolEntry.name
|
|
186
|
+
}
|
|
187
|
+
tools.push(...crudTools)
|
|
188
|
+
|
|
182
189
|
// 2. Build Plugin Tools (Built-in + External)
|
|
183
190
|
try {
|
|
184
191
|
const pluginTools = pluginManager.getTools(activePlugins)
|
|
@@ -266,11 +273,34 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
|
|
|
266
273
|
const normalized = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
|
|
267
274
|
const toolId = normalized.toolId as string | undefined
|
|
268
275
|
const reason = normalized.reason as string | undefined
|
|
276
|
+
if (!toolId?.trim()) {
|
|
277
|
+
return JSON.stringify({
|
|
278
|
+
error: 'toolId is required',
|
|
279
|
+
message: 'Specify the exact plugin ID to request access for.',
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
const { requestApprovalMaybeAutoApprove } = await import('../approvals')
|
|
283
|
+
const approval = await requestApprovalMaybeAutoApprove({
|
|
284
|
+
category: 'tool_access',
|
|
285
|
+
title: `Enable Plugin: ${toolId}`,
|
|
286
|
+
description: reason || `Agent is requesting access to "${toolId}".`,
|
|
287
|
+
data: { toolId, pluginId: toolId, reason: reason || '' },
|
|
288
|
+
agentId: ctx?.agentId,
|
|
289
|
+
sessionId: ctx?.sessionId,
|
|
290
|
+
})
|
|
291
|
+
if (approval.status === 'approved') {
|
|
292
|
+
return JSON.stringify({
|
|
293
|
+
type: 'tool_request',
|
|
294
|
+
toolId,
|
|
295
|
+
autoApproved: true,
|
|
296
|
+
message: `Tool access for "${toolId}" was granted. Proceed to use it directly.`,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
269
299
|
return JSON.stringify({
|
|
270
300
|
type: 'tool_request',
|
|
271
301
|
toolId,
|
|
272
302
|
reason,
|
|
273
|
-
message: `Tool access request sent to user for "${toolId}".
|
|
303
|
+
message: `Tool access request sent to user for "${toolId}". Once granted, continue immediately with the original task using the newly available tool.`,
|
|
274
304
|
})
|
|
275
305
|
},
|
|
276
306
|
{
|
|
@@ -0,0 +1,137 @@
|
|
|
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-schedule-tool-'))
|
|
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
|
+
},
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
})
|
|
22
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
23
|
+
const lines = (result.stdout || '')
|
|
24
|
+
.trim()
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((line) => line.trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
29
|
+
return JSON.parse(jsonLine || '{}')
|
|
30
|
+
} finally {
|
|
31
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('manage_schedules tool', () => {
|
|
36
|
+
it('defaults schedules to the current agent and derives a runnable taskPrompt from run_script payloads', () => {
|
|
37
|
+
const output = runWithTempDataDir(`
|
|
38
|
+
import fs from 'node:fs'
|
|
39
|
+
import path from 'node:path'
|
|
40
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
41
|
+
const crudMod = await import('./src/lib/server/session-tools/crud.ts')
|
|
42
|
+
const storage = storageMod.default || storageMod
|
|
43
|
+
const crud = crudMod.default || crudMod
|
|
44
|
+
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
storage.saveAgents({
|
|
47
|
+
default: {
|
|
48
|
+
id: 'default',
|
|
49
|
+
name: 'Molly',
|
|
50
|
+
description: '',
|
|
51
|
+
systemPrompt: '',
|
|
52
|
+
provider: 'openai',
|
|
53
|
+
model: 'gpt-test',
|
|
54
|
+
createdAt: now,
|
|
55
|
+
updatedAt: now,
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const cwd = process.env.WORKSPACE_DIR
|
|
60
|
+
fs.mkdirSync(path.join(cwd, 'weather_workspace'), { recursive: true })
|
|
61
|
+
fs.writeFileSync(path.join(cwd, 'weather_workspace', 'weather_fetch.py'), 'print("weather")\\n')
|
|
62
|
+
|
|
63
|
+
const tools = crud.buildCrudTools({
|
|
64
|
+
cwd,
|
|
65
|
+
ctx: { sessionId: 'session-1', agentId: 'default', platformAssignScope: 'self' },
|
|
66
|
+
hasPlugin: (name) => name === 'manage_schedules',
|
|
67
|
+
})
|
|
68
|
+
const tool = tools.find((entry) => entry.name === 'manage_schedules')
|
|
69
|
+
const raw = await tool.invoke({
|
|
70
|
+
action: 'create',
|
|
71
|
+
data: JSON.stringify({
|
|
72
|
+
name: 'Daily Weather Update',
|
|
73
|
+
scheduleType: 'interval',
|
|
74
|
+
intervalMs: 60000,
|
|
75
|
+
action: 'run_script',
|
|
76
|
+
path: 'weather_workspace/weather_fetch.py',
|
|
77
|
+
}),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const schedule = Object.values(storage.loadSchedules())[0]
|
|
81
|
+
console.log(JSON.stringify({
|
|
82
|
+
raw,
|
|
83
|
+
schedule,
|
|
84
|
+
}))
|
|
85
|
+
`)
|
|
86
|
+
|
|
87
|
+
assert.equal(output.schedule.agentId, 'default')
|
|
88
|
+
assert.equal(output.schedule.path, 'weather_workspace/weather_fetch.py')
|
|
89
|
+
assert.match(output.schedule.taskPrompt, /weather_workspace\/weather_fetch\.py/)
|
|
90
|
+
assert.equal(output.schedule.status, 'active')
|
|
91
|
+
assert.equal(typeof output.schedule.nextRunAt, 'number')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('rejects schedules whose referenced script path does not exist', () => {
|
|
95
|
+
const output = runWithTempDataDir(`
|
|
96
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
97
|
+
const crudMod = await import('./src/lib/server/session-tools/crud.ts')
|
|
98
|
+
const storage = storageMod.default || storageMod
|
|
99
|
+
const crud = crudMod.default || crudMod
|
|
100
|
+
|
|
101
|
+
const now = Date.now()
|
|
102
|
+
storage.saveAgents({
|
|
103
|
+
default: {
|
|
104
|
+
id: 'default',
|
|
105
|
+
name: 'Molly',
|
|
106
|
+
description: '',
|
|
107
|
+
systemPrompt: '',
|
|
108
|
+
provider: 'openai',
|
|
109
|
+
model: 'gpt-test',
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const tools = crud.buildCrudTools({
|
|
116
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
117
|
+
ctx: { sessionId: 'session-2', agentId: 'default', platformAssignScope: 'self' },
|
|
118
|
+
hasPlugin: (name) => name === 'manage_schedules',
|
|
119
|
+
})
|
|
120
|
+
const tool = tools.find((entry) => entry.name === 'manage_schedules')
|
|
121
|
+
const raw = await tool.invoke({
|
|
122
|
+
action: 'create',
|
|
123
|
+
data: JSON.stringify({
|
|
124
|
+
name: 'Broken Weather Update',
|
|
125
|
+
scheduleType: 'interval',
|
|
126
|
+
intervalMs: 60000,
|
|
127
|
+
action: 'run_script',
|
|
128
|
+
path: 'weather_workspace/missing.py',
|
|
129
|
+
}),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
console.log(JSON.stringify({ raw }))
|
|
133
|
+
`)
|
|
134
|
+
|
|
135
|
+
assert.match(String(output.raw), /schedule path not found: weather_workspace\/missing\.py/i)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
3
|
import fs from 'fs'
|
|
4
|
-
import path from 'path'
|
|
5
4
|
import * as os from 'os'
|
|
6
5
|
import type { ToolBuildContext } from './context'
|
|
7
6
|
import { getPluginManager } from '../plugins'
|
|
@@ -89,17 +88,25 @@ async function createDurableWatch(
|
|
|
89
88
|
return JSON.stringify(job, null, 2)
|
|
90
89
|
}
|
|
91
90
|
|
|
91
|
+
function getErrorMessage(err: unknown): string {
|
|
92
|
+
return err instanceof Error ? err.message : String(err)
|
|
93
|
+
}
|
|
94
|
+
|
|
92
95
|
/**
|
|
93
96
|
* Unified Monitoring Logic
|
|
94
97
|
*/
|
|
95
|
-
async function executeMonitorAction(
|
|
98
|
+
async function executeMonitorAction(
|
|
99
|
+
args: Record<string, unknown> | undefined,
|
|
100
|
+
bctx: { cwd: string; sessionId?: string; agentId?: string | null },
|
|
101
|
+
) {
|
|
96
102
|
const normalized = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
|
|
97
103
|
const action = normalized.action as string | undefined
|
|
98
104
|
const target = (normalized.target ?? normalized.url ?? normalized.path) as string | undefined
|
|
99
105
|
const limit = normalized.limit as number | undefined
|
|
100
106
|
const threshold = normalized.threshold as number | undefined
|
|
101
107
|
const sessionId = typeof normalized.sessionId === 'string' ? normalized.sessionId : bctx.sessionId
|
|
102
|
-
|
|
108
|
+
void limit
|
|
109
|
+
void sessionId
|
|
103
110
|
|
|
104
111
|
try {
|
|
105
112
|
switch (action) {
|
|
@@ -150,10 +157,10 @@ async function executeMonitorAction(args: any, bctx: { cwd: string; sessionId?:
|
|
|
150
157
|
thresholdExceeded: typeof threshold === 'number' ? latency >= threshold : undefined,
|
|
151
158
|
url
|
|
152
159
|
}, null, 2)
|
|
153
|
-
} catch (err:
|
|
160
|
+
} catch (err: unknown) {
|
|
154
161
|
return JSON.stringify({
|
|
155
162
|
status: 'error',
|
|
156
|
-
error: err
|
|
163
|
+
error: getErrorMessage(err),
|
|
157
164
|
url
|
|
158
165
|
}, null, 2)
|
|
159
166
|
}
|
|
@@ -211,8 +218,8 @@ async function executeMonitorAction(args: any, bctx: { cwd: string; sessionId?:
|
|
|
211
218
|
default:
|
|
212
219
|
return `Error: Unknown action "${action}"`
|
|
213
220
|
}
|
|
214
|
-
} catch (err:
|
|
215
|
-
return `Error: ${err
|
|
221
|
+
} catch (err: unknown) {
|
|
222
|
+
return `Error: ${getErrorMessage(err)}`
|
|
216
223
|
}
|
|
217
224
|
}
|
|
218
225
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import { executeNodesAction } from './openclaw-nodes'
|
|
4
|
+
import type { OpenClawGateway } from '../openclaw-gateway'
|
|
5
|
+
|
|
6
|
+
test('executeNodesAction returns not_connected when no gateway is available', async () => {
|
|
7
|
+
const raw = await executeNodesAction(
|
|
8
|
+
{ action: 'list', profileId: 'gateway-1' },
|
|
9
|
+
{ ensureGatewayConnected: async () => null },
|
|
10
|
+
)
|
|
11
|
+
const result = JSON.parse(raw)
|
|
12
|
+
assert.equal(result.status, 'not_connected')
|
|
13
|
+
assert.match(result.message, /gateway not connected/i)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('executeNodesAction lists nodes against the selected gateway profile', async () => {
|
|
17
|
+
const calls: Array<{ method: string; params: unknown }> = []
|
|
18
|
+
const gateway = {
|
|
19
|
+
rpc: async (method: string, params?: unknown) => {
|
|
20
|
+
calls.push({ method, params })
|
|
21
|
+
return { ts: 1, nodes: [{ nodeId: 'node-1' }] }
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const raw = await executeNodesAction(
|
|
26
|
+
{ action: 'list', profileId: 'gateway-1' },
|
|
27
|
+
{ ensureGatewayConnected: async () => gateway as unknown as OpenClawGateway },
|
|
28
|
+
)
|
|
29
|
+
const result = JSON.parse(raw)
|
|
30
|
+
assert.equal(result.status, 'ok')
|
|
31
|
+
assert.equal(calls[0]?.method, 'node.list')
|
|
32
|
+
assert.deepEqual(calls[0]?.params, { profileId: 'gateway-1' })
|
|
33
|
+
assert.equal(result.result.nodes[0].nodeId, 'node-1')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('executeNodesAction aggregates node and device pairings', async () => {
|
|
37
|
+
const calls: string[] = []
|
|
38
|
+
const gateway = {
|
|
39
|
+
rpc: async (method: string) => {
|
|
40
|
+
calls.push(method)
|
|
41
|
+
if (method === 'node.pair.list') return { pending: [{ requestId: 'node-req-1' }] }
|
|
42
|
+
if (method === 'device.pair.list') return { pending: [{ requestId: 'device-req-1' }], paired: [{ deviceId: 'device-1' }] }
|
|
43
|
+
throw new Error(`Unexpected RPC ${method}`)
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const raw = await executeNodesAction(
|
|
48
|
+
{ action: 'pairings', profileId: 'gateway-1' },
|
|
49
|
+
{ ensureGatewayConnected: async () => gateway as unknown as OpenClawGateway },
|
|
50
|
+
)
|
|
51
|
+
const result = JSON.parse(raw)
|
|
52
|
+
assert.equal(result.status, 'ok')
|
|
53
|
+
assert.deepEqual(calls, ['node.pair.list', 'device.pair.list'])
|
|
54
|
+
assert.equal(result.result.nodePairings.pending[0].requestId, 'node-req-1')
|
|
55
|
+
assert.equal(result.result.devicePairings.paired[0].deviceId, 'device-1')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('executeNodesAction routes device pairing approvals to the device RPC surface', async () => {
|
|
59
|
+
const calls: Array<{ method: string; params: unknown }> = []
|
|
60
|
+
const gateway = {
|
|
61
|
+
rpc: async (method: string, params?: unknown) => {
|
|
62
|
+
calls.push({ method, params })
|
|
63
|
+
return { ok: true }
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const raw = await executeNodesAction(
|
|
68
|
+
{ action: 'approve_pairing', pairingType: 'device', requestId: 'req-1', profileId: 'gateway-1' },
|
|
69
|
+
{ ensureGatewayConnected: async () => gateway as unknown as OpenClawGateway },
|
|
70
|
+
)
|
|
71
|
+
const result = JSON.parse(raw)
|
|
72
|
+
assert.equal(result.status, 'ok')
|
|
73
|
+
assert.equal(calls[0]?.method, 'device.pair.approve')
|
|
74
|
+
assert.deepEqual(calls[0]?.params, { requestId: 'req-1', profileId: 'gateway-1' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('executeNodesAction forwards notify payloads through node.invoke with a generated idempotency key', async () => {
|
|
78
|
+
const calls: Array<{ method: string; params: unknown }> = []
|
|
79
|
+
const gateway = {
|
|
80
|
+
rpc: async (method: string, params?: unknown) => {
|
|
81
|
+
calls.push({ method, params })
|
|
82
|
+
return { delivered: true }
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const raw = await executeNodesAction(
|
|
87
|
+
{
|
|
88
|
+
action: 'notify',
|
|
89
|
+
profileId: 'gateway-1',
|
|
90
|
+
nodeId: 'node-42',
|
|
91
|
+
message: 'hello from test',
|
|
92
|
+
params: { urgency: 'high' },
|
|
93
|
+
timeoutMs: 5000,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
ensureGatewayConnected: async () => gateway as unknown as OpenClawGateway,
|
|
97
|
+
generateId: () => 'fixed-id',
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
const result = JSON.parse(raw)
|
|
101
|
+
assert.equal(result.status, 'ok')
|
|
102
|
+
assert.equal(calls[0]?.method, 'node.invoke')
|
|
103
|
+
assert.deepEqual(calls[0]?.params, {
|
|
104
|
+
nodeId: 'node-42',
|
|
105
|
+
command: 'notify',
|
|
106
|
+
params: { urgency: 'high', message: 'hello from test' },
|
|
107
|
+
timeoutMs: 5000,
|
|
108
|
+
idempotencyKey: 'fixed-id',
|
|
109
|
+
profileId: 'gateway-1',
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -1,46 +1,105 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { randomUUID } from 'crypto'
|
|
2
3
|
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
4
|
import type { ToolBuildContext } from './context'
|
|
4
5
|
import type { Plugin, PluginHooks } from '@/types'
|
|
5
6
|
import { getPluginManager } from '../plugins'
|
|
6
7
|
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
8
|
+
import { ensureGatewayConnected } from '../openclaw-gateway'
|
|
9
|
+
|
|
10
|
+
interface OpenClawNodesDeps {
|
|
11
|
+
ensureGatewayConnected?: typeof ensureGatewayConnected
|
|
12
|
+
generateId?: () => string
|
|
13
|
+
}
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* Core OpenClaw Nodes Execution Logic
|
|
10
17
|
*/
|
|
11
|
-
async function executeNodesAction(args: any) {
|
|
18
|
+
export async function executeNodesAction(args: any, deps: OpenClawNodesDeps = {}) {
|
|
12
19
|
const normalized = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
|
|
13
20
|
const action = normalized.action as string | undefined
|
|
14
21
|
const nodeId = (normalized.nodeId ?? normalized.node_id) as string | undefined
|
|
22
|
+
const deviceId = (normalized.deviceId ?? normalized.device_id) as string | undefined
|
|
23
|
+
const requestId = (normalized.requestId ?? normalized.request_id) as string | undefined
|
|
15
24
|
const message = normalized.message as string | undefined
|
|
16
25
|
const params = normalized.params as Record<string, unknown> | undefined
|
|
26
|
+
const command = (normalized.command ?? params?.command ?? params?.action) as string | undefined
|
|
27
|
+
const pairingType = typeof normalized.pairingType === 'string' ? normalized.pairingType : (typeof normalized.kind === 'string' ? normalized.kind : 'node')
|
|
28
|
+
const profileId = (normalized.profileId ?? normalized.gatewayProfileId ?? normalized.gateway_profile_id) as string | undefined
|
|
29
|
+
const agentId = (normalized.agentId ?? normalized.agent_id) as string | undefined
|
|
30
|
+
const timeoutMs = typeof normalized.timeoutMs === 'number'
|
|
31
|
+
? normalized.timeoutMs
|
|
32
|
+
: (typeof params?.timeoutMs === 'number' ? params.timeoutMs : undefined)
|
|
33
|
+
const ensureGatewayConnectedFn = deps.ensureGatewayConnected ?? ensureGatewayConnected
|
|
34
|
+
const generateId = deps.generateId ?? randomUUID
|
|
17
35
|
try {
|
|
18
|
-
const {
|
|
19
|
-
|
|
20
|
-
if (!openclawConnectors.length) {
|
|
36
|
+
const gateway = await ensureGatewayConnectedFn({ profileId, agentId })
|
|
37
|
+
if (!gateway) {
|
|
21
38
|
return JSON.stringify({
|
|
22
39
|
status: 'not_connected',
|
|
23
|
-
message: '
|
|
24
|
-
hint: '
|
|
40
|
+
message: 'OpenClaw gateway not connected.',
|
|
41
|
+
hint: 'Connect an OpenClaw gateway profile in Providers, then retry.',
|
|
25
42
|
})
|
|
26
43
|
}
|
|
27
|
-
|
|
28
|
-
if (
|
|
44
|
+
|
|
45
|
+
if (action === 'list') {
|
|
46
|
+
const result = await gateway.rpc('node.list', { profileId })
|
|
47
|
+
return JSON.stringify({ status: 'ok', action, result })
|
|
48
|
+
}
|
|
49
|
+
if (action === 'describe') {
|
|
50
|
+
if (!nodeId) return JSON.stringify({ status: 'error', error: 'nodeId is required for describe.' })
|
|
51
|
+
const result = await gateway.rpc('node.describe', { nodeId, profileId })
|
|
52
|
+
return JSON.stringify({ status: 'ok', action, nodeId, result })
|
|
53
|
+
}
|
|
54
|
+
if (action === 'pairings') {
|
|
55
|
+
const [nodePairings, devicePairings] = await Promise.all([
|
|
56
|
+
gateway.rpc('node.pair.list', { profileId }),
|
|
57
|
+
gateway.rpc('device.pair.list', { profileId }),
|
|
58
|
+
])
|
|
29
59
|
return JSON.stringify({
|
|
30
|
-
status: '
|
|
31
|
-
|
|
32
|
-
|
|
60
|
+
status: 'ok',
|
|
61
|
+
action,
|
|
62
|
+
result: {
|
|
63
|
+
nodePairings,
|
|
64
|
+
devicePairings,
|
|
65
|
+
},
|
|
33
66
|
})
|
|
34
67
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
68
|
+
if (action === 'approve_pairing') {
|
|
69
|
+
if (!requestId) return JSON.stringify({ status: 'error', error: 'requestId is required for approve_pairing.' })
|
|
70
|
+
const method = pairingType === 'device' ? 'device.pair.approve' : 'node.pair.approve'
|
|
71
|
+
const result = await gateway.rpc(method, { requestId, profileId })
|
|
72
|
+
return JSON.stringify({ status: 'ok', action, pairingType, requestId, result })
|
|
73
|
+
}
|
|
74
|
+
if (action === 'reject_pairing') {
|
|
75
|
+
if (!requestId) return JSON.stringify({ status: 'error', error: 'requestId is required for reject_pairing.' })
|
|
76
|
+
const method = pairingType === 'device' ? 'device.pair.reject' : 'node.pair.reject'
|
|
77
|
+
const result = await gateway.rpc(method, { requestId, profileId })
|
|
78
|
+
return JSON.stringify({ status: 'ok', action, pairingType, requestId, result })
|
|
38
79
|
}
|
|
39
|
-
if (action === '
|
|
40
|
-
return JSON.stringify({ status: '
|
|
80
|
+
if (action === 'remove_device') {
|
|
81
|
+
if (!deviceId) return JSON.stringify({ status: 'error', error: 'deviceId is required for remove_device.' })
|
|
82
|
+
const result = await gateway.rpc('device.pair.remove', { deviceId, profileId })
|
|
83
|
+
return JSON.stringify({ status: 'ok', action, deviceId, result })
|
|
41
84
|
}
|
|
42
|
-
if (action === 'invoke') {
|
|
43
|
-
return JSON.stringify({ status: '
|
|
85
|
+
if (action === 'notify' || action === 'invoke') {
|
|
86
|
+
if (!nodeId) return JSON.stringify({ status: 'error', error: 'nodeId is required for invoke.' })
|
|
87
|
+
const invokeCommand = typeof command === 'string' && command.trim()
|
|
88
|
+
? command.trim()
|
|
89
|
+
: (action === 'notify' ? 'notify' : '')
|
|
90
|
+
if (!invokeCommand) return JSON.stringify({ status: 'error', error: 'command is required for invoke.' })
|
|
91
|
+
const invokeParams = action === 'notify'
|
|
92
|
+
? { ...(params || {}), message }
|
|
93
|
+
: (params || {})
|
|
94
|
+
const result = await gateway.rpc('node.invoke', {
|
|
95
|
+
nodeId,
|
|
96
|
+
command: invokeCommand,
|
|
97
|
+
params: invokeParams,
|
|
98
|
+
timeoutMs,
|
|
99
|
+
idempotencyKey: generateId(),
|
|
100
|
+
profileId,
|
|
101
|
+
})
|
|
102
|
+
return JSON.stringify({ status: 'ok', action, nodeId, command: invokeCommand, result })
|
|
44
103
|
}
|
|
45
104
|
|
|
46
105
|
return JSON.stringify({ status: 'error', error: `Unknown nodes action "${action}".` })
|
|
@@ -63,10 +122,17 @@ const NodesPlugin: Plugin = {
|
|
|
63
122
|
parameters: {
|
|
64
123
|
type: 'object',
|
|
65
124
|
properties: {
|
|
66
|
-
action: { type: 'string', enum: ['list', 'notify', 'invoke'] },
|
|
125
|
+
action: { type: 'string', enum: ['list', 'describe', 'pairings', 'approve_pairing', 'reject_pairing', 'remove_device', 'notify', 'invoke'] },
|
|
67
126
|
nodeId: { type: 'string' },
|
|
127
|
+
deviceId: { type: 'string' },
|
|
128
|
+
requestId: { type: 'string' },
|
|
129
|
+
pairingType: { type: 'string', enum: ['node', 'device'] },
|
|
130
|
+
profileId: { type: 'string' },
|
|
131
|
+
agentId: { type: 'string' },
|
|
132
|
+
command: { type: 'string' },
|
|
68
133
|
message: { type: 'string' },
|
|
69
|
-
params: { type: 'object' }
|
|
134
|
+
params: { type: 'object' },
|
|
135
|
+
timeoutMs: { type: 'number' },
|
|
70
136
|
},
|
|
71
137
|
required: ['action']
|
|
72
138
|
},
|
|
@@ -190,7 +190,7 @@ const PlatformPlugin: Plugin = {
|
|
|
190
190
|
tools: [
|
|
191
191
|
{
|
|
192
192
|
name: 'manage_platform',
|
|
193
|
-
description: 'Unified tool for managing
|
|
193
|
+
description: 'Unified fallback tool for managing SwarmClaw resources when a more specific `manage_*` tool is not available. For create/update, pass resource + action, then either put fields inside data, pass them as top-level fields, or use a single resources[0].parameters envelope.',
|
|
194
194
|
parameters: {
|
|
195
195
|
type: 'object',
|
|
196
196
|
properties: {
|
|
@@ -245,8 +245,15 @@ Key rules:
|
|
|
245
245
|
*/
|
|
246
246
|
const PluginCreatorPlugin: Plugin = {
|
|
247
247
|
name: 'Plugin Creator',
|
|
248
|
-
description: 'Design
|
|
249
|
-
hooks: {
|
|
248
|
+
description: 'Design focused SwarmClaw plugins for durable capabilities and recurring automations.',
|
|
249
|
+
hooks: {
|
|
250
|
+
getCapabilityDescription: () => 'I can scaffold focused plugins (`plugin_creator_tool`) when a capability should become durable instead of living in a one-off sandbox script.',
|
|
251
|
+
getOperatingGuidance: () => [
|
|
252
|
+
'For recurring or scheduled automations, prefer a focused plugin plus `manage_schedules` over repeated sandbox runs.',
|
|
253
|
+
'Put API keys in plugin settings or SwarmClaw secrets instead of hardcoding them in plugin source.',
|
|
254
|
+
'Call `get_spec` before scaffolding so the plugin follows the current contract.',
|
|
255
|
+
],
|
|
256
|
+
} as PluginHooks,
|
|
250
257
|
tools: [
|
|
251
258
|
{
|
|
252
259
|
name: 'plugin_creator_tool',
|