diff --git a/.changeset/plugin-custom-agents.md b/.changeset/plugin-custom-agents.md new file mode 100644 index 0000000000..d4c819fc16 --- /dev/null +++ b/.changeset/plugin-custom-agents.md @@ -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. diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 6aab183df2..b9c67039dd 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -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 diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index b679565250..80f9a07384 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -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:`, 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. diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 56bdbd95a2..9a935e94fb 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -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 信任模型 diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 33fb08cd3e..f000709716 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -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:` 或模型自动调用),`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。 diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 52cf313268..b774c77f8b 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1013,7 +1013,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map { @@ -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; } diff --git a/packages/agent-core-v2/src/app/plugin/plugin.ts b/packages/agent-core-v2/src/app/plugin/plugin.ts index f9be2e0901..f9d1162bb5 100644 --- a/packages/agent-core-v2/src/app/plugin/plugin.ts +++ b/packages/agent-core-v2/src/app/plugin/plugin.ts @@ -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; checkUpdates(): Promise; pluginSkillRoots(): Promise; + pluginAgentRoots(): Promise; enabledSessionStarts(): Promise; enabledSystemPrompts(): Promise; enabledMcpServers(): Promise>; diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index 20e8947aa2..83fe5d1bc1 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -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 { + return this.runConsumptionRead([], async () => this.manager.pluginAgentRoots()); + } + enabledSessionStarts(): Promise { return this.runConsumptionRead([], async () => this.manager.enabledSessionStarts()); } diff --git a/packages/agent-core-v2/src/app/plugin/types.ts b/packages/agent-core-v2/src/app/plugin/types.ts index 43de7854ec..414f8d3a51 100644 --- a/packages/agent-core-v2/src/app/plugin/types.ts +++ b/packages/agent-core-v2/src/app/plugin/types.ts @@ -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>; readonly hooks?: readonly HookDefConfig[]; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3f667afe1e..c23035c11c 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts new file mode 100644 index 0000000000..42af972661 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts @@ -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 = + createDecorator('pluginAgentProfileSource'); + +export class PluginAgentProfileSource implements IPluginAgentProfileSource { + declare readonly _serviceBrand: undefined; + + readonly id = 'plugin'; + readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.plugin; + readonly onDidChange: Event = (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 { + 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', +); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts index 920a5cd34b..a0612d28a2 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -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) { diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index 833f502f41..a0a77fcf20 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -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 () => ({}), diff --git a/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts b/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts index 81d6362aeb..67b4591cd4 100644 --- a/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts +++ b/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts @@ -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'); diff --git a/packages/agent-core-v2/test/app/plugin/manifest.test.ts b/packages/agent-core-v2/test/app/plugin/manifest.test.ts index 90be94f9bc..8ea730bf78 100644 --- a/packages/agent-core-v2/test/app/plugin/manifest.test.ts +++ b/packages/agent-core-v2/test/app/plugin/manifest.test.ts @@ -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'), diff --git a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts index 27c0909bad..ccb7df17e8 100644 --- a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts @@ -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, +): 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; }, ) { 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(); + 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( diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index 8f26502925..d552fb6c0c 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -118,6 +118,7 @@ function pluginStub( listPluginCommands: async () => [], checkUpdates: async () => [], pluginSkillRoots: async () => skillRoots, + pluginAgentRoots: async () => [], enabledSessionStarts: async () => [], enabledSystemPrompts: async () => [], enabledMcpServers: async () => ({}), diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts index 977e567e31..b22efa71ef 100644 --- a/packages/agent-core/src/plugin/manager.ts +++ b/packages/agent-core/src/plugin/manager.ts @@ -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()) { diff --git a/packages/agent-core/src/plugin/manifest.ts b/packages/agent-core/src/plugin/manifest.ts index 0eafa4445f..5355fe7ffd 100644 --- a/packages/agent-core/src/plugin/manifest.ts +++ b/packages/agent-core/src/plugin/manifest.ts @@ -103,7 +103,7 @@ export async function parseManifest(pluginRoot: string): Promise { @@ -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; } diff --git a/packages/agent-core/src/plugin/types.ts b/packages/agent-core/src/plugin/types.ts index 72618e7678..e47a470cf7 100644 --- a/packages/agent-core/src/plugin/types.ts +++ b/packages/agent-core/src/plugin/types.ts @@ -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>; readonly hooks?: readonly HookDefConfig[]; diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts index 9b78c214e3..ca81cca981 100644 --- a/packages/agent-core/src/profile/agentfile/catalog.ts +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -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> = { + 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 { + 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: ``, - 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({ diff --git a/packages/agent-core/src/profile/agentfile/types.ts b/packages/agent-core/src/profile/agentfile/types.ts index 8e50b20dcf..3597b85e4d 100644 --- a/packages/agent-core/src/profile/agentfile/types.ts +++ b/packages/agent-core/src/profile/agentfile/types.ts @@ -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(), }); /** diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 97bebee790..319b55551f 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -346,12 +346,14 @@ export class KimiCore implements PromisableMethods { ); } }; + 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 { kaos?: Kaos; persistenceKaos?: Kaos; forcePluginSessionStartReminder?: boolean; + refreshPluginAgents?: boolean; }, ): Promise { const summary = await this.sessionStore.get(input.sessionId); @@ -567,6 +570,9 @@ export class KimiCore implements PromisableMethods { 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 { 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 { } return this.resumeSessionWithOverrides( { sessionId: summary.id }, - { forcePluginSessionStartReminder: input.forcePluginSessionStartReminder }, + { + forcePluginSessionStartReminder: input.forcePluginSessionStartReminder, + refreshPluginAgents: true, + }, ); } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 272ab2dea7..50762884f8 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -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; diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index a2847a12b8..912a6b5df2 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -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(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ 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(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ 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'); diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts index b7caecff83..ce8888c90f 100644 --- a/packages/agent-core/test/plugin/manager.test.ts +++ b/packages/agent-core/test/plugin/manager.test.ts @@ -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', { diff --git a/packages/agent-core/test/plugin/manifest.test.ts b/packages/agent-core/test/plugin/manifest.test.ts index e638e9b1a7..4dc8e61378 100644 --- a/packages/agent-core/test/plugin/manifest.test.ts +++ b/packages/agent-core/test/plugin/manifest.test.ts @@ -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( { diff --git a/packages/agent-core/test/profile/agentfile.test.ts b/packages/agent-core/test/profile/agentfile.test.ts index ff1440f334..14b61ab228 100644 --- a/packages/agent-core/test/profile/agentfile.test.ts +++ b/packages/agent-core/test/profile/agentfile.test.ts @@ -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[] = []; diff --git a/packages/klient/src/contract/global/plugins.ts b/packages/klient/src/contract/global/plugins.ts index c6ad3a1732..d3b6207318 100644 --- a/packages/klient/src/contract/global/plugins.ts +++ b/packages/klient/src/contract/global/plugins.ts @@ -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(),