diff --git a/.changeset/deny-nested-subagents.md b/.changeset/deny-nested-subagents.md new file mode 100644 index 00000000000..38466b96f7e --- /dev/null +++ b/.changeset/deny-nested-subagents.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent subagents from spawning nested subagents. diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 633defeb843..b349ab86b30 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -24,6 +24,11 @@ export namespace KiloTask { if (info.mode === "primary") throw new Error(`Agent "${name}" is a primary agent and cannot be used as a subagent`) } + /** Kilo keeps delegation one level deep to avoid recursive subagent chains. */ + export function nestedTask(): false { + return false + } + /** * Build inherited permission rules from the calling agent. * Merges the static agent definition with the session's accumulated permissions diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 62ed942e820..33ab459a3c9 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -62,7 +62,7 @@ export const TaskTool = Tool.define( KiloTask.validate(next, params.subagent_type) // kilocode_change end - const canTask = next.permission.some((rule) => rule.permission === id) + const canTask = KiloTask.nestedTask() // kilocode_change - Kilo disallows subagents spawning subagents const canTodo = next.permission.some((rule) => rule.permission === "todowrite") const parent = yield* sessions.get(ctx.sessionID) diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts new file mode 100644 index 00000000000..fc92a9b884b --- /dev/null +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { Session } from "../../src/session/session" +import { MessageV2 } from "../../src/session/message-v2" +import type { SessionPrompt } from "../../src/session/prompt" +import { MessageID, PartID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { TaskTool, type TaskPromptOps } from "../../src/tool/task" +import { Truncate } from "../../src/tool/truncate" +import { ToolRegistry } from "../../src/tool/registry" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + Config.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + Truncate.defaultLayer, + ToolRegistry.defaultLayer, + ), +) + +afterEach(async () => { + await disposeAllInstances() +}) + +const seed = Effect.fn("NestedTaskToolTest.seed")(function* () { + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Parent" }) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } + yield* sessions.updateMessage(assistant) + return { chat, assistant } +}) + +function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void }): TaskPromptOps { + return { + cancel() {}, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.sync(() => { + opts?.onPrompt?.(input) + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant", + parentID: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + mode: input.agent ?? "general", + agent: input.agent ?? "general", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + finish: "stop", + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text: "done", + }, + ], + } satisfies MessageV2.WithParts + }), + } +} + +describe("Kilo task nesting", () => { + it.live("allows primary agents to delegate one level to a subagent", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "explore", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(result.metadata.sessionId) + expect(kids[0]?.parentID).toBe(chat.id) + expect(seen?.sessionID).toBe(result.metadata.sessionId) + expect(seen?.agent).toBe("explore") + }), + ), + ) + + it.live("disables nested task tool even when global task permission allows it", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "explore", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const child = yield* sessions.get(result.metadata.sessionId) + expect(seen?.tools?.task).toBe(false) + expect(child.permission).toEqual( + expect.arrayContaining([ + { + permission: "task", + pattern: "*", + action: "deny", + }, + ]), + ) + }), + { + config: { + permission: { + task: "allow", + }, + }, + }, + ), + ) +}) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 5a3c965d3f5..3646c1b1851 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -383,6 +383,7 @@ describe("tool.task", () => { // kilocode_change end expect(seen?.tools).toEqual({ todowrite: false, + task: false, // kilocode_change - Kilo disallows nested subagents bash: false, read: false, })