@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
@@ -0,0 +1,114 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-task-tool-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ describe('manage_tasks tool', () => {
36
+ it('inherits continuation context from continueFromTaskId', () => {
37
+ const output = runWithTempDataDir(`
38
+ import fs from 'node:fs'
39
+ import path from 'node:path'
40
+ const storageMod = await import('./src/lib/server/storage.ts')
41
+ const crudMod = await import('./src/lib/server/session-tools/crud.ts')
42
+ const storage = storageMod.default || storageMod
43
+ const crud = crudMod.default || crudMod
44
+
45
+ const now = Date.now()
46
+ const workspaceDir = process.env.WORKSPACE_DIR
47
+ const projectDir = path.join(workspaceDir, 'projects', 'project-1')
48
+ fs.mkdirSync(projectDir, { recursive: true })
49
+
50
+ storage.saveAgents({
51
+ default: {
52
+ id: 'default',
53
+ name: 'Molly',
54
+ description: '',
55
+ systemPrompt: '',
56
+ provider: 'openai',
57
+ model: 'gpt-test',
58
+ createdAt: now,
59
+ updatedAt: now,
60
+ },
61
+ worker: {
62
+ id: 'worker',
63
+ name: 'Worker',
64
+ description: '',
65
+ systemPrompt: '',
66
+ provider: 'openai',
67
+ model: 'gpt-test',
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ },
71
+ })
72
+
73
+ storage.saveTasks({
74
+ 'task-source': {
75
+ id: 'task-source',
76
+ title: 'Source task',
77
+ description: 'Original work',
78
+ status: 'completed',
79
+ agentId: 'worker',
80
+ projectId: 'project-1',
81
+ cwd: projectDir,
82
+ sessionId: 'session-source',
83
+ codexResumeId: 'codex-thread-1',
84
+ createdAt: now,
85
+ updatedAt: now,
86
+ },
87
+ })
88
+
89
+ const tools = crud.buildCrudTools({
90
+ cwd: workspaceDir,
91
+ ctx: { sessionId: 'session-creator', agentId: 'default', platformAssignScope: 'all' },
92
+ hasPlugin: (name) => name === 'manage_tasks',
93
+ })
94
+ const tool = tools.find((entry) => entry.name === 'manage_tasks')
95
+ const raw = await tool.invoke({
96
+ action: 'create',
97
+ title: 'Follow-up task',
98
+ description: 'Continue the previous task with the next deliverable.',
99
+ status: 'backlog',
100
+ continueFromTaskId: 'task-source',
101
+ })
102
+
103
+ const tasks = storage.loadTasks()
104
+ const created = Object.values(tasks).find((entry) => entry.id !== 'task-source')
105
+ console.log(JSON.stringify({ raw, created }))
106
+ `)
107
+
108
+ assert.equal(output.created.projectId, 'project-1')
109
+ assert.equal(output.created.agentId, 'worker')
110
+ assert.equal(output.created.sessionId, 'session-source')
111
+ assert.equal(output.created.codexResumeId, 'codex-thread-1')
112
+ assert.deepEqual(output.created.blockedBy, ['task-source'])
113
+ })
114
+ })
@@ -42,7 +42,8 @@ async function executeMemoryAction(input: any, ctx: any) {
42
42
  ? ctx.id
43
43
  : null
44
44
  const currentSession = ctx && typeof ctx === 'object' && Array.isArray(ctx.messages) ? ctx : null
45
- const rawScope = typeof scope === 'string' ? scope : 'auto'
45
+ const configuredScope = typeof ctx?.memoryScopeMode === 'string' ? ctx.memoryScopeMode : 'auto'
46
+ const rawScope = typeof scope === 'string' ? scope : configuredScope
46
47
  const scopeMode = normalizeMemoryScopeMode(rawScope === 'shared' ? 'global' : rawScope)
47
48
  const rerankMode = rerank === 'semantic' || rerank === 'lexical' ? rerank : 'balanced'
48
49
 
@@ -50,7 +51,11 @@ async function executeMemoryAction(input: any, ctx: any) {
50
51
  mode: scopeMode,
51
52
  agentId: currentAgentId,
52
53
  sessionId: (typeof scopeSessionId === 'string' && scopeSessionId.trim()) ? scopeSessionId.trim() : currentSessionId,
53
- projectRoot: (typeof projectRoot === 'string' && projectRoot.trim()) ? projectRoot.trim() : ((project && typeof project === 'object' && 'rootPath' in project && typeof (project as Record<string, unknown>).rootPath === 'string') ? (project as Record<string, unknown>).rootPath as string : null),
54
+ projectRoot: (typeof projectRoot === 'string' && projectRoot.trim())
55
+ ? projectRoot.trim()
56
+ : ((project && typeof project === 'object' && 'rootPath' in project && typeof (project as Record<string, unknown>).rootPath === 'string')
57
+ ? (project as Record<string, unknown>).rootPath as string
58
+ : (typeof ctx?.projectRoot === 'string' && ctx.projectRoot.trim() ? ctx.projectRoot.trim() : null)),
54
59
  }
55
60
 
56
61
  const filterScope = (rows: MemoryEntry[]) => filterMemoriesByScope(rows, scopeFilter)
@@ -80,12 +85,19 @@ async function executeMemoryAction(input: any, ctx: any) {
80
85
  if (imagePath && fs.existsSync(imagePath)) {
81
86
  storedImage = await storeMemoryImageAsset(imagePath, genId(6))
82
87
  }
88
+ const metadata = n.metadata && typeof n.metadata === 'object' && !Array.isArray(n.metadata)
89
+ ? { ...(n.metadata as Record<string, unknown>) }
90
+ : {}
91
+ if (scopeMode === 'project' && scopeFilter.projectRoot && !metadata.projectRoot) {
92
+ metadata.projectRoot = scopeFilter.projectRoot
93
+ }
83
94
  const entry = memDb.add({
84
95
  agentId: scopeMode === 'global' ? null : currentAgentId,
85
96
  sessionId: ctx?.sessionId || null,
86
97
  category: category || 'note',
87
98
  title: key,
88
99
  content: value || '',
100
+ metadata,
89
101
  references: Array.isArray(references) ? references : [],
90
102
  filePaths: filePaths as any,
91
103
  imagePath: storedImage?.path || undefined,
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict'
2
+ import { afterEach, describe, it } from 'node:test'
3
+ import type { ToolBuildContext } from './context'
4
+ import { buildPlatformTools } from './platform'
5
+ import { loadSettings, saveSettings } from '../storage'
6
+
7
+ const originalSettings = loadSettings()
8
+
9
+ afterEach(() => {
10
+ saveSettings(originalSettings)
11
+ })
12
+
13
+ function buildTestContext(hasPlugin: (name: string) => boolean): ToolBuildContext {
14
+ return {
15
+ cwd: process.cwd(),
16
+ ctx: undefined,
17
+ hasPlugin,
18
+ hasTool: hasPlugin,
19
+ cleanupFns: [],
20
+ commandTimeoutMs: 1_000,
21
+ claudeTimeoutMs: 1_000,
22
+ cliProcessTimeoutMs: 1_000,
23
+ persistDelegateResumeId: () => {},
24
+ readStoredDelegateResumeId: () => null,
25
+ resolveCurrentSession: () => null,
26
+ activePlugins: ['manage_platform'],
27
+ }
28
+ }
29
+
30
+ describe('buildPlatformTools', () => {
31
+ it('blocks task resources when task management is disabled', async () => {
32
+ saveSettings({
33
+ ...originalSettings,
34
+ taskManagementEnabled: false,
35
+ projectManagementEnabled: true,
36
+ })
37
+
38
+ const [toolEntry] = buildPlatformTools(buildTestContext((name) => name === 'manage_platform'))
39
+ assert.ok(toolEntry)
40
+
41
+ const result = await toolEntry.invoke({ resource: 'tasks', action: 'list' })
42
+ assert.match(String(result), /task management is disabled/i)
43
+ })
44
+
45
+ it('allows project resources through manage_platform when project management is enabled', async () => {
46
+ saveSettings({
47
+ ...originalSettings,
48
+ taskManagementEnabled: true,
49
+ projectManagementEnabled: true,
50
+ })
51
+
52
+ const [toolEntry] = buildPlatformTools(buildTestContext((name) => name === 'manage_platform'))
53
+ assert.ok(toolEntry)
54
+
55
+ const result = await toolEntry.invoke({ resource: 'projects', action: 'list' })
56
+ assert.doesNotMatch(String(result), /unknown resource|disabled/i)
57
+ })
58
+ })
@@ -2,9 +2,13 @@ import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import { buildCrudTools } from './crud'
4
4
  import type { ToolBuildContext } from './context'
5
- import type { Plugin, PluginHooks } from '@/types'
5
+ import type { Plugin, PluginHooks, Session } from '@/types'
6
6
  import { getPluginManager } from '../plugins'
7
7
  import { normalizeToolInputArgs } from './normalize-tool-args'
8
+ import { loadSettings } from '../storage'
9
+ import { resolveSessionToolPolicy } from '../tool-capability-policy'
10
+ import { loadRuntimeSettings } from '../runtime-settings'
11
+ import { expandPluginIds } from '../tool-aliases'
8
12
 
9
13
  function parsePlatformData(value: unknown): Record<string, unknown> | null {
10
14
  if (!value) return null
@@ -39,6 +43,7 @@ function normalizePlatformResourceName(value: unknown): string | undefined {
39
43
  if (!normalized) return undefined
40
44
  const singularMap: Record<string, string> = {
41
45
  agent: 'agents',
46
+ project: 'projects',
42
47
  task: 'tasks',
43
48
  backlog_task: 'tasks',
44
49
  'backlog-task': 'tasks',
@@ -144,33 +149,69 @@ function uniqueStrings(values: Array<string | undefined>): string[] {
144
149
  return [...new Set(values.filter((value): value is string => Boolean(value)))]
145
150
  }
146
151
 
152
+ function resolvePlatformResourceAccess(toolId: string, bctx: ToolBuildContext): { allowed: boolean; reason: string | null } {
153
+ if (bctx.hasPlugin(toolId)) return { allowed: true, reason: null }
154
+ if (!bctx.hasPlugin('manage_platform')) return { allowed: false, reason: null }
155
+ const settings = loadSettings()
156
+ const decision = resolveSessionToolPolicy(['manage_platform', toolId], settings)
157
+ const allowed = decision.enabledPlugins.includes(toolId)
158
+ const blocked = decision.blockedPlugins.find((entry) => entry.tool === toolId)
159
+ return { allowed, reason: blocked?.reason || null }
160
+ }
161
+
162
+ function buildPlatformContextFromSession(session: Session): ToolBuildContext {
163
+ const runtime = loadRuntimeSettings()
164
+ const sessionPlugins = Array.isArray(session.plugins) ? session.plugins : []
165
+ const legacyTools = Array.isArray(session.tools) ? session.tools : []
166
+ const activePlugins = expandPluginIds([...sessionPlugins, ...legacyTools, 'manage_platform'])
167
+ const activePluginSet = new Set(activePlugins)
168
+ const hasPlugin = (name: string) => activePluginSet.has(name)
169
+
170
+ return {
171
+ cwd: session.cwd || process.cwd(),
172
+ ctx: {
173
+ sessionId: session.id,
174
+ agentId: session.agentId ?? null,
175
+ },
176
+ hasPlugin,
177
+ hasTool: hasPlugin,
178
+ cleanupFns: [],
179
+ commandTimeoutMs: runtime.shellCommandTimeoutMs,
180
+ claudeTimeoutMs: runtime.claudeCodeTimeoutMs,
181
+ cliProcessTimeoutMs: runtime.cliProcessTimeoutMs,
182
+ persistDelegateResumeId: () => {},
183
+ readStoredDelegateResumeId: () => null,
184
+ resolveCurrentSession: () => session,
185
+ activePlugins,
186
+ }
187
+ }
188
+
147
189
  /**
148
190
  * Unified Platform Execution Logic
149
191
  */
150
- async function executePlatformAction(args: any, bctx: any) {
192
+ async function executePlatformAction(args: any, bctx: ToolBuildContext) {
151
193
  const normalized = normalizePlatformActionArgs((args ?? {}) as Record<string, unknown>)
152
194
  const { resource, action, id, data } = normalized
195
+ const resourceName = typeof resource === 'string' ? resource : ''
153
196
 
154
197
  // We reuse the existing CRUD tool logic but expose it via a single tool
155
198
  const crudTools = buildCrudTools({
156
199
  ...bctx,
157
- hasPlugin: (id: string) => [
158
- 'manage_agents',
159
- 'manage_tasks',
160
- 'manage_schedules',
161
- 'manage_skills',
162
- 'manage_documents',
163
- 'manage_secrets',
164
- 'manage_connectors',
165
- 'manage_sessions'
166
- ].includes(id)
200
+ hasPlugin: (toolId: string) => resolvePlatformResourceAccess(toolId, bctx).allowed,
167
201
  })
168
202
 
169
- const targetToolName = `manage_${resource}`
203
+ const targetToolName = `manage_${resourceName}`
170
204
  const targetTool = crudTools.find(t => t.name === targetToolName)
171
205
 
172
206
  if (!targetTool) {
173
- return `Error: Unknown resource type "${resource}". Valid resources: agents, tasks, schedules, skills, documents, secrets, connectors, sessions.`
207
+ const knownResources = ['agents', 'projects', 'tasks', 'schedules', 'skills', 'documents', 'secrets', 'connectors', 'sessions']
208
+ if (resourceName && knownResources.includes(resourceName)) {
209
+ const toolId = `manage_${resourceName}`
210
+ const access = resolvePlatformResourceAccess(toolId, bctx)
211
+ const suffix = access.reason ? ` (${access.reason})` : ''
212
+ return `Error: Resource "${resourceName}" is disabled by app settings or capability policy in this chat${suffix}.`
213
+ }
214
+ return `Error: Unknown resource type "${resourceName || resource}". Valid resources: ${knownResources.join(', ')}.`
174
215
  }
175
216
 
176
217
  // Forward to the specific CRUD tool implementation
@@ -182,10 +223,10 @@ async function executePlatformAction(args: any, bctx: any) {
182
223
  */
183
224
  const PlatformPlugin: Plugin = {
184
225
  name: 'Core Platform',
185
- description: 'Unified management of agents, tasks, schedules, skills, documents, and secrets.',
226
+ description: 'Unified management of agents, projects, tasks, schedules, skills, documents, and secrets.',
186
227
  hooks: {
187
- getCapabilityDescription: () => 'I can create and configure other agents (`manage_agents`), manage tasks (`manage_tasks`), set up schedules (`manage_schedules`), store and search documents (`manage_documents`), register webhooks (`manage_webhooks`), manage reusable skills (`manage_skills`), and store encrypted secrets (`manage_secrets`).',
188
- getOperatingGuidance: () => ['Create/update tasks for long-lived goals to track progress.', 'Use schedules for follow-ups. Check existing schedules before creating new ones.', 'Inspect existing chats before creating duplicates.'],
228
+ getCapabilityDescription: () => 'I can manage durable execution context across agents, projects, tasks, schedules, documents, skills, webhooks, connectors, sessions, and encrypted secrets.',
229
+ getOperatingGuidance: () => ['Use projects to hold longer-lived goals, objectives, and credential requirements.', 'Create/update tasks for long-lived goals to track progress.', 'Use schedules for follow-ups and heartbeat-style check-ins. Check existing schedules before creating new ones.', 'Inspect existing chats before creating duplicates.'],
189
230
  } as PluginHooks,
190
231
  tools: [
191
232
  {
@@ -194,14 +235,14 @@ const PlatformPlugin: Plugin = {
194
235
  parameters: {
195
236
  type: 'object',
196
237
  properties: {
197
- resource: { type: 'string', enum: ['agents', 'tasks', 'schedules', 'skills', 'documents', 'secrets', 'connectors', 'sessions'] },
238
+ resource: { type: 'string', enum: ['agents', 'projects', 'tasks', 'schedules', 'skills', 'documents', 'secrets', 'connectors', 'sessions'] },
198
239
  action: { type: 'string', enum: ['list', 'get', 'create', 'update', 'delete'] },
199
240
  id: { type: 'string' },
200
241
  data: { type: 'string' }
201
242
  },
202
243
  required: ['resource', 'action']
203
244
  },
204
- execute: async (args, context) => executePlatformAction(args, { ...context.session, ctx: context.session })
245
+ execute: async (args, context) => executePlatformAction(args, buildPlatformContextFromSession(context.session))
205
246
  }
206
247
  ]
207
248
  }
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { inferWebActionFromArgs } from './web'
4
+
5
+ describe('inferWebActionFromArgs', () => {
6
+ it('defaults to search when only query text is provided', () => {
7
+ assert.equal(inferWebActionFromArgs({ query: 'latest US-Iran news' }), 'search')
8
+ })
9
+
10
+ it('defaults to fetch when the url is an absolute http url', () => {
11
+ assert.equal(inferWebActionFromArgs({ url: 'https://example.com/article' }), 'fetch')
12
+ })
13
+
14
+ it('preserves an explicit action when present', () => {
15
+ assert.equal(inferWebActionFromArgs({ action: 'search', url: 'https://example.com/article' }), 'search')
16
+ })
17
+ })
@@ -159,13 +159,30 @@ export function cleanupSessionBrowser(sessionId: string): void {
159
159
  export function getActiveBrowserCount(): number { return activeBrowsers.size }
160
160
  export function hasActiveBrowser(sessionId: string): boolean { return activeBrowsers.has(sessionId) }
161
161
 
162
+ export function inferWebActionFromArgs(params: {
163
+ action?: string
164
+ query?: string
165
+ url?: string
166
+ }): 'search' | 'fetch' | undefined {
167
+ if (params.action === 'search' || params.action === 'fetch') return params.action
168
+ if (typeof params.url === 'string' && /^https?:\/\//i.test(params.url.trim())) return 'fetch'
169
+ if (typeof params.query === 'string' && params.query.trim()) return 'search'
170
+ if (typeof params.url === 'string' && params.url.trim()) return 'search'
171
+ return undefined
172
+ }
173
+
162
174
  /**
163
175
  * Unified Web Execution Logic
164
176
  */
165
177
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
166
178
  async function executeWebAction(args: Record<string, unknown>, bctx: any) {
167
179
  const normalized = normalizeToolInputArgs(args)
168
- const { action, query, url, maxResults } = normalized as { action: string; query?: string; url?: string; maxResults?: number }
180
+ const { query, url, maxResults } = normalized as { query?: string; url?: string; maxResults?: number }
181
+ const action = inferWebActionFromArgs({
182
+ action: (normalized as { action?: string }).action,
183
+ query,
184
+ url,
185
+ })
169
186
  try {
170
187
  if (action === 'search') {
171
188
  const searchQuery = query || url
@@ -650,12 +667,141 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
650
667
  }
651
668
 
652
669
  const dismissCookieBanners = async (mcpCall: (toolName: string, args: Record<string, unknown>) => Promise<string>) => {
653
- await new Promise((r) => setTimeout(r, 1500))
670
+ await new Promise((r) => setTimeout(r, 1200))
654
671
  const js = `() => {
655
- const sel = ['button[id*="reject" i]', 'button[class*="reject" i]', 'a[id*="reject" i]', 'a[class*="reject" i]', '#onetrust-reject-all-handler', '#CybotCookiebotDialogBodyButtonDecline', '#didomi-notice-disagree-button', '.qc-cmp2-summary-buttons button:first-child', 'button.sp_choice_type_12'];
656
- for (const s of sel) { const el = document.querySelector(s); if (el && el.offsetParent !== null) { el.click(); return 'dismissed:' + s; } }
657
- const btns = [...document.querySelectorAll('button, a[role="button"]')]; const rejectRe = /^(reject|reject all|decline|deny|refuse|no,? thanks|only necessary|necessary only)$/i;
658
- for (const b of btns) { const txt = (b.textContent || '').trim(); if (rejectRe.test(txt) && b.offsetParent !== null) { b.click(); return 'dismissed:text=' + txt; } }
672
+ const docs = [document];
673
+ const roots = [document];
674
+ const seenRoots = new Set([document]);
675
+ const pushRoot = (root) => {
676
+ if (!root || seenRoots.has(root)) return;
677
+ seenRoots.add(root);
678
+ roots.push(root);
679
+ };
680
+ const collectFrames = (doc) => {
681
+ try {
682
+ const frames = doc.querySelectorAll('iframe');
683
+ for (const frame of frames) {
684
+ try {
685
+ const child = frame.contentDocument || frame.contentWindow?.document;
686
+ if (child && !docs.includes(child)) {
687
+ docs.push(child);
688
+ pushRoot(child);
689
+ }
690
+ } catch {}
691
+ }
692
+ } catch {}
693
+ };
694
+ const collectShadowRoots = () => {
695
+ for (const root of [...roots]) {
696
+ try {
697
+ const all = root.querySelectorAll('*');
698
+ for (const el of all) {
699
+ if (el.shadowRoot) pushRoot(el.shadowRoot);
700
+ }
701
+ } catch {}
702
+ }
703
+ };
704
+ collectFrames(document);
705
+ collectShadowRoots();
706
+ const allRoots = [...new Set([...docs, ...roots])];
707
+ const visible = (el) => {
708
+ if (!el) return false;
709
+ const style = window.getComputedStyle(el);
710
+ if (!style || style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
711
+ const rect = el.getBoundingClientRect();
712
+ return rect.width > 0 && rect.height > 0;
713
+ };
714
+ const normalizedText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
715
+ const candidateSelectors = [
716
+ '#onetrust-reject-all-handler',
717
+ '#CybotCookiebotDialogBodyButtonDecline',
718
+ '#didomi-notice-disagree-button',
719
+ '.qc-cmp2-summary-buttons button:first-child',
720
+ 'button.sp_choice_type_12',
721
+ 'button[id*="reject" i]',
722
+ 'button[class*="reject" i]',
723
+ 'button[id*="decline" i]',
724
+ 'button[class*="decline" i]',
725
+ 'button[id*="consent" i]',
726
+ 'button[class*="consent" i]',
727
+ 'a[id*="reject" i]',
728
+ 'a[class*="reject" i]',
729
+ 'a[id*="decline" i]',
730
+ 'a[class*="decline" i]'
731
+ ];
732
+ for (const root of allRoots) {
733
+ for (const selector of candidateSelectors) {
734
+ try {
735
+ const el = root.querySelector(selector);
736
+ if (el && visible(el)) {
737
+ el.click();
738
+ return 'clicked:' + selector;
739
+ }
740
+ } catch {}
741
+ }
742
+ }
743
+ const buttonSelector = 'button, a[role="button"], [role="button"], input[type="button"], input[type="submit"]';
744
+ const rejectRe = /^(reject|reject all|decline|decline all|deny|deny all|refuse|no,? thanks|only necessary|necessary only|use necessary cookies only)$/i;
745
+ const acceptRe = /^(accept|accept all|allow all|agree|i agree|okay|ok|got it|continue|consent)$/i;
746
+ const closeRe = /^(close|dismiss|skip|not now|x|×)$/i;
747
+ const clickMatching = (matcher, label) => {
748
+ for (const root of allRoots) {
749
+ let buttons = [];
750
+ try { buttons = [...root.querySelectorAll(buttonSelector)]; } catch {}
751
+ for (const button of buttons) {
752
+ const txt = normalizedText(button.textContent || button.getAttribute?.('aria-label') || button.getAttribute?.('value'));
753
+ if (!txt || !matcher.test(txt) || !visible(button)) continue;
754
+ try {
755
+ button.click();
756
+ return label + ':' + txt.slice(0, 80);
757
+ } catch {}
758
+ }
759
+ }
760
+ return null;
761
+ };
762
+ const clicked = clickMatching(rejectRe, 'reject') || clickMatching(acceptRe, 'accept') || clickMatching(closeRe, 'close');
763
+ if (clicked) return clicked;
764
+ const overlaySelectors = [
765
+ '#onetrust-banner-sdk',
766
+ '#onetrust-consent-sdk',
767
+ '#CybotCookiebotDialog',
768
+ '.didomi-popup-container',
769
+ '.fc-consent-root',
770
+ '[id*="cookie" i]',
771
+ '[class*="cookie" i]',
772
+ '[id*="consent" i]',
773
+ '[class*="consent" i]',
774
+ '[id*="privacy" i]',
775
+ '[class*="privacy" i]',
776
+ '[id*="sp_message" i]',
777
+ '[class*="sp_message" i]'
778
+ ];
779
+ const hidden = [];
780
+ for (const root of allRoots) {
781
+ for (const selector of overlaySelectors) {
782
+ let nodes = [];
783
+ try { nodes = [...root.querySelectorAll(selector)]; } catch {}
784
+ for (const node of nodes) {
785
+ if (!visible(node)) continue;
786
+ const text = normalizedText(node.textContent).toLowerCase();
787
+ const attrs = normalizedText(node.id + ' ' + node.className).toLowerCase();
788
+ if (!text.includes('cookie') && !text.includes('consent') && !text.includes('privacy') && !attrs.includes('cookie') && !attrs.includes('consent') && !attrs.includes('privacy') && !attrs.includes('onetrust') && !attrs.includes('didomi') && !attrs.includes('sp_message')) continue;
789
+ try {
790
+ node.style.setProperty('display', 'none', 'important');
791
+ node.style.setProperty('visibility', 'hidden', 'important');
792
+ node.style.setProperty('pointer-events', 'none', 'important');
793
+ hidden.push(selector);
794
+ } catch {}
795
+ }
796
+ }
797
+ }
798
+ if (hidden.length) {
799
+ try {
800
+ document.documentElement.style.removeProperty('overflow');
801
+ document.body.style.removeProperty('overflow');
802
+ } catch {}
803
+ return 'hidden:' + hidden[0];
804
+ }
659
805
  return 'none';
660
806
  }`
661
807
  await mcpCall('browser_evaluate', { function: js })
@@ -1139,6 +1285,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1139
1285
  } catch {
1140
1286
  await new Promise((r) => setTimeout(r, 1200))
1141
1287
  }
1288
+ try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
1142
1289
  }
1143
1290
 
1144
1291
  let result = await callMcpTool(mcpTool, args, { saveTo: typeof params.saveTo === 'string' ? params.saveTo : undefined })
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { describe, it } from 'node:test'
5
- import { buildToolDisciplineLines, looksLikeOpenEndedDeliverableTask } from './stream-agent-chat'
5
+ import { buildToolDisciplineLines, getExplicitRequiredToolNames, looksLikeOpenEndedDeliverableTask } from './stream-agent-chat'
6
6
 
7
7
  const streamAgentChatSource = fs.readFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), 'stream-agent-chat.ts'), 'utf-8')
8
8
 
@@ -27,19 +27,44 @@ describe('buildToolDisciplineLines', () => {
27
27
  })
28
28
 
29
29
  it('warns browser tasks to use literal urls and the supported form schema', () => {
30
- const lines = buildToolDisciplineLines(['browser', 'http_request', 'email', 'ask_human'])
30
+ const lines = buildToolDisciplineLines(['web_search', 'web_fetch', 'browser', 'manage_connectors', 'http_request', 'email', 'ask_human'])
31
31
 
32
32
  assert.ok(lines.some((line) => line.includes('Do not invent placeholder URLs')))
33
33
  assert.ok(lines.some((line) => line.includes('A shorthand `form` object keyed by input id/name also works')))
34
+ assert.ok(lines.some((line) => line.includes('For current events, breaking news, or "latest" requests, start with `web_search`')))
35
+ assert.ok(lines.some((line) => line.includes('Use `browser` when the user asks for screenshots')))
36
+ assert.ok(lines.some((line) => line.includes('do not capture screenshots') && line.includes('`browser`')))
37
+ assert.ok(lines.some((line) => line.includes('connector_message_tool') && line.includes('list_running')))
38
+ assert.ok(lines.some((line) => line.includes('connector/channel setup is missing')))
39
+ assert.ok(lines.some((line) => line.includes('capture the artifact first with `browser`') && line.includes('`connector_message_tool`')))
34
40
  assert.ok(lines.some((line) => line.includes('Keep JSON request bodies as raw JSON strings')))
35
41
  assert.ok(lines.some((line) => line.includes('{"action":"send","to":"user@example.com","subject":"...","body":"..."}')))
36
42
  assert.ok(lines.some((line) => line.includes('do not guess or keep re-submitting blank forms')))
37
43
  })
38
44
 
45
+ it('requires research, browser, and connector tools for hybrid news delivery requests', () => {
46
+ const required = getExplicitRequiredToolNames(
47
+ 'Can you tell me more if there is any news related to the US-Iran war, and can you send me some screenshots and give me a summary and maybe send me a voice note about it?',
48
+ ['web_search', 'web_fetch', 'browser', 'manage_connectors'],
49
+ )
50
+
51
+ assert.deepEqual(required, ['web_search', 'browser', 'connector_message_tool'])
52
+ })
53
+
54
+ it('requires connector delivery for explicit channel requests', () => {
55
+ const required = getExplicitRequiredToolNames(
56
+ 'Research the latest launch news, take a screenshot, and send it to me over Slack.',
57
+ ['web_search', 'browser', 'manage_connectors'],
58
+ )
59
+
60
+ assert.deepEqual(required, ['web_search', 'browser', 'connector_message_tool'])
61
+ })
62
+
39
63
  it('tells the agent that named enabled tools are completion requirements', () => {
40
64
  assert.ok(streamAgentChatSource.includes('If a task explicitly names an enabled tool, use that tool before declaring success.'))
41
65
  assert.ok(streamAgentChatSource.includes('collect required human input through the tool'))
42
66
  assert.ok(streamAgentChatSource.includes('You have not yet completed the required explicit tool step(s):'))
67
+ assert.ok(streamAgentChatSource.includes('do not replace screenshot requests with text-only summaries'))
43
68
  assert.ok(streamAgentChatSource.includes('[Loop Budget Reached]'))
44
69
  })
45
70
  })