diff --git a/.changeset/agent-catalog-reload-on-miss.md b/.changeset/agent-catalog-reload-on-miss.md new file mode 100644 index 0000000000..147feda821 --- /dev/null +++ b/.changeset/agent-catalog-reload-on-miss.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Pick up agent files written after a session started: when a subagent dispatch names a profile the session catalog does not have, the agent directories are rescanned once and the lookup retried before the dispatch fails. Fixes long-lived `kimi web` sessions never seeing newly added agent Markdown files. diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 209711bdca..4f4250b71f 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -47,6 +47,8 @@ Beyond the three built-in sub-agents, you can define your own agents as Markdown 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. +A session scans these directories when it is created. In long-lived sessions (for example sessions hosted by `kimi web`), agent files written after the session started still become available: when a dispatch names a profile the catalog does not have yet, the directories are rescanned once and the lookup retried before the dispatch fails. + **User level** (applies to all projects): - `$KIMI_CODE_HOME/agents/` (default: `~/.kimi-code/agents/`) - `~/.agents/agents/` diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 0dd0a9c8a7..a960007d39 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -47,6 +47,8 @@ Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形 Kimi Code CLI 按作用域发现 Agent 文件,作用域越具体,优先级越高:**显式(`--agent-file`)> 项目 > 额外 > 用户 > 内置**。两个文件定义了相同的 `name` 时,高优先级作用域胜出。每个目录都会递归扫描 `.md` 文件。 +会话在创建时扫描这些目录。在长生命周期会话中(例如由 `kimi web` 托管的会话),会话启动后写入的 Agent 文件仍然可用:当一次派发指定了目录中尚不存在的 profile 时,会先重新扫描一次目录并重试查找,然后才会判定派发失败。 + **用户级**(对所有项目生效): - `$KIMI_CODE_HOME/agents/`(默认:`~/.kimi-code/agents/`) - `~/.agents/agents/` diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index c69c08ef45..5353808866 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -33,6 +33,7 @@ import { toInputJsonSchema } from '#/tool/input-schema'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; +import { getProfileOrReload } from '#/session/sessionAgentProfileCatalog/getOrReload'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; import { @@ -160,7 +161,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { if (allowlist !== undefined && !allowlist.includes(profileName)) { throw new Error(subagentTypeNotAllowedMessage(profileName, allowlist)); } - const targetProfile = this.catalog.get(profileName); + const targetProfile = await getProfileOrReload(this.catalog, profileName); if (targetProfile === undefined) { throw new Error(`Unknown agent type: "${profileName}"`); } diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 6d4d62b5ea..abd7f9926c 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -61,6 +61,7 @@ import { } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService, type ToolReference } from '#/agent/toolRegistry/toolRegistry'; import { type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { getProfileOrReload } from '#/session/sessionAgentProfileCatalog/getOrReload'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { @@ -255,7 +256,7 @@ export class SubagentTool implements ISubagentTool { if (allowlist !== undefined && !allowlist.includes(requestedProfileName)) { throw new Error(subagentTypeNotAllowedMessage(requestedProfileName, allowlist)); } - const profile = this.catalog.get(requestedProfileName); + const profile = await getProfileOrReload(this.catalog, requestedProfileName); if (profile === undefined) { throw new Error(`Unknown agent type: "${requestedProfileName}"`); } diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/getOrReload.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/getOrReload.ts new file mode 100644 index 0000000000..7870e3942e --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/getOrReload.ts @@ -0,0 +1,36 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — reload-on-miss profile lookup. + * + * The merged catalog is scanned once when the session materializes and is not + * refreshed afterward, so a long-lived session (e.g. one hosted by `kimi web`) + * never sees agent files written after it started. Dispatch consumers call + * `getProfileOrReload` instead of `ready` + `get`: on a miss it rescans the + * file sources once via `reload()` and retries, so a newly written agent file + * becomes dispatchable in a live session without a restart. Concurrent misses + * share one in-flight reload per catalog; a name that is still unknown after + * the rescan returns `undefined` and the caller's original error path applies. + */ + +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; + +import type { ISessionAgentProfileCatalog } from './sessionAgentProfileCatalog'; + +const reloadInFlight = new WeakMap>(); + +export async function getProfileOrReload( + catalog: ISessionAgentProfileCatalog, + name: string, +): Promise { + await catalog.ready; + const hit = catalog.get(name); + if (hit !== undefined) return hit; + let reload = reloadInFlight.get(catalog); + if (reload === undefined) { + reload = catalog.reload().finally(() => { + if (reloadInFlight.get(catalog) === reload) reloadInFlight.delete(catalog); + }); + reloadInFlight.set(catalog, reload); + } + await reload; + return catalog.get(name); +} diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 00fa9fc11b..fa4be28547 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -30,6 +30,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus } from '#/app/event/eventBus'; +import { getProfileOrReload } from '#/session/sessionAgentProfileCatalog/getOrReload'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -143,8 +144,7 @@ export class SessionSwarmService implements ISessionSwarmService { ): Promise { options.signal.throwIfAborted(); const caller = this.requireHandle(callerAgentId, 'Caller agent'); - await this.catalog.ready; - const profile = this.catalog.get(options.profileName); + const profile = await getProfileOrReload(this.catalog, options.profileName); if (profile === undefined) { throw new Error(`Unknown agent type: "${options.profileName}"`); } diff --git a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts index 27c0909bad..57fc95d6f9 100644 --- a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts @@ -45,6 +45,7 @@ import { ExtraFileAgentSource, IExtraFileAgentSource, } from '#/session/sessionAgentProfileCatalog/extraFileAgentSource'; +import { getProfileOrReload } from '#/session/sessionAgentProfileCatalog/getOrReload'; import { IProjectFileAgentSource, ProjectFileAgentSource, @@ -358,6 +359,77 @@ describe('SessionAgentProfileCatalogService', () => { }); }); + it('reload-on-miss: picks up an agent file written after the initial scan', async () => { + await withFixture(async (fixture) => { + const { host, session } = makeSession(fixture); + const catalog = session.accessor.get(ISessionAgentProfileCatalog); + await catalog.load(); + expect(catalog.get('late-agent')).toBeUndefined(); + + await writeAgent( + join(fixture.homeDir, 'agents'), + 'late-agent.md', + agentMd('late-agent', 'written after scan'), + ); + const profile = await getProfileOrReload(catalog, 'late-agent'); + + expect(profile?.description).toBe('written after scan'); + host.dispose(); + }); + }); + + it('reload-on-miss: returns undefined for a genuinely unknown name', async () => { + await withFixture(async (fixture) => { + const { host, session } = makeSession(fixture); + const catalog = session.accessor.get(ISessionAgentProfileCatalog); + await catalog.load(); + + await expect(getProfileOrReload(catalog, 'no-such-agent')).resolves.toBeUndefined(); + host.dispose(); + }); + }); + + it('reload-on-miss: hits skip the rescan and concurrent misses share one reload', async () => { + await withFixture(async (fixture) => { + const { host, session } = makeSession(fixture); + const catalog = session.accessor.get(ISessionAgentProfileCatalog); + await catalog.load(); + + let reloads = 0; + const wrapped: ISessionAgentProfileCatalog = { + _serviceBrand: undefined, + ready: catalog.ready, + onDidChange: catalog.onDidChange, + get: (name) => catalog.get(name), + getDefault: () => catalog.getDefault(), + list: () => catalog.list(), + load: () => catalog.load(), + reload: async () => { + reloads += 1; + await catalog.reload(); + }, + }; + + await getProfileOrReload(wrapped, DEFAULT_AGENT_PROFILE_NAME); + expect(reloads).toBe(0); + + await writeAgent( + join(fixture.homeDir, 'agents'), + 'late-agent.md', + agentMd('late-agent', 'written after scan'), + ); + const [a, b] = await Promise.all([ + getProfileOrReload(wrapped, 'late-agent'), + getProfileOrReload(wrapped, 'late-agent'), + ]); + + expect(a?.name).toBe('late-agent'); + expect(b?.name).toBe('late-agent'); + expect(reloads).toBe(1); + host.dispose(); + }); + }); + it('resolves relative explicit files against the session workDir', async () => { await withFixture(async (fixture) => { await writeAgent(