mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge pull request #11786 from Kilo-Org/vagabond-melody
feat(agent-manager): model and reasoning variant selection for tool-started sessions
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"@kilocode/sdk": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Allow Agent Manager chat tools to discover available models and reasoning variants by model name, then start each session with the chosen model and reasoning effort. Agent Manager resolves the provider for a named model automatically, preferring the provider behind the current default model and falling back to the Kilo Gateway.
|
||||
@@ -153,7 +153,9 @@ The tool supports two modes:
|
||||
| `worktree` | Creates one Agent Manager git worktree and session per task |
|
||||
| `local` | Creates Agent Manager sessions in the current workspace without git worktree isolation |
|
||||
|
||||
Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions.
|
||||
Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. A task with an initial prompt can also specify a `model` (by name, e.g. `Claude Opus 4.1`) and one of that model's reasoning `variant` values. Agent Manager resolves the provider for the chosen model, preferring the provider used by the current default model and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted to force a specific provider. Tasks without those fields use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions.
|
||||
|
||||
The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context.
|
||||
|
||||
The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested mode, so approving `worktree` does not automatically approve `local`.
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface ToolTask {
|
||||
prompt?: string
|
||||
name?: string
|
||||
branchName?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export interface ToolRequest {
|
||||
@@ -105,6 +107,8 @@ async function prompt(client: KiloClient, sid: string, dir: string, task: ToolTa
|
||||
sessionID: sid,
|
||||
directory: dir,
|
||||
parts: [{ type: "text", text: body }],
|
||||
model: task.model,
|
||||
variant: task.variant,
|
||||
snapshotInitialization: SNAPSHOT_INITIALIZATION,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
@@ -237,12 +241,30 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object"
|
||||
}
|
||||
|
||||
function model(value: unknown): ToolTask["model"] {
|
||||
if (!record(value)) return undefined
|
||||
const providerID = typeof value.providerID === "string" ? value.providerID.trim() : ""
|
||||
const modelID = typeof value.modelID === "string" ? value.modelID.trim() : ""
|
||||
if (!providerID || !modelID) return undefined
|
||||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
function task(value: unknown): ToolTask | undefined {
|
||||
if (!record(value)) return undefined
|
||||
const out: ToolTask = {}
|
||||
for (const key of ["prompt", "name", "branchName"] as const) {
|
||||
if (typeof value[key] === "string" && value[key].trim()) out[key] = value[key]
|
||||
if (Object.hasOwn(value, key) && typeof value[key] === "string" && value[key].trim()) out[key] = value[key]
|
||||
}
|
||||
const hasModel = Object.hasOwn(value, "model")
|
||||
const selected = hasModel ? model(value.model) : undefined
|
||||
if (hasModel && !selected) return undefined
|
||||
if (selected) out.model = selected
|
||||
|
||||
if (Object.hasOwn(value, "variant")) {
|
||||
if (!selected || typeof value.variant !== "string" || !value.variant.trim()) return undefined
|
||||
out.variant = value.variant.trim()
|
||||
}
|
||||
if (selected && !out.prompt) return undefined
|
||||
if (!out.prompt && !out.name && !out.branchName) return undefined
|
||||
return out
|
||||
}
|
||||
@@ -253,11 +275,9 @@ export function parseToolRequest(value: unknown): ToolRequest | undefined {
|
||||
const tasks = value.tasks
|
||||
if (mode !== "worktree" && mode !== "local") return undefined
|
||||
if (!Array.isArray(tasks) || tasks.length === 0) return undefined
|
||||
const parsed = tasks
|
||||
.slice(0, 20)
|
||||
.map(task)
|
||||
.filter((item): item is ToolTask => !!item)
|
||||
if (parsed.length === 0) return undefined
|
||||
const limited = tasks.slice(0, 20)
|
||||
const parsed = limited.map(task).filter((item): item is ToolTask => !!item)
|
||||
if (parsed.length !== limited.length) return undefined
|
||||
return {
|
||||
requestID: typeof value.requestID === "string" ? value.requestID : `am-${Date.now()}`,
|
||||
sessionID: typeof value.sessionID === "string" ? value.sessionID : undefined,
|
||||
|
||||
@@ -48,13 +48,41 @@ function deps(overrides: Partial<ToolDeps> = {}): ToolDeps {
|
||||
|
||||
describe("agent manager tool start", () => {
|
||||
it("parses tool start events defensively", () => {
|
||||
const parsed = parseToolRequest({ mode: "local", tasks: [{ prompt: "one" }] })
|
||||
const parsed = parseToolRequest({
|
||||
mode: "local",
|
||||
tasks: [
|
||||
{
|
||||
prompt: "one",
|
||||
model: { providerID: " test ", modelID: " reasoning/model " },
|
||||
variant: " high ",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(parsed?.requestID.startsWith("am-")).toBe(true)
|
||||
expect(parsed?.sessionID).toBeUndefined()
|
||||
expect(parsed?.directory).toBeUndefined()
|
||||
expect(parsed?.mode).toBe("local")
|
||||
expect(parsed?.versions).toBeUndefined()
|
||||
expect(parsed?.tasks).toEqual([{ prompt: "one" }])
|
||||
expect(parsed?.tasks).toEqual([
|
||||
{
|
||||
prompt: "one",
|
||||
model: { providerID: "test", modelID: "reasoning/model" },
|
||||
variant: "high",
|
||||
},
|
||||
])
|
||||
expect(
|
||||
parseToolRequest({
|
||||
mode: "local",
|
||||
tasks: [{ prompt: "one", model: { providerID: "", modelID: "model" }, variant: "high" }],
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(parseToolRequest({ mode: "local", tasks: [{ prompt: "one", variant: "high" }] })).toBeUndefined()
|
||||
expect(
|
||||
parseToolRequest({
|
||||
mode: "local",
|
||||
tasks: [{ name: "Prepared session", model: { providerID: "test", modelID: "model" } }],
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(parseToolRequest({ mode: "bad", tasks: [{ prompt: "one" }] })).toBeUndefined()
|
||||
expect(parseToolRequest({ mode: "local", tasks: [] })).toBeUndefined()
|
||||
expect(parseToolRequest({ mode: "local", tasks: [{}] })).toBeUndefined()
|
||||
@@ -71,7 +99,13 @@ describe("agent manager tool start", () => {
|
||||
const req: ToolRequest = {
|
||||
requestID: "am-1",
|
||||
mode: "local",
|
||||
tasks: [{ prompt: "Do work" }],
|
||||
tasks: [
|
||||
{
|
||||
prompt: "Do work",
|
||||
model: { providerID: "test", modelID: "reasoning/model" },
|
||||
variant: "high",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await startFromTool(c, req)
|
||||
@@ -92,6 +126,8 @@ describe("agent manager tool start", () => {
|
||||
sessionID: "s-local",
|
||||
directory: "/repo",
|
||||
parts: [{ type: "text", text: "Do work" }],
|
||||
model: { providerID: "test", modelID: "reasoning/model" },
|
||||
variant: "high",
|
||||
snapshotInitialization: "wait",
|
||||
}),
|
||||
{ throwOnError: true },
|
||||
@@ -99,8 +135,25 @@ describe("agent manager tool start", () => {
|
||||
})
|
||||
|
||||
it("starts worktree sessions through existing hooks", async () => {
|
||||
const c = deps()
|
||||
await startFromTool(c, { requestID: "am-2", mode: "worktree", tasks: [{ prompt: "Fix", branchName: "fix/one" }] })
|
||||
const client = {
|
||||
session: {
|
||||
create: mock(async () => ({ data: session("s-local") })),
|
||||
promptAsync: mock(async () => ({})),
|
||||
},
|
||||
}
|
||||
const c = deps({ getClient: () => client as never })
|
||||
await startFromTool(c, {
|
||||
requestID: "am-2",
|
||||
mode: "worktree",
|
||||
tasks: [
|
||||
{
|
||||
prompt: "Fix",
|
||||
branchName: "fix/one",
|
||||
model: { providerID: "test", modelID: "reasoning/model" },
|
||||
variant: "low",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(c.createWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branchName: "fix-one", name: "fix-one", label: "one" }),
|
||||
@@ -109,6 +162,15 @@ describe("agent manager tool start", () => {
|
||||
expect(c.createSessionInWorktree).toHaveBeenCalled()
|
||||
expect(c.registerWorktreeSession).toHaveBeenCalledWith("s-wt", "/repo/.kilo/worktrees/wt-1")
|
||||
expect(c.notifyReady).toHaveBeenCalled()
|
||||
expect(client.session.promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionID: "s-wt",
|
||||
directory: "/repo/.kilo/worktrees/wt-1",
|
||||
model: { providerID: "test", modelID: "reasoning/model" },
|
||||
variant: "low",
|
||||
}),
|
||||
{ throwOnError: true },
|
||||
)
|
||||
})
|
||||
|
||||
it("deduplicates repeated delivery of the same exact request", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// kilocode_change - new file
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -7,7 +8,15 @@ export const AgentManagerTask = Schema.Struct({
|
||||
prompt: Schema.optional(Schema.String).annotate({ description: "Initial prompt to send to the new session" }),
|
||||
name: Schema.optional(Schema.String).annotate({ description: "Short display name for the Agent Manager card" }),
|
||||
branchName: Schema.optional(Schema.String).annotate({ description: "Git branch name seed for worktree mode" }),
|
||||
model: Schema.optional(
|
||||
Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
}),
|
||||
),
|
||||
variant: Schema.optional(Schema.String),
|
||||
})
|
||||
export type AgentManagerTask = Schema.Schema.Type<typeof AgentManagerTask>
|
||||
|
||||
export const AgentManagerMode = Schema.Literals(["worktree", "local"])
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import type { ProviderID } from "@/provider/schema"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { matchesQuery } from "./model-search"
|
||||
import DESCRIPTION from "./agent-manager-models.txt"
|
||||
|
||||
const Params = Schema.Struct({
|
||||
query: Schema.optional(Schema.String).annotate({
|
||||
description: "Case-insensitive search across model names and IDs (e.g. 'opus', 'glm 5.2')",
|
||||
}),
|
||||
offset: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({
|
||||
description: "Result offset for pagination (default 0)",
|
||||
}),
|
||||
limit: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))).annotate({
|
||||
description: "Maximum models to return (default 20; hard-capped at 20 to keep output small)",
|
||||
}),
|
||||
})
|
||||
|
||||
const MAX_LIMIT = 20
|
||||
|
||||
type Entry = {
|
||||
name: string
|
||||
providers: string[]
|
||||
variants: string[]
|
||||
ids: string[]
|
||||
rank: number
|
||||
}
|
||||
|
||||
// Group models by display name so the agent picks a model, not a provider.
|
||||
// The same model is often offered by several providers under different IDs;
|
||||
// agent_manager resolves which provider to actually use at launch time.
|
||||
function entries(providers: Record<ProviderID, Provider.Info>): Entry[] {
|
||||
const byName = new Map<string, Entry>()
|
||||
for (const provider of Object.values(providers)) {
|
||||
for (const model of Object.values(provider.models)) {
|
||||
const entry = byName.get(model.name) ?? {
|
||||
name: model.name,
|
||||
providers: [],
|
||||
variants: [],
|
||||
ids: [],
|
||||
rank: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
if (!entry.providers.includes(provider.id)) entry.providers.push(provider.id)
|
||||
entry.ids.push(`${provider.id}/${model.id}`)
|
||||
for (const variant of Object.keys(model.variants ?? {})) {
|
||||
if (!entry.variants.includes(variant)) entry.variants.push(variant)
|
||||
}
|
||||
const index = typeof model.recommendedIndex === "number" ? model.recommendedIndex : Number.POSITIVE_INFINITY
|
||||
entry.rank = Math.min(entry.rank, index)
|
||||
byName.set(model.name, entry)
|
||||
}
|
||||
}
|
||||
return [...byName.values()].sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
function view(entry: Entry) {
|
||||
return { name: entry.name, providers: entry.providers, variants: entry.variants }
|
||||
}
|
||||
|
||||
export const AgentManagerModelsTool = Tool.define<
|
||||
typeof Params,
|
||||
{ count: number; total: number },
|
||||
Provider.Service,
|
||||
"agent_manager_models"
|
||||
>(
|
||||
"agent_manager_models",
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Params,
|
||||
execute: (params) =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* provider.list()
|
||||
const all = entries(providers)
|
||||
const query = params.query?.trim()
|
||||
const matches = query ? all.filter((entry) => matchesQuery([entry.name, ...entry.ids], query)) : all
|
||||
const offset = params.offset ?? 0
|
||||
const limit = Math.min(params.limit ?? MAX_LIMIT, MAX_LIMIT)
|
||||
const models = matches.slice(offset, offset + limit).map(view)
|
||||
const nextOffset = offset + models.length < matches.length ? offset + models.length : undefined
|
||||
return {
|
||||
title: query
|
||||
? `${matches.length} model${matches.length === 1 ? "" : "s"} matching "${params.query?.trim()}"`
|
||||
: `${matches.length} available models`,
|
||||
output: JSON.stringify({
|
||||
models,
|
||||
offset,
|
||||
total: matches.length,
|
||||
nextOffset,
|
||||
hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Agent Manager picks the provider, preferring the one you use by default.",
|
||||
}),
|
||||
metadata: { count: models.length, total: matches.length },
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
Search the models available to Agent Manager sessions and inspect their reasoning variants.
|
||||
|
||||
Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, because you select a model and Agent Manager chooses the provider for you. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name.
|
||||
|
||||
Each result includes the model name, its reasoning variant names, and the providers that offer it (informational only). Pass the model name back as the `agent_manager` task `model`. Agent Manager resolves the provider automatically, preferring the provider you use by default and falling back to the Kilo Gateway, so you do not need to choose a provider yourself.
|
||||
@@ -1,20 +1,35 @@
|
||||
// kilocode_change - new file
|
||||
import { Bus } from "@/bus"
|
||||
import { AgentManagerEvent } from "@/kilocode/agent-manager/event"
|
||||
import { AgentManagerEvent, type AgentManagerTask } from "@/kilocode/agent-manager/event"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { matchesQuery } from "./model-search"
|
||||
import DESCRIPTION from "./agent-manager.txt"
|
||||
|
||||
const Task = Schema.Struct({
|
||||
prompt: Schema.optional(Schema.String).annotate({ description: "Initial prompt to send to the new session" }),
|
||||
name: Schema.optional(Schema.String).annotate({ description: "Short display name for the Agent Manager card" }),
|
||||
branchName: Schema.optional(Schema.String).annotate({ description: "Git branch name seed for worktree mode" }),
|
||||
model: Schema.optional(Schema.String).annotate({
|
||||
description:
|
||||
"Model name from agent_manager_models (e.g. 'Claude Opus 4.1'). Agent Manager picks the provider. A qualified provider/model ID is also accepted to force a specific provider.",
|
||||
}),
|
||||
variant: Schema.optional(Schema.String).annotate({
|
||||
description: "Reasoning variant name for this model, from agent_manager_models",
|
||||
}),
|
||||
}).check(
|
||||
Schema.makeFilter((task: { prompt?: string; name?: string; branchName?: string }) =>
|
||||
Schema.makeFilter((task) =>
|
||||
task.prompt?.trim() || task.name?.trim() || task.branchName?.trim()
|
||||
? undefined
|
||||
: "Each task must include prompt, name, or branchName",
|
||||
),
|
||||
Schema.makeFilter((task) =>
|
||||
task.model?.trim() && !task.prompt?.trim() ? "A task model requires an initial prompt" : undefined,
|
||||
),
|
||||
Schema.makeFilter((task) =>
|
||||
task.variant?.trim() && !task.model?.trim() ? "A task variant requires a model" : undefined,
|
||||
),
|
||||
)
|
||||
|
||||
export const Params = Schema.Struct({
|
||||
@@ -30,25 +45,147 @@ export const Params = Schema.Struct({
|
||||
.annotate({ description: "Agent Manager sessions to start" }),
|
||||
})
|
||||
|
||||
type Input = Schema.Schema.Type<typeof Task>
|
||||
type Selected = { task?: AgentManagerTask; error?: string }
|
||||
type Candidate = { providerID: string; model: Provider.Info["models"][string] }
|
||||
|
||||
function candidates(providers: Record<string, Provider.Info>): Candidate[] {
|
||||
return Object.values(providers).flatMap((provider) =>
|
||||
Object.values(provider.models).map((model) => ({ providerID: provider.id, model })),
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve a model query to the candidates for a single logical model (possibly
|
||||
// offered by several providers). Exact id/name win first so a precise request is
|
||||
// never drowned out; otherwise fall back to lenient fuzzy matching so the agent
|
||||
// does not need the exact model name.
|
||||
function lookup(all: Candidate[], value: string): { pool: Candidate[]; names: string[] } {
|
||||
const query = value.toLowerCase()
|
||||
const exactId = all.filter((item) => `${item.providerID}/${item.model.id}`.toLowerCase() === query)
|
||||
const exactName = exactId.length ? exactId : all.filter((item) => item.model.name.toLowerCase() === query)
|
||||
const pool = exactName.length
|
||||
? exactName
|
||||
: all.filter((item) => matchesQuery([item.model.name, `${item.providerID}/${item.model.id}`], value))
|
||||
const names = [...new Set(pool.map((item) => item.model.name))]
|
||||
return { pool, names }
|
||||
}
|
||||
|
||||
// Closest model names to a query that found no full match, so a wrong guess is
|
||||
// self-correcting without a separate agent_manager_models round-trip.
|
||||
function suggest(all: Candidate[], value: string): string[] {
|
||||
const tokens = value
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
if (tokens.length === 0) return []
|
||||
const scored = new Map<string, number>()
|
||||
for (const item of all) {
|
||||
const text = `${item.model.name} ${item.providerID}/${item.model.id}`.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
||||
const score = tokens.filter((token) => text.includes(token)).length
|
||||
if (score > 0) scored.set(item.model.name, Math.max(scored.get(item.model.name) ?? 0, score))
|
||||
}
|
||||
return [...scored.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 3)
|
||||
.map((entry) => entry[0])
|
||||
}
|
||||
|
||||
// Prefer the provider the user already uses by default, then the Kilo Gateway,
|
||||
// so a model name resolves to the provider with the best chance of working
|
||||
// without forcing the agent to know about provider plumbing.
|
||||
function rank(providerID: string, preferred: string | undefined): number {
|
||||
if (providerID === preferred) return 0
|
||||
if (providerID === "kilo") return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
function select(task: Input, all: Candidate[], preferred: string | undefined, index: number): Selected {
|
||||
const base = {
|
||||
...(task.prompt !== undefined ? { prompt: task.prompt } : {}),
|
||||
...(task.name !== undefined ? { name: task.name } : {}),
|
||||
...(task.branchName !== undefined ? { branchName: task.branchName } : {}),
|
||||
}
|
||||
const value = task.model?.trim()
|
||||
if (!value) return { task: base }
|
||||
|
||||
const { pool, names } = lookup(all, value)
|
||||
if (pool.length === 0) {
|
||||
const close = suggest(all, value)
|
||||
const hint = close.length ? ` Closest matches: ${close.join(", ")}.` : ""
|
||||
return {
|
||||
error: `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`,
|
||||
}
|
||||
}
|
||||
if (names.length > 1) {
|
||||
return {
|
||||
error: `Task ${index + 1} model "${value}" is ambiguous and matches several models: ${names.slice(0, 5).join(", ")}. Use a more specific name.`,
|
||||
}
|
||||
}
|
||||
|
||||
const variant = task.variant?.trim()
|
||||
const eligible = variant
|
||||
? pool.filter((item) => item.model.variants && Object.hasOwn(item.model.variants, variant))
|
||||
: pool
|
||||
if (variant && eligible.length === 0) {
|
||||
const available = [...new Set(pool.flatMap((item) => Object.keys(item.model.variants ?? {})))]
|
||||
return {
|
||||
error: `Task ${index + 1} variant "${variant}" is not available for ${names[0]}. Available variants: ${available.join(", ") || "none"}`,
|
||||
}
|
||||
}
|
||||
|
||||
const chosen = [...eligible].sort((a, b) => rank(a.providerID, preferred) - rank(b.providerID, preferred))[0]!
|
||||
return {
|
||||
task: {
|
||||
...base,
|
||||
model: { providerID: chosen.model.providerID, modelID: chosen.model.id },
|
||||
...(variant ? { variant } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const AgentManagerTool = Tool.define<
|
||||
typeof Params,
|
||||
{ requestID: string; count: number },
|
||||
Bus.Service,
|
||||
{ requestID?: string; count: number },
|
||||
Bus.Service | Provider.Service,
|
||||
"agent_manager"
|
||||
>(
|
||||
"agent_manager",
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const provider = yield* Provider.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Params,
|
||||
execute: (params, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const need = params.tasks.some((task) => task.model?.trim())
|
||||
const all = need ? candidates(yield* provider.list()) : []
|
||||
const preferred = need
|
||||
? yield* provider.defaultModel().pipe(
|
||||
Effect.map((model) => model.providerID as string),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: undefined
|
||||
const selected = params.tasks.map((task, index) => select(task, all, preferred, index))
|
||||
const errors = selected.flatMap((item) => (item.error ? [item.error] : []))
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
title: "Invalid Agent Manager model selection",
|
||||
output: [
|
||||
"No Agent Manager sessions were requested.",
|
||||
...errors,
|
||||
"Use agent_manager_models to find available model names and reasoning variants.",
|
||||
].join("\n"),
|
||||
metadata: { count: 0 },
|
||||
}
|
||||
}
|
||||
const tasks = selected.flatMap((item) => (item.task ? [item.task] : []))
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "agent_manager",
|
||||
patterns: [params.mode],
|
||||
always: [params.mode],
|
||||
metadata: { mode: params.mode, count: params.tasks.length },
|
||||
metadata: { mode: params.mode, count: tasks.length },
|
||||
})
|
||||
|
||||
const requestID = `am-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
@@ -57,17 +194,30 @@ export const AgentManagerTool = Tool.define<
|
||||
sessionID: ctx.sessionID,
|
||||
mode: params.mode,
|
||||
versions: params.versions,
|
||||
tasks: params.tasks,
|
||||
tasks,
|
||||
})
|
||||
|
||||
// Echo how each named model resolved (provider + variant) so the agent
|
||||
// and the user can confirm the resolution without opening the session.
|
||||
const resolved = tasks.flatMap((task) => {
|
||||
if (!task.model) return []
|
||||
const name = all.find(
|
||||
(item) => item.providerID === task.model!.providerID && item.model.id === task.model!.modelID,
|
||||
)?.model.name
|
||||
const label = task.name?.trim() || task.branchName?.trim() || "session"
|
||||
const variant = task.variant ? ` · ${task.variant}` : ""
|
||||
return [`- ${label}: ${name ?? task.model.modelID} (${task.model.providerID})${variant}`]
|
||||
})
|
||||
|
||||
return {
|
||||
title: `Requested ${params.tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${params.tasks.length === 1 ? "" : "s"}`,
|
||||
title: `Requested ${tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${tasks.length === 1 ? "" : "s"}`,
|
||||
output: [
|
||||
`Requested ${params.tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${params.tasks.length === 1 ? "" : "s"}.`,
|
||||
`Requested ${tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${tasks.length === 1 ? "" : "s"}.`,
|
||||
`request_id: ${requestID}`,
|
||||
...(resolved.length ? ["Resolved models:", ...resolved] : []),
|
||||
"The VS Code extension will create the sessions asynchronously and show progress in Agent Manager.",
|
||||
].join("\n"),
|
||||
metadata: { requestID, count: params.tasks.length },
|
||||
metadata: { requestID, count: tasks.length },
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Modes:
|
||||
- `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog.
|
||||
- `local`: creates Agent Manager sessions in the current workspace directory without git worktree isolation.
|
||||
|
||||
Each task may provide a prompt, a short display name, and a branch name. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. The agent, model, reasoning, and base branch settings always use the normal defaults.
|
||||
Each task may provide a prompt, a short display name, a branch name, a `model`, and a model-specific reasoning `variant`. Specify `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider you use by default and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Tasks that omit `model` and `variant` use the normal defaults. The agent and base branch settings always use the normal defaults.
|
||||
|
||||
By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes.
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// kilocode_change - new file
|
||||
//
|
||||
// Lenient, dependency-free model matching shared by agent_manager and
|
||||
// agent_manager_models so the agent never needs an exact model name.
|
||||
//
|
||||
// A query matches when every alphanumeric token in the query appears in the
|
||||
// alphanumeric-collapsed haystack (model name + qualified ids). This is
|
||||
// order-independent and ignores punctuation/spacing, so "opus claude",
|
||||
// "glm5.2", and "gpt5" all match "Claude Opus 4.1", "Z.ai: GLM 5.2", and
|
||||
// "GPT-5.5" respectively. It is intentionally not typo-tolerant.
|
||||
|
||||
function collapse(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
||||
}
|
||||
|
||||
function queryTokens(query: string): string[] {
|
||||
return query
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function matchesQuery(haystacks: string[], query: string): boolean {
|
||||
const tokens = queryTokens(query)
|
||||
if (tokens.length === 0) return true
|
||||
// Join collapsed haystacks with a space so a token cannot match across the
|
||||
// boundary between two separate strings.
|
||||
const text = haystacks.map(collapse).join(" ")
|
||||
return tokens.every((token) => text.includes(token))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// kilocode_change - new file
|
||||
import { CodebaseSearchTool } from "../../tool/warpgrep"
|
||||
import { RecallTool } from "../../tool/recall"
|
||||
import { AgentManagerModelsTool } from "./agent-manager-models"
|
||||
import { AgentManagerTool } from "./agent-manager"
|
||||
import { BackgroundProcessTool } from "./background-process"
|
||||
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host"
|
||||
@@ -37,15 +38,16 @@ export namespace KiloToolRegistry {
|
||||
return Effect.gen(function* () {
|
||||
const codebase = yield* CodebaseSearchTool
|
||||
const recall = yield* RecallTool
|
||||
const managerModels = yield* AgentManagerModelsTool
|
||||
const manager = yield* AgentManagerTool
|
||||
const process = yield* BackgroundProcessTool
|
||||
if (!notebook) return { codebase, recall, manager, process }
|
||||
if (!notebook) return { codebase, recall, managerModels, manager, process }
|
||||
const tools = yield* Effect.all({
|
||||
notebookRead: NotebookReadTool,
|
||||
notebookEdit: NotebookEditTool,
|
||||
notebookExecute: NotebookExecuteTool,
|
||||
}).pipe(Effect.provideService(Notebook.Service, notebook))
|
||||
return { codebase, recall, manager, process, ...tools }
|
||||
return { codebase, recall, managerModels, manager, process, ...tools }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +57,7 @@ export namespace KiloToolRegistry {
|
||||
tools: {
|
||||
codebase: Tool.Info
|
||||
recall: Tool.Info
|
||||
managerModels: Tool.Info
|
||||
manager: Tool.Info
|
||||
process: Tool.Info
|
||||
notebookRead?: Tool.Info
|
||||
@@ -68,6 +71,7 @@ export namespace KiloToolRegistry {
|
||||
const base = yield* Effect.all({
|
||||
codebase: Tool.init(tools.codebase),
|
||||
recall: Tool.init(tools.recall),
|
||||
managerModels: Tool.init(tools.managerModels),
|
||||
manager: Tool.init(tools.manager),
|
||||
process: Tool.init(tools.process),
|
||||
})
|
||||
@@ -127,6 +131,7 @@ export namespace KiloToolRegistry {
|
||||
codebase: Tool.Def
|
||||
semantic?: Tool.Def
|
||||
recall: Tool.Def
|
||||
managerModels: Tool.Def
|
||||
manager: Tool.Def
|
||||
process: Tool.Def
|
||||
notebookRead?: Tool.Def
|
||||
@@ -140,8 +145,8 @@ export namespace KiloToolRegistry {
|
||||
...(tools.semantic ? [tools.semantic] : []),
|
||||
tools.recall,
|
||||
...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []),
|
||||
// The extension is the only client that can consume the Agent Manager start event.
|
||||
...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []),
|
||||
// Agent Manager tools are useful only when the extension can create and display their sessions.
|
||||
...(Flag.KILO_CLIENT === "vscode" ? [tools.managerModels, tools.manager] : []),
|
||||
...(Flag.KILO_CLIENT === "vscode" &&
|
||||
cfg.experimental?.native_notebook_tools === true &&
|
||||
tools.notebookRead &&
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { AgentManagerModelsTool } from "../../src/kilocode/tool/agent-manager-models"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
|
||||
const bulk = Object.fromEntries(
|
||||
Array.from({ length: 22 }, (_, index) => {
|
||||
const id = `bulk-${String(index + 1).padStart(2, "0")}`
|
||||
return [id, { id, providerID: "gamma", name: `Bulk ${String(index + 1).padStart(2, "0")}` }]
|
||||
}),
|
||||
)
|
||||
|
||||
const providers = {
|
||||
alpha: {
|
||||
id: "alpha",
|
||||
name: "Alpha Provider",
|
||||
models: {
|
||||
"reasoning/one": {
|
||||
id: "reasoning/one",
|
||||
providerID: "alpha",
|
||||
name: "Reasoning One",
|
||||
variants: { low: {}, high: {} },
|
||||
},
|
||||
"reasoning/two": { id: "reasoning/two", providerID: "alpha", name: "Reasoning Two", variants: { medium: {} } },
|
||||
basic: { id: "basic", providerID: "alpha", name: "Basic" },
|
||||
shared: { id: "shared", providerID: "alpha", name: "Shared", variants: { low: {} } },
|
||||
},
|
||||
} as unknown as Provider.Info,
|
||||
beta: {
|
||||
id: "beta",
|
||||
name: "Beta Provider",
|
||||
models: {
|
||||
other: { id: "other", providerID: "beta", name: "Other" },
|
||||
shared: { id: "shared", providerID: "beta", name: "Shared", variants: { high: {} } },
|
||||
},
|
||||
} as unknown as Provider.Info,
|
||||
gamma: {
|
||||
id: "gamma",
|
||||
name: "Gamma Provider",
|
||||
models: bulk,
|
||||
} as unknown as Provider.Info,
|
||||
}
|
||||
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Layer.mock(Provider.Service, { list: () => Effect.succeed(providers) }),
|
||||
),
|
||||
)
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("msg_test"),
|
||||
callID: "call_agent_manager_models",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
function run(params: Record<string, unknown>) {
|
||||
return runtime.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const tool = yield* Tool.init(yield* AgentManagerModelsTool)
|
||||
return yield* tool.execute(params, ctx)
|
||||
}),
|
||||
).pipe(Effect.scoped),
|
||||
)
|
||||
}
|
||||
|
||||
function json<T>(value: string): T {
|
||||
return JSON.parse(value) as T
|
||||
}
|
||||
|
||||
describe("agent_manager_models tool", () => {
|
||||
test("returns models grouped by name, capped at 20", async () => {
|
||||
const result = await run({})
|
||||
const output = json<{ models: Array<{ name: string }>; total: number; nextOffset?: number }>(result.output)
|
||||
|
||||
// 5 named models (Basic, Other, Reasoning One/Two, Shared) + 22 bulk = 27 distinct names.
|
||||
expect(output.total).toBe(27)
|
||||
expect(output.models).toHaveLength(20)
|
||||
expect(output.nextOffset).toBe(20)
|
||||
expect(result.metadata).toMatchObject({ count: 20, total: 27 })
|
||||
})
|
||||
|
||||
test("deduplicates a model offered by several providers and unions variants", async () => {
|
||||
const result = await run({ query: "shared" })
|
||||
const output = json<{ models: Array<{ name: string; providers: string[]; variants: string[] }> }>(result.output)
|
||||
|
||||
expect(output.models).toEqual([{ name: "Shared", providers: ["alpha", "beta"], variants: ["low", "high"] }])
|
||||
})
|
||||
|
||||
test("searches by name with bounded pagination and variant names", async () => {
|
||||
const result = await run({ query: "reasoning", limit: 1 })
|
||||
const output = json<{
|
||||
models: Array<{ name: string; providers: string[]; variants: string[] }>
|
||||
total: number
|
||||
nextOffset?: number
|
||||
}>(result.output)
|
||||
|
||||
expect(output.total).toBe(2)
|
||||
expect(output.nextOffset).toBe(1)
|
||||
expect(output.models).toEqual([{ name: "Reasoning One", providers: ["alpha"], variants: ["low", "high"] }])
|
||||
})
|
||||
|
||||
test("matches a qualified provider/model id whose model id contains slashes", async () => {
|
||||
const result = await run({ query: "alpha/reasoning/two" })
|
||||
const output = json<{ models: Array<{ name: string }>; total: number }>(result.output)
|
||||
|
||||
expect(output.total).toBe(1)
|
||||
expect(output.models[0]?.name).toBe("Reasoning Two")
|
||||
})
|
||||
|
||||
test("matches leniently: order-independent, punctuation- and case-insensitive", async () => {
|
||||
// "reasoning one" reordered, lowercased, no exact substring of the display name order.
|
||||
const reordered = json<{ models: Array<{ name: string }>; total: number }>(
|
||||
(await run({ query: "one reasoning" })).output,
|
||||
)
|
||||
expect(reordered.models.map((m) => m.name)).toEqual(["Reasoning One"])
|
||||
|
||||
// Collapsed across punctuation/spacing.
|
||||
const collapsed = json<{ models: Array<{ name: string }> }>((await run({ query: "reasoningtwo" })).output)
|
||||
expect(collapsed.models.map((m) => m.name)).toEqual(["Reasoning Two"])
|
||||
})
|
||||
|
||||
test("hard-caps results at 20 even when a larger limit is requested", async () => {
|
||||
const result = await run({ query: "bulk", limit: 100 })
|
||||
const output = json<{ models: unknown[]; total: number; nextOffset?: number }>(result.output)
|
||||
|
||||
expect(output.total).toBe(22)
|
||||
expect(output.models).toHaveLength(20)
|
||||
expect(output.nextOffset).toBe(20)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Effect, Layer, ManagedRuntime, Queue } from "effect"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AgentManagerTool } from "../../src/kilocode/tool/agent-manager"
|
||||
import { AgentManagerEvent, type AgentManagerStart } from "../../src/kilocode/agent-manager/event"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer, Bus.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
const providers = {
|
||||
test: {
|
||||
id: "test",
|
||||
name: "Test Provider",
|
||||
models: {
|
||||
"reasoning/model": {
|
||||
id: "reasoning/model",
|
||||
providerID: "test",
|
||||
name: "Reasoning Model",
|
||||
variants: { low: {}, high: {} },
|
||||
},
|
||||
// "Shared" is also offered by the kilo provider, to exercise provider resolution.
|
||||
"test/shared": { id: "test/shared", providerID: "test", name: "Shared", variants: { low: {}, high: {} } },
|
||||
},
|
||||
} as unknown as Provider.Info,
|
||||
kilo: {
|
||||
id: "kilo",
|
||||
name: "Kilo Gateway",
|
||||
models: {
|
||||
"kilo/shared": { id: "kilo/shared", providerID: "kilo", name: "Shared", variants: { low: {} } },
|
||||
"kilo/only": { id: "kilo/only", providerID: "kilo", name: "Gateway Only", variants: { low: {} } },
|
||||
},
|
||||
} as unknown as Provider.Info,
|
||||
zeta: {
|
||||
id: "zeta",
|
||||
name: "Zeta Provider",
|
||||
models: {
|
||||
"zeta/only": { id: "zeta/only", providerID: "zeta", name: "Gateway Only", variants: { low: {} } },
|
||||
},
|
||||
} as unknown as Provider.Info,
|
||||
}
|
||||
|
||||
// Default provider is `test`, so resolution should prefer test, then kilo, then others.
|
||||
function makeRuntime(defaultProviderID = "test") {
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Layer.mock(Provider.Service, {
|
||||
list: () => Effect.succeed(providers),
|
||||
defaultModel: () => Effect.succeed({ providerID: defaultProviderID, modelID: "reasoning/model" }) as never,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const runtime = makeRuntime()
|
||||
|
||||
async function init() {
|
||||
return runtime.runPromise(
|
||||
@@ -33,6 +81,26 @@ const ctx = {
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
// Run one local task and return the resolved task published on the Start event.
|
||||
function publish(rt: ReturnType<typeof makeRuntime>, task: Record<string, unknown>) {
|
||||
return rt.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const tool = yield* Tool.init(yield* AgentManagerTool)
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* Queue.unbounded<AgentManagerStart>()
|
||||
const off = yield* bus.subscribeCallback(AgentManagerEvent.Start, (item) =>
|
||||
Queue.offerUnsafe(events, item.properties),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
yield* tool.execute({ mode: "local", tasks: [task] }, { ...ctx, ask: () => Effect.void })
|
||||
const event = yield* Queue.take(events).pipe(Effect.timeout("2 seconds"))
|
||||
return event.tasks[0]
|
||||
}),
|
||||
).pipe(Effect.scoped),
|
||||
)
|
||||
}
|
||||
|
||||
describe("agent_manager tool", () => {
|
||||
test("asks for agent_manager permission", async () => {
|
||||
const tool = await init()
|
||||
@@ -57,6 +125,157 @@ describe("agent_manager tool", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("publishes validated model and variant selections", async () => {
|
||||
const tool = await init()
|
||||
|
||||
const event = await runtime.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* Queue.unbounded<AgentManagerStart>()
|
||||
const off = yield* bus.subscribeCallback(AgentManagerEvent.Start, (item) =>
|
||||
Queue.offerUnsafe(events, item.properties),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
|
||||
yield* tool.execute(
|
||||
{
|
||||
mode: "local",
|
||||
tasks: [{ prompt: "Fix issue", model: "test/reasoning/model", variant: "high" }],
|
||||
},
|
||||
{ ...ctx, ask: () => Effect.void },
|
||||
)
|
||||
return yield* Queue.take(events).pipe(Effect.timeout("2 seconds"))
|
||||
}),
|
||||
).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
expect(event.tasks).toHaveLength(1)
|
||||
expect(event.tasks[0]?.prompt).toBe("Fix issue")
|
||||
expect(String(event.tasks[0]?.model?.providerID)).toBe("test")
|
||||
expect(String(event.tasks[0]?.model?.modelID)).toBe("reasoning/model")
|
||||
expect(event.tasks[0]?.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("resolves a model by name to the preferred (default) provider", async () => {
|
||||
const task = await publish(runtime, { prompt: "Fix", model: "Shared", variant: "low" })
|
||||
expect(String(task?.model?.providerID)).toBe("test")
|
||||
expect(String(task?.model?.modelID)).toBe("test/shared")
|
||||
expect(task?.variant).toBe("low")
|
||||
})
|
||||
|
||||
test("uses the provider of a different default model when that is the user's choice", async () => {
|
||||
const rt = makeRuntime("kilo")
|
||||
const task = await publish(rt, { prompt: "Fix", model: "Shared", variant: "low" })
|
||||
expect(String(task?.model?.providerID)).toBe("kilo")
|
||||
expect(String(task?.model?.modelID)).toBe("kilo/shared")
|
||||
await rt.dispose()
|
||||
})
|
||||
|
||||
test("resolves an approximate, reordered model name", async () => {
|
||||
const task = await publish(runtime, { prompt: "Fix", model: "model reasoning" })
|
||||
expect(String(task?.model?.providerID)).toBe("test")
|
||||
expect(String(task?.model?.modelID)).toBe("reasoning/model")
|
||||
})
|
||||
|
||||
test("suggests close model names when a guess finds no match", async () => {
|
||||
const tool = await init()
|
||||
const result = await runtime.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
tool.execute(
|
||||
{ mode: "local", tasks: [{ prompt: "Fix", model: "reasoning supreme" }] },
|
||||
{ ...ctx, ask: () => Effect.void },
|
||||
),
|
||||
).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Closest matches:")
|
||||
expect(result.output).toContain("Reasoning Model")
|
||||
expect(result.metadata.count).toBe(0)
|
||||
})
|
||||
|
||||
test("echoes how each named model resolved", async () => {
|
||||
const tool = await init()
|
||||
const result = await runtime.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
tool.execute(
|
||||
{ mode: "local", tasks: [{ prompt: "Fix", name: "Smoke", model: "Shared", variant: "high" }] },
|
||||
{ ...ctx, ask: () => Effect.void },
|
||||
),
|
||||
).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Resolved models:")
|
||||
expect(result.output).toContain("- Smoke: Shared (test) · high")
|
||||
})
|
||||
|
||||
test("falls back to the kilo gateway when the preferred provider lacks the model", async () => {
|
||||
const task = await publish(runtime, { prompt: "Fix", model: "Gateway Only" })
|
||||
// Default provider `test` does not offer it; kilo is preferred over zeta.
|
||||
expect(String(task?.model?.providerID)).toBe("kilo")
|
||||
})
|
||||
|
||||
test("narrows to a provider that supports the requested variant", async () => {
|
||||
const rt = makeRuntime("kilo")
|
||||
// kilo is preferred, but only `test`'s Shared has the `high` variant.
|
||||
const task = await publish(rt, { prompt: "Fix", model: "Shared", variant: "high" })
|
||||
expect(String(task?.model?.providerID)).toBe("test")
|
||||
expect(task?.variant).toBe("high")
|
||||
await rt.dispose()
|
||||
})
|
||||
|
||||
test("rejects unavailable variants before requesting permission", async () => {
|
||||
const tool = await init()
|
||||
const calls: unknown[] = []
|
||||
|
||||
const result = await runtime.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
tool.execute(
|
||||
{
|
||||
mode: "local",
|
||||
tasks: [{ prompt: "Fix issue", model: "test/reasoning/model", variant: "toString" }],
|
||||
},
|
||||
{ ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) },
|
||||
),
|
||||
).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([])
|
||||
expect(result.output).toContain("Available variants: low, high")
|
||||
expect(result.metadata.count).toBe(0)
|
||||
})
|
||||
|
||||
test("rejects inherited provider and model properties", async () => {
|
||||
const tool = await init()
|
||||
|
||||
const result = await runtime.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
tool.execute(
|
||||
{ mode: "local", tasks: [{ prompt: "Fix issue", model: "__proto__/constructor" }] },
|
||||
{ ...ctx, ask: () => Effect.void },
|
||||
),
|
||||
).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("model is not available: __proto__/constructor")
|
||||
expect(result.metadata.count).toBe(0)
|
||||
})
|
||||
|
||||
test("requires an initial prompt for model selections", async () => {
|
||||
const tool = await init()
|
||||
|
||||
await expect(
|
||||
runtime.runPromise(
|
||||
provideTmpdirInstance(() =>
|
||||
tool.execute(
|
||||
{ mode: "local", tasks: [{ name: "Prepared session", model: "test/reasoning/model" }] },
|
||||
{ ...ctx, ask: () => Effect.void },
|
||||
),
|
||||
).pipe(Effect.scoped),
|
||||
),
|
||||
).rejects.toThrow("A task model requires an initial prompt")
|
||||
})
|
||||
|
||||
test("rejects empty tasks", async () => {
|
||||
const tool = await init()
|
||||
|
||||
|
||||
@@ -167,6 +167,8 @@ describe("Kilo PublicApi OpenAPI contract", () => {
|
||||
const profile = response(KiloGatewayPaths.profile)?.properties
|
||||
expect(profile?.balance).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
|
||||
expect(profile?.kiloPass).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
|
||||
const pass = profile?.kiloPass?.anyOf?.find((item) => item.type === "object")?.properties
|
||||
expect(pass?.nextBillingAt).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
|
||||
expect(profile?.currentOrgId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
|
||||
|
||||
const auth = response(KiloGatewayPaths.authStatus)?.properties
|
||||
|
||||
@@ -37,6 +37,7 @@ function infos() {
|
||||
return {
|
||||
codebase: info("codebase_search"),
|
||||
recall: info("recall"),
|
||||
managerModels: info("agent_manager_models"),
|
||||
manager: info("agent_manager"),
|
||||
process: info("background_process"),
|
||||
notebookRead: info("notebook_read"),
|
||||
|
||||
@@ -201,6 +201,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
codebase: def("codebase_search"),
|
||||
semantic: def("semantic_search"),
|
||||
recall: def("recall"),
|
||||
managerModels: def("agent_manager_models"),
|
||||
manager: def("agent_manager"),
|
||||
process: def("background_process"),
|
||||
notebookRead: def("notebook_read"),
|
||||
@@ -221,7 +222,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
|
||||
process.env["KILO_CLIENT"] = "vscode"
|
||||
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
|
||||
["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"],
|
||||
["codebase_search", "semantic_search", "recall", "background_process", "agent_manager_models", "agent_manager"],
|
||||
)
|
||||
expect(
|
||||
KiloToolRegistry.extra(tools, {
|
||||
@@ -232,6 +233,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"semantic_search",
|
||||
"recall",
|
||||
"background_process",
|
||||
"agent_manager_models",
|
||||
"agent_manager",
|
||||
"notebook_read",
|
||||
"notebook_edit",
|
||||
@@ -240,6 +242,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([
|
||||
"recall",
|
||||
"background_process",
|
||||
"agent_manager_models",
|
||||
"agent_manager",
|
||||
])
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ function infos() {
|
||||
return {
|
||||
codebase: info("codebase_search"),
|
||||
recall: info("recall"),
|
||||
managerModels: info("agent_manager_models"),
|
||||
manager: info("agent_manager"),
|
||||
process: info("background_process"),
|
||||
notebookRead: info("notebook_read"),
|
||||
|
||||
@@ -3322,6 +3322,11 @@ export type EventKilocodeAgentManagerStart = {
|
||||
prompt?: string
|
||||
name?: string
|
||||
branchName?: string
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
variant?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27800,6 +27800,22 @@
|
||||
},
|
||||
"branchName": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["providerID", "modelID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user