diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index c073359fc..1e42fb322 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -189,39 +189,6 @@ const onAgentLog: OnLogCallback = (level, scope, message, data) => { } }; -const HAIKU_EXPLORE_AGENT_OVERRIDE = { - description: - 'Fast agent for exploring and understanding codebases. Use this when you need to find files by pattern (eg. "src/components/**/*.tsx"), search for code or keywords (eg. "where is the auth middleware?"), or answer questions about how the codebase works (eg. "how does the session service handle reconnects?"). When calling this agent, specify a thoroughness level: "quick" for targeted lookups, "medium" for broader exploration, or "very thorough" for comprehensive analysis across multiple locations.', - model: "haiku", - prompt: `You are a fast, read-only codebase exploration agent. - -Your job is to find files, search code, read the most relevant sources, and report findings clearly. - -Rules: -- Never create, modify, delete, move, or copy files. -- Never use shell redirection or any command that changes system state. -- Use Glob for broad file pattern matching. -- Use Grep for searching file contents. -- Use Read when you know the exact file path to inspect. -- Use Bash only for safe read-only commands like ls, git status, git log, git diff, find, cat, head, and tail. -- Adapt your search approach based on the thoroughness level specified by the caller. -- Return file paths as absolute paths in your final response. -- Avoid using emojis. -- Wherever possible, spawn multiple parallel tool calls for grepping and reading files. -- Search efficiently, then read only the most relevant files. -- Return findings directly in your final response — do not create files.`, - tools: [ - "Bash", - "Glob", - "Grep", - "Read", - "WebFetch", - "WebSearch", - "NotebookRead", - "TodoWrite", - ], -}; - function buildClaudeCodeOptions(args: { additionalDirectories?: string[]; effort?: EffortLevel; @@ -233,9 +200,6 @@ function buildClaudeCodeOptions(args: { }), ...(args.effort && { effort: args.effort }), plugins: args.plugins, - agents: { - "ph-explore": HAIKU_EXPLORE_AGENT_OVERRIDE, - }, }; } diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index eab122d48..d896fb9d6 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -173,12 +173,12 @@ export const createPostToolUseHook = * * https://github.com/anthropics/claude-agent-sdk-typescript/issues/267 */ -const SUBAGENT_REWRITES: Record = { +export const SUBAGENT_REWRITES: Record = { Explore: "ph-explore", }; export const createSubagentRewriteHook = - (logger: Logger): HookCallback => + (logger: Logger, registeredAgents: ReadonlySet): HookCallback => async (input: HookInput, _toolUseID: string | undefined) => { if (input.hook_event_name !== "PreToolUse") { return { continue: true }; @@ -195,6 +195,13 @@ export const createSubagentRewriteHook = } const target = SUBAGENT_REWRITES[subagentType]; + if (!registeredAgents.has(target)) { + logger.warn( + `[SubagentRewriteHook] Skipping rewrite ${subagentType} → ${target}: target agent not registered for this session. Falling back to built-in ${subagentType}.`, + ); + return { continue: true }; + } + logger.info( `[SubagentRewriteHook] Rewriting subagent_type: ${subagentType} → ${target}`, ); diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts new file mode 100644 index 000000000..ae0489bb8 --- /dev/null +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -0,0 +1,72 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { Logger } from "../../../utils/logger"; +import { SUBAGENT_REWRITES } from "../hooks"; +import { buildSessionOptions } from "./options"; +import { SettingsManager } from "./settings"; + +function makeParams() { + const cwd = path.join(os.tmpdir(), `options-test-${Date.now()}`); + return { + cwd, + mcpServers: {}, + permissionMode: "default" as const, + canUseTool: async () => ({ behavior: "allow" as const, updatedInput: {} }), + logger: new Logger(), + sessionId: "test-session", + isResume: false, + settingsManager: new SettingsManager(cwd), + }; +} + +describe("buildSessionOptions", () => { + it.each(Object.entries(SUBAGENT_REWRITES))( + 'registers rewrite target "%s" → "%s" in options.agents', + (_source, target) => { + const options = buildSessionOptions(makeParams()); + const registered = new Set(Object.keys(options.agents ?? {})); + + expect( + registered.has(target), + `Rewrite target "${target}" is not registered in options.agents — either register the agent in buildAgents or remove the rewrite.`, + ).toBe(true); + }, + ); + + it("preserves caller-provided agents alongside defaults", () => { + const params = makeParams(); + const options = buildSessionOptions({ + ...params, + userProvidedOptions: { + agents: { + "custom-agent": { + description: "Custom", + prompt: "Custom prompt", + }, + }, + }, + }); + + expect(options.agents?.["custom-agent"]).toBeDefined(); + expect(options.agents?.["ph-explore"]).toBeDefined(); + }); + + it("lets caller-provided agents override defaults by name", () => { + const params = makeParams(); + const override = { + description: "Overridden", + prompt: "Overridden prompt", + }; + const options = buildSessionOptions({ + ...params, + userProvidedOptions: { + agents: { + "ph-explore": override, + }, + }, + }); + + expect(options.agents?.["ph-explore"]).toEqual(override); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index ed786e2cd..56ce6a3eb 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -117,6 +117,7 @@ function buildHooks( logger: Logger, enrichmentDeps: FileEnrichmentDeps | undefined, enrichedReadCache: EnrichedReadCache | undefined, + registeredAgents: ReadonlySet, ): Options["hooks"] { const postToolUseHooks = [createPostToolUseHook({ onModeChange, logger })]; if (enrichmentDeps && enrichedReadCache) { @@ -136,13 +137,62 @@ function buildHooks( { hooks: [ createPreToolUseHook(settingsManager, logger), - createSubagentRewriteHook(logger), + createSubagentRewriteHook(logger, registeredAgents), ], }, ], }; } +/** + * Read-only Haiku-powered exploration agent. Registered under the `ph-explore` + * name rather than `Explore` to work around a Claude Agent SDK bug where + * `options.agents` cannot shadow built-in agent definitions. The + * `createSubagentRewriteHook` rewrites `subagent_type: "Explore"` to + * `"ph-explore"` so callers don't have to know about the alias. + */ +const PH_EXPLORE_AGENT: NonNullable[string] = { + description: + 'Fast agent for exploring and understanding codebases. Use this when you need to find files by pattern (eg. "src/components/**/*.tsx"), search for code or keywords (eg. "where is the auth middleware?"), or answer questions about how the codebase works (eg. "how does the session service handle reconnects?"). When calling this agent, specify a thoroughness level: "quick" for targeted lookups, "medium" for broader exploration, or "very thorough" for comprehensive analysis across multiple locations.', + model: "haiku", + prompt: `You are a fast, read-only codebase exploration agent. + +Your job is to find files, search code, read the most relevant sources, and report findings clearly. + +Rules: +- Never create, modify, delete, move, or copy files. +- Never use shell redirection or any command that changes system state. +- Use Glob for broad file pattern matching. +- Use Grep for searching file contents. +- Use Read when you know the exact file path to inspect. +- Use Bash only for safe read-only commands like ls, git status, git log, git diff, find, cat, head, and tail. +- Adapt your search approach based on the thoroughness level specified by the caller. +- Return file paths as absolute paths in your final response. +- Avoid using emojis. +- Wherever possible, spawn multiple parallel tool calls for grepping and reading files. +- Search efficiently, then read only the most relevant files. +- Return findings directly in your final response — do not create files.`, + tools: [ + "Bash", + "Glob", + "Grep", + "Read", + "WebFetch", + "WebSearch", + "NotebookRead", + "TodoWrite", + ], +}; + +function buildAgents( + userAgents: Options["agents"], +): NonNullable { + return { + "ph-explore": PH_EXPLORE_AGENT, + ...(userAgents || {}), + }; +} + function getAbortController( userProvidedController: AbortController | undefined, ): AbortController { @@ -256,6 +306,9 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { ? [] : { type: "preset", preset: "claude_code" }); + const agents = buildAgents(params.userProvidedOptions?.agents); + const registeredAgentNames = new Set(Object.keys(agents)); + const options: Options = { ...params.userProvidedOptions, betas: ["context-1m-2025-08-07"], @@ -269,6 +322,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { canUseTool: params.canUseTool, executable: "node", tools, + agents, extraArgs: { ...params.userProvidedOptions?.extraArgs, "replay-user-messages": "", @@ -285,6 +339,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.logger, params.enrichmentDeps, params.enrichedReadCache, + registeredAgentNames, ), outputFormat: params.outputFormat, abortController: getAbortController(