Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/queue-busy-agent-manager-prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---

Queue Agent Manager follow-up prompts when the target session is busy instead of rejecting them.
2 changes: 2 additions & 0 deletions packages/kilo-docs/pages/automate/agent-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ The companion `agent_manager_models` tool searches models and their supported re

The same tool also manages existing sessions. It can return an overview of sections, worktrees, and local sessions, send a prompt to one managed session, stop a managed session, or move a session's worktree into a section. The overview includes section IDs, each section's assigned worktrees, worktree IDs, and session IDs. Use those exact IDs for a subsequent move. Moving accepts a section ID from the overview, or `null` to ungroup the worktree. Moving a session moves its whole worktree, including multi-version siblings. Local sessions cannot be assigned to a section. Stopping aborts the session's active work and removes it from the panel, just like closing the session tab.

Prompts to busy or retrying sessions enter the same queue as follow-up messages sent from chat. The tool returns when the prompt is accepted, without waiting for it to run or finish. Sessions with pending questions or permission requests still refuse prompts. Answer the question with `action: "answer"`, or resolve the permission request in Agent Manager, before prompting again.

The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. Stopping a session likewise requires an explicit `stop` approval, and moving a worktree requires an explicit `move` approval.

## Sections
Expand Down
29 changes: 0 additions & 29 deletions packages/kilo-vscode/src/agent-manager/orchestration-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,6 @@ function truncate(value: string, max: number): string {
return value.length <= max ? value : `${value.slice(0, max - 1)}…`
}

// Name what keeps the target from accepting a prompt. A session blocked on a question
// never becomes idle on its own, so naming the blocker here lets an orchestrating agent
// answer it instead of waiting out the idle timeout. The message echoes the exact session
// and question IDs so a follow-up answer call can copy them without guessing.
async function blocked(input: Target, dir: string, name: string): Promise<string | undefined> {
const [perms, qs] = await Promise.all([
input.client.permission.list({ directory: dir }),
Expand Down Expand Up @@ -398,14 +394,12 @@ export async function prompt(input: {
text: string
messageID: string
signal?: AbortSignal
idleTimeoutMs?: number
managed?: ManagedSession
}): Promise<void> {
if (input.signal?.aborted) return
const target = await locate(input)
const blocker = await blocked(input, target.dir, target.name)
if (blocker) throw new OrchestrationError("unavailable_session", blocker)
await waitForIdle(input.client, target.dir, input.sessionID, input.signal, input.idleTimeoutMs ?? 30_000)
if (input.signal?.aborted) return
await input.client.session.promptAsync(
{
Expand Down Expand Up @@ -467,29 +461,6 @@ export async function answer(input: {
return { questionID: target.id }
}

async function waitForIdle(
client: KiloClient,
directory: string,
sessionID: string,
signal: AbortSignal | undefined,
timeout: number,
start = Date.now(),
): Promise<void> {
if (signal?.aborted) return
const status = await client.session.status({ directory })
if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read")
const activity = status.data?.[sessionID]?.type ?? "idle"
if (activity === "idle") return
if (Date.now() - start >= timeout) {
throw new OrchestrationError(
"unavailable_session",
`The managed session is still ${activity}; only idle sessions can be prompted`,
)
}
await new Promise<void>((resolve) => setTimeout(resolve, 250))
return waitForIdle(client, directory, sessionID, signal, timeout, start)
}

export function move(input: {
state: WorktreeStateManager
sessionID: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ describe("AgentManagerOrchestrationBridge", () => {
prompt: "Continue",
}

it("deduplicates prompt delivery and retries only the failed acknowledgement", async () => {
it("deduplicates busy-session prompt submission and retries only the failed acknowledgement", async () => {
const test = harness()
test.client.session.status.mockImplementation(async () => ({ data: { ses_target: { type: "busy" } } }))
test.status.failReply = true

test.request(request)
Expand All @@ -163,6 +164,8 @@ describe("AgentManagerOrchestrationBridge", () => {
test.request(request)
await waitFor(() => test.replies.length === 2)

expect(test.client.session.status).not.toHaveBeenCalled()
expect(test.rejections).toEqual([])
expect(test.promptAsync).toHaveBeenCalledTimes(1)
expect(test.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ describe("Agent Manager orchestration domain", () => {
expect(dirs.size).toBe(4)
})

it("delivers only to an idle managed session in its authoritative directory", async () => {
it("delivers to a managed session in its authoritative directory", async () => {
const managed = state.addWorktree({ branch: "fix/prompt", path: worktree, parentBranch: "main" })
state.addSession("ses_target", managed.id)
const get = mock(async () => ({
Expand Down Expand Up @@ -302,32 +302,92 @@ describe("Agent Manager orchestration domain", () => {
expect(delivered).toHaveBeenCalledWith(expect.objectContaining({ directory: worktree }), { throwOnError: true })
})

it("waits for a busy managed session to become idle before prompting", async () => {
const managed = state.addWorktree({ branch: "fix/wait", path: worktree, parentBranch: "main" })
state.addSession("ses_wait", managed.id)
let calls = 0
const promptAsync = mock(async () => ({ data: undefined }))
it.each(["busy", "retry"] as const)("submits prompts to a %s session without waiting for idle", async (activity) => {
const managed = state.addWorktree({ branch: "fix/queue", path: worktree, parentBranch: "main" })
state.addSession("ses_queue", managed.id)
const delivered = mock(async () => ({ data: undefined }))
const client = {
session: {
get: mock(async () => ({ data: { id: "ses_wait", directory: worktree, title: "Wait" } as Session })),
status: mock(async () => ({ data: calls++ === 0 ? { ses_wait: { type: "busy" } } : {} })),
promptAsync,
get: mock(async () => ({ data: { id: "ses_queue", directory: worktree, title: "Queue" } as Session })),
status: mock(async () => ({ data: { ses_queue: { type: activity } } })),
promptAsync: delivered,
abort: mock(async () => ({ data: true })),
},
permission: {
list: mock(async () => ({ data: [] })),
permission: { list: mock(async () => ({ data: [] })) },
question: { list: mock(async () => ({ data: noQuestions })) },
} as unknown as KiloClient

await prompt({ client, root, state, sessionID: "ses_queue", text: "Continue", messageID: "amr_queue" })

expect(client.session.status).not.toHaveBeenCalled()
expect(client.session.abort).not.toHaveBeenCalled()
expect(delivered).toHaveBeenCalledTimes(1)
expect(delivered).toHaveBeenCalledWith(
{
sessionID: "ses_queue",
directory: worktree,
messageID: "msg_agent_manager_amr_queue",
parts: [{ type: "text", text: "Continue" }],
snapshotInitialization: "wait",
},
{ throwOnError: true },
)
})

it("rejects prompts to a busy session with a pending permission", async () => {
state.addSession("ses_blocked", null)
const delivered = mock(async () => ({ data: undefined }))
const client = {
session: {
get: mock(async () => ({ data: { id: "ses_blocked", directory: root, title: "Blocked" } as Session })),
status: mock(async () => ({ data: { ses_blocked: { type: "busy" } } })),
promptAsync: delivered,
},
permission: { list: mock(async () => ({ data: [{ id: "perm_1", sessionID: "ses_blocked" }] })) },
question: { list: mock(async () => ({ data: noQuestions })) },
} as unknown as KiloClient

await expect(
prompt({ client, root, state, sessionID: "ses_blocked", text: "Continue", messageID: "amr_permission" }),
).rejects.toMatchObject({
code: "unavailable_session",
message: expect.stringContaining("pending permission request"),
})
expect(delivered).not.toHaveBeenCalled()
})

it("does not submit a prompt cancelled during validation", async () => {
state.addSession("ses_cancelled", null)
const controller = new AbortController()
const delivered = mock(async () => ({ data: undefined }))
const client = {
session: {
get: mock(async () => ({ data: { id: "ses_cancelled", directory: root, title: "Cancelled" } as Session })),
promptAsync: delivered,
},
permission: { list: mock(async () => ({ data: [] })) },
question: {
list: mock(async () => ({ data: noQuestions })),
list: mock(async () => {
controller.abort()
return { data: noQuestions }
}),
},
} as unknown as KiloClient

await prompt({ client, root, state, sessionID: "ses_wait", text: "Continue", messageID: "amr_wait" })
await prompt({
client,
root,
state,
sessionID: "ses_cancelled",
text: "Continue",
messageID: "amr_cancelled",
signal: controller.signal,
})

expect(client.session.status).toHaveBeenCalledTimes(2)
expect(promptAsync).toHaveBeenCalledTimes(1)
expect(delivered).not.toHaveBeenCalled()
})

it("fails fast with the pending question named instead of waiting out the idle timeout", async () => {
it("rejects prompts with the pending question and answer options named", async () => {
const managed = state.addWorktree({ branch: "fix/blocked", path: worktree, parentBranch: "main" })
state.addSession("ses_blocked", managed.id)
const promptAsync = mock(async () => ({ data: undefined }))
Expand Down Expand Up @@ -420,7 +480,7 @@ describe("Agent Manager orchestration domain", () => {
} satisfies Partial<OrchestrationError>)
})

it("rejects unknown, stale, cross-workspace, and busy targets", async () => {
it("rejects unknown, stale, and cross-workspace targets", async () => {
const managed = state.addWorktree({ branch: "fix/errors", path: worktree, parentBranch: "main" })
state.addSession("ses_target", managed.id)
const promptAsync = mock(async () => ({ data: undefined }))
Expand All @@ -430,7 +490,6 @@ describe("Agent Manager orchestration domain", () => {
status: mock(async () => ({ data: {} })),
promptAsync,
},
// Permission replies remain out of scope. This empty read keeps the test focused on question/idle handling.
permission: {
list: mock(async () => ({ data: [] })),
},
Expand Down Expand Up @@ -471,25 +530,6 @@ describe("Agent Manager orchestration domain", () => {
).rejects.toMatchObject({
code: "cross_workspace",
} satisfies Partial<OrchestrationError>)
;(client.session.get as ReturnType<typeof mock>).mockImplementation(async () => ({
data: { id: "ses_target", directory: worktree, title: "Target" } as Session,
}))
;(client.session.status as ReturnType<typeof mock>).mockImplementation(async () => ({
data: { ses_target: { type: "busy" } },
}))
await expect(
prompt({
client,
root,
state,
sessionID: "ses_target",
text: "Continue",
messageID: "amr_busy",
idleTimeoutMs: 0,
}),
).rejects.toMatchObject({
code: "unavailable_session",
} satisfies Partial<OrchestrationError>)

fs.rmSync(worktree, { recursive: true, force: true })
await expect(
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/kilocode/tool/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,8 +409,8 @@ export const AgentManagerTool = Tool.define<
if (result.operation !== "prompt")
return yield* Effect.die(new Error("Agent Manager host returned the wrong result type"))
return {
title: "Prompt delivered",
output: `Delivered the prompt to Agent Manager session ${result.sessionID}. The session accepted it asynchronously.`,
title: "Prompt accepted",
output: `Agent Manager session ${result.sessionID} accepted the prompt. If the session is busy, the prompt is queued behind active work. This does not wait for completion.`,
metadata: { action: "prompt", sessionID: result.sessionID },
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/kilocode/tool/agent-manager.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A session waiting on a question reports `attention: ["question"]` in the list ou

Permission blockers are separate from questions: a session may report `attention: ["permission"]`; resolve that request in Agent Manager before prompting. The `answer` action resolves questions only and requires its own `answer` permission approval.

For any assignment request, the required sequence is: (1) call `agent_manager` with `{ "action": "list" }`; (2) read the returned `sections[].id`, `sections[].worktrees[].session.id` or `sessions[].id`, and `ungrouped[].session.id` or `sessions[].sessions[].id`; (3) call `agent_manager` with `{ "action": "move", "sessionID": "<returned session id>", "sectionID": "<returned section id>" }` once for each worktree; (4) use `sectionID: null` to unassign. Never invent IDs, use section names instead of IDs, or edit `.kilo/agent-manager.json`. The `list` result is the source of truth for IDs and assignments: each `sections` entry includes the section `id`, name, and its assigned `worktrees`; each worktree includes its worktree `id` and its session ID(s) in `session` or `sessions`; `ungrouped` lists worktrees that have no section; and `local.sessions` lists local sessions that cannot be assigned to a section. For `move`, pass the target session's ID as `sessionID` and a section ID as `sectionID`; pass `null` to unassign it. Optional filters can narrow by section ID or by `idle`, `busy`, `retry`, `offline`, or `waiting` state. Prompting, stopping, and moving are targeted only: they do not broadcast or create sessions, and prompting does not wait for the target to finish. Moving a session moves its whole worktree, including multi-version siblings; local sessions cannot be assigned to a section.
For any assignment request, the required sequence is: (1) call `agent_manager` with `{ "action": "list" }`; (2) read the returned `sections[].id`, `sections[].worktrees[].session.id` or `sessions[].id`, and `ungrouped[].session.id` or `sessions[].sessions[].id`; (3) call `agent_manager` with `{ "action": "move", "sessionID": "<returned session id>", "sectionID": "<returned section id>" }` once for each worktree; (4) use `sectionID: null` to unassign. Never invent IDs, use section names instead of IDs, or edit `.kilo/agent-manager.json`. The `list` result is the source of truth for IDs and assignments: each `sections` entry includes the section `id`, name, and its assigned `worktrees`; each worktree includes its worktree `id` and its session ID(s) in `session` or `sessions`; `ungrouped` lists worktrees that have no section; and `local.sessions` lists local sessions that cannot be assigned to a section. For `move`, pass the target session's ID as `sessionID` and a section ID as `sectionID`; pass `null` to unassign it. Optional filters can narrow by section ID or by `idle`, `busy`, `retry`, `offline`, or `waiting` state. Prompting, stopping, and moving are targeted only: they do not broadcast or create sessions, and prompting does not wait for the target to finish. Prompts to busy or retrying sessions are queued behind active work instead of rejected; you do not need to wait for idle before prompting. Moving a session moves its whole worktree, including multi-version siblings; local sessions cannot be assigned to a section.

To start sessions, keep using the existing `mode` and `tasks` input without an action. Use start mode when the user explicitly asks you to fan out work into Agent Manager, create Agent Manager worktrees, or start multiple Agent Manager sessions for independent tasks.

Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/test/kilocode/agent-manager-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,9 @@ describe("agent_manager tool", () => {
prompt: "Continue the fix",
},
])
expect(result.output).toContain("accepted it asynchronously")
expect(result.title).toBe("Prompt accepted")
expect(result.output).toContain("queued behind active work")
expect(result.output).toContain("does not wait for completion")
expect(result.metadata).toEqual(expect.objectContaining({ action: "prompt", sessionID: "ses_target" }))
await rt.dispose()
})
Expand Down
Loading