Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ 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.
> **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`.

## `.rulesync/skills/*/SKILL.md`

Expand Down
2 changes: 1 addition & 1 deletion skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ 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.
> **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`.

## `.rulesync/skills/*/SKILL.md`

Expand Down
152 changes: 152 additions & 0 deletions src/features/subagents/kilo-subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,33 @@ Body content`,
expect(subagent.getFrontmatter().mode).toBe("all");
});

it("should fail validation on invalid schema types", () => {
const result = KiloSubagentFrontmatterSchema.safeParse({
mode: "subagent",
temperature: "hot", // invalid: should be number
});
expect(result.success).toBe(false);
});

it("should throw during fromFile on invalid schema types", async () => {
const dirPath = join(testDir, ".kilo", "agent");
const filePath = join(dirPath, "invalid-type.md");

await writeFileContent(
filePath,
`---
temperature: "hot"
---
Body content`,
);

await expect(
KiloSubagent.fromFile({
relativeFilePath: "invalid-type.md",
}),
).rejects.toThrow("Invalid input: expected number, received string");
});

it("should preserve custom mode value when explicitly set", async () => {
const dirPath = join(testDir, ".kilo", "agent");
const filePath = join(dirPath, "custom-mode.md");
Expand All @@ -308,4 +335,129 @@ Body content`,

expect(subagent.getFrontmatter().mode).toBe("all");
});

it("should accept and expose all explicit Kilo frontmatter fields", async () => {
const dirPath = join(testDir, ".kilo", "agent");

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three new tests are happy-path. The headline behavior of the PR — that bad types now raise Zod errors — isn't covered, so a regression that, say, drops the validate() override or loosens z.number() back to z.unknown() would pass CI. A single negative test (e.g. KiloSubagentFrontmatterSchema.safeParse({ mode: "subagent", temperature: "hot" }) asserting result.success === false, and/or KiloSubagent.fromFile rejecting a file with an invalid type) would lock in the strict behavior this PR is introducing.

const filePath = join(dirPath, "full-fields.md");

await writeFileContent(
filePath,
`---
description: Full featured agent
mode: subagent
name: full-fields
displayName: Full Fields Agent
deprecated: false
native: false
hidden: true
top_p: 0.9
temperature: 0.7
color: "#ff0000"
permission: read-only
model: claude-3-5-sonnet
variant: fast
prompt: You are a helpful assistant
disable: false
---
Agent body`,
);

const subagent = await KiloSubagent.fromFile({
relativeFilePath: "full-fields.md",
});

const fm = subagent.getFrontmatter();
expect(fm.displayName).toBe("Full Fields Agent");
expect(fm.deprecated).toBe(false);
expect(fm.native).toBe(false);
expect(fm.hidden).toBe(true);
expect(fm.top_p).toBe(0.9);
expect(fm.temperature).toBe(0.7);
expect(fm.color).toBe("#ff0000");
expect(fm.permission).toBe("read-only");
expect(fm.model).toBe("claude-3-5-sonnet");
expect(fm.variant).toBe("fast");
expect(fm.prompt).toBe("You are a helpful assistant");
expect(fm.disable).toBe(false);
});

it("should pass through explicit Kilo fields via fromRulesyncSubagent", () => {
const rulesyncSubagent = new RulesyncSubagent({
outputRoot: testDir,
relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
relativeFilePath: "full-kilo.md",
frontmatter: {
targets: ["kilo"],
name: "full-kilo",
description: "Agent with all Kilo fields",
kilo: {
mode: "subagent",
displayName: "Full Kilo",
deprecated: false,
native: false,
hidden: true,
top_p: 0.95,
temperature: 0.3,
color: "blue",
permission: "write",
model: "gpt-4o",
variant: "default",
prompt: "Be concise",
disable: false,
},
},
body: "Kilo agent body",
validate: false,
});

const toolSubagent = KiloSubagent.fromRulesyncSubagent({
rulesyncSubagent,
outputRoot: testDir,
relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
}) as KiloSubagent;

const fm = toolSubagent.getFrontmatter();
expect(fm.displayName).toBe("Full Kilo");
expect(fm.deprecated).toBe(false);
expect(fm.native).toBe(false);
expect(fm.hidden).toBe(true);
expect(fm.top_p).toBe(0.95);
expect(fm.temperature).toBe(0.3);
expect(fm.color).toBe("blue");
expect(fm.permission).toBe("write");
expect(fm.model).toBe("gpt-4o");
expect(fm.variant).toBe("default");
expect(fm.prompt).toBe("Be concise");
expect(fm.disable).toBe(false);
});

it("should validate schema accepts all explicit Kilo fields", () => {
const result = KiloSubagentFrontmatterSchema.safeParse({
description: "Agent",
mode: "subagent",
displayName: "My Agent",
deprecated: true,
native: false,
hidden: false,
top_p: 0.8,
temperature: 0.5,
color: "green",
permission: "read",
model: "claude-sonnet-4",
variant: "extended",
prompt: "Think carefully",
options: { key: "value" },
steps: [{ name: "step1" }],
disable: false,
});

expect(result.success).toBe(true);
if (result.success) {
expect(result.data.displayName).toBe("My Agent");
expect(result.data.deprecated).toBe(true);
expect(result.data.top_p).toBe(0.8);
expect(result.data.steps).toEqual([{ name: "step1" }]);
expect(result.data.options).toEqual({ key: "value" });
}
});
});
62 changes: 58 additions & 4 deletions src/features/subagents/kilo-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { join } from "node:path";

import { z } from "zod/mini";

import { ValidationResult } from "../../types/ai-file.js";
import { ToolTarget } from "../../types/tool-targets.js";
import { formatError } from "../../utils/error.js";
import { readFileContent } from "../../utils/file.js";
Expand All @@ -18,19 +19,65 @@ import {

export const KiloSubagentFrontmatterSchema = z.looseObject({
description: z.optional(z.string()),
// Kilo's documented default for user-defined agents is "all":
// available both as a top-level pick and as a subagent.
mode: z._default(z.string(), "all"),
name: z.optional(z.string()),
displayName: z.optional(z.string()),
deprecated: z.optional(z.boolean()),
native: z.optional(z.boolean()),
hidden: z.optional(z.boolean()),
top_p: z.optional(z.number()),
temperature: z.optional(z.number()),
color: z.optional(z.string()),
permission: z.optional(z.string()),
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({}))),
disable: z.optional(z.boolean()),
});
export type KiloSubagentFrontmatter = z.infer<typeof KiloSubagentFrontmatterSchema>;
export type KiloSubagentParams = OpenCodeStyleSubagentParams;
export type KiloSubagentParams = Omit<OpenCodeStyleSubagentParams, "frontmatter"> & {
frontmatter: KiloSubagentFrontmatter;
};

export class KiloSubagent extends OpenCodeStyleSubagent {
declare protected readonly frontmatter: KiloSubagentFrontmatter;

constructor(params: KiloSubagentParams) {
super(params);
if (params.validate !== false) {
const result = this.validate();
if (!result.success) {
throw result.error;
}
}
}

protected getToolTarget(): Extract<ToolTarget, "opencode" | "kilo"> {
return "kilo";
}

getFrontmatter(): KiloSubagentFrontmatter {
return this.frontmatter;
}

validate(): ValidationResult {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One subtlety worth flagging: this override is not called from the parent constructor. OpenCodeStyleSubagent's constructor only runs the parent's OpenCodeStyleSubagentFrontmatterSchema.safeParse(...) when validate: true, so new KiloSubagent({ validate: true, frontmatter: { temperature: "hot" } }) does not throw. The strict Kilo schema only kicks in via KiloSubagent.fromFile, the explicit .parse() in fromRulesyncSubagent, or a manual subagent.validate() call. That's arguably fine and matches existing conventions, but the PR description reads as if construction-time validation is strict — worth either documenting the actual surface in a class-level JSDoc or having the child constructor re-validate against the Kilo schema.

const result = KiloSubagentFrontmatterSchema.safeParse(this.frontmatter);
if (result.success) {
// @ts-expect-error - readonly
this.frontmatter = result.data;
return { success: true, error: null };
}

return {
success: false,
error: new Error(
`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`,
),
};
}

static getSettablePaths({
global = false,
}: {
Expand All @@ -50,12 +97,19 @@ export class KiloSubagent extends OpenCodeStyleSubagent {
const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
const kiloSection = rulesyncFrontmatter.kilo ?? {};

const kiloFrontmatter: KiloSubagentFrontmatter = KiloSubagentFrontmatterSchema.parse({
const parseResult = KiloSubagentFrontmatterSchema.safeParse({
...kiloSection,
description: rulesyncFrontmatter.description,
...(rulesyncFrontmatter.name && { name: rulesyncFrontmatter.name }),
});

if (!parseResult.success) {
throw new Error(
`Invalid frontmatter in ${rulesyncSubagent.getRelativeFilePath()}: ${formatError(parseResult.error)}`,
);
}
const kiloFrontmatter: KiloSubagentFrontmatter = parseResult.data;

const body = rulesyncSubagent.getBody();
const fileContent = stringifyFrontmatter(body, kiloFrontmatter);
const paths = this.getSettablePaths({ global });
Expand Down
2 changes: 2 additions & 0 deletions src/features/subagents/opencode-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class OpenCodeSubagent extends OpenCodeStyleSubagent {
outputRoot = process.cwd(),
relativeDirPath,
relativeFilePath,
global = false,
}: ToolSubagentForDeletionParams): OpenCodeSubagent {
return new OpenCodeSubagent({
outputRoot,
Expand All @@ -118,6 +119,7 @@ export class OpenCodeSubagent extends OpenCodeStyleSubagent {
body: "",
fileContent: "",
validate: false,
global,
});
}
}
Loading