diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b7a216680..0e1a2201c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,6 +38,9 @@ ### Fixed - Fixed the vibe pre-init-kill test racing `registry.kill()` against the worker's job-body dispatch (the mock only registered its AgentRef after the abort signal landed, so a loaded runner could observe no registration); the test now waits for the worker to be mid-initialization before killing. +### Fixed + +- Removed hard-coded `scout` references from system and tool prompts that leaked into the model even when the scout agent was disabled (`task.disabledAgents`) or absent from the spawn list: the task tool description, delegation gates, plan-mode and workflowz notices, and the glob/grep/ast-grep guidance now only mention scout when it is actually spawnable ([#7313](https://github.com/can1357/oh-my-pi/issues/7313)). ## [17.2.4] - 2026-08-01 diff --git a/packages/coding-agent/src/modes/workflow.ts b/packages/coding-agent/src/modes/workflow.ts index 44ed5b662..edc1ab86b 100644 --- a/packages/coding-agent/src/modes/workflow.ts +++ b/packages/coding-agent/src/modes/workflow.ts @@ -23,8 +23,14 @@ const WORKFLOW_WORD = magicKeywordRegex("workflowz"); export const WORKFLOW_NOTICE: string = renderWorkflowNotice({ taskBatch: true }); /** renderWorkflowNotice renders the workflow notice for the active task schema. */ -export function renderWorkflowNotice({ taskBatch }: { taskBatch: boolean }): string { - return prompt.render(workflowNoticeTemplate, { taskBatch }).trim(); +export function renderWorkflowNotice({ + taskBatch, + scoutAvailable, +}: { + taskBatch: boolean; + scoutAvailable?: boolean; +}): string { + return prompt.render(workflowNoticeTemplate, { taskBatch, scoutAvailable: scoutAvailable ?? true }).trim(); } /** diff --git a/packages/coding-agent/src/prompts/system/plan-mode-active.md b/packages/coding-agent/src/prompts/system/plan-mode-active.md index f14b4e5ed..de0c67980 100644 --- a/packages/coding-agent/src/prompts/system/plan-mode-active.md +++ b/packages/coding-agent/src/prompts/system/plan-mode-active.md @@ -40,7 +40,7 @@ Write each section together with its body — `N*` needs a multi-line section; a You eliminate unknowns by discovering facts, not by asking. -- **Discoverable facts** (file locations, current behavior, signatures, configs): you MUST find them yourself with `glob`, `grep`, `read`, or parallel `scout` subagents. Every path, symbol, signature, and behavior the plan states as fact MUST come from something you actually read this session. Anything you could not confirm you mark inline (`unverified — confirm first`); you NEVER present a guess as settled. Ask only when several real candidates survive exploration — then present them with a recommendation. +- **Discoverable facts** (file locations, current behavior, signatures, configs): you MUST find them yourself with `glob`, `grep`, `read`,{{#if scoutAvailable}} or parallel `scout` subagents{{/if}}. Every path, symbol, signature, and behavior the plan states as fact MUST come from something you actually read this session. Anything you could not confirm you mark inline (`unverified — confirm first`); you NEVER present a guess as settled. Ask only when several real candidates survive exploration — then present them with a recommendation. - **Preferences and tradeoffs** (intent, UX, scope edges, performance-vs-simplicity): not derivable from code. Surface these early via `{{askToolName}}` with 2–4 mutually exclusive options and a recommended default. Left unanswered → proceed with the default and record it under Assumptions. Every question MUST change the plan or settle a load-bearing choice. Batch them. You NEVER ask what exploration answers, and you NEVER ask filler. @@ -72,7 +72,7 @@ You are re-entering plan mode with a NEW request. That new request is the primar ## Workflow — parallel -1. **Understand** — focus on the request and the code behind it. Launch parallel `scout` subagents (via `task`) when scope spans areas; give each a distinct focus (existing implementations, related components, test patterns). Hunt for reusable code before proposing new. +1. **Understand** — focus on the request and the code behind it.{{#if scoutAvailable}} Launch parallel `scout` subagents (via `task`) when scope spans areas; give each a distinct focus (existing implementations, related components, test patterns).{{/if}} Hunt for reusable code before proposing new. 2. **Design** — draft one approach from what you found, weigh tradeoffs briefly, then commit. For large or cross-cutting work you MAY spawn a critique subagent to pressure-test it before committing. 3. **Review** — read the files you intend to touch and confirm the approach holds against the real code; confirm the plan still answers the literal request; use `{{askToolName}}` to close any remaining preference questions. 4. **Write** — write the plan per **Plan contents** below. diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index c5483adeb..da50e4ea1 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -169,7 +169,7 @@ Everything else—multi-file changes, refactors, new features, tests, investigat ## Delegation gates: - **Own the decomposition.** Map the request, the independent slices, and cross-slice contracts (formats, schemas, interfaces) before spawning; only user-enumerated 2+ self-contained runnable slices skip straight to dispatch. NEVER outsource the top-level plan — a generic "plan"/"design" subagent starts blank, knows less than you, and adds a round-trip for zero parallelism. Slice-local design and explicitly requested competing plans or reviews are fine. -- **Use real concurrency.** Fan out exactly as wide as the work genuinely decomposes{{#if taskBatch}}, batched into one `tasks[]` array{{else}}, as parallel calls in one message{{/if}}. NEVER serialize slices that can run concurrently, pad the batch with invented slices, or spawn one subagent and sit idle behind it; a single read-only scout while you keep working is fine. +- **Use real concurrency.** Fan out exactly as wide as the work genuinely decomposes{{#if taskBatch}}, batched into one `tasks[]` array{{else}}, as parallel calls in one message{{/if}}. NEVER serialize slices that can run concurrently, pad the batch with invented slices, or spawn one subagent and sit idle behind it{{#if scoutAvailable}}; a single read-only scout while you keep working is fine{{/if}}. - **Carry the user's intent.** Subagents never see this conversation. Interpreting the request and taste calls stay with you; each assignment carries every requirement its slice needs. {{#when MAX_CONCURRENCY ">" 0}} - **Concurrency cap:** At most {{pluralize MAX_CONCURRENCY "subagent" "subagents"}} run at once in this session — anything beyond that just queues, so a {{#if taskBatch}}`tasks[]` batch{{else}}set of parallel `task` calls{{/if}} larger than {{MAX_CONCURRENCY}} only delays results. Keep the fan-out at or under the cap. diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index cea71f1fe..b6feb58bb 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -2,7 +2,7 @@ The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Author the orchestration in the `eval` tool and fan out subagents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough. -Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline FIRST (list the files, scope the diff, find the call sites) to discover the work-list, then fan out over it — you don't need to know the shape before the *task*, only before the *fan-out*. Common shapes, each a well-scoped `eval` call you can chain across turns: +Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents.{{#if scoutAvailable}} Scout inline FIRST{{else}} Explore inline FIRST{{/if}} (list the files, scope the diff, find the call sites) to discover the work-list, then fan out over it — you don't need to know the shape before the *task*, only before the *fan-out*. Common shapes, each a well-scoped `eval` call you can chain across turns: - **Understand** — parallel readers over subsystems → structured map - **Design** — judge panel of N independent approaches → scored synthesis - **Review** — split into dimensions → find per dimension → adversarially verify each finding @@ -11,9 +11,9 @@ Worth it when the task benefits from decomposition + parallel coverage, or from -State persists across eval calls, so scout in one call and fan out in the next. Every eval call has: +State persists across eval calls,{{#if scoutAvailable}} so scout in one call and fan out in the next.{{else}} so explore in one call and fan out in the next.{{/if}} Every eval call has: -- `agent(prompt, *, agent="task", label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("scout", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; a negative value disables the cap); deeper calls raise an error. +- `agent(prompt, *, agent="task", label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent{{#if scoutAvailable}} ("scout", "reviewer", …){{/if}}; `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; a negative value disables the cap); deeper ca… - `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one. - `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`. - `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out. diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index e7ee59f86..c97119b48 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -15,5 +15,5 @@ Structural code search via ast-grep. Use when syntax shape matters more than tex - AVOID repo-root scans — narrow `path` first. - Parse issues = query failure, not absence: fix pattern or tighten `path` before concluding "no matches". -- Broad cross-subsystem exploration → Task tool + scout subagent first. +- Broad cross-subsystem exploration → {{#if scoutAvailable}}Task tool + scout{{else}}Task tool{{/if}} subagent first. diff --git a/packages/coding-agent/src/prompts/tools/glob.md b/packages/coding-agent/src/prompts/tools/glob.md index 96cc64dbc..9d5d12060 100644 --- a/packages/coding-agent/src/prompts/tools/glob.md +++ b/packages/coding-agent/src/prompts/tools/glob.md @@ -12,5 +12,5 @@ Matches are newest-first and grouped by directory; directories end in `/`. -Open-ended multi-round discovery → Task + scout. +Open-ended multi-round discovery → {{#if scoutAvailable}}Task + scout.{{else}}Task.{{/if}} diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index e3d6cb7db..7f7db8dff 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -9,5 +9,5 @@ Searches files and internal URLs with Rust regex plus PCRE2 fallback. - MUST use this instead of shell `grep`/`rg`. -- Open-ended multi-round search MUST use Task + scout, not chained calls. +- Open-ended multi-round search MUST use {{#if scoutAvailable}}Task + scout,{{else}}Task,{{/if}} not chained calls. diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index b5d818991..50ff4eb48 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -11,9 +11,9 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking {{/if}} # Task Design -- **Agent typing:** Pick each item's `agent` type. Read-only research MUST use `agent: "scout"` (faster model). Use default worker only when no specialist fits. +- **Agent typing:** Pick each item's `agent` type.{{#if scoutAvailable}} Read-only research MUST use `agent: "scout"` (faster model).{{/if}} Use default worker only when no specialist fits. - **No overhead:** Each `task` MUST instruct its agent to skip formatters, linters, and project-wide test suites. Run those once at the end. -- **One-pass:** Prefer agents that investigate AND edit in one pass; spin a read-only scout only when affected files are genuinely unknown. +- **One-pass:** Prefer agents that investigate AND edit in one pass;{{#if scoutAvailable}} spin a read-only scout only when affected files are genuinely unknown.{{/if}} - **Overlap is safe:** Concurrent edits to the same files auto-resolve{{#if ircEnabled}}; worst case, agents coordinate directly over IRC{{/if}}. NEVER shrink or serialize a batch to avoid file overlap. Two prerequisites: 1. Every task MUST skip validation (build/lint/tests) — validating mid-flight blocks agents on each other's edits. 2. Decide cross-task contracts up front (e.g. the interface A implements and B consumes) and state them in the {{#if batchEnabled}}batch `context`{{else}}task{{/if}}, not left for agents to negotiate. @@ -23,7 +23,7 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking - `context`: Shared project state, constraints, and contracts. Applies to the entire batch; do not duplicate this background into individual tasks. - `tasks[]`: Array of subagents to spawn. - `name`: A stable CamelCase identifier (≤32 chars), used to address the agent (IRC, job ids). Generated automatically if omitted. - - `agent`: The agent type running this item (e.g. `scout`, `reviewer`). Omitting it gives you the general-purpose worker (`{{defaultAgent}}`) — NEVER pass that name explicitly. Only omit it after checking the agent list below and finding no specialist that fits.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}} + - `agent`: The agent type running this item (e.g. {{#if scoutAvailable}}`scout`, {{/if}}`reviewer`). Omitting it gives you the general-purpose worker (`{{defaultAgent}}`) — NEVER pass that name explicitly. Only omit it after checking the agent list below and finding no specialist that fits.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}} - `task`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED. {{#if effortEnabled}} - `effort`: Scale w/ complexity of this task: `"lo"`|`"med"`|`"hi"` {{/if}} @@ -38,7 +38,7 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking {{/if}} {{else}} - `name`: A stable CamelCase identifier (≤32 chars), used to address the agent (IRC, job ids). Generated automatically if omitted. -- `agent`: The agent type to spawn (e.g. `scout`, `reviewer`). Omitting it gives you the general-purpose worker (`{{defaultAgent}}`) — NEVER pass that name explicitly. Only omit it after checking the agent list below and finding no specialist that fits.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}} +- `agent`: The agent type to spawn (e.g. {{#if scoutAvailable}}`scout`, {{/if}}`reviewer`). Omitting it gives you the general-purpose worker (`{{defaultAgent}}`) — NEVER pass that name explicitly. Only omit it after checking the agent list below and finding no specialist that fits.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}} - `task`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED. {{#if effortEnabled}}- `effort`: Scale w/ complexity of this task: `"lo"`|`"med"`|`"hi"` {{/if}} diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 657fdfa26..bb7050f95 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -170,6 +170,7 @@ import { } from "./system-prompt"; import { AgentOutputManager } from "./task/output-manager"; import { wrapStreamFnWithProviderConcurrency } from "./task/provider-concurrency"; +import { isScoutSpawnable } from "./task/spawn-policy"; import type { StructuredSubagentSchemaMode } from "./task/types"; import { AUTO_THINKING, @@ -2911,6 +2912,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro eagerTasksAlways, taskBatch: settings.get("task.batch"), taskMaxConcurrency: settings.get("task.maxConcurrency"), + scoutAvailable: isScoutSpawnable( + settings.get("task.disabledAgents") as string[] | undefined, + options.spawns ?? "*", + ), taskIrcEnabled: !restrictToolNames && isIrcEnabled(settings, options.taskDepth ?? 0), autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings), secretsEnabled, @@ -3342,6 +3347,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro initialAdvisorCosts, settings, autoApprove: options.autoApprove, + scoutAvailable: isScoutSpawnable( + settings.get("task.disabledAgents") as string[] | undefined, + options.spawns ?? "*", + ), evalKernelOwnerId, // Defined only for top-level sessions (creation is gated above). // AgentSession uses this to decide whether it may dispose the global diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index 21fbb50ae..fc1c4c85a 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -109,6 +109,8 @@ export interface AgentSessionConfig { agent: Agent; sessionManager: SessionManager; settings: Settings; + /** Whether the read-only `scout` subagent is spawnable (not disabled, allowed by spawn policy). Defaults to true. */ + scoutAvailable?: boolean; /** Whether the caller explicitly requested yolo/auto-approve behavior for this session. */ autoApprove?: boolean; /** Models to cycle through with Ctrl+P (from --models flag). */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 7bbb34e34..7ff7449be 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -518,6 +518,7 @@ export class AgentSession { // Agent identity (registry id) used for IRC routing and job ownership. #agentId: string | undefined; #agentKind: "main" | "sub" = "main"; + #scoutAvailable = true; #providerSessionId: string | undefined; #freshProviderSessionId: string | undefined; #inheritedProviderPromptCacheKey: string | undefined; @@ -1237,6 +1238,7 @@ export class AgentSession { this.#loopGuards = new LoopGuards(streamGuardsHost); this.#agentId = config.agentId; this.#agentKind = config.agentKind ?? "main"; + this.#scoutAvailable = config.scoutAvailable ?? true; this.#providerSessionId = config.providerSessionId; this.#inheritedProviderPromptCacheKey = config.providerPromptCacheKeySource === "fork" ? this.agent.promptCacheKey : undefined; @@ -4658,6 +4660,7 @@ export class AgentSession { isHashlineEditMode: this.#resolveActiveEditMode() === "hashline", reentry: state.reentry ?? false, iterative: state.workflow === "iterative", + scoutAvailable: this.#scoutAvailable, }); return { @@ -4789,7 +4792,10 @@ export class AgentSession { keywordNotices.push({ role: "custom", customType: "workflow-notice", - content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }), + content: renderWorkflowNotice({ + taskBatch: this.settings.get("task.batch"), + scoutAvailable: this.#scoutAvailable, + }), display: false, attribution: "user", timestamp, diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 4a78aa965..229317f4a 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -525,6 +525,9 @@ export interface BuildSystemPromptOptions { taskMaxConcurrency?: number; /** Whether IRC-backed parallel coordination can be included in delegation policy. */ taskIrcEnabled?: boolean; + /** Whether the read-only `scout` subagent is spawnable (not disabled, allowed by spawn policy). Defaults to true. */ + scoutAvailable?: boolean; + /** Rules with alwaysApply=true — their full content is injected into the prompt. */ alwaysApplyRules?: AlwaysApplyRule[]; /** Whether secret obfuscation is active. When true, explains the redaction format in the prompt. */ @@ -599,6 +602,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): taskIrcEnabled = false, secretsEnabled = false, workspaceTree: providedWorkspaceTree, + scoutAvailable = true, memoryRootEnabled = false, securityEnabled = false, model, @@ -879,6 +883,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): eagerTasksAlways, taskBatch, MAX_CONCURRENCY: normalizeConcurrencyLimit(taskMaxConcurrency), + scoutAvailable, taskIrcEnabled, secretsEnabled, hasMemoryRoot: memoryRootEnabled, diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index ec90646e4..e195b3334 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -27,7 +27,7 @@ import { TASK_EFFORTS, type TaskEffort } from "../thinking"; import { truncateForPrompt } from "../tools/approval"; import { isIrcEnabled } from "../tools/hub"; import { formatBytes, formatDuration } from "../tools/render-utils"; -import { resolveSpawnPolicy } from "./spawn-policy"; +import { isScoutSpawnable, resolveSpawnPolicy } from "./spawn-policy"; import { type AgentDefinition, type AgentProgress, @@ -188,8 +188,10 @@ function renderDescription(options: TaskDescriptionOptions): string { readOnly: isReadOnlyAgent(agent), blocking: agent.blocking === true, })); + const scoutAvailable = isScoutSpawnable(options.disabledAgents, options.parentSpawns); return prompt.render(taskDescriptionTemplate, { agents: renderedAgents, + scoutAvailable, spawningDisabled, defaultAgent: spawnPolicy.defaultAgent, isolationEnabled: options.isolationEnabled, @@ -398,15 +400,19 @@ const GENERIC_SPAWN_AGENTS: ReadonlySet = new Set(["task", "sonic"]); * (DepthCapacity: it currently has the `task` tool). `agentNames` are the * per-item resolved agent types. Returns undefined when no nudge applies. */ -export function buildSpecializationAdvisory(agentNames: string[], depthCapacity: boolean): string | undefined { +export function buildSpecializationAdvisory( + agentNames: string[], + depthCapacity: boolean, + scoutAvailable = true, +): string | undefined { if (!depthCapacity) return undefined; const generics = agentNames.filter(name => GENERIC_SPAWN_AGENTS.has(name)); if (generics.length < 2) return undefined; - return ( - `Tip: this call spawned ${generics.length} generic \`${generics[0]}\` workers. ` + - `Check the agent list for a closer specialist type — e.g. read-only research belongs on ` + - `\`agent: "scout"\`, which runs on a faster model.` - ); + const specialist = scoutAvailable + ? `Check the agent list for a closer specialist type — e.g. read-only research belongs on ` + + `\`agent: "scout"\`, which runs on a faster model.` + : `Check the agent list for a closer specialist type.`; + return `Tip: this call spawned ${generics.length} generic \`${generics[0]}\` workers. ${specialist}`; } /** @@ -443,10 +449,11 @@ export function composeSpawnAdvisory(args: { depthCapacity: boolean; ircEnabled: boolean; willRunAsync: boolean; + scoutAvailable?: boolean; }): string | undefined { return ( [ - buildSpecializationAdvisory(args.agents, args.depthCapacity), + buildSpecializationAdvisory(args.agents, args.depthCapacity, args.scoutAvailable), args.willRunAsync ? buildCoordinationAdvisory(args.items, args.depthCapacity, args.ircEnabled) : undefined, ] .filter(Boolean) @@ -751,6 +758,10 @@ export class TaskTool implements AgentTool 0, + scoutAvailable: isScoutSpawnable( + this.session.settings.get("task.disabledAgents") as string[] | undefined, + this.session.getSessionSpawns?.() ?? "*", + ), }); // Returns a fresh result (copied content array, copied text part) rather // than mutating the caller's — task results are short-lived here, but an diff --git a/packages/coding-agent/src/task/spawn-policy.ts b/packages/coding-agent/src/task/spawn-policy.ts index 225d72066..b10cc6027 100644 --- a/packages/coding-agent/src/task/spawn-policy.ts +++ b/packages/coding-agent/src/task/spawn-policy.ts @@ -56,3 +56,17 @@ export function resolveSpawnPolicy(parentSpawns: string | boolean | null | undef allowedPromptText: allowedAgents.map(agent => `\`${agent}\``).join(", "), }; } + +/** + * Whether the `scout` agent is spawnable in a session: not disabled via + * `task.disabledAgents`, and permitted by the session spawn policy. + */ +export function isScoutSpawnable( + disabledAgents: readonly string[] | undefined, + spawns: string | boolean | null | undefined, +): boolean { + if (disabledAgents?.includes("scout")) return false; + const policy = resolveSpawnPolicy(spawns); + if (!policy.enabled) return false; + return policy.allowedAgents === null || policy.allowedAgents.includes("scout"); +} diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index a492c29f0..48e2f1ee4 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -11,6 +11,7 @@ import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapsh import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" }; +import { isScoutSpawnable } from "../task/spawn-policy"; import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; @@ -150,7 +151,14 @@ export class AstGrepTool implements AgentTool { readonly approval = "read" as const; readonly loadMode = "essential"; readonly label = "Glob"; - readonly description: string; + get description(): string { + return prompt.render(globDescription, { + scoutAvailable: isScoutSpawnable( + this.session.settings.get("task.disabledAgents") as string[] | undefined, + this.session.getSessionSpawns?.() ?? "*", + ), + }); + } readonly parameters = findSchema; readonly examples: readonly ToolExample[] = [ @@ -138,7 +146,6 @@ export class GlobTool implements AgentTool { ) { this.#customOps = options?.operations; this.#rootPathAlias = options?.rootPathAlias === true; - this.description = prompt.render(globDescription); } async execute( diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 353d5b951..ff25ecdad 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -22,6 +22,7 @@ import type { InternalResource, ResolveContext } from "../internal-urls/types"; import type { Theme } from "../modes/theme/theme"; import grepDescription from "../prompts/tools/grep.md" with { type: "text" }; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead, truncateLine } from "../session/streaming-output"; +import { isScoutSpawnable } from "../task/spawn-policy"; import { Ellipsis, fileHyperlink, @@ -909,7 +910,17 @@ export class GrepTool implements AgentTool readonly label = "Grep"; readonly loadMode = "discoverable"; readonly summary = "Grep file contents using ripgrep (fast regex search)"; - readonly description: string; + get description(): string { + const displayMode = resolveFileDisplayMode(this.session); + return prompt.render(grepDescription, { + IS_HL_MODE: displayMode.hashLines, + IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers, + scoutAvailable: isScoutSpawnable( + this.session.settings.get("task.disabledAgents") as string[] | undefined, + this.session.getSessionSpawns?.() ?? "*", + ), + }); + } readonly parameters = searchSchema; readonly strict = true; @@ -924,11 +935,6 @@ export class GrepTool implements AgentTool this.#contextOverride = context !== undefined ? Math.max(0, Math.floor(context)) : undefined; const total = options?.totalMatchLimit; this.#totalMatchLimit = total !== undefined ? Math.max(1, Math.floor(total)) : undefined; - const displayMode = resolveFileDisplayMode(session); - this.description = prompt.render(grepDescription, { - IS_HL_MODE: displayMode.hashLines, - IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers, - }); } async execute( diff --git a/packages/coding-agent/test/system-prompt-inventory.test.ts b/packages/coding-agent/test/system-prompt-inventory.test.ts index 5fd264aa5..592d6c187 100644 --- a/packages/coding-agent/test/system-prompt-inventory.test.ts +++ b/packages/coding-agent/test/system-prompt-inventory.test.ts @@ -653,4 +653,33 @@ describe("system prompt tool inventory", () => { expect(text).toContain(""); expect(text).toContain("- frontend-design: Frontend UI workflow"); }); + + it("omits the read-only scout delegation gate when scout is unavailable", async () => { + const opts = { toolNames: ["read", "bash", "task"], tools: TOOLS }; + const withScout = ( + await buildSystemPrompt({ + ...opts, + cwd: tempDir, + contextFiles: [], + skills: [], + rules: [], + workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, + scoutAvailable: true, + }) + ).systemPrompt.join("\n\n"); + const withoutScout = ( + await buildSystemPrompt({ + ...opts, + cwd: tempDir, + contextFiles: [], + skills: [], + rules: [], + workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, + scoutAvailable: false, + }) + ).systemPrompt.join("\n\n"); + + expect(withScout).toContain("a read-only scout keeping bulk exploration"); + expect(withoutScout).not.toContain("read-only scout"); + }); }); diff --git a/packages/coding-agent/test/task/coordination-advisory.test.ts b/packages/coding-agent/test/task/coordination-advisory.test.ts index b2a9a50e7..dafb70407 100644 --- a/packages/coding-agent/test/task/coordination-advisory.test.ts +++ b/packages/coding-agent/test/task/coordination-advisory.test.ts @@ -46,6 +46,20 @@ describe("composeSpawnAdvisory", () => { expect(advisory).toContain("Coordinate:"); }); + it("drops the scout example from the specialization tip when scout is unavailable", () => { + const advisory = composeSpawnAdvisory({ + agents: ["task", "task"], + items: [worker(), worker()], + depthCapacity: true, + ircEnabled: true, + willRunAsync: true, + scoutAvailable: false, + }); + expect(advisory).toContain("generic"); + expect(advisory).not.toContain("scout"); + expect(advisory).toContain("Coordinate:"); + }); + it("drops the coordination suggestion on the sync path but keeps the specialization tip", () => { const advisory = composeSpawnAdvisory({ agents: ["task", "task"], diff --git a/packages/coding-agent/test/task/spawn-advisory.test.ts b/packages/coding-agent/test/task/spawn-advisory.test.ts index 18f8bd885..9ac136db4 100644 --- a/packages/coding-agent/test/task/spawn-advisory.test.ts +++ b/packages/coding-agent/test/task/spawn-advisory.test.ts @@ -78,7 +78,23 @@ describe("task tool advisory gating via suppressSpawnAdvisory", () => { } as unknown as ToolSession; } - async function spawnText(suppress: boolean): Promise { + function sessionWithScoutDisabled(): ToolSession { + return { + cwd: "/tmp", + hasUI: false, + // `task.disabledAgents` is what the task tool reads to drop scout from + // the rendered description and the appended specialization advisory. + settings: Settings.isolated({ + "task.isolation.mode": "none", + "task.batch": true, + "task.disabledAgents": ["scout"], + }), + getSessionFile: () => null, + getSessionSpawns: () => "*", + } as unknown as ToolSession; + } + + async function spawnTextFor(s: ToolSession): Promise { vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [agent], projectAgentsDir: null }); vi.spyOn(executorModule, "runSubprocess").mockImplementation( async (options): Promise => ({ @@ -97,9 +113,7 @@ describe("task tool advisory gating via suppressSpawnAdvisory", () => { requests: 1, }), ); - const tool = await TaskTool.create(session(suppress)); - // Both items omit `agent`, so each resolves to the generic spawn-policy - // default ("task") — the ≥2-generics condition the advisory gates on. + const tool = await TaskTool.create(s); const result = await tool.execute("tc", { context: "shared fan-out background", tasks: [ @@ -111,10 +125,14 @@ describe("task tool advisory gating via suppressSpawnAdvisory", () => { } it("appends the specialization advisory when a batch resolves two generic workers", async () => { - expect(await spawnText(false)).toContain('`agent: "scout"`'); + expect(await spawnTextFor(session(false))).toContain('`agent: "scout"`'); + }); + + it("drops the scout example when scout is disabled", async () => { + expect(await spawnTextFor(sessionWithScoutDisabled())).not.toContain("scout"); }); it("omits the advisory entirely when the session suppresses it", async () => { - expect(await spawnText(true)).not.toContain('`agent: "scout"`'); + expect(await spawnTextFor(session(true))).not.toContain('`agent: "scout"`'); }); }); diff --git a/packages/coding-agent/test/task/spawn-policy.test.ts b/packages/coding-agent/test/task/spawn-policy.test.ts index 5c8399b15..f963733bc 100644 --- a/packages/coding-agent/test/task/spawn-policy.test.ts +++ b/packages/coding-agent/test/task/spawn-policy.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { Settings } from "../../src/config/settings"; import * as taskDiscovery from "../../src/task/discovery"; import { TaskTool } from "../../src/task/index"; +import { isScoutSpawnable } from "../../src/task/spawn-policy"; import type { AgentDefinition } from "../../src/task/types"; import { getTaskSchema } from "../../src/task/types"; import type { ToolSession } from "../../src/tools"; @@ -60,3 +61,72 @@ describe("task spawn policy surfaces", () => { expect(description).not.toContain("### oracle"); }); }); + +describe("isScoutSpawnable", () => { + it("is true with no disabled agents and unrestricted spawns", () => { + expect(isScoutSpawnable(undefined, "*")).toBe(true); + expect(isScoutSpawnable([], "*")).toBe(true); + }); + + it("is false when scout is disabled via task.disabledAgents", () => { + expect(isScoutSpawnable(["scout"], "*")).toBe(false); + expect(isScoutSpawnable(["scout", "reviewer"], "*")).toBe(false); + }); + + it("is false when spawning is disabled", () => { + expect(isScoutSpawnable(undefined, false)).toBe(false); + expect(isScoutSpawnable(undefined, "")).toBe(false); + }); + + it("is false when scout is not in the allowed spawn list", () => { + expect(isScoutSpawnable(undefined, "reviewer")).toBe(false); + }); + + it("is true when scout is in the allowed spawn list", () => { + expect(isScoutSpawnable(undefined, "scout,reviewer")).toBe(true); + expect(isScoutSpawnable(["reviewer"], "scout")).toBe(true); + }); +}); + +describe("task tool description scout gating", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function renderDescription(disabledScout: boolean): Promise { + vi.spyOn(taskDiscovery, "discoverAgents").mockResolvedValue({ + agents: [ + { name: "scout", description: "Read-only scout.", systemPrompt: "Scout.", source: "bundled" }, + { name: "reviewer", description: "Reviewer.", systemPrompt: "Review.", source: "bundled" }, + ], + projectAgentsDir: null, + }); + const settings = Settings.isolated({ + "async.enabled": false, + "task.batch": true, + "task.isolation.mode": "none", + ...(disabledScout ? { "task.disabledAgents": ["scout"] } : {}), + }); + const tool = await TaskTool.create({ + cwd: process.cwd(), + hasUI: false, + settings, + getSessionFile: () => null, + getSessionSpawns: () => "*", + } as unknown as ToolSession); + return tool.description; + } + + it("mentions scout in the task description when scout is enabled", async () => { + expect(await renderDescription(false)).toContain("scout"); + }); + + it("omits every scout reference from the task description when scout is disabled", async () => { + const description = await renderDescription(true); + expect(description).not.toContain("scout"); + // The read-only agent remains listed as an available agent (the spawn + // policy only filters disabledAgents, so reviewer stays); only the + // hard-coded scout guidance is dropped. + expect(description).toContain("### reviewer"); + }); +});