@swarmclawai/swarmclaw 0.7.6 → 0.7.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.
Files changed (86) hide show
  1. package/README.md +19 -10
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +13 -1
  7. package/src/app/api/connectors/[id]/route.ts +20 -2
  8. package/src/app/api/connectors/route.ts +12 -8
  9. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  10. package/src/app/api/external-agents/[id]/route.ts +38 -6
  11. package/src/app/api/external-agents/route.ts +17 -1
  12. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  13. package/src/app/api/gateways/[id]/route.ts +53 -1
  14. package/src/app/api/gateways/route.ts +53 -0
  15. package/src/app/api/openclaw/deploy/route.ts +139 -0
  16. package/src/app/api/projects/[id]/route.ts +6 -2
  17. package/src/app/api/projects/route.ts +4 -3
  18. package/src/app/api/secrets/[id]/route.ts +1 -0
  19. package/src/app/api/secrets/route.ts +2 -1
  20. package/src/app/api/settings/route.ts +2 -0
  21. package/src/cli/index.js +40 -0
  22. package/src/cli/index.test.js +68 -0
  23. package/src/cli/spec.js +60 -0
  24. package/src/components/agents/agent-sheet.tsx +281 -33
  25. package/src/components/auth/setup-wizard.tsx +75 -2
  26. package/src/components/chat/chat-area.tsx +36 -19
  27. package/src/components/chat/chat-header.tsx +4 -0
  28. package/src/components/chat/delegation-banner.test.ts +14 -1
  29. package/src/components/chat/delegation-banner.tsx +1 -1
  30. package/src/components/gateways/gateway-sheet.tsx +140 -8
  31. package/src/components/layout/app-layout.tsx +40 -23
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
  33. package/src/components/projects/project-detail.tsx +217 -0
  34. package/src/components/projects/project-sheet.tsx +176 -4
  35. package/src/components/providers/provider-list.tsx +221 -17
  36. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  37. package/src/components/shared/settings/section-voice.tsx +11 -3
  38. package/src/components/tasks/approvals-panel.tsx +177 -18
  39. package/src/components/tasks/task-board.tsx +137 -23
  40. package/src/components/tasks/task-card.tsx +29 -0
  41. package/src/components/tasks/task-sheet.tsx +16 -4
  42. package/src/lib/server/agent-runtime-config.ts +142 -7
  43. package/src/lib/server/agent-thread-session.ts +9 -1
  44. package/src/lib/server/capability-router.test.ts +22 -0
  45. package/src/lib/server/capability-router.ts +54 -18
  46. package/src/lib/server/chat-execution.ts +33 -3
  47. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  48. package/src/lib/server/connectors/manager.ts +99 -74
  49. package/src/lib/server/daemon-state.ts +83 -46
  50. package/src/lib/server/elevenlabs.test.ts +59 -1
  51. package/src/lib/server/heartbeat-service.ts +5 -1
  52. package/src/lib/server/main-agent-loop.test.ts +260 -0
  53. package/src/lib/server/main-agent-loop.ts +559 -14
  54. package/src/lib/server/openclaw-deploy.test.ts +8 -0
  55. package/src/lib/server/openclaw-deploy.ts +679 -19
  56. package/src/lib/server/orchestrator-lg.ts +1 -0
  57. package/src/lib/server/orchestrator.ts +11 -0
  58. package/src/lib/server/plugins.ts +6 -1
  59. package/src/lib/server/project-context.ts +162 -0
  60. package/src/lib/server/project-utils.ts +150 -0
  61. package/src/lib/server/queue-followups.test.ts +147 -2
  62. package/src/lib/server/queue.ts +278 -8
  63. package/src/lib/server/session-run-manager.ts +31 -0
  64. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  65. package/src/lib/server/session-tools/connector.ts +26 -1
  66. package/src/lib/server/session-tools/context.ts +5 -0
  67. package/src/lib/server/session-tools/crud.ts +265 -76
  68. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  69. package/src/lib/server/session-tools/delegate.ts +38 -2
  70. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  71. package/src/lib/server/session-tools/memory.ts +14 -2
  72. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  73. package/src/lib/server/session-tools/platform.ts +60 -19
  74. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  75. package/src/lib/server/session-tools/web.ts +153 -6
  76. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  77. package/src/lib/server/stream-agent-chat.ts +104 -30
  78. package/src/lib/server/tool-aliases.ts +2 -0
  79. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  80. package/src/lib/server/tool-capability-policy.ts +29 -1
  81. package/src/lib/server/tool-planning.test.ts +44 -0
  82. package/src/lib/server/tool-planning.ts +269 -0
  83. package/src/lib/setup-defaults.ts +2 -2
  84. package/src/lib/tool-definitions.ts +2 -1
  85. package/src/lib/validation/schemas.ts +9 -0
  86. package/src/types/index.ts +104 -0
@@ -11,11 +11,19 @@ import { loadRuntimeSettings, getAgentLoopRecursionLimit } from './runtime-setti
11
11
 
12
12
  import { logExecution } from './execution-log'
13
13
  import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
14
- import { expandPluginIds } from './tool-aliases'
14
+ import { canonicalizePluginId, expandPluginIds } from './tool-aliases'
15
15
  import type { Session, Message, UsageRecord, PluginInvocationRecord } from '@/types'
16
16
  import { extractSuggestions } from './suggestions'
17
17
  import { buildIdentityContinuityContext } from './identity-continuity'
18
18
  import { enqueueSystemEvent } from './system-events'
19
+ import { resolveActiveProjectContext } from './project-context'
20
+ import {
21
+ getEnabledToolPlanningView,
22
+ getFirstToolForCapability,
23
+ getToolsForCapability,
24
+ matchToolCapabilitiesForMessage,
25
+ TOOL_CAPABILITY,
26
+ } from './tool-planning'
19
27
 
20
28
  /** Extract a breadcrumb title from notable tool completions (task/schedule/agent creation). */
21
29
  interface StreamAgentChatOpts {
@@ -46,7 +54,8 @@ function buildPluginCapabilityLines(enabledPlugins: string[], opts?: { platformA
46
54
  }
47
55
 
48
56
  export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
49
- const uniqueTools = Array.from(new Set(enabledPlugins.filter(Boolean))).sort()
57
+ const planning = getEnabledToolPlanningView(enabledPlugins)
58
+ const uniqueTools = planning.displayToolIds
50
59
  if (uniqueTools.length === 0) return []
51
60
 
52
61
  const lines = [
@@ -59,33 +68,26 @@ export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
59
68
  lines.push(`Use direct platform tools exactly as named (${directPlatformTools.map((toolId) => `\`${toolId}\``).join(', ')}). Do not substitute \`manage_platform\` unless it is explicitly enabled.`)
60
69
  }
61
70
 
62
- if (uniqueTools.includes('files')) {
63
- lines.push('For `files`, include an explicit action whenever possible. Common patterns: `{"action":"list","dirPath":"."}`, `{"action":"read","filePath":"path/to/file.md"}`, and `{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}`.')
64
- }
65
-
66
- if (uniqueTools.includes('shell')) {
67
- lines.push('For `shell`, use `{"action":"execute","command":"..."}` for commands and `{"action":"status","processId":"..."}` or `{"action":"log","processId":"..."}` for long-lived processes.')
68
- }
71
+ lines.push(...planning.disciplineGuidance)
69
72
 
70
- if (uniqueTools.includes('web')) {
71
- lines.push('For `web`, use `{"action":"search","query":"..."}` to research and `{"action":"fetch","url":"https://..."}` to read a specific page.')
72
- }
73
-
74
- if (uniqueTools.includes('browser')) {
75
- lines.push('For `browser`, when the task includes a literal URL, pass that exact URL string to `{"action":"navigate","url":"..."}`. Do not invent placeholder URLs like `[Your URL]`, `Example_URL`, or `MockMailPage_URL`.')
76
- lines.push('For `browser` form work, prefer `{"action":"fill_form","fields":[{"element":"#email","value":"user@example.com"},{"element":"#password","value":"..."}]}`. A shorthand `form` object keyed by input id/name also works for simple forms.')
77
- }
73
+ const researchSearchTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.researchSearch)
74
+ const researchFetchTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.researchFetch)
75
+ const browserCaptureTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.browserCapture)
76
+ const deliveryMediaTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.deliveryMedia)
77
+ const deliveryVoiceTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.deliveryVoiceNote)
78
78
 
79
- if (uniqueTools.includes('http_request')) {
80
- lines.push('For `http_request`, send exact literal URLs from the task or from prior tool results. Keep JSON request bodies as raw JSON strings.')
79
+ if ((researchSearchTools.length || researchFetchTools.length) && browserCaptureTools.length) {
80
+ const researchLabel = [...researchSearchTools, ...researchFetchTools].map((toolName) => `\`${toolName}\``).join('/')
81
+ lines.push(`Research tools like ${researchLabel} gather sources and text, but they do not capture screenshots. Use \`${browserCaptureTools[0]}\` for screenshots or rendered page evidence.`)
82
+ lines.push(`When a task asks for both research and screenshots, use ${researchLabel} first to identify the right source URLs, then use \`${browserCaptureTools[0]}\` to capture the relevant page.`)
81
83
  }
82
84
 
83
- if (uniqueTools.includes('email')) {
84
- lines.push('For `email`, send mail with `{"action":"send","to":"user@example.com","subject":"...","body":"..."}`. If delivery depends on SMTP setup, check `{"action":"status"}` before claiming success.')
85
+ if (browserCaptureTools.length && deliveryMediaTools.length) {
86
+ lines.push(`When the user asks you to send screenshots or other media, capture the artifact first with \`${browserCaptureTools[0]}\`, then deliver that exact file or upload URL through \`${deliveryMediaTools[0]}\` instead of saying the capability is unavailable.`)
85
87
  }
86
88
 
87
- if (uniqueTools.includes('ask_human')) {
88
- lines.push('For `ask_human`, when a workflow needs a code, approval, or out-of-band value from a person, do not guess or keep re-submitting blank forms. Use `{"action":"request_input","question":"..."}` and, for durable pauses, `{"action":"wait_for_reply","correlationId":"..."}`.')
89
+ if (deliveryVoiceTools.length) {
90
+ lines.push(`If the user asks for a voice note and \`${deliveryVoiceTools[0]}\` is enabled, try it before saying voice notes are unsupported.`)
89
91
  }
90
92
 
91
93
  return lines
@@ -99,9 +101,36 @@ export function looksLikeOpenEndedDeliverableTask(text: string): boolean {
99
101
  return isBroadGoal(text) && /(\.md\b|\.txt\b|copy|brief|proposal|plan|report|draft|document)/.test(normalized)
100
102
  }
101
103
 
102
- function getExplicitRequiredToolNames(userMessage: string, enabledPlugins: string[]): string[] {
104
+ export function getExplicitRequiredToolNames(userMessage: string, enabledPlugins: string[]): string[] {
103
105
  const normalized = userMessage.toLowerCase()
104
106
  const required: string[] = []
107
+ const matchedCapabilities = matchToolCapabilitiesForMessage(enabledPlugins, userMessage)
108
+
109
+ const requireCapability = (capability: string) => {
110
+ const toolName = matchedCapabilities.get(capability)?.[0] || getFirstToolForCapability(enabledPlugins, capability)
111
+ if (toolName && !required.includes(toolName)) required.push(toolName)
112
+ }
113
+
114
+ if (matchedCapabilities.has(TOOL_CAPABILITY.researchSearch)) {
115
+ requireCapability(TOOL_CAPABILITY.researchSearch)
116
+ }
117
+
118
+ if (matchedCapabilities.has(TOOL_CAPABILITY.researchFetch)) {
119
+ requireCapability(TOOL_CAPABILITY.researchFetch)
120
+ }
121
+
122
+ if (matchedCapabilities.has(TOOL_CAPABILITY.browserCapture)) {
123
+ requireCapability(TOOL_CAPABILITY.browserCapture)
124
+ }
125
+
126
+ if (matchedCapabilities.has(TOOL_CAPABILITY.deliveryVoiceNote)) {
127
+ requireCapability(TOOL_CAPABILITY.deliveryVoiceNote)
128
+ }
129
+
130
+ if (matchedCapabilities.has(TOOL_CAPABILITY.deliveryMedia) || matchedCapabilities.has(TOOL_CAPABILITY.deliveryMessage)) {
131
+ requireCapability(TOOL_CAPABILITY.deliveryMedia)
132
+ requireCapability(TOOL_CAPABILITY.deliveryMessage)
133
+ }
105
134
 
106
135
  if (enabledPlugins.includes('ask_human')
107
136
  && (/\bask_human\b/.test(normalized) || /ask the human/.test(normalized) || /request_input/.test(normalized))) {
@@ -140,10 +169,10 @@ const GOAL_DECOMPOSITION_BLOCK = [
140
169
  '## Goal Decomposition',
141
170
  'When you receive a broad, open-ended goal:',
142
171
  '1. Break it into 3-7 concrete, sequentially-executable subtasks before taking action.',
143
- '2. If manage_tasks is available, create a task for each subtask to track progress.',
144
- '3. Present the plan as a short checklist or numbered list in plain language.',
145
- '4. Execute the first subtask immediately — do not stop after planning.',
146
- '5. After each subtask, update progress and move to the next.',
172
+ '2. If manage_tasks is available, use it only for durable tracking: multi-turn work, delegation, explicit backlog requests, or work you expect to resume later. Do not create a task for every micro-step.',
173
+ '3. Present the plan as a short checklist or numbered list in plain language. If durable tracking is unnecessary, keep it inline instead of creating tasks.',
174
+ '4. Execute the first substantive subtask immediately — do not stop after planning.',
175
+ '5. Update only the durable tasks you actually created; otherwise just continue executing and report progress plainly.',
147
176
  ].join('\n')
148
177
 
149
178
  function buildAgenticExecutionPolicy(opts: {
@@ -275,6 +304,8 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
275
304
  let agentMcpServerIds: string[] | undefined
276
305
  let agentMcpDisabledTools: string[] | undefined
277
306
  let agentHeartbeatEnabled = false
307
+ let agentMemoryScopeMode: 'auto' | 'all' | 'global' | 'agent' | 'session' | 'project' | null = null
308
+ const activeProjectContext = resolveActiveProjectContext(session)
278
309
  if (session.agentId) {
279
310
  const agents = loadAgents()
280
311
  const agent = agents[session.agentId]
@@ -282,6 +313,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
282
313
  agentMcpServerIds = agent?.mcpServerIds
283
314
  agentMcpDisabledTools = agent?.mcpDisabledTools
284
315
  agentHeartbeatEnabled = agent?.heartbeatEnabled === true
316
+ agentMemoryScopeMode = agent?.memoryScopeMode || null
285
317
  if (!hasProvidedSystemPrompt) {
286
318
  // Identity block — make sure the agent knows who it is
287
319
  const identityLines = [`## My Identity`, `My name is ${agent?.name || 'Agent'}.`]
@@ -336,6 +368,43 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
336
368
  // Plugin context injection is non-critical
337
369
  }
338
370
 
371
+ if (!hasProvidedSystemPrompt && activeProjectContext.projectId) {
372
+ const projectLines = ['## Current Project']
373
+ if (activeProjectContext.project?.name) {
374
+ projectLines.push(`Active project: ${activeProjectContext.project.name}.`)
375
+ } else {
376
+ projectLines.push(`Active project ID: ${activeProjectContext.projectId}.`)
377
+ }
378
+ if (activeProjectContext.project?.description) {
379
+ projectLines.push(`Project description: ${activeProjectContext.project.description}`)
380
+ projectLines.push('Treat the project description above as authoritative context for who the project is for, what it is focused on, and which pilot priorities matter right now. If the user asks about the active project, answer from that description instead of saying the context is unavailable.')
381
+ }
382
+ if (activeProjectContext.objective) projectLines.push(`Project objective: ${activeProjectContext.objective}`)
383
+ if (activeProjectContext.audience) projectLines.push(`Who it is for: ${activeProjectContext.audience}`)
384
+ if (activeProjectContext.priorities.length > 0) projectLines.push(`Pilot priorities: ${activeProjectContext.priorities.join('; ')}`)
385
+ if (activeProjectContext.openObjectives.length > 0) projectLines.push(`Open objectives: ${activeProjectContext.openObjectives.join('; ')}`)
386
+ if (activeProjectContext.capabilityHints.length > 0) projectLines.push(`Suggested operating modes: ${activeProjectContext.capabilityHints.join('; ')}`)
387
+ if (activeProjectContext.credentialRequirements.length > 0) projectLines.push(`Credential and secret requirements: ${activeProjectContext.credentialRequirements.join('; ')}`)
388
+ if (activeProjectContext.successMetrics.length > 0) projectLines.push(`Success metrics: ${activeProjectContext.successMetrics.join('; ')}`)
389
+ if (activeProjectContext.heartbeatPrompt) projectLines.push(`Preferred heartbeat prompt: ${activeProjectContext.heartbeatPrompt}`)
390
+ if (activeProjectContext.heartbeatIntervalSec != null) projectLines.push(`Preferred heartbeat interval: ${activeProjectContext.heartbeatIntervalSec}s`)
391
+ if (activeProjectContext.resourceSummary) {
392
+ const summary = activeProjectContext.resourceSummary
393
+ const resourceBits = [
394
+ `open tasks ${summary.openTaskCount}`,
395
+ `active schedules ${summary.activeScheduleCount}`,
396
+ `project secrets ${summary.secretCount}`,
397
+ ]
398
+ if (summary.topTaskTitles.length > 0) projectLines.push(`Top open tasks: ${summary.topTaskTitles.join('; ')}`)
399
+ if (summary.scheduleNames.length > 0) projectLines.push(`Active schedules: ${summary.scheduleNames.join('; ')}`)
400
+ if (summary.secretNames.length > 0) projectLines.push(`Known project secrets: ${summary.secretNames.join('; ')}`)
401
+ projectLines.push(`Project resource summary: ${resourceBits.join(', ')}.`)
402
+ }
403
+ if (activeProjectContext.projectRoot) projectLines.push(`Workspace root: ${activeProjectContext.projectRoot}`)
404
+ projectLines.push('When creating project tasks, schedules, secrets, memories, or deliverables for this work, default them to the active project unless the user redirects you.')
405
+ stateModifierParts.push(projectLines.join('\n'))
406
+ }
407
+
339
408
  // Tell the LLM about available plugins and their access status
340
409
  {
341
410
  const agentEnabledSet = new Set(sessionPlugins)
@@ -407,6 +476,11 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
407
476
  platformAssignScope: agentPlatformAssignScope,
408
477
  mcpServerIds: agentMcpServerIds,
409
478
  mcpDisabledTools: agentMcpDisabledTools,
479
+ projectId: activeProjectContext.projectId,
480
+ projectRoot: activeProjectContext.projectRoot,
481
+ projectName: activeProjectContext.project?.name || null,
482
+ projectDescription: activeProjectContext.project?.description || null,
483
+ memoryScopeMode: agentMemoryScopeMode,
410
484
  })
411
485
  const agent = createReactAgent({ llm, tools, stateModifier })
412
486
  const recursionLimit = getAgentLoopRecursionLimit(runtime)
@@ -707,7 +781,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
707
781
  needsTextSeparator = true
708
782
  lastSegment = ''
709
783
  const toolName = event.name || 'unknown'
710
- usedToolNames.add(toolName)
784
+ usedToolNames.add(canonicalizePluginId(toolName) || toolName)
711
785
  const input = event.data?.input
712
786
  // Estimate input tokens for plugin invocation tracking
713
787
  const inputStr = typeof input === 'string' ? input : JSON.stringify(input)
@@ -851,7 +925,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
851
925
  langchainMessages.push(new AIMessage({ content: fullText }))
852
926
  }
853
927
  langchainMessages.push(new HumanMessage({
854
- content: `You have not yet completed the required explicit tool step(s): ${requiredToolReminderNames.join(', ')}. Use those enabled tools now before declaring success. Do not replace ask_human with a plain-text request, and do not replace email delivery with browser work or prose.`,
928
+ content: `You have not yet completed the required explicit tool step(s): ${requiredToolReminderNames.join(', ')}. Use those enabled tools now before declaring success. Do not replace ask_human with a plain-text request, do not replace outbound delivery tools with prose, and do not replace screenshot requests with text-only summaries.`,
855
929
  }))
856
930
  lastSegment = ''
857
931
  } else if (shouldContinue === 'transient') {
@@ -7,6 +7,7 @@ const PLUGIN_ALIAS_GROUPS: string[][] = [
7
7
  ['delegate', 'claude_code', 'codex_cli', 'opencode_cli', 'gemini_cli', 'delegate_to_claude_code', 'delegate_to_codex_cli', 'delegate_to_opencode_cli', 'delegate_to_gemini_cli'],
8
8
  ['manage_platform'],
9
9
  ['manage_agents'],
10
+ ['manage_projects'],
10
11
  ['manage_tasks'],
11
12
  ['manage_schedules'],
12
13
  ['manage_skills'],
@@ -43,6 +44,7 @@ const PLUGIN_IMPLICATIONS: Record<string, string[]> = {
43
44
  shell: ['process'],
44
45
  manage_platform: [
45
46
  'manage_agents',
47
+ 'manage_projects',
46
48
  'manage_tasks',
47
49
  'manage_schedules',
48
50
  'manage_skills',
@@ -56,3 +56,27 @@ test('concrete tool checks inherit blocked family rules', () => {
56
56
  null,
57
57
  )
58
58
  })
59
+
60
+ test('task and project management can be disabled from app settings', () => {
61
+ const decision = resolveSessionToolPolicy(
62
+ ['manage_platform', 'manage_tasks', 'manage_projects'],
63
+ {
64
+ taskManagementEnabled: false,
65
+ projectManagementEnabled: false,
66
+ },
67
+ )
68
+
69
+ assert.deepEqual(decision.enabledPlugins, ['manage_platform'])
70
+ assert.equal(
71
+ decision.blockedPlugins.some((entry) => entry.tool === 'manage_tasks' && /disabled in app settings/.test(entry.reason)),
72
+ true,
73
+ )
74
+ assert.equal(
75
+ decision.blockedPlugins.some((entry) => entry.tool === 'manage_projects' && /disabled in app settings/.test(entry.reason)),
76
+ true,
77
+ )
78
+ assert.match(
79
+ resolveConcreteToolPolicyBlock('manage_tasks', decision, { taskManagementEnabled: false }),
80
+ /task management is disabled/i,
81
+ )
82
+ })
@@ -64,8 +64,9 @@ const TOOL_DESCRIPTORS: Record<string, ToolDescriptor> = {
64
64
  monitor: { categories: ['execution'], concreteTools: ['monitor', 'monitor_tool'] },
65
65
  openclaw_workspace: { categories: ['filesystem', 'platform'], concreteTools: ['openclaw_workspace'] },
66
66
  openclaw_nodes: { categories: ['platform'], concreteTools: ['openclaw_nodes'] },
67
- manage_platform: { categories: ['platform'], concreteTools: ['manage_platform', 'manage_agents', 'manage_tasks', 'manage_schedules', 'manage_skills', 'manage_documents', 'manage_webhooks', 'manage_connectors', 'manage_sessions', 'manage_secrets'] },
67
+ manage_platform: { categories: ['platform'], concreteTools: ['manage_platform', 'manage_agents', 'manage_projects', 'manage_tasks', 'manage_schedules', 'manage_skills', 'manage_documents', 'manage_webhooks', 'manage_connectors', 'manage_sessions', 'manage_secrets'] },
68
68
  manage_agents: { categories: ['platform'], concreteTools: ['manage_agents'] },
69
+ manage_projects: { categories: ['platform'], concreteTools: ['manage_projects'] },
69
70
  manage_tasks: { categories: ['platform'], concreteTools: ['manage_tasks'] },
70
71
  manage_schedules: { categories: ['platform'], concreteTools: ['manage_schedules'] },
71
72
  schedule_wake: { categories: ['platform'], concreteTools: ['schedule_wake'] },
@@ -179,6 +180,24 @@ function ensureSettings(settings?: AppSettings | Record<string, unknown> | null)
179
180
  return settings as Record<string, unknown>
180
181
  }
181
182
 
183
+ export function isTaskManagementEnabled(settings?: AppSettings | Record<string, unknown> | null): boolean {
184
+ return ensureSettings(settings).taskManagementEnabled !== false
185
+ }
186
+
187
+ export function isProjectManagementEnabled(settings?: AppSettings | Record<string, unknown> | null): boolean {
188
+ return ensureSettings(settings).projectManagementEnabled !== false
189
+ }
190
+
191
+ function settingsBlockReason(toolName: string, settings?: AppSettings | Record<string, unknown> | null): string | null {
192
+ if (toolName === 'manage_tasks' && !isTaskManagementEnabled(settings)) {
193
+ return 'blocked because task management is disabled in app settings'
194
+ }
195
+ if (toolName === 'manage_projects' && !isProjectManagementEnabled(settings)) {
196
+ return 'blocked because project management is disabled in app settings'
197
+ }
198
+ return null
199
+ }
200
+
182
201
  function parsePolicyConfig(settings: Record<string, unknown>) {
183
202
  const mode = normalizeMode(settings.capabilityPolicyMode)
184
203
  const safetyBlocked = new Set(getSettingsList(settings, 'safetyBlockedTools'))
@@ -216,6 +235,12 @@ export function resolveSessionToolPolicy(
216
235
 
217
236
  for (const pluginName of requestedPlugins) {
218
237
  const descriptor = TOOL_DESCRIPTORS[pluginName]
238
+ const settingsReason = settingsBlockReason(pluginName, normalizedSettings)
239
+
240
+ if (settingsReason) {
241
+ blockedPlugins.push({ tool: pluginName, reason: settingsReason, source: 'policy' })
242
+ continue
243
+ }
219
244
 
220
245
  if (safetyMatchesTool(safetyBlocked, pluginName, descriptor)) {
221
246
  blockedPlugins.push({ tool: pluginName, reason: 'blocked by safety policy', source: 'safety' })
@@ -269,6 +294,9 @@ export function resolveConcreteToolPolicyBlock(
269
294
  policyBlockedNames,
270
295
  policyAllowedNames,
271
296
  } = parsePolicyConfig(normalizedSettings)
297
+ const settingsReason = settingsBlockReason(name, normalizedSettings)
298
+
299
+ if (settingsReason) return settingsReason
272
300
 
273
301
  if (safetyBlocked.has(name)) return 'blocked by safety policy'
274
302
 
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { getPluginManager } from './plugins'
4
+ import { getEnabledToolPlanningView, getToolsForCapability, TOOL_CAPABILITY } from './tool-planning'
5
+
6
+ let seq = 0
7
+
8
+ function uniquePluginId(prefix: string): string {
9
+ seq += 1
10
+ return `${prefix}_${Date.now()}_${seq}`
11
+ }
12
+
13
+ describe('tool-planning', () => {
14
+ it('collects core planning metadata for aliased built-in tools', () => {
15
+ const view = getEnabledToolPlanningView(['web_search', 'web_fetch', 'browser', 'manage_connectors'])
16
+
17
+ assert.deepEqual(view.displayToolIds, ['browser', 'manage_connectors', 'web'])
18
+ assert.deepEqual(getToolsForCapability(['web_search'], TOOL_CAPABILITY.researchSearch), ['web_search'])
19
+ assert.deepEqual(getToolsForCapability(['manage_connectors'], TOOL_CAPABILITY.deliveryVoiceNote), ['connector_message_tool'])
20
+ })
21
+
22
+ it('collects planning metadata from custom plugin tools', () => {
23
+ const pluginId = uniquePluginId('planner_plugin')
24
+ getPluginManager().registerBuiltin(pluginId, {
25
+ name: 'Planner Plugin',
26
+ tools: [
27
+ {
28
+ name: 'custom_media_sender',
29
+ description: 'Send rendered media somewhere special.',
30
+ planning: {
31
+ capabilities: ['delivery.media', 'delivery.voice_note'],
32
+ disciplineGuidance: ['Use `custom_media_sender` for bespoke outbound media delivery.'],
33
+ },
34
+ parameters: { type: 'object', properties: {} },
35
+ execute: async () => 'ok',
36
+ },
37
+ ],
38
+ })
39
+
40
+ const view = getEnabledToolPlanningView([pluginId])
41
+ assert.deepEqual(getToolsForCapability([pluginId], TOOL_CAPABILITY.deliveryMedia), ['custom_media_sender'])
42
+ assert.equal(view.disciplineGuidance.includes('Use `custom_media_sender` for bespoke outbound media delivery.'), true)
43
+ })
44
+ })
@@ -0,0 +1,269 @@
1
+ import type { PluginToolPlanning } from '@/types'
2
+ import { getPluginManager } from './plugins'
3
+ import { canonicalizePluginId, expandPluginIds } from './tool-aliases'
4
+
5
+ export const TOOL_CAPABILITY = {
6
+ researchSearch: 'research.search',
7
+ researchFetch: 'research.fetch',
8
+ browserNavigate: 'browser.navigate',
9
+ browserCapture: 'browser.capture',
10
+ artifactPdf: 'artifact.pdf',
11
+ deliveryMessage: 'delivery.message',
12
+ deliveryMedia: 'delivery.media',
13
+ deliveryVoiceNote: 'delivery.voice_note',
14
+ } as const
15
+
16
+ export interface ToolPlanningEntry {
17
+ toolName: string
18
+ capabilities: string[]
19
+ disciplineGuidance: string[]
20
+ requestMatchers: NonNullable<PluginToolPlanning['requestMatchers']>
21
+ }
22
+
23
+ export interface ToolPlanningView {
24
+ displayToolIds: string[]
25
+ expandedPluginIds: string[]
26
+ entries: ToolPlanningEntry[]
27
+ disciplineGuidance: string[]
28
+ capabilityToTools: Map<string, string[]>
29
+ }
30
+
31
+ const CORE_TOOL_PLANNING: Record<string, ToolPlanningEntry[]> = {
32
+ files: [
33
+ {
34
+ toolName: 'files',
35
+ capabilities: ['artifact.files'],
36
+ disciplineGuidance: [
37
+ 'For `files`, include an explicit action whenever possible. Common patterns: `{"action":"list","dirPath":"."}`, `{"action":"read","filePath":"path/to/file.md"}`, and `{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}`.',
38
+ ],
39
+ requestMatchers: [],
40
+ },
41
+ ],
42
+ shell: [
43
+ {
44
+ toolName: 'shell',
45
+ capabilities: ['runtime.shell'],
46
+ disciplineGuidance: [
47
+ 'For `shell`, use `{"action":"execute","command":"..."}` for commands and `{"action":"status","processId":"..."}` or `{"action":"log","processId":"..."}` for long-lived processes.',
48
+ ],
49
+ requestMatchers: [],
50
+ },
51
+ ],
52
+ web: [
53
+ {
54
+ toolName: 'web_search',
55
+ capabilities: [TOOL_CAPABILITY.researchSearch],
56
+ disciplineGuidance: [
57
+ 'For `web_search`, use `{"query":"..."}` to research fresh information. For current events, breaking news, or "latest" requests, start with `web_search` before summarizing.',
58
+ ],
59
+ requestMatchers: [
60
+ {
61
+ capability: TOOL_CAPABILITY.researchSearch,
62
+ patterns: ['research', 'look up', 'find out', 'search for', 'compare', 'latest', 'news', 'headline', 'current event', 'recent update', "what's new", 'what happened'],
63
+ forbidLiteralUrl: true,
64
+ },
65
+ ],
66
+ },
67
+ {
68
+ toolName: 'web_fetch',
69
+ capabilities: [TOOL_CAPABILITY.researchFetch],
70
+ disciplineGuidance: [
71
+ 'For `web_fetch`, use `{"url":"https://..."}` to read a specific page or article after you know the URL.',
72
+ ],
73
+ requestMatchers: [
74
+ {
75
+ capability: TOOL_CAPABILITY.researchFetch,
76
+ patterns: ['read', 'summarize', 'summarise', 'analyze', 'analyse', 'extract', 'review', 'article', 'page', 'url', 'link'],
77
+ requireLiteralUrl: true,
78
+ },
79
+ ],
80
+ },
81
+ ],
82
+ browser: [
83
+ {
84
+ toolName: 'browser',
85
+ capabilities: [TOOL_CAPABILITY.browserNavigate, TOOL_CAPABILITY.browserCapture, TOOL_CAPABILITY.artifactPdf],
86
+ disciplineGuidance: [
87
+ 'For `browser`, when the task includes a literal URL, pass that exact URL string to `{"action":"navigate","url":"..."}`. Do not invent placeholder URLs like `[Your URL]`, `Example_URL`, or `MockMailPage_URL`.',
88
+ 'For `browser` form work, prefer `{"action":"fill_form","fields":[{"element":"#email","value":"user@example.com"},{"element":"#password","value":"..."}]}`. A shorthand `form` object keyed by input id/name also works for simple forms.',
89
+ 'Use `browser` when the user asks for screenshots, visual proof, page capture, PDFs, or a rendered view of a page. `navigate` alone is not a screenshot.',
90
+ ],
91
+ requestMatchers: [
92
+ {
93
+ capability: TOOL_CAPABILITY.browserNavigate,
94
+ patterns: ['browser', 'click', 'fill form', 'log in', 'login', 'navigate'],
95
+ requireLiteralUrl: true,
96
+ },
97
+ {
98
+ capability: TOOL_CAPABILITY.browserCapture,
99
+ patterns: ['screenshot', 'screen shot', 'snapshot', 'page capture', 'visual proof', 'capture the page', 'rendered view'],
100
+ },
101
+ {
102
+ capability: TOOL_CAPABILITY.artifactPdf,
103
+ patterns: ['pdf', 'save as pdf', 'export pdf'],
104
+ },
105
+ ],
106
+ },
107
+ ],
108
+ manage_connectors: [
109
+ {
110
+ toolName: 'connector_message_tool',
111
+ capabilities: [TOOL_CAPABILITY.deliveryMessage, TOOL_CAPABILITY.deliveryMedia, TOOL_CAPABILITY.deliveryVoiceNote],
112
+ disciplineGuidance: [
113
+ 'For outbound delivery, inspect available channels with `connector_message_tool` using `{"action":"list_running"}` before claiming something cannot be sent.',
114
+ 'Use `connector_message_tool` with `{"action":"send","message":"...","mediaPath":"..."}` for text/media and `{"action":"send_voice_note","voiceText":"..."}` for voice notes.',
115
+ 'If no channel or recipient is configured, explain that connector/channel setup is missing rather than claiming the capability does not exist.',
116
+ ],
117
+ requestMatchers: [
118
+ {
119
+ capability: TOOL_CAPABILITY.deliveryMessage,
120
+ patterns: ['send', 'share', 'deliver', 'message'],
121
+ },
122
+ {
123
+ capability: TOOL_CAPABILITY.deliveryMedia,
124
+ patterns: ['screenshot', 'screen shot', 'snapshot', 'image', 'photo', 'file', 'pdf', 'attachment'],
125
+ },
126
+ {
127
+ capability: TOOL_CAPABILITY.deliveryVoiceNote,
128
+ patterns: ['voice note', 'voice-note', 'voicenote', 'voice memo', 'voice message', 'audio note', 'audio update', 'ptt'],
129
+ },
130
+ ],
131
+ },
132
+ ],
133
+ http_request: [
134
+ {
135
+ toolName: 'http_request',
136
+ capabilities: ['network.http'],
137
+ disciplineGuidance: [
138
+ 'For `http_request`, send exact literal URLs from the task or from prior tool results. Keep JSON request bodies as raw JSON strings.',
139
+ ],
140
+ requestMatchers: [],
141
+ },
142
+ ],
143
+ email: [
144
+ {
145
+ toolName: 'email',
146
+ capabilities: ['delivery.email'],
147
+ disciplineGuidance: [
148
+ 'For `email`, send mail with `{"action":"send","to":"user@example.com","subject":"...","body":"..."}`. If delivery depends on SMTP setup, check `{"action":"status"}` before claiming success.',
149
+ ],
150
+ requestMatchers: [],
151
+ },
152
+ ],
153
+ ask_human: [
154
+ {
155
+ toolName: 'ask_human',
156
+ capabilities: ['human.input'],
157
+ disciplineGuidance: [
158
+ 'For `ask_human`, when a workflow needs a code, approval, or out-of-band value from a person, do not guess or keep re-submitting blank forms. Use `{"action":"request_input","question":"..."}` and, for durable pauses, `{"action":"wait_for_reply","correlationId":"..."}`.',
159
+ ],
160
+ requestMatchers: [],
161
+ },
162
+ ],
163
+ }
164
+
165
+ function dedupeStrings(values: string[]): string[] {
166
+ return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim())))
167
+ }
168
+
169
+ function normalizePlanningEntry(toolName: string, planning: PluginToolPlanning | null | undefined): ToolPlanningEntry | null {
170
+ if (!planning) return null
171
+ const capabilities = dedupeStrings(Array.isArray(planning.capabilities) ? planning.capabilities : [])
172
+ const disciplineGuidance = dedupeStrings(Array.isArray(planning.disciplineGuidance) ? planning.disciplineGuidance : [])
173
+ const requestMatchers = Array.isArray(planning.requestMatchers)
174
+ ? planning.requestMatchers
175
+ .map((matcher) => ({
176
+ capability: typeof matcher?.capability === 'string' ? matcher.capability.trim() : '',
177
+ patterns: dedupeStrings(Array.isArray(matcher?.patterns) ? matcher.patterns : []),
178
+ requireLiteralUrl: matcher?.requireLiteralUrl === true,
179
+ forbidLiteralUrl: matcher?.forbidLiteralUrl === true,
180
+ }))
181
+ .filter((matcher) => matcher.capability || matcher.patterns.length > 0)
182
+ : []
183
+ if (!capabilities.length && !disciplineGuidance.length && !requestMatchers.length) return null
184
+ return {
185
+ toolName,
186
+ capabilities,
187
+ disciplineGuidance,
188
+ requestMatchers,
189
+ }
190
+ }
191
+
192
+ export function getEnabledToolPlanningView(enabledPlugins: string[]): ToolPlanningView {
193
+ const displayToolIds = dedupeStrings(enabledPlugins.map((toolId) => canonicalizePluginId(toolId))).sort()
194
+ const expandedPluginIds = dedupeStrings(expandPluginIds(enabledPlugins)).sort()
195
+ const entries: ToolPlanningEntry[] = []
196
+
197
+ for (const pluginId of expandedPluginIds) {
198
+ const coreEntries = CORE_TOOL_PLANNING[pluginId] || []
199
+ for (const entry of coreEntries) {
200
+ entries.push({
201
+ toolName: entry.toolName,
202
+ capabilities: [...entry.capabilities],
203
+ disciplineGuidance: [...entry.disciplineGuidance],
204
+ requestMatchers: [...entry.requestMatchers],
205
+ })
206
+ }
207
+ }
208
+
209
+ for (const entry of getPluginManager().getTools(expandedPluginIds)) {
210
+ const planningEntry = normalizePlanningEntry(entry.tool.name, entry.tool.planning)
211
+ if (planningEntry) entries.push(planningEntry)
212
+ }
213
+
214
+ const disciplineSet = new Set<string>()
215
+ const capabilityToTools = new Map<string, Set<string>>()
216
+ for (const entry of entries) {
217
+ for (const line of entry.disciplineGuidance) disciplineSet.add(line)
218
+ for (const capability of entry.capabilities) {
219
+ const current = capabilityToTools.get(capability) || new Set<string>()
220
+ current.add(entry.toolName)
221
+ capabilityToTools.set(capability, current)
222
+ }
223
+ }
224
+
225
+ return {
226
+ displayToolIds,
227
+ expandedPluginIds,
228
+ entries,
229
+ disciplineGuidance: Array.from(disciplineSet),
230
+ capabilityToTools: new Map(
231
+ Array.from(capabilityToTools.entries()).map(([capability, toolNames]) => [capability, Array.from(toolNames)]),
232
+ ),
233
+ }
234
+ }
235
+
236
+ export function getToolsForCapability(enabledPlugins: string[], capability: string): string[] {
237
+ return getEnabledToolPlanningView(enabledPlugins).capabilityToTools.get(capability) || []
238
+ }
239
+
240
+ export function getFirstToolForCapability(enabledPlugins: string[], capability: string): string | null {
241
+ return getToolsForCapability(enabledPlugins, capability)[0] || null
242
+ }
243
+
244
+ export function matchToolCapabilitiesForMessage(
245
+ enabledPlugins: string[],
246
+ message: string,
247
+ ): Map<string, string[]> {
248
+ const text = String(message || '').toLowerCase()
249
+ const hasLiteralUrl = /https?:\/\/[^\s<>"')]+/i.test(message)
250
+ const matches = new Map<string, Set<string>>()
251
+
252
+ for (const entry of getEnabledToolPlanningView(enabledPlugins).entries) {
253
+ for (const matcher of entry.requestMatchers) {
254
+ const patterns = Array.isArray(matcher.patterns) ? matcher.patterns : []
255
+ if (matcher.requireLiteralUrl === true && !hasLiteralUrl) continue
256
+ if (matcher.forbidLiteralUrl === true && hasLiteralUrl) continue
257
+ if (!patterns.length) continue
258
+ const matched = patterns.some((pattern) => text.includes(pattern.toLowerCase()))
259
+ if (!matched) continue
260
+ const capability = matcher.capability || entry.capabilities[0] || ''
261
+ if (!capability) continue
262
+ const current = matches.get(capability) || new Set<string>()
263
+ current.add(entry.toolName)
264
+ matches.set(capability, current)
265
+ }
266
+ }
267
+
268
+ return new Map(Array.from(matches.entries()).map(([capability, toolNames]) => [capability, Array.from(toolNames)]))
269
+ }