mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix(cli): prevent nested subagent delegation (#10137)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent subagents from spawning nested subagents.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user