@swarmclawai/swarmclaw 0.3.0 → 0.4.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 +20 -11
- package/bin/server-cmd.js +14 -7
- package/bin/swarmclaw.js +3 -1
- package/bin/update-cmd.js +120 -0
- package/next.config.ts +2 -0
- package/package.json +3 -1
- package/src/app/api/agents/[id]/route.ts +3 -0
- package/src/app/api/agents/[id]/thread/route.ts +2 -1
- package/src/app/api/agents/route.ts +5 -1
- package/src/app/api/auth/route.ts +3 -1
- package/src/app/api/claude-skills/route.ts +3 -1
- package/src/app/api/connectors/[id]/route.ts +4 -0
- package/src/app/api/connectors/route.ts +6 -1
- package/src/app/api/credentials/route.ts +3 -1
- package/src/app/api/daemon/route.ts +6 -1
- package/src/app/api/ip/route.ts +3 -1
- package/src/app/api/mcp-servers/route.ts +3 -1
- package/src/app/api/orchestrator/graph/route.ts +25 -0
- package/src/app/api/plugins/marketplace/route.ts +3 -1
- package/src/app/api/plugins/route.ts +3 -1
- package/src/app/api/providers/[id]/route.ts +3 -0
- package/src/app/api/providers/configs/route.ts +3 -1
- package/src/app/api/providers/route.ts +5 -1
- package/src/app/api/schedules/[id]/route.ts +3 -0
- package/src/app/api/schedules/route.ts +6 -1
- package/src/app/api/secrets/route.ts +3 -1
- package/src/app/api/sessions/[id]/chat/route.ts +5 -2
- package/src/app/api/sessions/route.ts +9 -2
- package/src/app/api/settings/route.ts +3 -1
- package/src/app/api/setup/doctor/route.ts +1 -0
- package/src/app/api/setup/openclaw-device/route.ts +3 -1
- package/src/app/api/skills/route.ts +3 -1
- package/src/app/api/tasks/[id]/approve/route.ts +73 -0
- package/src/app/api/tasks/[id]/route.ts +3 -0
- package/src/app/api/tasks/route.ts +3 -0
- package/src/app/api/usage/route.ts +3 -1
- package/src/app/api/version/route.ts +3 -1
- package/src/app/api/webhooks/[id]/route.ts +2 -1
- package/src/app/api/webhooks/route.ts +3 -1
- package/src/app/icon.svg +58 -0
- package/src/app/page.tsx +8 -2
- package/src/cli/index.js +1 -9
- package/src/cli/index.ts +51 -1
- package/src/cli/spec.js +0 -8
- package/src/components/agents/agent-card.tsx +1 -1
- package/src/components/agents/agent-sheet.tsx +63 -80
- package/src/components/chat/chat-area.tsx +44 -30
- package/src/components/chat/chat-tool-toggles.tsx +12 -53
- package/src/components/chat/message-bubble.tsx +110 -42
- package/src/components/chat/tool-call-bubble.tsx +41 -3
- package/src/components/chat/tool-request-banner.tsx +1 -9
- package/src/components/connectors/connector-list.tsx +3 -8
- package/src/components/connectors/connector-sheet.tsx +24 -29
- package/src/components/input/chat-input.tsx +72 -56
- package/src/components/knowledge/knowledge-list.tsx +27 -31
- package/src/components/layout/app-layout.tsx +92 -71
- package/src/components/layout/daemon-indicator.tsx +3 -5
- package/src/components/logs/log-list.tsx +5 -9
- package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
- package/src/components/memory/memory-detail.tsx +1 -1
- package/src/components/plugins/plugin-list.tsx +227 -27
- package/src/components/providers/provider-list.tsx +46 -13
- package/src/components/providers/provider-sheet.tsx +0 -45
- package/src/components/runs/run-list.tsx +6 -15
- package/src/components/schedules/schedule-card.tsx +54 -4
- package/src/components/schedules/schedule-list.tsx +6 -3
- package/src/components/schedules/schedule-sheet.tsx +0 -47
- package/src/components/secrets/secrets-list.tsx +20 -2
- package/src/components/sessions/new-session-sheet.tsx +8 -9
- package/src/components/shared/connector-platform-icon.tsx +22 -20
- package/src/components/shared/model-combobox.tsx +148 -0
- package/src/components/shared/settings/section-heartbeat.tsx +7 -39
- package/src/components/shared/settings/section-orchestrator.tsx +8 -9
- package/src/components/skills/skill-list.tsx +260 -34
- package/src/components/skills/skill-sheet.tsx +0 -45
- package/src/components/tasks/task-board.tsx +3 -6
- package/src/components/tasks/task-card.tsx +43 -1
- package/src/components/tasks/task-list.tsx +3 -5
- package/src/components/tasks/task-sheet.tsx +0 -44
- package/src/components/usage/usage-list.tsx +12 -4
- package/src/hooks/use-ws.ts +66 -0
- package/src/instrumentation.ts +2 -0
- package/src/lib/chat.ts +14 -2
- package/src/lib/providers/anthropic.ts +1 -1
- package/src/lib/providers/index.ts +2 -0
- package/src/lib/providers/ollama.ts +1 -1
- package/src/lib/providers/openai.ts +33 -12
- package/src/lib/server/chat-execution.ts +19 -4
- package/src/lib/server/connectors/manager.ts +9 -3
- package/src/lib/server/context-manager.ts +1 -1
- package/src/lib/server/daemon-state.ts +3 -0
- package/src/lib/server/data-dir.ts +1 -0
- package/src/lib/server/heartbeat-service.ts +67 -3
- package/src/lib/server/langgraph-checkpoint.ts +274 -0
- package/src/lib/server/main-agent-loop.ts +61 -2
- package/src/lib/server/orchestrator-lg.ts +394 -13
- package/src/lib/server/orchestrator.ts +25 -5
- package/src/lib/server/queue.ts +17 -3
- package/src/lib/server/session-run-manager.ts +6 -1
- package/src/lib/server/session-tools/delegate.ts +2 -2
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/sandbox.ts +164 -0
- package/src/lib/server/storage-mcp.test.ts +25 -2
- package/src/lib/server/storage.ts +24 -7
- package/src/lib/server/stream-agent-chat.ts +77 -22
- package/src/lib/server/task-validation.test.ts +23 -0
- package/src/lib/server/task-validation.ts +5 -3
- package/src/lib/server/ws-hub.ts +85 -0
- package/src/lib/tool-definitions.ts +42 -0
- package/src/lib/upload.ts +7 -1
- package/src/lib/ws-client.ts +124 -0
- package/src/stores/use-chat-store.ts +33 -13
- package/src/types/index.ts +8 -1
- package/src/app/api/agents/generate/route.ts +0 -42
- package/src/app/api/generate/info/route.ts +0 -12
- package/src/app/api/generate/route.ts +0 -106
- package/src/app/favicon.ico +0 -0
- package/src/components/shared/ai-gen-block.tsx +0 -77
|
@@ -619,8 +619,8 @@ export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterf
|
|
|
619
619
|
}
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
-
// delegate_to_agent: requires
|
|
623
|
-
if (
|
|
622
|
+
// delegate_to_agent: requires "Assign to Other Agents" (platformAssignScope: 'all')
|
|
623
|
+
if (ctx?.platformAssignScope === 'all' && ctx?.agentId) {
|
|
624
624
|
tools.push(
|
|
625
625
|
tool(
|
|
626
626
|
async ({ agentId: targetAgentId, task: taskPrompt, description: taskDesc, startImmediately }) => {
|
|
@@ -14,6 +14,7 @@ import { buildCrudTools } from './crud'
|
|
|
14
14
|
import { buildSessionInfoTools } from './session-info'
|
|
15
15
|
import { buildConnectorTools } from './connector'
|
|
16
16
|
import { buildContextTools } from './context-mgmt'
|
|
17
|
+
import { buildSandboxTools } from './sandbox'
|
|
17
18
|
|
|
18
19
|
export type { ToolContext, SessionToolsResult }
|
|
19
20
|
export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
|
|
@@ -93,6 +94,7 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
|
|
|
93
94
|
...buildSessionInfoTools(bctx),
|
|
94
95
|
...buildConnectorTools(bctx),
|
|
95
96
|
...buildContextTools(bctx),
|
|
97
|
+
...buildSandboxTools(bctx),
|
|
96
98
|
)
|
|
97
99
|
|
|
98
100
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import { spawnSync } from 'child_process'
|
|
6
|
+
import { UPLOAD_DIR } from '../storage'
|
|
7
|
+
import { findBinaryOnPath, truncate, MAX_OUTPUT } from './context'
|
|
8
|
+
import type { ToolBuildContext } from './context'
|
|
9
|
+
|
|
10
|
+
function getDenoPath(): string | null {
|
|
11
|
+
return findBinaryOnPath('deno')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPythonPath(): string | null {
|
|
15
|
+
return findBinaryOnPath('python3') ?? findBinaryOnPath('python')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const EXT_MAP: Record<string, string> = {
|
|
19
|
+
javascript: 'js',
|
|
20
|
+
typescript: 'ts',
|
|
21
|
+
python: 'py',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildSandboxTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
25
|
+
if (!bctx.hasTool('sandbox')) return []
|
|
26
|
+
|
|
27
|
+
const tools: StructuredToolInterface[] = []
|
|
28
|
+
|
|
29
|
+
tools.push(
|
|
30
|
+
tool(
|
|
31
|
+
async ({ language, code, timeoutSec }) => {
|
|
32
|
+
const timeout = Math.min(Math.max(timeoutSec ?? 60, 5), 300) * 1000
|
|
33
|
+
const ext = EXT_MAP[language]
|
|
34
|
+
const sessionId = bctx.ctx?.sessionId ?? 'unknown'
|
|
35
|
+
const sandboxDir = path.join('/tmp', `swarmclaw-sandbox-${sessionId}-${Date.now()}`)
|
|
36
|
+
|
|
37
|
+
// Check runtime availability
|
|
38
|
+
if ((language === 'javascript' || language === 'typescript') && !getDenoPath()) {
|
|
39
|
+
return JSON.stringify({ error: 'Deno is not installed. Install it with: curl -fsSL https://deno.land/install.sh | sh' })
|
|
40
|
+
}
|
|
41
|
+
if (language === 'python' && !getPythonPath()) {
|
|
42
|
+
return JSON.stringify({ error: 'Python is not installed. Install python3 to use Python sandbox.' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(sandboxDir, { recursive: true })
|
|
47
|
+
const scriptFile = `script.${ext}`
|
|
48
|
+
const scriptPath = path.join(sandboxDir, scriptFile)
|
|
49
|
+
fs.writeFileSync(scriptPath, code, 'utf-8')
|
|
50
|
+
|
|
51
|
+
let result: ReturnType<typeof spawnSync>
|
|
52
|
+
|
|
53
|
+
if (language === 'javascript' || language === 'typescript') {
|
|
54
|
+
const denoPath = getDenoPath()!
|
|
55
|
+
result = spawnSync(denoPath, [
|
|
56
|
+
'run',
|
|
57
|
+
'--allow-read=.',
|
|
58
|
+
'--allow-write=.',
|
|
59
|
+
'--allow-net',
|
|
60
|
+
'--deny-env',
|
|
61
|
+
'--no-prompt',
|
|
62
|
+
scriptFile,
|
|
63
|
+
], {
|
|
64
|
+
cwd: sandboxDir,
|
|
65
|
+
encoding: 'utf-8',
|
|
66
|
+
timeout,
|
|
67
|
+
maxBuffer: MAX_OUTPUT,
|
|
68
|
+
})
|
|
69
|
+
} else {
|
|
70
|
+
const pythonPath = getPythonPath()!
|
|
71
|
+
result = spawnSync(pythonPath, [scriptPath], {
|
|
72
|
+
cwd: sandboxDir,
|
|
73
|
+
encoding: 'utf-8',
|
|
74
|
+
timeout,
|
|
75
|
+
maxBuffer: MAX_OUTPUT,
|
|
76
|
+
env: { PATH: process.env.PATH || '/usr/bin:/bin' } as unknown as NodeJS.ProcessEnv,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const stdout = truncate((result.stdout || '').toString(), MAX_OUTPUT)
|
|
81
|
+
const stderr = truncate((result.stderr || '').toString(), MAX_OUTPUT)
|
|
82
|
+
const exitCode = result.status ?? (result.error ? 1 : 0)
|
|
83
|
+
const timedOut = result.error?.message?.includes('ETIMEDOUT') || result.signal === 'SIGTERM'
|
|
84
|
+
|
|
85
|
+
// Scan for created files (exclude the script itself)
|
|
86
|
+
const artifacts: { name: string; url: string }[] = []
|
|
87
|
+
try {
|
|
88
|
+
const files = fs.readdirSync(sandboxDir)
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
if (file === scriptFile) continue
|
|
91
|
+
const src = path.join(sandboxDir, file)
|
|
92
|
+
const stat = fs.statSync(src)
|
|
93
|
+
if (!stat.isFile()) continue
|
|
94
|
+
// Copy to upload dir
|
|
95
|
+
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
|
96
|
+
const destName = `sandbox-${Date.now()}-${file}`
|
|
97
|
+
const dest = path.join(UPLOAD_DIR, destName)
|
|
98
|
+
fs.copyFileSync(src, dest)
|
|
99
|
+
artifacts.push({
|
|
100
|
+
name: file,
|
|
101
|
+
url: `/api/uploads/${encodeURIComponent(destName)}`,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore scan errors
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return JSON.stringify({
|
|
109
|
+
exitCode,
|
|
110
|
+
timedOut,
|
|
111
|
+
stdout,
|
|
112
|
+
stderr,
|
|
113
|
+
artifacts,
|
|
114
|
+
})
|
|
115
|
+
} catch (err: unknown) {
|
|
116
|
+
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
117
|
+
} finally {
|
|
118
|
+
try { fs.rmSync(sandboxDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'sandbox_exec',
|
|
123
|
+
description:
|
|
124
|
+
'Execute code in an isolated sandbox. JS/TS runs via Deno with network access but no env vars. Python runs with a stripped environment. ' +
|
|
125
|
+
'Files created in the sandbox directory are returned as downloadable artifact URLs. Use this for data processing, API calls, calculations, and file generation.',
|
|
126
|
+
schema: z.object({
|
|
127
|
+
language: z.enum(['javascript', 'typescript', 'python']).describe('Programming language to execute'),
|
|
128
|
+
code: z.string().describe('Source code to run'),
|
|
129
|
+
timeoutSec: z.number().optional().describe('Execution timeout in seconds (default 60, max 300)'),
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
tools.push(
|
|
136
|
+
tool(
|
|
137
|
+
async () => {
|
|
138
|
+
const denoPath = getDenoPath()
|
|
139
|
+
const pythonPath = getPythonPath()
|
|
140
|
+
|
|
141
|
+
const runtimes: Record<string, { available: boolean; path: string | null; version: string | null }> = {}
|
|
142
|
+
|
|
143
|
+
for (const [name, bin] of [['deno', denoPath], ['python', pythonPath]] as const) {
|
|
144
|
+
if (bin) {
|
|
145
|
+
const ver = spawnSync(bin, ['--version'], { encoding: 'utf-8', timeout: 3000 })
|
|
146
|
+
const version = (ver.stdout || '').split('\n')[0]?.trim() || null
|
|
147
|
+
runtimes[name] = { available: true, path: bin, version }
|
|
148
|
+
} else {
|
|
149
|
+
runtimes[name] = { available: false, path: null, version: null }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return JSON.stringify(runtimes)
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'sandbox_list_runtimes',
|
|
157
|
+
description: 'List available sandbox runtimes (Deno for JS/TS, Python) and their versions. Use this to check what languages are available before running code.',
|
|
158
|
+
schema: z.object({}),
|
|
159
|
+
},
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return tools
|
|
164
|
+
}
|
|
@@ -33,8 +33,15 @@ function loadMcpServers(): Record<string, any> {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function saveMcpServers(m: Record<string, any>) {
|
|
36
|
+
const existingRows = db.prepare(`SELECT id FROM ${TABLE}`).all() as { id: string }[]
|
|
37
|
+
const nextIds = new Set(Object.keys(m))
|
|
38
|
+
const toDelete = existingRows.map((r) => r.id).filter((id) => !nextIds.has(id))
|
|
36
39
|
const upsert = db.prepare(`INSERT OR REPLACE INTO ${TABLE} (id, data) VALUES (?, ?)`)
|
|
40
|
+
const del = db.prepare(`DELETE FROM ${TABLE} WHERE id = ?`)
|
|
37
41
|
const transaction = db.transaction(() => {
|
|
42
|
+
for (const id of toDelete) {
|
|
43
|
+
del.run(id)
|
|
44
|
+
}
|
|
38
45
|
for (const [id, val] of Object.entries(m)) {
|
|
39
46
|
upsert.run(id, JSON.stringify(val))
|
|
40
47
|
}
|
|
@@ -75,8 +82,10 @@ describe('MCP server storage', () => {
|
|
|
75
82
|
})
|
|
76
83
|
|
|
77
84
|
it('loadMcpServers returns all saved configs', () => {
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
saveMcpServers({
|
|
86
|
+
'srv-1': { id: 'srv-1', name: 'First' },
|
|
87
|
+
'srv-2': { id: 'srv-2', name: 'Second' },
|
|
88
|
+
})
|
|
80
89
|
|
|
81
90
|
const all = loadMcpServers()
|
|
82
91
|
assert.ok('srv-1' in all)
|
|
@@ -102,6 +111,20 @@ describe('MCP server storage', () => {
|
|
|
102
111
|
assert.equal(count, 1)
|
|
103
112
|
})
|
|
104
113
|
|
|
114
|
+
it('saveMcpServers removes records omitted from the next save payload', () => {
|
|
115
|
+
saveMcpServers({
|
|
116
|
+
'srv-a': { id: 'srv-a', name: 'A' },
|
|
117
|
+
'srv-b': { id: 'srv-b', name: 'B' },
|
|
118
|
+
})
|
|
119
|
+
saveMcpServers({
|
|
120
|
+
'srv-b': { id: 'srv-b', name: 'B2' },
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const all = loadMcpServers()
|
|
124
|
+
assert.equal('srv-a' in all, false)
|
|
125
|
+
assert.equal(all['srv-b'].name, 'B2')
|
|
126
|
+
})
|
|
127
|
+
|
|
105
128
|
it('deleteMcpServer removes the record', () => {
|
|
106
129
|
saveMcpServers({ 'srv-d': { id: 'srv-d', name: 'ToDelete' } })
|
|
107
130
|
deleteMcpServer('srv-d')
|
|
@@ -4,11 +4,11 @@ import crypto from 'crypto'
|
|
|
4
4
|
import os from 'os'
|
|
5
5
|
import Database from 'better-sqlite3'
|
|
6
6
|
|
|
7
|
-
import { DATA_DIR } from './data-dir'
|
|
8
|
-
export const UPLOAD_DIR = path.join(
|
|
7
|
+
import { DATA_DIR, WORKSPACE_DIR } from './data-dir'
|
|
8
|
+
export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
|
|
9
9
|
|
|
10
10
|
// Ensure directories exist
|
|
11
|
-
for (const dir of [DATA_DIR, UPLOAD_DIR]) {
|
|
11
|
+
for (const dir of [DATA_DIR, UPLOAD_DIR, WORKSPACE_DIR]) {
|
|
12
12
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -65,8 +65,8 @@ function readCollectionRaw(table: string): Map<string, string> {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function getCollectionRawCache(table: string): Map<string, string> {
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// Always reload from SQLite so concurrent Next.js workers/processes
|
|
69
|
+
// observe each other's writes immediately.
|
|
70
70
|
const loaded = readCollectionRaw(table)
|
|
71
71
|
collectionCache.set(table, loaded)
|
|
72
72
|
return loaded
|
|
@@ -87,26 +87,43 @@ function loadCollection(table: string): Record<string, any> {
|
|
|
87
87
|
|
|
88
88
|
function saveCollection(table: string, data: Record<string, any>) {
|
|
89
89
|
const current = getCollectionRawCache(table)
|
|
90
|
+
const next = new Map<string, string>()
|
|
90
91
|
const toUpsert: Array<[string, string]> = []
|
|
92
|
+
const toDelete: string[] = []
|
|
91
93
|
|
|
92
94
|
for (const [id, val] of Object.entries(data)) {
|
|
93
95
|
const serialized = JSON.stringify(val)
|
|
94
96
|
if (typeof serialized !== 'string') continue
|
|
97
|
+
next.set(id, serialized)
|
|
95
98
|
if (current.get(id) !== serialized) {
|
|
96
99
|
toUpsert.push([id, serialized])
|
|
97
100
|
}
|
|
98
|
-
current.set(id, serialized)
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
|
|
103
|
+
for (const id of current.keys()) {
|
|
104
|
+
if (!next.has(id)) toDelete.push(id)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!toUpsert.length && !toDelete.length) return
|
|
102
108
|
|
|
103
109
|
const transaction = db.transaction(() => {
|
|
110
|
+
if (toDelete.length) {
|
|
111
|
+
const del = db.prepare(`DELETE FROM ${table} WHERE id = ?`)
|
|
112
|
+
for (const id of toDelete) del.run(id)
|
|
113
|
+
}
|
|
104
114
|
const upsert = db.prepare(`INSERT OR REPLACE INTO ${table} (id, data) VALUES (?, ?)`)
|
|
105
115
|
for (const [id, serialized] of toUpsert) {
|
|
106
116
|
upsert.run(id, serialized)
|
|
107
117
|
}
|
|
108
118
|
})
|
|
109
119
|
transaction()
|
|
120
|
+
|
|
121
|
+
for (const id of toDelete) {
|
|
122
|
+
current.delete(id)
|
|
123
|
+
}
|
|
124
|
+
for (const [id, serialized] of next.entries()) {
|
|
125
|
+
current.set(id, serialized)
|
|
126
|
+
}
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
function deleteCollectionItem(table: string, id: string) {
|
|
@@ -15,6 +15,7 @@ interface StreamAgentChatOpts {
|
|
|
15
15
|
session: Session
|
|
16
16
|
message: string
|
|
17
17
|
imagePath?: string
|
|
18
|
+
attachedFiles?: string[]
|
|
18
19
|
apiKey: string | null
|
|
19
20
|
systemPrompt?: string
|
|
20
21
|
write: (data: string) => void
|
|
@@ -38,6 +39,7 @@ function buildToolCapabilityLines(enabledTools: string[]): string[] {
|
|
|
38
39
|
if (enabledTools.includes('codex_cli')) lines.push('- Codex delegation is available (`delegate_to_codex_cli`) for deep coding/refactor tasks. Resume IDs may be returned via `[delegate_meta]`.')
|
|
39
40
|
if (enabledTools.includes('opencode_cli')) lines.push('- OpenCode delegation is available (`delegate_to_opencode_cli`) for deep coding/refactor tasks. Resume IDs may be returned via `[delegate_meta]`.')
|
|
40
41
|
if (enabledTools.includes('memory')) lines.push('- Long-term memory is available (`memory_tool`) to store and recall durable context.')
|
|
42
|
+
if (enabledTools.includes('sandbox')) lines.push('- Sandboxed code execution is available (`sandbox_exec`). Write and run JS/TS (Deno) or Python scripts in an isolated environment. Output includes stdout, stderr, and any files created as downloadable artifacts.')
|
|
41
43
|
if (enabledTools.includes('manage_agents')) lines.push('- Agent management is available (`manage_agents`) to create or adjust specialist agents.')
|
|
42
44
|
if (enabledTools.includes('manage_tasks')) lines.push('- Task management is available (`manage_tasks`) to create and track execution plans.')
|
|
43
45
|
if (enabledTools.includes('manage_schedules')) lines.push('- Schedule management is available (`manage_schedules`) for recurring/ongoing runs.')
|
|
@@ -162,7 +164,7 @@ export interface StreamAgentChatResult {
|
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<StreamAgentChatResult> {
|
|
165
|
-
const { session, message, imagePath, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
|
|
167
|
+
const { session, message, imagePath, attachedFiles, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
|
|
166
168
|
|
|
167
169
|
// fallbackCredentialIds is intentionally accepted for compatibility with caller signatures.
|
|
168
170
|
void fallbackCredentialIds
|
|
@@ -310,7 +312,6 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
310
312
|
const allToolIds = [
|
|
311
313
|
'shell', 'files', 'edit_file', 'process', 'web_search', 'web_fetch', 'browser', 'memory',
|
|
312
314
|
'claude_code', 'codex_cli', 'opencode_cli',
|
|
313
|
-
'orchestrator',
|
|
314
315
|
'manage_agents', 'manage_tasks', 'manage_schedules', 'manage_skills',
|
|
315
316
|
'manage_documents', 'manage_webhooks', 'manage_connectors', 'manage_sessions', 'manage_secrets',
|
|
316
317
|
]
|
|
@@ -318,13 +319,9 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
318
319
|
const mcpDisabled = agentMcpDisabledTools ?? []
|
|
319
320
|
const allDisabled = [...disabled, ...mcpDisabled]
|
|
320
321
|
if (allDisabled.length > 0) {
|
|
321
|
-
const delegateNote = disabled.includes('orchestrator')
|
|
322
|
-
? '\n\nIMPORTANT: The `delegate_to_agent` tool requires the `orchestrator` capability to be enabled. You must request access to `orchestrator` before you can delegate work to other agents.'
|
|
323
|
-
: ''
|
|
324
322
|
stateModifierParts.push(
|
|
325
323
|
`## Disabled Tools\nThe following tools exist but are not enabled for you: ${allDisabled.join(', ')}.\n` +
|
|
326
|
-
'If you need one of these to complete a task, use the `request_tool_access` tool to ask the user for permission.'
|
|
327
|
-
delegateNote,
|
|
324
|
+
'If you need one of these to complete a task, use the `request_tool_access` tool to ask the user for permission.',
|
|
328
325
|
)
|
|
329
326
|
}
|
|
330
327
|
}
|
|
@@ -354,25 +351,82 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
354
351
|
const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
|
|
355
352
|
const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
|
|
356
353
|
|
|
357
|
-
function
|
|
358
|
-
if (!
|
|
354
|
+
async function buildContentForFile(filePath: string): Promise<{ type: string; [k: string]: any } | string | null> {
|
|
355
|
+
if (!fs.existsSync(filePath)) {
|
|
356
|
+
console.log(`[stream-agent-chat] FILE NOT FOUND: ${filePath}`)
|
|
357
|
+
return null
|
|
358
|
+
}
|
|
359
|
+
const name = filePath.split('/').pop() || 'file'
|
|
359
360
|
if (IMAGE_EXTS.test(filePath)) {
|
|
360
|
-
const
|
|
361
|
+
const buf = fs.readFileSync(filePath)
|
|
362
|
+
if (buf.length === 0) {
|
|
363
|
+
console.warn(`[stream-agent-chat] Image file is empty: ${filePath}`)
|
|
364
|
+
return `[Attached image: ${name} — file is empty]`
|
|
365
|
+
}
|
|
366
|
+
const data = buf.toString('base64')
|
|
361
367
|
const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
]
|
|
368
|
+
// Detect actual MIME from magic bytes (fall back to extension-based)
|
|
369
|
+
let mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
|
|
370
|
+
if (buf[0] === 0xFF && buf[1] === 0xD8) mimeType = 'image/jpeg'
|
|
371
|
+
else if (buf[0] === 0x89 && buf[1] === 0x50) mimeType = 'image/png'
|
|
372
|
+
else if (buf[0] === 0x47 && buf[1] === 0x49) mimeType = 'image/gif'
|
|
373
|
+
else if (buf[0] === 0x52 && buf[1] === 0x49) mimeType = 'image/webp'
|
|
374
|
+
return { type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}`, detail: 'auto' } }
|
|
367
375
|
}
|
|
368
|
-
if (
|
|
376
|
+
if (filePath.endsWith('.pdf')) {
|
|
377
|
+
try {
|
|
378
|
+
// @ts-ignore — pdf-parse types
|
|
379
|
+
const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
|
|
380
|
+
const buf = fs.readFileSync(filePath)
|
|
381
|
+
const result = await pdfParse(buf)
|
|
382
|
+
const pdfText = (result.text || '').trim()
|
|
383
|
+
if (!pdfText) return `[Attached PDF: ${name} — no extractable text]`
|
|
384
|
+
// Truncate very large PDFs to avoid token limits
|
|
385
|
+
const maxChars = 100_000
|
|
386
|
+
const truncated = pdfText.length > maxChars ? pdfText.slice(0, maxChars) + '\n\n[... truncated]' : pdfText
|
|
387
|
+
return `[Attached PDF: ${name} (${result.numpages} pages)]\n\n${truncated}`
|
|
388
|
+
} catch {
|
|
389
|
+
return `[Attached PDF: ${name} — could not extract text]`
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (TEXT_EXTS.test(filePath)) {
|
|
369
393
|
try {
|
|
370
394
|
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
} catch { return text }
|
|
395
|
+
return `[Attached file: ${name}]\n\n${fileContent}`
|
|
396
|
+
} catch { return `[Attached file: ${name} — read error]` }
|
|
374
397
|
}
|
|
375
|
-
return `[Attached file: ${
|
|
398
|
+
return `[Attached file: ${name}]`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function buildLangChainContent(text: string, filePath?: string, extraFiles?: string[]): Promise<any> {
|
|
402
|
+
const filePaths: string[] = []
|
|
403
|
+
if (filePath) filePaths.push(filePath)
|
|
404
|
+
if (extraFiles?.length) {
|
|
405
|
+
for (const f of extraFiles) {
|
|
406
|
+
if (f && !filePaths.includes(f)) filePaths.push(f)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!filePaths.length) return text
|
|
410
|
+
|
|
411
|
+
const parts: any[] = []
|
|
412
|
+
const textParts: string[] = []
|
|
413
|
+
for (const fp of filePaths) {
|
|
414
|
+
const content = await buildContentForFile(fp)
|
|
415
|
+
if (!content) continue
|
|
416
|
+
if (typeof content === 'string') {
|
|
417
|
+
textParts.push(content)
|
|
418
|
+
} else {
|
|
419
|
+
parts.push(content)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const combinedText = textParts.length
|
|
424
|
+
? `${textParts.join('\n\n')}\n\n${text}`
|
|
425
|
+
: text
|
|
426
|
+
|
|
427
|
+
if (parts.length === 0) return combinedText
|
|
428
|
+
parts.push({ type: 'text', text: combinedText })
|
|
429
|
+
return parts
|
|
376
430
|
}
|
|
377
431
|
|
|
378
432
|
// Auto-compaction: prune old history if approaching context window limit
|
|
@@ -397,14 +451,15 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
397
451
|
const langchainMessages: Array<HumanMessage | AIMessage> = []
|
|
398
452
|
for (const m of effectiveHistory.slice(-20)) {
|
|
399
453
|
if (m.role === 'user') {
|
|
400
|
-
langchainMessages.push(new HumanMessage({ content: buildLangChainContent(m.text, m.imagePath) }))
|
|
454
|
+
langchainMessages.push(new HumanMessage({ content: await buildLangChainContent(m.text, m.imagePath, m.attachedFiles) }))
|
|
401
455
|
} else {
|
|
402
456
|
langchainMessages.push(new AIMessage({ content: m.text }))
|
|
403
457
|
}
|
|
404
458
|
}
|
|
405
459
|
|
|
406
460
|
// Add current message
|
|
407
|
-
|
|
461
|
+
const currentContent = await buildLangChainContent(message, imagePath, attachedFiles)
|
|
462
|
+
langchainMessages.push(new HumanMessage({ content: currentContent }))
|
|
408
463
|
|
|
409
464
|
let fullText = ''
|
|
410
465
|
let lastSegment = ''
|
|
@@ -25,3 +25,26 @@ test('validateTaskCompletion accepts screenshot delivery tasks with upload artif
|
|
|
25
25
|
|
|
26
26
|
assert.equal(validation.ok, true)
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
test('validateTaskCompletion accepts concise non-implementation result summaries', () => {
|
|
30
|
+
const validation = validateTaskCompletion({
|
|
31
|
+
title: 'Answer greeting',
|
|
32
|
+
description: 'Respond to a basic hello prompt.',
|
|
33
|
+
result: 'Hello! How can I help you today?',
|
|
34
|
+
error: null,
|
|
35
|
+
} as Partial<BoardTask>)
|
|
36
|
+
|
|
37
|
+
assert.equal(validation.ok, true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('validateTaskCompletion still enforces stricter minimum for implementation tasks', () => {
|
|
41
|
+
const validation = validateTaskCompletion({
|
|
42
|
+
title: 'Fix retry bug',
|
|
43
|
+
description: 'Implement queue retry fixes and verify.',
|
|
44
|
+
result: 'Patched queue retry bug.',
|
|
45
|
+
error: null,
|
|
46
|
+
} as Partial<BoardTask>)
|
|
47
|
+
|
|
48
|
+
assert.equal(validation.ok, false)
|
|
49
|
+
assert.ok(validation.reasons.some((reason) => reason.includes('Result summary is too short')))
|
|
50
|
+
})
|
|
@@ -11,7 +11,8 @@ interface TaskCompletionValidationOptions {
|
|
|
11
11
|
report?: TaskReportArtifact | null
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const MIN_RESULT_CHARS_IMPLEMENTATION = 40
|
|
15
|
+
const MIN_RESULT_CHARS_GENERIC = 20
|
|
15
16
|
|
|
16
17
|
const WEAK_RESULT_PATTERNS: RegExp[] = [
|
|
17
18
|
/what can i help you with/i,
|
|
@@ -45,12 +46,14 @@ export function validateTaskCompletion(
|
|
|
45
46
|
const result = normalizeText(task.result)
|
|
46
47
|
const error = normalizeText(task.error)
|
|
47
48
|
const report = options.report || null
|
|
49
|
+
const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
|
|
48
50
|
|
|
49
51
|
if (error) reasons.push('Task has a non-empty error field.')
|
|
50
52
|
|
|
51
53
|
if (!result) reasons.push('Result summary is empty.')
|
|
52
54
|
else {
|
|
53
|
-
|
|
55
|
+
const minChars = implementationTask ? MIN_RESULT_CHARS_IMPLEMENTATION : MIN_RESULT_CHARS_GENERIC
|
|
56
|
+
if (result.length < minChars) reasons.push(`Result summary is too short (${result.length} chars; min ${minChars}).`)
|
|
54
57
|
if (WEAK_RESULT_PATTERNS.some((rx) => rx.test(result))) {
|
|
55
58
|
reasons.push('Result contains placeholder/planning language instead of completion evidence.')
|
|
56
59
|
}
|
|
@@ -58,7 +61,6 @@ export function validateTaskCompletion(
|
|
|
58
61
|
|
|
59
62
|
// If task description/title suggests implementation work, require concrete evidence in
|
|
60
63
|
// the result summary OR task report.
|
|
61
|
-
const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
|
|
62
64
|
const hasResultEvidence = EXECUTION_EVIDENCE.test(result)
|
|
63
65
|
const hasReportEvidence = report?.evidence.hasEvidence === true
|
|
64
66
|
if (implementationTask && !hasResultEvidence && !hasReportEvidence) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws'
|
|
2
|
+
import type { IncomingMessage } from 'http'
|
|
3
|
+
import { validateAccessKey } from './storage'
|
|
4
|
+
|
|
5
|
+
interface WsClient {
|
|
6
|
+
ws: WebSocket
|
|
7
|
+
topics: Set<string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface WsHub {
|
|
11
|
+
wss: WebSocketServer
|
|
12
|
+
clients: Set<WsClient>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const GK = '__swarmclaw_ws__' as const
|
|
16
|
+
|
|
17
|
+
function getHub(): WsHub | null {
|
|
18
|
+
return (globalThis as any)[GK] ?? null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function initWsServer() {
|
|
22
|
+
if (getHub()) return
|
|
23
|
+
|
|
24
|
+
const port = Number(process.env.WS_PORT) || (Number(process.env.PORT) || 3456) + 1
|
|
25
|
+
const wss = new WebSocketServer({ port, path: '/ws' })
|
|
26
|
+
const clients = new Set<WsClient>()
|
|
27
|
+
|
|
28
|
+
const hub: WsHub = { wss, clients }
|
|
29
|
+
;(globalThis as any)[GK] = hub
|
|
30
|
+
|
|
31
|
+
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
|
32
|
+
// Auth: validate ?key= from upgrade URL
|
|
33
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
|
|
34
|
+
const key = url.searchParams.get('key') || ''
|
|
35
|
+
if (!validateAccessKey(key)) {
|
|
36
|
+
ws.close(4001, 'Unauthorized')
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const client: WsClient = { ws, topics: new Set() }
|
|
41
|
+
clients.add(client)
|
|
42
|
+
|
|
43
|
+
ws.on('message', (raw) => {
|
|
44
|
+
try {
|
|
45
|
+
const msg = JSON.parse(String(raw))
|
|
46
|
+
if (msg.type === 'subscribe' && Array.isArray(msg.topics)) {
|
|
47
|
+
for (const t of msg.topics) {
|
|
48
|
+
if (typeof t === 'string') client.topics.add(t)
|
|
49
|
+
}
|
|
50
|
+
} else if (msg.type === 'unsubscribe' && Array.isArray(msg.topics)) {
|
|
51
|
+
for (const t of msg.topics) client.topics.delete(t)
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore malformed messages
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
ws.on('close', () => {
|
|
59
|
+
clients.delete(client)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
ws.on('error', () => {
|
|
63
|
+
clients.delete(client)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
wss.on('error', (err) => {
|
|
68
|
+
console.error('[ws-hub] WebSocket server error:', err.message)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
console.log(`[ws-hub] WebSocket server listening on port ${port}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function notify(topic: string, action = 'update', id?: string) {
|
|
75
|
+
const hub = getHub()
|
|
76
|
+
if (!hub) return
|
|
77
|
+
|
|
78
|
+
const payload = JSON.stringify(id ? { topic, action, id } : { topic, action })
|
|
79
|
+
|
|
80
|
+
for (const client of hub.clients) {
|
|
81
|
+
if (client.topics.has(topic) && client.ws.readyState === WebSocket.OPEN) {
|
|
82
|
+
client.ws.send(payload)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface ToolDefinition {
|
|
2
|
+
id: string
|
|
3
|
+
label: string
|
|
4
|
+
description: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const AVAILABLE_TOOLS: ToolDefinition[] = [
|
|
8
|
+
{ id: 'shell', label: 'Shell', description: 'Execute commands in the working directory' },
|
|
9
|
+
{ id: 'files', label: 'Files', description: 'Read, write, list, move, copy, and send files' },
|
|
10
|
+
{ id: 'copy_file', label: 'Copy File', description: 'Copy files within the working directory' },
|
|
11
|
+
{ id: 'move_file', label: 'Move File', description: 'Move/rename files within the working directory' },
|
|
12
|
+
{ id: 'delete_file', label: 'Delete File', description: 'Delete files/directories (disabled by default)' },
|
|
13
|
+
{ id: 'edit_file', label: 'Edit File', description: 'Search-and-replace editing within files' },
|
|
14
|
+
{ id: 'process', label: 'Process', description: 'Monitor and control long-running shell commands' },
|
|
15
|
+
{ id: 'web_search', label: 'Web Search', description: 'Search the web via DuckDuckGo' },
|
|
16
|
+
{ id: 'web_fetch', label: 'Web Fetch', description: 'Fetch and extract text from URLs' },
|
|
17
|
+
{ id: 'claude_code', label: 'Claude Code', description: 'Delegate complex tasks to Claude Code CLI' },
|
|
18
|
+
{ id: 'codex_cli', label: 'Codex CLI', description: 'Delegate complex tasks to OpenAI Codex CLI' },
|
|
19
|
+
{ id: 'opencode_cli', label: 'OpenCode CLI', description: 'Delegate complex tasks to OpenCode CLI' },
|
|
20
|
+
{ id: 'browser', label: 'Browser', description: 'Playwright — browse, scrape, interact with web pages' },
|
|
21
|
+
{ id: 'memory', label: 'Memory', description: 'Store and retrieve long-term memories across sessions' },
|
|
22
|
+
{ id: 'sandbox', label: 'Sandbox', description: 'Run JS/TS/Python code in an isolated Deno sandbox' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export const PLATFORM_TOOLS: ToolDefinition[] = [
|
|
26
|
+
{ id: 'manage_agents', label: 'Agents', description: 'Create, edit, and delete agents' },
|
|
27
|
+
{ id: 'manage_tasks', label: 'Tasks', description: 'Create, edit, and delete tasks' },
|
|
28
|
+
{ id: 'manage_schedules', label: 'Schedules', description: 'Create, edit, and delete schedules' },
|
|
29
|
+
{ id: 'manage_skills', label: 'Skills', description: 'Create, edit, and delete skills' },
|
|
30
|
+
{ id: 'manage_documents', label: 'Documents', description: 'Upload, search, and delete indexed documents' },
|
|
31
|
+
{ id: 'manage_webhooks', label: 'Webhooks', description: 'Register webhooks that trigger agent sessions' },
|
|
32
|
+
{ id: 'manage_connectors', label: 'Connectors', description: 'Create, edit, and delete connectors' },
|
|
33
|
+
{ id: 'manage_sessions', label: 'Sessions', description: 'List sessions, send messages, and spawn session work' },
|
|
34
|
+
{ id: 'manage_secrets', label: 'Secrets', description: 'Store and retrieve encrypted service secrets' },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
export const ALL_TOOLS: ToolDefinition[] = [...AVAILABLE_TOOLS, ...PLATFORM_TOOLS]
|
|
38
|
+
|
|
39
|
+
/** Flat id→label lookup for display */
|
|
40
|
+
export const TOOL_LABELS: Record<string, string> = Object.fromEntries(
|
|
41
|
+
ALL_TOOLS.map((t) => [t.id, t.label]),
|
|
42
|
+
)
|