From 7baefdddf3717ec88de37e455b4e6544bfd02096 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 15:26:58 +0200 Subject: [PATCH 1/2] feat(agent-manager): answer pending questions --- .changeset/agent-manager-answer-action.md | 5 + .../src/agent-manager/orchestration-bridge.ts | 49 ++- .../src/agent-manager/orchestration-domain.ts | 116 ++++++- ...agent-manager-orchestration-bridge.test.ts | 59 ++++ ...agent-manager-orchestration-domain.test.ts | 206 ++++++++++++- .../src/kilocode/agent-manager/protocol.ts | 25 +- .../src/kilocode/permission/agent-manager.ts | 6 +- .../src/kilocode/tool/agent-manager.ts | 60 +++- .../src/kilocode/tool/agent-manager.txt | 6 +- .../test/kilocode/agent-manager-tool.test.ts | 93 +++++- .../permission/agent-manager-prompt.test.ts | 6 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 86 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 104 +++++++ packages/sdk/openapi.json | 288 ++++++++++++++++++ 14 files changed, 1079 insertions(+), 30 deletions(-) create mode 100644 .changeset/agent-manager-answer-action.md diff --git a/.changeset/agent-manager-answer-action.md b/.changeset/agent-manager-answer-action.md new file mode 100644 index 00000000000..7e6a2867aeb --- /dev/null +++ b/.changeset/agent-manager-answer-action.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add an agent_manager answer action so orchestrating agents can resolve a managed session's pending question instead of only stopping it. Prompting a session that waits on input now fails immediately with the pending question named. diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts index e4e5d05360d..3912fa4cf16 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts @@ -7,6 +7,7 @@ import type { PRStatus } from "./types" import type { WorktreeStateManager } from "./WorktreeStateManager" import { OrchestrationError, + answer, move, overview, prompt, @@ -28,12 +29,14 @@ type Request = | (RequestBase & { operation: "prompt"; targetSessionID: string; prompt: string }) | (RequestBase & { operation: "stop"; targetSessionID: string }) | (RequestBase & { operation: "move"; targetSessionID: string; sectionID: string | null }) + | (RequestBase & { operation: "answer"; targetSessionID: string; questionID?: string; answers: string[][] }) type Result = | { operation: "overview"; overview: Overview } | { operation: "prompt"; sessionID: string; delivered: true } | { operation: "stop"; sessionID: string; stopped: true } | { operation: "move"; sessionID: string; sectionID: string | null; moved: true } + | { operation: "answer"; sessionID: string; questionID: string; resolved: true } interface Failure { code: FailureCode | "cancelled" | "disconnected" | "timeout" @@ -288,6 +291,9 @@ export class AgentManagerOrchestrationBridge { if (this.disposed || active.cancelled) return return { result: { operation: "prompt", sessionID: request.targetSessionID, delivered: true } } } + if (request.operation === "answer") { + return await this.resolveQuestion(client, root, state, request, origin, active) + } if (request.operation === "move") { move({ state, sessionID: request.targetSessionID, sectionID: request.sectionID }) this.options.push(origin.directory) @@ -301,18 +307,49 @@ export class AgentManagerOrchestrationBridge { }, } } - if (!this.options.managed(request.targetSessionID, origin.directory)) { - throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace") - } - await this.options.close(request.targetSessionID, origin.directory) - if (this.disposed || active.cancelled) return - return { result: { operation: "stop", sessionID: request.targetSessionID, stopped: true } } + return await this.deactivate(request.targetSessionID, origin.directory, active) } catch (error) { if (this.disposed || active.cancelled) return return { error: failure(error) } } } + private async deactivate(sessionID: string, originDirectory: string, active: Active): Promise { + if (!this.options.managed(sessionID, originDirectory)) { + throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace") + } + await this.options.close(sessionID, originDirectory) + if (this.disposed || active.cancelled) return + return { result: { operation: "stop", sessionID, stopped: true } } + } + + private async resolveQuestion( + client: KiloClient, + root: string, + state: WorktreeStateManager, + request: Extract, + origin: Origin, + active: Active, + ): Promise { + const resolved = await answer({ + client, + root, + state, + sessionID: request.targetSessionID, + questionID: request.questionID, + answers: request.answers, + }) + if (this.disposed || active.cancelled) return + return { + result: { + operation: "answer", + sessionID: request.targetSessionID, + questionID: resolved.questionID, + resolved: true, + }, + } + } + private async reply(requestID: string, directory: string, result: Result): Promise { try { const response = await this.connection.getClient().kilocode.agentManager.reply({ requestID, directory, result }) diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts index f1b7849c329..ca06fd4711d 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts @@ -311,17 +311,21 @@ export async function overview(input: OverviewInput): Promise { return grouped(input, sessions, summaries, worktreeSummaries(input, summaries, filters)) } -export async function prompt(input: { +interface Target { client: KiloClient root: string state: WorktreeStateManager sessionID: string - text: string - messageID: string - signal?: AbortSignal - idleTimeoutMs?: number -}): Promise { - if (input.signal?.aborted) return +} + +interface Located { + dir: string + name: string +} + +// Verify the target is a live managed session of this workspace and return its authoritative +// directory plus display name, so error messages can echo exact IDs back to the caller. +async function locate(input: Target): Promise { const managed = input.state.getSession(input.sessionID) if (!managed) throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace") @@ -343,12 +347,59 @@ export async function prompt(input: { if (!(await sameManagedDirectory(response.data.directory, dir))) { throw new OrchestrationError("cross_workspace", "The managed session belongs to a different workspace directory") } - await waitForIdle(input.client, dir, input.sessionID, input.signal, input.idleTimeoutMs ?? 30_000) + return { dir, name: response.data.title.trim() || input.sessionID } +} + +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 { + const [perms, qs] = await Promise.all([ + input.client.permission.list({ directory: dir }), + input.client.question.list({ directory: dir }), + ]) + const mine = (qs.error ? [] : (qs.data ?? [])).filter((value) => value.sessionID === input.sessionID) + const first = mine[0] + if (first) { + const info = first.questions[0] + const labels = (info?.options ?? []).slice(0, 8).map((option) => option.label) + const detail = info + ? ` asking "${truncate(info.question, 200)}"${labels.length ? ` (options: ${labels.join(", ")})` : ""}` + : "" + const more = mine.length > 1 ? ` ${mine.length} questions are pending; answer each in order.` : "" + return `The managed session ${input.sessionID} ("${name}") is waiting for an answer to question ${first.id}${detail}. Call agent_manager with action "answer", sessionID "${input.sessionID}", questionID "${first.id}", and one label array per question before prompting.${more}` + } + if ((perms.error ? [] : (perms.data ?? [])).some((value) => value.sessionID === input.sessionID)) { + return `The managed session ${input.sessionID} ("${name}") has a pending permission request; resolve it in Agent Manager before prompting` + } + return undefined +} + +export async function prompt(input: { + client: KiloClient + root: string + state: WorktreeStateManager + sessionID: string + text: string + messageID: string + signal?: AbortSignal + idleTimeoutMs?: number +}): Promise { + 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( { sessionID: input.sessionID, - directory: dir, + directory: target.dir, messageID: `msg_agent_manager_${input.messageID}`, parts: [{ type: "text", text: input.text }], snapshotInitialization: SNAPSHOT_INITIALIZATION, @@ -357,6 +408,53 @@ export async function prompt(input: { ) } +export async function answer(input: { + client: KiloClient + root: string + state: WorktreeStateManager + sessionID: string + questionID?: string + answers: string[][] +}): Promise<{ questionID: string }> { + const dir = (await locate(input)).dir + const listed = await input.client.question.list({ directory: dir }) + if (listed.error) throw new OrchestrationError("host_error", "The managed session questions could not be read") + const mine = (listed.data ?? []).filter((value) => value.sessionID === input.sessionID) + if (mine.length === 0) { + // The caller may have mixed up lookalike session IDs. Point at the sessions that + // actually hold pending questions so one retry with the right ID resolves it. + const others = (listed.data ?? []).map((value) => `${value.sessionID} (question ${value.id})`) + const hint = others.length ? ` Sessions with pending questions: ${others.join(", ")}.` : "" + throw new OrchestrationError("unavailable_session", `The managed session has no pending question to answer.${hint}`) + } + let target = mine[0] + if (input.questionID) { + const found = mine.find((value) => value.id === input.questionID) + if (!found) + throw new OrchestrationError( + "unavailable_session", + `The session has no pending question ${input.questionID}; pending: ${mine.map((value) => value.id).join(", ")}`, + ) + target = found + } else if (mine.length > 1) { + throw new OrchestrationError( + "unavailable_session", + `Several questions are pending: ${mine.map((value) => value.id).join(", ")}. Name one with questionID.`, + ) + } + if (input.answers.length !== target.questions.length) { + throw new OrchestrationError( + "unavailable_session", + `Question ${target.id} expects one answer array per question (${target.questions.length}), received ${input.answers.length}`, + ) + } + await input.client.question.reply( + { requestID: target.id, answers: input.answers, directory: dir }, + { throwOnError: true }, + ) + return { questionID: target.id } +} + async function waitForIdle( client: KiloClient, directory: string, diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts index c5cd830f04e..a543211b418 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts @@ -49,6 +49,7 @@ describe("AgentManagerOrchestrationBridge", () => { const promptAsync = mock(async () => ({ data: undefined })) const close = mock(async () => undefined) const push = mock(() => undefined) + const questionReply = mock(async () => ({ data: true })) const client = { session: { get: mock(async ({ sessionID, directory }: { sessionID?: string; directory?: string }) => ({ @@ -62,6 +63,7 @@ describe("AgentManagerOrchestrationBridge", () => { }, question: { list: mock(async () => ({ data: [] })), + reply: questionReply, }, kilocode: { agentManager: { @@ -129,6 +131,7 @@ describe("AgentManagerOrchestrationBridge", () => { managed, promptAsync, push, + questionReply, rejections, replies, request, @@ -305,6 +308,62 @@ describe("AgentManagerOrchestrationBridge", () => { test.bridge.dispose() }) + it("answers a managed session's pending question through the backend reply route", async () => { + const test = harness() + ;(test.client.question.list as ReturnType).mockImplementation(async () => ({ + data: [ + { + id: "que_1", + sessionID: "ses_target", + questions: [{ header: "Approve", question: "Proceed?", options: [{ label: "Yes", description: "go" }] }], + }, + ], + })) + + test.request({ + id: "amr_answer", + sessionID: "ses_caller", + operation: "answer", + targetSessionID: "ses_target", + answers: [["Yes"]], + }) + await waitFor(() => test.replies.length === 1) + + expect(test.questionReply).toHaveBeenCalledTimes(1) + expect(test.questionReply).toHaveBeenCalledWith( + { requestID: "que_1", answers: [["Yes"]], directory: dir }, + { throwOnError: true }, + ) + expect(test.replies[0]).toEqual({ + requestID: "amr_answer", + directory: root, + result: { operation: "answer", sessionID: "ses_target", questionID: "que_1", resolved: true }, + }) + test.bridge.dispose() + }) + + it("rejects an answer when the target has no pending question", async () => { + const test = harness() + + test.request({ + id: "amr_answer_none", + sessionID: "ses_caller", + operation: "answer", + targetSessionID: "ses_target", + questionID: "que_gone", + answers: [["Yes"]], + }) + await waitFor(() => test.rejections.length === 1) + + expect(test.questionReply).not.toHaveBeenCalled() + expect(test.rejections[0]).toEqual({ + requestID: "amr_answer_none", + directory: root, + error: { code: "unavailable_session", message: expect.stringContaining("no pending question") }, + }) + test.bridge.dispose() + }) + it("rejects stopping a session not managed by the current workspace", async () => { const test = harness() test.request({ diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts index 11d92fda5c4..10e18abbf32 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts @@ -2,11 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import * as fs from "fs" import * as os from "os" import * as path from "path" -import type { KiloClient, Session } from "@kilocode/sdk/v2/client" -import { OrchestrationError, overview, prompt } from "../../src/agent-manager/orchestration-domain" +import type { KiloClient, QuestionRequest, Session } from "@kilocode/sdk/v2/client" +import { OrchestrationError, answer, overview, prompt } from "../../src/agent-manager/orchestration-domain" import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" import type { PRStatus as AgentManagerPRStatus } from "../../src/agent-manager/types" +const noQuestions: QuestionRequest[] = [] + describe("Agent Manager orchestration domain", () => { let root: string let worktree: string @@ -185,6 +187,12 @@ describe("Agent Manager orchestration domain", () => { status: mock(async () => ({ data: {} })), promptAsync, }, + permission: { + list: mock(async () => ({ data: [] })), + }, + question: { + list: mock(async () => ({ data: noQuestions })), + }, } as unknown as KiloClient await prompt({ client, root, state, sessionID: "ses_target", text: "Continue", messageID: "amr_prompt" }) @@ -213,6 +221,12 @@ describe("Agent Manager orchestration domain", () => { status: mock(async () => ({ data: calls++ === 0 ? { ses_wait: { type: "busy" } } : {} })), promptAsync, }, + permission: { + list: mock(async () => ({ data: [] })), + }, + question: { + list: mock(async () => ({ data: noQuestions })), + }, } as unknown as KiloClient await prompt({ client, root, state, sessionID: "ses_wait", text: "Continue", messageID: "amr_wait" }) @@ -221,6 +235,63 @@ describe("Agent Manager orchestration domain", () => { expect(promptAsync).toHaveBeenCalledTimes(1) }) + it("fails fast with the pending question named instead of waiting out the idle timeout", async () => { + const managed = state.addWorktree({ branch: "fix/blocked", path: worktree, parentBranch: "main" }) + state.addSession("ses_blocked", managed.id) + const promptAsync = mock(async () => ({ data: undefined })) + const question: QuestionRequest = { + id: "que_1", + sessionID: "ses_blocked", + questions: [ + { + header: "Deploy", + question: "Should I deploy to production now?", + options: [ + { label: "Yes", description: "Deploy now" }, + { label: "No", description: "Wait" }, + ], + }, + ], + } + const client = { + session: { + get: mock(async () => ({ data: { id: "ses_blocked", directory: worktree, title: "Blocked" } as Session })), + status: mock(async () => ({ data: { ses_blocked: { type: "busy" } } })), + promptAsync, + }, + permission: { + list: mock(async () => ({ data: [] })), + }, + question: { + list: mock(async () => ({ data: [question] })), + }, + } as unknown as KiloClient + + await expect( + prompt({ client, root, state, sessionID: "ses_blocked", text: "Continue", messageID: "amr_blocked" }), + ).rejects.toMatchObject({ + code: "unavailable_session", + message: expect.stringContaining('sessionID "ses_blocked"'), + }) + + const failure = await prompt({ + client, + root, + state, + sessionID: "ses_blocked", + text: "Continue", + messageID: "amr_blocked2", + }).then( + () => undefined, + (error: OrchestrationError) => error, + ) + expect(failure?.message).toContain('questionID "que_1"') + expect(failure?.message).toContain('"Should I deploy to production now?"') + expect(failure?.message).toContain("(options: Yes, No)") + expect(client.question.list).toHaveBeenCalledTimes(2) + expect(promptAsync).not.toHaveBeenCalled() + }) + it("rejects unknown, stale, cross-workspace, and busy targets", async () => { const managed = state.addWorktree({ branch: "fix/errors", path: worktree, parentBranch: "main" }) state.addSession("ses_target", managed.id) @@ -231,6 +302,13 @@ 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: [] })), + }, + question: { + list: mock(async () => ({ data: noQuestions })), + }, } as unknown as KiloClient await expect( @@ -271,4 +349,128 @@ describe("Agent Manager orchestration domain", () => { } satisfies Partial) expect(promptAsync).not.toHaveBeenCalled() }) + + it("answers the sole pending question without a question ID", async () => { + const managed = state.addWorktree({ branch: "fix/answer", path: worktree, parentBranch: "main" }) + state.addSession("ses_ask", managed.id) + const reply = mock(async () => ({ data: true })) + const client = { + session: { + get: mock(async () => ({ data: { id: "ses_ask", directory: worktree, title: "Ask" } as Session })), + }, + question: { + list: mock(async () => ({ + data: [ + { + id: "que_solo", + sessionID: "ses_ask", + questions: [ + { + header: "Deploy", + question: "Deploy now?", + options: [{ label: "Yes", description: "ok" }], + }, + ], + } satisfies QuestionRequest, + ], + })), + reply, + }, + } as unknown as KiloClient + + const resolved = await answer({ client, root, state, sessionID: "ses_ask", answers: [["Yes"]] }) + + expect(resolved).toEqual({ questionID: "que_solo" }) + expect(reply).toHaveBeenCalledWith( + { requestID: "que_solo", answers: [["Yes"]], directory: worktree }, + { throwOnError: true }, + ) + }) + + it("requires a question ID when several are pending and validates answers per question", async () => { + const managed = state.addWorktree({ branch: "fix/answer-many", path: worktree, parentBranch: "main" }) + state.addSession("ses_many", managed.id) + const reply = mock(async () => ({ data: true })) + const pending: QuestionRequest[] = [ + { id: "que_a", sessionID: "ses_many", questions: [{ header: "A", question: "First?", options: [] }] }, + { id: "que_b", sessionID: "ses_many", questions: [{ header: "B", question: "Second?", options: [] }] }, + ] + const client = { + session: { + get: mock(async () => ({ data: { id: "ses_many", directory: worktree, title: "Many" } as Session })), + }, + question: { + list: mock(async () => ({ data: pending })), + reply, + }, + } as unknown as KiloClient + + await expect(answer({ client, root, state, sessionID: "ses_many", answers: [["x"]] })).rejects.toMatchObject({ + code: "unavailable_session", + message: expect.stringContaining("que_a"), + }) + await expect( + answer({ client, root, state, sessionID: "ses_many", questionID: "que_b", answers: [["x"], ["y"]] }), + ).rejects.toMatchObject({ + code: "unavailable_session", + message: expect.stringContaining("one answer array per question (1)"), + }) + + const resolved = await answer({ + client, + root, + state, + sessionID: "ses_many", + questionID: "que_b", + answers: [["go"]], + }) + expect(resolved).toEqual({ questionID: "que_b" }) + expect(reply).toHaveBeenCalledWith( + { requestID: "que_b", answers: [["go"]], directory: worktree }, + { throwOnError: true }, + ) + }) + + it("rejects answering when nothing or something foreign is pending", async () => { + const managed = state.addWorktree({ branch: "fix/answer-none", path: worktree, parentBranch: "main" }) + state.addSession("ses_none", managed.id) + const reply = mock(async () => ({ data: true })) + const client = { + session: { + get: mock(async () => ({ data: { id: "ses_none", directory: worktree, title: "None" } as Session })), + }, + question: { + list: mock(async () => ({ + data: [ + { + id: "que_other", + sessionID: "ses_stranger", + questions: [{ header: "X", question: "Other session's question", options: [] }], + } satisfies QuestionRequest, + ], + })), + reply, + }, + } as unknown as KiloClient + + await expect(answer({ client, root, state, sessionID: "ses_none", answers: [["x"]] })).rejects.toMatchObject({ + code: "unavailable_session", + message: expect.stringContaining("no pending question"), + }) + const dead = await answer({ client, root, state, sessionID: "ses_none", answers: [["x"]] }).then( + (value) => undefined, + (error: OrchestrationError) => error, + ) + expect(dead?.message).toContain("Sessions with pending questions: ses_stranger (question que_other)") + await expect( + answer({ client, root, state, sessionID: "ses_none", questionID: "que_other", answers: [["x"]] }), + ).rejects.toMatchObject({ + code: "unavailable_session", + message: expect.stringContaining("no pending question"), + }) + await expect(answer({ client, root, state, sessionID: "ses_unknown", answers: [["x"]] })).rejects.toMatchObject({ + code: "unknown_session", + } satisfies Partial) + expect(reply).not.toHaveBeenCalled() + }) }) diff --git a/packages/opencode/src/kilocode/agent-manager/protocol.ts b/packages/opencode/src/kilocode/agent-manager/protocol.ts index d6c66376c02..8a5567085ca 100644 --- a/packages/opencode/src/kilocode/agent-manager/protocol.ts +++ b/packages/opencode/src/kilocode/agent-manager/protocol.ts @@ -112,7 +112,21 @@ export const MoveRequest = Schema.Struct({ sectionID: Schema.NullOr(ID), }).annotate({ identifier: "AgentManagerMoveRequest" }) -export const Request = Schema.Union([OverviewRequest, PromptRequest, StopRequest, MoveRequest]).annotate({ +const AnswerLabels = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)) +const AnswerArray = Schema.Array(AnswerLabels).check(Schema.isMaxLength(20)) +export const Answers = Schema.Array(AnswerArray) + .check(Schema.isMinLength(1), Schema.isMaxLength(20)) + .annotate({ identifier: "AgentManagerAnswers" }) + +export const AnswerRequest = Schema.Struct({ + ...Base, + operation: Schema.Literal("answer"), + targetSessionID: SessionID, + questionID: Schema.optional(ID), + answers: Answers, +}).annotate({ identifier: "AgentManagerAnswerRequest" }) + +export const Request = Schema.Union([OverviewRequest, PromptRequest, StopRequest, MoveRequest, AnswerRequest]).annotate({ identifier: "AgentManagerRequest", }) export type Request = Schema.Schema.Type @@ -141,7 +155,14 @@ export const MoveResult = Schema.Struct({ moved: Schema.Literal(true), }).annotate({ identifier: "AgentManagerMoveResult" }) -export const Result = Schema.Union([OverviewResult, PromptResult, StopResult, MoveResult]).annotate({ +export const AnswerResult = Schema.Struct({ + operation: Schema.Literal("answer"), + sessionID: SessionID, + questionID: ID, + resolved: Schema.Literal(true), +}).annotate({ identifier: "AgentManagerAnswerResult" }) + +export const Result = Schema.Union([OverviewResult, PromptResult, StopResult, MoveResult, AnswerResult]).annotate({ identifier: "AgentManagerResult", }) export type Result = Schema.Schema.Type diff --git a/packages/opencode/src/kilocode/permission/agent-manager.ts b/packages/opencode/src/kilocode/permission/agent-manager.ts index 672bee8b042..1e30af45419 100644 --- a/packages/opencode/src/kilocode/permission/agent-manager.ts +++ b/packages/opencode/src/kilocode/permission/agent-manager.ts @@ -2,11 +2,11 @@ import { type Rule } from "./rule" export namespace AgentManagerPermission { /** - * Prompting, stopping, or moving an existing Agent Manager session has an external side effect. - * Broad approvals for legacy session creation must not silently grant it. + * Prompting, stopping, moving, or answering a pending question on an existing Agent Manager session has an + * external side effect. Broad approvals for legacy session creation must not silently grant it. */ export function harden(permission: string, pattern: string, rule: Rule): Rule { - if (permission !== "agent_manager" || !["prompt", "stop", "move"].includes(pattern) || rule.action !== "allow") return rule + if (permission !== "agent_manager" || !["prompt", "stop", "move", "answer"].includes(pattern) || rule.action !== "allow") return rule if (rule.permission === "agent_manager" && rule.pattern === pattern) return rule return { permission, pattern, action: "ask" } } diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 2c5aed828c0..8166f8302ee 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -103,7 +103,26 @@ const MoveParams = Schema.Struct({ }), }) -export const Params = Schema.Union([StartParams, ListParams, PromptParams, StopParams, MoveParams]) +const AnswerParams = Schema.Struct({ + action: Schema.Literal("answer").annotate({ + description: "Resolve the pending question that blocks exactly one managed session.", + }), + sessionID: SessionID, + questionID: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: + "Pending question ID, learned from a failed prompt or an earlier answer error. Omit only when exactly one question is pending.", + }), + answers: Schema.Array( + Schema.Array(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200))).check(Schema.isMaxLength(20)), + ) + .check(Schema.isMinLength(1), Schema.isMaxLength(20)) + .annotate({ + description: + "One array of selected option labels per question of the request, in order. Labels must match the advertised options.", + }), +}) + +export const Params = Schema.Union([StartParams, ListParams, PromptParams, StopParams, MoveParams, AnswerParams]) // Anthropic rejects a top-level anyOf/oneOf/allOf, so the advertised schema has to // stay one flat object while Params keeps the real per-operation validation. That @@ -125,7 +144,7 @@ const WireParams = Schema.Struct({ description: "Start sessions only. Agent Manager sessions to start. Send null whenever action is set.", }), action: Schema.optional( - Schema.NullOr(Schema.Literals(["list", "prompt", "stop", "move"])).annotate({ + Schema.NullOr(Schema.Literals(["list", "prompt", "stop", "move", "answer"])).annotate({ description: "Use list first to discover IDs and assignments. Use move only after list, once per worktree. Never edit .kilo/agent-manager.json for these operations. Send null when starting sessions with mode and tasks, otherwise the action is used instead of the start request.", }), @@ -133,13 +152,15 @@ const WireParams = Schema.Struct({ filter: ListParams.fields.filter, sessionID: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: - "For prompt, stop, and move: a session ID returned by action=list (IDs start with ses_). Send null for every other operation.", + "For prompt, stop, move, and answer: a session ID returned by action=list (IDs start with ses_). Send null for every other operation.", }), prompt: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "For prompt: the instruction to send to that session. Start requests use tasks[].prompt instead, so send null.", }), sectionID: Schema.optional(MoveParams.fields.sectionID), + questionID: AnswerParams.fields.questionID, + answers: Schema.optional(Schema.NullOr(AnswerParams.fields.answers)), }) type Input = Schema.Schema.Type @@ -291,7 +312,13 @@ function select( export const AgentManagerTool = Tool.define< typeof Params, - { action: "start" | "list" | "prompt" | "stop" | "move"; requestID?: string; count?: number; sessionID?: string }, + { + action: "start" | "list" | "prompt" | "stop" | "move" | "answer" + requestID?: string + count?: number + sessionID?: string + questionID?: string + }, AgentManager.Service | Bus.Service | Provider.Service, "agent_manager" >( @@ -393,6 +420,31 @@ export const AgentManagerTool = Tool.define< metadata: { action: "stop", sessionID: result.sessionID }, } } + if (params.action === "answer") { + yield* ctx.ask({ + permission: "agent_manager", + patterns: ["answer"], + always: ["answer"], + metadata: { action: "answer", sessionID: params.sessionID }, + }) + const result = yield* run( + host.request({ + operation: "answer", + sessionID: ctx.sessionID, + targetSessionID: params.sessionID, + ...(params.questionID?.trim() ? { questionID: params.questionID.trim() } : {}), + answers: params.answers, + }), + ctx.abort, + ) + if (result.operation !== "answer") + return yield* Effect.die(new Error("Agent Manager host returned the wrong result type")) + return { + title: "Question answered", + output: `Answered Agent Manager question ${result.questionID} for session ${result.sessionID}. The session resumes with those answers.`, + metadata: { action: "answer", sessionID: result.sessionID, questionID: result.questionID }, + } + } yield* ctx.ask({ permission: "agent_manager", patterns: ["move"], diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 80be0f58e43..42b25844396 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -1,6 +1,10 @@ Inspect and orchestrate Agent Manager sessions, or start new sessions, in the VS Code extension. Use this tool for Agent Manager sections and assignments. Do not edit `.kilo/agent-manager.json` directly: it is persisted UI/recovery state, not the Agent Manager API, and manual patches can overwrite live state. -Use `action: "list"` to inspect the Agent Manager overview, `action: "prompt"` to send one instruction to one existing managed session, `action: "stop"` to stop and remove one managed session, and `action: "move"` to move one session's worktree into a section or ungroup it. 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": "", "sectionID": "" }` 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. +Use `action: "list"` to inspect the Agent Manager overview, `action: "prompt"` to send one instruction to one existing managed session, `action: "stop"` to stop and remove one managed session, `action: "move"` to move one session's worktree into a section or ungroup it, and `action: "answer"` to resolve the pending question that blocks exactly one managed session. + +A session waiting on a question reports `attention: ["question"]` in the list output and refuses prompts; a failed prompt names the target session ID, the pending question with its ID, text, and option labels. Use those exact IDs in the answer call. Answer it with `{ "action": "answer", "sessionID": "", "answers": [["