diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 11d4830cc..7d602b617 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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 ### Added 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 ef82a956f..818d2dd11 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -185,7 +185,7 @@ Everything else—multi-file changes, refactors, new features, tests, investigat ## Delegation gates: - **Scope before you spawn.** YOU read the request, map the work, and name the independent slices. Delegation is NEVER the first move on a fresh request — unless the user already enumerated 2+ self-contained runnable slices, in which case dispatch them immediately in one batch. - **NEVER outsource the top-level plan.** Scoping the request, the overall decomposition, and cross-slice contracts (formats, schemas, interfaces) are YOUR job. A generic "plan"/"design" subagent as step one starts blank, knows less than you, runs alone, and adds a full round-trip for ZERO parallelism — the canonical dumb spawn. Delegating design WITHIN a slice is fine: each executor details its own slice, and once the top-level split is settled you MAY fan out per-subsystem sub-planning in parallel. (Competing plans or independent reviews the user explicitly asked for are also legitimate.) -- **Spawn-one-then-wait is a bug.** A lone subagent you sit idle behind is you doing the work with extra latency plus a lossy handoff — do it inline. A single spawn is fine ONLY when you immediately continue another independent slice yourself, or it is a read-only scout keeping bulk exploration out of your context. +- **Spawn-one-then-wait is a bug.** A lone subagent you sit idle behind is you doing the work with extra latency plus a lossy handoff — do it inline. A single spawn is fine ONLY when you immediately continue another independent slice yourself,{{#if scoutAvailable}} or it is a read-only scout keeping bulk exploration out of your context.{{/if}} - **Width = real independence.** 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; NEVER pad the batch with invented slices to look parallel. - **Prerequisites run inline.** A step every slice depends on (shared schema, core interface, scaffold) has by definition nothing to run beside it — do it yourself, then fan out. "Parallelize" means parallel EXECUTION of the independent slices, not routing sequential steps through agents. - **You own 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. diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index cea71f1fe..b0cc5087b 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -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 c9b567661..a62d262df 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -169,6 +169,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, @@ -2910,6 +2911,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, @@ -3341,6 +3346,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..96a9a902b 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 "."; @@ -179,7 +180,12 @@ export class AstGrepTool implements AgentTool { ) { this.#customOps = options?.operations; this.#rootPathAlias = options?.rootPathAlias === true; - this.description = prompt.render(globDescription); + this.description = prompt.render(globDescription, { + scoutAvailable: isScoutSpawnable( + this.session.settings.get("task.disabledAgents") as string[] | undefined, + this.session.getSessionSpawns?.() ?? "*", + ), + }); } async execute( diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 353d5b951..9088e6ed8 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, @@ -928,6 +929,10 @@ export class GrepTool implements AgentTool this.description = prompt.render(grepDescription, { IS_HL_MODE: displayMode.hashLines, IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers, + scoutAvailable: isScoutSpawnable( + session.settings.get("task.disabledAgents") as string[] | undefined, + session.getSessionSpawns?.() ?? "*", + ), }); } diff --git a/packages/coding-agent/test/system-prompt-inventory.test.ts b/packages/coding-agent/test/system-prompt-inventory.test.ts index 6d74a4e40..90f169342 100644 --- a/packages/coding-agent/test/system-prompt-inventory.test.ts +++ b/packages/coding-agent/test/system-prompt-inventory.test.ts @@ -668,4 +668,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 a3bbd62cb..a2b34f561 100644 --- a/packages/coding-agent/test/task/coordination-advisory.test.ts +++ b/packages/coding-agent/test/task/coordination-advisory.test.ts @@ -62,6 +62,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"); + }); +});