Build the initial system message with full context.
(
userPrompt: string,
mode: ToolExecutionMode['mode'],
modelId: string,
/** Sub-agent role name. Affects system prompt selection. */
role?: string,
/** Tools actually available to this run; if provided, drives the "Available tools:"
* line so a restricted sub-agent doesn't think it has tools the registry filters out. */
allowedTools?: string[],
)
| 365 | // messages.session_id carries a FK to sessions.id, so the sub-agent's FIRST |
| 366 | // recordTurn would fail with "FOREIGN KEY constraint failed", killing every |
| 367 | // delegation instantly. Create the parent row up front (idempotent). |
| 368 | getSessionStore().ensureSession(opts.sessionId, this.cwd, modelUsed); |
| 369 | |
| 370 | // Role-specific tool restriction (allow-list). Built-in policy: |
| 371 | // - vision role: only vision_analyze + read-only browser/file/web tools |
| 372 | // - subagent role: everything except `task` (no recursion) — handled by mode=subagent |
| 373 | const roleConfig = (this.config as any).roles?.[role] as { allowedTools?: string[] } | undefined; |
| 374 | let allowedTools = roleConfig?.allowedTools; |
| 375 | if (!allowedTools && role === 'vision') { |
| 376 | // Sensible default for vision role: it should ANALYZE images, not refactor code. |
| 377 | // Keep it focused — read-only inspection + the vision tool. |
| 378 | allowedTools = [ |
| 379 | 'vision_analyze', |
| 380 | 'read_file', 'ls', 'glob', 'grep', |
| 381 | 'browser_navigate', 'browser_screenshot', 'browser_get_text', |
| 382 | 'browser_console', 'browser_wait_for', 'browser_close', |
| 383 | 'web_fetch', |
| 384 | ]; |
| 385 | } else if (!allowedTools && role === 'scout') { |
| 386 | // Scout role: read-only reconnaissance for the `gather` tool. Collects data/ |
| 387 | // context for the parent to decide on — must NEVER mutate. Restricted to |
| 388 | // read-only inspection tools (any missing one just degrades gracefully). |
| 389 | allowedTools = [ |
| 390 | 'read_file', 'ls', 'glob', 'grep', 'semantic_search', |
| 391 | 'project_overview', 'explain_codebase', 'data_flow', 'analyze_impact', 'find_dead_code', |
| 392 | 'git_status', 'git_diff', 'git_log', |
| 393 | 'db_schema', 'db_query', 'openapi_digest', 'backend_routemap', |
| 394 | 'web_search', 'web_fetch', 'media_probe', |
| 395 | 'project_recall', 'recall', |
| 396 | ]; |
| 397 | } |
| 398 | |
| 399 | // Build a fresh message stack — sub-agent has NO prior context. The system |
| 400 | // prompt is selected per-role; built-ins (subagent, vision) have crafted defaults. |
| 401 | // Custom roles can override via config.roles.<name>.systemPrompt. |
| 402 | // |
| 403 | // CRITICAL: we pass `allowedTools` so the system prompt lists ONLY the tools the |
| 404 | // sub-agent can actually call. Small/quantized models will hallucinate they don't |
| 405 | // have web_search if it isn't named in prose — see Sub-Agent persona fix. |
| 406 | const initialMessages = await this.buildInitialMessages(prompt, 'subagent', dispatchModel.model, role, allowedTools); |
| 407 | |
| 408 | for await (const event of this.run(initialMessages, opts.sessionId, { |
| 409 | mode: { mode: 'subagent', allowedTools }, |
| 410 | signal: opts.signal, |
| 411 | // run() reads `maxIterationsOverride` (not `maxIterations`) to cap the child's |
| 412 | // budget — passing the wrong key silently left every sub-agent on the parent's |
| 413 | // full iteration budget. Pass BOTH so the cap actually applies. |
| 414 | maxIterationsOverride: opts.maxIterations, |
| 415 | maxIterations: opts.maxIterations, |
| 416 | modelOverride: { provider: dispatchModel.provider, model: dispatchModel.model }, |
| 417 | // A sub-agent runs unattended: it has no interactive user to answer a |
| 418 | // permission prompt. Without an askUser, any tool that prompts would call |
| 419 | // `ctx.askUser` === undefined and crash the whole delegation. Supply a |
| 420 | // conservative auto-decline so a gated tool degrades to a tool-level refusal |
| 421 | // (which the child can adapt to) instead of killing the run. |
| 422 | askUser: async () => 'no', |
| 423 | } as any)) { |
| 424 | if (event.type === 'tool_call_start') toolCallsRun += 1; |
no test coverage detected