mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #13372 from Kilo-Org/implement-issue-13332
feat(agent-manager): answer pending questions
This commit is contained in:
@@ -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.
|
||||
@@ -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<Outcome | undefined> {
|
||||
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<Request, { operation: "answer" }>,
|
||||
origin: Origin,
|
||||
active: Active,
|
||||
): Promise<Outcome | undefined> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const response = await this.connection.getClient().kilocode.agentManager.reply({ requestID, directory, result })
|
||||
|
||||
@@ -311,17 +311,21 @@ export async function overview(input: OverviewInput): Promise<Overview> {
|
||||
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<void> {
|
||||
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<Located> {
|
||||
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,68 @@ 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<string | undefined> {
|
||||
const [perms, qs] = await Promise.all([
|
||||
input.client.permission.list({ directory: dir }),
|
||||
input.client.question.list({ directory: dir }),
|
||||
])
|
||||
if (perms.error || qs.error)
|
||||
throw new OrchestrationError("host_error", "The managed session blockers could not be read")
|
||||
const mine = (qs.data ?? []).filter((value) => value.sessionID === input.sessionID)
|
||||
const first = mine[0]
|
||||
if (first) {
|
||||
const detail = mine
|
||||
.map((value) => {
|
||||
const list = value.questions
|
||||
.map((info, index) => {
|
||||
const labels = info.options.slice(0, 8).map((option) => option.label)
|
||||
return `${index + 1}. "${truncate(info.question, 200)}"${labels.length ? ` (options: ${labels.join(", ")})` : ""}`
|
||||
})
|
||||
.join("; ")
|
||||
return `questionID "${value.id}": ${list}`
|
||||
})
|
||||
.join(" | ")
|
||||
const count = first.questions.length
|
||||
const more = mine.length > 1 ? ` Pending request IDs: ${mine.map((value) => value.id).join(", ")}.` : ""
|
||||
return `The managed session ${input.sessionID} ("${name}") is waiting for input. Pending question requests: ${detail}. Call agent_manager with action "answer", sessionID "${input.sessionID}", questionID "${first.id}", and one label array per question in that request (${count} total), in order, before prompting.${more}`
|
||||
}
|
||||
if ((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<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(
|
||||
{
|
||||
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 +417,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,
|
||||
|
||||
@@ -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<typeof mock>).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({
|
||||
|
||||
@@ -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,99 @@ 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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Region",
|
||||
question: "Which region should receive the deployment?",
|
||||
options: [
|
||||
{ label: "US", description: "Deploy to the US" },
|
||||
{ label: "EU", description: "Deploy to the EU" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
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(failure?.message).toContain('"Which region should receive the deployment?"')
|
||||
expect(failure?.message).toContain("(options: US, EU)")
|
||||
expect(failure?.message).toContain("one label array per question in that request (2 total)")
|
||||
expect(client.question.list).toHaveBeenCalledTimes(2)
|
||||
expect(promptAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("fails closed when pending blocker state cannot be read", async () => {
|
||||
const managed = state.addWorktree({ branch: "fix/blocker-error", path: worktree, parentBranch: "main" })
|
||||
state.addSession("ses_blocker_error", managed.id)
|
||||
const client = {
|
||||
session: {
|
||||
get: mock(async () => ({
|
||||
data: { id: "ses_blocker_error", directory: worktree, title: "Blocker error" } as Session,
|
||||
})),
|
||||
},
|
||||
permission: {
|
||||
list: mock(async () => ({ error: { message: "offline" } })),
|
||||
},
|
||||
question: {
|
||||
list: mock(async () => ({ data: noQuestions })),
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
|
||||
await expect(
|
||||
prompt({ client, root, state, sessionID: "ses_blocker_error", text: "Continue", messageID: "amr_error" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "host_error",
|
||||
message: "The managed session blockers could not be read",
|
||||
} satisfies Partial<OrchestrationError>)
|
||||
})
|
||||
|
||||
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 +338,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 +385,128 @@ describe("Agent Manager orchestration domain", () => {
|
||||
} satisfies Partial<OrchestrationError>)
|
||||
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<OrchestrationError>)
|
||||
expect(reply).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof Request>
|
||||
@@ -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<typeof Result>
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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<typeof Task>
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": "<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.
|
||||
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": "<id>", "answers": [["<label>"]] }`, providing one label array per question of the request in order and matching the advertised option labels exactly. Omit `questionID` only when that error or a prior answer error shows exactly one pending question; otherwise pass the ID it names. If the question was already answered by a person, the call fails instead of overwriting, and the reported state is stale rather than wrong. Treat answering as consequential: questions such as plan approval ("Ready to implement?") start implementation when answered affirmatively. Never guess labels for options you have not seen; if the pending question's text is unknown, prompt once to surface it in the error before answering.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("agent_manager tool", () => {
|
||||
expect(schema.allOf).toBeUndefined()
|
||||
const action = schema.properties?.action
|
||||
expect(action && typeof action === "object" ? action.anyOf?.[0] : undefined).toEqual(
|
||||
expect.objectContaining({ enum: ["list", "prompt", "stop", "move"] }),
|
||||
expect.objectContaining({ enum: ["list", "prompt", "stop", "move", "answer"] }),
|
||||
)
|
||||
expect(action && typeof action === "object" ? action.description : undefined).toContain("Use list first")
|
||||
expect(action && typeof action === "object" ? action.description : undefined).toContain("Never edit")
|
||||
@@ -186,6 +186,8 @@ describe("agent_manager tool", () => {
|
||||
"sessionID",
|
||||
"prompt",
|
||||
"sectionID",
|
||||
"questionID",
|
||||
"answers",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -196,7 +198,18 @@ describe("agent_manager tool", () => {
|
||||
const tool = await init()
|
||||
const schema = ToolJsonSchema.fromTool(tool)
|
||||
|
||||
for (const key of ["mode", "versions", "tasks", "action", "filter", "sessionID", "prompt", "sectionID"]) {
|
||||
for (const key of [
|
||||
"mode",
|
||||
"versions",
|
||||
"tasks",
|
||||
"action",
|
||||
"filter",
|
||||
"sessionID",
|
||||
"prompt",
|
||||
"sectionID",
|
||||
"questionID",
|
||||
"answers",
|
||||
]) {
|
||||
const property = schema.properties?.[key]
|
||||
const branches = property && typeof property === "object" ? property.anyOf : undefined
|
||||
expect(
|
||||
@@ -267,6 +280,82 @@ describe("agent_manager tool", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("routes a null-filled answer request to its action", () => {
|
||||
const blanks = { mode: null, versions: null, tasks: null, filter: null, sectionID: null, prompt: null }
|
||||
const decode = (input: unknown) => Schema.decodeUnknownSync(Params)(input) as Record<string, unknown>
|
||||
|
||||
expect(decode({ ...blanks, action: "answer", sessionID: "ses_target", answers: [["Yes"]] })).toEqual({
|
||||
action: "answer",
|
||||
sessionID: "ses_target",
|
||||
answers: [["Yes"]],
|
||||
})
|
||||
expect(
|
||||
decode({ ...blanks, action: "answer", sessionID: "ses_target", questionID: "que_1", answers: [["Yes"], []] }),
|
||||
).toEqual({
|
||||
action: "answer",
|
||||
sessionID: "ses_target",
|
||||
questionID: "que_1",
|
||||
answers: [["Yes"], []],
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects an answer without answers or with an empty label array", () => {
|
||||
const decode = (input: unknown) => Schema.decodeUnknownSync(Params)(input)
|
||||
expect(() => decode({ action: "answer", sessionID: "ses_target" })).toThrow()
|
||||
expect(() => decode({ action: "answer", sessionID: "ses_target", answers: [[""]] })).toThrow()
|
||||
expect(() => decode({ action: "answer", sessionID: "ses_target", answers: [] })).toThrow()
|
||||
})
|
||||
|
||||
test("answers one pending question with a separate mutation permission pattern", async () => {
|
||||
const requests: unknown[] = []
|
||||
const rt = makeRuntime("test", {
|
||||
request: (input) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(input)
|
||||
return {
|
||||
operation: "answer" as const,
|
||||
sessionID: SessionID.make("ses_target"),
|
||||
questionID: "que_1",
|
||||
resolved: true as const,
|
||||
}
|
||||
}),
|
||||
})
|
||||
const tool = await rt.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* Tool.init(yield* AgentManagerTool)
|
||||
}),
|
||||
)
|
||||
const permissions: unknown[] = []
|
||||
const result = await rt.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
tool.execute(
|
||||
{ action: "answer", sessionID: SessionID.make("ses_target"), answers: [["Yes"], ["detail"]] },
|
||||
{ ...ctx, ask: (input: unknown) => Effect.sync(() => permissions.push(input)) },
|
||||
),
|
||||
).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
expect(permissions).toEqual([
|
||||
{
|
||||
permission: "agent_manager",
|
||||
patterns: ["answer"],
|
||||
always: ["answer"],
|
||||
metadata: { action: "answer", sessionID: "ses_target" },
|
||||
},
|
||||
])
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
operation: "answer",
|
||||
sessionID: ctx.sessionID,
|
||||
targetSessionID: "ses_target",
|
||||
answers: [["Yes"], ["detail"]],
|
||||
},
|
||||
])
|
||||
expect(result.output).toContain("que_1")
|
||||
expect(result.metadata).toEqual(expect.objectContaining({ action: "answer", sessionID: "ses_target" }))
|
||||
await rt.dispose()
|
||||
})
|
||||
|
||||
test("asks for agent_manager permission", async () => {
|
||||
const tool = await init()
|
||||
const calls: unknown[] = []
|
||||
|
||||
@@ -8,6 +8,7 @@ describe("Agent Manager side-effect permissions", () => {
|
||||
expect(Permission.resolve("agent_manager", "prompt", broad).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "stop", broad).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "move", broad).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "answer", broad).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "local", broad).action).toBe("allow")
|
||||
expect(Permission.resolve("agent_manager", "worktree", broad).action).toBe("allow")
|
||||
})
|
||||
@@ -17,6 +18,7 @@ describe("Agent Manager side-effect permissions", () => {
|
||||
expect(Permission.resolve("agent_manager", "prompt", rules).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "stop", rules).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "move", rules).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "answer", rules).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("requires consent despite a saved wildcard approval", () => {
|
||||
@@ -25,12 +27,14 @@ describe("Agent Manager side-effect permissions", () => {
|
||||
expect(Permission.resolve("agent_manager", "prompt", rules, saved).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "stop", rules, saved).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "move", rules, saved).action).toBe("ask")
|
||||
expect(Permission.resolve("agent_manager", "answer", rules, saved).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("allows only explicit side-effect approvals", () => {
|
||||
const rules = Permission.fromConfig({ agent_manager: { prompt: "allow", stop: "allow", move: "allow" } })
|
||||
const rules = Permission.fromConfig({ agent_manager: { prompt: "allow", stop: "allow", move: "allow", answer: "allow" } })
|
||||
expect(Permission.resolve("agent_manager", "prompt", rules).action).toBe("allow")
|
||||
expect(Permission.resolve("agent_manager", "stop", rules).action).toBe("allow")
|
||||
expect(Permission.resolve("agent_manager", "move", rules).action).toBe("allow")
|
||||
expect(Permission.resolve("agent_manager", "answer", rules).action).toBe("allow")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -382,11 +382,23 @@ export type AgentManagerMoveRequest = {
|
||||
sectionID: string | null
|
||||
}
|
||||
|
||||
export type AgentManagerAnswers = Array<Array<string>>
|
||||
|
||||
export type AgentManagerAnswerRequest = {
|
||||
id: AgentManagerRequestId
|
||||
sessionID: string
|
||||
operation: "answer"
|
||||
targetSessionID: string
|
||||
questionID?: string
|
||||
answers: AgentManagerAnswers
|
||||
}
|
||||
|
||||
export type AgentManagerRequest =
|
||||
| AgentManagerOverviewRequest
|
||||
| AgentManagerPromptRequest
|
||||
| AgentManagerStopRequest
|
||||
| AgentManagerMoveRequest
|
||||
| AgentManagerAnswerRequest
|
||||
|
||||
export type NotebookRequestId = string
|
||||
|
||||
@@ -4379,11 +4391,19 @@ export type AgentManagerMoveResult = {
|
||||
moved: true
|
||||
}
|
||||
|
||||
export type AgentManagerAnswerResult = {
|
||||
operation: "answer"
|
||||
sessionID: string
|
||||
questionID: string
|
||||
resolved: true
|
||||
}
|
||||
|
||||
export type AgentManagerResult =
|
||||
| AgentManagerOverviewResult
|
||||
| AgentManagerPromptResult
|
||||
| AgentManagerStopResult
|
||||
| AgentManagerMoveResult
|
||||
| AgentManagerAnswerResult
|
||||
|
||||
export type AgentManagerFailure = {
|
||||
code:
|
||||
|
||||
@@ -3921,6 +3921,220 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/resource/read": {
|
||||
"post": {
|
||||
"tags": ["mcp"],
|
||||
"operationId": "mcp.readResource",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Resource content",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"mimeType": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"blob": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["uri"],
|
||||
"additionalProperties": false,
|
||||
"description": "Resource content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "BadRequest | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Read a resource from a connected MCP server by URI. Used by MCP Apps to load UI resources.",
|
||||
"summary": "Read MCP resource",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"server": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["uri", "server"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.mcp.readResource({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/mcp/call-tool": {
|
||||
"post": {
|
||||
"tags": ["mcp"],
|
||||
"operationId": "mcp.callTool",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Tool call result",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "array"
|
||||
},
|
||||
"isError": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"structuredContent": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false,
|
||||
"description": "Tool call result"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "BadRequest | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Call a tool on a connected MCP server. Used by MCP Apps for widget-initiated tool calls.",
|
||||
"summary": "Call MCP tool",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"arguments": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["server", "name"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.mcp.callTool({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/project": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
@@ -27115,6 +27329,50 @@
|
||||
"required": ["id", "sessionID", "operation", "targetSessionID", "sectionID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AgentManagerAnswers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"maxItems": 20
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 20
|
||||
},
|
||||
"AgentManagerAnswerRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/components/schemas/AgentManagerRequestID"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["answer"]
|
||||
},
|
||||
"targetSessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"questionID": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"answers": {
|
||||
"$ref": "#/components/schemas/AgentManagerAnswers"
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "operation", "targetSessionID", "answers"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AgentManagerRequest": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -27128,6 +27386,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentManagerMoveRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentManagerAnswerRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -39477,6 +39738,30 @@
|
||||
"required": ["operation", "sessionID", "sectionID", "moved"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AgentManagerAnswerResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["answer"]
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"questionID": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"resolved": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
}
|
||||
},
|
||||
"required": ["operation", "sessionID", "questionID", "resolved"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AgentManagerResult": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -39490,6 +39775,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentManagerMoveResult"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentManagerAnswerResult"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user