mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
d89b1b6e16
* feat(agent-requirements): gate agents on declared requirements * feat(cli): add agent requirements preflight helpers Introduce a Kilo-owned CLI requirements module that preflights agent declarations before session creation. The helper resolves requirement status via the SDK, blocks agents with unmet skills, errored MCPs, or VS Code extension dependencies, and produces grouped actionable guidance for terminal users. Includes planning documents and a focused test suite covering all blocking and allow paths. * chore: update kilo-vscode visual regression baselines * fix(vscode): guard extension subscription and suppress empty invalidations Wrap vscode.extensions.onDidChange in a typeof check so the subscribe callback is undefined in environments where the API is unavailable. Skip posting agentRequirementsInvalidated when the controller cache is already empty to avoid spurious messages to the webview. Update the associated test to seed cache state before asserting on the clear-triggered invalidation flow. * refactor(agent-requirements): replace numeric generations with object-identity tokens Switch the controller's supersession tracking from incrementing counters to ephemeral object references, ensuring stale tokens are cleaned up on both success and failure paths. This eliminates a class of race conditions where cleared counters could alias with fresh requests. Conditionally emit the "Install the required skills..." footer in the CLI formatter only when skills or MCPs are actually present, preventing misleading guidance for extension-only requirement failures. * test(httpapi): add exercise scenario for agent requirements endpoint Cover the GET /kilocode/agent/requirements route in the HTTP API exercise harness, asserting that the response echoes the requested agent, uses the routed workspace directory, and correctly reports the disabled state with empty skills/mcps/extensions arrays. * feat(agent-requirements): enforce guard across all clients and relax ID validation Lift the VS Code–only restriction from the requirements guard so that blocked skills/MCPs fail for CLI and other clients as well. VS Code extension requirements remain client-specific. The evaluate function now accepts pre-decoded requirements as an explicit input rather than decoding internally, and the schema switches from the strict alphanumeric ID pattern to a permissive non-whitespace Name pattern allowing slashes and spaces in skill/MCP identifiers. Update prompt interfaces to surface RequirementBlockedError in the typed error channel and preserve "ready" results in the webview cache alongside blocked/error states. * chore(opencode): fix requirement guard annotations --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
245 lines
8.9 KiB
TypeScript
245 lines
8.9 KiB
TypeScript
export * as ConfigAgent from "./agent"
|
|
|
|
import path from "path"
|
|
import { Schema, SchemaGetter } from "effect"
|
|
import { PositiveInt } from "@opencode-ai/core/schema"
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
import { Glob } from "@opencode-ai/core/util/glob"
|
|
import { configEntryNameFromPath } from "./entry-name"
|
|
import { ConfigError } from "./error"
|
|
import * as ConfigMarkdown from "./markdown"
|
|
import { ConfigModelID } from "./model-id"
|
|
import { ConfigParse } from "./parse"
|
|
import { ConfigPermission } from "./permission"
|
|
import { ConfigVariable } from "./variable" // kilocode_change
|
|
// kilocode_change start
|
|
import { Bus } from "@/bus"
|
|
import { NamedError } from "@opencode-ai/core/util/error"
|
|
import { KilocodeConfig } from "@/kilocode/config/config"
|
|
import type { Warning } from "./config"
|
|
import { Requirements } from "@/kilocode/agent-requirements"
|
|
// kilocode_change end
|
|
|
|
const log = Log.create({ service: "config" })
|
|
|
|
const Color = Schema.Union([
|
|
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
|
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
|
])
|
|
|
|
const AgentSchema = Schema.StructWithRest(
|
|
Schema.Struct({
|
|
model: Schema.optional(Schema.NullOr(ConfigModelID)), // kilocode_change - nullable for delete sentinel
|
|
// kilocode_change start - nullable for delete sentinel
|
|
variant: Schema.optional(Schema.NullOr(Schema.String)).annotate({
|
|
description: "Default model variant for this agent (applies only when using the agent's configured model).",
|
|
}),
|
|
// kilocode_change end
|
|
temperature: Schema.optional(Schema.NullOr(Schema.Finite)), // kilocode_change - nullable for delete sentinel
|
|
top_p: Schema.optional(Schema.NullOr(Schema.Finite)), // kilocode_change - nullable for delete sentinel
|
|
prompt: Schema.optional(Schema.NullOr(Schema.String)), // kilocode_change - nullable for delete sentinel
|
|
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
|
description: "@deprecated Use 'permission' field instead",
|
|
}),
|
|
disable: Schema.optional(Schema.Boolean),
|
|
// kilocode_change start - nullable for delete sentinel
|
|
description: Schema.optional(Schema.NullOr(Schema.String)).annotate({
|
|
description: "Description of when to use the agent",
|
|
}),
|
|
// kilocode_change end
|
|
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
|
|
hidden: Schema.optional(Schema.Boolean).annotate({
|
|
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
|
|
}),
|
|
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
|
color: Schema.optional(Color).annotate({
|
|
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
|
|
}),
|
|
// kilocode_change start - nullable for delete sentinel
|
|
steps: Schema.optional(Schema.NullOr(PositiveInt)).annotate({
|
|
description: "Maximum number of agentic iterations before forcing text-only response",
|
|
}),
|
|
// kilocode_change end
|
|
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
|
|
permission: Schema.optional(ConfigPermission.Info),
|
|
requirements: Schema.optional(Requirements), // kilocode_change
|
|
}),
|
|
[Schema.Record(Schema.String, Schema.Any)],
|
|
)
|
|
|
|
const KNOWN_KEYS = new Set([
|
|
"name",
|
|
"model",
|
|
"variant",
|
|
"prompt",
|
|
"description",
|
|
"temperature",
|
|
"top_p",
|
|
"mode",
|
|
"hidden",
|
|
"color",
|
|
"steps",
|
|
"maxSteps",
|
|
"options",
|
|
"permission",
|
|
"disable",
|
|
"tools",
|
|
"requirements", // kilocode_change
|
|
])
|
|
|
|
// Post-parse normalisation:
|
|
// - Promote any unknown-but-present keys into `options` so they survive the
|
|
// round-trip in a well-known field.
|
|
// - Translate the deprecated `tools: { name: boolean }` map into the new
|
|
// `permission` shape (write-adjacent tools collapse into `permission.edit`).
|
|
// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
|
|
const normalize = (agent: Schema.Schema.Type<typeof AgentSchema>): Schema.Schema.Type<typeof AgentSchema> => {
|
|
const options: Record<string, unknown> = { ...agent.options }
|
|
for (const [key, value] of Object.entries(agent)) {
|
|
if (!KNOWN_KEYS.has(key)) options[key] = value
|
|
}
|
|
|
|
const permission: ConfigPermission.Info = {}
|
|
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
|
const action = enabled ? "allow" : "deny"
|
|
if (tool === "write" || tool === "edit" || tool === "patch") {
|
|
permission.edit = action
|
|
continue
|
|
}
|
|
permission[tool] = action
|
|
}
|
|
globalThis.Object.assign(permission, agent.permission)
|
|
|
|
// kilocode_change start - preserve null delete sentinel (?? would collapse null to maxSteps)
|
|
const steps = agent.steps !== undefined ? agent.steps : agent.maxSteps
|
|
return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }
|
|
// kilocode_change end
|
|
}
|
|
|
|
export const Info = AgentSchema.pipe(
|
|
Schema.decodeTo(AgentSchema, {
|
|
decode: SchemaGetter.transform(normalize),
|
|
encode: SchemaGetter.passthrough({ strict: false }),
|
|
}),
|
|
).annotate({ identifier: "AgentConfig" })
|
|
export type Info = Schema.Schema.Type<typeof Info>
|
|
|
|
// kilocode_change start
|
|
export async function load(dir: string, warnings?: Warning[]) {
|
|
// kilocode_change end
|
|
const result: Record<string, Info> = {}
|
|
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
|
|
cwd: dir,
|
|
absolute: true,
|
|
dot: true,
|
|
symlink: true,
|
|
})) {
|
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
? err.data.message
|
|
: `Failed to parse agent ${item}`
|
|
// kilocode_change start
|
|
if (warnings) warnings.push({ path: item, message })
|
|
try {
|
|
const { capture } = await import("@/kilocode/instance")
|
|
const ctx = capture()
|
|
if (ctx) {
|
|
const { Session } = await import("@/session/session")
|
|
await Bus.publish(ctx, Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
}
|
|
} catch (error) {
|
|
log.warn("could not publish session error", { message, err: error })
|
|
}
|
|
// kilocode_change end
|
|
log.error("failed to load agent", { agent: item, err })
|
|
return undefined
|
|
})
|
|
if (!md) continue
|
|
|
|
const name = configEntryNameFromPath(path.relative(dir, item), ["agent/", "agents/"])
|
|
|
|
// kilocode_change start - substitute agent prompt variables relative to the agent file
|
|
const prompt = await ConfigVariable.substitute({
|
|
text: md.content.trim(),
|
|
type: "virtual",
|
|
dir: path.dirname(item),
|
|
source: item,
|
|
missing: "empty",
|
|
escapeJson: false,
|
|
})
|
|
const config = {
|
|
name,
|
|
...md.data,
|
|
prompt,
|
|
}
|
|
// kilocode_change end
|
|
// kilocode_change start - use Effect schema (propertyOrder: original) + non-fatal handleInvalid
|
|
try {
|
|
result[config.name] = ConfigParse.schema(Info, config, item) as Info
|
|
} catch (err) {
|
|
if (ConfigError.InvalidError.isInstance(err)) {
|
|
await KilocodeConfig.handleInvalid("agent", item, err.data.issues ?? [], err, warnings)
|
|
continue
|
|
}
|
|
throw err
|
|
}
|
|
// kilocode_change end
|
|
}
|
|
return result
|
|
}
|
|
|
|
// kilocode_change start
|
|
export async function loadMode(dir: string, warnings?: Warning[]) {
|
|
// kilocode_change end
|
|
const result: Record<string, Info> = {}
|
|
for (const item of await Glob.scan("{mode,modes}/*.md", {
|
|
cwd: dir,
|
|
absolute: true,
|
|
dot: true,
|
|
symlink: true,
|
|
})) {
|
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
? err.data.message
|
|
: `Failed to parse mode ${item}`
|
|
// kilocode_change start
|
|
if (warnings) warnings.push({ path: item, message })
|
|
try {
|
|
const { capture } = await import("@/kilocode/instance")
|
|
const ctx = capture()
|
|
if (ctx) {
|
|
const { Session } = await import("@/session/session")
|
|
await Bus.publish(ctx, Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
}
|
|
} catch (error) {
|
|
log.warn("could not publish session error", { message, err: error })
|
|
}
|
|
// kilocode_change end
|
|
log.error("failed to load mode", { mode: item, err })
|
|
return undefined
|
|
})
|
|
if (!md) continue
|
|
|
|
const config = {
|
|
name: configEntryNameFromPath(path.relative(dir, item), ["mode/", "modes/"]),
|
|
...md.data,
|
|
prompt: md.content.trim(),
|
|
}
|
|
// kilocode_change start - use Effect schema (propertyOrder: original) + non-fatal handleInvalid
|
|
try {
|
|
result[config.name] = {
|
|
...(ConfigParse.schema(Info, config, item) as Info),
|
|
mode: "primary" as const,
|
|
}
|
|
} catch (err) {
|
|
if (ConfigError.InvalidError.isInstance(err)) {
|
|
await KilocodeConfig.handleInvalid("agent", item, err.data.issues ?? [], err, warnings)
|
|
continue
|
|
}
|
|
throw err
|
|
}
|
|
// kilocode_change end
|
|
}
|
|
return result
|
|
}
|