@swarmclawai/swarmclaw 0.6.0 → 0.6.3

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.
Files changed (118) hide show
  1. package/README.md +56 -42
  2. package/bin/server-cmd.js +1 -0
  3. package/package.json +2 -1
  4. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
  6. package/src/app/api/connectors/[id]/route.ts +1 -0
  7. package/src/app/api/connectors/route.ts +2 -1
  8. package/src/app/api/files/open/route.ts +43 -0
  9. package/src/app/api/search/route.ts +9 -7
  10. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  11. package/src/app/api/sessions/[id]/route.ts +4 -0
  12. package/src/app/api/tasks/metrics/route.ts +101 -0
  13. package/src/app/api/tasks/route.ts +17 -2
  14. package/src/app/api/tts/route.ts +16 -35
  15. package/src/app/api/tts/stream/route.ts +14 -42
  16. package/src/app/api/uploads/[filename]/route.ts +19 -34
  17. package/src/app/api/uploads/route.ts +94 -0
  18. package/src/app/globals.css +5 -0
  19. package/src/cli/index.js +16 -1
  20. package/src/cli/spec.js +26 -0
  21. package/src/components/agents/agent-card.tsx +3 -3
  22. package/src/components/agents/agent-chat-list.tsx +29 -6
  23. package/src/components/agents/agent-sheet.tsx +66 -4
  24. package/src/components/agents/inspector-panel.tsx +81 -6
  25. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  26. package/src/components/agents/personality-builder.tsx +42 -14
  27. package/src/components/agents/soul-library-picker.tsx +89 -0
  28. package/src/components/canvas/canvas-panel.tsx +96 -0
  29. package/src/components/chat/activity-moment.tsx +8 -4
  30. package/src/components/chat/chat-area.tsx +76 -24
  31. package/src/components/chat/chat-header.tsx +522 -286
  32. package/src/components/chat/chat-preview-panel.tsx +1 -2
  33. package/src/components/chat/delegation-banner.tsx +371 -0
  34. package/src/components/chat/file-path-chip.tsx +23 -2
  35. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  36. package/src/components/chat/message-bubble.tsx +315 -25
  37. package/src/components/chat/message-list.tsx +113 -8
  38. package/src/components/chat/streaming-bubble.tsx +68 -1
  39. package/src/components/chat/tool-call-bubble.tsx +45 -3
  40. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  41. package/src/components/chatrooms/chatroom-list.tsx +8 -1
  42. package/src/components/chatrooms/chatroom-message.tsx +8 -3
  43. package/src/components/chatrooms/chatroom-view.tsx +3 -3
  44. package/src/components/connectors/connector-list.tsx +168 -90
  45. package/src/components/connectors/connector-sheet.tsx +84 -17
  46. package/src/components/home/home-view.tsx +1 -1
  47. package/src/components/input/chat-input.tsx +28 -2
  48. package/src/components/layout/app-layout.tsx +19 -2
  49. package/src/components/projects/project-detail.tsx +1 -1
  50. package/src/components/schedules/schedule-sheet.tsx +260 -127
  51. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  54. package/src/components/shared/connector-platform-icon.tsx +51 -4
  55. package/src/components/shared/icon-button.tsx +16 -2
  56. package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
  57. package/src/components/shared/search-dialog.tsx +17 -10
  58. package/src/components/shared/settings/section-embedding.tsx +48 -13
  59. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  60. package/src/components/shared/settings/section-storage.tsx +206 -0
  61. package/src/components/shared/settings/section-user-preferences.tsx +18 -0
  62. package/src/components/shared/settings/section-voice.tsx +42 -21
  63. package/src/components/shared/settings/section-web-search.tsx +30 -6
  64. package/src/components/shared/settings/settings-page.tsx +3 -1
  65. package/src/components/shared/settings/storage-browser.tsx +259 -0
  66. package/src/components/tasks/task-card.tsx +14 -1
  67. package/src/components/tasks/task-sheet.tsx +328 -3
  68. package/src/components/usage/metrics-dashboard.tsx +90 -6
  69. package/src/hooks/use-continuous-speech.ts +10 -4
  70. package/src/hooks/use-voice-conversation.ts +53 -10
  71. package/src/hooks/use-ws.ts +4 -2
  72. package/src/lib/providers/anthropic.ts +13 -7
  73. package/src/lib/providers/index.ts +1 -0
  74. package/src/lib/providers/openai.ts +13 -7
  75. package/src/lib/server/chat-execution.ts +125 -14
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/connector-routing.test.ts +118 -1
  78. package/src/lib/server/connectors/discord.ts +31 -8
  79. package/src/lib/server/connectors/manager.ts +594 -16
  80. package/src/lib/server/connectors/media.ts +5 -0
  81. package/src/lib/server/connectors/telegram.ts +12 -2
  82. package/src/lib/server/connectors/types.ts +2 -0
  83. package/src/lib/server/connectors/whatsapp.ts +28 -2
  84. package/src/lib/server/elevenlabs.test.ts +60 -0
  85. package/src/lib/server/elevenlabs.ts +103 -0
  86. package/src/lib/server/heartbeat-service.ts +8 -1
  87. package/src/lib/server/main-agent-loop.ts +1 -1
  88. package/src/lib/server/memory-consolidation.ts +15 -2
  89. package/src/lib/server/memory-db.ts +134 -6
  90. package/src/lib/server/mime.ts +51 -0
  91. package/src/lib/server/openclaw-gateway.ts +2 -2
  92. package/src/lib/server/orchestrator-lg.ts +2 -0
  93. package/src/lib/server/orchestrator.ts +5 -2
  94. package/src/lib/server/playwright-proxy.mjs +2 -3
  95. package/src/lib/server/prompt-runtime-context.ts +53 -0
  96. package/src/lib/server/queue.ts +182 -8
  97. package/src/lib/server/session-tools/canvas.ts +67 -0
  98. package/src/lib/server/session-tools/connector.ts +583 -63
  99. package/src/lib/server/session-tools/crud.ts +21 -0
  100. package/src/lib/server/session-tools/delegate.ts +68 -4
  101. package/src/lib/server/session-tools/file.ts +26 -7
  102. package/src/lib/server/session-tools/git.ts +71 -0
  103. package/src/lib/server/session-tools/http.ts +57 -0
  104. package/src/lib/server/session-tools/index.ts +8 -0
  105. package/src/lib/server/session-tools/memory.ts +1 -0
  106. package/src/lib/server/session-tools/search-providers.ts +16 -8
  107. package/src/lib/server/session-tools/subagent.ts +106 -0
  108. package/src/lib/server/session-tools/web.ts +118 -8
  109. package/src/lib/server/stream-agent-chat.ts +39 -10
  110. package/src/lib/server/task-mention.ts +41 -0
  111. package/src/lib/sessions.ts +10 -0
  112. package/src/lib/soul-library.ts +103 -0
  113. package/src/lib/task-dedupe.ts +26 -0
  114. package/src/lib/tool-definitions.ts +2 -0
  115. package/src/lib/tts.ts +2 -2
  116. package/src/stores/use-app-store.ts +5 -1
  117. package/src/stores/use-chat-store.ts +65 -2
  118. package/src/types/index.ts +32 -2
@@ -20,6 +20,8 @@ import {
20
20
  } from '../storage'
21
21
  import { resolveScheduleName } from '@/lib/schedule-name'
22
22
  import { findDuplicateSchedule, type ScheduleLike } from '@/lib/schedule-dedupe'
23
+ import { computeTaskFingerprint, findDuplicateTask } from '@/lib/task-dedupe'
24
+ import { resolveTaskAgentFromDescription } from '@/lib/server/task-mention'
23
25
  import type { ToolBuildContext } from './context'
24
26
  import { safePath, findBinaryOnPath } from './context'
25
27
 
@@ -115,6 +117,7 @@ const RESOURCE_DEFAULTS: Record<string, (parsed: any) => any> = {
115
117
  queuedAt: null,
116
118
  startedAt: null,
117
119
  completedAt: null,
120
+ priority: ['low', 'medium', 'high', 'critical'].includes(p.priority) ? p.priority : undefined,
118
121
  ...p,
119
122
  }),
120
123
  manage_schedules: (p) => {
@@ -342,6 +345,24 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
342
345
  })
343
346
  }
344
347
  }
348
+ // @mention agent resolution for tasks
349
+ if (toolKey === 'manage_tasks' && parsed.description) {
350
+ const agents = loadAgents()
351
+ parsed.agentId = resolveTaskAgentFromDescription(
352
+ parsed.description,
353
+ parsed.agentId || ctx?.agentId || '',
354
+ agents,
355
+ )
356
+ }
357
+ // Task dedup
358
+ if (toolKey === 'manage_tasks') {
359
+ const fp = computeTaskFingerprint(parsed.title || 'Untitled Task', parsed.agentId || ctx?.agentId || '')
360
+ parsed.fingerprint = fp
361
+ const dupe = findDuplicateTask(all as Record<string, import('@/types').BoardTask>, { fingerprint: fp })
362
+ if (dupe) {
363
+ return JSON.stringify({ ...dupe, deduplicated: true })
364
+ }
365
+ }
345
366
  const newId = genId()
346
367
  const entry = {
347
368
  id: newId,
@@ -634,6 +634,69 @@ export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterf
634
634
  }
635
635
  }
636
636
 
637
+ // check_delegation_status: lets agents check on tasks they delegated
638
+ if (ctx?.platformAssignScope === 'all' && ctx?.agentId) {
639
+ tools.push(
640
+ tool(
641
+ async ({ taskId }) => {
642
+ try {
643
+ const tasks = loadTasks()
644
+ const task = tasks[taskId] as Record<string, unknown> | undefined
645
+ if (!task) return `Error: Task "${taskId}" not found.`
646
+
647
+ const status = task.status as string || 'unknown'
648
+ const result = typeof task.result === 'string' ? task.result : null
649
+ const error = typeof task.error === 'string' ? task.error : null
650
+ const agentId = task.agentId as string || ''
651
+ const agents = loadAgents()
652
+ const agent = agents[agentId]
653
+ const startedAt = typeof task.startedAt === 'number' ? task.startedAt : null
654
+ const completedAt = typeof task.completedAt === 'number' ? task.completedAt : null
655
+
656
+ const info: Record<string, unknown> = {
657
+ taskId,
658
+ status,
659
+ agentId,
660
+ agentName: agent?.name || agentId,
661
+ agentAvatarSeed: agent?.avatarSeed || null,
662
+ title: task.title || '',
663
+ }
664
+
665
+ if (startedAt) info.startedAt = new Date(startedAt).toISOString()
666
+ if (completedAt) info.completedAt = new Date(completedAt).toISOString()
667
+ if (startedAt && !completedAt && status === 'running') {
668
+ info.runningForSeconds = Math.round((Date.now() - startedAt) / 1000)
669
+ }
670
+ if (result) info.result = result.slice(0, 4000)
671
+ if (error) info.error = error.slice(0, 1000)
672
+
673
+ // Include latest comments for context
674
+ const comments = Array.isArray(task.comments) ? task.comments as Array<{ text: string; author: string; createdAt: number }> : []
675
+ if (comments.length > 0) {
676
+ const latest = comments.slice(-3).map((c) => ({
677
+ author: c.author,
678
+ text: (c.text || '').slice(0, 500),
679
+ time: new Date(c.createdAt).toISOString(),
680
+ }))
681
+ info.latestComments = latest
682
+ }
683
+
684
+ return JSON.stringify(info)
685
+ } catch (err: unknown) {
686
+ return `Error checking task: ${err instanceof Error ? err.message : String(err)}`
687
+ }
688
+ },
689
+ {
690
+ name: 'check_delegation_status',
691
+ description: 'Check the status and result of a delegated task. Use this after delegate_to_agent to monitor progress. Returns status (todo/queued/running/completed/failed), result if completed, and latest comments.',
692
+ schema: z.object({
693
+ taskId: z.string().describe('The task ID returned by delegate_to_agent'),
694
+ }),
695
+ },
696
+ ),
697
+ )
698
+ }
699
+
637
700
  // delegate_to_agent: requires "Assign to Other Agents" (platformAssignScope: 'all')
638
701
  if (ctx?.platformAssignScope === 'all' && ctx?.agentId) {
639
702
  tools.push(
@@ -698,9 +761,10 @@ export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterf
698
761
  taskId,
699
762
  agentId: resolvedId,
700
763
  agentName: target.name,
764
+ agentAvatarSeed: target.avatarSeed || null,
701
765
  message: startImmediately
702
- ? `Task delegated to ${target.name} and queued for immediate execution. Task ID: ${taskId}.`
703
- : `Task delegated to ${target.name}. Task ID: ${taskId}. Status: todo. Ask the user if they want to start it now — call again with startImmediately: true to queue it.`,
766
+ ? `Task delegated to ${target.name} and queued for immediate execution. Task ID: ${taskId}. Use check_delegation_status to monitor progress.`
767
+ : `Task delegated to ${target.name}. Task ID: ${taskId}. Status: todo (not auto-started). Use delegate_to_agent with startImmediately: true to queue it.`,
704
768
  })
705
769
  } catch (err: unknown) {
706
770
  return `Error delegating task: ${err instanceof Error ? err.message : String(err)}`
@@ -708,12 +772,12 @@ export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterf
708
772
  },
709
773
  {
710
774
  name: 'delegate_to_agent',
711
- description: 'Delegate a task to another agent. Creates a task on the task board. By default the task goes to "todo" status. Set startImmediately=true to queue it for execution right away. Ask the user to confirm before starting immediately.',
775
+ description: 'Delegate a task to another agent. Creates a task on the task board and queues it for immediate execution by default. Set startImmediately=false if you want the task to go to "todo" status instead.',
712
776
  schema: z.object({
713
777
  agentId: z.string().describe('ID or name of the target agent to delegate to'),
714
778
  task: z.string().describe('What the target agent should do'),
715
779
  description: z.string().optional().describe('Optional longer description of the task'),
716
- startImmediately: z.boolean().optional().default(false).describe('If true, queue the task for immediate execution instead of putting it in todo'),
780
+ startImmediately: z.boolean().optional().default(true).describe('If true (default), queue the task for immediate execution. Set false to put in todo for manual start.'),
717
781
  }),
718
782
  },
719
783
  ),
@@ -6,6 +6,17 @@ import { UPLOAD_DIR } from '../storage'
6
6
  import type { ToolBuildContext } from './context'
7
7
  import { safePath, truncate, listDirRecursive, MAX_OUTPUT, MAX_FILE } from './context'
8
8
 
9
+ const SEND_FILE_DEDUPE_TTL_MS = 30_000
10
+ const recentSendFileResults = new Map<string, { at: number; output: string; uploadPath: string }>()
11
+
12
+ function pruneRecentSendFileCache(now: number): void {
13
+ for (const [key, entry] of recentSendFileResults.entries()) {
14
+ if (now - entry.at > SEND_FILE_DEDUPE_TTL_MS || !fs.existsSync(entry.uploadPath)) {
15
+ recentSendFileResults.delete(key)
16
+ }
17
+ }
18
+ }
19
+
9
20
  export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[] {
10
21
  const tools: StructuredToolInterface[] = []
11
22
 
@@ -197,6 +208,8 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
197
208
  tool(
198
209
  async ({ filePath: rawPath }) => {
199
210
  try {
211
+ const now = Date.now()
212
+ pruneRecentSendFileCache(now)
200
213
  // Resolve relative to cwd, but also allow absolute paths
201
214
  const resolved = path.isAbsolute(rawPath) ? rawPath : path.resolve(bctx.cwd, rawPath)
202
215
  if (!fs.existsSync(resolved)) return `Error: file not found: ${rawPath}`
@@ -204,6 +217,13 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
204
217
  if (stat.isDirectory()) return `Error: cannot send a directory. Send individual files instead.`
205
218
  if (stat.size > 100 * 1024 * 1024) return `Error: file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Max 100MB.`
206
219
 
220
+ const sessionId = bctx.ctx?.sessionId || 'no-session'
221
+ const dedupeKey = `${sessionId}|${resolved}`
222
+ const cached = recentSendFileResults.get(dedupeKey)
223
+ if (cached && now - cached.at <= SEND_FILE_DEDUPE_TTL_MS && fs.existsSync(cached.uploadPath)) {
224
+ return cached.output
225
+ }
226
+
207
227
  const ext = path.extname(resolved).slice(1).toLowerCase()
208
228
  const basename = path.basename(resolved)
209
229
  const filename = `${Date.now()}-${basename}`
@@ -212,14 +232,13 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
212
232
 
213
233
  const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']
214
234
  const VIDEO_EXTS = ['mp4', 'webm', 'mov', 'avi', 'mkv']
235
+ const AUDIO_EXTS = ['mp3', 'ogg', 'wav', 'aac', 'm4a', 'opus']
215
236
 
216
- if (IMAGE_EXTS.includes(ext)) {
217
- return `![${basename}](/api/uploads/${filename})`
218
- } else if (VIDEO_EXTS.includes(ext)) {
219
- return `![${basename}](/api/uploads/${filename})`
220
- } else {
221
- return `[Download ${basename}](/api/uploads/${filename})`
222
- }
237
+ const output = (IMAGE_EXTS.includes(ext) || VIDEO_EXTS.includes(ext) || AUDIO_EXTS.includes(ext))
238
+ ? `![${basename}](/api/uploads/${filename})`
239
+ : `[Download ${basename}](/api/uploads/${filename})`
240
+ recentSendFileResults.set(dedupeKey, { at: now, output, uploadPath: dest })
241
+ return output
223
242
  } catch (err: unknown) {
224
243
  return `Error sending file: ${err instanceof Error ? err.message : String(err)}`
225
244
  }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod'
2
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import { execFile } from 'child_process'
4
+ import { promisify } from 'util'
5
+ import type { ToolBuildContext } from './context'
6
+ import { findBinaryOnPath, safePath, truncate, MAX_OUTPUT } from './context'
7
+
8
+ const execFileAsync = promisify(execFile)
9
+
10
+ const GIT_ACTIONS = [
11
+ 'status', 'log', 'diff', 'commit', 'add', 'push', 'pull',
12
+ 'branch', 'checkout', 'stash', 'merge', 'clone', 'remote',
13
+ 'tag', 'reset', 'show',
14
+ ] as const
15
+
16
+ export function buildGitTools(bctx: ToolBuildContext): StructuredToolInterface[] {
17
+ if (!bctx.hasTool('git')) return []
18
+
19
+ const gitPath = findBinaryOnPath('git')
20
+ if (!gitPath) return []
21
+
22
+ return [
23
+ tool(
24
+ async ({ action, args, repoPath, timeoutSec }) => {
25
+ try {
26
+ const cwd = repoPath ? safePath(bctx.cwd, repoPath) : bctx.cwd
27
+ const timeout = Math.max(5, Math.min(timeoutSec ?? 60, 300)) * 1000
28
+
29
+ // Verify we're in a git repo (except for clone)
30
+ if (action !== 'clone') {
31
+ try {
32
+ await execFileAsync(gitPath, ['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 })
33
+ } catch {
34
+ return JSON.stringify({ error: `Not a git repository: ${cwd}` })
35
+ }
36
+ }
37
+
38
+ const cmdArgs = [action, ...(args ?? [])]
39
+ const result = await execFileAsync(gitPath, cmdArgs, {
40
+ cwd,
41
+ timeout,
42
+ maxBuffer: MAX_OUTPUT,
43
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
44
+ })
45
+ return JSON.stringify({
46
+ exitCode: 0,
47
+ stdout: truncate(result.stdout ?? '', MAX_OUTPUT),
48
+ stderr: truncate(result.stderr ?? '', MAX_OUTPUT),
49
+ })
50
+ } catch (err: unknown) {
51
+ const execErr = err as { code?: number; stdout?: string; stderr?: string; message?: string }
52
+ return JSON.stringify({
53
+ exitCode: execErr.code ?? 1,
54
+ stdout: truncate(execErr.stdout ?? '', MAX_OUTPUT),
55
+ stderr: truncate(execErr.stderr ?? execErr.message ?? String(err), MAX_OUTPUT),
56
+ })
57
+ }
58
+ },
59
+ {
60
+ name: 'git',
61
+ description: 'Run git operations. Verify the repo exists before committing or pushing. Use args for subcommand flags (e.g. args: ["-m", "message"] for commit).',
62
+ schema: z.object({
63
+ action: z.enum(GIT_ACTIONS).describe('Git subcommand to run'),
64
+ args: z.array(z.string()).optional().describe('Additional arguments (e.g. ["-m", "fix: typo"], ["--oneline", "-n", "5"])'),
65
+ repoPath: z.string().optional().describe('Relative path to git repo (defaults to working directory)'),
66
+ timeoutSec: z.number().optional().describe('Timeout in seconds (default 60, max 300)'),
67
+ }),
68
+ },
69
+ ),
70
+ ]
71
+ }
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod'
2
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import type { ToolBuildContext } from './context'
4
+ import { truncate, MAX_OUTPUT } from './context'
5
+
6
+ export function buildHttpTools(bctx: ToolBuildContext): StructuredToolInterface[] {
7
+ if (!bctx.hasTool('http_request')) return []
8
+
9
+ return [
10
+ tool(
11
+ async ({ method, url, headers, body, timeoutSec, followRedirects }) => {
12
+ try {
13
+ const timeout = Math.max(1, Math.min(timeoutSec ?? 30, 120)) * 1000
14
+ const init: RequestInit = {
15
+ method,
16
+ headers: (headers ?? undefined) as Record<string, string> | undefined,
17
+ signal: AbortSignal.timeout(timeout),
18
+ }
19
+ if (body && method !== 'GET' && method !== 'HEAD') {
20
+ init.body = body
21
+ }
22
+ if (followRedirects === false) {
23
+ init.redirect = 'manual'
24
+ }
25
+ const res = await fetch(url, init)
26
+ const resHeaders: Record<string, string> = {}
27
+ for (const key of ['content-type', 'location', 'x-request-id', 'retry-after', 'content-length']) {
28
+ const val = res.headers.get(key)
29
+ if (val) resHeaders[key] = val
30
+ }
31
+ let resBody: string
32
+ const ct = res.headers.get('content-type') ?? ''
33
+ if (ct.includes('image/') || ct.includes('audio/') || ct.includes('video/') || ct.includes('application/octet-stream')) {
34
+ resBody = `[binary content, ${res.headers.get('content-length') ?? 'unknown'} bytes]`
35
+ } else {
36
+ resBody = truncate(await res.text(), MAX_OUTPUT)
37
+ }
38
+ return JSON.stringify({ status: res.status, statusText: res.statusText, headers: resHeaders, body: resBody })
39
+ } catch (err: unknown) {
40
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
41
+ }
42
+ },
43
+ {
44
+ name: 'http_request',
45
+ description: 'Make an HTTP API request. Supports all methods, custom headers, and request bodies. Returns status, headers, and body.',
46
+ schema: z.object({
47
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']).describe('HTTP method'),
48
+ url: z.string().describe('Full URL to request'),
49
+ headers: z.record(z.string(), z.string()).optional().describe('Request headers as key-value pairs'),
50
+ body: z.string().optional().describe('Request body (JSON string, form data, or plain text). Ignored for GET/HEAD.'),
51
+ timeoutSec: z.number().optional().describe('Timeout in seconds (default 30, max 120)'),
52
+ followRedirects: z.boolean().optional().describe('Follow redirects (default true). Set false to inspect redirect responses.'),
53
+ }),
54
+ },
55
+ ),
56
+ ]
57
+ }
@@ -17,6 +17,10 @@ import { buildContextTools } from './context-mgmt'
17
17
  import { buildSandboxTools } from './sandbox'
18
18
  import { buildOpenClawNodeTools } from './openclaw-nodes'
19
19
  import { buildChatroomTools } from './chatroom'
20
+ import { buildSubagentTools } from './subagent'
21
+ import { buildCanvasTools } from './canvas'
22
+ import { buildHttpTools } from './http'
23
+ import { buildGitTools } from './git'
20
24
 
21
25
  export type { ToolContext, SessionToolsResult }
22
26
  export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
@@ -99,6 +103,10 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
99
103
  ...buildSandboxTools(bctx),
100
104
  ...buildOpenClawNodeTools(bctx),
101
105
  ...buildChatroomTools(bctx),
106
+ ...buildSubagentTools(bctx),
107
+ ...buildCanvasTools(bctx),
108
+ ...buildHttpTools(bctx),
109
+ ...buildGitTools(bctx),
102
110
  )
103
111
 
104
112
  // ---------------------------------------------------------------------------
@@ -60,6 +60,7 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
60
60
 
61
61
  const formatEntry = (m: any) => {
62
62
  let line = `[${m.id}] (${m.agentId ? `agent:${m.agentId}` : 'shared'}) ${m.category}/${m.title}: ${m.content}`
63
+ if (m.reinforcementCount) line += ` (reinforced ×${m.reinforcementCount})`
63
64
  if (m.references?.length) {
64
65
  line += `\n refs: ${m.references.map((r: any) => {
65
66
  const core = r.path || r.title || r.type
@@ -260,16 +260,24 @@ export async function getSearchProvider(settings: Partial<AppSettings>): Promise
260
260
  return new SearXNGProvider(url)
261
261
  }
262
262
  case 'tavily': {
263
- const { getSecret } = await import('../storage')
264
- const secret = await getSecret('tavily')
265
- if (!secret?.value) throw new Error('Tavily requires an API key. Add a secret named "tavily" in Secrets.')
266
- return new TavilyProvider(secret.value)
263
+ let apiKey = settings.tavilyApiKey
264
+ if (!apiKey) {
265
+ const { getSecret } = await import('../storage')
266
+ const secret = await getSecret('tavily')
267
+ apiKey = secret?.value ?? null
268
+ }
269
+ if (!apiKey) throw new Error('Tavily requires an API key. Set one in Settings > Web Search.')
270
+ return new TavilyProvider(apiKey)
267
271
  }
268
272
  case 'brave': {
269
- const { getSecret } = await import('../storage')
270
- const secret = await getSecret('brave')
271
- if (!secret?.value) throw new Error('Brave Search requires an API key. Add a secret named "brave" in Secrets.')
272
- return new BraveProvider(secret.value)
273
+ let apiKey = settings.braveApiKey
274
+ if (!apiKey) {
275
+ const { getSecret } = await import('../storage')
276
+ const secret = await getSecret('brave')
277
+ apiKey = secret?.value ?? null
278
+ }
279
+ if (!apiKey) throw new Error('Brave Search requires an API key. Set one in Settings > Web Search.')
280
+ return new BraveProvider(apiKey)
273
281
  }
274
282
  default:
275
283
  return new DuckDuckGoProvider()
@@ -0,0 +1,106 @@
1
+ import { z } from 'zod'
2
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import { genId } from '@/lib/id'
4
+ import { loadAgents, loadSessions, saveSessions } from '../storage'
5
+ import { executeSessionChatTurn } from '../chat-execution'
6
+ import { log } from '../logger'
7
+ import type { ToolBuildContext } from './context'
8
+
9
+ const MAX_RECURSION_DEPTH = 3
10
+
11
+ function getSessionDepth(sessionId: string | undefined): number {
12
+ if (!sessionId) return 0
13
+ const sessions = loadSessions()
14
+ let depth = 0
15
+ let current = sessionId
16
+ while (current && depth < MAX_RECURSION_DEPTH + 1) {
17
+ const session = sessions[current]
18
+ if (!session?.parentSessionId) break
19
+ current = session.parentSessionId
20
+ depth++
21
+ }
22
+ return depth
23
+ }
24
+
25
+ export function buildSubagentTools(bctx: ToolBuildContext): StructuredToolInterface[] {
26
+ const { ctx, hasTool } = bctx
27
+ if (!hasTool('spawn_subagent')) return []
28
+
29
+ return [
30
+ tool(
31
+ async ({ agentId, message, cwd }) => {
32
+ try {
33
+ // Validate agent exists
34
+ const agents = loadAgents()
35
+ const agent = agents[agentId]
36
+ if (!agent) return `Error: Agent "${agentId}" not found. Available agents: ${Object.values(agents).map((a) => `"${a.id}" (${a.name})`).join(', ')}`
37
+
38
+ // Check recursion depth
39
+ const depth = getSessionDepth(ctx?.sessionId ?? undefined)
40
+ if (depth >= MAX_RECURSION_DEPTH) {
41
+ return `Error: Maximum subagent recursion depth (${MAX_RECURSION_DEPTH}) reached. Cannot spawn further subagents.`
42
+ }
43
+
44
+ // Create ephemeral session
45
+ const sessionId = genId()
46
+ const now = Date.now()
47
+ const sessions = loadSessions()
48
+ sessions[sessionId] = {
49
+ id: sessionId,
50
+ name: `subagent-${agent.name}-${sessionId.slice(0, 6)}`,
51
+ cwd: cwd || bctx.cwd,
52
+ user: 'agent',
53
+ provider: agent.provider,
54
+ model: agent.model,
55
+ credentialId: agent.credentialId || null,
56
+ fallbackCredentialIds: agent.fallbackCredentialIds || [],
57
+ apiEndpoint: agent.apiEndpoint || null,
58
+ claudeSessionId: null,
59
+ messages: [],
60
+ createdAt: now,
61
+ lastActiveAt: now,
62
+ sessionType: 'orchestrated',
63
+ agentId: agent.id,
64
+ parentSessionId: ctx?.sessionId || null,
65
+ tools: agent.tools || [],
66
+ }
67
+ saveSessions(sessions)
68
+
69
+ log.info('subagent', `Spawning subagent "${agent.name}" (depth=${depth + 1})`, {
70
+ parentSessionId: ctx?.sessionId,
71
+ childSessionId: sessionId,
72
+ agentId,
73
+ })
74
+
75
+ // Execute the chat turn
76
+ const result = await executeSessionChatTurn({
77
+ sessionId,
78
+ message,
79
+ internal: true,
80
+ source: 'subagent',
81
+ })
82
+
83
+ return JSON.stringify({
84
+ agentId,
85
+ agentName: agent.name,
86
+ sessionId,
87
+ response: result.text.slice(0, 8000),
88
+ toolEvents: result.toolEvents?.length || 0,
89
+ error: result.error || null,
90
+ })
91
+ } catch (err: unknown) {
92
+ return `Error spawning subagent: ${err instanceof Error ? err.message : String(err)}`
93
+ }
94
+ },
95
+ {
96
+ name: 'spawn_subagent',
97
+ description: `Delegate a task to another agent. The subagent runs independently and returns its response. Use this to leverage specialized agents for subtasks. Max recursion depth: ${MAX_RECURSION_DEPTH}.`,
98
+ schema: z.object({
99
+ agentId: z.string().describe('ID of the agent to delegate to'),
100
+ message: z.string().describe('The message/task to send to the subagent'),
101
+ cwd: z.string().optional().describe('Optional working directory for the subagent (defaults to current)'),
102
+ }),
103
+ },
104
+ ),
105
+ ]
106
+ }