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
This commit is contained in:
7Sageer 2026-07-29 21:59:48 +08:00 committed by GitHub
parent 1896d1a13a
commit fa2c5ce18b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 641 additions and 33 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
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.

View file

@ -45,7 +45,7 @@ Beyond the three built-in sub-agents, you can define your own agents as Markdown
### Agent Locations
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.
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.
**User level** (applies to all projects):
- `$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
extra_agent_dirs = ["~/team-agents", ".agents/team-agents"]
```
**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.
**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.
::: warning Trust model

View file

@ -1,6 +1,6 @@
# Plugins
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.
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.
## Installation and Management
@ -162,6 +162,7 @@ Supported fields:
| `version`, `description`, `keywords`, `author`, `homepage`, `license` | Display metadata |
| `interface` | Fields shown in `/plugins`: `displayName`, `shortDescription`, `longDescription`, `developerName`, `websiteURL` |
| `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 |
| `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 |
| `sessionStart.skill` | Loads the specified plugin Skill into the main Agent when a new or resumed session starts |
| `skillInstructions` | Additional instructions appended whenever a Skill from this plugin is loaded |
| `systemPrompt` | Inline instructions contributed to the agent's system prompt while the plugin is enabled |
@ -271,6 +272,19 @@ my-plugin/
Regardless of how a Skill is loaded (`sessionStart.skill`, `/skill:<name>`, or automatic model invocation), `skillInstructions` appears alongside that plugin's Skill.
## Plugin Agents
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.
```text
my-plugin/
kimi.plugin.json
agents/
reviewer.md
```
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`.
## MCP Servers in Plugins
When a plugin needs real tool capabilities, it can declare `mcpServers` in its manifest, reusing the [MCP](./mcp.md) schema.

View file

@ -45,7 +45,7 @@ Kimi Code CLI 内置三种子 Agent开箱即用分别面向不同任务形
### Agent 目录
Kimi Code CLI 按作用域发现 Agent 文件,作用域越具体,优先级越高:**显式(`--agent-file`> 项目 > 额外 > 用户 > 内置**。两个文件定义了相同的 `name` 时,高优先级作用域胜出。每个目录都会递归扫描 `.md` 文件。
Kimi Code CLI 按作用域发现 Agent 文件,作用域越具体,优先级越高:**显式(`--agent-file`> 项目 > 额外 > 用户 > Plugin > 内置**。两个文件定义了相同的 `name` 时,高优先级作用域胜出。每个目录都会递归扫描 `.md` 文件。
**用户级**(对所有项目生效):
- `$KIMI_CODE_HOME/agents/`(默认:`~/.kimi-code/agents/`
@ -63,6 +63,8 @@ Kimi 专属的用户 Agent 目录随 `KIMI_CODE_HOME` 移动,通用的 `~/.age
extra_agent_dirs = ["~/team-agents", ".agents/team-agents"]
```
**Plugin 级**:已启用 plugin 在其 manifest 的 `agents` 字段中声明的目录(省略时自动采用 plugin 根下的 `agents/` 目录),见[插件 Agent](./plugins.md#插件-agent)。Plugin Agent 优先级仅高于内置 Agent。
**内置 Agent** 随 CLI 分发,优先级最低。目录中发现的文件不会仅凭同名覆盖内置 Agent如确需替换必须在 Frontmatter 中声明 `override: true`。通过 `--agent-file` 加载的文件视为显式启动意图,可以覆盖同名内置 Agent优先级高于所有目录作用域且仅对本次启动生效。另外`$KIMI_CODE_HOME/SYSTEM.md` 可永久覆盖默认主 Agent 的系统提示词(它不参与 Agent 文件发现),其优先级交互见下文 SYSTEM.md 小节。
::: warning 信任模型

View file

@ -1,6 +1,6 @@
# Plugins
Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以添加 [Agent Skills](./skills.md)、在会话启动时自动加载指定 Skill、提供系统提示词指令也可以声明 MCP servers 来提供真实工具能力。适合把工作流共享给团队、连接外部服务,或从官方 marketplace 安装扩展。
Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以添加 [Agent Skills](./skills.md)、自定义 [Agent](./agents.md)、在会话启动时自动加载指定 Skill、提供系统提示词指令也可以声明 MCP servers 来提供真实工具能力。适合把工作流共享给团队、连接外部服务,或从官方 marketplace 安装扩展。
## 安装与管理
@ -162,6 +162,7 @@ Plugin 是一个带 manifest 的目录或 zip 文件。Manifest 可以放在以
| `version``description``keywords``author``homepage``license` | 展示元数据 |
| `interface` | 在 `/plugins` 中展示的字段:`displayName``shortDescription``longDescription``developerName``websiteURL` |
| `skills` | 一个或多个 `./` 路径,必须位于 plugin 根目录内。省略时根目录的 `SKILL.md` 被当作单个 Skill root |
| `agents` | 一个或多个 `./` 路径,必须位于 plugin 根目录内,指向含有 [Agent 文件](./agents.md#自定义-agent)的目录。省略时根下的 `agents/` 目录(若存在)被自动采用 |
| `sessionStart.skill` | 在新会话或恢复会话开始时,把指定 plugin Skill 加载到主 Agent |
| `skillInstructions` | 每次加载此 plugin 的 Skill 时一并附带的额外说明 |
| `systemPrompt` | plugin 启用期间提供给 Agent 系统提示词的内联指令 |
@ -271,6 +272,19 @@ my-plugin/
无论 Skill 通过哪种方式加载(`sessionStart.skill``/skill:<name>` 或模型自动调用),`skillInstructions` 都会随该 plugin 的 Skill 一起出现。
## 插件 Agent
Plugin 可以携带自定义 Agent在 manifest 的 `agents` 字段里声明一个或多个 `./` 目录(或直接在 plugin 根下放置 `agents/` 目录),其中的 Agent 文件与[自定义 Agent](./agents.md#自定义-agent) 格式相同,会在 plugin 启用期间作为子 Agent 被主 Agent 自动发现和委派。
```text
my-plugin/
kimi.plugin.json
agents/
reviewer.md
```
Plugin Agent 的优先级低于其他文件来源:同名时用户级、额外目录、项目级和 `--agent-file` 的 Agent 都会覆盖 plugin 提供的版本;替换内置 Agent 同样需要在 frontmatter 里显式写 `override: true`。安装、启用、禁用或移除 plugin 后Agent 列表在新会话(或 `/reload`时刷新v2 引擎的当前会话还会在 `/plugins reload` 后刷新。
## Plugin 中的 MCP servers
当 plugin 需要真实工具能力时,可以在 manifest 中声明 `mcpServers`,复用 [MCP](./mcp.md) 的 schema。

View file

@ -1013,7 +1013,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2678": undefined;
readonly "__@mediaStripSnapshotBrand@2681": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {

View file

@ -7,9 +7,9 @@
* collisions). Mirrors `skillCatalog/skillSource`, with one deliberate
* deviation: `explicit` outranks every other source (in the skill system it
* aliases `user`) because `--agent-file` is a one-shot command-line intent that
* must always win. Concrete sources (user at App scope; project / extra /
* explicit at Session scope) each bind their own DI token extending this
* contract.
* must always win. Concrete sources (user at App scope; plugin / project /
* extra / explicit at Session scope) each bind their own DI token extending
* this contract.
*
* A source may mark `load()` failures as `fatal`: the Session catalog lets
* them propagate into `ready` so awaiters see the error (`explicit` does
@ -38,6 +38,7 @@ export interface AgentProfileContribution {
}
export const AGENT_PROFILE_SOURCE_PRIORITY = {
plugin: 5,
user: 10,
extra: 20,
project: 30,

View file

@ -9,7 +9,7 @@
import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog';
export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit';
export type AgentFileSource = 'plugin' | 'project' | 'user' | 'extra' | 'explicit';
export interface AgentFileRoot {
readonly path: string;

View file

@ -12,6 +12,7 @@ import path from 'node:path';
import { Error2, PluginErrors } from '#/errors';
import type { HookDef } from '#/agent/externalHooks/types';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import { discoverFileSkills } from '#/app/skillCatalog/fileSkillDiscovery';
import type { SkillDiscoveryResult } from '#/app/skillCatalog/skillDiscovery';
import type { SkillRoot } from '#/app/skillCatalog/types';
@ -313,6 +314,17 @@ export class PluginManager {
return roots;
}
pluginAgentRoots(): readonly AgentFileRoot[] {
const roots: AgentFileRoot[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const dir of record.manifest.agents ?? []) {
roots.push({ path: dir, source: 'plugin' });
}
}
return roots;
}
enabledSessionStarts(): readonly EnabledPluginSessionStart[] {
const out: EnabledPluginSessionStart[] = [];
for (const record of this.records.values()) {

View file

@ -97,7 +97,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
let skills = await resolveSkillsField(pluginRoot, raw['skills'], diagnostics);
let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics);
if (raw['skills'] === undefined) {
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
if (await isFile(rootSkillMd)) {
@ -105,6 +105,14 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
}
}
let agents = await resolveDirListField(pluginRoot, 'agents', raw['agents'], diagnostics);
if (raw['agents'] === undefined) {
const agentsDir = path.join(pluginRoot, 'agents');
if (await isDir(agentsDir)) {
agents = [agentsDir];
}
}
const skillInstructions =
typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined;
@ -121,6 +129,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
license: stringField(raw, 'license'),
author: readAuthor(raw['author']),
skills,
agents,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
hooks: readHooks(raw['hooks'], diagnostics),
@ -146,8 +155,9 @@ function recordUnsupportedRuntimeFields(
}
}
async function resolveSkillsField(
async function resolveDirListField(
pluginRoot: string,
field: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<readonly string[]> {
@ -158,7 +168,7 @@ async function resolveSkillsField(
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
entries.push(...raw);
} else {
diagnostics.push({ severity: 'error', message: '"skills" must be a string or string[]' });
diagnostics.push({ severity: 'error', message: `"${field}" must be a string or string[]` });
return [];
}
@ -167,7 +177,7 @@ async function resolveSkillsField(
if (!entry.startsWith('./')) {
diagnostics.push({
severity: 'error',
message: `"skills" path must start with "./" (got "${entry}")`,
message: `"${field}" path must start with "./" (got "${entry}")`,
});
continue;
}
@ -182,14 +192,14 @@ async function resolveSkillsField(
if (!isWithin(real, rootReal)) {
diagnostics.push({
severity: 'error',
message: `"skills" path resolves outside the plugin (${entry})`,
message: `"${field}" path resolves outside the plugin (${entry})`,
});
continue;
}
if (!(await isDir(real))) {
diagnostics.push({
severity: 'warn',
message: `"skills" path is not a directory (${entry})`,
message: `"${field}" path is not a directory (${entry})`,
});
continue;
}

View file

@ -11,6 +11,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio
import type { Event } from '#/_base/event';
import type { HookDef } from '#/agent/externalHooks/types';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import type { SkillRoot } from '#/app/skillCatalog/types';
import type {
@ -59,6 +60,7 @@ export interface IPluginService {
listPluginCommands(): Promise<readonly PluginCommandDef[]>;
checkUpdates(): Promise<readonly PluginUpdateStatus[]>;
pluginSkillRoots(): Promise<readonly SkillRoot[]>;
pluginAgentRoots(): Promise<readonly AgentFileRoot[]>;
enabledSessionStarts(): Promise<readonly EnabledPluginSessionStart[]>;
enabledSystemPrompts(): Promise<readonly EnabledPluginSystemPrompt[]>;
enabledMcpServers(): Promise<Record<string, McpServerConfig>>;

View file

@ -21,6 +21,7 @@ import { IProviderService } from '#/kosong/provider/provider';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import type { HookDef } from '#/agent/externalHooks/types';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import type { SkillRoot } from '#/app/skillCatalog/types';
import { PluginManager } from './manager';
@ -158,6 +159,10 @@ export class PluginService extends Disposable implements IPluginService {
return this.runConsumptionRead([], async () => this.manager.pluginSkillRoots());
}
pluginAgentRoots(): Promise<readonly AgentFileRoot[]> {
return this.runConsumptionRead([], async () => this.manager.pluginAgentRoots());
}
enabledSessionStarts(): Promise<readonly EnabledPluginSessionStart[]> {
return this.runConsumptionRead([], async () => this.manager.enabledSessionStarts());
}

View file

@ -34,6 +34,7 @@ export interface PluginManifest {
readonly homepage?: string;
readonly license?: string;
readonly skills?: readonly string[];
readonly agents?: readonly string[];
readonly sessionStart?: PluginSessionStart;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly hooks?: readonly HookDefConfig[];

View file

@ -212,6 +212,7 @@ export * from '#/session/sessionSkillCatalog/workspaceFileSkillSource';
export * from '#/session/sessionSkillCatalog/pluginSkillSource';
export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService';
export * from '#/session/sessionAgentProfileCatalog/pluginAgentProfileSource';
export * from '#/session/sessionAgentProfileCatalog/projectFileAgentSource';
export * from '#/session/sessionAgentProfileCatalog/extraFileAgentSource';
export * from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource';

View file

@ -0,0 +1,74 @@
/**
* `sessionAgentProfileCatalog` domain (L3) plugin `IAgentProfileSource`
* producer.
*
* Discovers agent profiles contributed by enabled plugins (roots from
* `plugin.pluginAgentRoots()`), contributing them at priority 5 (above
* builtin, below user / extra / project / explicit, so every other file
* source wins name collisions). `${base_prompt}` is backed by the user
* source's effective default profile. Re-emits `plugin.onDidReload` as
* `onDidChange` so the catalog re-pulls plugin agents when plugins reload;
* install / enable / remove mutations deliberately do not refresh the
* session catalog those take effect on the next explicit reload. Bound at
* Session scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/_base/log/log';
import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery';
import {
AGENT_PROFILE_SOURCE_PRIORITY,
profilesFromDiscovery,
type AgentProfileContribution,
type IAgentProfileSource,
} from '#/app/agentFileCatalog/agentProfileSource';
import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource';
import { IPluginService } from '#/app/plugin/plugin';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
export interface IPluginAgentProfileSource extends IAgentProfileSource {
readonly _serviceBrand: undefined;
}
export const IPluginAgentProfileSource: ServiceIdentifier<IPluginAgentProfileSource> =
createDecorator<IPluginAgentProfileSource>('pluginAgentProfileSource');
export class PluginAgentProfileSource implements IPluginAgentProfileSource {
declare readonly _serviceBrand: undefined;
readonly id = 'plugin';
readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.plugin;
readonly onDidChange: Event<void> = (listener, thisArg, disposables) =>
this.plugins.onDidReload(
() => listener.call(thisArg, undefined as void),
undefined,
disposables,
);
constructor(
@IPluginService private readonly plugins: IPluginService,
@IHostFileSystem private readonly fs: IHostFileSystem,
@ILogService private readonly log: ILogService,
@IUserFileAgentSource private readonly user: IUserFileAgentSource,
) {}
async load(): Promise<AgentProfileContribution> {
const roots = await this.plugins.pluginAgentRoots();
return profilesFromDiscovery(
await discoverAgentFiles(this.fs, roots, (message) => {
this.log.warn(message);
}),
(context) => this.user.getDefaultProfile().systemPrompt(context),
);
}
}
registerScopedService(
LifecycleScope.Session,
IPluginAgentProfileSource,
PluginAgentProfileSource,
ScopeActivation.OnScopeCreated,
'sessionAgentProfileCatalog',
);

View file

@ -3,8 +3,9 @@
* implementation.
*
* Merges the builtin (code-contribution) App catalog with the file-backed
* sources (user / extra / project / explicit) by priority, requiring an
* explicit opt-in before a file replaces a same-name builtin, and serializing
* sources (plugin / user / extra / project / explicit) by priority, requiring
* an explicit opt-in before a file replaces a same-name builtin, and
* serializing
* refreshes per source the same way `sessionSkillCatalog` does. The merged
* view always contains the builtin profiles (seeded at construction); file
* profiles appear once `ready` resolves. A rejecting `fatal` source (an
@ -43,6 +44,7 @@ import { ISessionStateService } from '#/session/state/sessionState';
import { IExplicitFileAgentSource } from './explicitFileAgentSource';
import { IExtraFileAgentSource } from './extraFileAgentSource';
import { IPluginAgentProfileSource } from './pluginAgentProfileSource';
import { IProjectFileAgentSource } from './projectFileAgentSource';
import { ISessionAgentProfileCatalog } from './sessionAgentProfileCatalog';
@ -69,6 +71,7 @@ export class SessionAgentProfileCatalogService
constructor(
@ISessionStateService private readonly states: ISessionStateService,
@IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService,
@IPluginAgentProfileSource plugin: IPluginAgentProfileSource,
@IUserFileAgentSource user: IUserFileAgentSource,
@IExtraFileAgentSource extra: IExtraFileAgentSource,
@IProjectFileAgentSource project: IProjectFileAgentSource,
@ -78,7 +81,7 @@ export class SessionAgentProfileCatalogService
super();
this.states.register(agentProfileCatalogContributionsKey);
this.states.register(agentProfileCatalogMergedKey);
this.sources = [user, extra, project, explicit].toSorted(
this.sources = [plugin, user, extra, project, explicit].toSorted(
(a, b) => a.priority - b.priority,
);
for (const s of this.sources) {

View file

@ -59,6 +59,7 @@ function pluginServiceStub(options: PluginServiceStubOptions): IPluginService {
listPluginCommands: async () => [],
checkUpdates: async () => [],
pluginSkillRoots: async () => [],
pluginAgentRoots: async () => [],
enabledSessionStarts: async () => options.sessionStarts,
enabledSystemPrompts: async () => [],
enabledMcpServers: async () => ({}),

View file

@ -44,6 +44,7 @@ async function makePlugin(
options: {
skills?: boolean;
skillNames?: readonly string[];
agents?: boolean;
version?: string;
sessionStartSkill?: string;
systemPrompt?: string;
@ -70,6 +71,15 @@ async function makePlugin(
);
}
}
if (options.agents === true) {
manifest['agents'] = './agents/';
await mkdir(path.join(root, 'agents'), { recursive: true });
await writeFile(
path.join(root, 'agents', 'demo-agent.md'),
'---\nname: demo-agent\ndescription: A demo agent\n---\nbody',
'utf8',
);
}
if (options.sessionStartSkill !== undefined) {
manifest['sessionStart'] = { skill: options.sessionStartSkill };
}
@ -179,6 +189,27 @@ describe('PluginManager consumption plane', () => {
});
});
it('pluginAgentRoots() returns only enabled plugins agents paths', async () => {
const home = await makeKimiHome();
const a = await makePlugin('a', { agents: true });
const b = await makePlugin('b', { agents: true });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(a);
await manager.install(b);
await manager.setEnabled('b', false);
const managedA = await managedPluginRoot(manager, 'a');
const managedB = await managedPluginRoot(manager, 'b');
expect(manager.pluginAgentRoots()).toContainEqual({
path: path.join(managedA, 'agents'),
source: 'plugin',
});
expect(manager.pluginAgentRoots()).not.toContainEqual({
path: path.join(managedB, 'agents'),
source: 'plugin',
});
});
it('pluginSkillRoots() excludes plugins in error state', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');

View file

@ -63,6 +63,55 @@ describe('plugin manifest parser', () => {
]);
});
it('resolves explicit agents directories', async () => {
await mkdir(join(dir, 'agents'), { recursive: true });
await writeFile(
join(dir, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo', agents: ['./agents'] }),
'utf8',
);
const result = await parseManifest(dir);
const root = await realpath(dir);
expect(result.manifest?.agents).toEqual([join(root, 'agents')]);
expect(result.diagnostics).toEqual([]);
});
it('defaults agents to the ./agents directory when the field is absent', async () => {
await mkdir(join(dir, 'agents'), { recursive: true });
await writeFile(join(dir, 'kimi.plugin.json'), JSON.stringify({ name: 'demo' }), 'utf8');
const result = await parseManifest(dir);
expect(result.manifest?.agents).toEqual([join(dir, 'agents')]);
expect(result.diagnostics).toEqual([]);
});
it('keeps agents empty when the field is absent and no ./agents directory exists', async () => {
await writeFile(join(dir, 'kimi.plugin.json'), JSON.stringify({ name: 'demo' }), 'utf8');
const result = await parseManifest(dir);
expect(result.manifest?.agents).toEqual([]);
expect(result.diagnostics).toEqual([]);
});
it('warns on invalid agents paths', async () => {
await writeFile(
join(dir, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo', agents: ['../outside'] }),
'utf8',
);
const result = await parseManifest(dir);
expect(result.manifest?.agents).toEqual([]);
expect(result.diagnostics.map((d) => d.message)).toEqual([
'"agents" path must start with "./" (got "../outside")',
]);
});
it('reads the systemPrompt field, trimming surrounding whitespace', async () => {
await writeFile(
join(dir, 'kimi.plugin.json'),

View file

@ -32,8 +32,11 @@ import {
IUserFileAgentSource,
UserFileAgentSource,
} from '#/app/agentFileCatalog/userFileAgentSource';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IPluginService } from '#/app/plugin/plugin';
import type { ReloadSummary } from '#/app/plugin/types';
import '#/index';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
@ -45,6 +48,10 @@ import {
ExtraFileAgentSource,
IExtraFileAgentSource,
} from '#/session/sessionAgentProfileCatalog/extraFileAgentSource';
import {
IPluginAgentProfileSource,
PluginAgentProfileSource,
} from '#/session/sessionAgentProfileCatalog/pluginAgentProfileSource';
import {
IProjectFileAgentSource,
ProjectFileAgentSource,
@ -113,6 +120,33 @@ function workspaceStub(workDir: string): ISessionWorkspaceContext {
};
}
function pluginStub(
agentRoots: readonly AgentFileRoot[] = [],
reloadEmitter?: Emitter<ReloadSummary>,
): IPluginService {
return {
_serviceBrand: undefined,
onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }),
listPlugins: async () => [],
installPlugin: async () => ({ id: '' }) as never,
setPluginEnabled: async () => {},
setPluginMcpServerEnabled: async () => {},
removePlugin: async () => {},
reloadPlugins: async () => ({ added: [], removed: [], errors: [] }),
getPluginInfo: async () => {
throw new Error('getPluginInfo is not used by these tests');
},
listPluginCommands: async () => [],
checkUpdates: async () => [],
pluginSkillRoots: async () => [],
pluginAgentRoots: async () => agentRoots,
enabledSessionStarts: async () => [],
enabledSystemPrompts: async () => [],
enabledMcpServers: async () => ({}),
enabledHooks: async () => [],
};
}
function agentMd(name: string, description: string, override = false): string {
const overrideLine = override ? 'override: true\n' : '';
return `---\nname: ${name}\ndescription: ${description}\n${overrideLine}---\n\nYou are ${name}.\n`;
@ -174,6 +208,8 @@ function makeSession(
readonly logWarnings?: string[];
readonly userSource?: IUserFileAgentSource;
readonly explicitSource?: IExplicitFileAgentSource;
readonly pluginAgentRoots?: readonly AgentFileRoot[];
readonly pluginReloadEmitter?: Emitter<ReloadSummary>;
},
) {
const config = configStub();
@ -190,6 +226,10 @@ function makeSession(
stubPair(IConfigService, config),
stubPair(IAgentCatalogRuntimeOptions, runtimeOptions),
stubPair(ILogService, logStub()),
stubPair(
IPluginService,
pluginStub(opts?.pluginAgentRoots ?? [], opts?.pluginReloadEmitter),
),
...(opts?.userSource ? [stubPair(IUserFileAgentSource, opts.userSource)] : []),
]);
const session = host.child(LifecycleScope.Session, 's1', [
@ -248,6 +288,11 @@ describe('SessionAgentProfileCatalogService', () => {
IProjectFileAgentSource,
ProjectFileAgentSource,
);
registerScopedService(
LifecycleScope.Session,
IPluginAgentProfileSource,
PluginAgentProfileSource,
);
});
it('lists builtin profiles when no agent directories exist', async () => {
@ -311,6 +356,47 @@ describe('SessionAgentProfileCatalogService', () => {
});
});
it('merges plugin agents below user; user wins on name collision', async () => {
await withFixture(async (fixture) => {
const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents');
await writeAgent(pluginAgentsDir, 'shared.md', agentMd('shared', 'from plugin'));
await writeAgent(pluginAgentsDir, 'plugin-only.md', agentMd('plugin-only', 'from plugin'));
await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user'));
const { host, session } = makeSession(fixture, {
pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }],
});
const catalog = session.accessor.get(ISessionAgentProfileCatalog);
await catalog.load();
expect(catalog.get('shared')?.description).toBe('from user');
expect(catalog.get('plugin-only')?.description).toBe('from plugin');
host.dispose();
});
});
it('reloads the plugin source when plugins reload', async () => {
await withFixture(async (fixture) => {
const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents');
await mkdir(pluginAgentsDir, { recursive: true });
const reloadEmitter = new Emitter<ReloadSummary>();
const { host, session } = makeSession(fixture, {
pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }],
pluginReloadEmitter: reloadEmitter,
});
const catalog = session.accessor.get(ISessionAgentProfileCatalog);
await catalog.load();
expect(catalog.get('late')).toBeUndefined();
await writeAgent(pluginAgentsDir, 'late.md', agentMd('late', 'late plugin agent'));
const changed = waitForEvent(catalog.onDidChange);
reloadEmitter.fire({ added: [], removed: [], errors: [] });
await changed;
expect(catalog.get('late')?.description).toBe('late plugin agent');
host.dispose();
});
});
it('fails ready when an explicit agent file is invalid', async () => {
await withFixture(async (fixture) => {
const bad = await writeAgent(

View file

@ -118,6 +118,7 @@ function pluginStub(
listPluginCommands: async () => [],
checkUpdates: async () => [],
pluginSkillRoots: async () => skillRoots,
pluginAgentRoots: async () => [],
enabledSessionStarts: async () => [],
enabledSystemPrompts: async () => [],
enabledMcpServers: async () => ({}),

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import type { McpServerConfig } from '../config/schema';
import type { AgentFileRoot } from '../profile/agentfile/types';
import { discoverSkills, type SkillRoot } from '../skill';
import type { HookDef } from '../session/hooks';
import { loadPluginCommand } from './commands';
@ -216,6 +217,17 @@ export class PluginManager {
return roots;
}
pluginAgentRoots(): readonly AgentFileRoot[] {
const roots: AgentFileRoot[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const dir of record.manifest.agents ?? []) {
roots.push({ path: dir, source: 'plugin' });
}
}
return roots;
}
enabledSessionStarts(): readonly EnabledPluginSessionStart[] {
const out: EnabledPluginSessionStart[] = [];
for (const record of this.records.values()) {

View file

@ -103,7 +103,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
let skills = await resolveSkillsField(pluginRoot, raw['skills'], diagnostics);
let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics);
if (raw['skills'] === undefined) {
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
if (await isFile(rootSkillMd)) {
@ -111,6 +111,14 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
}
}
let agents = await resolveDirListField(pluginRoot, 'agents', raw['agents'], diagnostics);
if (raw['agents'] === undefined) {
const agentsDir = path.join(pluginRoot, 'agents');
if (await isDir(agentsDir)) {
agents = [agentsDir];
}
}
const skillInstructions =
typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined;
@ -127,6 +135,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
license: stringField(raw, 'license'),
author: readAuthor(raw['author']),
skills,
agents,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
hooks: readHooks(raw['hooks'], diagnostics),
@ -152,8 +161,9 @@ function recordUnsupportedRuntimeFields(
}
}
async function resolveSkillsField(
async function resolveDirListField(
pluginRoot: string,
field: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<readonly string[]> {
@ -164,7 +174,7 @@ async function resolveSkillsField(
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
entries.push(...raw);
} else {
diagnostics.push({ severity: 'error', message: '"skills" must be a string or string[]' });
diagnostics.push({ severity: 'error', message: `"${field}" must be a string or string[]` });
return [];
}
@ -173,7 +183,7 @@ async function resolveSkillsField(
if (!entry.startsWith('./')) {
diagnostics.push({
severity: 'error',
message: `"skills" path must start with "./" (got "${entry}")`,
message: `"${field}" path must start with "./" (got "${entry}")`,
});
continue;
}
@ -188,14 +198,14 @@ async function resolveSkillsField(
if (!isWithin(real, rootReal)) {
diagnostics.push({
severity: 'error',
message: `"skills" path resolves outside the plugin (${entry})`,
message: `"${field}" path resolves outside the plugin (${entry})`,
});
continue;
}
if (!(await isDir(real))) {
diagnostics.push({
severity: 'warn',
message: `"skills" path is not a directory (${entry})`,
message: `"${field}" path is not a directory (${entry})`,
});
continue;
}

View file

@ -33,6 +33,7 @@ export interface PluginManifest {
readonly homepage?: string;
readonly license?: string;
readonly skills?: readonly string[]; // resolved absolute paths
readonly agents?: readonly string[]; // resolved absolute paths
readonly sessionStart?: PluginSessionStart;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly hooks?: readonly HookDefConfig[];

View file

@ -2,7 +2,8 @@
* Session-level agent profile catalog.
*
* Merges the builtin (code-embedded) profiles with the file-backed sources
* (user / extra / project / explicit) by priority, requiring an explicit
* (plugin / user / extra / project / explicit) by priority, requiring an
* explicit
* opt-in (`override: true`) before a file replaces a same-name builtin. The
* merged view always contains the builtin profiles (seeded at construction);
* file profiles appear once `ready` resolves. A failing `explicit` source (an
@ -34,6 +35,7 @@ import { describeInactiveToolPattern, findInactiveToolPatterns } from './validat
import {
AgentProfileCatalogSnapshotSchema,
type AgentFileDefinition,
type AgentFileRoot,
type AgentFileSource,
type AgentProfileCatalogSnapshot,
} from './types';
@ -48,10 +50,13 @@ export interface SessionAgentCatalogOptions {
readonly osHomeDir: string;
readonly extraDirs?: readonly string[];
readonly explicitFiles?: readonly string[];
/** Agent directories contributed by enabled plugins (lowest file priority). */
readonly pluginRoots?: readonly AgentFileRoot[];
readonly warn?: (message: string, error?: unknown) => void;
}
const SOURCE_PRIORITY: Readonly<Record<AgentFileSource, number>> = {
plugin: 5,
user: 10,
extra: 20,
project: 30,
@ -123,6 +128,44 @@ export class SessionAgentProfileCatalog {
/** Replace live discovery with the file-backed catalog bound at creation. */
restoreSnapshot(snapshot: AgentProfileCatalogSnapshot): void {
const restored = AgentProfileCatalogSnapshotSchema.parse(snapshot);
const { entries } = this.entriesFromSnapshot(restored);
this.applyFileEntries(entries);
this.snapshotValue = restored;
}
/** Replace only the persisted plugin layer while keeping the session-bound local profiles. */
async restoreSnapshotRefreshingPlugins(
snapshot: AgentProfileCatalogSnapshot,
pluginRoots: readonly AgentFileRoot[],
): Promise<void> {
const restored = AgentProfileCatalogSnapshotSchema.parse(snapshot);
const { effectiveDefault, entries, systemMd } = this.entriesFromSnapshot(
restored,
(profile) => profile.source !== 'plugin',
);
if (pluginRoots.length > 0) {
const discovered = await discoverAgentFiles(pluginRoots, this.warn);
for (const definition of discovered.agents) {
this.warnInactivePatterns(definition);
entries.push(this.entryFromDefinition(definition, effectiveDefault));
}
}
const winners = this.applyFileEntries(entries);
this.snapshotValue = this.snapshotFromEntries(winners, systemMd);
}
private entriesFromSnapshot(
restored: AgentProfileCatalogSnapshot,
includeProfile: (
profile: AgentProfileCatalogSnapshot['profiles'][number],
) => boolean = () => true,
): {
readonly effectiveDefault: ResolvedAgentProfile;
readonly entries: FileProfileEntry[];
readonly systemMd: AgentFileDefinition | undefined;
} {
this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES));
const builtinDefault = this.getDefault();
@ -137,6 +180,7 @@ export class SessionAgentProfileCatalog {
entries.push(this.systemMdEntry(systemMd, effectiveDefault));
}
for (const profile of restored.profiles) {
if (!includeProfile(profile)) continue;
const definition: AgentFileDefinition = {
name: profile.name,
description: profile.description,
@ -148,13 +192,12 @@ export class SessionAgentProfileCatalog {
modelPreference: profile.modelPreference,
prompt: profile.prompt,
path: `<session-agent-profile:${profile.name}>`,
source: 'explicit',
source: profile.source ?? 'explicit',
};
entries.push(this.entryFromDefinition(definition, effectiveDefault));
}
this.applyFileEntries(entries);
this.snapshotValue = restored;
return { effectiveDefault, entries, systemMd };
}
/**
@ -217,6 +260,15 @@ export class SessionAgentProfileCatalog {
}
}
const pluginRoots = this.options.pluginRoots ?? [];
if (pluginRoots.length > 0) {
const discovered = await discoverAgentFiles(pluginRoots, warn);
for (const definition of discovered.agents) {
this.warnInactivePatterns(definition);
entries.push(this.entryFromDefinition(definition, effectiveDefault));
}
}
// ── Explicit source (fatal) ──────────────────────────────────────
// Match v2's per-source merge semantics: when several explicit files
// declare the same profile name, the last file replaces the earlier one.
@ -355,6 +407,7 @@ export class SessionAgentProfileCatalog {
subagents: Object.keys(profile.subagents ?? {}),
modelPreference: profile.modelPreference,
prompt: definition.prompt,
source: definition.source,
}));
if (systemMd === undefined && profiles.length === 0) return undefined;
return AgentProfileCatalogSnapshotSchema.parse({

View file

@ -11,7 +11,7 @@
import type { AgentModelPreference } from '../types';
import { z } from 'zod';
export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit';
export type AgentFileSource = 'plugin' | 'project' | 'user' | 'extra' | 'explicit';
export interface AgentFileRoot {
readonly path: string;
@ -52,6 +52,7 @@ const AgentProfileSnapshotSchema = z.object({
subagents: z.array(z.string()),
modelPreference: z.enum(['primary', 'secondary']).optional(),
prompt: z.string(),
source: z.enum(['plugin', 'project', 'user', 'extra', 'explicit']).optional(),
});
/**

View file

@ -346,12 +346,14 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
);
}
};
await this.pluginsReady;
const agentCatalog = new SessionAgentProfileCatalog({
workDir,
brandHomeDir: this.homeDir,
osHomeDir: this.userHomeDir,
extraDirs: config.extraAgentDirs,
explicitFiles: options.agentFiles,
pluginRoots: this.plugins.pluginAgentRoots(),
warn: (message, error) => {
agentCatalogWarnings.push({ message, error });
},
@ -498,6 +500,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
kaos?: Kaos;
persistenceKaos?: Kaos;
forcePluginSessionStartReminder?: boolean;
refreshPluginAgents?: boolean;
},
): Promise<ResumeSessionResult> {
const summary = await this.sessionStore.get(input.sessionId);
@ -567,6 +570,9 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
agents: {
userHomeDir: this.userHomeDir,
extraDirs: config.extraAgentDirs,
pluginRoots:
overrides.refreshPluginAgents === true ? this.plugins.pluginAgentRoots() : undefined,
refreshPluginAgents: overrides.refreshPluginAgents,
},
mcpConfig,
experimentalFlags: this.experimentalFlags,
@ -585,6 +591,9 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
warning = resumeResult.warning;
await session.assertMainProfileSelection(input.agentProfile);
await this.refreshSessionRuntimeConfig(session, config);
if (overrides.refreshPluginAgents === true) {
await session.writeMetadata();
}
} catch (error) {
await session.close().catch(() => {});
withTelemetryContext(this.telemetry, { sessionId: summary.id }).track('session_load_failed', {
@ -628,7 +637,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
}
return this.resumeSessionWithOverrides(
{ sessionId: summary.id },
{ forcePluginSessionStartReminder: input.forcePluginSessionStartReminder },
{
forcePluginSessionStartReminder: input.forcePluginSessionStartReminder,
refreshPluginAgents: true,
},
);
}

View file

@ -42,6 +42,7 @@ import {
SessionAgentProfileCatalog,
loadAgentsMd,
prepareSystemPromptContext,
type AgentFileRoot,
type AgentProfileCatalogSnapshot,
type ResolvedAgentProfile,
} from '../profile';
@ -128,6 +129,10 @@ export interface SessionAgentCatalogConfig {
readonly explicitFiles?: readonly string[];
readonly extraDirs?: readonly string[];
readonly profileName?: string;
/** Agent directories contributed by enabled plugins (lowest file priority). */
readonly pluginRoots?: readonly AgentFileRoot[];
/** Refresh only the plugin contribution when restoring the persisted catalog. */
readonly refreshPluginAgents?: boolean;
/** Already-loaded catalog prepared before a persistent session is created. */
readonly catalog?: SessionAgentProfileCatalog;
}
@ -300,6 +305,7 @@ export class Session {
osHomeDir: options.agents?.userHomeDir ?? homedir(),
extraDirs: options.agents?.extraDirs ?? options.config?.extraAgentDirs,
explicitFiles: options.agents?.explicitFiles,
pluginRoots: options.agents?.pluginRoots,
warn: (message, error) => {
this.log.warn(message, error === undefined ? undefined : { error });
},
@ -1030,15 +1036,29 @@ export class Session {
const persisted = JSON.parse(text) as PersistedSessionState;
const { agentProfileCatalog, ...metadata } = persisted;
this.metadata = metadata;
if (agentProfileCatalog !== undefined) {
if (agentProfileCatalog === undefined) {
if (this.options.agents?.refreshPluginAgents === true) {
this.agentProfileSnapshot = this.agentCatalog.snapshot();
}
} else {
const parsed = AgentProfileCatalogSnapshotSchema.safeParse(agentProfileCatalog);
if (parsed.success) {
this.agentProfileSnapshot = parsed.data;
this.agentCatalog.restoreSnapshot(parsed.data);
if (this.options.agents?.refreshPluginAgents === true) {
await this.agentCatalog.restoreSnapshotRefreshingPlugins(
parsed.data,
this.options.agents.pluginRoots ?? [],
);
} else {
this.agentCatalog.restoreSnapshot(parsed.data);
}
this.agentProfileSnapshot = this.agentCatalog.snapshot();
} else {
this.log.warn('stored agent profile catalog is invalid; using discovered profiles', {
error: parsed.error.message,
});
if (this.options.agents?.refreshPluginAgents === true) {
this.agentProfileSnapshot = this.agentCatalog.snapshot();
}
}
}
return this.metadata;

View file

@ -1176,6 +1176,97 @@ base_url = "https://search.example.test/v1"
expect(reminders.at(-1)).toContain('supersedes any earlier plugin_session_start');
});
it('replaces only the plugin agent layer when a plugin is disabled before reload', async () => {
tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
const homeDir = join(tmp, 'home');
const workDir = join(tmp, 'work');
const pluginRoot = join(tmp, 'plugin');
await mkdir(join(homeDir, 'agents'), { recursive: true });
await mkdir(workDir, { recursive: true });
await mkdir(join(pluginRoot, 'agents'), { recursive: true });
await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
await writeFile(
join(homeDir, 'agents', 'local-reviewer.md'),
'---\nname: local-reviewer\ndescription: Local reviewer\n---\n\nLocal prompt.\n',
);
await writeFile(
join(pluginRoot, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo', agents: ['./agents'] }),
);
await writeFile(
join(pluginRoot, 'agents', 'plugin-reviewer.md'),
'---\nname: plugin-reviewer\ndescription: Plugin reviewer\n---\n\nPlugin prompt.\n',
);
const [coreRpc, sdkRpc] = createRPC<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ decision: 'rejected' })),
requestQuestion: vi.fn(async () => null),
toolCall: vi.fn(async () => ({ output: '' })),
});
await core.installPlugin({ source: pluginRoot });
const created = await rpc.createSession({
id: 'ses_runtime_reload_plugin_agents',
workDir,
model: 'default-mock',
});
expect(core.sessions.get(created.id)?.agentCatalog.get('plugin-reviewer')).toBeDefined();
expect(core.sessions.get(created.id)?.agentCatalog.get('local-reviewer')).toBeDefined();
await core.setPluginEnabled({ id: 'demo', enabled: false });
await rpc.reloadSession({ sessionId: created.id });
const reloadedCatalog = core.sessions.get(created.id)?.agentCatalog;
expect(reloadedCatalog?.get('plugin-reviewer')).toBeUndefined();
expect(reloadedCatalog?.get('local-reviewer')?.description).toBe('Local reviewer');
});
it('adds a newly installed plugin agent on reload and preserves it on resume', async () => {
tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
const homeDir = join(tmp, 'home');
const workDir = join(tmp, 'work');
const pluginRoot = join(tmp, 'plugin');
await mkdir(homeDir, { recursive: true });
await mkdir(workDir, { recursive: true });
await mkdir(join(pluginRoot, 'agents'), { recursive: true });
await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
await writeFile(
join(pluginRoot, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo', agents: ['./agents'] }),
);
await writeFile(
join(pluginRoot, 'agents', 'plugin-reviewer.md'),
'---\nname: plugin-reviewer\ndescription: Plugin reviewer\n---\n\nPlugin prompt.\n',
);
const [coreRpc, sdkRpc] = createRPC<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ decision: 'rejected' })),
requestQuestion: vi.fn(async () => null),
toolCall: vi.fn(async () => ({ output: '' })),
});
const created = await rpc.createSession({
id: 'ses_runtime_reload_new_plugin_agent',
workDir,
model: 'default-mock',
});
expect(core.sessions.get(created.id)?.agentCatalog.get('plugin-reviewer')).toBeUndefined();
await core.installPlugin({ source: pluginRoot });
await rpc.reloadSession({ sessionId: created.id });
expect(core.sessions.get(created.id)?.agentCatalog.get('plugin-reviewer')).toBeDefined();
await rpc.closeSession({ sessionId: created.id });
await rpc.resumeSession({ sessionId: created.id });
expect(core.sessions.get(created.id)?.agentCatalog.get('plugin-reviewer')).toBeDefined();
});
it('does not append a plugin_session_start reminder on reload without the force flag', async () => {
tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
const homeDir = join(tmp, 'home');

View file

@ -20,6 +20,7 @@ async function makePlugin(
options: {
skills?: boolean;
skillNames?: readonly string[];
agents?: boolean;
version?: string;
sessionStartSkill?: string;
systemPrompt?: string;
@ -46,6 +47,15 @@ async function makePlugin(
);
}
}
if (options.agents === true) {
manifest['agents'] = './agents/';
await mkdir(path.join(root, 'agents'), { recursive: true });
await writeFile(
path.join(root, 'agents', 'demo-agent.md'),
'---\nname: demo-agent\ndescription: A demo agent\n---\nbody',
'utf8',
);
}
if (options.sessionStartSkill !== undefined) {
manifest['sessionStart'] = { skill: options.sessionStartSkill };
}
@ -206,6 +216,27 @@ describe('PluginManager', () => {
});
});
it('pluginAgentRoots() returns only enabled plugins agents paths', async () => {
const home = await makeKimiHome();
const a = await makePlugin('a', { agents: true });
const b = await makePlugin('b', { agents: true });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(a);
await manager.install(b);
await manager.setEnabled('b', false);
const managedA = await managedPluginRoot(home, 'a');
const managedB = await managedPluginRoot(home, 'b');
expect(manager.pluginAgentRoots()).toContainEqual({
path: path.join(managedA, 'agents'),
source: 'plugin',
});
expect(manager.pluginAgentRoots()).not.toContainEqual({
path: path.join(managedB, 'agents'),
source: 'plugin',
});
});
it('summaries count discovered skills inside plugin skill roots', async () => {
const home = await makeKimiHome();
const root = await makePlugin('superpowers', {

View file

@ -201,6 +201,47 @@ describe('parseManifest', () => {
expect(result.manifest?.skills).toEqual([root]);
});
it('resolves an explicit agents path', async () => {
const root = await makePlugin(
{ 'kimi.plugin.json': JSON.stringify({ name: 'demo', agents: './agents/' }) },
{ dirs: ['agents'] },
);
const result = await parseManifest(root);
expect(result.manifest?.agents).toEqual([path.join(root, 'agents')]);
expect(result.diagnostics).toEqual([]);
});
it('falls back to the agents directory when agents field is absent', async () => {
const root = await makePlugin(
{ 'kimi.plugin.json': JSON.stringify({ name: 'demo' }) },
{ dirs: ['agents'] },
);
const result = await parseManifest(root);
expect(result.manifest?.agents).toEqual([path.join(root, 'agents')]);
});
it('keeps agents empty when the field is absent and no agents directory exists', async () => {
const root = await makePlugin({
'kimi.plugin.json': JSON.stringify({ name: 'demo' }),
});
const result = await parseManifest(root);
expect(result.manifest?.agents).toEqual([]);
});
it('rejects an agents path not prefixed with ./', async () => {
const root = await makePlugin({
'kimi.plugin.json': JSON.stringify({ name: 'demo', agents: 'agents/' }),
});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('"agents" path must start with "./"'),
}),
);
expect(result.manifest?.agents).toEqual([]);
});
it('does not fall back to root SKILL.md when skills field is present', async () => {
const root = await makePlugin(
{

View file

@ -22,6 +22,7 @@ import {
SessionAgentProfileCatalog,
agentProfileFromFile,
parseAgentFileText,
type AgentFileRoot,
type SystemPromptContext,
} from '../../src/profile';
import { AgentFileParseError } from '../../src/profile/agentfile/parser';
@ -262,6 +263,7 @@ describe('SessionAgentProfileCatalog', () => {
osHomeDir: string;
extraDirs?: readonly string[];
explicitFiles?: readonly string[];
pluginRoots?: readonly AgentFileRoot[];
warnings?: string[];
}): SessionAgentProfileCatalog {
return new SessionAgentProfileCatalog({
@ -270,6 +272,7 @@ describe('SessionAgentProfileCatalog', () => {
osHomeDir: options.osHomeDir,
extraDirs: options.extraDirs,
explicitFiles: options.explicitFiles,
pluginRoots: options.pluginRoots,
warn: (message) => options.warnings?.push(message),
});
}
@ -311,6 +314,24 @@ describe('SessionAgentProfileCatalog', () => {
expect(c.get('shared')?.description).toBe('From project.');
});
it('discovers plugin agents and lets the user source shadow them', async () => {
const { workDir, brandHome, osHome } = await makeLayout();
const pluginDir = await makeTempDir();
await writeAgent(pluginDir, 'shared.md', agentFileText({ description: 'From plugin.' }));
await writeAgent(pluginDir, 'plugin-only.md', agentFileText({ description: 'Plugin agent.' }));
await writeAgent(join(brandHome, 'agents'), 'shared.md', agentFileText({ description: 'From user.' }));
const c = catalog({
workDir,
brandHomeDir: brandHome,
osHomeDir: osHome,
pluginRoots: [{ path: pluginDir, source: 'plugin' }],
});
await c.ready;
expect(c.get('shared')?.description).toBe('From user.');
expect(c.get('plugin-only')?.description).toBe('Plugin agent.');
});
it('requires override: true before a file replaces a same-name builtin', async () => {
const { workDir, brandHome, osHome } = await makeLayout();
const warnings: string[] = [];

View file

@ -85,6 +85,7 @@ export const pluginManifestSchema = z.object({
homepage: z.string().optional(),
license: z.string().optional(),
skills: z.array(z.string()).optional(),
agents: z.array(z.string()).optional(),
sessionStart: pluginSessionStartSchema.optional(),
mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(),
hooks: z.array(hookDefSchema).optional(),