Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6590480
feat: support custom agent files
7Sageer Jul 15, 2026
6262b42
chore: shorten custom agents changeset
7Sageer Jul 15, 2026
d7344f6
fix(agent-core-v2): stringify caught errors in agent catalog log calls
7Sageer Jul 15, 2026
b919c3d
docs: drop v2 engine notes from custom agents docs
7Sageer Jul 15, 2026
6c16401
fix: align custom agent binding semantics across engine and edges
7Sageer Jul 15, 2026
264c8fe
fix: tighten custom agent behavior
7Sageer Jul 15, 2026
6fd4930
fix: address custom agent review findings
7Sageer Jul 15, 2026
2de39ac
fix(agent-core,agent-core-v2): keep active-tool wire records replayab…
7Sageer Jul 15, 2026
b77be0f
fix(agent-core-v2): tolerate unreadable directories in agent-file dis…
7Sageer Jul 15, 2026
6dee92a
fix(agent-core-v2): warn when a same-name builtin suppresses a file p…
7Sageer Jul 15, 2026
3e7f732
refactor(agent-core-v2): pass skillActive explicitly to renderSystemP…
7Sageer Jul 15, 2026
1d43502
refactor(agent-core-v2): fall back to the configured default model in…
7Sageer Jul 15, 2026
adb227c
fix(agent-core-v2,kap-server): reject unsupported thinking atomically…
7Sageer Jul 15, 2026
5cf2c75
fix(agent-core-v2): await the agent profile catalog at session materi…
7Sageer Jul 15, 2026
0714c99
test: cover the --agent-file fatal path and tidy profile registration…
7Sageer Jul 15, 2026
8e46ab7
docs: align custom agent docs with v2-engine gating
7Sageer Jul 15, 2026
8cbc500
test(agent-core-v2): revert unrelated style churn in fs/workspace tests
7Sageer Jul 15, 2026
f9c44d4
Merge remote-tracking branch 'upstream/main' into feat-custom-agent-file
7Sageer Jul 16, 2026
0561dee
Merge remote-tracking branch 'upstream/main' into feat-custom-agent-file
7Sageer Jul 16, 2026
61183b5
feat(agent-core-v2): add permanent system prompt override via SYSTEM.md
7Sageer Jul 17, 2026
f6251cd
feat(agent-core-v2): gate tools globally and accept session disabledT…
7Sageer Jul 17, 2026
637dee4
fix: enforce session tool policy across agents
7Sageer Jul 17, 2026
7ac9e73
fix(agent-core-v2): enforce tool policy at execution
7Sageer Jul 17, 2026
26518f0
fix(agent-core-v2): align subagent tool descriptions with policy
7Sageer Jul 17, 2026
ce5d2e3
fix(agent-core-v2): harden custom agent policy state
7Sageer Jul 17, 2026
2c10985
fix(agent-core-v2): harden custom agent lifecycle
7Sageer Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/custom-agent-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/agent-core-v2": minor
"@moonshot-ai/kap-server": minor
"@moonshot-ai/protocol": minor
"@moonshot-ai/kimi-code": minor
---

Support custom agents defined as Markdown files with frontmatter — replace or append the system prompt, allow/deny tools — selected via `--agent <name>` / `--agent-file <path>` (v2 engine only: `KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p`). See "Custom Agents" in the Agents docs for the file format, discovery directories, and binding semantics.

Fix resuming a session in the v1 engine when its wire log contains a `tools.set_active_tools` record without `names` (written by v2-engine sessions): such records no longer crash replay.
11 changes: 11 additions & 0 deletions .changeset/global-tool-gating.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/agent-core-v2": minor
"@moonshot-ai/kap-server": minor
"@moonshot-ai/protocol": minor
"@moonshot-ai/klient": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---

Add globally enforced tool gating (v2 engine only): a `[tools]` section in `config.toml` with `enabled` / `disabled` lists constrains every profile and direct tool execution, while prompt submissions accept a session-persistent `disabledTools` list (REST field `disabled_tools`, SDK prompt options). Set `[tools] disabled = ["Task"]` in `config.toml`, or pass `disabledTools` when prompting through the SDK.
6 changes: 6 additions & 0 deletions .changeset/system-md-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core-v2": minor
"@moonshot-ai/kimi-code": minor
---

Support a permanent main-agent system prompt override via `~/.kimi-code/SYSTEM.md`: when present, it replaces the default system prompt for every session, and `${skills}` / `${agents_md}` / `${cwd}` variables are substituted (v2 engine only: `KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p`). Write `~/.kimi-code/SYSTEM.md` to override the main prompt.
16 changes: 16 additions & 0 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ export function createProgram(
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(
new Option(
'--agent <name>',
'Agent profile to use for this invocation (v2 engine only). Custom profiles are discovered from agent directories or loaded via --agent-file.',
),
)
.addOption(
new Option(
'--agent-file <path>',
'Load an agent definition from a Markdown file and select it (v2 engine only). Can be repeated.',
)
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(
new Option(
'--add-dir <dir>',
Expand Down Expand Up @@ -133,6 +147,8 @@ export function createProgram(
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
prompt: raw['prompt'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
agent: raw['agent'] as string | undefined,
agentFiles: raw['agentFile'] as string[],
addDirs: raw['addDir'] as string[],
};

Expand Down
18 changes: 18 additions & 0 deletions apps/kimi-code/src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isKimiV2Enabled } from './experimental-v2';

export type UIMode = 'shell' | 'print';
export type PromptOutputFormat = 'text' | 'stream-json';

Expand Down Expand Up @@ -44,6 +46,8 @@ export interface CLIOptions {
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
skillsDirs: string[];
agent: string | undefined;
agentFiles: string[];
addDirs?: string[];
}

Expand Down Expand Up @@ -83,6 +87,20 @@ export function validateOptions(
if (promptMode && opts.plan) {
throw new OptionConflictError('Cannot combine --prompt with --plan.');
}
if (opts.agent !== undefined && opts.agent.trim().length === 0) {
throw new OptionConflictError('Agent cannot be empty.');
}
if (opts.agentFiles.some((file) => file.trim().length === 0)) {
throw new OptionConflictError('Agent file path cannot be empty.');
}
if (
(opts.agent !== undefined || opts.agentFiles.length > 0) &&
(!promptMode || !isKimiV2Enabled(env))
) {
throw new OptionConflictError(
'--agent/--agent-file are only available with the v2 engine (kimi -p with KIMI_CODE_EXPERIMENTAL_FLAG=1).',
);
}
if (promptMode && opts.session === '') {
throw new OptionConflictError('Cannot use --session without an id in prompt mode.');
}
Expand Down
81 changes: 74 additions & 7 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set.
*/

import { readFile } from 'node:fs/promises';

import {
IAgentGoalService,
IAgentLifecycleService,
Expand All @@ -24,17 +26,21 @@ import {
IAgentPromptService,
IAgentTaskService,
IAuthSummaryService,
IBootstrapService,
IConfigService,
IEventBus,
IOAuthToolkit,
ISessionIndex,
ISessionLifecycleService,
ITelemetryService,
agentCatalogRuntimeOptionsSeed,
bootstrap,
createCloudAppender,
ensureMainAgent,
hostRequestHeadersSeed,
logSeed,
parseAgentFileText,
resolveAgentPath,
resolveAgentTaskConfig,
resolveKimiHome,
resolveLoggingConfig,
Expand Down Expand Up @@ -120,6 +126,11 @@ export async function runV2Print(
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
// user / project discovery for this process.
...skillCatalogRuntimeOptionsSeed(opts.skillsDirs),
// `--agent-file`: explicit agent definition files, registered with the
// highest-precedence source for this process. Passed through unresolved —
// the engine expands `~` and resolves relative paths against the session
// workDir (mirroring `--skills-dir`).
...agentCatalogRuntimeOptionsSeed(opts.agentFiles),
]);
const auth = app.accessor.get(IOAuthToolkit);

Expand Down Expand Up @@ -236,6 +247,63 @@ async function resolveNativeSession(
const lifecycle = app.accessor.get(ISessionLifecycleService);
const index = app.accessor.get(ISessionIndex);

// `--agent` selects a catalog profile by name; otherwise the last
// `--agent-file` implicitly selects the profile that file defines. The file
// is parsed here (fatal on error) so a bad file fails before any turn.
let agentProfileName = opts.agent;
const lastAgentFile = opts.agentFiles.at(-1);
if (agentProfileName === undefined && lastAgentFile !== undefined) {
const agentFilePath = resolveAgentPath(
lastAgentFile,
workDir,
app.accessor.get(IBootstrapService).osHomeDir,
);
let agentFileText: string;
try {
agentFileText = await readFile(agentFilePath, 'utf8');
} catch (error) {
throw new Error(
`Failed to read agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
try {
agentProfileName = parseAgentFileText({
path: agentFilePath,
source: 'explicit',
text: agentFileText,
}).name;
} catch (error) {
throw new Error(
`Invalid agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}

// `--agent` / `--agent-file` bind an explicit profile; without them the
// historical setModel path (default profile on first bind) is kept. A
// same-name re-select on a resumed session keeps the profile and only applies
// an explicitly requested model; a different name is rejected by the
// engine's first-bind guard inside `bind`.
const applyProfileSelection = async (
profile: IAgentProfileService,
model: string | undefined,
): Promise<void> => {
if (agentProfileName !== undefined) {
if (profile.data().profileName === agentProfileName) {
if (model !== undefined) await profile.setModel(model);
return;
}
await profile.bind({
profile: agentProfileName,
model: requireConfiguredModel(model ?? profile.getModel(), defaultModel),
});
} else if (model !== undefined) {
await profile.setModel(model);
}
};

const resumeById = async (id: string): Promise<ISessionScopeHandle> => {
const session = await lifecycle.resume(id);
if (session === undefined) {
Expand Down Expand Up @@ -273,9 +341,7 @@ async function resolveNativeSession(
const session = await resumeById(opts.session);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
if (opts.model !== undefined) {
await profile.setModel(opts.model);
}
await applyProfileSelection(profile, opts.model);
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
Expand All @@ -294,9 +360,7 @@ async function resolveNativeSession(
const session = await resumeById(previous.id);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
if (opts.model !== undefined) {
await profile.setModel(opts.model);
}
await applyProfileSelection(profile, opts.model);
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
Expand All @@ -314,9 +378,12 @@ async function resolveNativeSession(
const session = await lifecycle.create({
workDir,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
mainAgentBinding: {
profile: agentProfileName ?? 'agent',
model,
},
});
const agent = await ensureMainAgent(session);
await agent.accessor.get(IAgentProfileService).setModel(model);
agent.accessor.get(IAgentPermissionModeService).setMode('auto');
return {
session,
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = {
outputFormat: undefined,
prompt: undefined,
skillsDirs: [],
agent: undefined,
agentFiles: [],
};

export function main(): void {
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/test/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ function defaultOpts(): CLIOptions {
outputFormat: undefined,
prompt: undefined,
skillsDirs: [],
agent: undefined,
agentFiles: [],
};
}

Expand Down
53 changes: 51 additions & 2 deletions apps/kimi-code/test/cli/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ describe('CLI options parsing', () => {
expect(opts.outputFormat).toBeUndefined();
expect(opts.prompt).toBeUndefined();
expect(opts.skillsDirs).toEqual([]);
expect(opts.agent).toBeUndefined();
expect(opts.agentFiles).toEqual([]);
expect(opts.addDirs).toEqual([]);
});
});
Expand Down Expand Up @@ -390,6 +392,55 @@ describe('CLI options parsing', () => {
});
});

describe('--agent / --agent-file', () => {
it('parses --agent and repeated --agent-file', () => {
const opts = parse([
'-p',
'hi',
'--agent',
'reviewer',
'--agent-file',
'a.md',
'--agent-file=b.md',
]);
expect(opts.agent).toBe('reviewer');
expect(opts.agentFiles).toEqual(['a.md', 'b.md']);
});

it('rejects empty agent values', () => {
const opts = parse(['-p', 'hi', '--agent', ' ']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow('Agent cannot be empty.');
});

it('rejects empty agent file values', () => {
const opts = parse(['-p', 'hi', '--agent-file', ' ']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow('Agent file path cannot be empty.');
});

it('rejects the flags in shell mode', () => {
const opts = parse(['--agent', 'reviewer']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow(
'--agent/--agent-file are only available with the v2 engine',
);
});

it('rejects the flags in prompt mode without the v2 engine flag', () => {
const opts = parse(['-p', 'hi', '--agent-file', 'a.md']);
expect(() => validateOptions(opts, {})).toThrow(OptionConflictError);
expect(() => validateOptions(opts, {})).toThrow(
'--agent/--agent-file are only available with the v2 engine',
);
});

it('accepts the flags in prompt mode with the v2 engine flag', () => {
const opts = parse(['-p', 'hi', '--agent', 'reviewer']);
expect(validateOptions(opts, { KIMI_CODE_EXPERIMENTAL_FLAG: '1' }).uiMode).toBe('print');
});
});

describe('--add-dir', () => {
it('parses one additional workspace directory', () => {
expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']);
Expand Down Expand Up @@ -483,13 +534,11 @@ describe('CLI options parsing', () => {
'--thinking',
'--print',
'--wire',
'--agent=default',
'--raw-model',
'--config-file=x',
'--quiet',
'--final-message-only',
'--input-format=text',
'--agent-file=x',
'--mcp-config={}',
'--mcp-config-file=/',
]) {
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/test/cli/run-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) {
outputFormat: undefined,
prompt: 'say hello',
skillsDirs: [],
agent: undefined,
agentFiles: [],
addDirs: [],
...overrides,
};
Expand Down
Loading