fix(agent): stop leaking scout into prompts when it is disabled

Hard-coded 'scout' references reached the model even when the scout
agent was disabled via task.disabledAgents or absent from the session
spawn list. Gate every such reference on scout actually being spawnable:
the task tool description, the delegation gates, the plan-mode and
workflowz notices, the glob/grep/ast-grep guidance, and the task
specialization advisory. Prompt shape is otherwise unchanged; only
erroneous references to the unavailable subagent are dropped.

Closes #7313
This commit is contained in:
Slava Zavadsky
2026-08-01 23:50:33 -04:00
parent 06343fef42
commit 9885ee34fc
22 changed files with 240 additions and 31 deletions
+4
View File
@@ -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
+8 -2
View File
@@ -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();
}
/**
@@ -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
<procedure>
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.
@@ -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.
@@ -11,9 +11,9 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
</when>
<helpers>
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.
@@ -15,5 +15,5 @@ Structural code search via ast-grep. Use when syntax shape matters more than tex
<critical>
- 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.
</critical>
@@ -12,5 +12,5 @@ Matches are newest-first and grouped by directory; directories end in `/`.
</output>
<avoid>
Open-ended multi-round discovery → Task + scout.
Open-ended multi-round discovery → {{#if scoutAvailable}}Task + scout.{{else}}Task.{{/if}}
</avoid>
@@ -9,5 +9,5 @@ Searches files and internal URLs with Rust regex plus PCRE2 fallback.
<critical>
- 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.
</critical>
@@ -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}}
+9
View File
@@ -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
@@ -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). */
@@ -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,
@@ -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,
+23 -8
View File
@@ -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<string> = 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<TaskToolSchemaInstance, TaskToolDetai
depthCapacity,
ircEnabled,
willRunAsync: false,
scoutAvailable: isScoutSpawnable(
this.session.settings.get("task.disabledAgents") as string[] | undefined,
this.session.getSessionSpawns?.() ?? "*",
),
});
const result = await this.#executeSyncFanout(
toolCallId,
@@ -784,6 +795,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
depthCapacity,
ircEnabled,
willRunAsync: asyncItems.length > 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
@@ -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");
}
+7 -1
View File
@@ -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<typeof astGrepSchema, AstGrepToolD
readonly loadMode = "discoverable";
constructor(private readonly session: ToolSession) {
this.description = prompt.render(astGrepDescription);
this.description = prompt.render(astGrepDescription, {
scoutAvailable: isScoutSpawnable(
this.session.settings.get("task.disabledAgents") as string[] | undefined,
this.session.getSessionSpawns?.() ?? "*",
),
});
}
async execute(
+7 -1
View File
@@ -13,6 +13,7 @@ import { splitMemoryGlobPattern } from "../internal-urls/memory-protocol";
import type { Theme } from "../modes/theme/theme";
import globDescription from "../prompts/tools/glob.md" with { type: "text" };
import { type TruncationResult, truncateHead } from "../session/streaming-output";
import { isScoutSpawnable } from "../task/spawn-policy";
import { Ellipsis, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit";
@@ -138,7 +139,12 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
) {
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(
+5
View File
@@ -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<typeof searchSchema, GrepToolDetails>
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?.() ?? "*",
),
});
}
@@ -668,4 +668,33 @@ describe("system prompt tool inventory", () => {
expect(text).toContain("<skills>");
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");
});
});
@@ -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"],
@@ -78,7 +78,23 @@ describe("task tool advisory gating via suppressSpawnAdvisory", () => {
} as unknown as ToolSession;
}
async function spawnText(suppress: boolean): Promise<string> {
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<string> {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [agent], projectAgentsDir: null });
vi.spyOn(executorModule, "runSubprocess").mockImplementation(
async (options): Promise<SingleResult> => ({
@@ -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"`');
});
});
@@ -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<string> {
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");
});
});