diff --git a/docs/task-agent-discovery.md b/docs/task-agent-discovery.md index c725f61f0..be8a4beeb 100644 --- a/docs/task-agent-discovery.md +++ b/docs/task-agent-discovery.md @@ -24,7 +24,7 @@ It covers runtime behavior as implemented today, including precedence, invalid-d Task agents normalize into `AgentDefinition` (`src/task/types.ts`): - `name`, `description`, `systemPrompt` (required for a valid loaded agent) -- optional `tools`, `spawns`, `model`, `thinkingLevel`, `output`, `blocking`, `autoloadSkills`, `readSummarize` +- optional `tools`, `spawns`, `model`, `thinkingLevel`, `output`, `blocking`, `autoloadSkills`, `readSummarize`, `prewalk` - `source`: `"bundled" | "user" | "project"` - optional `filePath` @@ -36,6 +36,7 @@ Parsing comes from frontmatter via `parseAgentFields()` (`src/discovery/helpers. - backward-compat behavior: if `spawns` missing but `tools` includes `task`, `spawns` becomes `*` - `output` is passed through as opaque schema data - `read-summarize: false` (parsed as `readSummarize`) forces the subagent's `read` tool to return verbatim file content instead of structural summaries — `runSubprocess` applies it as a `read.summarize.enabled: false` override on the subagent's isolated settings (`src/task/executor.ts`). `scout` and `librarian` ship with it disabled. Defaults to enabled when the field is absent. +- `prewalk: true` starts the subagent on its resolved model and hands off to the default prewalk target (the `smol` role) at its first edit/write, exactly like the session-level `--prewalk`; a string value (e.g. `prewalk: "@smol"` or `prewalk: "openai/gpt-5-mini"`) picks a custom target. The `task.agentPrewalk` settings record (agent name → `"on"` / `"off"` / pattern, toggled per agent from `/agents` with `P`) overrides the frontmatter. Resolution happens in `runSubprocess` (`src/task/executor.ts`); an unresolvable target or a target equal to the starting model skips the hand-off instead of failing the spawn. ## Bundled agents @@ -44,7 +45,7 @@ Bundled agents are embedded at build time (`src/task/agents.ts`) using text impo `EMBEDDED_AGENT_DEFS` defines: - `scout`, `designer`, `reviewer`, `librarian` from prompt files -- `task` and `sonic` from shared `task.md` body plus injected frontmatter +- `task` and `sonic` from shared `task.md` body plus injected frontmatter; `task` ships with `prewalk: true` (default hand-off to the `smol` role, opt out per agent via `/agents` / `task.agentPrewalk`) Loading path: @@ -184,5 +185,6 @@ When parent plan mode is enabled, `TaskTool.#runSpawn` builds an `effectiveAgent - prepends the plan-mode subagent system prompt - restricts tools to `read`, `search`, `find`, `lsp`, and `web_search`, plus `ast_grep`/`report_finding` when the agent's own tool list declares them (`PLAN_MODE_AGENT_TOOL_ALLOWLIST`) - clears child spawns +- clears `prewalk` (read-only exploration must not receive the prewalk plan/implement nudges) The same `effectiveAgent` is used for subprocess launch, model/thinking overrides, and output-schema selection. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 05bfa6114..327dfefc6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added per-agent prewalk for subagents: a `prewalk` frontmatter field (`true` = hand off to the default prewalk target, a string = custom target model pattern) and a `task.agentPrewalk` settings override toggled per agent from the `/agents` dashboard with `P`. The bundled generic `task` agent ships with prewalk enabled by default (skipped when the target resolves to the subagent's own starting model, and never armed for plan-mode spawns). + ### Fixed - Fixed Bash internal URLs remaining unresolved when used as unquoted arguments inside command substitutions ([#5535](https://github.com/can1357/oh-my-pi/issues/5535)). diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index e7b3249a4..28e2b5a56 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -1055,6 +1055,37 @@ export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOp activeModelPattern?.trim() || fallbackModelPattern?.trim() || settings?.getModelRole("default")?.trim() || ""; return resolveConfiguredModelPatterns(fallback, settings); } +/** Default prewalk hand-off target when no explicit target is configured. */ +export const DEFAULT_PREWALK_TARGET = "@smol"; + +export interface AgentPrewalkResolutionOptions { + /** `task.agentPrewalk` settings value for this agent: `"on"`, `"off"`, or a model pattern. */ + settingsOverride?: string; + /** Agent definition `prewalk` frontmatter: `true` = default target, string = custom target pattern. */ + agentPrewalk?: boolean | string; +} + +/** + * Effective prewalk target pattern for a subagent, or `undefined` when prewalk + * is disabled. The settings override decides enablement first ("off" wins, + * "on" enables with the agent's own target or {@link DEFAULT_PREWALK_TARGET}, + * any other value is a custom target pattern); otherwise the agent + * definition's `prewalk` field applies. Role aliases in the returned pattern + * are expanded later by {@link resolveModelOverride}. + */ +export function resolveAgentPrewalkPattern(options: AgentPrewalkResolutionOptions): string | undefined { + const agentPattern = + typeof options.agentPrewalk === "string" && options.agentPrewalk.trim() ? options.agentPrewalk.trim() : undefined; + const override = options.settingsOverride?.trim(); + if (override) { + const lowered = override.toLowerCase(); + if (lowered === "off" || lowered === "false") return undefined; + if (lowered === "on" || lowered === "true") return agentPattern ?? DEFAULT_PREWALK_TARGET; + return override; + } + if (options.agentPrewalk === true) return DEFAULT_PREWALK_TARGET; + return agentPattern; +} /** * Resolve a model role value into a concrete model and thinking metadata. diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index ae879faaf..8e7b5e6bb 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -4264,6 +4264,10 @@ export const SETTINGS_SCHEMA = { type: "record", default: {} as Record, }, + "task.agentPrewalk": { + type: "record", + default: {} as Record, + }, "tasks.todoClearDelay": { type: "number", diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index 093e1ef5d..26b5fb6da 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -232,6 +232,8 @@ export interface ParsedAgentFields { autoloadSkills?: string[]; readSummarize?: boolean; blocking?: boolean; + /** `true` = prewalk into the default target; string = prewalk into that model pattern. */ + prewalk?: boolean | string; } /** @@ -286,10 +288,28 @@ export function parseAgentFields(frontmatter: Record): ParsedAg const model = parseModelList(frontmatter.model); const blocking = parseBoolean(frontmatter.blocking); const readSummarize = parseBoolean(frontmatter.readSummarize); + // prewalk: true → hand off to the default prewalk target; "" → custom target. + let prewalk: boolean | string | undefined = parseBoolean(frontmatter.prewalk); + if (prewalk === undefined && typeof frontmatter.prewalk === "string") { + const trimmed = frontmatter.prewalk.trim(); + if (trimmed) prewalk = trimmed; + } const autoloadSkills = parseArrayOrCSV(frontmatter.autoloadSkills) ?.map(s => s.trim()) .filter(Boolean); - return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills, readSummarize }; + return { + name, + description, + tools, + spawns, + model, + output, + thinkingLevel, + blocking, + autoloadSkills, + readSummarize, + prewalk, + }; } async function globIf( diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 463a4cc22..8486b6abd 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -31,6 +31,7 @@ import { applyStartupCwd } from "./cli/startup-cwd"; import { findConfigFile } from "./config"; import { ModelRegistry } from "./config/model-registry"; import { + DEFAULT_PREWALK_TARGET, expandRoleAlias, getModelMatchPreferences, resolveCliModel, @@ -136,6 +137,7 @@ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [ "task.maxRecursionDepth", "task.disabledAgents", "task.agentModelOverrides", + "task.agentPrewalk", // Memory subsystems are off-by-default for RPC/ACP hosts; embedders that want // memory should opt in explicitly through their own settings layer. "memory.backend", @@ -938,13 +940,13 @@ export async function buildSessionOptions( ? true : activeSettings.get("prewalk.enabled"); if (prewalkEnabled) { - const rolePattern = expandRoleAlias(parsed.prewalkInto ?? "@smol", activeSettings); + const rolePattern = expandRoleAlias(parsed.prewalkInto ?? DEFAULT_PREWALK_TARGET, activeSettings); const resolved = resolveCliModel({ cliModel: rolePattern, modelRegistry, preferences: modelMatchPreferences }); if (resolved.warning) { process.stderr.write(`${chalk.yellow(`Warning: ${resolved.warning}`)}\n`); } if (resolved.error || !resolved.model) { - throw new Error(resolved.error ?? `Model "${parsed.prewalkInto ?? "@smol"}" not found`); + throw new Error(resolved.error ?? `Model "${parsed.prewalkInto ?? DEFAULT_PREWALK_TARGET}" not found`); } if (!modelRegistry.hasConfiguredAuth(resolved.model)) { throw new Error(`No API key for ${resolved.model.provider}/${resolved.model.id}`); diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index e7a63c6dd..a8e294ef5 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -40,6 +40,7 @@ import type { ModelRegistry } from "../../config/model-registry"; import { formatModelString, resolveAgentModelPatterns, + resolveAgentPrewalkPattern, resolveConfiguredModelPatterns, resolveModelOverride, } from "../../config/model-resolver"; @@ -72,6 +73,8 @@ interface SourceTab { interface DashboardAgent extends AgentDefinition { disabled: boolean; overrideModel?: string; + /** `task.agentPrewalk` value for this agent: "on", "off", or a model pattern. */ + prewalkOverride?: string; } interface ModelResolution { @@ -105,7 +108,7 @@ const SOURCE_LABEL: Record = { }; const LIST_FOOTER = - " ↑/↓: navigate Space: toggle Enter: model override N: new agent ←/→: source Ctrl+R: reload Esc: close"; + " ↑/↓: navigate Space: toggle Enter: model override P: prewalk N: new agent ←/→: source Ctrl+R: reload Esc: close"; const IDENTIFIER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+){1,5}$/; function joinPatterns(patterns: string[]): string { @@ -258,6 +261,8 @@ class AgentInspectorPane implements Component { private readonly defaultResolution: ModelResolution | undefined, private readonly effectivePatterns: string[], private readonly effectiveResolution: ModelResolution | undefined, + private readonly prewalkPattern: string | undefined, + private readonly prewalkResolution: ModelResolution | undefined, ) {} render(width: number): readonly string[] { @@ -287,6 +292,7 @@ class AgentInspectorPane implements Component { lines.push( `${theme.fg("muted", "Effective:")} ${this.effectiveResolution ? this.#formatResolution(this.effectiveResolution) : theme.fg("dim", "(unresolved)")}`, ); + lines.push(`${theme.fg("muted", "Prewalk:")} ${this.#prewalkLabel()}`); if (this.agent.filePath) { lines.push(""); @@ -304,6 +310,23 @@ class AgentInspectorPane implements Component { return lines; } + /** "off", "on → target" (with source: agent default vs override), or the unresolved pattern. */ + #prewalkLabel(): string { + if (!this.agent) return theme.fg("dim", "off"); + const override = this.agent.prewalkOverride?.trim(); + const sourceTag = override + ? theme.fg("warning", " (override)") + : this.agent.prewalk !== undefined && this.agent.prewalk !== false + ? theme.fg("dim", " (agent default)") + : ""; + if (!this.prewalkPattern) { + return `${theme.fg("dim", "off")}${override ? sourceTag : ""}`; + } + const target = this.prewalkResolution + ? this.#formatResolution(this.prewalkResolution) + : theme.fg("dim", "(unresolved)"); + return `${theme.fg("success", "on")} ${theme.fg("dim", `${replaceTabs(this.prewalkPattern)} →`)} ${target}${sourceTag}`; + } #formatResolution(resolution: ModelResolution): string { return formatResolution(resolution); @@ -410,6 +433,7 @@ export class AgentDashboard extends Container { const { agents } = await discoverAgents(this.cwd); const disabled = new Set((this.#settingsManager?.get("task.disabledAgents") as string[] | undefined) ?? []); const overrides = this.#settingsManager?.get("task.agentModelOverrides") ?? {}; + const prewalkOverrides = this.#settingsManager?.get("task.agentPrewalk") ?? {}; this.#allAgents = agents .slice() @@ -422,6 +446,7 @@ export class AgentDashboard extends Container { ...agent, disabled: disabled.has(agent.name), overrideModel: overrides[agent.name]?.trim() || undefined, + prewalkOverride: prewalkOverrides[agent.name]?.trim() || undefined, })); this.#tabs = this.#buildTabs(this.#allAgents); @@ -556,6 +581,33 @@ export class AgentDashboard extends Container { } this.#settingsManager.set("task.agentModelOverrides", overrides); } + #persistPrewalkOverrides(): void { + if (!this.#settingsManager) return; + const overrides: Record = {}; + for (const agent of this.#allAgents) { + const value = agent.prewalkOverride?.trim(); + if (value) { + overrides[agent.name] = value; + } + } + this.#settingsManager.set("task.agentPrewalk", overrides); + } + + /** Cycle the prewalk override for the selected agent: agent default → on → off → agent default. */ + #cyclePrewalkOverride(): void { + const selected = this.#selectedAgent(); + if (!selected) return; + const current = selected.prewalkOverride?.trim().toLowerCase(); + selected.prewalkOverride = current === undefined || current === "" ? "on" : current === "on" ? "off" : undefined; + this.#persistPrewalkOverrides(); + const pattern = resolveAgentPrewalkPattern({ + settingsOverride: selected.prewalkOverride, + agentPrewalk: selected.prewalk, + }); + const state = selected.prewalkOverride ?? "agent default"; + this.#notice = `Prewalk for ${selected.name}: ${state}${pattern ? ` (into ${pattern})` : ""}`; + this.#buildLayout(); + } #toggleSelectedAgent(): void { const selected = this.#selectedAgent(); @@ -1026,6 +1078,13 @@ export class AgentDashboard extends Container { const defaultResolution = selected ? this.#resolvePatterns(defaultPatterns) : undefined; const effectivePatterns = selected ? this.#effectivePatternsFor(selected, selected.overrideModel) : []; const effectiveResolution = selected ? this.#resolvePatterns(effectivePatterns) : undefined; + const prewalkPattern = selected + ? resolveAgentPrewalkPattern({ + settingsOverride: selected.prewalkOverride, + agentPrewalk: selected.prewalk, + }) + : undefined; + const prewalkResolution = prewalkPattern ? this.#resolvePatterns([prewalkPattern]) : undefined; const listPane = new AgentListPane( this.#filteredAgents, @@ -1040,6 +1099,8 @@ export class AgentDashboard extends Container { defaultResolution, effectivePatterns, effectiveResolution, + prewalkPattern, + prewalkResolution, ); const bodyHeight = this.#computeBodyHeight(); this.addChild(new TwoColumnBody(listPane, inspector, bodyHeight)); @@ -1163,6 +1224,10 @@ export class AgentDashboard extends Container { this.#beginCreateFlow(); return; } + if (data.toLowerCase() === "p") { + this.#cyclePrewalkOverride(); + return; + } if (matchesKey(data, "backspace")) { if (this.#searchQuery.length > 0) { diff --git a/packages/coding-agent/src/prompts/agents/frontmatter.md b/packages/coding-agent/src/prompts/agents/frontmatter.md index 941ab049b..f2f715aa7 100644 --- a/packages/coding-agent/src/prompts/agents/frontmatter.md +++ b/packages/coding-agent/src/prompts/agents/frontmatter.md @@ -6,6 +6,7 @@ description: {{jsonStringify description}} {{/if}}{{#if model}}model: {{jsonStringify model}} {{/if}}{{#if thinkingLevel}}thinking-level: {{jsonStringify thinkingLevel}} {{/if}}{{#if blocking}}blocking: true +{{/if}}{{#if prewalk}}prewalk: {{jsonStringify prewalk}} {{/if}}{{#if autoloadSkills}}autoloadSkills: {{jsonStringify autoloadSkills}} {{/if}}--- {{body}} diff --git a/packages/coding-agent/src/task/agents.ts b/packages/coding-agent/src/task/agents.ts index 2b071e4a4..40aade7f0 100644 --- a/packages/coding-agent/src/task/agents.ts +++ b/packages/coding-agent/src/task/agents.ts @@ -25,6 +25,7 @@ interface AgentFrontmatter { model?: string | string[]; thinkingLevel?: string; blocking?: boolean; + prewalk?: boolean | string; } interface EmbeddedAgentDef { @@ -52,6 +53,9 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [ spawns: "*", model: "@task", thinkingLevel: AUTO_THINKING, + // Strong model plans and starts the implementation, then hands off to + // the smol role. Per-agent opt-out via /agents (task.agentPrewalk). + prewalk: true, }, template: taskMd, }, diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 958a7ec85..8f3f33a39 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -14,6 +14,7 @@ import { ModelRegistry } from "../config/model-registry"; import { formatModelSelectorValue, formatModelStringWithRouting, + resolveAgentPrewalkPattern, resolveModelOverride, resolveModelOverrideWithAuthFallback, } from "../config/model-resolver"; @@ -36,7 +37,7 @@ import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md import { AgentLifecycleManager } from "../registry/agent-lifecycle"; import { AgentRegistry } from "../registry/agent-registry"; import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "../sdk"; -import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; +import type { AgentSession, AgentSessionEvent, Prewalk } from "../session/agent-session"; import type { ArtifactManager } from "../session/artifacts"; import type { AuthStorage } from "../session/auth-storage"; import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../session/messages"; @@ -2376,6 +2377,36 @@ export async function runSubprocess(options: ExecutorOptions): Promise { test("returns undefined readSummarize when field absent", () => { expect(parseAgentFields({ name: "explore", description: "desc" })?.readSummarize).toBeUndefined(); }); + test("parses prewalk from boolean frontmatter", () => { + expect(parseAgentFields({ name: "worker", description: "desc", prewalk: true })?.prewalk).toBe(true); + expect(parseAgentFields({ name: "worker", description: "desc", prewalk: false })?.prewalk).toBe(false); + }); + + test("parses prewalk boolean strings as booleans", () => { + expect(parseAgentFields({ name: "worker", description: "desc", prewalk: "true" })?.prewalk).toBe(true); + expect(parseAgentFields({ name: "worker", description: "desc", prewalk: "false" })?.prewalk).toBe(false); + }); + + test("parses prewalk model pattern strings", () => { + expect(parseAgentFields({ name: "worker", description: "desc", prewalk: " @smol " })?.prewalk).toBe("@smol"); + expect(parseAgentFields({ name: "worker", description: "desc", prewalk: "openai/gpt-5-mini" })?.prewalk).toBe( + "openai/gpt-5-mini", + ); + }); + + test("ignores empty and absent prewalk values", () => { + expect(parseAgentFields({ name: "worker", description: "desc", prewalk: " " })?.prewalk).toBeUndefined(); + expect(parseAgentFields({ name: "worker", description: "desc" })?.prewalk).toBeUndefined(); + }); }); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 5b3152e40..da75db427 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -10,6 +10,7 @@ import { parseModelString, pickDefaultAvailableModel, resolveAgentModelPatterns, + resolveAgentPrewalkPattern, resolveAllowedModels, resolveCliModel, resolveModelFromString, @@ -736,6 +737,33 @@ describe("resolveModelRoleValue", () => { expect(result.explicitThinkingLevel).toBe(true); }); }); +describe("resolveAgentPrewalkPattern", () => { + test("agent definition alone decides: true → default target, pattern → custom, false/absent → off", () => { + expect(resolveAgentPrewalkPattern({ agentPrewalk: true })).toBe("@smol"); + expect(resolveAgentPrewalkPattern({ agentPrewalk: "@very-smol" })).toBe("@very-smol"); + expect(resolveAgentPrewalkPattern({ agentPrewalk: false })).toBeUndefined(); + expect(resolveAgentPrewalkPattern({})).toBeUndefined(); + }); + + test("settings override wins over the agent definition", () => { + expect(resolveAgentPrewalkPattern({ settingsOverride: "off", agentPrewalk: true })).toBeUndefined(); + expect(resolveAgentPrewalkPattern({ settingsOverride: "off", agentPrewalk: "@very-smol" })).toBeUndefined(); + expect(resolveAgentPrewalkPattern({ settingsOverride: "on", agentPrewalk: false })).toBe("@smol"); + expect(resolveAgentPrewalkPattern({ settingsOverride: "openai/gpt-4o", agentPrewalk: false })).toBe( + "openai/gpt-4o", + ); + }); + + test("override 'on' keeps the agent's custom target when one is defined", () => { + expect(resolveAgentPrewalkPattern({ settingsOverride: "on", agentPrewalk: "@very-smol" })).toBe("@very-smol"); + expect(resolveAgentPrewalkPattern({ settingsOverride: "on" })).toBe("@smol"); + }); + + test("blank override falls through to the agent definition", () => { + expect(resolveAgentPrewalkPattern({ settingsOverride: " ", agentPrewalk: true })).toBe("@smol"); + expect(resolveAgentPrewalkPattern({ settingsOverride: "", agentPrewalk: false })).toBeUndefined(); + }); +}); describe("resolveAgentModelPatterns", () => { test("falls back to the active session model when @task is unset", () => { const settings = Settings.isolated({ diff --git a/packages/coding-agent/test/task/executor-prewalk.test.ts b/packages/coding-agent/test/task/executor-prewalk.test.ts new file mode 100644 index 000000000..f7b14ba11 --- /dev/null +++ b/packages/coding-agent/test/task/executor-prewalk.test.ts @@ -0,0 +1,281 @@ +/** + * Per-agent prewalk resolution in `runSubprocess`: the agent definition's + * `prewalk` frontmatter and the `task.agentPrewalk` settings override decide + * whether the spawned session gets a `prewalk` hand-off config, which target + * model it resolves to, and when the hand-off is skipped (override off, + * target identical to the starting model). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; +import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; +import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; +import type { AgentSession, AgentSessionEvent, PromptOptions } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { TaskTool } from "@oh-my-pi/pi-coding-agent/task"; +import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery"; +import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; +import { runSubprocess } from "@oh-my-pi/pi-coding-agent/task/executor"; +import type { AgentDefinition, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; + +function yieldEmittingSession(): AgentSession { + const listeners: Array<(event: AgentSessionEvent) => void> = []; + const session = { + state: { messages: [] }, + agent: { state: { systemPrompt: ["test"] } }, + model: undefined, + extensionRunner: undefined, + sessionManager: { appendSessionInit: () => {} }, + getActiveToolNames: () => ["read", "yield"], + setActiveToolsByName: async (_toolNames: string[]) => {}, + subscribe: (listener: (event: AgentSessionEvent) => void) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index >= 0) listeners.splice(index, 1); + }; + }, + prompt: async (_text: string, _options?: PromptOptions) => { + for (const listener of listeners) { + listener({ + type: "tool_execution_end", + toolCallId: "tool-prewalk", + toolName: "yield", + result: { + content: [{ type: "text", text: "Result submitted." }], + details: { status: "success", data: { ok: true } }, + }, + isError: false, + }); + } + }, + waitForIdle: async () => {}, + getLastAssistantMessage: () => undefined, + abort: async () => {}, + dispose: async () => {}, + }; + return session as unknown as AgentSession; +} + +function createSessionResult(session: AgentSession): CreateAgentSessionResult { + return { + session, + extensionsResult: { extensions: [], errors: [], runtime: {} as unknown } as unknown as LoadExtensionsResult, + setToolUIContext: () => {}, + eventBus: new EventBus(), + }; +} + +function modelOrThrow(id: string): Model { + const model = getBundledModel("anthropic", id); + if (!model) throw new Error(`Expected bundled model ${id}`); + return model; +} + +function createModelRegistry(models: Model[]): ModelRegistry { + return { + authStorage: {}, + refresh: async () => {}, + getAvailable: () => models, + getApiKey: async () => "test-key", + hasConfiguredAuth: () => true, + } as unknown as ModelRegistry; +} + +const baseAgent: AgentDefinition = { + name: "task", + description: "test", + systemPrompt: "test", + source: "bundled", +}; + +describe("runSubprocess per-agent prewalk", () => { + const primary = modelOrThrow("claude-sonnet-4-5"); + const target = modelOrThrow("claude-sonnet-4-6"); + + function baseOptions(id: string, settings: Settings) { + return { + cwd: "/tmp", + task: "do work", + index: 0, + id, + settings, + modelRegistry: createModelRegistry([primary, target]), + enableLsp: false, + }; + } + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("resolves a frontmatter prewalk pattern to a target for the spawned session", async () => { + const spy = vi + .spyOn(sdkModule, "createAgentSession") + .mockResolvedValue(createSessionResult(yieldEmittingSession())); + + const result = await runSubprocess({ + ...baseOptions("subagent-prewalk-frontmatter", Settings.isolated()), + agent: { + ...baseAgent, + model: [`${primary.provider}/${primary.id}`], + prewalk: `${target.provider}/${target.id}`, + }, + }); + + expect(result.exitCode).toBe(0); + const forwarded = spy.mock.calls[0]?.[0]; + expect(forwarded?.prewalk?.target.id).toBe(target.id); + expect(forwarded?.prewalk?.target.provider).toBe(target.provider); + }); + + it("resolves prewalk: true through the smol role default target", async () => { + const settings = Settings.isolated(); + settings.setModelRole("smol", `${target.provider}/${target.id}`); + const spy = vi + .spyOn(sdkModule, "createAgentSession") + .mockResolvedValue(createSessionResult(yieldEmittingSession())); + + const result = await runSubprocess({ + ...baseOptions("subagent-prewalk-default-target", settings), + agent: { ...baseAgent, model: [`${primary.provider}/${primary.id}`], prewalk: true }, + }); + + expect(result.exitCode).toBe(0); + const forwarded = spy.mock.calls[0]?.[0]; + expect(forwarded?.prewalk?.target.id).toBe(target.id); + }); + + it("task.agentPrewalk 'off' disables a frontmatter-enabled prewalk", async () => { + const settings = Settings.isolated(); + settings.set("task.agentPrewalk", { task: "off" }); + const spy = vi + .spyOn(sdkModule, "createAgentSession") + .mockResolvedValue(createSessionResult(yieldEmittingSession())); + + const result = await runSubprocess({ + ...baseOptions("subagent-prewalk-off", settings), + agent: { + ...baseAgent, + model: [`${primary.provider}/${primary.id}`], + prewalk: `${target.provider}/${target.id}`, + }, + }); + + expect(result.exitCode).toBe(0); + expect(spy.mock.calls[0]?.[0]?.prewalk).toBeUndefined(); + }); + + it("task.agentPrewalk 'on' enables prewalk for an agent without frontmatter", async () => { + const settings = Settings.isolated(); + settings.setModelRole("smol", `${target.provider}/${target.id}`); + settings.set("task.agentPrewalk", { task: "on" }); + const spy = vi + .spyOn(sdkModule, "createAgentSession") + .mockResolvedValue(createSessionResult(yieldEmittingSession())); + + const result = await runSubprocess({ + ...baseOptions("subagent-prewalk-on", settings), + agent: { ...baseAgent, model: [`${primary.provider}/${primary.id}`] }, + }); + + expect(result.exitCode).toBe(0); + expect(spy.mock.calls[0]?.[0]?.prewalk?.target.id).toBe(target.id); + }); + + it("skips prewalk when the target resolves to the starting model", async () => { + const spy = vi + .spyOn(sdkModule, "createAgentSession") + .mockResolvedValue(createSessionResult(yieldEmittingSession())); + + const result = await runSubprocess({ + ...baseOptions("subagent-prewalk-same-model", Settings.isolated()), + agent: { + ...baseAgent, + model: [`${primary.provider}/${primary.id}`], + prewalk: `${primary.provider}/${primary.id}`, + }, + }); + + expect(result.exitCode).toBe(0); + expect(spy.mock.calls[0]?.[0]?.prewalk).toBeUndefined(); + }); +}); +// Plan-mode spawns are read-only exploration: the task tool must strip a +// prewalk-enabled agent definition before spawning so the hidden +// plan/implement nudges never reach an agent without edit tools. +describe("task tool plan-mode prewalk guard", () => { + const prewalkAgent: AgentDefinition = { + name: "task", + description: "General-purpose task agent", + systemPrompt: "You are a task agent.", + source: "bundled", + prewalk: true, + }; + + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + AgentLifecycleManager.resetGlobalForTests(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + AgentLifecycleManager.resetGlobalForTests(); + AgentRegistry.resetGlobalForTests(); + }); + + function toolSession(planMode: boolean): ToolSession { + return { + cwd: "/tmp", + hasUI: false, + settings: Settings.isolated({ "task.isolation.mode": "none" }), + getSessionFile: () => null, + getSessionSpawns: () => "*", + getPlanModeState: () => (planMode ? { enabled: true, planFilePath: "local://PLAN.md" } : undefined), + } as unknown as ToolSession; + } + + async function spawnedAgentPrewalk(planMode: boolean): Promise { + vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ + agents: [prewalkAgent], + projectAgentsDir: null, + }); + let forwarded: AgentDefinition | undefined; + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async (options): Promise => { + forwarded = options.agent; + return { + index: options.index ?? 0, + id: options.id ?? "X", + agent: "task", + agentSource: "bundled", + task: "t", + assignment: "do the thing", + exitCode: 0, + output: "done", + stderr: "", + truncated: false, + durationMs: 1, + tokens: 0, + requests: 1, + }; + }); + const tool = await TaskTool.create(toolSession(planMode)); + await tool.execute("tc", { task: "explore the thing" }); + expect(forwarded).toBeDefined(); + return forwarded?.prewalk; + } + + it("strips prewalk from the agent definition while plan mode is active", async () => { + expect(await spawnedAgentPrewalk(true)).toBeUndefined(); + }); + + it("keeps the agent definition's prewalk outside plan mode", async () => { + expect(await spawnedAgentPrewalk(false)).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/tools/task-agent-capabilities.test.ts b/packages/coding-agent/test/tools/task-agent-capabilities.test.ts index 022b2f057..574d6ed90 100644 --- a/packages/coding-agent/test/tools/task-agent-capabilities.test.ts +++ b/packages/coding-agent/test/tools/task-agent-capabilities.test.ts @@ -28,4 +28,12 @@ describe("task agent capability descriptions", () => { expect(agentByName(agents, name).readSummarize).toBeUndefined(); } }); + it("ships the generic task agent with prewalk enabled, all other bundled agents without", () => { + const agents = loadBundledAgents(); + + expect(agentByName(agents, "task").prewalk).toBe(true); + for (const name of ["scout", "sonic", "reviewer", "designer", "librarian"]) { + expect(agentByName(agents, name).prewalk).toBeUndefined(); + } + }); });