Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/agent-catalog-reload-on-miss.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/en/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"`);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ISessionAgentProfileCatalog, Promise<void>>();

export async function getProfileOrReload(
catalog: ISessionAgentProfileCatalog,
name: string,
): Promise<AgentProfile | undefined> {
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor aborts after a miss-triggered catalog reload

When a foreground Agent/AgentSwarm dispatch is interrupted while this miss-triggered reload is scanning, the callers have already linked their abort signal but do not re-check it after await getProfileOrReload; execution can therefore continue into lifecycle.create and emit a spawned subagent before the later subagents.run observes the aborted signal. Please make this helper abortable or have each caller call signal.throwIfAborted() immediately after the reload-backed lookup.

Useful? React with 👍 / 👎.

return catalog.get(name);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -143,8 +144,7 @@ export class SessionSwarmService implements ISessionSwarmService {
): Promise<AgentRunAttemptHandle> {
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}"`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
ExtraFileAgentSource,
IExtraFileAgentSource,
} from '#/session/sessionAgentProfileCatalog/extraFileAgentSource';
import { getProfileOrReload } from '#/session/sessionAgentProfileCatalog/getOrReload';
import {
IProjectFileAgentSource,
ProjectFileAgentSource,
Expand Down Expand Up @@ -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(
Expand Down