mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-30 03:24:59 +00:00
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:
parent
1896d1a13a
commit
fa2c5ce18b
33 changed files with 641 additions and 33 deletions
5
.changeset/plugin-custom-agents.md
Normal file
5
.changeset/plugin-custom-agents.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 信任模型
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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 */ {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 () => ({}),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ function pluginStub(
|
|||
listPluginCommands: async () => [],
|
||||
checkUpdates: async () => [],
|
||||
pluginSkillRoots: async () => skillRoots,
|
||||
pluginAgentRoots: async () => [],
|
||||
enabledSessionStarts: async () => [],
|
||||
enabledSystemPrompts: async () => [],
|
||||
enabledMcpServers: async () => ({}),
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue