Skip to content

Commit fa2c5ce

Browse files
authored
feat: support plugin-contributed custom agents (#2365)
* feat: support plugin-contributed custom agents * fix: await plugin loading before agent catalog * fix: refresh plugin agents on v1 reload * test(agent-core-v2): add enabledSystemPrompts to the plugin service stub
1 parent 1896d1a commit fa2c5ce

33 files changed

Lines changed: 641 additions & 33 deletions

File tree

.changeset/plugin-custom-agents.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
Add support for plugin-contributed custom agents, discovered automatically and available for sub-agent delegation. Ship an `agents/` directory in the plugin (or declare `agents` paths in the plugin manifest) to provide them.

docs/en/customization/agents.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Beyond the three built-in sub-agents, you can define your own agents as Markdown
4545

4646
### Agent Locations
4747

48-
Kimi Code CLI discovers agent files by scope; more specific scopes take higher priority: **Explicit (`--agent-file`) > Project > Extra > User > Built-in**. When two files define the same `name`, the higher-priority scope wins. Each directory is scanned recursively for `.md` files.
48+
Kimi Code CLI discovers agent files by scope; more specific scopes take higher priority: **Explicit (`--agent-file`) > Project > Extra > User > Plugin > Built-in**. When two files define the same `name`, the higher-priority scope wins. Each directory is scanned recursively for `.md` files.
4949

5050
**User level** (applies to all projects):
5151
- `$KIMI_CODE_HOME/agents/` (default: `~/.kimi-code/agents/`)
@@ -63,6 +63,8 @@ The Kimi-specific user agent directory moves with `KIMI_CODE_HOME`, while the ge
6363
extra_agent_dirs = ["~/team-agents", ".agents/team-agents"]
6464
```
6565

66+
**Plugin level**: directories declared in an enabled plugin's manifest `agents` field (when omitted, the `agents/` directory under the plugin root is picked up automatically); see [Plugin Agents](./plugins.md#plugin-agents). Plugin agents outrank only the built-in agents.
67+
6668
**Built-in agents** are distributed with the CLI and have the lowest priority. A directory-discovered file does not override a same-name built-in Agent unless its frontmatter declares `override: true`. A file loaded through `--agent-file` is treated as explicit launch intent, may override a same-name built-in Agent, outranks every directory scope, and applies to the current launch only. Separately, `$KIMI_CODE_HOME/SYSTEM.md` permanently overrides the default main agent's system prompt (it is not part of agent-file discovery); its precedence interactions are covered in the SYSTEM.md section below.
6769

6870
::: warning Trust model

docs/en/customization/plugins.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Plugins
22

3-
Plugins package reusable Kimi Code CLI capabilities into installable units — they can add [Agent Skills](./skills.md), automatically load a specified Skill at session start, contribute system-prompt instructions, and declare MCP servers to provide real tool capabilities. They are ideal for sharing workflows with a team, connecting to external services, or installing extensions from the official marketplace.
3+
Plugins package reusable Kimi Code CLI capabilities into installable units — they can add [Agent Skills](./skills.md), custom [agents](./agents.md), automatically load a specified Skill at session start, contribute system-prompt instructions, and declare MCP servers to provide real tool capabilities. They are ideal for sharing workflows with a team, connecting to external services, or installing extensions from the official marketplace.
44

55
## Installation and Management
66

@@ -162,6 +162,7 @@ Supported fields:
162162
| `version`, `description`, `keywords`, `author`, `homepage`, `license` | Display metadata |
163163
| `interface` | Fields shown in `/plugins`: `displayName`, `shortDescription`, `longDescription`, `developerName`, `websiteURL` |
164164
| `skills` | One or more `./` paths; must be within the plugin root directory. When omitted, the `SKILL.md` in the root directory is treated as a single Skill root |
165+
| `agents` | One or more `./` paths; must be within the plugin root directory and point to directories containing [agent files](./agents.md#custom-agents). When omitted, the `agents/` directory under the plugin root (if present) is picked up automatically |
165166
| `sessionStart.skill` | Loads the specified plugin Skill into the main Agent when a new or resumed session starts |
166167
| `skillInstructions` | Additional instructions appended whenever a Skill from this plugin is loaded |
167168
| `systemPrompt` | Inline instructions contributed to the agent's system prompt while the plugin is enabled |
@@ -271,6 +272,19 @@ my-plugin/
271272

272273
Regardless of how a Skill is loaded (`sessionStart.skill`, `/skill:<name>`, or automatic model invocation), `skillInstructions` appears alongside that plugin's Skill.
273274

275+
## Plugin Agents
276+
277+
A plugin can ship custom agents: declare one or more `./` directories in the manifest's `agents` field (or simply place an `agents/` directory under the plugin root). The agent files inside use the same format as [custom agents](./agents.md#custom-agents) and, while the plugin is enabled, are discovered automatically and can be delegated to as sub-agents by the main Agent.
278+
279+
```text
280+
my-plugin/
281+
kimi.plugin.json
282+
agents/
283+
reviewer.md
284+
```
285+
286+
Plugin agents rank below every other file source: on a name collision, user-level, extra, project-level, and `--agent-file` agents all win over the plugin-provided one, and replacing a built-in agent still requires an explicit `override: true` in the frontmatter. After installing, enabling, disabling, or removing a plugin, the agent list refreshes in a new session (or on `/reload`); on the v2 engine the live session also refreshes after `/plugins reload`.
287+
274288
## MCP Servers in Plugins
275289

276290
When a plugin needs real tool capabilities, it can declare `mcpServers` in its manifest, reusing the [MCP](./mcp.md) schema.

docs/zh/customization/agents.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形
4545

4646
### Agent 目录
4747

48-
Kimi Code CLI 按作用域发现 Agent 文件,作用域越具体,优先级越高:**显式(`--agent-file`)> 项目 > 额外 > 用户 > 内置**。两个文件定义了相同的 `name` 时,高优先级作用域胜出。每个目录都会递归扫描 `.md` 文件。
48+
Kimi Code CLI 按作用域发现 Agent 文件,作用域越具体,优先级越高:**显式(`--agent-file`)> 项目 > 额外 > 用户 > Plugin > 内置**。两个文件定义了相同的 `name` 时,高优先级作用域胜出。每个目录都会递归扫描 `.md` 文件。
4949

5050
**用户级**(对所有项目生效):
5151
- `$KIMI_CODE_HOME/agents/`(默认:`~/.kimi-code/agents/`
@@ -63,6 +63,8 @@ Kimi 专属的用户 Agent 目录随 `KIMI_CODE_HOME` 移动,通用的 `~/.age
6363
extra_agent_dirs = ["~/team-agents", ".agents/team-agents"]
6464
```
6565

66+
**Plugin 级**:已启用 plugin 在其 manifest 的 `agents` 字段中声明的目录(省略时自动采用 plugin 根下的 `agents/` 目录),见[插件 Agent](./plugins.md#插件-agent)。Plugin Agent 优先级仅高于内置 Agent。
67+
6668
**内置 Agent** 随 CLI 分发,优先级最低。目录中发现的文件不会仅凭同名覆盖内置 Agent;如确需替换,必须在 Frontmatter 中声明 `override: true`。通过 `--agent-file` 加载的文件视为显式启动意图,可以覆盖同名内置 Agent,优先级高于所有目录作用域,且仅对本次启动生效。另外,`$KIMI_CODE_HOME/SYSTEM.md` 可永久覆盖默认主 Agent 的系统提示词(它不参与 Agent 文件发现),其优先级交互见下文 SYSTEM.md 小节。
6769

6870
::: warning 信任模型

docs/zh/customization/plugins.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Plugins
22

3-
Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以添加 [Agent Skills](./skills.md)、在会话启动时自动加载指定 Skill、提供系统提示词指令,也可以声明 MCP servers 来提供真实工具能力。适合把工作流共享给团队、连接外部服务,或从官方 marketplace 安装扩展。
3+
Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以添加 [Agent Skills](./skills.md)自定义 [Agent](./agents.md)在会话启动时自动加载指定 Skill、提供系统提示词指令,也可以声明 MCP servers 来提供真实工具能力。适合把工作流共享给团队、连接外部服务,或从官方 marketplace 安装扩展。
44

55
## 安装与管理
66

@@ -162,6 +162,7 @@ Plugin 是一个带 manifest 的目录或 zip 文件。Manifest 可以放在以
162162
| `version``description``keywords``author``homepage``license` | 展示元数据 |
163163
| `interface` |`/plugins` 中展示的字段:`displayName``shortDescription``longDescription``developerName``websiteURL` |
164164
| `skills` | 一个或多个 `./` 路径,必须位于 plugin 根目录内。省略时根目录的 `SKILL.md` 被当作单个 Skill root |
165+
| `agents` | 一个或多个 `./` 路径,必须位于 plugin 根目录内,指向含有 [Agent 文件](./agents.md#自定义-agent)的目录。省略时根下的 `agents/` 目录(若存在)被自动采用 |
165166
| `sessionStart.skill` | 在新会话或恢复会话开始时,把指定 plugin Skill 加载到主 Agent |
166167
| `skillInstructions` | 每次加载此 plugin 的 Skill 时一并附带的额外说明 |
167168
| `systemPrompt` | plugin 启用期间提供给 Agent 系统提示词的内联指令 |
@@ -271,6 +272,19 @@ my-plugin/
271272

272273
无论 Skill 通过哪种方式加载(`sessionStart.skill``/skill:<name>` 或模型自动调用),`skillInstructions` 都会随该 plugin 的 Skill 一起出现。
273274

275+
## 插件 Agent
276+
277+
Plugin 可以携带自定义 Agent:在 manifest 的 `agents` 字段里声明一个或多个 `./` 目录(或直接在 plugin 根下放置 `agents/` 目录),其中的 Agent 文件与[自定义 Agent](./agents.md#自定义-agent) 格式相同,会在 plugin 启用期间作为子 Agent 被主 Agent 自动发现和委派。
278+
279+
```text
280+
my-plugin/
281+
kimi.plugin.json
282+
agents/
283+
reviewer.md
284+
```
285+
286+
Plugin Agent 的优先级低于其他文件来源:同名时用户级、额外目录、项目级和 `--agent-file` 的 Agent 都会覆盖 plugin 提供的版本;替换内置 Agent 同样需要在 frontmatter 里显式写 `override: true`。安装、启用、禁用或移除 plugin 后,Agent 列表在新会话(或 `/reload`)时刷新;v2 引擎的当前会话还会在 `/plugins reload` 后刷新。
287+
274288
## Plugin 中的 MCP servers
275289

276290
当 plugin 需要真实工具能力时,可以在 manifest 中声明 `mcpServers`,复用 [MCP](./mcp.md) 的 schema。

packages/agent-core-v2/docs/state-manifest.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,7 @@ export interface AgentStateSnapshot {
10131013
'llmRequester.lastConfigLogSignature': string | undefined;
10141014
'llmRequester.mediaDegradedTurns': Set<number>;
10151015
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
1016-
readonly "__@mediaStripSnapshotBrand@2678": undefined;
1016+
readonly "__@mediaStripSnapshotBrand@2681": undefined;
10171017
}>;
10181018
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
10191019
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {

packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
* collisions). Mirrors `skillCatalog/skillSource`, with one deliberate
88
* deviation: `explicit` outranks every other source (in the skill system it
99
* aliases `user`) because `--agent-file` is a one-shot command-line intent that
10-
* must always win. Concrete sources (user at App scope; project / extra /
11-
* explicit at Session scope) each bind their own DI token extending this
12-
* contract.
10+
* must always win. Concrete sources (user at App scope; plugin / project /
11+
* extra / explicit at Session scope) each bind their own DI token extending
12+
* this contract.
1313
*
1414
* A source may mark `load()` failures as `fatal`: the Session catalog lets
1515
* them propagate into `ready` so awaiters see the error (`explicit` does —
@@ -38,6 +38,7 @@ export interface AgentProfileContribution {
3838
}
3939

4040
export const AGENT_PROFILE_SOURCE_PRIORITY = {
41+
plugin: 5,
4142
user: 10,
4243
extra: 20,
4344
project: 30,

packages/agent-core-v2/src/app/agentFileCatalog/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog';
1111

12-
export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit';
12+
export type AgentFileSource = 'plugin' | 'project' | 'user' | 'extra' | 'explicit';
1313

1414
export interface AgentFileRoot {
1515
readonly path: string;

packages/agent-core-v2/src/app/plugin/manager.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import path from 'node:path';
1212
import { Error2, PluginErrors } from '#/errors';
1313
import type { HookDef } from '#/agent/externalHooks/types';
1414
import type { McpServerConfig } from '#/agent/mcp/config-schema';
15+
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
1516
import { discoverFileSkills } from '#/app/skillCatalog/fileSkillDiscovery';
1617
import type { SkillDiscoveryResult } from '#/app/skillCatalog/skillDiscovery';
1718
import type { SkillRoot } from '#/app/skillCatalog/types';
@@ -313,6 +314,17 @@ export class PluginManager {
313314
return roots;
314315
}
315316

317+
pluginAgentRoots(): readonly AgentFileRoot[] {
318+
const roots: AgentFileRoot[] = [];
319+
for (const record of this.records.values()) {
320+
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
321+
for (const dir of record.manifest.agents ?? []) {
322+
roots.push({ path: dir, source: 'plugin' });
323+
}
324+
}
325+
return roots;
326+
}
327+
316328
enabledSessionStarts(): readonly EnabledPluginSessionStart[] {
317329
const out: EnabledPluginSessionStart[] = [];
318330
for (const record of this.records.values()) {

packages/agent-core-v2/src/app/plugin/manifest.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,22 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
9797
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
9898
}
9999

100-
let skills = await resolveSkillsField(pluginRoot, raw['skills'], diagnostics);
100+
let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics);
101101
if (raw['skills'] === undefined) {
102102
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
103103
if (await isFile(rootSkillMd)) {
104104
skills = [pluginRoot];
105105
}
106106
}
107107

108+
let agents = await resolveDirListField(pluginRoot, 'agents', raw['agents'], diagnostics);
109+
if (raw['agents'] === undefined) {
110+
const agentsDir = path.join(pluginRoot, 'agents');
111+
if (await isDir(agentsDir)) {
112+
agents = [agentsDir];
113+
}
114+
}
115+
108116
const skillInstructions =
109117
typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined;
110118

@@ -121,6 +129,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
121129
license: stringField(raw, 'license'),
122130
author: readAuthor(raw['author']),
123131
skills,
132+
agents,
124133
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
125134
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
126135
hooks: readHooks(raw['hooks'], diagnostics),
@@ -146,8 +155,9 @@ function recordUnsupportedRuntimeFields(
146155
}
147156
}
148157

149-
async function resolveSkillsField(
158+
async function resolveDirListField(
150159
pluginRoot: string,
160+
field: string,
151161
raw: unknown,
152162
diagnostics: PluginDiagnostic[],
153163
): Promise<readonly string[]> {
@@ -158,7 +168,7 @@ async function resolveSkillsField(
158168
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
159169
entries.push(...raw);
160170
} else {
161-
diagnostics.push({ severity: 'error', message: '"skills" must be a string or string[]' });
171+
diagnostics.push({ severity: 'error', message: `"${field}" must be a string or string[]` });
162172
return [];
163173
}
164174

@@ -167,7 +177,7 @@ async function resolveSkillsField(
167177
if (!entry.startsWith('./')) {
168178
diagnostics.push({
169179
severity: 'error',
170-
message: `"skills" path must start with "./" (got "${entry}")`,
180+
message: `"${field}" path must start with "./" (got "${entry}")`,
171181
});
172182
continue;
173183
}
@@ -182,14 +192,14 @@ async function resolveSkillsField(
182192
if (!isWithin(real, rootReal)) {
183193
diagnostics.push({
184194
severity: 'error',
185-
message: `"skills" path resolves outside the plugin (${entry})`,
195+
message: `"${field}" path resolves outside the plugin (${entry})`,
186196
});
187197
continue;
188198
}
189199
if (!(await isDir(real))) {
190200
diagnostics.push({
191201
severity: 'warn',
192-
message: `"skills" path is not a directory (${entry})`,
202+
message: `"${field}" path is not a directory (${entry})`,
193203
});
194204
continue;
195205
}

0 commit comments

Comments
 (0)