diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index a137b5ed7..a56750f51 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -270,7 +270,26 @@ Attention, again, you are just the planner, so though you can read any files and > **Gemini CLI note (as of 2026-04-01):** Subagents are generated to `.gemini/agents/`. To enable the agents feature, set `"experimental": { "enableAgents": true }` in your `.gemini/settings.json`. -> **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior. Other supported fields include `displayName`, `temperature`, `top_p`, `model`, `permission`, `prompt`, `color`, `native`, `hidden`, `variant`, `disable`, `deprecated`, `steps`, and `options`. +> **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior. + +Besides `mode`, the `kilo` subagent block accepts these optional fields (all preserved on round-trip): + +| Field | Type | Notes | +| ------------- | --------------- | ------------------------------------ | +| `displayName` | string | Human-friendly name shown in pickers | +| `model` | string | Model id | +| `variant` | string | Model variant | +| `temperature` | number | Sampling temperature | +| `top_p` | number | Nucleus-sampling parameter | +| `permission` | string | Permission profile | +| `prompt` | string | Inline system prompt | +| `color` | string | UI color | +| `native` | boolean | Native (built-in) agent flag | +| `hidden` | boolean | Hide from top-level picker | +| `disable` | boolean | Disable the agent | +| `deprecated` | boolean | Mark as deprecated | +| `steps` | array of object | Ordered step definitions | +| `options` | object | Free-form key/value options | ## `.rulesync/skills/*/SKILL.md` diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index a137b5ed7..a56750f51 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -270,7 +270,26 @@ Attention, again, you are just the planner, so though you can read any files and > **Gemini CLI note (as of 2026-04-01):** Subagents are generated to `.gemini/agents/`. To enable the agents feature, set `"experimental": { "enableAgents": true }` in your `.gemini/settings.json`. -> **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior. Other supported fields include `displayName`, `temperature`, `top_p`, `model`, `permission`, `prompt`, `color`, `native`, `hidden`, `variant`, `disable`, `deprecated`, `steps`, and `options`. +> **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior. + +Besides `mode`, the `kilo` subagent block accepts these optional fields (all preserved on round-trip): + +| Field | Type | Notes | +| ------------- | --------------- | ------------------------------------ | +| `displayName` | string | Human-friendly name shown in pickers | +| `model` | string | Model id | +| `variant` | string | Model variant | +| `temperature` | number | Sampling temperature | +| `top_p` | number | Nucleus-sampling parameter | +| `permission` | string | Permission profile | +| `prompt` | string | Inline system prompt | +| `color` | string | UI color | +| `native` | boolean | Native (built-in) agent flag | +| `hidden` | boolean | Hide from top-level picker | +| `disable` | boolean | Disable the agent | +| `deprecated` | boolean | Mark as deprecated | +| `steps` | array of object | Ordered step definitions | +| `options` | object | Free-form key/value options | ## `.rulesync/skills/*/SKILL.md` diff --git a/src/features/subagents/kilo-subagent.test.ts b/src/features/subagents/kilo-subagent.test.ts index 2548e84ba..e1717fc5b 100644 --- a/src/features/subagents/kilo-subagent.test.ts +++ b/src/features/subagents/kilo-subagent.test.ts @@ -460,4 +460,56 @@ Agent body`, expect(result.data.options).toEqual({ key: "value" }); } }); + + it("should round-trip the options and steps fields via fromRulesyncSubagent", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "with-steps.md", + frontmatter: { + targets: ["kilo"], + name: "with-steps", + description: "Agent with options/steps", + kilo: { + options: { temperature: 0.2 }, + steps: [{ name: "step1" }, { name: "step2" }], + }, + }, + body: "Body", + validate: false, + }); + + const fm = ( + KiloSubagent.fromRulesyncSubagent({ + rulesyncSubagent, + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + }) as KiloSubagent + ).getFrontmatter(); + expect(fm.options).toEqual({ temperature: 0.2 }); + expect(fm.steps).toEqual([{ name: "step1" }, { name: "step2" }]); + }); + + describe("forDeletion", () => { + it("should create a deletable placeholder with the default mode", () => { + const subagent = KiloSubagent.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".kilo/agents", + relativeFilePath: "obsolete.md", + }); + expect(subagent.isDeletable()).toBe(true); + expect(subagent.getBody()).toBe(""); + expect(subagent.getFrontmatter().mode).toBe("all"); + }); + + it("should preserve the global flag passthrough (regression for #1639)", () => { + const subagent = KiloSubagent.forDeletion({ + outputRoot: testDir, + relativeDirPath: join(".config", "kilo", "agents"), + relativeFilePath: "obsolete.md", + global: true, + }); + expect((subagent as unknown as { global: boolean }).global).toBe(true); + }); + }); }); diff --git a/src/features/subagents/kilo-subagent.ts b/src/features/subagents/kilo-subagent.ts index c810eb81e..0e72be63b 100644 --- a/src/features/subagents/kilo-subagent.ts +++ b/src/features/subagents/kilo-subagent.ts @@ -17,9 +17,12 @@ import { ToolSubagentSettablePaths, } from "./tool-subagent.js"; +/** Default `mode` applied to Kilo subagents (single source of truth). */ +export const KILO_DEFAULT_MODE = "all"; + export const KiloSubagentFrontmatterSchema = z.looseObject({ description: z.optional(z.string()), - mode: z._default(z.string(), "all"), + mode: z._default(z.string(), KILO_DEFAULT_MODE), name: z.optional(z.string()), displayName: z.optional(z.string()), deprecated: z.optional(z.boolean()), @@ -32,8 +35,8 @@ export const KiloSubagentFrontmatterSchema = z.looseObject({ model: z.optional(z.string()), variant: z.optional(z.string()), prompt: z.optional(z.string()), - options: z.optional(z.looseObject({})), - steps: z.optional(z.array(z.looseObject({}))), + options: z.optional(z.record(z.string(), z.unknown())), + steps: z.optional(z.array(z.record(z.string(), z.unknown()))), disable: z.optional(z.boolean()), }); export type KiloSubagentFrontmatter = z.infer; @@ -45,13 +48,23 @@ export class KiloSubagent extends OpenCodeStyleSubagent { declare protected readonly frontmatter: KiloSubagentFrontmatter; constructor(params: KiloSubagentParams) { - super(params); + // Apply the Kilo schema (which also fills the `mode` default) up front so the + // stored frontmatter is normalized and `validate()` can stay side-effect-free. + // Kilo's schema is a strict superset of the parent's, so the parent's own + // validation is redundant — pass `validate: false` to skip it (the check has + // already happened here). + let frontmatter = params.frontmatter; if (params.validate !== false) { - const result = this.validate(); + const result = KiloSubagentFrontmatterSchema.safeParse(params.frontmatter); if (!result.success) { - throw result.error; + throw new Error( + `Invalid frontmatter in ${join(params.relativeDirPath, params.relativeFilePath)}: ${formatError(result.error)}`, + ); } + frontmatter = result.data; } + + super({ ...params, frontmatter, validate: false }); } protected getToolTarget(): Extract { @@ -62,11 +75,14 @@ export class KiloSubagent extends OpenCodeStyleSubagent { return this.frontmatter; } + /** + * Pure validation (matches every sibling subagent): checks the stored + * frontmatter against the Kilo schema without mutating it. Default application + * happens in the constructor, not here. + */ validate(): ValidationResult { const result = KiloSubagentFrontmatterSchema.safeParse(this.frontmatter); if (result.success) { - // @ts-expect-error - readonly - this.frontmatter = result.data; return { success: true, error: null }; } @@ -171,7 +187,7 @@ export class KiloSubagent extends OpenCodeStyleSubagent { outputRoot, relativeDirPath, relativeFilePath, - frontmatter: { description: "", mode: "all" }, + frontmatter: { description: "", mode: KILO_DEFAULT_MODE }, body: "", fileContent: "", validate: false, diff --git a/src/features/subagents/opencode-subagent.test.ts b/src/features/subagents/opencode-subagent.test.ts index fbf7d47d1..cf8ed820c 100644 --- a/src/features/subagents/opencode-subagent.test.ts +++ b/src/features/subagents/opencode-subagent.test.ts @@ -243,4 +243,26 @@ Body content`, expect(subagent.getFrontmatter().mode).toBe("all"); }); + + describe("forDeletion", () => { + it("should create a deletable placeholder", () => { + const subagent = OpenCodeSubagent.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".opencode/agents", + relativeFilePath: "obsolete.md", + }); + expect(subagent.isDeletable()).toBe(true); + expect(subagent.getBody()).toBe(""); + }); + + it("should preserve the global flag passthrough (regression for #1639)", () => { + const subagent = OpenCodeSubagent.forDeletion({ + outputRoot: testDir, + relativeDirPath: join(".config", "opencode", "agents"), + relativeFilePath: "obsolete.md", + global: true, + }); + expect((subagent as unknown as { global: boolean }).global).toBe(true); + }); + }); });