From 88b43b108ed379a5a146f8508b36909d6f65bd76 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sun, 9 Aug 2026 18:18:36 -0400 Subject: [PATCH] fix(kimi-code): honor --agent-file and --agent at TUI launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive TUI runs on the agent-core-v2 engine by default, and that engine's SDK client dropped both agent options at session creation: it read only the session id, work dir, additional dirs, model, thinking, permission and metadata, then bound the default profile. The startup session therefore started on the built-in agent with no warning, while print mode — which takes a different path — bound the requested one. Register explicit agent files on the client's engine bootstrap, the way skill directories already are and the way print mode already registers the same flag, and pass a requested profile into the bind the client already performs for the model and thinking effort. That bind deliberately stays where it is, after the session is wired: it publishes the oversized AGENTS.md and tool-pattern warnings, the agent event bus has no replay, and the tool-pattern warning is emitted once per pattern, so binding any earlier would drop them. Binding that late means the session already exists by the time an unusable profile name is caught, and creating a session runs the SessionStart hook and records the start as its last step. So the name is resolved against the workspace's agent catalog before anything is created, the way the legacy engine resolves it before its store write, and deleting the session is kept only as a backstop for a bind that fails after that check passes. The CLI hands the files to the harness, which is built before any session exists and is the only place a launch-wide registration can happen. Selecting a profile stays per-session, so a session created later still starts on the default agent — though a file named after a built-in agent keeps replacing that built-in for the rest of the launch, which the docs now state. --- .changeset/tui-honors-agent-file.md | 6 + apps/kimi-code/src/cli/run-shell.ts | 5 + apps/kimi-code/test/cli/run-shell.test.ts | 43 ++- docs/en/reference/kimi-command.md | 2 +- docs/zh/reference/kimi-command.md | 2 +- packages/node-sdk/src/sdk-rpc-client-v2.ts | 226 ++++++++++++- packages/node-sdk/src/types.ts | 29 +- .../node-sdk/test/sdk-rpc-client-v2.test.ts | 305 ++++++++++++++++++ 8 files changed, 595 insertions(+), 23 deletions(-) create mode 100644 .changeset/tui-honors-agent-file.md diff --git a/.changeset/tui-honors-agent-file.md b/.changeset/tui-honors-agent-file.md new file mode 100644 index 0000000000..4810ab9825 --- /dev/null +++ b/.changeset/tui-honors-agent-file.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": minor +--- + +Bind the agent selected by `--agent` or `--agent-file` to the startup session of the interactive TUI, which previously started on the default agent without reporting that the flag was dropped. An `--agent-file` is registered for the whole launch while the flag itself still binds only the startup session, so a file named after a built-in agent keeps replacing that built-in for sessions created later in the same launch. A requested name absent from the workspace agent catalog is now rejected before the session is created instead of after. The SDK gains a matching `agentFiles` harness option. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 3d6c741ceb..eb3985041c 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -68,6 +68,11 @@ export async function runShell( homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), skillDirs: opts.skillsDirs, + // `--agent-file` registers the file for this launch; the harness is built + // before any session exists, and the v2 engine reads explicit agent files + // from its own bootstrap arguments, so the registration has to happen here. + // Selecting the profile it defines stays per-session (`agentProfile`). + agentFiles: opts.agentFiles, telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c4e95c1d1b..3a02ee984f 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -1,4 +1,7 @@ import { execSync } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -156,11 +159,16 @@ vi.mock('node:child_process', () => ({ })); describe('runShell', () => { + const tempDirs: string[] = []; + beforeEach(() => { vi.stubEnv('KIMI_CODE_LEGACY_FLAG', '1'); }); - afterEach(() => { + afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } vi.clearAllMocks(); vi.unstubAllEnvs(); mocks.harnessGetConfig.mockResolvedValue({ @@ -398,6 +406,39 @@ describe('runShell', () => { ); }); + // `--agent-file` registers the file for the whole launch, and the v2 engine + // reads explicit agent files from the process bootstrap the harness sets up — + // so the flag has to reach the harness, not only the startup session. Runs on + // the default (v2) route, which is the one that reads the option. + it('forwards agentFiles from CLI options to the v2 harness', async () => { + stubTuiStartup(); + const agentDir = await mkdtemp(join(tmpdir(), 'kimi-code-agent-')); + tempDirs.push(agentDir); + const agentFile = join(agentDir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', + ); + + await withEnv( + { KIMI_CODE_LEGACY_FLAG: undefined, KIMI_CODE_EXPERIMENTAL_FLAG: undefined }, + async () => { + await runShell({ ...minimalCliOptions, agentFiles: [agentFile] }, '1.2.3-test'); + }, + ); + + expect(mocks.kimiHarnessV2Constructor).toHaveBeenCalledWith( + expect.objectContaining({ agentFiles: [agentFile] }), + ); + // The startup session still selects the profile that file defines. + expect(mocks.kimiTuiConstructor).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ agentProfile: 'reviewer' }), + ); + }); + it('tracks first launch when device id creation reports first launch', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 36480081d2..48552b76a4 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -105,7 +105,7 @@ kimi --agent reviewer kimi -p --agent reviewer "Review the changes on this branch" ``` -`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. Both flags only apply when starting a new session — neither can be combined with `--session`/`--continue`, because the agent is bound at session creation and resuming restores the bound agent automatically. The selection is fixed at the session's first bind and cannot be switched later; in the TUI the flags bind only the startup session, and a session created later in the same process (for example via `/new`) starts with the default agent. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. +`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. Both flags only apply when starting a new session — neither can be combined with `--session`/`--continue`, because the agent is bound at session creation and resuming restores the bound agent automatically. The selection is fixed at the session's first bind and cannot be switched later; in the TUI the flags bind only the startup session, and a session created later in the same process (for example via `/new`) starts with the default agent. On the default `agent-core-v2` engine, the file `--agent-file` points at stays in the agent catalog for the rest of the launch: sessions created later start on the default agent, but the file's agent remains available for delegation as a sub-agent, subject to the active agent's sub-agent allowlist — and if the file is named after a built-in agent, it keeps replacing that built-in for those sessions too. The legacy engine selected with `KIMI_CODE_LEGACY_FLAG=1` registers the file for the startup session only. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. ## Non-Interactive Execution diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 642026c799..460aa7458a 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -105,7 +105,7 @@ kimi --agent reviewer kimi -p --agent reviewer "审查这个分支上的改动" ``` -`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥。两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合,因为 Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent。选择在会话首次绑定后即固定,之后不可切换;在 TUI 中,这些 flag 只绑定启动时的会话,之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 +`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥。两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合,因为 Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent。选择在会话首次绑定后即固定,之后不可切换;在 TUI 中,这些 flag 只绑定启动时的会话,之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。在默认的 `agent-core-v2` 引擎下,`--agent-file` 指向的文件在本次启动的剩余时间内一直留在 Agent 清单中:之后新建的会话虽然以默认 Agent 启动,该文件定义的 Agent 仍可作为子 Agent 派发,但要受当前 Agent 的子 Agent 允许列表限制;如果该文件与某个内置 Agent 同名,它对这些会话也会继续替换那个内置 Agent。设置 `KIMI_CODE_LEGACY_FLAG=1` 选择旧版引擎时,该文件只为启动时的会话注册。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 ## 非交互执行 diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index d723c41063..a7e4c13dc8 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -171,6 +171,7 @@ import { IAgentLoopService, IAgentPermissionModeService, IAgentPermissionRulesService, + IAgentProfileRegistry, IAgentProfileService, IAgentRPCService, IAgentSkillService, @@ -180,10 +181,14 @@ import { IBootstrapService, IConfigService, IEventService, + IExplicitAgentProfileLoader, + IExtraAgentProfileLoader, IHostEnvironment, IHostFileSystem, + ILogService, IModelCatalog, IModelService, + IPluginAgentProfileLoader, IProviderService, ISessionBtwService, ISessionContext, @@ -198,7 +203,10 @@ import { ISessionSkillCatalog, ISessionWorkspaceContext, ITelemetryService, + IUserAgentProfileLoader, + IWorkspaceAgentProfileLoader, IWorkspaceAliases, + IWorkspaceContext, IWorkspaceDirs, IWorkspaceMcpService, ISessionActivityView, @@ -230,6 +238,7 @@ import { type IAgentScopeHandle, type IDisposable, type ISessionScopeHandle, + type IWorkspaceScopeHandle, type Scope, type SecondaryModelConfig, type ServicesAccessor, @@ -337,6 +346,19 @@ export interface SDKRpcClientV2Options { * source. Passed into the engine through `BootstrapInput.args.skillDirs`. */ readonly skillDirs?: readonly string[]; + /** + * Explicit agent files (the CLI's `--agent-file`): loaded at the highest + * catalog priority for every workspace this client hosts, on top of the + * discovered user / project / plugin agent roots. Passed into the engine + * through `BootstrapInput.args.agentFiles`, which is where the + * workspace-scoped explicit loader reads them from — a session selects one + * of the profiles they define with `createSession`'s `agentProfile`. + * + * The scope is this client's own engine bootstrap, not the OS process: a + * second client in the same process bootstraps its own engine and sees none + * of these files. + */ + readonly agentFiles?: readonly string[]; readonly telemetry?: TelemetryClient; readonly onOAuthRefresh?: (outcome: OAuthRefreshOutcome) => void; readonly uiMode?: string; @@ -444,6 +466,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // `--skills-dir` (v1 parity): explicit skill dirs replace default // user / project discovery for every session this client hosts. skillDirs: options.skillDirs, + // `--agent-file` (v1 parity): explicit agent definition files, added + // to every workspace catalog at the highest priority. Passed through + // unresolved — the engine expands `~` and resolves relative paths + // against the workspace root, mirroring `skillDirs`. + agentFiles: options.agentFiles, }, }, [...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env }))], @@ -818,11 +845,32 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return handle; } + /** + * v1's rejection for a `createSession` profile name the catalog does not + * have (`resolveMainAgentProfile`): a `KimiError` carrying + * `agent.not_found`. The v2 engine raises its own `ProfileError` with + * `profile.unknown` instead, and that class is not exported from this + * package — `isKimiError` is an `instanceof KimiError` test, so a consumer + * that switches engines would stop recognizing the failure entirely. Both + * routes by which {@link createSession} can learn the name is unusable (the + * pre-create catalog check, and a bind that loses the race behind it) answer + * with this instead. Only that SDK surface is translated: the klient facade + * and kap-server keep speaking the engine's own error vocabulary. + */ + private static agentProfileNotFound(profileName: string, available: string): KimiError { + return new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Agent profile "${profileName}" was not found. Available profiles: ${available}`, + { details: { profile: profileName, available } }, + ); + } + /** * Attach the event/interaction wiring to a freshly materialized session - * (idempotent). Unwiring needs no call site of its own: every close path - * goes through the engine's lifecycle close, whose `onDidCloseSession` - * subscription (constructor) drops the wiring. + * (idempotent). Unwiring needs one call site of its own — {@link + * createSession}'s rollback, which unwires ahead of its delete; every other + * close path goes through the engine's lifecycle close, whose + * `onDidCloseSession` subscription (constructor) drops the wiring. */ private wireSession(handle: ISessionScopeHandle): void { if (this.sessionWirings.has(handle.id)) return; @@ -1049,6 +1097,14 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * deliberately NOT `strictThinking`, and the v2-only create-time rejections * that still leak through (unknown alias → `config.invalid`, no configured * default model → `model.not_configured`) are pinned in the parity tests. + * + * `agentProfile` (`--agent`, and the profile an `--agent-file` defines) joins + * that same bind rather than the engine's own `mainAgentBinding`, because + * binding inside `create` would run before the session is wired and drop the + * warnings the bind publishes. Binding that late is also why the name is + * checked against the workspace catalog first (see + * {@link assertProfileInWorkspaceCatalog}) and why a bind that still fails + * takes the session with it. */ override async createSession(input: CreateSessionOptions): Promise { const workDir = normalizeRequiredWorkDir('createSession', input.workDir); @@ -1066,23 +1122,91 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { const handler = await this.engineAccessor .get(IWorkspaceLifecycleService) .handlerFor({ root: workDir }); - const handle = await handler.accessor.get(ISessionLifecycleService).create({ + const lifecycle = handler.accessor.get(ISessionLifecycleService); + if (input.agentProfile !== undefined) { + await this.assertProfileInWorkspaceCatalog(handler, input.agentProfile); + } + const handle = await lifecycle.create({ sessionId: input.id, workDir, additionalDirs: input.additionalDirs, }); - // Wired before the optional main-agent materialization so a profile-bind - // warning (oversized AGENTS.md) reaches the listeners like v1's create. + // Wired before the main-agent materialization so the warnings the bind + // publishes — an oversized AGENTS.md, and the tool patterns a profile's + // `tools` / `disallowedTools` match nothing with — reach the listeners + // like v1's create. The agent event bus has no replay, and the + // tool-pattern warning is emitted once per pattern, so a bind that runs + // before this line loses them for good. this.wireSession(handle); if ( input.model !== undefined || input.thinking !== undefined || - input.permission !== undefined + input.permission !== undefined || + input.agentProfile !== undefined ) { - const agent = await this.materializeMainAgent(handle, { - model: input.model, - thinking: input.thinking, - }); + let agent: IAgentScopeHandle; + try { + agent = await this.materializeMainAgent(handle, { + model: input.model, + thinking: input.thinking, + profile: input.agentProfile, + }); + } catch (error) { + // An explicit `agentProfile` states what this session is FOR, so any + // bind that fails to honor it takes the session with it — the unknown + // name that raced the check above, an unknown model alias + // (`config.invalid`), a home with no configured model at all + // (`model.not_configured`). Without a profile those two keep the + // session they have always left behind: that is pre-existing behavior + // the parity tests pin, and widening the rollback to it is a separate + // change from this one. + if (input.agentProfile !== undefined) { + // Ahead of the delete: `close` drops the handle from the engine's + // session map before disposal fires `onDidCloseSession`, so a delete + // that throws midway would otherwise strand this session's wiring + // here for good. + this.unwireSession(handle.id); + const log = this.engineAccessor.get(ILogService); + // `delete` resolves by id, and the id may no longer be ours: two + // concurrent creates for the SAME explicit id both succeed (the + // engine has no duplicate-id guard and the SESSION_ALREADY_EXISTS + // check above is not atomic), leaving the last one registered. + // Deleting then would take the other session, not the one that just + // failed. Roll back only while the id still resolves to the handle + // this call created — the underlying race predates this rollback and + // is the engine's to fix; this only keeps the rollback out of it. + if (lifecycle.get(handle.id) === handle) { + await lifecycle.delete(handle.id).catch((rollbackError: unknown) => { + // The bind failure stays the caller's error; a rollback that + // itself fails leaves a session on disk and in the index, which + // is only ever visible in the log. + log.error( + `rollback of session "${handle.id}" after a failed "${input.agentProfile}" profile bind failed`, + { error: String(rollbackError) }, + ); + }); + } else { + log.error( + `session "${handle.id}" was replaced by a concurrent create; leaving it in place rather than rolling back another session`, + ); + } + // The name passed the pre-create check and was gone by the time the + // bind ran (a catalog reload in between). Rare, but it is the same + // failure the caller would have got a moment earlier, so it must not + // arrive wearing a different type — translate it like the check does. + if ( + error instanceof ProfileError && + error.code === ProfileErrors.codes.PROFILE_UNKNOWN + ) { + const available = error.details?.['available']; + throw SDKRpcClientV2.agentProfileNotFound( + input.agentProfile, + typeof available === 'string' ? available : '', + ); + } + } + throw error; + } if (input.permission !== undefined) { agent.accessor.get(IAgentPermissionModeService).setMode(input.permission); } @@ -1095,6 +1219,69 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return { ...(await this.liveSessionSummary(handle)), metadata: input.metadata }; } + /** + * Reject a main-agent profile the workspace cannot serve BEFORE any session + * exists. `ISessionLifecycleService.create` announces the finished session as + * its last step — the `SessionStart` external hook and the `session_started` + * fact — so a profile rejected by the later bind would have already run the + * user's hook commands and recorded a start for a session about to be rolled + * back (and the rollback then runs `SessionEnd` on top). v1 resolves the + * requested profile against its catalog before its store write for the same + * reason. + * + * The v2 merged catalog only exists at Session scope, so the question is + * asked one level up: await the five workspace profile loaders (exactly what + * session materialization awaits), then fold the App-scope registry down to + * the records this workspace sees. Presence of the NAME is the whole test — + * the Session catalog's merge only ever chooses between same-name candidates + * (its one skip, a non-`override` file shadowing a builtin, falls back to + * that builtin), so a name any visible record contributes always resolves to + * some profile there. The bind stays authoritative: this only moves the + * failure earlier, and {@link createSession}'s rollback still covers the + * check→bind race. + * + * INVARIANT this rests on: every source of agent profiles is registry-visible + * by the time a session is created. True of all six loaders today. A future + * contribution point that registers lazily, or without the `workspaceKey` + * this fold filters on, would be invisible here and make the check reject a + * profile the bind would have accepted — extend the fold with it rather than + * leaving the rejection to be discovered at runtime. + */ + private async assertProfileInWorkspaceCatalog( + handler: IWorkspaceScopeHandle, + profileName: string, + ): Promise { + try { + await Promise.all([ + handler.accessor.get(IWorkspaceAgentProfileLoader).ready, + handler.accessor.get(IExtraAgentProfileLoader).ready, + handler.accessor.get(IExplicitAgentProfileLoader).ready, + handler.accessor.get(IUserAgentProfileLoader).ready, + handler.accessor.get(IPluginAgentProfileLoader).ready, + ]); + } catch { + // A loader that failed is the engine's error to raise, not this check's: + // `create` awaits the same five and rejects with it, and its own failure + // path reloads the explicit loader so the next attempt re-reads the file. + // Reporting "unknown profile" off a half-loaded catalog would be a lie. + return; + } + const workspaceKey = handler.accessor.get(IWorkspaceContext).workspaceId; + const available = new Set(); + for (const entry of this.engineAccessor.get(IAgentProfileRegistry).entries()) { + // Global records (builtin) plus this workspace's own; another + // workspace's user/project/explicit files are not in this catalog. + if (entry.workspaceKey !== undefined && entry.workspaceKey !== workspaceKey) continue; + for (const profile of entry.contribution.profiles) available.add(profile.name); + } + if (available.has(profileName)) return; + // v1's shape, not the engine's — see {@link agentProfileNotFound}. The + // `available` list is a human-readable hint rather than a contract, and its + // order does differ from the engine's (registry order here, merged-map + // order there, which puts builtins first). + throw SDKRpcClientV2.agentProfileNotFound(profileName, [...available].join(', ')); + } + /** * v1 renames through the live session when there is one and at the store * level otherwise. The v2 metadata service is session-scoped (and the @@ -1310,14 +1497,19 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * The session's materialized main agent with v1's eager default binding * applied: a freshly created agent whose profile is still unbound gets the * default profile + configured default model (the same bind kap-server's - * prompt route performs on first use). A home with no configured model - * leaves the agent unbound instead of failing — v1's model-less session - * reads (`model: undefined`, `'off'` thinking, zero capabilities) map onto - * the unbound state exactly. + * prompt route performs on first use). With no binding requested, a home + * with no configured model leaves the agent unbound instead of failing — + * v1's model-less session reads (`model: undefined`, `'off'` thinking, zero + * capabilities) map onto the unbound state exactly. A caller that DID + * request one gets the rejection instead, since there is no unbound state + * that honors it: `binding.profile` selects a profile (`--agent` / + * `--agent-file`) and an unknown name raises the catalog's own + * `profile.unknown` — which {@link createSession} restates as v1's + * `agent.not_found` before it reaches an SDK caller. */ private async materializeMainAgent( session: ISessionScopeHandle, - binding?: { readonly model?: string; readonly thinking?: string }, + binding?: { readonly model?: string; readonly thinking?: string; readonly profile?: string }, ): Promise { await this.modelReady; const agent = await ensureMainAgent(session); @@ -1325,7 +1517,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (binding !== undefined || profile.data().profileName === undefined) { try { await profile.bind({ - profile: DEFAULT_AGENT_PROFILE_NAME, + profile: binding?.profile ?? DEFAULT_AGENT_PROFILE_NAME, model: binding?.model, thinking: binding?.thinking, }); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 8ce05461d4..6b00b25427 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -109,6 +109,19 @@ export interface KimiHarnessOptions { readonly autoLoadConfig?: boolean | undefined; readonly uiMode?: string; readonly skillDirs?: readonly string[]; + /** + * Explicit agent files (`--agent-file`) registered at the highest catalog + * priority. Registration is per harness, not per session: a session selects + * one of the profiles they define through + * {@link CreateSessionOptions.agentProfile}, and the profiles stay in the + * catalog for every later session this harness creates. Two harnesses in one + * process therefore have separate catalogs. + * + * `createKimiHarnessV2` only: the legacy harness ignores this option and + * takes explicit files per session through + * {@link CreateSessionOptions.agentFiles} instead. + */ + readonly agentFiles?: readonly string[]; readonly telemetry?: TelemetryClient | undefined; readonly onOAuthRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; readonly sessionStartedProperties?: TelemetryProperties; @@ -127,12 +140,22 @@ export interface CreateSessionOptions { readonly additionalDirs?: readonly string[]; /** * Main-agent profile name (`--agent`): a builtin profile or one defined by - * an agentfile discovered from the user/project agent directories. + * an agent file discovered from the user/project agent directories. + * + * Naming one is explicit intent, so unlike a session that takes whatever + * default it is given, it never degrades: a name the workspace catalog does + * not have is rejected before anything is created — as a `KimiError` with + * `agent.not_found`, on both engines — and a home with no configured model + * rejects with `model.not_configured` rather than leaving the main agent + * unbound the way a profile-less create does. */ readonly agentProfile?: string; /** - * Explicit agentfiles (`--agent-file`) loaded for this session with the - * highest precedence; an invalid file fails session creation. + * Explicit agent files (`--agent-file`) loaded for this session with the + * highest precedence; an invalid file fails session creation. The v2 engine + * registers explicit files per harness rather than per session, so hosts on + * that engine pass them through {@link KimiHarnessOptions.agentFiles} and + * select the resulting profile here with {@link agentProfile}. */ readonly agentFiles?: readonly string[]; readonly sessionStartedProperties?: TelemetryProperties; diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 5c6bad93a4..4c17f23fce 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -26,7 +26,11 @@ import { foldAgentWireReplay } from '#/v2/resume-replay'; import { drainQueryStoreDisposals, drainSessionIndexMirror, + ensureMainAgent, + getLiveSessionById, + IAgentProfileService, IHostRequestHeaders, + type ProfileData, } from '@moonshot-ai/agent-core-v2'; import { McpOAuthService } from '../../agent-core/src/mcp/oauth/service'; @@ -226,6 +230,267 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('binds the requested agentProfile on the session it creates', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + // Discoverable through the project agent root, so only the selection + // (`--agent`) is under test here, not the explicit-file registration. + await writeReviewerAgent(join(workDir, '.kimi-code', 'agents')); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'reviewer', + }); + expect(await mainAgentProfile(rpc, summary.id)).toMatchObject({ + profileName: 'reviewer', + }); + } finally { + await rpc.close(); + } + }); + + it('accepts an agentProfile discovered from the user agent root', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + // The user loader, rather than the project or explicit one the other tests + // use: the pre-create catalog check has to see every profile source, so + // each one it can reject over is worth exercising. + await writeReviewerAgent(join(homeDir, 'agents')); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'reviewer', + }); + expect(await mainAgentProfile(rpc, summary.id)).toMatchObject({ + profileName: 'reviewer', + }); + } finally { + await rpc.close(); + } + }); + + it('creates nothing at all for an unknown agentProfile', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + + const records: TelemetryRecord[] = []; + const rpc = new SDKRpcClientV2({ + homeDir, + identity: TEST_IDENTITY, + telemetry: recordingTelemetry(records), + }); + const started = (): number => records.filter((r) => r.event === 'session_started').length; + try { + // A control session first, so the absences asserted below are real ones + // and not a telemetry client that never receives anything. + await rpc.createSession({ id: 'session_control', workDir, model: TEST_MODEL }); + expect(started()).toBe(1); + + // The v1 contract for this option, pinned in create-session-transport's + // "does not persist a session record when the requested agent profile is + // missing": a KimiError with `agent.not_found`. The v2 engine's own + // ProfileError is not exported from this package and `isKimiError` is an + // instanceof test, so surfacing it raw would strand SDK consumers. + await expect( + rpc.createSession({ + id: 'session_missing_agent_profile', + workDir, + model: TEST_MODEL, + agentProfile: 'missing-agent', + }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: ErrorCodes.AGENT_NOT_FOUND, + message: expect.stringContaining('missing-agent'), + }); + + // Session creation announces the finished session as its last step — the + // SessionStart hook and this fact — so a name rejected only once the bind + // runs would have fired both for a session that never survived the call. + expect(started()).toBe(1); + const listed = await rpc.listSessions({ workDir }); + expect(listed.map((session) => session.id)).toEqual(['session_control']); + // Nothing on disk either, so the id is free. + await expect( + rpc.createSession({ id: 'session_missing_agent_profile', workDir, model: TEST_MODEL }), + ).resolves.toMatchObject({ id: 'session_missing_agent_profile' }); + } finally { + await rpc.close(); + } + }); + + it('rolls the session back when an explicitly selected profile cannot be bound', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + await writeReviewerAgent(join(workDir, '.kimi-code', 'agents')); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + // The profile resolves, so the catalog check passes and the bind is + // reached — and then fails on the model alias. The session the caller + // asked to run `reviewer` on must not outlive that failure. + await expect( + rpc.createSession({ + id: 'session_reviewer_bad_model', + workDir, + model: 'no-such-model', + agentProfile: 'reviewer', + }), + ).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); + expect(await rpc.listSessions({ workDir })).toEqual([]); + } finally { + await rpc.close(); + } + }); + + it('rejects an explicitly selected profile on a home with no model configured', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + // No config.toml at all. Without a profile the main agent is left unbound + // (v1's model-less session, pinned in the parity suite); an explicit + // `agentProfile` has no such degraded state to fall back to, so it fails. + await writeReviewerAgent(join(workDir, '.kimi-code', 'agents')); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + await expect( + rpc.createSession({ + id: 'session_reviewer_no_model', + workDir, + agentProfile: 'reviewer', + }), + ).rejects.toMatchObject({ code: ErrorCodes.MODEL_NOT_CONFIGURED }); + expect(await rpc.listSessions({ workDir })).toEqual([]); + } finally { + await rpc.close(); + } + }); + + it('registers agentFiles (--agent-file) from outside every discovery root', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const explicitDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-explicit-')); + tempDirs.push(explicitDir); + await writeTestModelConfig(homeDir); + // Outside every discovery root: the profile can only reach the catalog + // through the explicit `agentFiles` registration. + const agentFile = await writeReviewerAgent(explicitDir); + + const rpc = new SDKRpcClientV2({ + homeDir, + identity: TEST_IDENTITY, + agentFiles: [agentFile], + }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'reviewer', + }); + const profile = await mainAgentProfile(rpc, summary.id); + expect(profile.profileName).toBe('reviewer'); + // The file's body is what the bound profile renders as its prompt. + expect(profile.systemPrompt).toContain('Review the requested change.'); + } finally { + await rpc.close(); + } + }); + + it('shadows a same-name builtin profile with the explicit agent file, for later sessions too', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const explicitDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-explicit-')); + tempDirs.push(explicitDir); + await writeTestModelConfig(homeDir); + // Deliberately named after the builtin default profile: `--agent-file` is + // explicit launch intent and outranks every other source, so it replaces + // the builtin for the whole launch — including sessions created later, + // which bind that same name as their default. + const agentFile = join(explicitDir, 'agent.md'); + await writeFile( + agentFile, + '---\nname: agent\ndescription: Overrides the builtin default.\n---\n\nShadowed default prompt.\n', + 'utf-8', + ); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY, agentFiles: [agentFile] }); + try { + const first = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'agent', + }); + expect((await mainAgentProfile(rpc, first.id)).systemPrompt).toContain( + 'Shadowed default prompt.', + ); + // A session created later selects no profile at all, so it binds the + // default by name — which the explicit file now owns. + const later = await rpc.createSession({ workDir, model: TEST_MODEL }); + expect((await mainAgentProfile(rpc, later.id)).systemPrompt).toContain( + 'Shadowed default prompt.', + ); + } finally { + await rpc.close(); + } + }); + + it('delivers the profile-bind warnings of an explicitly selected agent file', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const explicitDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-explicit-')); + tempDirs.push(explicitDir); + await writeTestModelConfig(homeDir); + // `Reed` is a typo no registered tool answers to, so binding this profile + // publishes a `tool-pattern-no-match` warning. The agent event bus has no + // replay and the warning fires once per pattern, so it only reaches a + // listener if the bind runs after the session is wired. + const agentFile = join(explicitDir, 'typo-tools.md'); + await writeFile( + agentFile, + '---\nname: typo-tools\ndescription: Has a misspelled tool.\ntools:\n - Reed\n---\n\nBody.\n', + 'utf-8', + ); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY, agentFiles: [agentFile] }); + const warnings: string[] = []; + const unsubscribe = rpc.onEvent((event) => { + if (event.type === 'warning') warnings.push(event.message); + }); + try { + await rpc.createSession({ workDir, model: TEST_MODEL, agentProfile: 'typo-tools' }); + expect(warnings.some((message) => message.includes('Reed'))).toBe(true); + } finally { + unsubscribe(); + await rpc.close(); + } + }); + it('serves the plugin catalog from the v2 engine on an empty home', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); @@ -539,3 +804,43 @@ async function writeSkill(dir: string, name: string): Promise { await mkd 'utf-8', ); } + +const TEST_MODEL = 'kimi-test-model'; + +async function writeTestModelConfig(homeDir: string): Promise { + await writeFile( + join(homeDir, 'config.toml'), + ` +[providers.local] +type = "kimi" +base_url = "https://example.test/v1" +api_key = "sk-test" + +[models."${TEST_MODEL}"] +provider = "local" +model = "${TEST_MODEL}" +max_context_size = 1000 +`, + 'utf-8', + ); +} + +/** Writes the `reviewer` agent file into `dir` and returns its path. */ +async function writeReviewerAgent(dir: string): Promise { + await mkdir(dir, { recursive: true }); + const path = join(dir, 'reviewer.md'); + await writeFile( + path, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', + ); + return path; +} + +/** The profile the session's main agent is actually bound to. */ +async function mainAgentProfile(rpc: SDKRpcClientV2, sessionId: string): Promise { + const session = getLiveSessionById(rpc.engineAccessor, sessionId); + expect(session).toBeDefined(); + const agent = await ensureMainAgent(session!); + return agent.accessor.get(IAgentProfileService).data(); +}