diff --git a/.changeset/custom-output-styles.md b/.changeset/custom-output-styles.md new file mode 100644 index 000000000..59b1343d2 --- /dev/null +++ b/.changeset/custom-output-styles.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add custom output styles. Markdown style files — built-in `concise` and `explanatory`, plus your own under `/.kimi-code/output-styles/` and `~/.kimi-code/output-styles/` — are injected additively into the system prompt to shape the assistant's tone, format, and verbosity. Select one with the `output_style` config key (precedence: project > user > built-in). diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f35266ad5..ae93a0fde 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -69,6 +69,7 @@ const config = withMermaid(defineConfig({ { text: 'Agent 与子 Agent', link: '/zh/customization/agents' }, { text: 'Hooks', link: '/zh/customization/hooks' }, { text: '自定义主题', link: '/zh/customization/themes' }, + { text: '输出风格', link: '/zh/customization/output-styles' }, ], }, ], @@ -146,6 +147,7 @@ const config = withMermaid(defineConfig({ { text: 'Agents and Subagents', link: '/en/customization/agents' }, { text: 'Hooks', link: '/en/customization/hooks' }, { text: 'Custom Themes', link: '/en/customization/themes' }, + { text: 'Output Styles', link: '/en/customization/output-styles' }, ], }, ], diff --git a/docs/en/customization/output-styles.md b/docs/en/customization/output-styles.md new file mode 100644 index 000000000..451b7f392 --- /dev/null +++ b/docs/en/customization/output-styles.md @@ -0,0 +1,63 @@ +# Custom Output Styles + +An output style is a set of instructions injected into Kimi Code's system prompt to shape **how** the assistant responds — its tone, format, and verbosity. Styles are **additive**: they layer on top of Kimi Code's default behavior and never override correctness, safety, or what a task actually requires. Two styles are built in (`concise` and `explanatory`), and you can add your own as Markdown files. + +## Built-in styles + +| Name | What it does | +| --- | --- | +| `concise` | Terse, minimal-prose responses focused on the result — skips preamble, restating the question, and summaries unless asked. | +| `explanatory` | Explains the reasoning behind notable decisions and trade-offs, teaching as it works. | + +Built-in styles always exist. A style file you create with the same name overrides the built-in one. + +## Create a style + +Add a `.md` file to an output-styles directory: + +- **Project scope** — `/.kimi-code/output-styles/`, where `` is the nearest ancestor of the working directory that contains a `.git` directory (or the working directory itself when there is no git repository). Style files in other subdirectories are not scanned. +- **User scope** — `~/.kimi-code/output-styles/`, or `$KIMI_CODE_HOME/output-styles/` when the `KIMI_CODE_HOME` environment variable is set + +Style files are read from the local filesystem, so when you run Kimi Code against a remote host (for example over SSH), keep your style files on the local machine. + +Create the directory if it does not exist. The **filename is the style name** when no `name` is set in the frontmatter: `socratic.md` becomes the style `socratic`. + +A style file is optional YAML frontmatter followed by the instruction body: + +```md +--- +name: socratic +description: Answers with guiding questions instead of handing over solutions. +--- +Guide the user toward the answer with focused questions rather than handing over +the full solution immediately. Once they are close, confirm and fill in the +remaining details. Keep each reply short. +``` + +Fields: + +- `name` (optional): the style identifier. Falls back to the filename without `.md`. +- `description` (optional): a human-readable summary. Falls back to the first non-empty line of the body. +- **body** (required): the instructions injected into the system prompt. A file with an empty body is skipped. + +## Precedence + +When more than one scope defines a style with the same name, the more specific scope wins: **project overrides user overrides built-in**. + +## Select a style + +Set `output_style` to the style name in `config.toml`: + +```toml +# ~/.kimi-code/config.toml +output_style = "concise" +``` + +`output_style` is applied when an agent's system prompt is built. A **new session's** main agent uses the current value, and subagents pick it up on every spawn. An already-running session keeps its main agent's system prompt, so `/reload` and resume do **not** change the main agent's style — start a new session to apply a change. Leave `output_style` unset for the default behavior. + +## What happens on errors + +Output styles are designed never to block startup: + +- **An invalid or empty style file** (malformed YAML frontmatter, empty body): that file is skipped; the other styles still load. +- **`output_style` names a style that does not exist**: no style is injected, and Kimi Code runs with its default behavior. diff --git a/docs/zh/customization/output-styles.md b/docs/zh/customization/output-styles.md new file mode 100644 index 000000000..4691ff488 --- /dev/null +++ b/docs/zh/customization/output-styles.md @@ -0,0 +1,62 @@ +# 自定义输出风格 + +输出风格(output style)是一段注入到 Kimi Code 系统提示中的指令,用来塑造助手**如何**回应——它的语气、格式和详略程度。风格是**叠加式**的:它们叠加在 Kimi Code 的默认行为之上,绝不会覆盖正确性、安全性,或任务本身真正的要求。内置了两种风格(`concise` 和 `explanatory`),你也可以用 Markdown 文件添加自己的风格。 + +## 内置风格 + +| 名称 | 作用 | +| --- | --- | +| `concise` | 简短、少废话、聚焦结果的回应——除非被要求,否则省略开场白、复述问题和总结。 | +| `explanatory` | 解释关键决策与取舍背后的理由,一边做一边讲解。 | + +内置风格始终存在。你创建的同名风格文件会覆盖内置的那一个。 + +## 创建一个风格 + +在某个 output-styles 目录下新建一个 `.md` 文件: + +- **项目作用域** —— `/.kimi-code/output-styles/`,其中 `` 是工作目录向上最近的、包含 `.git` 的祖先目录(若不在 git 仓库中,则为工作目录本身)。其他子目录中的风格文件不会被扫描。 +- **用户作用域** —— `~/.kimi-code/output-styles/`,或在设置了 `KIMI_CODE_HOME` 环境变量时为 `$KIMI_CODE_HOME/output-styles/` + +风格文件从本地文件系统读取;当你通过 SSH 等方式连接远程主机运行 Kimi Code 时,请把风格文件放在本地机器上。 + +目录不存在就自己建一个。当 frontmatter 中没有写 `name` 时,**文件名就是风格名**:`socratic.md` 会成为风格 `socratic`。 + +一个风格文件由可选的 YAML frontmatter 加上指令正文组成: + +```md +--- +name: socratic +description: 用引导式提问代替直接给出答案。 +--- +用有针对性的问题引导用户逼近答案,而不是一上来就把完整方案给出去。 +当用户接近答案时,确认并补全剩余细节。每次回复都尽量简短。 +``` + +字段: + +- `name`(可选):风格标识。缺省时回退为去掉 `.md` 的文件名。 +- `description`(可选):便于阅读的简介。缺省时回退为正文的第一行非空内容。 +- **正文**(必填):注入到系统提示中的指令。正文为空的文件会被跳过。 + +## 优先级 + +当多个作用域定义了同名风格时,更具体的作用域胜出:**项目覆盖用户,用户覆盖内置**。 + +## 选择一个风格 + +在 `config.toml` 中把 `output_style` 设为风格名: + +```toml +# ~/.kimi-code/config.toml +output_style = "concise" +``` + +`output_style` 在构建 Agent 的系统提示时生效。**新会话**的主 Agent 使用当前值,子 Agent 则在每次生成时读取当前值。已在运行的会话会保留其主 Agent 的系统提示,因此 `/reload` 和恢复(resume)**不会**改变主 Agent 的风格——要应用更改请开启新会话。不设置 `output_style` 即为默认行为。 + +## 出错时会发生什么 + +输出风格的设计目标是绝不阻塞启动: + +- **无效或空的风格文件**(frontmatter 格式错误、正文为空):该文件会被跳过;其余风格照常加载。 +- **`output_style` 指向一个不存在的风格**:不会注入任何风格,Kimi Code 以默认行为运行。 diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index ea1cd806f..9a3a361de 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -238,6 +238,7 @@ export class Agent { skills: this.skills?.registry, cwdListing: context?.cwdListing, agentsMd: context?.agentsMd, + outputStyleBody: context?.outputStyleBody, }); this.config.update({ profileName: profile.name, systemPrompt }); this.tools.setActiveTools(profile.tools); diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 9b3d11cf0..1c49d0a66 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -216,6 +216,7 @@ export const KimiConfigSchema = z.object({ services: ServicesConfigSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + outputStyle: z.string().optional(), loopControl: LoopControlSchema.optional(), background: BackgroundConfigSchema.optional(), experimental: ExperimentalConfigSchema.optional(), @@ -255,6 +256,7 @@ export const KimiConfigPatchSchema = z services: ServicesConfigPatchSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + outputStyle: z.string().optional(), loopControl: LoopControlPatchSchema.optional(), background: BackgroundConfigPatchSchema.optional(), experimental: ExperimentalConfigPatchSchema.optional(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 172e97cfc..79789c145 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -472,6 +472,7 @@ export function configToTomlData(config: KimiConfig): Record { 'defaultPlanMode', 'mergeAllAvailableSkills', 'extraSkillDirs', + 'outputStyle', 'telemetry', ]; for (const key of scalarFields) { diff --git a/packages/agent-core/src/output-style/builtin.ts b/packages/agent-core/src/output-style/builtin.ts new file mode 100644 index 000000000..c41f03e7b --- /dev/null +++ b/packages/agent-core/src/output-style/builtin.ts @@ -0,0 +1,11 @@ +import type { OutputStyle } from './types'; +export const BUILTIN_OUTPUT_STYLES: readonly OutputStyle[] = [ + { name: 'concise', source: 'builtin', description: 'Terse, minimal-prose responses focused on the result.', + body: ['Respond as briefly as the task allows. Prefer the answer or the action over explanation.', + 'Skip preamble, restating the question, and summaries unless explicitly asked.', + 'Use short sentences and compact lists. Never pad. Correctness and completeness still come first.'].join('\n') }, + { name: 'explanatory', source: 'builtin', description: 'Explains the reasoning and teaches as it works.', + body: ['As you work, briefly explain the reasoning behind notable decisions and trade-offs so the user learns from the process.', + 'Call out alternatives you considered and why you rejected them, and surface non-obvious gotchas.', + 'Keep explanations proportional — enough to teach, never so much that it buries the result.'].join('\n') }, +]; diff --git a/packages/agent-core/src/output-style/index.ts b/packages/agent-core/src/output-style/index.ts new file mode 100644 index 000000000..01efe3414 --- /dev/null +++ b/packages/agent-core/src/output-style/index.ts @@ -0,0 +1,32 @@ +import { loadOutputStyles } from './loader'; +import type { OutputStyle } from './types'; + +export type { OutputStyle, OutputStyleSource, ParsedOutputStyle } from './types'; +export { parseOutputStyle, OutputStyleParseError } from './parser'; +export { BUILTIN_OUTPUT_STYLES } from './builtin'; +export { loadOutputStyles } from './loader'; +export type { LoadOutputStylesOptions, OutputStylePathContext } from './loader'; + +export function resolveOutputStyle(styles: readonly OutputStyle[], name: string | undefined): OutputStyle | undefined { + if (name === undefined || name.trim() === '') return undefined; + return styles.find((s) => s.name === name.trim()); +} + +export interface LoadConfiguredOutputStyleInput { + readonly name: string | undefined; + readonly userHomeDir: string; + readonly brandHomeDir?: string; + readonly workDir: string; + readonly onWarning?: (message: string, cause?: unknown) => void; +} + +export async function loadConfiguredOutputStyleBody( + input: LoadConfiguredOutputStyleInput, +): Promise { + if (input.name === undefined || input.name.trim() === '') return undefined; + const styles = await loadOutputStyles({ + paths: { userHomeDir: input.userHomeDir, brandHomeDir: input.brandHomeDir, workDir: input.workDir }, + onWarning: input.onWarning, + }); + return resolveOutputStyle(styles, input.name)?.body; +} diff --git a/packages/agent-core/src/output-style/loader.ts b/packages/agent-core/src/output-style/loader.ts new file mode 100644 index 000000000..c465f33c1 --- /dev/null +++ b/packages/agent-core/src/output-style/loader.ts @@ -0,0 +1,57 @@ +import { promises as fs } from 'node:fs'; +import { dirname, join, resolve } from 'pathe'; +import { parseOutputStyle, OutputStyleParseError } from './parser'; +import { BUILTIN_OUTPUT_STYLES } from './builtin'; +import type { OutputStyle, OutputStyleSource } from './types'; + +export interface OutputStylePathContext { readonly userHomeDir: string; readonly brandHomeDir?: string; readonly workDir: string; } +export interface LoadOutputStylesOptions { + readonly paths: OutputStylePathContext; + readonly onWarning?: (message: string, cause?: unknown) => void; + readonly readdir?: (p: string) => Promise; + readonly readFile?: (p: string) => Promise; + readonly isDir?: (p: string) => Promise; +} + +export async function loadOutputStyles(options: LoadOutputStylesOptions): Promise { + const readdir = options.readdir ?? ((p) => fs.readdir(p)); + const readFile = options.readFile ?? ((p) => fs.readFile(p, 'utf8')); + const isDir = options.isDir ?? (async (p) => { try { return (await fs.stat(p)).isDirectory(); } catch { return false; } }); + const warn = options.onWarning ?? (() => {}); + const { userHomeDir, workDir } = options.paths; + const brandHomeDir = options.paths.brandHomeDir ?? join(userHomeDir, '.kimi-code'); + const projectRoot = await findProjectRoot(workDir); + const byName = new Map(); + for (const s of BUILTIN_OUTPUT_STYLES) byName.set(s.name, s); + await scanDir(join(brandHomeDir, 'output-styles'), 'user'); + await scanDir(join(projectRoot, '.kimi-code', 'output-styles'), 'project'); + return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)); + + async function scanDir(dir: string, source: OutputStyleSource): Promise { + if (!(await isDir(dir))) return; + let entries: readonly string[]; + try { entries = [...(await readdir(dir))].toSorted(); } + catch (error) { warn(`Failed to read output-style directory ${dir}`, error); return; } + for (const entry of entries) { + if (!entry.endsWith('.md')) continue; + const file = join(dir, entry); + try { + const parsed = parseOutputStyle(await readFile(file), entry.slice(0, -'.md'.length)); + byName.set(parsed.name, { ...parsed, source }); + } catch (error) { + if (error instanceof OutputStyleParseError) warn(`Skipping invalid output style at ${file}: ${error.message}`, error); + else warn(`Skipping output style at ${file} due to unexpected error`, error); + } + } + } +} + +async function findProjectRoot(workDir: string): Promise { + let current = resolve(workDir); + while (true) { + try { await fs.stat(join(current, '.git')); return current; } catch { /* keep walking */ } + const parent = dirname(current); + if (parent === current) return resolve(workDir); + current = parent; + } +} diff --git a/packages/agent-core/src/output-style/parser.ts b/packages/agent-core/src/output-style/parser.ts new file mode 100644 index 000000000..352e13158 --- /dev/null +++ b/packages/agent-core/src/output-style/parser.ts @@ -0,0 +1,33 @@ +import { FrontmatterError, parseFrontmatter } from '../skill/parser'; +import type { ParsedOutputStyle } from './types'; + +export class OutputStyleParseError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'OutputStyleParseError'; + if (cause !== undefined) Object.defineProperty(this, 'cause', { value: cause, configurable: true }); + } +} + +export function parseOutputStyle(text: string, fallbackName: string): ParsedOutputStyle { + let parsed; + try { parsed = parseFrontmatter(text); } + catch (error) { + if (error instanceof FrontmatterError) throw new OutputStyleParseError(`Invalid frontmatter: ${error.message}`, error); + throw error; + } + const fm = isRecord(parsed.data) ? parsed.data : {}; + const body = parsed.body.trim(); + if (body === '') throw new OutputStyleParseError('Output style body is empty'); + const name = nonEmptyString(fm['name']) ?? fallbackName; + const description = nonEmptyString(fm['description']) ?? firstLine(body) ?? 'No description provided.'; + return { name, description, body }; +} + +function firstLine(body: string): string | undefined { + const line = body.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0); + if (line === undefined) return undefined; + return line.length > 240 ? `${line.slice(0, 239)}…` : line; +} +function nonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; } +function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/agent-core/src/output-style/types.ts b/packages/agent-core/src/output-style/types.ts new file mode 100644 index 000000000..7bae47abb --- /dev/null +++ b/packages/agent-core/src/output-style/types.ts @@ -0,0 +1,3 @@ +export type OutputStyleSource = 'project' | 'user' | 'builtin'; +export interface OutputStyle { readonly name: string; readonly description: string; readonly body: string; readonly source: OutputStyleSource; } +export interface ParsedOutputStyle { readonly name: string; readonly description: string; readonly body: string; } diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index 49d8d8105..d708785e9 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -1,8 +1,11 @@ +import { homedir } from 'node:os'; + import { dirname, join } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; import { listDirectory } from '../tools/support/list-directory'; +import { loadConfiguredOutputStyleBody } from '../output-style'; import type { SystemPromptContext } from './types'; const AGENTS_MD_MAX_BYTES = 32 * 1024; @@ -11,17 +14,27 @@ const AGENTS_MD_TRUNCATION_MARKER = const S_IFMT = 0o170000; const S_IFREG = 0o100000; -export type PreparedSystemPromptContext = Pick; +export type PreparedSystemPromptContext = Pick< + SystemPromptContext, + 'cwdListing' | 'agentsMd' | 'outputStyleBody' +>; export async function prepareSystemPromptContext( kaos: Kaos, brandHome?: string, + outputStyleName?: string, ): Promise { - const [cwdListing, agentsMd] = await Promise.all([ + const [cwdListing, agentsMd, outputStyleBody] = await Promise.all([ listDirectory(kaos, undefined, { collapseHiddenDirs: true }), loadAgentsMd(kaos, brandHome), + loadConfiguredOutputStyleBody({ + name: outputStyleName, + userHomeDir: homedir(), + brandHomeDir: brandHome, + workDir: kaos.getcwd(), + }), ]); - return { cwdListing, agentsMd }; + return { cwdListing, agentsMd, outputStyleBody }; } export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise { diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index d3b0084cc..56f0a21e5 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -116,6 +116,16 @@ The applicable `AGENTS.md` instructions are: ``````` {{ KIMI_AGENTS_MD }} ``````` +{% if KIMI_OUTPUT_STYLE %} + +# Output Style + +The user selected a custom output style. Apply these response-style instructions on top of everything above. They govern tone, format, and verbosity only — they never override correctness, safety, or the task's actual requirements: + +``````` +{{ KIMI_OUTPUT_STYLE }} +``````` +{% endif %} # Skills diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 001f7d19f..9fed13a1f 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -162,6 +162,7 @@ function buildTemplateVars( KIMI_ADDITIONAL_DIRS_INFO: context.additionalDirsInfo ?? '', ROLE_ADDITIONAL: context.roleAdditional ?? promptVars['ROLE_ADDITIONAL'] ?? promptVars['roleAdditional'] ?? '', + KIMI_OUTPUT_STYLE: context.outputStyleBody ?? '', }; } diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts index 27d407c3b..0d3402a9d 100644 --- a/packages/agent-core/src/profile/types.ts +++ b/packages/agent-core/src/profile/types.ts @@ -42,6 +42,7 @@ export interface SystemPromptContext { readonly skills?: SkillRegistry | string; readonly additionalDirsInfo?: string; readonly roleAdditional?: string; + readonly outputStyleBody?: string; } export type SystemPromptRenderer = (context: SystemPromptContext) => string; diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ca8c531a9..c6b570e54 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -407,6 +407,7 @@ export class Session { const context = await prepareSystemPromptContext( this.systemContextKaos(agent.kaos.getcwd()), this.options.kimiHomeDir, + this.options.config?.outputStyle, ); agent.useProfile(profile, context); } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index b47e1cd68..25e40af99 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -365,6 +365,7 @@ export class SessionSubagentHost { const context = await prepareSystemPromptContext( this.session.systemContextKaos(child.kaos.getcwd()), this.session.options.kimiHomeDir, + this.session.options.config?.outputStyle, ); child.useProfile(profile, context); child.tools.inheritUserTools(parent.tools); diff --git a/packages/agent-core/test/config/output-style-config.test.ts b/packages/agent-core/test/config/output-style-config.test.ts new file mode 100644 index 000000000..a78f8d11f --- /dev/null +++ b/packages/agent-core/test/config/output-style-config.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest'; +import { KimiConfigSchema, KimiConfigPatchSchema } from '../../src/config/schema'; +describe('outputStyle config', () => { + it('accepts an outputStyle name', () => { expect(KimiConfigSchema.parse({ outputStyle: 'concise' }).outputStyle).toBe('concise'); }); + it('is optional', () => { expect(KimiConfigSchema.parse({}).outputStyle).toBeUndefined(); }); + it('is patchable', () => { expect(KimiConfigPatchSchema.parse({ outputStyle: 'explanatory' }).outputStyle).toBe('explanatory'); }); +}); diff --git a/packages/agent-core/test/config/output-style-writeback.test.ts b/packages/agent-core/test/config/output-style-writeback.test.ts new file mode 100644 index 000000000..2903fecfb --- /dev/null +++ b/packages/agent-core/test/config/output-style-writeback.test.ts @@ -0,0 +1,27 @@ +import { mkdtempSync } from 'node:fs'; +import { readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { parseConfigString, writeConfigFile } from '../../src/config'; + +const tempDirs: string[] = []; +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'kimi-os-cfg-')); + tempDirs.push(dir); + return dir; +} + +describe('outputStyle config write-back', () => { + it('persists output_style through writeConfigFile and reparses it', async () => { + const configPath = join(makeTempDir(), 'config.toml'); + await writeConfigFile(configPath, { providers: {}, outputStyle: 'concise' }); + const text = await readFile(configPath, 'utf-8'); + expect(text).toContain('output_style = "concise"'); + expect(parseConfigString(text, configPath).outputStyle).toBe('concise'); + }); +}); diff --git a/packages/agent-core/test/output-style/builtin.test.ts b/packages/agent-core/test/output-style/builtin.test.ts new file mode 100644 index 000000000..793677a55 --- /dev/null +++ b/packages/agent-core/test/output-style/builtin.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { BUILTIN_OUTPUT_STYLES } from '../../src/output-style/builtin'; +describe('BUILTIN_OUTPUT_STYLES', () => { + it('ships concise and explanatory, all builtin-sourced with non-empty bodies', () => { + expect(BUILTIN_OUTPUT_STYLES.map((s) => s.name).toSorted()).toEqual(['concise', 'explanatory']); + for (const s of BUILTIN_OUTPUT_STYLES) { + expect(s.source).toBe('builtin'); + expect(s.body.trim().length).toBeGreaterThan(0); + expect(s.description.trim().length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/agent-core/test/output-style/configured.test.ts b/packages/agent-core/test/output-style/configured.test.ts new file mode 100644 index 000000000..83f79a072 --- /dev/null +++ b/packages/agent-core/test/output-style/configured.test.ts @@ -0,0 +1,30 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadConfiguredOutputStyleBody } from '../../src/output-style'; + +let dir: string; +beforeEach(async () => { dir = await mkdtemp(path.join(tmpdir(), 'osc-')); }); +afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + +describe('loadConfiguredOutputStyleBody', () => { + it('returns undefined when no name is configured', async () => { + expect(await loadConfiguredOutputStyleBody({ name: undefined, userHomeDir: dir, workDir: dir })).toBeUndefined(); + expect(await loadConfiguredOutputStyleBody({ name: ' ', userHomeDir: dir, workDir: dir })).toBeUndefined(); + }); + it('resolves a built-in style body by name', async () => { + const body = await loadConfiguredOutputStyleBody({ name: 'concise', userHomeDir: dir, workDir: dir }); + expect(body).toContain('Respond as briefly'); + }); + it('returns undefined for an unknown style name', async () => { + expect(await loadConfiguredOutputStyleBody({ name: 'nope', userHomeDir: dir, workDir: dir })).toBeUndefined(); + }); + it('resolves a user-defined style that overrides a built-in', async () => { + const styleDir = path.join(dir, 'output-styles'); + await mkdir(styleDir, { recursive: true }); + await writeFile(path.join(styleDir, 'concise.md'), '---\nname: concise\n---\nMY OVERRIDE'); + const body = await loadConfiguredOutputStyleBody({ name: 'concise', userHomeDir: dir, brandHomeDir: dir, workDir: dir }); + expect(body).toBe('MY OVERRIDE'); + }); +}); diff --git a/packages/agent-core/test/output-style/loader.test.ts b/packages/agent-core/test/output-style/loader.test.ts new file mode 100644 index 000000000..bb9b75c9b --- /dev/null +++ b/packages/agent-core/test/output-style/loader.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadOutputStyles } from '../../src/output-style/loader'; + +let dir: string; +beforeEach(async () => { dir = await mkdtemp(path.join(tmpdir(), 'os-')); }); +afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); +async function writeStyle(root: string, name: string, body: string) { + const d = path.join(root, 'output-styles'); + await mkdir(d, { recursive: true }); + await writeFile(path.join(d, `${name}.md`), `---\nname: ${name}\ndescription: ${name} desc\n---\n${body}\n`); +} +describe('loadOutputStyles', () => { + it('includes built-ins when no files exist', async () => { + const styles = await loadOutputStyles({ paths: { userHomeDir: dir, workDir: dir } }); + expect(styles.map((s) => s.name).toSorted()).toEqual(['concise', 'explanatory']); + }); + it('project overrides user overrides built-in by name', async () => { + const project = path.join(dir, 'proj'); const brand = path.join(dir, 'brand'); + await mkdir(path.join(project, '.git'), { recursive: true }); + await writeStyle(path.join(project, '.kimi-code'), 'concise', 'PROJECT body'); + await writeStyle(brand, 'concise', 'USER body'); + const styles = await loadOutputStyles({ paths: { userHomeDir: dir, brandHomeDir: brand, workDir: path.join(project, '.kimi-code') } }); + const c = styles.find((s) => s.name === 'concise'); + expect(c?.body).toBe('PROJECT body'); expect(c?.source).toBe('project'); + }); + it('skips invalid files but keeps the rest', async () => { + const brand = path.join(dir, 'brand'); + await writeStyle(brand, 'good', 'ok body'); + await mkdir(path.join(brand, 'output-styles'), { recursive: true }); + await writeFile(path.join(brand, 'output-styles', 'bad.md'), '---\nname: "unterminated\n---\nx'); + const warnings: string[] = []; + const styles = await loadOutputStyles({ paths: { userHomeDir: dir, brandHomeDir: brand, workDir: dir }, onWarning: (m) => warnings.push(m) }); + expect(styles.find((s) => s.name === 'good')).toBeDefined(); + expect(warnings.some((w) => w.includes('bad.md'))).toBe(true); + }); +}); diff --git a/packages/agent-core/test/output-style/parser.test.ts b/packages/agent-core/test/output-style/parser.test.ts new file mode 100644 index 000000000..892c8b903 --- /dev/null +++ b/packages/agent-core/test/output-style/parser.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { OutputStyleParseError, parseOutputStyle } from '../../src/output-style/parser'; + +describe('parseOutputStyle', () => { + it('reads name + description from frontmatter and trims body', () => { + const text = ['---', 'name: concise', 'description: Terse answers', '---', '', 'Be brief.', ''].join('\n'); + expect(parseOutputStyle(text, 'fallback')).toEqual({ name: 'concise', description: 'Terse answers', body: 'Be brief.' }); + }); + it('falls back to filename and first body line when frontmatter omits them', () => { + const style = parseOutputStyle('Just instructions, no frontmatter.', 'my-style'); + expect(style.name).toBe('my-style'); + expect(style.description).toBe('Just instructions, no frontmatter.'); + expect(style.body).toBe('Just instructions, no frontmatter.'); + }); + it('throws when the body is empty', () => { + expect(() => parseOutputStyle(['---', 'name: empty', '---', '', ' ', ''].join('\n'), 'empty')).toThrow(OutputStyleParseError); + }); + it('throws OutputStyleParseError on invalid frontmatter YAML', () => { + expect(() => parseOutputStyle(['---', 'name: "unterminated', '---', 'body'].join('\n'), 'x')).toThrow(OutputStyleParseError); + }); +}); diff --git a/packages/agent-core/test/output-style/resolve.test.ts b/packages/agent-core/test/output-style/resolve.test.ts new file mode 100644 index 000000000..fae613a17 --- /dev/null +++ b/packages/agent-core/test/output-style/resolve.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { resolveOutputStyle, type OutputStyle } from '../../src/output-style'; + +const styles: readonly OutputStyle[] = [ + { name: 'concise', description: 'd', body: 'b1', source: 'builtin' }, + { name: 'explanatory', description: 'd', body: 'b2', source: 'user' }, +]; + +describe('resolveOutputStyle', () => { + it('returns the matching style by name', () => { + expect(resolveOutputStyle(styles, 'explanatory')?.body).toBe('b2'); + }); + it('trims the name before matching', () => { + expect(resolveOutputStyle(styles, ' concise ')?.name).toBe('concise'); + }); + it('returns undefined for unknown, empty, or undefined name', () => { + expect(resolveOutputStyle(styles, 'nope')).toBeUndefined(); + expect(resolveOutputStyle(styles, '')).toBeUndefined(); + expect(resolveOutputStyle(styles, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/agent-core/test/output-style/system-prompt.test.ts b/packages/agent-core/test/output-style/system-prompt.test.ts new file mode 100644 index 000000000..3821ab30f --- /dev/null +++ b/packages/agent-core/test/output-style/system-prompt.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_AGENT_PROFILES } from '../../src/profile'; + +const baseContext = { + osEnv: { + osKind: 'macOS', + osArch: 'arm64', + osVersion: '0', + shellName: 'bash', + shellPath: '/bin/bash', + }, + cwd: '/workspace', + now: '2026-05-09T00:00:00.000Z', + cwdListing: '', + agentsMd: '', + skills: '', +} as const; + +describe('output style system prompt plumbing', () => { + it('injects outputStyleBody into the rendered system prompt', () => { + const context = { ...baseContext, outputStyleBody: 'BE_TERSE_MARKER' }; + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt(context) ?? ''; + expect(prompt).toContain('BE_TERSE_MARKER'); + expect(prompt).toContain('# Output Style'); + }); + + it('omits the Output Style section when outputStyleBody is absent', () => { + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt(baseContext) ?? ''; + expect(prompt).not.toContain('# Output Style'); + }); +});