@swarmclawai/swarmclaw 0.6.7 → 0.6.8
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 +24 -6
- package/package.json +1 -1
- package/src/app/api/agents/route.ts +1 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +4 -0
- package/src/app/api/eval/run/route.ts +37 -0
- package/src/app/api/eval/scenarios/route.ts +24 -0
- package/src/app/api/eval/suite/route.ts +29 -0
- package/src/app/api/memory/graph/route.ts +46 -0
- package/src/app/api/sessions/[id]/checkpoints/route.ts +31 -0
- package/src/app/api/sessions/[id]/restore/route.ts +36 -0
- package/src/app/api/souls/[id]/route.ts +65 -0
- package/src/app/api/souls/route.ts +70 -0
- package/src/app/api/tasks/[id]/route.ts +5 -0
- package/src/app/api/tasks/route.ts +2 -0
- package/src/app/api/usage/route.ts +9 -2
- package/src/cli/index.js +24 -0
- package/src/components/agents/agent-sheet.tsx +27 -6
- package/src/components/agents/soul-library-picker.tsx +84 -13
- package/src/components/chat/activity-moment.tsx +2 -0
- package/src/components/chat/checkpoint-timeline.tsx +112 -0
- package/src/components/chat/message-list.tsx +19 -3
- package/src/components/chat/session-debug-panel.tsx +106 -84
- package/src/components/chat/task-approval-card.tsx +78 -0
- package/src/components/chat/tool-call-bubble.tsx +3 -0
- package/src/components/connectors/connector-sheet.tsx +8 -1
- package/src/components/home/home-view.tsx +39 -15
- package/src/components/layout/app-layout.tsx +18 -2
- package/src/components/memory/memory-browser.tsx +73 -45
- package/src/components/memory/memory-graph-view.tsx +203 -0
- package/src/components/plugins/plugin-list.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +9 -2
- package/src/components/shared/hint-tip.tsx +31 -0
- package/src/components/shared/settings/section-runtime-loop.tsx +5 -4
- package/src/components/tasks/approvals-panel.tsx +120 -0
- package/src/components/usage/metrics-dashboard.tsx +25 -3
- package/src/lib/server/chat-execution.ts +96 -12
- package/src/lib/server/chatroom-helpers.ts +63 -5
- package/src/lib/server/chatroom-orchestration.ts +74 -0
- package/src/lib/server/context-manager.ts +132 -50
- package/src/lib/server/daemon-state.ts +70 -1
- package/src/lib/server/eval/runner.ts +126 -0
- package/src/lib/server/eval/scenarios.ts +218 -0
- package/src/lib/server/eval/scorer.ts +96 -0
- package/src/lib/server/eval/store.ts +37 -0
- package/src/lib/server/eval/types.ts +48 -0
- package/src/lib/server/execution-log.ts +12 -8
- package/src/lib/server/guardian.ts +34 -0
- package/src/lib/server/heartbeat-service.ts +53 -1
- package/src/lib/server/langgraph-checkpoint.ts +10 -0
- package/src/lib/server/link-understanding.ts +55 -0
- package/src/lib/server/main-agent-loop.ts +114 -15
- package/src/lib/server/memory-db.ts +18 -7
- package/src/lib/server/mmr.ts +73 -0
- package/src/lib/server/orchestrator-lg.ts +3 -0
- package/src/lib/server/plugins.ts +44 -22
- package/src/lib/server/query-expansion.ts +57 -0
- package/src/lib/server/queue.ts +27 -0
- package/src/lib/server/session-run-manager.ts +21 -1
- package/src/lib/server/session-tools/http.ts +19 -9
- package/src/lib/server/session-tools/index.ts +34 -0
- package/src/lib/server/session-tools/memory.ts +39 -11
- package/src/lib/server/session-tools/schedule.ts +43 -0
- package/src/lib/server/session-tools/web.ts +35 -11
- package/src/lib/server/storage.ts +12 -0
- package/src/lib/server/stream-agent-chat.ts +57 -8
- package/src/lib/server/tool-capability-policy.ts +1 -0
- package/src/lib/server/tool-retry.ts +62 -0
- package/src/lib/server/transcript-repair.ts +72 -0
- package/src/lib/setup-defaults.ts +1 -0
- package/src/lib/tool-definitions.ts +1 -0
- package/src/lib/validation/schemas.ts +1 -0
- package/src/lib/view-routes.ts +1 -0
- package/src/types/index.ts +34 -3
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import fs from 'fs'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { createRequire } from 'module'
|
|
4
|
-
import type { Plugin, PluginHooks, PluginMeta } from '@/types'
|
|
4
|
+
import type { Plugin, PluginHooks, PluginMeta, PluginToolDef } from '@/types'
|
|
5
5
|
|
|
6
6
|
import { DATA_DIR } from './data-dir'
|
|
7
7
|
|
|
8
8
|
const PLUGINS_DIR = path.join(DATA_DIR, 'plugins')
|
|
9
9
|
const PLUGINS_CONFIG = path.join(DATA_DIR, 'plugins.json')
|
|
10
10
|
|
|
11
|
+
// Hook registrar: maps event names to functions that register a handler
|
|
12
|
+
type HookRegistrar = Record<string, (fn: (...args: unknown[]) => unknown) => void>
|
|
13
|
+
|
|
11
14
|
// OpenClaw plugin format: { name, version, activate(ctx), deactivate() }
|
|
12
15
|
interface OpenClawPlugin {
|
|
13
16
|
name: string
|
|
14
17
|
version?: string
|
|
15
|
-
activate: (ctx:
|
|
18
|
+
activate: (ctx: HookRegistrar) => void
|
|
16
19
|
deactivate?: () => void
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -21,33 +24,39 @@ interface OpenClawPlugin {
|
|
|
21
24
|
* Supports both SwarmClaw format ({ name, hooks }) and OpenClaw format
|
|
22
25
|
* ({ name, activate(ctx) }) where activate receives event hook registrars.
|
|
23
26
|
*/
|
|
24
|
-
function normalizePlugin(mod:
|
|
25
|
-
const
|
|
27
|
+
function normalizePlugin(mod: unknown): Plugin | null {
|
|
28
|
+
const modObj = mod as Record<string, unknown>
|
|
29
|
+
const raw: Record<string, unknown> = (modObj?.default as Record<string, unknown>) || modObj
|
|
26
30
|
|
|
27
31
|
// SwarmClaw native format
|
|
28
32
|
if (raw.name && raw.hooks) {
|
|
29
|
-
return
|
|
33
|
+
return {
|
|
34
|
+
name: raw.name,
|
|
35
|
+
description: raw.description,
|
|
36
|
+
hooks: raw.hooks,
|
|
37
|
+
tools: raw.tools,
|
|
38
|
+
} as Plugin
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
// OpenClaw format: { name, activate(ctx), deactivate() }
|
|
33
42
|
if (raw.name && typeof raw.activate === 'function') {
|
|
34
|
-
const oc = raw as OpenClawPlugin
|
|
43
|
+
const oc = raw as unknown as OpenClawPlugin
|
|
35
44
|
const hooks: PluginHooks = {}
|
|
36
45
|
|
|
37
46
|
// OpenClaw's activate receives an object of hook registrars.
|
|
38
47
|
// Map OpenClaw lifecycle names to SwarmClaw hook names.
|
|
39
|
-
const registrar:
|
|
40
|
-
onAgentStart: (fn) => { hooks.beforeAgentStart = fn },
|
|
41
|
-
onAgentComplete: (fn) => { hooks.afterAgentComplete = fn },
|
|
42
|
-
onToolCall: (fn) => { hooks.beforeToolExec = fn },
|
|
43
|
-
onToolResult: (fn) => { hooks.afterToolExec = fn },
|
|
44
|
-
onMessage: (fn) => { hooks.onMessage = fn },
|
|
48
|
+
const registrar: HookRegistrar = {
|
|
49
|
+
onAgentStart: (fn) => { hooks.beforeAgentStart = fn as PluginHooks['beforeAgentStart'] },
|
|
50
|
+
onAgentComplete: (fn) => { hooks.afterAgentComplete = fn as PluginHooks['afterAgentComplete'] },
|
|
51
|
+
onToolCall: (fn) => { hooks.beforeToolExec = fn as PluginHooks['beforeToolExec'] },
|
|
52
|
+
onToolResult: (fn) => { hooks.afterToolExec = fn as PluginHooks['afterToolExec'] },
|
|
53
|
+
onMessage: (fn) => { hooks.onMessage = fn as PluginHooks['onMessage'] },
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
try {
|
|
48
57
|
oc.activate(registrar)
|
|
49
|
-
} catch (err:
|
|
50
|
-
console.error(`[plugins] OpenClaw activate() failed for ${oc.name}:`, err.message)
|
|
58
|
+
} catch (err: unknown) {
|
|
59
|
+
console.error(`[plugins] OpenClaw activate() failed for ${oc.name}:`, err instanceof Error ? err.message : String(err))
|
|
51
60
|
return null
|
|
52
61
|
}
|
|
53
62
|
|
|
@@ -68,9 +77,10 @@ if (!fs.existsSync(PLUGINS_CONFIG)) fs.writeFileSync(PLUGINS_CONFIG, '{}')
|
|
|
68
77
|
// Use createRequire to avoid Turbopack static analysis of require()
|
|
69
78
|
const dynamicRequire = createRequire(import.meta.url || __filename)
|
|
70
79
|
|
|
71
|
-
interface LoadedPlugin {
|
|
80
|
+
interface LoadedPlugin {
|
|
72
81
|
meta: PluginMeta
|
|
73
82
|
hooks: PluginHooks
|
|
83
|
+
tools?: PluginToolDef[]
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
class PluginManager {
|
|
@@ -112,11 +122,12 @@ class PluginManager {
|
|
|
112
122
|
enabled: true,
|
|
113
123
|
},
|
|
114
124
|
hooks: plugin.hooks,
|
|
125
|
+
tools: plugin.tools,
|
|
115
126
|
})
|
|
116
127
|
console.log(`[plugins] Loaded: ${plugin.name} (${file})`)
|
|
117
128
|
}
|
|
118
|
-
} catch (err:
|
|
119
|
-
console.error(`[plugins] Failed to load ${file}:`, err.message)
|
|
129
|
+
} catch (err: unknown) {
|
|
130
|
+
console.error(`[plugins] Failed to load ${file}:`, err instanceof Error ? err.message : String(err))
|
|
120
131
|
}
|
|
121
132
|
}
|
|
122
133
|
} catch {
|
|
@@ -126,6 +137,17 @@ class PluginManager {
|
|
|
126
137
|
this.loaded = true
|
|
127
138
|
}
|
|
128
139
|
|
|
140
|
+
getPluginTools(): PluginToolDef[] {
|
|
141
|
+
this.load()
|
|
142
|
+
const allTools: PluginToolDef[] = []
|
|
143
|
+
for (const plugin of this.plugins) {
|
|
144
|
+
if (plugin.tools && Array.isArray(plugin.tools)) {
|
|
145
|
+
allTools.push(...plugin.tools)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return allTools
|
|
149
|
+
}
|
|
150
|
+
|
|
129
151
|
async runHook<K extends keyof PluginHooks>(
|
|
130
152
|
hookName: K,
|
|
131
153
|
ctx: Parameters<NonNullable<PluginHooks[K]>>[0],
|
|
@@ -135,9 +157,9 @@ class PluginManager {
|
|
|
135
157
|
const hook = plugin.hooks[hookName]
|
|
136
158
|
if (hook) {
|
|
137
159
|
try {
|
|
138
|
-
await (hook as
|
|
139
|
-
} catch (err:
|
|
140
|
-
console.error(`[plugins] Error in ${plugin.meta.name}.${hookName}:`, err.message)
|
|
160
|
+
await (hook as (ctx: Parameters<NonNullable<PluginHooks[K]>>[0]) => Promise<void> | void)(ctx)
|
|
161
|
+
} catch (err: unknown) {
|
|
162
|
+
console.error(`[plugins] Error in ${plugin.meta.name}.${hookName}:`, err instanceof Error ? err.message : String(err))
|
|
141
163
|
}
|
|
142
164
|
}
|
|
143
165
|
}
|
|
@@ -198,8 +220,8 @@ class PluginManager {
|
|
|
198
220
|
fs.writeFileSync(path.join(PLUGINS_DIR, sanitized), code, 'utf8')
|
|
199
221
|
this.reload()
|
|
200
222
|
return { ok: true }
|
|
201
|
-
} catch (err:
|
|
202
|
-
return { ok: false, error: err.message }
|
|
223
|
+
} catch (err: unknown) {
|
|
224
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
|
203
225
|
}
|
|
204
226
|
}
|
|
205
227
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { loadAgents, loadSettings, loadCredentials, decryptKey } from './storage'
|
|
2
|
+
import { getProvider } from '../providers'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expands a single user query into multiple semantic search variants
|
|
6
|
+
* to improve vector database recall (OpenClaw-style).
|
|
7
|
+
*/
|
|
8
|
+
export async function expandQuery(query: string): Promise<string[]> {
|
|
9
|
+
const agents = loadAgents()
|
|
10
|
+
const settings = loadSettings()
|
|
11
|
+
const defaultAgent = agents[settings.defaultAgentId]
|
|
12
|
+
if (!defaultAgent) return [query]
|
|
13
|
+
|
|
14
|
+
const providerEntry = getProvider(defaultAgent.provider)
|
|
15
|
+
if (!providerEntry?.handler?.streamChat) return [query]
|
|
16
|
+
|
|
17
|
+
const creds = loadCredentials()
|
|
18
|
+
const cred = creds[defaultAgent.credentialId || '']
|
|
19
|
+
const apiKey = cred ? decryptKey(cred.encryptedKey) : undefined
|
|
20
|
+
|
|
21
|
+
const systemPrompt = `You are a search query expansion assistant.
|
|
22
|
+
Given a user's question, generate 3 different semantic search queries that would help find the answer in a vector database.
|
|
23
|
+
Use different vocabulary and focus on different aspects of the intent.
|
|
24
|
+
Format your response as a simple newline-separated list. No numbering, no bullets, no introduction.`
|
|
25
|
+
|
|
26
|
+
let expanded = ''
|
|
27
|
+
try {
|
|
28
|
+
await providerEntry.handler.streamChat({
|
|
29
|
+
session: { id: 'expansion', messages: [], model: defaultAgent.model, provider: defaultAgent.provider },
|
|
30
|
+
message: query,
|
|
31
|
+
apiKey,
|
|
32
|
+
systemPrompt,
|
|
33
|
+
write: (raw: string) => {
|
|
34
|
+
const lines = raw.split('\n').filter(Boolean)
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
if (!line.startsWith('data: ')) continue
|
|
37
|
+
try {
|
|
38
|
+
const ev = JSON.parse(line.slice(6))
|
|
39
|
+
if (ev.t === 'd' && ev.text) expanded += ev.text
|
|
40
|
+
} catch { /* skip */ }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
active: new Map(),
|
|
44
|
+
loadHistory: () => [],
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const variants = expanded.split('\n').map(l => l.trim()).filter(Boolean)
|
|
48
|
+
if (variants.length > 0) {
|
|
49
|
+
// Return original query + variants
|
|
50
|
+
return [query, ...variants.slice(0, 3)]
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('[query-expansion] Failed to expand query:', err)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [query]
|
|
57
|
+
}
|
package/src/lib/server/queue.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { extractTaskResult, formatResultBody } from './task-result'
|
|
|
13
13
|
import { getCheckpointSaver } from './langgraph-checkpoint'
|
|
14
14
|
import { isProtectedMainSession } from './main-session'
|
|
15
15
|
import { cascadeUnblock } from './dag-validation'
|
|
16
|
+
import { performGuardianRollback } from './guardian'
|
|
16
17
|
import type { Agent, BoardTask, Connector, Message } from '@/types'
|
|
17
18
|
|
|
18
19
|
// HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
|
|
@@ -802,6 +803,32 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
|
|
|
802
803
|
text: `Task moved to dead-letter after ${task.attempts}/${task.maxAttempts} attempts.\n\nReason: ${reason}`,
|
|
803
804
|
createdAt: now,
|
|
804
805
|
})
|
|
806
|
+
|
|
807
|
+
// Guardian Auto-Rollback
|
|
808
|
+
const agents = loadAgents()
|
|
809
|
+
const agent = task.agentId ? agents[task.agentId] : null
|
|
810
|
+
if (agent?.autoRecovery) {
|
|
811
|
+
const cwd = task.projectId
|
|
812
|
+
? path.join(WORKSPACE_DIR, 'projects', task.projectId)
|
|
813
|
+
: WORKSPACE_DIR
|
|
814
|
+
const rollback = performGuardianRollback(cwd)
|
|
815
|
+
if (rollback.ok) {
|
|
816
|
+
task.comments.push({
|
|
817
|
+
id: genId(),
|
|
818
|
+
author: 'Guardian',
|
|
819
|
+
text: `Auto-recovery triggered: Workspace successfully rolled back to last clean state.`,
|
|
820
|
+
createdAt: now + 1,
|
|
821
|
+
})
|
|
822
|
+
} else {
|
|
823
|
+
task.comments.push({
|
|
824
|
+
id: genId(),
|
|
825
|
+
author: 'Guardian',
|
|
826
|
+
text: `Auto-recovery failed: ${rollback.reason}`,
|
|
827
|
+
createdAt: now + 1,
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
805
832
|
return 'dead_lettered'
|
|
806
833
|
}
|
|
807
834
|
|
|
@@ -271,6 +271,9 @@ async function drainExecution(executionKey: string): Promise<void> {
|
|
|
271
271
|
resultText: result.text,
|
|
272
272
|
error: result.error,
|
|
273
273
|
toolEvents: result.toolEvents,
|
|
274
|
+
inputTokens: result.inputTokens,
|
|
275
|
+
outputTokens: result.outputTokens,
|
|
276
|
+
estimatedCost: result.estimatedCost,
|
|
274
277
|
})
|
|
275
278
|
} catch (mainLoopErr: any) {
|
|
276
279
|
log.warn('session-run', `Main-loop update failed for ${next.run.id}`, mainLoopErr?.message || String(mainLoopErr))
|
|
@@ -372,6 +375,19 @@ export interface EnqueueSessionRunResult {
|
|
|
372
375
|
abort: () => void
|
|
373
376
|
}
|
|
374
377
|
|
|
378
|
+
const LONG_TOOL_NAMES: ReadonlySet<string> = new Set(['claude_code', 'codex_cli', 'opencode_cli'])
|
|
379
|
+
|
|
380
|
+
function computeEffectiveRunTimeoutMs(
|
|
381
|
+
baseTimeoutMs: number,
|
|
382
|
+
sessionTools: string[],
|
|
383
|
+
runtime: { claudeCodeTimeoutMs: number },
|
|
384
|
+
): number {
|
|
385
|
+
const hasLongTool = sessionTools.some(t => LONG_TOOL_NAMES.has(t))
|
|
386
|
+
if (!hasLongTool) return baseTimeoutMs
|
|
387
|
+
const toolTimeout = runtime.claudeCodeTimeoutMs + 120_000
|
|
388
|
+
return Math.max(baseTimeoutMs, toolTimeout)
|
|
389
|
+
}
|
|
390
|
+
|
|
375
391
|
export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSessionRunResult {
|
|
376
392
|
const internal = input.internal === true
|
|
377
393
|
const mode = normalizeMode(input.mode, internal)
|
|
@@ -379,9 +395,13 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
|
|
|
379
395
|
const executionKey = executionKeyForSession(input.sessionId)
|
|
380
396
|
const runtime = loadRuntimeSettings()
|
|
381
397
|
const defaultMaxRuntimeMs = runtime.ongoingLoopMaxRuntimeMs ?? (10 * 60_000)
|
|
398
|
+
const sessions = loadSessions()
|
|
399
|
+
const sessionData = sessions[input.sessionId]
|
|
400
|
+
const sessionTools: string[] = sessionData?.tools || []
|
|
401
|
+
const adjustedDefaultMs = computeEffectiveRunTimeoutMs(defaultMaxRuntimeMs, sessionTools, runtime)
|
|
382
402
|
const effectiveMaxRuntimeMs = typeof input.maxRuntimeMs === 'number'
|
|
383
403
|
? input.maxRuntimeMs
|
|
384
|
-
:
|
|
404
|
+
: adjustedDefaultMs
|
|
385
405
|
|
|
386
406
|
const dedupe = findDedupeMatch(input.sessionId, input.dedupeKey)
|
|
387
407
|
if (dedupe) {
|
|
@@ -2,27 +2,37 @@ import { z } from 'zod'
|
|
|
2
2
|
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
3
|
import type { ToolBuildContext } from './context'
|
|
4
4
|
import { truncate, MAX_OUTPUT } from './context'
|
|
5
|
+
import { withRetry } from '../tool-retry'
|
|
6
|
+
|
|
7
|
+
interface HttpRequestArgs {
|
|
8
|
+
method: string
|
|
9
|
+
url: string
|
|
10
|
+
headers?: Record<string, string>
|
|
11
|
+
body?: string
|
|
12
|
+
timeoutSec?: number
|
|
13
|
+
followRedirects?: boolean
|
|
14
|
+
}
|
|
5
15
|
|
|
6
16
|
export function buildHttpTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
7
17
|
if (!bctx.hasTool('http_request')) return []
|
|
8
18
|
|
|
9
19
|
return [
|
|
10
20
|
tool(
|
|
11
|
-
|
|
21
|
+
(args: HttpRequestArgs) => withRetry(async (_a: HttpRequestArgs) => {
|
|
12
22
|
try {
|
|
13
|
-
const timeout = Math.max(1, Math.min(timeoutSec ?? 30, 120)) * 1000
|
|
23
|
+
const timeout = Math.max(1, Math.min(_a.timeoutSec ?? 30, 120)) * 1000
|
|
14
24
|
const init: RequestInit = {
|
|
15
|
-
method,
|
|
16
|
-
headers: (headers ?? undefined) as Record<string, string> | undefined,
|
|
25
|
+
method: _a.method,
|
|
26
|
+
headers: (_a.headers ?? undefined) as Record<string, string> | undefined,
|
|
17
27
|
signal: AbortSignal.timeout(timeout),
|
|
18
28
|
}
|
|
19
|
-
if (body && method !== 'GET' && method !== 'HEAD') {
|
|
20
|
-
init.body = body
|
|
29
|
+
if (_a.body && _a.method !== 'GET' && _a.method !== 'HEAD') {
|
|
30
|
+
init.body = _a.body
|
|
21
31
|
}
|
|
22
|
-
if (followRedirects === false) {
|
|
32
|
+
if (_a.followRedirects === false) {
|
|
23
33
|
init.redirect = 'manual'
|
|
24
34
|
}
|
|
25
|
-
const res = await fetch(url, init)
|
|
35
|
+
const res = await fetch(_a.url, init)
|
|
26
36
|
const resHeaders: Record<string, string> = {}
|
|
27
37
|
for (const key of ['content-type', 'location', 'x-request-id', 'retry-after', 'content-length']) {
|
|
28
38
|
const val = res.headers.get(key)
|
|
@@ -39,7 +49,7 @@ export function buildHttpTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
39
49
|
} catch (err: unknown) {
|
|
40
50
|
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
41
51
|
}
|
|
42
|
-
},
|
|
52
|
+
}, args),
|
|
43
53
|
{
|
|
44
54
|
name: 'http_request',
|
|
45
55
|
description: 'Make an HTTP API request. Supports all methods, custom headers, and request bodies. Returns status, headers, and body.',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
|
+
import type { Session } from '@/types'
|
|
3
4
|
import { loadSettings, loadSessions, saveSessions, loadMcpServers } from '../storage'
|
|
4
5
|
import { loadRuntimeSettings } from '../runtime-settings'
|
|
5
6
|
import { log } from '../logger'
|
|
@@ -23,6 +24,9 @@ import { buildHttpTools } from './http'
|
|
|
23
24
|
import { buildGitTools } from './git'
|
|
24
25
|
import { buildWalletTools } from './wallet'
|
|
25
26
|
import { buildOpenClawWorkspaceTools } from './openclaw-workspace'
|
|
27
|
+
import { buildScheduleTools } from './schedule'
|
|
28
|
+
import { getPluginManager } from '../plugins'
|
|
29
|
+
import { jsonSchemaToZod } from '../mcp-client'
|
|
26
30
|
|
|
27
31
|
export type { ToolContext, SessionToolsResult }
|
|
28
32
|
export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
|
|
@@ -113,6 +117,7 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
|
|
|
113
117
|
...buildGitTools(bctx),
|
|
114
118
|
...buildWalletTools(bctx),
|
|
115
119
|
...buildOpenClawWorkspaceTools(bctx),
|
|
120
|
+
...buildScheduleTools(bctx),
|
|
116
121
|
)
|
|
117
122
|
|
|
118
123
|
// ---------------------------------------------------------------------------
|
|
@@ -151,6 +156,35 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
|
|
|
151
156
|
})
|
|
152
157
|
}
|
|
153
158
|
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Plugin tools — native tools provided by SwarmClaw plugins
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
try {
|
|
163
|
+
const pluginTools = getPluginManager().getPluginTools()
|
|
164
|
+
for (const pt of pluginTools) {
|
|
165
|
+
if (!disabledMcpToolNames.has(pt.name)) {
|
|
166
|
+
tools.push(
|
|
167
|
+
tool(
|
|
168
|
+
async (args) => {
|
|
169
|
+
const res = await pt.execute(args as Record<string, unknown>, {
|
|
170
|
+
session: { id: ctx?.sessionId || 'unknown', agentId: ctx?.agentId } as Session,
|
|
171
|
+
message: '',
|
|
172
|
+
})
|
|
173
|
+
return typeof res === 'string' ? res : JSON.stringify(res)
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: pt.name,
|
|
177
|
+
description: pt.description,
|
|
178
|
+
schema: jsonSchemaToZod(pt.parameters),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (err: unknown) {
|
|
185
|
+
log.error('session-tools', 'Failed to load plugin tools', { error: err instanceof Error ? err.message : String(err) })
|
|
186
|
+
}
|
|
187
|
+
|
|
154
188
|
// request_tool_access: always available
|
|
155
189
|
tools.push(
|
|
156
190
|
tool(
|
|
@@ -4,6 +4,8 @@ import fs from 'fs'
|
|
|
4
4
|
import { genId } from '@/lib/id'
|
|
5
5
|
import { getMemoryDb, getMemoryLookupLimits, storeMemoryImageAsset } from '../memory-db'
|
|
6
6
|
import { loadSettings } from '../storage'
|
|
7
|
+
import { expandQuery } from '../query-expansion'
|
|
8
|
+
import type { MemoryEntry } from '@/types'
|
|
7
9
|
import type { ToolBuildContext } from './context'
|
|
8
10
|
|
|
9
11
|
export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
@@ -20,8 +22,9 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
|
|
|
20
22
|
try {
|
|
21
23
|
const scopeMode = scope || 'auto'
|
|
22
24
|
const currentAgentId = ctx?.agentId || null
|
|
23
|
-
const canAccessMemory = (m:
|
|
24
|
-
const filterScope = (rows:
|
|
25
|
+
const canAccessMemory = (m: MemoryEntry) => !m?.agentId || m.agentId === currentAgentId
|
|
26
|
+
const filterScope = (rows: MemoryEntry[]) => {
|
|
27
|
+
if (scopeMode === 'all') return rows
|
|
25
28
|
if (scopeMode === 'shared') return rows.filter((m) => !m.agentId)
|
|
26
29
|
if (scopeMode === 'agent') return rows.filter((m) => currentAgentId && m.agentId === currentAgentId)
|
|
27
30
|
return rows.filter(canAccessMemory)
|
|
@@ -126,17 +129,42 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
|
|
|
126
129
|
return formatEntry(found)
|
|
127
130
|
}
|
|
128
131
|
if (action === 'search') {
|
|
132
|
+
const queries = query ? await expandQuery(query) : [key || '']
|
|
133
|
+
|
|
129
134
|
if (effectiveDepth > 0) {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
135
|
+
const allResults: MemoryEntry[] = []
|
|
136
|
+
const seenIds = new Set<string>()
|
|
137
|
+
let anyTruncated = false
|
|
138
|
+
for (const q of queries) {
|
|
139
|
+
const result = memDb.searchWithLinked(q, undefined, effectiveDepth, maxPerLookup, effectiveLinkedLimit)
|
|
140
|
+
if (result.truncated) anyTruncated = true
|
|
141
|
+
for (const r of result.entries) {
|
|
142
|
+
if (!seenIds.has(r.id)) {
|
|
143
|
+
seenIds.add(r.id)
|
|
144
|
+
allResults.push(r)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const accessible = filterScope(allResults)
|
|
132
149
|
if (!accessible.length) return 'No memories found.'
|
|
133
|
-
let output = accessible.map(formatEntry).join('\n')
|
|
134
|
-
if (
|
|
150
|
+
let output = accessible.slice(0, maxPerLookup).map(formatEntry).join('\n')
|
|
151
|
+
if (anyTruncated) output += `\n\n[Results truncated at ${maxPerLookup} memories / ${effectiveLinkedLimit} linked expansions]`
|
|
135
152
|
return output
|
|
136
153
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
154
|
+
|
|
155
|
+
const allResults: MemoryEntry[] = []
|
|
156
|
+
const seenIds = new Set<string>()
|
|
157
|
+
for (const q of queries) {
|
|
158
|
+
const results = filterScope(memDb.search(q))
|
|
159
|
+
for (const r of results) {
|
|
160
|
+
if (!seenIds.has(r.id)) {
|
|
161
|
+
seenIds.add(r.id)
|
|
162
|
+
allResults.push(r)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!allResults.length) return 'No memories found.'
|
|
167
|
+
return allResults.slice(0, maxPerLookup).map(formatEntry).join('\n')
|
|
140
168
|
}
|
|
141
169
|
if (action === 'list') {
|
|
142
170
|
const results = filterScope(memDb.list(undefined, maxPerLookup))
|
|
@@ -204,14 +232,14 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
|
|
|
204
232
|
},
|
|
205
233
|
{
|
|
206
234
|
name: 'memory_tool',
|
|
207
|
-
description:
|
|
235
|
+
description: `My long-term memory — things I remember across conversations. I can store personal notes, recall past context, and build up knowledge over time. Memories can be private to me or shared with other agents. I can also attach files, link related memories, and contribute to a shared knowledge base. Use \`scope: 'all'\` to search memories across all agents (useful when you need context from other agents' work).${bctx.hasTool('manage_agents') || bctx.hasTool('manage_sessions') ? ' As an orchestrator, cross-agent search with scope=all is especially useful for gathering context from sub-agents.' : ''} Actions: store, get, search, list, delete, link, unlink, knowledge_store, knowledge_search.`,
|
|
208
236
|
schema: z.object({
|
|
209
237
|
action: z.enum(['store', 'get', 'search', 'list', 'delete', 'link', 'unlink', 'knowledge_store', 'knowledge_search']).describe('The action to perform'),
|
|
210
238
|
key: z.string().describe('For store: memory title. For get/delete/link/unlink: memory ID. For search: optional query fallback.'),
|
|
211
239
|
value: z.string().optional().describe('The memory content (for store action)'),
|
|
212
240
|
category: z.string().optional().describe('Category like "note", "fact", "preference", "project", "identity" (for store action, defaults to "note")'),
|
|
213
241
|
query: z.string().optional().describe('Search query (alternative to key for search action)'),
|
|
214
|
-
scope: z.enum(['auto', 'shared', 'agent']).optional().describe('Scope hint: auto (shared + own), shared, or agent'),
|
|
242
|
+
scope: z.enum(['auto', 'shared', 'agent', 'all']).optional().describe('Scope hint: auto (shared + own), shared (shared only), agent (own only), or all (every agent — cross-agent search)'),
|
|
215
243
|
filePaths: z.array(z.object({
|
|
216
244
|
path: z.string().describe('File or folder path'),
|
|
217
245
|
contextSnippet: z.string().optional().describe('Brief context about this file reference'),
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { enqueueSystemEvent } from '../system-events'
|
|
4
|
+
import { requestHeartbeatNow } from '../heartbeat-wake'
|
|
5
|
+
import type { ToolBuildContext } from './context'
|
|
6
|
+
|
|
7
|
+
export function buildScheduleTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
8
|
+
const tools: StructuredToolInterface[] = []
|
|
9
|
+
const { ctx, hasTool } = bctx
|
|
10
|
+
|
|
11
|
+
if (hasTool('schedule_wake')) {
|
|
12
|
+
tools.push(
|
|
13
|
+
tool(
|
|
14
|
+
async ({ delayMinutes, message }) => {
|
|
15
|
+
if (!ctx?.sessionId) return 'Cannot schedule wake: no session context.'
|
|
16
|
+
if (delayMinutes <= 0 || delayMinutes > 1440) return 'delayMinutes must be between 1 and 1440 (24 hours).'
|
|
17
|
+
|
|
18
|
+
// Non-durable in-memory timeout for conversational wake events
|
|
19
|
+
// (For durable cron, use manage_schedules)
|
|
20
|
+
const delayMs = delayMinutes * 60 * 1000
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
if (ctx.sessionId) {
|
|
23
|
+
enqueueSystemEvent(ctx.sessionId, `[Scheduled Wake Event / Reminder] ${message}`)
|
|
24
|
+
requestHeartbeatNow({ sessionId: ctx.sessionId, reason: 'scheduled_wake' })
|
|
25
|
+
}
|
|
26
|
+
}, delayMs)
|
|
27
|
+
|
|
28
|
+
return `Successfully scheduled a wake event in ${delayMinutes} minutes with message: "${message}".`
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'schedule_wake',
|
|
32
|
+
description: 'Schedule a wake event (reminder) for yourself in this chatroom. Use this to proactively check back on a long-running process or to remind yourself to follow up with the user later.',
|
|
33
|
+
schema: z.object({
|
|
34
|
+
delayMinutes: z.number().describe('How many minutes from now to wake up (1-1440).'),
|
|
35
|
+
message: z.string().describe('The reminder text that will be passed back to you when you wake.'),
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return tools
|
|
43
|
+
}
|
|
@@ -9,6 +9,7 @@ import { spawnSync } from 'child_process'
|
|
|
9
9
|
import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
|
|
10
10
|
import { getSearchProvider } from './search-providers'
|
|
11
11
|
import { dedupeScreenshotMarkdownLines } from './web-output'
|
|
12
|
+
import { withRetry } from '../tool-retry'
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Search result compression — summarize verbose results before injecting into context
|
|
@@ -32,7 +33,7 @@ async function compressSearchResults(
|
|
|
32
33
|
if (session.credentialId) {
|
|
33
34
|
const creds = loadCredentials()
|
|
34
35
|
const cred = creds[session.credentialId]
|
|
35
|
-
if (cred) apiKey = decryptKey(cred)
|
|
36
|
+
if (cred) apiKey = decryptKey(cred.encryptedKey)
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const systemPrompt = 'You are a search result summarizer. Condense search results into a concise reference. Keep key facts, URLs, and data points. Remove filler and redundancy. Output plain text, not JSON.'
|
|
@@ -116,19 +117,19 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
116
117
|
if (bctx.hasTool('web_search')) {
|
|
117
118
|
tools.push(
|
|
118
119
|
tool(
|
|
119
|
-
async ({ query
|
|
120
|
+
({ query, maxResults }) => withRetry(async (_args: { query: string; maxResults?: number }) => {
|
|
120
121
|
try {
|
|
121
|
-
const limit = Math.min(maxResults || 5, 10)
|
|
122
|
+
const limit = Math.min(_args.maxResults || 5, 10)
|
|
122
123
|
const { loadSettings } = await import('../storage')
|
|
123
124
|
const settings = loadSettings()
|
|
124
125
|
const provider = await getSearchProvider(settings)
|
|
125
|
-
const results = await provider.search(query, limit)
|
|
126
|
+
const results = await provider.search(_args.query, limit)
|
|
126
127
|
if (results.length === 0) return 'No results found.'
|
|
127
128
|
const raw = JSON.stringify(results, null, 2)
|
|
128
129
|
// Compress search results if they exceed 2000 chars
|
|
129
130
|
if (raw.length > 2000) {
|
|
130
131
|
try {
|
|
131
|
-
const compressed = await compressSearchResults(results, query, bctx)
|
|
132
|
+
const compressed = await compressSearchResults(results, _args.query, bctx)
|
|
132
133
|
if (compressed) return compressed
|
|
133
134
|
} catch {
|
|
134
135
|
// Compression failed — fall through to raw results
|
|
@@ -138,7 +139,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
138
139
|
} catch (err: unknown) {
|
|
139
140
|
return `Error searching web: ${err instanceof Error ? err.message : String(err)}`
|
|
140
141
|
}
|
|
141
|
-
},
|
|
142
|
+
}, { query, maxResults }),
|
|
142
143
|
{
|
|
143
144
|
name: 'web_search',
|
|
144
145
|
description: 'Search the web for information. Returns results with title, url, and snippet.',
|
|
@@ -156,30 +157,53 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
156
157
|
if (bctx.hasTool('web_fetch')) {
|
|
157
158
|
tools.push(
|
|
158
159
|
tool(
|
|
159
|
-
async ({ url }) => {
|
|
160
|
+
({ url }) => withRetry(async (_args: { url: string }) => {
|
|
160
161
|
try {
|
|
161
|
-
const res = await fetch(url, {
|
|
162
|
+
const res = await fetch(_args.url, {
|
|
162
163
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
|
|
163
164
|
signal: AbortSignal.timeout(15000),
|
|
164
165
|
})
|
|
165
166
|
if (!res.ok) return `HTTP ${res.status}: ${res.statusText}`
|
|
167
|
+
|
|
168
|
+
const contentType = res.headers.get('content-type') || ''
|
|
169
|
+
if (contentType.includes('application/pdf')) {
|
|
170
|
+
try {
|
|
171
|
+
// @ts-expect-error pdf-parse has no type declarations
|
|
172
|
+
const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
|
|
173
|
+
const arrayBuffer = await res.arrayBuffer()
|
|
174
|
+
const result = await pdfParse(Buffer.from(arrayBuffer))
|
|
175
|
+
return truncate(result.text, MAX_OUTPUT)
|
|
176
|
+
} catch (err: unknown) {
|
|
177
|
+
return `Error parsing PDF: ${err instanceof Error ? err.message : String(err)}`
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
166
181
|
const html = await res.text()
|
|
182
|
+
|
|
183
|
+
// Basic YouTube extraction (title and description)
|
|
184
|
+
if (_args.url.includes('youtube.com/watch') || _args.url.includes('youtu.be/')) {
|
|
185
|
+
const $ = cheerio.load(html)
|
|
186
|
+
const title = $('meta[property="og:title"]').attr('content') || $('title').text()
|
|
187
|
+
const description = $('meta[property="og:description"]').attr('content') || ''
|
|
188
|
+
return truncate(`YouTube Video: ${title}\n\nDescription:\n${description}\n\n(Transcript extraction requires a specialized tool or API)`, MAX_OUTPUT)
|
|
189
|
+
}
|
|
190
|
+
|
|
167
191
|
// Use cheerio for robust HTML text extraction
|
|
168
192
|
const $ = cheerio.load(html)
|
|
169
193
|
$('script, style, noscript, nav, footer, header').remove()
|
|
170
194
|
// Prefer article/main content if available
|
|
171
195
|
const main = $('article, main, [role="main"]').first()
|
|
172
|
-
|
|
196
|
+
const text = (main.length ? main.text() : $('body').text())
|
|
173
197
|
.replace(/\s+/g, ' ')
|
|
174
198
|
.trim()
|
|
175
199
|
return truncate(text, MAX_OUTPUT)
|
|
176
200
|
} catch (err: unknown) {
|
|
177
201
|
return `Error fetching URL: ${err instanceof Error ? err.message : String(err)}`
|
|
178
202
|
}
|
|
179
|
-
},
|
|
203
|
+
}, { url }),
|
|
180
204
|
{
|
|
181
205
|
name: 'web_fetch',
|
|
182
|
-
description: 'Fetch a URL and read its content
|
|
206
|
+
description: 'Fetch a URL and read its content. Supports HTML web pages and direct PDF links. Provides basic metadata for YouTube videos.',
|
|
183
207
|
schema: z.object({
|
|
184
208
|
url: z.string().describe('The URL to fetch'),
|
|
185
209
|
}),
|