From fa2c5ce18b70577fa3ada4eb8bdd4993891994ce Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Wed, 29 Jul 2026 21:59:48 +0800 Subject: [PATCH 01/11] 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 --- .changeset/plugin-custom-agents.md | 5 + docs/en/customization/agents.md | 4 +- docs/en/customization/plugins.md | 16 +++- docs/zh/customization/agents.md | 4 +- docs/zh/customization/plugins.md | 16 +++- .../agent-core-v2/docs/state-manifest.d.ts | 2 +- .../agentFileCatalog/agentProfileSource.ts | 7 +- .../src/app/agentFileCatalog/types.ts | 2 +- .../agent-core-v2/src/app/plugin/manager.ts | 12 +++ .../agent-core-v2/src/app/plugin/manifest.ts | 22 +++-- .../agent-core-v2/src/app/plugin/plugin.ts | 2 + .../src/app/plugin/pluginService.ts | 5 + .../agent-core-v2/src/app/plugin/types.ts | 1 + packages/agent-core-v2/src/index.ts | 1 + .../pluginAgentProfileSource.ts | 74 +++++++++++++++ .../sessionAgentProfileCatalogService.ts | 9 +- .../test/agent/plugin/agentPlugin.test.ts | 1 + .../app/plugin/manager-consumption.test.ts | 31 +++++++ .../test/app/plugin/manifest.test.ts | 49 ++++++++++ .../sessionAgentProfileCatalog.test.ts | 86 ++++++++++++++++++ .../sessionSkillCatalog/skillCatalog.test.ts | 1 + packages/agent-core/src/plugin/manager.ts | 12 +++ packages/agent-core/src/plugin/manifest.ts | 22 +++-- packages/agent-core/src/plugin/types.ts | 1 + .../src/profile/agentfile/catalog.ts | 61 ++++++++++++- .../agent-core/src/profile/agentfile/types.ts | 3 +- packages/agent-core/src/rpc/core-impl.ts | 14 ++- packages/agent-core/src/session/index.ts | 26 +++++- .../agent-core/test/harness/runtime.test.ts | 91 +++++++++++++++++++ .../agent-core/test/plugin/manager.test.ts | 31 +++++++ .../agent-core/test/plugin/manifest.test.ts | 41 +++++++++ .../agent-core/test/profile/agentfile.test.ts | 21 +++++ .../klient/src/contract/global/plugins.ts | 1 + 33 files changed, 641 insertions(+), 33 deletions(-) create mode 100644 .changeset/plugin-custom-agents.md create mode 100644 packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts diff --git a/.changeset/plugin-custom-agents.md b/.changeset/plugin-custom-agents.md new file mode 100644 index 000000000..d4c819fc1 --- /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 6aab183df..b9c67039d 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 b67956525..80f9a0738 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 56bdbd95a..9a935e94f 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 33fb08cd3..f00070971 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 52cf31326..b774c77f8 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 f9be2e090..f9d1162bb 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 20e8947aa..83fe5d1bc 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 43de7854e..414f8d3a5 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 3f667afe1..c23035c11 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 000000000..42af97266 --- /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 920a5cd34..a0612d28a 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 833f502f4..a0a77fcf2 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 81d6362ae..67b4591cd 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 90be94f9b..8ea730bf7 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 27c0909ba..ccb7df17e 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 8f2650292..d552fb6c0 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 977e567e3..b22efa71e 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 0eafa4445..5355fe7ff 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 72618e767..e47a470cf 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 9b78c214e..ca81cca98 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 8e50b20dc..3597b85e4 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 97bebee79..319b55551 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 272ab2dea..50762884f 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 a2847a12b..912a6b5df 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 b7caecff8..ce8888c90 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 e638e9b1a..4dc8e6137 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 ff1440f33..14b61ab22 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 c6ad3a173..d3b620731 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(), From 691ec4679ea19d6be8ac18f359088384ed3e446d Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 30 Jul 2026 01:26:56 +0800 Subject: [PATCH 02/11] fix: remove the blocking wait from the TaskOutput tool (#2379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove the blocking wait from the TaskOutput tool The block/timeout parameters let a model stall the whole turn waiting for a background task (up to 3600s), even though completion already arrives via automatic notification. Remove both parameters from the v1 and v2 engines (kept in model-facing parity), simplify retrieval_status to success/not_ready, and update the tool, Bash, and Agent prompt wording plus user docs accordingly. Stale callers passing block are silently treated as a non-blocking snapshot. * fix: align background-task prompts with the non-blocking TaskOutput The compaction reminder promised TaskOutput could fetch a task's result for tasks that are still running, where it now returns not_ready — reword it to snapshot semantics and point at the completion notification. Also list AskUserQuestion(background=true) as a task source in the TaskOutput description. * test: exercise stale TaskOutput args through the runtime validator A stale block/timeout argument never reaches the tool: the executor's preflight validates args against the closed tool schema and rejects them immediately, so the old test documented silent-tolerance semantics the runtime never exhibits. Assert the real behavior through compileToolArgsValidator/validateToolArgs instead, and drop statement-adjacent comments to match the package's header-only comment convention. --- .changeset/task-output-nonblocking.md | 5 ++ docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- .../src/agent/task/taskService.ts | 2 +- .../tools/agent/agent-background-enabled.md | 2 +- .../src/agent/tools/os/bash/bash.md | 2 +- .../src/agent/tools/os/bash/bashTool.ts | 2 +- .../tools/task/task-output/task-output.md | 6 +- .../tools/task/task-output/task-output.ts | 15 ---- .../tools/task/task-output/taskOutputTool.ts | 30 ++----- .../test/agent/loop/loop.test.ts | 4 +- .../test/agent/task/taskService.test.ts | 2 +- .../test/agent/task/tools/task-tools.test.ts | 90 ++++--------------- .../os/backends/node-local/tools/bash.test.ts | 2 +- packages/agent-core-v2/test/tool/tool.test.ts | 10 +-- .../agent-core/src/agent/injection/manager.ts | 2 +- .../src/tools/background/task-output.md | 6 +- .../src/tools/background/task-output.ts | 40 +-------- .../collaboration/agent-background-enabled.md | 2 +- .../src/tools/builtin/shell/bash.md | 2 +- .../src/tools/builtin/shell/bash.ts | 2 +- .../test/harness/coder-subagent-tools.test.ts | 2 +- .../test/tools/background/task-tools.test.ts | 33 ++----- 23 files changed, 62 insertions(+), 203 deletions(-) create mode 100644 .changeset/task-output-nonblocking.md diff --git a/.changeset/task-output-nonblocking.md b/.changeset/task-output-nonblocking.md new file mode 100644 index 000000000..4a95c93e1 --- /dev/null +++ b/.changeset/task-output-nonblocking.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Remove the blocking `block`/`timeout` wait from the TaskOutput tool so checking a background task can no longer stall the conversation; it now always returns an immediate snapshot, and completion still arrives via automatic notification. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 0c38fd486..8b412b536 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -109,7 +109,7 @@ Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuest **`TaskList`** returns the list of background tasks. Optional parameters: `active_only` (defaults to true; lists only running tasks) and `limit` (defaults to 20; range 1–100). -**`TaskOutput`** returns the status and output of a task given its `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved to disk, and the tool also returns an `output_path` with a suggestion to use `Read` for paginated access. Optional `block` (defaults to false) and `timeout` (seconds to wait; defaults to 30; range 0–3600) parameters allow waiting for the task to complete before returning. +**`TaskOutput`** returns the status and output of a task given its `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved to disk, and the tool also returns an `output_path` with a suggestion to use `Read` for paginated access. The call is always non-blocking — it returns the current snapshot immediately, and task completion is delivered via automatic notification. **`TaskStop`** accepts a `task_id` and optional `reason` (defaults to `Stopped by TaskStop`). Safe to call on tasks that are already in a terminal state. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 04e84a6ad..009ff3d05 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -109,7 +109,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 **`TaskList`** 返回后台任务列表。可选参数 `active_only`(默认 true,仅列出运行中的任务)和 `limit`(默认 20,取值范围 1–100)。 -**`TaskOutput`** 根据 `task_id` 返回任务状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取。可选 `block`(默认 false)和 `timeout`(等待秒数,默认 30,取值范围 0–3600)参数可用于等待任务完成后再返回。 +**`TaskOutput`** 根据 `task_id` 返回任务状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取。该调用始终是非阻塞的——立即返回当前快照,任务完成会通过自动通知送达。 **`TaskStop`** 接受 `task_id` 和可选的 `reason`(默认 `Stopped by TaskStop`)。对已处于终止状态的任务也能安全调用。 diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index cc454d3de..b81ebb938 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -189,7 +189,7 @@ const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000; const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status'; const ACTIVE_BACKGROUND_TASK_GUIDANCE = [ 'The conversation was compacted, so the earlier messages that started these background tasks are gone — but the tasks are still running from before.', - 'Do not start duplicates. Use TaskOutput to fetch a task’s result, TaskList to list them, and TaskStop to cancel one.', + 'Do not start duplicates. Use TaskList to list them, TaskOutput for a non-blocking status/output snapshot, and TaskStop to cancel one — completion arrives via automatic notification.', ].join(' '); export function isAgentTaskTerminal(status: AgentTaskStatus): boolean { diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent-background-enabled.md b/packages/agent-core-v2/src/agent/tools/agent/agent-background-enabled.md index 67cafb5b7..7c2c65f35 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agent-background-enabled.md +++ b/packages/agent-core-v2/src/agent/tools/agent/agent-background-enabled.md @@ -1,3 +1,3 @@ When `run_in_background=true`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say. -Default to a foreground subagent (omit `run_in_background`) when your next step needs its result — foreground hands the result straight back. Reach for `run_in_background=true` only when you have other work to do while it runs and do not need its result to proceed. Never launch in the background and then immediately wait on it (with `TaskOutput block=true`, sleeping, or otherwise): that just blocks the turn for no benefit — run it in the foreground instead. +Default to a foreground subagent (omit `run_in_background`) when your next step needs its result — foreground hands the result straight back. Reach for `run_in_background=true` only when you have other work to do while it runs and do not need its result to proceed. Never launch in the background and then immediately wait on it (by polling `TaskOutput`, sleeping, or otherwise): that just blocks the turn for no benefit — run it in the foreground instead. diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bash.md b/packages/agent-core-v2/src/agent/tools/os/bash/bash.md index 13fc46b91..6b3ec9c4b 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bash.md +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bash.md @@ -13,7 +13,7 @@ The dedicated tools render in the per-tool permission UI and keep raw stdout out **Output:** The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a `Command failed with exit code: N` line; a command killed by its timeout or interrupted by the user ends with its own message instead. -If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a ${DEFAULT_BACKGROUND_TIMEOUT_S}s timeout and `timeout` is capped at ${MAX_BACKGROUND_TIMEOUT_S}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` only for a non-blocking status/output snapshot — do not set `block=true` to wait for a task you just launched, since its completion arrives automatically; reserve `block=true` for when the user explicitly asked you to wait. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. +If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a ${DEFAULT_BACKGROUND_TIMEOUT_S}s timeout and `timeout` is capped at ${MAX_BACKGROUND_TIMEOUT_S}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` only for a non-blocking status/output snapshot — do not wait on a task you just launched, since its completion arrives automatically. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. **Guidelines for safety and security:** - Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the `cwd` argument (or use absolute paths) rather than relying on a `cd` from an earlier call. diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 88c3bb666..0cda986a2 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -385,7 +385,7 @@ export class BashTool implements IBashTool { if (!output.fullOutputAvailable || output.outputPath === undefined) return result; const taskOutputHint = this.allowBackground() - ? `, or TaskOutput(task_id="${taskId}", block=false)` + ? `, or TaskOutput(task_id="${taskId}")` : ''; const reference = `\n\n[Full output saved]\n` + diff --git a/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.md b/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.md index 7b902943d..7e53c035d 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.md +++ b/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.md @@ -1,13 +1,11 @@ Retrieve a snapshot of a running or completed background task. -Use this after `Bash(run_in_background=true)` or `Agent(run_in_background=true)` to check progress, or to read the output of a task that has already completed. +Use this after `Bash(run_in_background=true)`, `Agent(run_in_background=true)`, or `AskUserQuestion(background=true)` to check progress, or to read the output of a task that has already completed. Guidelines: - Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives. -- By default this tool is non-blocking and returns a current status/output snapshot — that is the normal way to use it. +- This tool is always non-blocking: it returns the current status/output snapshot immediately and never waits for the task to finish. - Do not use TaskOutput to wait for a result you need before continuing — if your next step depends on the task's result, run that task in the foreground instead. TaskOutput is for a deliberate progress check you will act on without blocking, not a way to sit and wait for a background task you just launched. -- Use block=true only when the user explicitly asked you to wait for the task. Never block on a task you launched in the current turn — if you need its result right away, it should have been a foreground call. -- If a block=true call returns `retrieval_status: timeout` (the task is still running), do not block on the same task again. Continue with other work or hand back to the user — the completion notification arrives on its own. - This tool returns structured task metadata, a fixed-size output preview, and an output_path for the full log. - For a terminal task, the metadata also explains why it ended. A shell command that runs to completion reports `status: completed` on a zero exit, or `status: failed` with its non-zero `exit_code` — judge that failure from the `exit_code`, because a plain command failure carries no `stop_reason` and no `terminal_reason`. `terminal_reason` is a categorical label emitted only when the end is not an ordinary exit: `timed_out` when the deadline aborted it, `stopped` when it was explicitly stopped, or `failed` when it errored without producing an exit code; the `stopped` and `failed` cases also carry a human-readable `stop_reason`. A task that finished on its own with a clean exit carries neither `stop_reason` nor `terminal_reason`. - The full, never-truncated log is always available at output_path; use the `Read` tool with that path to page through it, whether or not the preview was truncated. diff --git a/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.ts b/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.ts index aa3c00eae..977db3542 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.ts @@ -15,21 +15,6 @@ import { type AgentTool } from '#/tool/toolContract'; export const TaskOutputInputSchema = z.object({ task_id: z.string().describe('The background task ID to inspect.'), - block: z - .boolean() - .default(false) - .describe( - 'Whether to wait for the task to finish before returning. Discouraged — background tasks notify automatically on completion; use only when the user explicitly asked you to wait.', - ) - .optional(), - timeout: z - .number() - .int() - .min(0) - .max(3600) - .default(30) - .describe('Maximum number of seconds to wait when block=true.') - .optional(), }); export type TaskOutputInput = z.infer; diff --git a/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts index 063a45f91..d5983a171 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts @@ -39,12 +39,8 @@ const OUTPUT_PREVIEW_BYTES = 32 * 1024; const PAGING_HINT_LINES = 300; -function retrievalStatus( - status: AgentTaskStatus, - block: boolean | undefined, -): 'success' | 'timeout' | 'not_ready' { - if (TERMINAL_STATUSES.has(status)) return 'success'; - return block ? 'timeout' : 'not_ready'; +function retrievalStatus(status: AgentTaskStatus): 'success' | 'not_ready' { + return TERMINAL_STATUSES.has(status) ? 'success' : 'not_ready'; } function terminalReason(info: AgentTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined { @@ -85,23 +81,11 @@ export class TaskOutputTool implements ITaskOutputTool { description: `Reading output of task ${args.task_id}`, approvalRule: this.name, matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id), - execute: ({ signal }) => this.execute(args, signal), + execute: () => this.execute(args), }; } - private async execute( - args: TaskOutputInput, - signal: AbortSignal, - ): Promise { - const info = this.tasks.getTask(args.task_id); - if (!info) { - return { isError: true, output: `Task not found: ${args.task_id}` }; - } - - if (args.block && !TERMINAL_STATUSES.has(info.status)) { - await this.tasks.wait(args.task_id, (args.timeout ?? 30) * 1000, signal); - } - + private async execute(args: TaskOutputInput): Promise { const current = this.tasks.getTask(args.task_id); if (!current) { return { isError: true, output: `Task not found: ${args.task_id}` }; @@ -111,7 +95,7 @@ export class TaskOutputTool implements ITaskOutputTool { const lines = [ formatPlainObject({ - retrievalStatus: retrievalStatus(current.status, args.block), + retrievalStatus: retrievalStatus(current.status), ...current, outputPath: output.outputPath, terminalReason: terminalReason(current), @@ -122,10 +106,6 @@ export class TaskOutputTool implements ITaskOutputTool { fullOutputTool: output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined, fullOutputHint: fullOutputHint(output), - nextStep: - args.block === true && !TERMINAL_STATUSES.has(current.status) - ? 'The task is still running after waiting. Do not block on it again — continue with other work or hand back to the user; you will be notified automatically when it completes.' - : undefined, }), '', ]; diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 02a7a2476..783a8b49c 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -104,8 +104,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "