diff --git a/.changeset/fair-subagents-route.md b/.changeset/fair-subagents-route.md new file mode 100644 index 00000000000..a1d2775fc90 --- /dev/null +++ b/.changeset/fair-subagents-route.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep config-defined subagents routable when an installed primary agent uses the same name. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5d08de3794c..cdd433b92ab 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -490,6 +490,7 @@ export const layer = Layer.effect( result = mergeConfigConcatArrays(result, { agent: orgModes.agents }) } warnings.push(...orgModes.warnings) + let configuredAgents = { ...(result.agent ?? {}) } // kilocode_change end const authEnv: Record = {} @@ -549,6 +550,7 @@ export const layer = Layer.effect( const trusted = sourceTrusted ?? scope === "global" const scoped = KilocodeConfig.scopeIndexing(SandboxConfig.scope(next, scope), scope) result = mergeConfigConcatArrays(result, scoped) + if (scoped.agent) configuredAgents = mergeDeep(configuredAgents, scoped.agent) if (next.instructions?.length) { result.instruction_origins = origins(result.instruction_origins, next.instructions, trusted, source) } @@ -765,13 +767,15 @@ export const layer = Layer.effect( result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir, warnings, dirTrusted, dirFileScope, dirSourceScope)), ) - result.agent = mergeDeep( + result.agent = KilocodeConfig.mergeAgentMarkdown( result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted, dirFileScope, dirSourceScope)), + configuredAgents, ) - result.agent = mergeDeep( + result.agent = KilocodeConfig.mergeAgentMarkdown( result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings, dirTrusted, dirFileScope, dirSourceScope)), + configuredAgents, ) // kilocode_change end // kilocode_change - Auto-discovered plugins under config directories are already local files, so ConfigPlugin.load diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index f98a0b8ffa8..d47a9a9a2b1 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -181,6 +181,35 @@ export namespace KilocodeConfig { return stripGlobalIndexing(info) } + /** + * Merge discovered agent markdown while preserving routing explicitly defined + * in config. Tracking config entries separately keeps normal directory + * precedence between markdown files intact. + */ + export function mergeAgentMarkdown( + existing: Record, + incoming: Record, + configured: Record, + ) { + const result = { ...existing } + for (const [name, agent] of Object.entries(incoming)) { + const current = result[name] + if (!current) { + result[name] = agent + continue + } + + const config = configured[name] + if (agent.mode === "primary" && config && config.mode !== "primary") { + result[name] = mergeDeep(mergeDeep(current, agent), { ...config, mode: config.mode ?? "all" }) + continue + } + + result[name] = mergeDeep(current, agent) + } + return result + } + export function retireIndexingFlag(info: Record, source: string) { if (!isRecord(info.experimental) || !("semantic_indexing" in info.experimental)) return info const experimental = { ...info.experimental } diff --git a/packages/opencode/test/kilocode/agent-routing.test.ts b/packages/opencode/test/kilocode/agent-routing.test.ts new file mode 100644 index 00000000000..2ce79e53423 --- /dev/null +++ b/packages/opencode/test/kilocode/agent-routing.test.ts @@ -0,0 +1,113 @@ +import { afterEach, expect, test } from "bun:test" +import { Global } from "@opencode-ai/core/global" +import path from "path" +import { Effect } from "effect" +import { Agent } from "../../src/agent/agent" +import { Filesystem } from "../../src/util/filesystem" +import { + disposeAllInstances, + provideInstance, + provideTestInstance, + testInstanceStoreLayer, + tmpdir, +} from "../fixture/fixture" + +function load(dir: string) { + return Effect.runPromise( + provideInstance(dir)(Agent.Service.use((svc) => svc.get("architect"))).pipe( + Effect.provide(Agent.defaultLayer), + Effect.provide(testInstanceStoreLayer), + ), + ) +} + +afterEach(async () => { + await disposeAllInstances() +}) + +test("config subagent routing survives a colliding primary agent markdown file", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + architect: { + mode: "subagent", + model: "test/configured-subagent", + }, + }, + }, + }) + await Filesystem.write( + path.join(tmp.path, ".kilo", "agents", "architect.md"), + [ + "---", + "mode: primary", + "description: Marketplace architect", + "---", + "", + "You are the marketplace architect.", + ].join("\n"), + ) + + const item = await provideTestInstance({ + directory: tmp.path, + fn: () => load(tmp.path), + }) + + expect(item?.mode).toBe("subagent") + expect(String(item?.model?.providerID)).toBe("test") + expect(String(item?.model?.modelID)).toBe("configured-subagent") + expect(item?.description).toBe("Marketplace architect") +}) + +test("config-only custom agent keeps its default all mode across a primary collision", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + architect: { + model: "test/configured-subagent", + }, + }, + }, + }) + await Filesystem.write( + path.join(tmp.path, ".kilo", "agents", "architect.md"), + ["---", "mode: primary", "---", "", "You are the marketplace architect."].join("\n"), + ) + + const item = await provideTestInstance({ + directory: tmp.path, + fn: () => load(tmp.path), + }) + + expect(item?.mode).toBe("all") + expect(String(item?.model?.providerID)).toBe("test") + expect(String(item?.model?.modelID)).toBe("configured-subagent") +}) + +test("higher-priority markdown can override lower-priority markdown routing", async () => { + await using tmp = await tmpdir() + const project = path.join(tmp.path, "project") + const global = path.join(tmp.path, "global") + await Filesystem.write( + path.join(global, "agents", "architect.md"), + ["---", "mode: subagent", "description: Lower-priority architect", "---"].join("\n"), + ) + await Filesystem.write( + path.join(project, ".kilo", "agents", "architect.md"), + ["---", "mode: primary", "description: Higher-priority architect", "---"].join("\n"), + ) + + const previous = Global.Path.config + ;(Global.Path as { config: string }).config = global + try { + const item = await provideTestInstance({ + directory: project, + fn: () => load(project), + }) + + expect(item?.mode).toBe("primary") + expect(item?.description).toBe("Higher-priority architect") + } finally { + ;(Global.Path as { config: string }).config = previous + } +})