/ src / lib / server / tool-planning.ts
tool-planning.ts
  1  import type { ExtensionToolPlanning } from '@/types'
  2  import { dedup } from '@/lib/shared-utils'
  3  import { getExtensionManager } from './extensions'
  4  import { getNativeCapabilityTools } from './native-capabilities'
  5  import { canonicalizeExtensionId, expandExtensionIds } from './tool-aliases'
  6  
  7  export const TOOL_CAPABILITY = {
  8    researchSearch: 'research.search',
  9    researchFetch: 'research.fetch',
 10    browserNavigate: 'browser.navigate',
 11    browserCapture: 'browser.capture',
 12    artifactPdf: 'artifact.pdf',
 13    deliveryMessage: 'delivery.message',
 14    deliveryMedia: 'delivery.media',
 15    deliveryVoiceNote: 'delivery.voice_note',
 16  } as const
 17  
 18  export interface ToolPlanningEntry {
 19    toolName: string
 20    capabilities: string[]
 21    disciplineGuidance: string[]
 22  }
 23  
 24  interface LegacyToolPlanningEntry extends ToolPlanningEntry {
 25    requestMatchers?: unknown
 26  }
 27  
 28  export interface ToolPlanningView {
 29    displayToolIds: string[]
 30    expandedExtensionIds: string[]
 31    entries: ToolPlanningEntry[]
 32    disciplineGuidance: string[]
 33    capabilityToTools: Map<string, string[]>
 34  }
 35  
 36  const CORE_TOOL_PLANNING: Record<string, LegacyToolPlanningEntry[]> = {
 37    files: [
 38      {
 39        toolName: 'files',
 40        capabilities: ['artifact.files'],
 41        disciplineGuidance: [
 42          '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":"..."}]}`.',
 43          'Prefer a single write call with multiple files over writing one file at a time.',
 44        ],
 45        requestMatchers: [],
 46      },
 47    ],
 48    shell: [
 49      {
 50        toolName: 'shell',
 51        capabilities: ['runtime.shell'],
 52        disciplineGuidance: [
 53          'For `shell`, use `{"action":"execute","command":"..."}` for commands and `{"action":"status","processId":"..."}` or `{"action":"log","processId":"..."}` for long-lived processes.',
 54          'Chain related commands in a single shell call using && to reduce round-trips. Avoid running the same build or test command repeatedly — if it fails, diagnose the error before retrying.',
 55        ],
 56        requestMatchers: [],
 57      },
 58    ],
 59    execute: [
 60      {
 61        toolName: 'execute',
 62        capabilities: ['runtime.execute'],
 63        disciplineGuidance: [
 64          'For `execute`, pass the full bash script in `{"code":"..."}`. Use it for sandboxed command execution, curl-based fetches, and one-shot scripts.',
 65          'Use `persistent=true` only when the agent is explicitly configured for host execution. Otherwise use `files` for persistent writes.',
 66        ],
 67        requestMatchers: [],
 68      },
 69    ],
 70    web: [
 71      {
 72        toolName: 'web_search',
 73        capabilities: [TOOL_CAPABILITY.researchSearch],
 74        disciplineGuidance: [
 75          'For `web_search`, use `{"query":"..."}` to research fresh information. For current events, breaking news, or "latest" requests, start with `web_search` before summarizing.',
 76          'Gather 2-3 key sources, then synthesize. Do not search-read-search-read in a loop.',
 77        ],
 78        requestMatchers: [
 79          {
 80            capability: TOOL_CAPABILITY.researchSearch,
 81            patterns: ['research', 'look up', 'find out', 'search for', 'compare', 'latest', 'news', 'headline', 'current event', 'recent update', 'update', 'updates', 'breaking', 'developments', 'keep watching', 'watch for', 'watching for', 'monitor', 'track', "what's new", 'what happened'],
 82            forbidLiteralUrl: true,
 83          },
 84        ],
 85      },
 86      {
 87        toolName: 'web_fetch',
 88        capabilities: [TOOL_CAPABILITY.researchFetch],
 89        disciplineGuidance: [
 90          'For `web_fetch`, use `{"url":"https://..."}` to read a specific page or article after you know the URL.',
 91          'Fetch the pages you need, then synthesize. Do not fetch-read-fetch-read in a loop.',
 92        ],
 93        requestMatchers: [
 94          {
 95            capability: TOOL_CAPABILITY.researchFetch,
 96            patterns: ['read', 'summarize', 'summarise', 'analyze', 'analyse', 'extract', 'review', 'article', 'page', 'url', 'link'],
 97            requireLiteralUrl: true,
 98          },
 99        ],
100      },
101    ],
102    browser: [
103      {
104        toolName: 'browser',
105        capabilities: [TOOL_CAPABILITY.browserNavigate, TOOL_CAPABILITY.browserCapture, TOOL_CAPABILITY.artifactPdf],
106        disciplineGuidance: [
107          '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`.',
108          '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.',
109          '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.',
110          'Limit browser navigations to what is needed. Each navigation is expensive. Plan your browser session: list the pages you need, visit each once, extract what you need.',
111        ],
112        requestMatchers: [
113          {
114            capability: TOOL_CAPABILITY.browserNavigate,
115            patterns: ['browser', 'click', 'fill form', 'log in', 'login', 'navigate'],
116            requireLiteralUrl: true,
117          },
118          {
119            capability: TOOL_CAPABILITY.browserCapture,
120            patterns: ['screenshot', 'screen shot', 'snapshot', 'page capture', 'visual proof', 'capture the page', 'rendered view'],
121          },
122          {
123            capability: TOOL_CAPABILITY.artifactPdf,
124            patterns: ['pdf', 'save as pdf', 'export pdf'],
125          },
126        ],
127      },
128    ],
129    manage_connectors: [
130      {
131        toolName: 'connector_message_tool',
132        capabilities: [TOOL_CAPABILITY.deliveryMessage, TOOL_CAPABILITY.deliveryMedia, TOOL_CAPABILITY.deliveryVoiceNote],
133        disciplineGuidance: [
134          'For outbound delivery, inspect available channels with `connector_message_tool` using `{"action":"list_running"}` before claiming something cannot be sent.',
135          'Use `connector_message_tool` with `{"action":"send","message":"...","mediaPath":"..."}` for text/media and `{"action":"send_voice_note","voiceText":"..."}` for voice notes.',
136          'If no channel or recipient is configured, explain that connector/channel setup is missing rather than claiming the capability does not exist.',
137          'Check channel availability once with `list_running`, then send. Do not re-list channels between each message.',
138        ],
139        requestMatchers: [
140          {
141            capability: TOOL_CAPABILITY.deliveryMessage,
142            patterns: ['send', 'share', 'deliver', 'message'],
143          },
144          {
145            capability: TOOL_CAPABILITY.deliveryMedia,
146            patterns: ['screenshot', 'screen shot', 'snapshot', 'image', 'photo', 'send file', 'send a file', 'pdf', 'attachment'],
147          },
148          {
149            capability: TOOL_CAPABILITY.deliveryVoiceNote,
150            patterns: ['voice note', 'voice-note', 'voicenote', 'voice memo', 'voice message', 'audio note', 'audio update', 'ptt'],
151          },
152        ],
153      },
154    ],
155    http_request: [
156      {
157        toolName: 'http_request',
158        capabilities: ['network.http'],
159        disciplineGuidance: [
160          'For `http_request`, send exact literal URLs from the task or from prior tool results. Keep JSON request bodies as raw JSON strings.',
161          'If an API call fails, inspect the error before retrying with the same request. Do not retry the same failing call in a loop.',
162        ],
163        requestMatchers: [],
164      },
165    ],
166    email: [
167      {
168        toolName: 'email',
169        capabilities: ['delivery.email'],
170        disciplineGuidance: [
171          '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.',
172          'Compose the full message in one send call. Do not send partial drafts followed by corrections.',
173        ],
174        requestMatchers: [],
175      },
176    ],
177    google_workspace: [
178      {
179        toolName: 'google_workspace',
180        capabilities: ['workspace.google'],
181        disciplineGuidance: [
182          'For `google_workspace`, pass exact `gws` arguments in `{"args":[...]}` form. Prefer list/get/read commands first to confirm IDs and current state before mutating Drive, Docs, Sheets, Gmail, Calendar, or Chat resources.',
183          'Use `params` and `jsonInput` for `--params` / `--json` payloads instead of packing raw JSON blobs into the `args` array.',
184          'Do not call interactive `gws auth login` or `gws auth setup` from the agent. Use the extension settings or a pre-authenticated `gws` install.',
185          'Confirm resource IDs with a single list/get call before mutating. Do not repeatedly list the same resources between edits.',
186        ],
187        requestMatchers: [
188          {
189            capability: 'workspace.google',
190            patterns: ['google workspace', 'google docs', 'google doc', 'google sheets', 'spreadsheet', 'google drive', 'gmail', 'google calendar', 'google chat', 'workspace file', 'shared drive'],
191          },
192        ],
193      },
194    ],
195    ask_human: [
196      {
197        toolName: 'ask_human',
198        capabilities: ['human.input'],
199        disciplineGuidance: [
200          '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":"..."}`.',
201          'Reuse the same `correlationId` from `request_input` when you call `wait_for_reply`. Once the durable wait returns active, stop the turn immediately and wait for the reply instead of calling `request_input` again.',
202          'Do not ask the same pending human question twice before the durable wait resumes unless the question materially changes.',
203          'Batch related questions into a single request rather than asking one question at a time.',
204        ],
205        requestMatchers: [],
206      },
207    ],
208  
209    // --- Internal platform tools ---
210  
211    manage_agents: [
212      {
213        toolName: 'manage_agents',
214        capabilities: ['platform.agents'],
215        disciplineGuidance: [
216          'List agents once at the start of a task, then work with specific agent IDs. Do not re-list between each action.',
217        ],
218        requestMatchers: [],
219      },
220    ],
221    manage_projects: [
222      {
223        toolName: 'manage_projects',
224        capabilities: ['platform.projects'],
225        disciplineGuidance: [
226          'List projects once to orient, then operate on specific project IDs. Do not re-list after each update.',
227        ],
228        requestMatchers: [],
229      },
230    ],
231    manage_tasks: [
232      {
233        toolName: 'manage_tasks',
234        capabilities: ['platform.tasks'],
235        disciplineGuidance: [
236          'Read the task list once, make your changes, then move on. Do not re-read the task list after every update.',
237        ],
238        requestMatchers: [],
239      },
240    ],
241    manage_schedules: [
242      {
243        toolName: 'manage_schedules',
244        capabilities: ['platform.schedules'],
245        disciplineGuidance: [
246          'List schedules once to check current state. Do not re-list after each modification.',
247        ],
248        requestMatchers: [],
249      },
250    ],
251    manage_skills: [
252      {
253        toolName: 'manage_skills',
254        capabilities: ['platform.skills'],
255        disciplineGuidance: [
256          'Use `recommend_for_task` to find a relevant skill efficiently. Do not repeatedly list or search skills between each action.',
257        ],
258        requestMatchers: [],
259      },
260    ],
261    manage_webhooks: [
262      {
263        toolName: 'manage_webhooks',
264        capabilities: ['platform.webhooks'],
265        disciplineGuidance: [
266          'List webhooks once for current state. Do not re-list after each change.',
267        ],
268        requestMatchers: [],
269      },
270    ],
271    manage_secrets: [
272      {
273        toolName: 'manage_secrets',
274        capabilities: ['platform.secrets'],
275        disciplineGuidance: [
276          'Store secrets directly. Use the `check` action (not `list`) to verify if a credential already exists before requesting a new one.',
277        ],
278        requestMatchers: [],
279      },
280    ],
281    manage_chatrooms: [
282      {
283        toolName: 'manage_chatrooms',
284        capabilities: ['platform.chatrooms'],
285        disciplineGuidance: [
286          'List chatrooms once to orient, then operate on specific IDs. Do not re-list after each message or update.',
287        ],
288        requestMatchers: [],
289      },
290    ],
291    manage_protocols: [
292      {
293        toolName: 'manage_protocols',
294        capabilities: ['platform.protocols'],
295        disciplineGuidance: [
296          'Read the protocol definition once, then execute steps. Do not re-read the protocol between each step.',
297        ],
298        requestMatchers: [],
299      },
300    ],
301    manage_platform: [
302      {
303        toolName: 'manage_platform',
304        capabilities: ['platform.umbrella'],
305        disciplineGuidance: [
306          'Prefer the direct `manage_*` tools (manage_agents, manage_tasks, etc.) when they are enabled. Use `manage_platform` only as a fallback when the specific tool is not available.',
307        ],
308        requestMatchers: [],
309      },
310    ],
311    spawn_subagent: [
312      {
313        toolName: 'spawn_subagent',
314        capabilities: ['delegation.subagent'],
315        disciplineGuidance: [
316          'Use `waitForCompletion: true` (the default) or `wait`/`wait_all` actions to await results. Do not poll `status` in a loop.',
317          'Batch related delegations — spawn multiple subagents at once if tasks are independent.',
318          'For multi-step or cross-domain work, delegate to a subagent rather than attempting everything in one long tool chain.',
319        ],
320        requestMatchers: [],
321      },
322    ],
323    delegate: [
324      {
325        toolName: 'delegate',
326        capabilities: ['delegation.cli'],
327        disciplineGuidance: [
328          'Give the delegate a complete task description in one call. Do not send incremental instructions across multiple delegation calls.',
329        ],
330        requestMatchers: [],
331      },
332    ],
333    manage_sessions: [
334      {
335        toolName: 'sessions_tool',
336        capabilities: ['platform.sessions'],
337        disciplineGuidance: [
338          'Check session identity once at the start. Do not re-query session info between each action.',
339        ],
340        requestMatchers: [],
341      },
342    ],
343    memory: [
344      {
345        toolName: 'memory_tool',
346        capabilities: ['memory.search', 'memory.store'],
347        disciplineGuidance: [
348          'Search memory once with a good query, then use the results. Do not run multiple overlapping searches for the same topic.',
349          'For stores and updates, write once with complete content. Do not read-back immediately after writing to confirm.',
350        ],
351        requestMatchers: [],
352      },
353    ],
354    context_mgmt: [
355      {
356        toolName: 'context_status',
357        capabilities: ['context.management'],
358        disciplineGuidance: [
359          'Check context status only when you suspect you are running low. Do not check after every tool call.',
360        ],
361        requestMatchers: [],
362      },
363    ],
364    monitor: [
365      {
366        toolName: 'monitor_tool',
367        capabilities: ['monitoring.watch'],
368        disciplineGuidance: [
369          'Prefer `wait_until`, `wait_for_http`, `wait_for_file`, or other `wait_for_*` shortcut actions — they create a durable wait that resumes your turn automatically. Avoid creating a watch with `create_watch` then polling `get_watch` in a loop.',
370        ],
371        requestMatchers: [],
372      },
373    ],
374    image_gen: [
375      {
376        toolName: 'generate_image',
377        capabilities: ['media.image_generation'],
378        disciplineGuidance: [
379          'Describe the image fully in one generation call. Do not generate multiple variations unless the user asks for options.',
380        ],
381        requestMatchers: [],
382      },
383    ],
384    replicate: [
385      {
386        toolName: 'replicate',
387        capabilities: ['media.replicate'],
388        disciplineGuidance: [
389          'Submit the job with complete parameters in one call. Use `wait: true` for synchronous completion. If running async, let the built-in polling handle it — do not add your own polling loop on top.',
390        ],
391        requestMatchers: [],
392      },
393    ],
394    schedule_wake: [
395      {
396        toolName: 'schedule_wake',
397        capabilities: ['runtime.schedule'],
398        disciplineGuidance: [
399          'Schedule the wake once with the correct time. Do not reschedule repeatedly to adjust by small increments.',
400        ],
401        requestMatchers: [],
402      },
403    ],
404    mailbox: [
405      {
406        toolName: 'mailbox',
407        capabilities: ['delivery.mailbox'],
408        disciplineGuidance: [
409          'Use `search_messages` for targeted retrieval instead of listing all messages. Do not poll the inbox in a loop waiting for replies.',
410        ],
411        requestMatchers: [],
412      },
413    ],
414  }
415  
416  function dedupeStrings(values: string[]): string[] {
417    return dedup(values.filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim()))
418  }
419  
420  function normalizePlanningEntry(toolName: string, planning: ExtensionToolPlanning | null | undefined): ToolPlanningEntry | null {
421    if (!planning) return null
422    const capabilities = dedupeStrings(Array.isArray(planning.capabilities) ? planning.capabilities : [])
423    const disciplineGuidance = dedupeStrings(Array.isArray(planning.disciplineGuidance) ? planning.disciplineGuidance : [])
424    if (!capabilities.length && !disciplineGuidance.length) return null
425    return {
426      toolName,
427      capabilities,
428      disciplineGuidance,
429    }
430  }
431  
432  export function getEnabledToolPlanningView(enabledExtensions: string[]): ToolPlanningView {
433    const displayToolIds = dedupeStrings(enabledExtensions.map((toolId) => canonicalizeExtensionId(toolId))).sort()
434    const expandedExtensionIds = dedupeStrings(expandExtensionIds(enabledExtensions)).sort()
435    const entries: ToolPlanningEntry[] = []
436  
437    for (const extensionId of expandedExtensionIds) {
438      const coreEntries = CORE_TOOL_PLANNING[extensionId] || []
439      for (const entry of coreEntries) {
440        entries.push({
441          toolName: entry.toolName,
442          capabilities: [...entry.capabilities],
443          disciplineGuidance: [...entry.disciplineGuidance],
444        })
445      }
446    }
447  
448    for (const entry of [
449      ...getNativeCapabilityTools(expandedExtensionIds),
450      ...getExtensionManager().getTools(expandedExtensionIds),
451    ]) {
452      const planningEntry = normalizePlanningEntry(entry.tool.name, entry.tool.planning)
453      if (planningEntry) entries.push(planningEntry)
454    }
455  
456    const disciplineSet = new Set<string>()
457    const capabilityToTools = new Map<string, Set<string>>()
458    for (const entry of entries) {
459      for (const line of entry.disciplineGuidance) disciplineSet.add(line)
460      for (const capability of entry.capabilities) {
461        const current = capabilityToTools.get(capability) || new Set<string>()
462        current.add(entry.toolName)
463        capabilityToTools.set(capability, current)
464      }
465    }
466  
467    return {
468      displayToolIds,
469      expandedExtensionIds,
470      entries,
471      disciplineGuidance: Array.from(disciplineSet),
472      capabilityToTools: new Map(
473        Array.from(capabilityToTools.entries()).map(([capability, toolNames]) => [capability, Array.from(toolNames)]),
474      ),
475    }
476  }
477  
478  export function getToolsForCapability(enabledExtensions: string[], capability: string): string[] {
479    return getEnabledToolPlanningView(enabledExtensions).capabilityToTools.get(capability) || []
480  }
481  
482  export function getFirstToolForCapability(enabledExtensions: string[], capability: string): string | null {
483    return getToolsForCapability(enabledExtensions, capability)[0] || null
484  }