From 44d34bbd56117212aeca96654a3f3cec066834b2 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Fri, 31 Jul 2026 16:47:34 +0800 Subject: [PATCH] refactor(agent-core-v2): move host runtime args onto IBootstrapService (#2460) * fix(agent-core-v2): resolve package self-references in check-import-boundaries Imports spelled @moonshot-ai/agent-core-v2/ (the legal `./*` export self-reference) were treated as external packages, letting kosong layer violations through that spelling pass the checker. * refactor(agent-core-v2): move host runtime args onto IBootstrapService - Add HostArgs under BootstrapInput.args / IBootstrapService.args (agentFiles, skillDirs, requestHeaders, displayName, replyStyleGuide), mirroring VS Code's NativeParsedArgs on the environment service - Remove the narrow per-domain runtime-options services and their seed functions: IAgentCatalogRuntimeOptions, ISkillCatalogRuntimeOptions, IHostIdentity - Reduce IHostRequestHeaders to a pure kosong port contract and bridge it from bootstrap args via a new app/kosongConfig adapter, keeping kosong free of app-layer imports - Pass host args through bootstrap() at the composition roots (kap-server, v2 print CLI, node-sdk) instead of seeding services - Persist SDK provider removal as one atomic multi-section config replace * fix(config): persist provider refresh updates atomically - expose atomic multi-section config replacement through klient and SDK - stage provider removals before one atomic write in TUI refresh - briefly drain startup refresh during shutdown --- .agents/skills/agent-core-dev/SKILL.md | 2 +- .agents/skills/agent-core-dev/config.md | 15 ++- apps/kimi-code/src/cli/v2/run-v2-print.ts | 33 ++--- .../src/tui/controllers/auth-flow.ts | 83 +++++++++--- apps/kimi-code/src/tui/kimi-tui.ts | 13 +- apps/kimi-code/test/cli/v2-run-print.test.ts | 38 +++--- .../test/tui/kimi-tui-startup.test.ts | 116 +++++++++++++++++ .../scripts/check-import-boundaries.mjs | 14 ++- .../src/agent/profile/profileService.ts | 6 +- .../app/auth/webSearch/webSearchService.ts | 13 +- .../src/app/bootstrap/bootstrap.ts | 66 +++++++++- .../src/app/bootstrap/bootstrapService.ts | 3 + .../agent-core-v2/src/app/config/config.ts | 9 ++ .../src/app/config/configService.ts | 15 ++- .../src/app/hostIdentity/hostIdentity.ts | 56 --------- .../src/app/kosongConfig/discoveryService.ts | 6 +- .../kosongConfig/hostRequestHeadersAdapter.ts | 29 +++++ .../skillCatalogRuntimeOptions.ts | 53 -------- .../app/skillCatalog/userFileSkillSource.ts | 4 +- .../agent-core-v2/src/app/web/webService.ts | 16 +-- packages/agent-core-v2/src/index.ts | 4 +- .../src/kosong/model/hostRequestHeaders.ts | 39 ++---- .../agentCatalogRuntimeOptions.ts | 51 -------- .../explicitAgentProfileLoaderService.ts | 4 +- .../explicitFileSkillSource.ts | 15 ++- .../rootFileSkillSource.ts | 6 +- .../test/agent/profile/profileOps.test.ts | 2 - .../agent-core-v2/test/app/auth/auth.test.ts | 12 +- .../agent-core-v2/test/app/bootstrap/stubs.ts | 9 +- .../test/app/config/config.test.ts | 25 ++++ .../test/app/kosongConfig/discovery.test.ts | 8 +- .../test/app/web/web-fetch-service.test.ts | 12 +- .../test/kosong/model/catalog.test.ts | 6 +- .../test/lint/import-boundaries.test.ts | 9 ++ .../agentProfileLoader.test.ts | 8 +- .../test/workspace/workspaceResources.test.ts | 11 +- .../skillCatalog.test.ts | 28 +---- packages/kap-server/src/routes/skills.ts | 4 +- packages/kap-server/src/start.ts | 24 ++-- packages/kap-server/test/boot.test.ts | 12 +- packages/kap-server/test/skills.test.ts | 3 +- packages/klient/src/contract/global/config.ts | 4 + packages/klient/src/core/facade/global.ts | 23 +++- packages/klient/test/helpers/conformance.ts | 33 +++++ packages/node-sdk/src/index.ts | 1 + packages/node-sdk/src/kimi-harness.ts | 18 +++ packages/node-sdk/src/rpc.ts | 24 ++++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 58 +++++---- packages/node-sdk/src/v2/config-mapper.ts | 25 ++++ .../node-sdk/test/sdk-rpc-client-v2.test.ts | 118 +++++++++++++++++- 50 files changed, 773 insertions(+), 413 deletions(-) delete mode 100644 packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts create mode 100644 packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts delete mode 100644 packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts delete mode 100644 packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md index da890291b..b35ea3a03 100644 --- a/.agents/skills/agent-core-dev/SKILL.md +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -67,4 +67,4 @@ Invariants that hold across every stage. Each is expanded in the stage file note 9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md) 10. Gate unreleased behavior behind a flag contributed via `registerFlagDefinition` and resolved through `IFlagService.enabled(id)`; no ad-hoc env toggles. (flags.md) 11. Tests resolve the SUT by interface; shared stubs live under `test/`, never `src/`. (test.md) -12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService` (kept domain-agnostic — never add cron/flags/model state); session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md) +12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService`, and host invocation arguments (CLI flags, host identity headers, prompt identity) → `BootstrapInput.args` / `IBootstrapService.args` — never new per-domain runtime-options services; domain runtime state (cron/flags/model) never goes onto `IBootstrapService`; session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md) diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 275f630c9..2eb48516e 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -42,9 +42,16 @@ If it fails any rule, it is not Config: **`IBootstrapService` is domain-agnostic.** It holds only generic facts shared by all domains — the env bag, resolved paths, and host facts (`platform`, `arch`, -`cwd`, `osHomeDir`, `isCI`, …). It must **never** hold state tied to a specific -upper domain (no `cron`, no `flags`, no feature-specific fields): that couples -the foundational layer to an upstream one. +`cwd`, `osHomeDir`, `isCI`, …) — plus the host's process-level invocation +arguments in `args` (explicit `agentFiles` / `skillDirs`, `requestHeaders`, +prompt identity). `args` mirrors VS Code's `NativeParsedArgs` on the +environment service: the host states them once via `BootstrapInput.args` at +the composition root, and downstream services read them from +`IBootstrapService.args` instead of through per-domain runtime-options +services (do not add new `IXxxRuntimeOptions` services or seed functions for +host parameters). What must **never** land on `IBootstrapService` is state +tied to a specific upper domain (no `cron`, no `flags`, no feature-specific +fields): that couples the foundational layer to an upstream one. Any value that belongs to a specific domain — including env-only operational toggles (`KIMI_CRON_*`, `KIMI_CODE_EXPERIMENTAL_*`), model parameters, or feature @@ -264,7 +271,7 @@ The authoritative, always-current list of registered sections — rendered in th - `config` never imports the domains that consume it — keep section schemas in the owning domain. - Config is the **preference registry**: register only values that are preferences, persistable, schema'd, and user/operator-facing. Facts → `IBootstrapService`; session state → Session scope; constants → code. - Business domains read `config.get(...)` or structured `IBootstrapService` facts; never call `IBootstrapService.getEnv()` directly — only `config` reads the raw env bag to build overlays. -- Keep `IBootstrapService` domain-agnostic: never add state tied to a specific upper domain (cron, flags, model params, …). Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`. +- Keep `IBootstrapService` domain-agnostic: host invocation arguments (CLI flags, host identity headers, prompt identity) go into `BootstrapInput.args` / `IBootstrapService.args` — never into new per-domain runtime-options services; domain runtime state (cron, flags, model params, …) never goes onto `IBootstrapService` at all. Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`. - Do not pass a whole config bag via options; read each section through `IConfigService`. There is no `KimiConfig` object — config is a registry of owner-owned sections. - `config.toml` is snake_case on disk, camelCase in memory — never write camelCase keys to disk, and never write to `config.toml` except through `IConfigService.set/replace`. - Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file. diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index d9f6237cf..891032b4d 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -37,13 +37,11 @@ import { ITelemetryService, PRINT_MAX_TURNS_DEFAULT, PRINT_WAIT_CEILING_S_DEFAULT, - agentCatalogRuntimeOptionsSeed, applyPrintModeConfigDefaults, bootstrap, createCloudAppender, ensureMainAgent, resumeSessionById, - hostRequestHeadersSeed, logSeed, parseAgentFileText, resolveAgentPath, @@ -51,7 +49,6 @@ import { resolveKimiHome, resolveLoggingConfig, resolvePrintBackgroundMode, - skillCatalogRuntimeOptionsSeed, type DomainEvent, type IAgentScopeHandle, type ISessionScopeHandle, @@ -130,18 +127,24 @@ export async function runV2Print( const identity = createKimiCodeHostIdentity(version); const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity }); - const { app } = bootstrap({ homeDir, clientIdentity: identity }, [ - ...logSeed(logging), - ...hostRequestHeadersSeed(hostHeaders), - // `--skillsDir` (v1 print parity): explicit skill dirs replace default - // user / project discovery for this process. - ...skillCatalogRuntimeOptionsSeed(opts.skillsDirs), - // `--agent-file`: explicit agent definition files, registered with the - // highest-precedence source for this process. Passed through unresolved — - // the engine expands `~` and resolves relative paths against the session - // workDir (mirroring `--skills-dir`). - ...agentCatalogRuntimeOptionsSeed(opts.agentFiles), - ]); + const { app } = bootstrap( + { + homeDir, + clientIdentity: identity, + args: { + requestHeaders: hostHeaders, + // `--skillsDir` (v1 print parity): explicit skill dirs replace default + // user / project discovery for this process. + skillDirs: opts.skillsDirs, + // `--agent-file`: explicit agent definition files, registered with the + // highest-precedence source for this process. Passed through unresolved — + // the engine expands `~` and resolves relative paths against the session + // workDir (mirroring `--skills-dir`). + agentFiles: opts.agentFiles, + }, + }, + [...logSeed(logging)], + ); const auth = app.accessor.get(IOAuthToolkit); const configService = app.accessor.get(IConfigService); diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index bf8699ff2..06c5f46d8 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,4 +1,11 @@ -import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { + removeProviderFromConfig, + type CreateSessionOptions, + type KimiConfig, + type KimiHarness, + type OAuthRef, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; import { createKimiCodeUserAgent } from '#/cli/version'; @@ -7,6 +14,7 @@ import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; import { refreshAllProviderModels, + type RefreshProviderHost, type RefreshProviderScope, type RefreshResult, } from '../utils/refresh-providers'; @@ -172,23 +180,68 @@ export class AuthFlowController { } private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise { - const { host } = this; - const result = await refreshAllProviderModels( - { - getConfig: () => host.harness.getConfig({ reload: true }), - removeProvider: (id) => host.harness.removeProvider(id), - setConfig: (patch) => host.harness.setConfig(patch), - resolveOAuthToken: async (providerName, oauthRef) => { - const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); - return tokenProvider.getAccessToken(); - }, - userAgent: createKimiCodeUserAgent(), - }, - { scope }, - ); + const result = await refreshAllProviderModels(this.buildRefreshHost(), { scope }); if (result.changed.length > 0) { await this.refreshAvailableModels(); } return result; } + + /** + * Build the refresh orchestrator's persistence host. When the harness can + * persist several config sections as ONE atomic write (the v2 engine's + * `replaceSections`), the orchestrator's two-phase contract (removeProvider + * then setConfig) is absorbed the same way the v2 engine's own refresh path + * does it: the removal is staged in memory only, and the following + * setConfig persists the complete records in a single write — so a process + * exit mid-refresh can never leave config.toml in a "provider removed, not + * yet restored" state. The v1 harness keeps the legacy host (two + * whole-document writes, each atomic on its own). + */ + private buildRefreshHost(): RefreshProviderHost { + const { host } = this; + const resolveOAuthToken = async (providerName: string, oauthRef?: OAuthRef): Promise => { + const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); + return tokenProvider.getAccessToken(); + }; + const userAgent = createKimiCodeUserAgent(); + if (!host.harness.supportsAtomicSectionReplace()) { + return { + getConfig: () => host.harness.getConfig({ reload: true }), + removeProvider: (id) => host.harness.removeProvider(id), + setConfig: (patch) => host.harness.setConfig(patch), + resolveOAuthToken, + userAgent, + }; + } + let staged: KimiConfig | undefined; + const requireStaged = (): KimiConfig => { + if (staged === undefined) { + throw new Error('refresh host: getConfig must be called before writes'); + } + return staged; + }; + return { + getConfig: async () => { + staged = await host.harness.getConfig({ reload: true }); + return staged; + }, + removeProvider: (id) => { + staged = removeProviderFromConfig(requireStaged(), id); + return Promise.resolve(staged); + }, + setConfig: async (patch) => { + // The orchestrator always passes complete records (built from a full + // clone), so the Partial-shaped patch is a full KimiConfig overlay. + staged = { ...requireStaged(), ...patch } as KimiConfig; + // Object.entries keeps keys whose value is `undefined`, so a cleared + // section (e.g. a dangling defaultModel) is expressed as a removal in + // the atomic write; sections absent from the patch stay untouched. + await host.harness.replaceConfigSections(Object.fromEntries(Object.entries(patch))); + return staged; + }, + resolveOAuthToken, + userAgent, + }; + } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2061725f4..834bd7777 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -325,6 +325,7 @@ export class KimiTUI { private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; + private backgroundRefreshPromise: Promise | undefined; private readonly migrationPlan: MigrationPlan | null; private readonly migrateOnly: boolean; private readonly engineV2: boolean; @@ -748,7 +749,7 @@ export class KimiTUI { private async init(): Promise { setExperimentalFeatures(await this.harness.getExperimentalFeatures()); await this.authFlow.refreshAvailableModels(); - void this.refreshProviderModelsInBackground(); + this.backgroundRefreshPromise = this.refreshProviderModelsInBackground(); const { startup } = this.options; const { workDir } = this.state.appState; @@ -852,6 +853,16 @@ export class KimiTUI { this.isShuttingDown = true; this.unregisterSignalHandlers(); this.aborted = true; + // Give the startup provider-model refresh a brief chance to finish before + // the harness closes (and the process exits): its config writes are each + // atomic, so draining can only ever leave a complete file behind. Bounded + // so a slow network never delays the exit. + if (this.backgroundRefreshPromise !== undefined) { + await Promise.race([ + this.backgroundRefreshPromise, + new Promise((resolve) => setTimeout(resolve, 1500)), + ]); + } this.streamingUI.discardPending(); // Stop background polling, streaming intervals, and per-component timers // before tearing the UI down, so they can't keep firing requestRender after diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index fab72a136..44d3390d8 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - IAgentCatalogRuntimeOptions, IAgentGoalService, IAgentLifecycleService, IAgentPermissionModeService, @@ -22,10 +21,9 @@ import { ISessionIndex, ISessionLifecycleService, IWorkspaceLifecycleService, - ISkillCatalogRuntimeOptions, ITelemetryService, + type BootstrapInput, type DomainEvent, - type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; import { runV2Print } from '../../src/cli/v2/run-v2-print'; @@ -295,7 +293,7 @@ describe('runV2Print', () => { expect(app.dispose).toHaveBeenCalled(); }); - it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => { + it('passes explicit skill dirs from --skillsDir into bootstrap args', async () => { const stdout = writer(); const stderr = writer(); const { app, agent } = makeFakeHarness(); @@ -308,12 +306,11 @@ describe('runV2Print', () => { stderr, }); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - const seeded = seeds.find(([id]) => id === ISkillCatalogRuntimeOptions); - expect(seeded?.[1]).toMatchObject({ explicitDirs: ['/skills'] }); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.skillDirs).toEqual(['/skills']); }); - it('leaves the skill runtime options unseeded when --skillsDir is empty', async () => { + it('leaves the skill dirs arg unset when --skillsDir is empty', async () => { const stdout = writer(); const stderr = writer(); const { app, agent } = makeFakeHarness(); @@ -323,8 +320,8 @@ describe('runV2Print', () => { await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.skillDirs ?? []).toEqual([]); }); it('seeds explicit agent files from --agentFile and binds the --agent profile', async () => { @@ -341,9 +338,8 @@ describe('runV2Print', () => { { stdout, stderr }, ); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); - expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] }); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual(['/agents/reviewer.md']); const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; @@ -376,9 +372,8 @@ describe('runV2Print', () => { stderr, }); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); - expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] }); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual([agentFile]); const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; @@ -430,7 +425,7 @@ describe('runV2Print', () => { expect(profile.bind).not.toHaveBeenCalled(); }); - it('leaves the agent runtime options unseeded when --agentFile is empty', async () => { + it('leaves the agent files arg unset when --agentFile is empty', async () => { const stdout = writer(); const stderr = writer(); const { app, agent } = makeFakeHarness(); @@ -440,8 +435,8 @@ describe('runV2Print', () => { await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - expect(seeds.some(([id]) => id === IAgentCatalogRuntimeOptions)).toBe(false); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles ?? []).toEqual([]); }); it('passes --agent-file paths through unresolved so the engine can expand ~', async () => { @@ -458,9 +453,8 @@ describe('runV2Print', () => { { stdout, stderr }, ); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); - expect(seeded?.[1]).toMatchObject({ explicitFiles: ['~/agents/reviewer.md'] }); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual(['~/agents/reviewer.md']); }); it('treats re-selecting the already-bound profile on resume as a no-op', async () => { diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index b0689431e..daaaa79e1 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -206,6 +206,7 @@ function makeHarness(session = makeSession(), overrides: Record track: vi.fn(), setTelemetryContext: vi.fn(), getExperimentalFeatures: vi.fn(async () => []), + supportsAtomicSectionReplace: vi.fn(() => false), auth: { status: vi.fn(async () => ({ providers: [] })), login: vi.fn(async () => {}), @@ -1140,6 +1141,121 @@ describe('KimiTUI startup', () => { expect(showStatus).toHaveBeenCalledWith("New Models · +2 models."); }); + it("stages provider-refresh removals and persists one atomic write on atomic-capable harnesses", async () => { + const registryUrl = "https://registry.example.test/v1/models/api.json"; + const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" }; + const replaceConfigSections = vi.fn(async (_sections: Record) => {}); + const removeProvider = vi.fn(async () => ({})); + const setConfig = vi.fn(async () => ({})); + const harness = makeHarness(makeSession(), { + supportsAtomicSectionReplace: vi.fn(() => true), + replaceConfigSections, + removeProvider, + setConfig, + getConfig: vi.fn(async () => ({ + providers: { + a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source }, + b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source }, + }, + models: { + "a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + "b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + }, + defaultModel: "b/m1", + thinking: { enabled: true }, + })), + }); + const driver = makeDriver(harness, makeStartupInput()); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + a: { + id: "a", + name: "Provider A", + api: "https://a.example.test/v1", + type: "openai", + models: { m1: { id: "m1" } }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + try { + const result = await (driver as any).authFlow.refreshProviderModels(); + + expect(result.failed).toEqual([]); + expect(result.changed).toContainEqual({ providerId: "b", providerName: "b", added: 0, removed: 1 }); + // The removal was staged in memory: no destructive pre-write, exactly + // one atomic section replace carrying the complete records — with the + // dangling default model / thinking expressed as cleared sections. + expect(removeProvider).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); + expect(replaceConfigSections).toHaveBeenCalledTimes(1); + const sections = replaceConfigSections.mock.calls[0]?.[0] as Record; + expect(Object.keys(sections["providers"] as object)).toEqual(["a"]); + expect(sections["models"]).not.toHaveProperty("b/m1"); + expect(sections["defaultModel"]).toBeUndefined(); + expect(sections["thinking"]).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps the two-phase removeProvider/setConfig host on harnesses without atomic replace", async () => { + const registryUrl = "https://registry.example.test/v1/models/api.json"; + const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" }; + const replaceConfigSections = vi.fn(async () => {}); + const removeProvider = vi.fn(async () => ({})); + const setConfig = vi.fn(async (patch: Record) => patch); + const harness = makeHarness(makeSession(), { + replaceConfigSections, + removeProvider, + setConfig, + getConfig: vi.fn(async () => ({ + providers: { + a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source }, + b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source }, + }, + models: { + "a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + "b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + }, + defaultModel: "b/m1", + })), + }); + const driver = makeDriver(harness, makeStartupInput()); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + a: { + id: "a", + name: "Provider A", + api: "https://a.example.test/v1", + type: "openai", + models: { m1: { id: "m1" } }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + try { + const result = await (driver as any).authFlow.refreshProviderModels(); + + expect(result.failed).toEqual([]); + expect(removeProvider).toHaveBeenCalledWith("b"); + expect(setConfig).toHaveBeenCalledTimes(1); + expect(replaceConfigSections).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("starts TUI without a session when fresh startup needs OAuth login", async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { diff --git a/packages/agent-core-v2/scripts/check-import-boundaries.mjs b/packages/agent-core-v2/scripts/check-import-boundaries.mjs index 77f23896e..903afa1fe 100644 --- a/packages/agent-core-v2/scripts/check-import-boundaries.mjs +++ b/packages/agent-core-v2/scripts/check-import-boundaries.mjs @@ -25,9 +25,11 @@ * Kosong directories that do not exist yet are skipped silently (later * refactor phases add them). * - * Intra-package relative imports and `#/`-alias imports are resolved against - * `src/`. Sibling packages (`@moonshot-ai/*` other than v1) and third-party - * imports are out of scope (except for the kosong purity bans above). + * Intra-package relative imports, `#/`-alias imports, and the package's + * self-reference (`@moonshot-ai/agent-core-v2/` → `src/`) are + * resolved against `src/`. Sibling packages (`@moonshot-ai/*` other than v1) + * and third-party imports are out of scope (except for the kosong purity + * bans above). * * Run: `node scripts/check-import-boundaries.mjs`. Exits non-zero on violation. */ @@ -42,6 +44,7 @@ export const SRC_ROOT = join(PKG_ROOT, 'src'); const TEST_ROOT = join(PKG_ROOT, 'test'); const V1_PACKAGE = '@moonshot-ai/agent-core'; +const SELF_PACKAGE_PREFIX = '@moonshot-ai/agent-core-v2/'; /** * Scope directories introduced by the `src/{scope}/{domain}` layout. A path's @@ -172,6 +175,11 @@ function resolveIntraV2(specifier, fromFile) { if (specifier.startsWith('#/')) { return join(SRC_ROOT, specifier.slice(2)); } + // The package's legal self-reference: `@moonshot-ai/agent-core-v2/x` maps + // to `src/x` via the `./*` export. + if (specifier.startsWith(SELF_PACKAGE_PREFIX)) { + return join(SRC_ROOT, specifier.slice(SELF_PACKAGE_PREFIX.length)); + } if (specifier.startsWith('.')) { return resolve(dirname(fromFile), specifier); } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index c177dfb17..9e4b99cac 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -107,7 +107,6 @@ import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryCon import { IWireService } from '#/wire/wire'; import type { PayloadOf } from '#/wire/types'; import { IEventBus } from '#/app/event/eventBus'; -import { IHostIdentity } from '#/app/hostIdentity/hostIdentity'; import { prepareSystemPromptContext, type LoadedAgentsMd } from './context'; import type { ApplyProfileOptions, @@ -220,7 +219,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader, @IAgentStateService private readonly states: IAgentStateService, - @IHostIdentity private readonly hostIdentity: IHostIdentity, @IPluginService private readonly plugins: IPluginService, ) { super(); @@ -871,8 +869,8 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ skills, pluginSections, skillActive: this.isToolActiveForProfile(profile, 'Skill'), - productName: this.hostIdentity.productName, - replyStyleGuide: this.hostIdentity.replyStyleGuide, + productName: this.bootstrap.args.displayName, + replyStyleGuide: this.bootstrap.args.replyStyleGuide, }; } diff --git a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts index cd353f637..93dcb0140 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts @@ -9,8 +9,9 @@ * state after a successful Kimi login), whose bearer token comes from * `IOAuthService.resolveTokenProvider(...)` and whose base URL is derived from * the provider's `baseUrl`. The explicit config wins over the managed - * derivation. Both use the host's Kimi identity headers (`IHostRequestHeaders`) - * as default headers. When neither source is configured it yields `undefined`. + * derivation. Both use the host's Kimi identity headers + * (`IBootstrapService.args.requestHeaders`) as default headers. When neither + * source is configured it yields `undefined`. * Tests and hosts that need a custom backend bind `IWebSearchProviderService` * directly. Bound at App scope. */ @@ -22,8 +23,8 @@ import { import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService } from '#/kosong/provider/provider'; import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; @@ -38,7 +39,7 @@ export class WebSearchProviderService implements IWebSearchProviderService { constructor( @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, - @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, + @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, ) {} @@ -59,7 +60,7 @@ export class WebSearchProviderService implements IWebSearchProviderService { baseUrl: search.baseUrl, tokenProvider, apiKey: nonEmptyString(search.apiKey), - defaultHeaders: { ...this.hostHeaders.headers }, + defaultHeaders: { ...this.bootstrap.args.requestHeaders }, customHeaders: search.customHeaders, }); } @@ -80,7 +81,7 @@ export class WebSearchProviderService implements IWebSearchProviderService { return new MoonshotWebSearchProvider({ baseUrl, tokenProvider, - defaultHeaders: { ...this.hostHeaders.headers }, + defaultHeaders: { ...this.bootstrap.args.requestHeaders }, customHeaders: provider.customHeaders, }); } diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 8e434e695..64ebf35a0 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -3,8 +3,12 @@ * * Defines the `IBootstrapService`, the snapshot of the world the process runs * in, resolved once at startup and frozen for the process: observed host facts - * (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientIdentity`) and the - * app path layout (`homeDir`, `configPath`, …). `resolveBootstrapOptions` is + * (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientIdentity`), the + * app path layout (`homeDir`, `configPath`, …), and the host's process-level + * invocation arguments (`args` — mirroring VS Code's `NativeParsedArgs` + * carried on the environment service: the host states them once in + * `BootstrapInput`; downstream services read them here instead of through + * per-domain runtime-options services). `resolveBootstrapOptions` is * the single place that reads `process.env` / `os.homedir()` / invocation * input to resolve the snapshot; everything downstream reads from * `IBootstrapService` instead of touching `process` directly. Bound at App @@ -30,6 +34,56 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +/** + * Host invocation arguments — process-level overrides the embedding host + * states once at startup (mirrors VS Code's `NativeParsedArgs` carried on the + * environment service). Resolved from {@link HostArgsInput} and read via + * `IBootstrapService.args`. + */ +export interface HostArgs { + /** + * Explicit agent definition files for this process (the CLI's + * `--agent-file`): loaded as the highest-priority `explicit` agent-profile + * source. Undefined means no explicit files. + */ + readonly agentFiles?: readonly string[]; + /** + * Explicit skill directories for this process (v1's SDK `skillDirs`): when + * non-empty, default user / project skill discovery is skipped and these + * directories serve as the user skill source. + */ + readonly skillDirs?: readonly string[]; + /** + * Host identity headers applied to outbound provider requests (User-Agent + + * `X-Msh-*`, built by the host through `createKimiDefaultHeaders`). + * Materialized to `{}` when the host passes none. + */ + readonly requestHeaders: Readonly>; + /** Fills the `${product_name}` slot in the base system-prompt template. */ + readonly displayName?: string; + /** Replaces the `${reply_style_guide}` block in the base system prompt. */ + readonly replyStyleGuide?: string; +} + +/** {@link HostArgs} as accepted from the host: `requestHeaders` may be omitted. */ +export interface HostArgsInput { + readonly agentFiles?: readonly string[]; + readonly skillDirs?: readonly string[]; + readonly requestHeaders?: Readonly>; + readonly displayName?: string; + readonly replyStyleGuide?: string; +} + +export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs { + return { + agentFiles: input?.agentFiles, + skillDirs: input?.skillDirs, + requestHeaders: input?.requestHeaders ?? {}, + displayName: input?.displayName, + replyStyleGuide: input?.replyStyleGuide, + }; +} + export interface IBootstrapOptions { readonly homeDir: string; readonly configPath: string; @@ -39,6 +93,7 @@ export interface IBootstrapOptions { readonly cwd: string; readonly env: NodeJS.ProcessEnv; readonly clientIdentity: KimiHostIdentity; + readonly args: HostArgs; } export const IBootstrapOptions: ServiceIdentifier = @@ -64,6 +119,8 @@ export interface IBootstrapService { readonly homeDir: string; readonly configPath: string; readonly clientIdentity: KimiHostIdentity; + /** Host invocation arguments; see {@link HostArgs}. */ + readonly args: HostArgs; readonly sessionsDir: string; readonly blobsDir: string; readonly storeDir: string; @@ -85,7 +142,11 @@ export interface BootstrapInput { readonly platform?: NodeJS.Platform; readonly arch?: string; readonly cwd?: string; + /** Required: every process names its host. There is deliberately no default + — a fabricated identity would silently misreport the host upstream. */ readonly clientIdentity: KimiHostIdentity; + /** Host invocation arguments; see {@link HostArgsInput}. */ + readonly args?: HostArgsInput; } export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOptions { @@ -102,6 +163,7 @@ export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOption cwd: input.cwd ?? process.cwd(), env, clientIdentity: input.clientIdentity, + args: resolveHostArgs(input.args), }; } diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index 97d5faae2..836512caf 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -18,6 +18,7 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { IBootstrapOptions, IBootstrapService, + type HostArgs, type PersistenceScopeName, } from './bootstrap'; @@ -31,6 +32,7 @@ export class BootstrapService implements IBootstrapService { readonly homeDir: string; readonly configPath: string; readonly clientIdentity: KimiHostIdentity; + readonly args: HostArgs; readonly sessionsDir: string; readonly blobsDir: string; readonly storeDir: string; @@ -50,6 +52,7 @@ export class BootstrapService implements IBootstrapService { this.homeDir = options.homeDir; this.configPath = options.configPath; this.clientIdentity = options.clientIdentity; + this.args = options.args; this.sessionsDir = join(options.homeDir, 'sessions'); this.blobsDir = join(options.homeDir, 'blobs'); this.storeDir = join(options.homeDir, 'store'); diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index 8da7e876a..f51d61259 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -202,7 +202,16 @@ export interface IConfigService { inspect(domain: string): ConfigInspectValue; getAll(): ResolvedConfig; set(domain: string, patch: unknown, target?: ConfigTarget): Promise; + /** + * Replace one domain wholesale; `undefined` (or `null`, the wire encoding + * of clear — JSON transports cannot carry `undefined`) removes the domain. + */ replace(domain: string, value: unknown, target?: ConfigTarget): Promise; + /** + * Replace several domains in ONE atomic write: a domain mapped to + * `undefined` (or `null`, see {@link replace}) is cleared, domains absent + * from `sections` are left untouched. + */ replaceSections( sections: Readonly>, target?: ConfigTarget, diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 8f1d3dd4f..05ba4b0c0 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -331,17 +331,20 @@ export class ConfigService extends Disposable implements IConfigService { target: ConfigTarget = ConfigTarget.User, ): Promise { await this.ready; + // `null` is the wire encoding of "clear this domain": JSON transports + // (klient memory/ipc, kap-server REST/WS) cannot carry `undefined`. + const effectiveValue = value === null ? undefined : value; if (target === ConfigTarget.Memory) { - if (value === undefined) { + if (effectiveValue === undefined) { delete this.memory[domain]; } else { - this.memory[domain] = this.registry.validate(domain, value); + this.memory[domain] = this.registry.validate(domain, effectiveValue); } this.commit('set', [domain]); return; } await this.enqueueStateTransition(async () => { - const stripped = this.stripEnv(domain, value); + const stripped = this.stripEnv(domain, effectiveValue); if (stripped === undefined) { delete this.raw[domain]; } else { @@ -363,7 +366,7 @@ export class ConfigService extends Disposable implements IConfigService { const staged: ResolvedConfig = { ...this.memory }; for (const domain of domains) { const value = sections[domain]; - if (value === undefined) { + if (value === undefined || value === null) { delete staged[domain]; } else { staged[domain] = this.registry.validate(domain, value); @@ -376,7 +379,9 @@ export class ConfigService extends Disposable implements IConfigService { await this.enqueueStateTransition(async () => { const staged: ResolvedConfig = { ...this.raw }; for (const domain of domains) { - const stripped = this.stripEnv(domain, sections[domain]); + // Same `null`-means-clear encoding as `replace` (see above). + const value = sections[domain] === null ? undefined : sections[domain]; + const stripped = this.stripEnv(domain, value); if (stripped === undefined) { delete staged[domain]; } else { diff --git a/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts b/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts deleted file mode 100644 index 1532a52db..000000000 --- a/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * `hostIdentity` domain — runtime identity of the embedding host. - * - * Holds process-level overrides the host product (CLI, desktop, …) injects at - * the composition root: `displayName` fills the `${product_name}` slot in the - * base system-prompt template, `replyStyleGuide` replaces the - * `${reply_style_guide}` block (the CLI default describes Markdown rendering - * in a terminal). Composition roots set them through {@link hostIdentitySeed}; - * the registered default carries no overrides, so the template renders its CLI - * defaults. Bound at App scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, registerScopedService, ScopeActivation, type ScopeSeed } from '#/_base/di/scope'; - -export interface PromptIdentityOverrides { - readonly displayName?: string; - readonly replyStyleGuide?: string; -} - -export interface IHostIdentity { - readonly _serviceBrand: undefined; - readonly productName?: string; - readonly replyStyleGuide?: string; -} - -export const IHostIdentity: ServiceIdentifier = - createDecorator('hostIdentity'); - -export class HostIdentity implements IHostIdentity { - declare readonly _serviceBrand: undefined; - - constructor( - readonly productName?: string, - readonly replyStyleGuide?: string, - ) {} -} - -export function hostIdentitySeed(overrides: PromptIdentityOverrides | undefined): ScopeSeed { - if (overrides === undefined) return []; - if (overrides.displayName === undefined && overrides.replyStyleGuide === undefined) return []; - return [ - [ - IHostIdentity as ServiceIdentifier, - new HostIdentity(overrides.displayName, overrides.replyStyleGuide), - ], - ]; -} - -registerScopedService( - LifecycleScope.App, - IHostIdentity, - HostIdentity, - ScopeActivation.OnScopeCreated, - 'hostIdentity', -); diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index 15966552a..6b0110d93 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -46,10 +46,10 @@ import { import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import { IOAuthService } from '#/app/auth/auth'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; import { ModelCatalogErrors } from '#/kosong/model/errors'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { type ModelRecord } from '#/kosong/model/model'; import { IProviderService, @@ -90,7 +90,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { @IConfigService private readonly config: IConfigService, @IOAuthService private readonly oauth: IOAuthService, @IEventService private readonly events: IEventService, - @IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders, + @IBootstrapService private readonly bootstrap: IBootstrapService, ) {} refreshProviderModels( @@ -181,7 +181,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { removeProvider: (providerId) => this.shapeWithoutProvider(providerId), setConfig: (patch) => this.applyRefreshPatch(patch, exclusion), resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef), - userAgent: this.hostRequestHeaders.headers['User-Agent'], + userAgent: this.bootstrap.args.requestHeaders['User-Agent'], }; } diff --git a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts new file mode 100644 index 000000000..ae8eae75b --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts @@ -0,0 +1,29 @@ +/** + * `kosongConfig` domain — `IHostRequestHeaders` implementation. + * + * Bridges kosong's host-headers port to the host invocation args: the headers + * are the ones the host stated in `BootstrapInput.args.requestHeaders` + * (usually built through `createKimiDefaultHeaders`), exposed through + * `IBootstrapService.args`. kosong's model catalog only sees the port. Bound + * at App scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; + +export class HostRequestHeadersAdapter implements IHostRequestHeaders { + readonly headers: Readonly>; + + constructor(@IBootstrapService bootstrap: IBootstrapService) { + this.headers = bootstrap.args.requestHeaders; + } +} + +registerScopedService( + LifecycleScope.App, + IHostRequestHeaders, + HostRequestHeadersAdapter, + ScopeActivation.OnDemand, + 'kosongConfig', +); diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts b/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts deleted file mode 100644 index fd7b5d5e8..000000000 --- a/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * `skillCatalog` domain — runtime options for skill discovery. - * - * Holds process-level runtime overrides that affect how skill roots are - * resolved. `explicitDirs` mirrors v1's SDK `skillDirs`: when present, default - * user / project discovery is skipped and the explicit directories are used as - * the user source. Bound at App scope. - * - * Composition roots set it through {@link skillCatalogRuntimeOptionsSeed} - * — the registered default carries no explicit dirs. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { - LifecycleScope, - ScopeActivation, - registerScopedService, - type ScopeSeed, -} from '#/_base/di/scope'; - -export interface ISkillCatalogRuntimeOptions { - readonly _serviceBrand: undefined; - readonly explicitDirs?: readonly string[]; -} - -export const ISkillCatalogRuntimeOptions: ServiceIdentifier = - createDecorator('skillCatalogRuntimeOptions'); - -export class SkillCatalogRuntimeOptions implements ISkillCatalogRuntimeOptions { - declare readonly _serviceBrand: undefined; - - constructor(readonly explicitDirs?: readonly string[]) {} -} - -export function skillCatalogRuntimeOptionsSeed( - explicitDirs: readonly string[] | undefined, -): ScopeSeed { - if (explicitDirs === undefined || explicitDirs.length === 0) return []; - return [ - [ - ISkillCatalogRuntimeOptions as ServiceIdentifier, - new SkillCatalogRuntimeOptions(explicitDirs), - ], - ]; -} - -registerScopedService( - LifecycleScope.App, - ISkillCatalogRuntimeOptions, - SkillCatalogRuntimeOptions, - ScopeActivation.OnScopeCreated, - 'skillCatalog', -); diff --git a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts index 3869e8af7..fafb01748 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts @@ -17,7 +17,6 @@ import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, type MergeAllAvailableSkillsConfig, } from './configSection'; -import { ISkillCatalogRuntimeOptions } from './skillCatalogRuntimeOptions'; import { ISkillDiscovery } from './skillDiscovery'; import { userRoots } from './skillRoots'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource'; @@ -41,7 +40,6 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, - @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, ) { super(); this._register( @@ -52,7 +50,7 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou } async load(): Promise { - if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) { + if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) { return { skills: [] }; } await this.config.ready; diff --git a/packages/agent-core-v2/src/app/web/webService.ts b/packages/agent-core-v2/src/app/web/webService.ts index eb10640a6..e03646116 100644 --- a/packages/agent-core-v2/src/app/web/webService.ts +++ b/packages/agent-core-v2/src/app/web/webService.ts @@ -9,10 +9,10 @@ * successful Kimi login), routing fetches through the Moonshot fetch service * (`${provider.baseUrl}/fetch`); and (3) the built-in `LocalFetchURLProvider`, * so `FetchURL` keeps working without any configuration. The first two use the - * host's Kimi identity headers (`IHostRequestHeaders`) and fall back to the - * local fetcher on failure. Reads config and the managed provider lazily on - * each `getUrlFetcher()` call so it tracks edits and login state. Bound at - * App scope. + * host's Kimi identity headers (`IBootstrapService.args.requestHeaders`) and + * fall back to the local fetcher on failure. Reads config and the managed + * provider lazily on each `getUrlFetcher()` call so it tracks edits and login + * state. Bound at App scope. */ import { @@ -23,8 +23,8 @@ import { import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService } from '#/kosong/provider/provider'; import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; @@ -40,7 +40,7 @@ export class WebFetchService implements IWebFetchService { constructor( @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, - @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, + @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, ) { this.localFetcher = new LocalFetchURLProvider(); @@ -63,7 +63,7 @@ export class WebFetchService implements IWebFetchService { baseUrl: fetchConfig.baseUrl, tokenProvider, apiKey: nonEmptyString(fetchConfig.apiKey), - defaultHeaders: { ...this.hostHeaders.headers }, + defaultHeaders: { ...this.bootstrap.args.requestHeaders }, customHeaders: fetchConfig.customHeaders, localFallback: this.localFetcher, }); @@ -85,7 +85,7 @@ export class WebFetchService implements IWebFetchService { return new MoonshotFetchURLProvider({ baseUrl, tokenProvider, - defaultHeaders: { ...this.hostHeaders.headers }, + defaultHeaders: { ...this.bootstrap.args.requestHeaders }, customHeaders: provider.customHeaders, localFallback: this.localFetcher, }); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index aee6359fb..169cf9990 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -137,6 +137,7 @@ export * from '#/app/kosongConfig/kosongConfig'; export * from '#/app/kosongConfig/kosongConfigService'; export * from '#/kosong/model/modelOAuth'; export * from '#/app/kosongConfig/oauthTokenAdapter'; +export * from '#/app/kosongConfig/hostRequestHeadersAdapter'; export * from '#/app/kosongConfig/discovery'; export * from '#/app/kosongConfig/discoveryService'; export * from '#/app/kosongConfig/errors'; @@ -165,10 +166,8 @@ export { export * from '#/workspace/workspaceAgentProfileLoader/configSection'; export { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; export { resolveAgentPath } from '#/workspace/workspaceAgentProfileLoader/internal/paths'; -export * from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; -export * from '#/app/hostIdentity/hostIdentity'; export * from '#/app/plugin/types'; export * from '#/app/plugin/commands'; export * from '#/app/plugin/manifest'; @@ -189,7 +188,6 @@ export * from '#/agent/skill/skill'; export * from '#/agent/skill/skillService'; export * from '#/app/skillCatalog/types'; export * from '#/app/skillCatalog/configSection'; -export * from '#/app/skillCatalog/skillCatalogRuntimeOptions'; export * from '#/app/skillCatalog/parser'; export * from '#/app/skillCatalog/registry'; export * from '#/app/skillCatalog/errors'; diff --git a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts b/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts index 27458ae3c..53b3bd0da 100644 --- a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts +++ b/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts @@ -1,38 +1,21 @@ /** - * `kosong/model` domain — host-provided default headers for outbound - * provider requests. + * `kosong/model` domain (L2) — host-provided default headers for outbound + * provider requests (port contract). * - * The host (CLI / server) builds the full Kimi identity headers (`User-Agent` - * + `X-Msh-*`) and seeds them here. Defaults to empty so non-host contexts - * (tests, embedders) send no extra headers. + * Mirrors v1's `kimiRequestHeaders`: the host (CLI / server) states its Kimi + * identity headers (`User-Agent` + `X-Msh-*`) in + * `BootstrapInput.args.requestHeaders`; the app-side adapter + * (`app/kosongConfig/hostRequestHeadersAdapter`) bridges + * `IBootstrapService.args` to this port so kosong stays a pure abstraction + * layer. `ModelCatalog` merges them per vendor — the full set for vendors + * whose definition declares `hostHeaders: 'full'`, only the `User-Agent` for + * everyone else (so device identity never leaks to third-party endpoints). */ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { - LifecycleScope, - ScopeActivation, - registerScopedService, - type ScopeSeed, -} from '#/_base/di/scope'; +import { createDecorator } from '#/_base/di/instantiation'; export interface IHostRequestHeaders { readonly headers: Readonly>; } export const IHostRequestHeaders = createDecorator('hostRequestHeaders'); - -export class HostRequestHeaders implements IHostRequestHeaders { - constructor(readonly headers: Readonly> = {}) {} -} - -export function hostRequestHeadersSeed(headers: Readonly>): ScopeSeed { - return [[IHostRequestHeaders as ServiceIdentifier, new HostRequestHeaders(headers)]]; -} - -registerScopedService( - LifecycleScope.App, - IHostRequestHeaders, - HostRequestHeaders, - ScopeActivation.OnScopeCreated, - 'model', -); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts deleted file mode 100644 index 6f47335bd..000000000 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * `workspaceAgentProfileLoader` domain — runtime options for agent-file discovery. - * - * Holds process-level runtime overrides: `explicitFiles` mirrors the CLI's - * `--agent-file` — individual agent Markdown files loaded as the highest- - * priority `explicit` source. Composition roots set it through - * {@link agentCatalogRuntimeOptionsSeed}; the registered default carries no - * explicit files. Bound at App scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { - LifecycleScope, - ScopeActivation, - registerScopedService, - type ScopeSeed, -} from '#/_base/di/scope'; - -export interface IAgentCatalogRuntimeOptions { - readonly _serviceBrand: undefined; - readonly explicitFiles?: readonly string[]; -} - -export const IAgentCatalogRuntimeOptions: ServiceIdentifier = - createDecorator('agentCatalogRuntimeOptions'); - -export class AgentCatalogRuntimeOptions implements IAgentCatalogRuntimeOptions { - declare readonly _serviceBrand: undefined; - - constructor(readonly explicitFiles?: readonly string[]) {} -} - -export function agentCatalogRuntimeOptionsSeed( - explicitFiles: readonly string[] | undefined, -): ScopeSeed { - if (explicitFiles === undefined || explicitFiles.length === 0) return []; - return [ - [ - IAgentCatalogRuntimeOptions as ServiceIdentifier, - new AgentCatalogRuntimeOptions(explicitFiles), - ], - ]; -} - -registerScopedService( - LifecycleScope.App, - IAgentCatalogRuntimeOptions, - AgentCatalogRuntimeOptions, - ScopeActivation.OnScopeCreated, - 'workspaceAgentProfileLoader', -); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts index a5cf7d2c4..8b539e2b1 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts @@ -10,7 +10,6 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { ILogService } from '#/_base/log/log'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; -import { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; import { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; import { agentProfileFromFile } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile'; @@ -37,7 +36,6 @@ export class ExplicitAgentProfileLoaderService protected override readonly fatal = true; constructor( - @IAgentCatalogRuntimeOptions private readonly runtimeOptions: IAgentCatalogRuntimeOptions, @IWorkspaceContext private readonly workspace: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, @IHostFileSystem private readonly fs: IHostFileSystem, @@ -54,7 +52,7 @@ export class ExplicitAgentProfileLoaderService } protected async load(): Promise { - const files = this.runtimeOptions.explicitFiles ?? []; + const files = this.bootstrap.args.agentFiles ?? []; const profiles: AgentProfile[] = []; for (const file of files) { const filePath = resolveAgentPath(file, this.workspace.cwd, this.bootstrap.osHomeDir); diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts index 307624e1f..68f3edc79 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts @@ -1,18 +1,18 @@ /** * `workspaceSkillCatalog` domain — explicit `ISkillSource` producer. * - * Mirrors v1 SDK `skillDirs`: when runtime options provide `explicitDirs`, this - * source contributes those directories as the user source, resolving relative - * paths against the workspace root. When no explicit dirs are configured, - * it yields nothing so default user / project discovery remains active. Bound - * at Workspace scope so every session of the handler shares one scan. + * Mirrors v1 SDK `skillDirs`: when the host invocation args provide + * `skillDirs`, this source contributes those directories as the user source, + * resolving relative paths against the workspace root. When no explicit dirs + * are configured, it yields nothing so default user / project discovery + * remains active. Bound at Workspace scope so every session of the handler + * shares one scan. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { configuredRoots } from '#/app/skillCatalog/skillRoots'; -import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import { SKILL_SOURCE_PRIORITY, @@ -36,13 +36,12 @@ export class ExplicitFileSkillSource implements IExplicitFileSkillSource { constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, - @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, @IWorkspaceContext private readonly workspace: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, ) {} async load(): Promise { - const explicitDirs = this.runtimeOptions.explicitDirs ?? []; + const explicitDirs = this.bootstrap.args.skillDirs ?? []; if (explicitDirs.length === 0) { return { skills: [] }; } diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts index 0c8604c43..05d51b47e 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts @@ -19,11 +19,11 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; import { IConfigService } from '#/app/config/config'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, type MergeAllAvailableSkillsConfig, } from '#/app/skillCatalog/configSection'; -import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import { projectRoots, projectSkillRootCandidates } from '#/app/skillCatalog/skillRoots'; import { @@ -59,7 +59,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IWorkspaceContext private readonly workspace: IWorkspaceContext, @IConfigService private readonly config: IConfigService, - @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IBootstrapService private readonly bootstrap: IBootstrapService, @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, ) { super(); @@ -73,7 +73,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo async load(): Promise { await this.watchReady; - if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) { + if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) { return { skills: [] }; } await this.config.ready; diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index 5becafb84..290d5c269 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -21,7 +21,6 @@ import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { IHostIdentity } from '#/app/hostIdentity/hostIdentity'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; @@ -213,7 +212,6 @@ function buildHost(key: string): { host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); host.stub(IHostEnvironment, stubUnused()); host.stub(IHostFileSystem, stubUnused()); - host.stub(IHostIdentity, stubUnused()); host.stub(IBootstrapService, stubUnused()); host.stub(ISessionContext, createSessionContextStub()); host.stub(ISessionWorkspaceContext, stubUnused()); diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index 1b82fe027..3dc8e398b 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -32,7 +32,7 @@ import { IConfigService } from '#/app/config/config'; import { ConfigRegistry } from '#/app/config/configService'; import { type DomainEvent, IEventService } from '#/app/event/event'; import { ILogService } from '#/_base/log/log'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IModelService, type ModelRecord } from '#/kosong/model/model'; import { MODELS_SECTION } from '#/app/kosongConfig/configSection'; import { IProviderService, type ProviderConfig, type ProvidersChangedEvent } from '#/kosong/provider/provider'; @@ -835,10 +835,12 @@ describe('WebSearchProviderService', () => { resolveTokenProvider: resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'], }); - reg.definePartialInstance(IHostRequestHeaders, { - headers: { - 'User-Agent': 'kimi-code-cli/test', - 'X-Msh-Device-Id': 'device-test', + reg.definePartialInstance(IBootstrapService, { + args: { + requestHeaders: { + 'User-Agent': 'kimi-code-cli/test', + 'X-Msh-Device-Id': 'device-test', + }, }, }); reg.definePartialInstance(IConfigService, { diff --git a/packages/agent-core-v2/test/app/bootstrap/stubs.ts b/packages/agent-core-v2/test/app/bootstrap/stubs.ts index 147b465d3..f35a3fad0 100644 --- a/packages/agent-core-v2/test/app/bootstrap/stubs.ts +++ b/packages/agent-core-v2/test/app/bootstrap/stubs.ts @@ -9,6 +9,8 @@ import type { ServiceRegistration } from '#/_base/di/test'; import { IBootstrapService, + resolveHostArgs, + type HostArgsInput, type PersistenceScopeName, } from '#/app/bootstrap/bootstrap'; @@ -18,7 +20,11 @@ export const stubClientIdentity = { platform: 'test_platform', } as const; -export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService { +export function stubBootstrap( + homeDir = '/tmp/kimi-home', + env: NodeJS.ProcessEnv = {}, + args: HostArgsInput = {}, +): IBootstrapService { const scopes: Record = { config: '', sessions: 'sessions', @@ -39,6 +45,7 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv configPath: `${homeDir}/config.toml`, configKey: 'config.toml', clientIdentity: stubClientIdentity, + args: resolveHostArgs(args), sessionsDir: `${homeDir}/sessions`, blobsDir: `${homeDir}/blobs`, storeDir: `${homeDir}/store`, diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 6560bb4f1..995bf58df 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -1960,6 +1960,31 @@ describe('ConfigService replaceSections', () => { disposables.dispose(); }); + it('treats null as clear — the wire encoding JSON transports use for undefined', async () => { + const { config, disposables, store } = await createSectionsConfig(); + const setSpy = vi.spyOn(store, 'set'); + + await config.replaceSections({ + [DEFAULT_MODEL_SECTION]: null, + [PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, + }); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(config.get(DEFAULT_MODEL_SECTION)).toBeUndefined(); + expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined(); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + acme: { type: 'openai', apiKey: 'sk-acme-2' }, + }); + + // `replace(domain, null)` clears too, so JSON transports behave + // identically to in-process `replace(domain, undefined)` callers. + await config.replace(DEFAULT_MODEL_SECTION, 'acme/m1'); + await config.replace(DEFAULT_MODEL_SECTION, null); + expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined(); + + disposables.dispose(); + }); + it('fires change events only after all domains have taken effect', async () => { const { config, disposables } = await createSectionsConfig(); const domains: string[] = []; diff --git a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts index 7d4d69a91..8cd4e77cf 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts @@ -28,6 +28,7 @@ import { createScopedTestHost } from '#/_base/di/test'; import { isError2 } from '#/_base/errors/errors'; import { ILogService, type LogPayload } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { ConfigRegistry } from '#/app/config/configService'; import { IEventService } from '#/app/event/event'; @@ -37,7 +38,6 @@ import { MODEL_CATALOG_SECTION } from '#/app/kosongConfig/configSection'; import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig'; import '#/app/kosongConfig/kosongConfigService'; import '#/kosong/model/errors'; -import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IModelService, type ModelRecord, @@ -52,6 +52,7 @@ import '#/kosong/provider/providers/kimi/kimi.contrib'; import '#/kosong/provider/providers/standard.contrib'; import { StubConfigService, stubOAuthService, stubTokenProvider } from '../../kosong/stubs'; +import { stubBootstrap } from '../bootstrap/stubs'; function stubEvents(): IEventService & { published: Array<{ type: string; payload: unknown }> } { const published: Array<{ type: string; payload: unknown }> = []; @@ -100,7 +101,10 @@ async function createHost( [IOAuthService, oauth], [IEventService, events], [ILogService, stubLogService()], - [IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'kimi-test/1.0' })], + [ + IBootstrapService, + stubBootstrap('/tmp/kimi-home', {}, { requestHeaders: { 'User-Agent': 'kimi-test/1.0' } }), + ], ]); const providers = host.app.accessor.get(IProviderService); const models = host.app.accessor.get(IModelService); diff --git a/packages/agent-core-v2/test/app/web/web-fetch-service.test.ts b/packages/agent-core-v2/test/app/web/web-fetch-service.test.ts index b08fd96c5..2b8a4d249 100644 --- a/packages/agent-core-v2/test/app/web/web-fetch-service.test.ts +++ b/packages/agent-core-v2/test/app/web/web-fetch-service.test.ts @@ -12,8 +12,8 @@ import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { IOAuthService } from '#/app/auth/auth'; import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider'; import { LocalFetchURLProvider } from '#/app/web/providers/local-fetch-url'; import { MoonshotFetchURLProvider } from '#/app/web/providers/moonshot-fetch-url'; @@ -47,10 +47,12 @@ describe('WebFetchService', () => { resolveTokenProvider: resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'], }); - reg.definePartialInstance(IHostRequestHeaders, { - headers: { - 'User-Agent': 'kimi-code-cli/test', - 'X-Msh-Device-Id': 'device-test', + reg.definePartialInstance(IBootstrapService, { + args: { + requestHeaders: { + 'User-Agent': 'kimi-code-cli/test', + 'X-Msh-Device-Id': 'device-test', + }, }, }); reg.definePartialInstance(IConfigService, { diff --git a/packages/agent-core-v2/test/kosong/model/catalog.test.ts b/packages/agent-core-v2/test/kosong/model/catalog.test.ts index dc108c531..a2d308a64 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -52,7 +52,7 @@ import { } from '#/kosong/model/catalog'; import { ModelCatalog } from '#/kosong/model/catalogService'; import '#/kosong/model/errors'; -import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IModelService, type ModelRecord, type ModelsSection } from '#/kosong/model/model'; import '#/kosong/model/modelService'; import { IModelOAuthTokens } from '#/kosong/model/modelOAuth'; @@ -75,7 +75,7 @@ function createHost( const host = createScopedTestHost([ [IConfigService, config], [IModelOAuthTokens, oauthTokens], - [IHostRequestHeaders, new HostRequestHeaders(HOST_HEADERS)], + [IHostRequestHeaders, { headers: HOST_HEADERS }], ]); // Kosong's registries are pure in-memory stores now (persistence lives in // the app/kosongConfig bridge): seed them from the fixture sections. @@ -807,7 +807,7 @@ describe('ModelCatalog ping', () => { models, stubModelOAuthTokens(), registry, - new HostRequestHeaders({}), + { headers: {} }, ); const result = await catalog.ping('k1'); expect(result).toMatchObject({ ok: true, text: 'pong', finishReason: 'completed' }); diff --git a/packages/agent-core-v2/test/lint/import-boundaries.test.ts b/packages/agent-core-v2/test/lint/import-boundaries.test.ts index c8394b153..2cf51f134 100644 --- a/packages/agent-core-v2/test/lint/import-boundaries.test.ts +++ b/packages/agent-core-v2/test/lint/import-boundaries.test.ts @@ -136,4 +136,13 @@ describe('check-import-boundaries', () => { ); expect(violations).toHaveLength(0); }); + + it('resolves the package self-reference as an intra-v2 import', () => { + const violations = checkSource( + `import { Foo } from '@moonshot-ai/agent-core-v2/kosong/provider/provider';`, + atKosong('protocol', 'protocol.ts'), + ); + expect(violations).toHaveLength(1); + expect(violations[0]?.message).toMatch(/kosong layer violation/); + }); }); diff --git a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts index f6cf5f332..14e691c1c 100644 --- a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts @@ -19,7 +19,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Emitter, Event } from '#/_base/event'; import type { ILogService } from '#/_base/log/log'; -import type { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; import { EXTRA_AGENT_DIRS_SECTION } from '#/workspace/workspaceAgentProfileLoader/configSection'; import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; import type { PluginAgentRoot } from '#/app/plugin/types'; @@ -252,15 +251,11 @@ function makeStack(fixture: Fixture, opts?: StackOptions) { const config = configStub(); if (opts?.extraAgentDirs !== undefined) config.setExtraAgentDirs(opts.extraAgentDirs); const bootstrap: IBootstrapService = { - ...stubBootstrap(fixture.homeDir), + ...stubBootstrap(fixture.homeDir, {}, { agentFiles: opts?.explicitFiles }), osHomeDir: fixture.osHomeDir, }; const hostFs = opts?.hostFs ?? new HostFileSystem(); const workspaceContext = workspaceContextStub(fixture.workDir); - const runtimeOptions = { - _serviceBrand: undefined, - explicitFiles: opts?.explicitFiles, - } as unknown as IAgentCatalogRuntimeOptions; const registry = new AgentProfileRegistryService(); const builtinLoader = new BuiltinAgentProfileLoaderService(registry); @@ -298,7 +293,6 @@ function makeStack(fixture: Fixture, opts?: StackOptions) { registry, ); const explicitLoader = new ExplicitAgentProfileLoaderService( - runtimeOptions, workspaceContext, bootstrap, hostFs, diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts index 2b188fa40..b4b4f5f83 100644 --- a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -32,12 +32,11 @@ import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileReg import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; -import { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IBootstrapService, resolveHostArgs } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { IEventService } from '#/app/event/event'; @@ -47,7 +46,6 @@ import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; -import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; @@ -278,6 +276,7 @@ describe('workspace resource sharing (handler chain)', () => { _serviceBrand: undefined, homeDir, osHomeDir: homeDir, + args: resolveHostArgs(undefined), scope: (name: string) => name, } as unknown as IBootstrapService), stubPair(IHostEnvironment, { @@ -297,12 +296,6 @@ describe('workspace resource sharing (handler chain)', () => { } as unknown as IConfigService), stubPair(ITelemetryService, noopTelemetryService), stubPair(ISkillDiscovery, discovery), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), - stubPair(IAgentCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as IAgentCatalogRuntimeOptions), stubPair(IPluginService, pluginStub()), stubPair(IWorkspaceService, workspaceCatalogStub()), stubPair(ISessionIndex, { diff --git a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts index 5c4b92dcb..8a372a8bd 100644 --- a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts @@ -37,7 +37,6 @@ import { EXTRA_SKILL_DIRS_SECTION, MERGE_ALL_AVAILABLE_SKILLS_SECTION, } from '#/app/skillCatalog/configSection'; -import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; @@ -168,15 +167,10 @@ function makeHost( pluginReloadEmitter?: Emitter, ) { const config = configStub(); - const runtimeOptions = { - _serviceBrand: undefined, - explicitDirs, - } as unknown as ISkillCatalogRuntimeOptions; const host = createScopedTestHost([ stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, bootstrapStub), + stubPair(IBootstrapService, stubBootstrap('/home', {}, { skillDirs: explicitDirs })), stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)), stubPair(IHostFsWatchService, fsWatchStub()), ]); @@ -383,15 +377,11 @@ describe('WorkspaceSkillCatalogService', () => { } as unknown as IConfigService; const store = new InMemorySkillDiscovery(); store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); - const runtimeOptions = { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions; const ws = workspaceContextStub('/work'); const host = createScopedTestHost([ stubPair(ISkillDiscovery, store), stubPair(IBootstrapService, bootstrapStub), stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub()), stubPair(IHostFsWatchService, fsWatchStub()), ]); @@ -423,15 +413,11 @@ describe('WorkspaceSkillCatalogService', () => { } const store = new CountingDiscovery(); const config = configStub(); - const runtimeOptions = { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions; const ws = workspaceContextStub('/work'); const host = createScopedTestHost([ stubPair(ISkillDiscovery, store), stubPair(IBootstrapService, bootstrapStub), stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub()), stubPair(IHostFsWatchService, fsWatchStub()), ]); @@ -646,9 +632,6 @@ describe('WorkspaceSkillCatalogService', () => { stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), stubPair(IBootstrapService, bootstrapStub), stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginStub()), stubPair(IHostFsWatchService, fsWatchStub()), ]); @@ -706,9 +689,6 @@ describe('WorkspaceSkillCatalogService', () => { stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), stubPair(IBootstrapService, bootstrapStub), stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginService), stubPair(IHostFsWatchService, fsWatchStub()), ]); @@ -765,9 +745,6 @@ describe('WorkspaceSkillCatalogService', () => { stubPair(ISkillDiscovery, store), stubPair(IBootstrapService, stubBootstrap(homeDir)), stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), stubPair(IProviderService, stubProviderService()), stubPair(IHostFsWatchService, fsWatchStub()), ]); @@ -821,9 +798,6 @@ describe('WorkspaceSkillCatalogService', () => { const host = createScopedTestHost([ stubPair(IBootstrapService, bootstrapStub), stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginStub()), stubPair(ILogService, stubLog()), stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 982125e0b..ebeb87468 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -81,7 +81,6 @@ import { ISessionIndex, ISessionMetadata, ISessionSkillCatalog, - ISkillCatalogRuntimeOptions, ISkillDiscovery, IWorkspaceService, InMemorySkillCatalog, @@ -342,11 +341,10 @@ async function listWorkspaceSkillsForRoot( const plugins = core.accessor.get(IPluginService); const config = core.accessor.get(IConfigService); await config.ready; - const runtimeOptions = core.accessor.get(ISkillCatalogRuntimeOptions); const extraSkillDirs = config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; const mergeAllAvailableSkills = config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; - const explicitDirs = runtimeOptions.explicitDirs ?? []; + const explicitDirs = bootstrap.args.skillDirs ?? []; const useExplicitDirs = explicitDirs.length > 0; const rootOptions = { mergeAllAvailableSkills }; diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 8fa4d4759..7e92e93ca 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -9,8 +9,6 @@ import { bootstrap, - hostIdentitySeed, - hostRequestHeadersSeed, IConfigService, IProviderDiscoveryService, IWorkspaceService, @@ -18,7 +16,6 @@ import { resolveConfigPath, resolveKimiHome, resolveLoggingConfig, - skillCatalogRuntimeOptionsSeed, type Scope, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; @@ -247,18 +244,17 @@ export async function startServer(opts: ServerStartOptions): Promise { expect(defaults.headers['X-Msh-Platform']).toBe('test_platform'); // Restart on the same homeDir with a host-provided seed; it must win over - // the default (the CLI passes full Kimi identity headers this way). + // the default (a host can always re-seed the port with its own instance). await server.close(); server = undefined; server = await startServer({ @@ -160,7 +158,7 @@ describe('server-v2 boot', () => { port: 0, homeDir: home, logLevel: 'silent', - seeds: hostRequestHeadersSeed({ 'User-Agent': 'custom-host/9.9' }), + seeds: [[IHostRequestHeaders, { headers: { 'User-Agent': 'custom-host/9.9' } }]], }); const overridden = server.core.accessor.get(IHostRequestHeaders); expect(overridden.headers['User-Agent']).toBe('custom-host/9.9'); @@ -176,11 +174,11 @@ describe('server-v2 boot', () => { logLevel: 'silent', skillDirs: ['/skills/explicit'], }); - expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toEqual([ + expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toEqual([ '/skills/explicit', ]); - // Without skillDirs the registered default carries no explicit dirs. + // Without skillDirs the resolved args carry no explicit dirs. await server.close(); server = undefined; server = await startServer({ @@ -190,7 +188,7 @@ describe('server-v2 boot', () => { homeDir: home, logLevel: 'silent', }); - expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toBeUndefined(); + expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toBeUndefined(); }); it('does not shut down a host-injected telemetry service when server telemetry is disabled', async () => { diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index 01b40e47c..904a36025 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -28,7 +28,6 @@ import { join } from 'node:path'; import { IAgentLifecycleService, getLiveSessionById, - ISkillCatalogRuntimeOptions, } from '@moonshot-ai/agent-core-v2'; import { activateSkillResultSchema, @@ -313,7 +312,7 @@ describe('server-v2 /api/v1 skills', () => { port: 0, homeDir: home, logLevel: 'silent', - seeds: [[ISkillCatalogRuntimeOptions, { _serviceBrand: undefined, explicitDirs: [explicitDir] }]] as never, + skillDirs: [explicitDir], }); base = `http://127.0.0.1:${server.port}`; diff --git a/packages/klient/src/contract/global/config.ts b/packages/klient/src/contract/global/config.ts index 80b7097a2..57d3f5fca 100644 --- a/packages/klient/src/contract/global/config.ts +++ b/packages/klient/src/contract/global/config.ts @@ -35,6 +35,10 @@ export const configContract = { input: z.tuple([z.string(), z.unknown(), configTargetSchema.optional()]), output: noResult, }, + replaceSections: { + input: z.tuple([z.record(z.string(), z.unknown()), configTargetSchema.optional()]), + output: noResult, + }, reload: { input: z.tuple([]), output: noResult }, diagnostics: { input: z.tuple([]), output: z.array(configDiagnosticSchema) }, } satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index 04371c86a..607ae0287 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -128,6 +128,15 @@ export interface GlobalConfigFacade { value: unknown; target?: ConfigTargetLiteral; }): Promise; + /** + * Replace several domains in ONE atomic write (the engine's + * `IConfigService.replaceSections`): a domain mapped to `undefined` is + * cleared, domains absent from `sections` are left untouched. + */ + replaceSections(input: { + sections: Record; + target?: ConfigTargetLiteral; + }): Promise; reload(): Promise; diagnostics(): Promise; } @@ -306,7 +315,19 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr set: ({ domain, patch, target }) => call('configService', 'set', [domain, patch, target]) as Promise, replace: ({ domain, value, target }) => - call('configService', 'replace', [domain, value, target]) as Promise, + // `null` is the wire encoding of "clear this domain" — JSON + // round-trips cannot carry `undefined` (see IConfigService.replace). + call('configService', 'replace', [domain, value === undefined ? null : value, target]) as Promise, + replaceSections: ({ sections, target }) => + call('configService', 'replaceSections', [ + Object.fromEntries( + Object.entries(sections).map(([domain, value]) => [ + domain, + value === undefined ? null : value, + ]), + ), + target, + ]) as Promise, reload: () => call('configService', 'reload', []) as Promise, diagnostics: () => call('configService', 'diagnostics', []) as Promise, diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index 8b69c9246..5c8ed684f 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -139,6 +139,39 @@ export function defineKlientConformance( expect(Array.isArray(await target.klient.global.config.diagnostics())).toBe(true); }); + it('config replaceSections writes several domains and clears undefined ones', async () => { + const config = target.klient.global.config; + const beforeProviders = await config.inspect>('providers'); + const beforeModels = await config.inspect>('models'); + try { + await config.replaceSections({ + sections: { + providers: { + ...beforeProviders.userValue, + 'conf-provider': { type: 'openai', baseUrl: 'http://127.0.0.1:1', apiKey: 'k' }, + }, + models: { + ...beforeModels.userValue, + 'conf-provider/m1': { provider: 'conf-provider', model: 'm1', maxContextSize: 100 }, + }, + defaultModel: 'conf-provider/m1', + }, + }); + expect((await config.inspect('defaultModel')).userValue).toBe('conf-provider/m1'); + + // A domain mapped to `undefined` is cleared; domains absent from the + // sections record are left untouched. + await config.replaceSections({ sections: { defaultModel: undefined } }); + expect((await config.inspect('defaultModel')).userValue).toBeUndefined(); + const providers = await config.inspect>('providers'); + expect(providers.userValue?.['conf-provider']).toBeDefined(); + } finally { + await config.replaceSections({ + sections: { providers: beforeProviders.userValue, models: beforeModels.userValue }, + }); + } + }); + it('hostFs.home() returns the host home and recent roots', async () => { const home = await target.klient.global.hostFs.home(); expect(home.home.length).toBeGreaterThan(0); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 388f5a763..19b0f08a5 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -20,6 +20,7 @@ export { export { SDKRpcClientBase } from '#/rpc'; export { KimiForCodingProvider } from '#/kimi-code-model-provider'; export type { KimiForCodingProviderOptions } from '#/kimi-code-model-provider'; +export { removeProviderFromConfig } from '#/v2/config-mapper'; export { applyCatalogProvider, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index f8b71fb27..a57a8d81e 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -295,6 +295,24 @@ export class KimiHarness { return this.rpc.removeProvider(providerId); } + /** + * Whether several config sections can be persisted as ONE atomic write + * (see {@link replaceConfigSections}). False on the v1 harness. + */ + supportsAtomicSectionReplace(): boolean { + return this.rpc.supportsAtomicSectionReplace(); + } + + /** + * Replace several top-level config sections in ONE atomic write: a section + * mapped to `undefined` is cleared, absent sections are left untouched. + * Replace semantics (unlike {@link setConfig}'s deep-merge), so staged + * removals are expressed by the written record itself. + */ + async replaceConfigSections(sections: Record): Promise { + return this.rpc.replaceConfigSections(sections); + } + /** User-global MCP entries from `/mcp.json` only. */ async listMcpServers(): Promise { return this.rpc.listGlobalMcpServers(); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 86624d3ae..fbcc4618c 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { ErrorCodes, + KimiError, makeErrorPayload, type AgentContextData, type ApprovalRequest, @@ -280,6 +281,29 @@ export abstract class SDKRpcClientBase { return rpc.removeKimiProvider({ providerId }); } + /** + * Whether this client can persist several config sections as ONE atomic + * write (see {@link replaceConfigSections}). v1 cannot — its config writes + * are whole-document merges — so the default is false. + */ + supportsAtomicSectionReplace(): boolean { + return false; + } + + /** + * Replace several top-level config sections in ONE atomic write: a section + * mapped to `undefined` is cleared, sections absent from the record are + * left untouched. Unlike {@link setConfig} (a deep-merge that cannot + * delete keys), this has replace semantics, so a staged removal can be + * expressed by the written record itself. + */ + replaceConfigSections(_sections: Record): Promise { + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'This SDK client does not support atomic config section replacement.', + ); + } + async listGlobalMcpServers(): Promise { const rpc = await this.getRpc(); return rpc.listGlobalMcpServers({}); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 009a59d07..2190f8525 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -192,11 +192,9 @@ import { ISessionSecondaryModelWarningService, ISessionSkillCatalog, ISessionWorkspaceContext, - ISkillCatalogRuntimeOptions, ISkillDiscovery, ITelemetryService, IWorkspaceAliases, - hostRequestHeadersSeed, IWorkspaceDirs, ISessionLifecycleService, IWorkspaceLifecycleService, @@ -222,7 +220,6 @@ import { resolveKimiHome, resolveLoggingConfig, resolvePrintBackgroundMode, - skillCatalogRuntimeOptionsSeed, summarizeSkill, userRoots, type IAgentScopeHandle, @@ -327,8 +324,7 @@ export interface SDKRpcClientV2Options { * Explicit skill directories for this process (v1's SDK `skillDirs` / * the CLI's `--skills-dir`): when non-empty, default user / project skill * discovery is skipped and these directories serve as the user skill - * source. Seeded into the engine's app-scope - * `ISkillCatalogRuntimeOptions`. + * source. Passed into the engine through `BootstrapInput.args.skillDirs`. */ readonly skillDirs?: readonly string[]; readonly telemetry?: TelemetryClient; @@ -430,19 +426,17 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { homeDir: this.homeDir, configPath: this.configPath, clientIdentity: identity, + args: { + // Host identity headers for the engine's outbound requests (model, + // WebSearch, registry refresh). Without them the managed vendors go + // out with the SDK's default User-Agent and no X-Msh-* at all. + requestHeaders: createKimiDefaultHeaders({ homeDir: this.homeDir, ...identity }), + // `--skills-dir` (v1 parity): explicit skill dirs replace default + // user / project discovery for every session this client hosts. + skillDirs: options.skillDirs, + }, }, - [ - ...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env })), - // Host identity headers for the engine's outbound requests (model, - // WebSearch, registry refresh). Without this seed the managed vendors - // go out with the SDK's default User-Agent and no X-Msh-* at all. - ...hostRequestHeadersSeed( - createKimiDefaultHeaders({ homeDir: this.homeDir, ...identity }), - ), - // `--skills-dir` (v1 parity): explicit skill dirs replace default - // user / project discovery for every session this client hosts. - ...skillCatalogRuntimeOptionsSeed(options.skillDirs), - ], + [...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env }))], ); this.app = app; this.klient = createKlient({ scope: app }); @@ -550,7 +544,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { override async listWorkspaceSkills(workDir: string): Promise { const bootstrapService = this.engineAccessor.get(IBootstrapService); const discovery = this.engineAccessor.get(ISkillDiscovery); - const explicitDirs = this.engineAccessor.get(ISkillCatalogRuntimeOptions).explicitDirs ?? []; + const explicitDirs = bootstrapService.args.skillDirs ?? []; const roots = explicitDirs.length > 0 ? await configuredRoots(explicitDirs, workDir, bootstrapService.osHomeDir, 'user') @@ -659,9 +653,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * v1's removal cascades: the provider entry, every model pointing at it, * and the default pointers when they dangle. The engine's own - * `kosong.removeProvider` only clears the default-provider pointer, so - * the full v1 cascade is computed from the user-layer values and applied - * through the config facade (see `planProviderRemoval`). + * `kosong.removeProvider` only clears the default-provider pointer, so the + * full v1 cascade is computed from the user-layer values (see + * `planProviderRemoval`) and persisted as ONE atomic multi-section replace — + * the same single-write shape as v1's `removeKimiProvider`, so a process + * exit can never leave the file in a halfway-cascaded state. */ override async removeProvider(providerId: string): Promise { await this.configReady; @@ -678,17 +674,29 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { defaultProvider: defaultProvider.userValue, providerId, }); - await this.klient.global.config.replace({ domain: 'providers', value: plan.providers }); - await this.klient.global.config.replace({ domain: 'models', value: plan.models }); + const sections: Record = { + providers: plan.providers, + models: plan.models, + }; if (plan.clearDefaultModel) { - await this.klient.global.config.replace({ domain: 'defaultModel', value: undefined }); + sections['defaultModel'] = undefined; } if (plan.clearDefaultProvider) { - await this.klient.global.config.replace({ domain: 'defaultProvider', value: undefined }); + sections['defaultProvider'] = undefined; } + await this.klient.global.config.replaceSections({ sections }); return this.getConfig(); } + override supportsAtomicSectionReplace(): boolean { + return true; + } + + override async replaceConfigSections(sections: Record): Promise { + await this.configReady; + await this.klient.global.config.replaceSections({ sections }); + } + override async listPlugins(): Promise { return this.klient.global.plugins.list(); } diff --git a/packages/node-sdk/src/v2/config-mapper.ts b/packages/node-sdk/src/v2/config-mapper.ts index 43cd8fd4d..ba47f9084 100644 --- a/packages/node-sdk/src/v2/config-mapper.ts +++ b/packages/node-sdk/src/v2/config-mapper.ts @@ -125,3 +125,28 @@ export function planProviderRemoval(input: { clearDefaultProvider: input.defaultProvider === input.providerId, }; } + +/** + * Apply the v1 remove-provider cascade to a whole `KimiConfig` in memory (no + * persistence): drop the provider entry, every model pointing at it, and the + * default pointers when they dangle. Hosts that stage a removal and fold it + * into a later atomic write (instead of persisting it immediately) build on + * this — the same role the v2 engine's `shapeWithoutProvider` plays for its + * own refresh path. + */ +export function removeProviderFromConfig(config: KimiConfig, providerId: string): KimiConfig { + const plan = planProviderRemoval({ + providers: config.providers as Record | undefined, + models: config.models as Record> | undefined, + defaultModel: config.defaultModel, + defaultProvider: config.defaultProvider, + providerId, + }); + return { + ...config, + providers: plan.providers as KimiConfig['providers'], + models: plan.models as KimiConfig['models'], + defaultModel: plan.clearDefaultModel ? undefined : config.defaultModel, + defaultProvider: plan.clearDefaultProvider ? undefined : config.defaultProvider, + }; +} 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 85509fdc7..aa2a3b2e5 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -13,7 +13,15 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { createKimiHarnessV2, ErrorCodes, KimiError, KimiHarness, SDKRpcClientV2 } from '#/index'; +import { + createKimiHarnessV2, + ErrorCodes, + KimiError, + KimiHarness, + removeProviderFromConfig, + SDKRpcClientV2, + type KimiConfig, +} from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; import { IHostRequestHeaders } from '@moonshot-ai/agent-core-v2'; @@ -144,6 +152,60 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('persists removeProvider as one atomic cascade (providers, models, defaults)', async () => { + const { harness } = await makeHarness(); + try { + await harness.setConfig({ + providers: { + a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' }, + b: { type: 'openai', baseUrl: 'https://b.example.test/v1', apiKey: 'sk-b' }, + }, + models: { + 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, + 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, + }, + defaultModel: 'b/m1', + defaultProvider: 'b', + }); + const next = await harness.removeProvider('b'); + expect(next.providers['b']).toBeUndefined(); + expect(next.providers['a']).toBeDefined(); + expect(next.models?.['b/m1']).toBeUndefined(); + expect(next.models?.['a/m1']).toBeDefined(); + expect(next.defaultModel).toBeUndefined(); + expect(next.defaultProvider).toBeUndefined(); + // A fresh read from disk sees the same state — the cascade landed as a + // single atomic write, never a halfway-removed intermediate. + const reread = await harness.getConfig({ reload: true }); + expect(reread.providers['b']).toBeUndefined(); + expect(reread.models?.['b/m1']).toBeUndefined(); + expect(reread.defaultModel).toBeUndefined(); + expect(reread.defaultProvider).toBeUndefined(); + } finally { + await harness.close(); + } + }); + + it('replaces config sections atomically and clears undefined sections', async () => { + const { harness } = await makeHarness(); + try { + expect(harness.supportsAtomicSectionReplace()).toBe(true); + await harness.setConfig({ + providers: { a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' } }, + models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 } }, + defaultModel: 'a/m1', + }); + await harness.replaceConfigSections({ defaultModel: undefined }); + const next = await harness.getConfig({ reload: true }); + expect(next.defaultModel).toBeUndefined(); + // Sections absent from the write stay untouched. + expect(next.providers['a']).toBeDefined(); + expect(next.models?.['a/m1']).toBeDefined(); + } finally { + await harness.close(); + } + }); + it('fails loudly with not_implemented for methods not yet migrated', async () => { const { harness } = await makeHarness(); try { @@ -332,8 +394,58 @@ describe('SDKRpcClientV2 engine telemetry', () => { }); }); -async function writeSkill(dir: string, name: string): Promise { - await mkdir(dir, { recursive: true }); +describe('removeProviderFromConfig', () => { + it('drops the provider, its models and dangling default pointers without mutating the input', () => { + const config = { + providers: { + a: { type: 'openai', baseUrl: 'https://a.example.test/v1' }, + b: { type: 'openai', baseUrl: 'https://b.example.test/v1' }, + }, + models: { + 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, + 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, + 'my-b': { provider: 'b', model: 'm1', maxContextSize: 100 }, + }, + defaultModel: 'my-b', + defaultProvider: 'b', + } as unknown as KimiConfig; + + const next = removeProviderFromConfig(config, 'b'); + + expect(Object.keys(next.providers)).toEqual(['a']); + expect(Object.keys(next.models ?? {})).toEqual(['a/m1']); + expect(next.defaultModel).toBeUndefined(); + expect(next.defaultProvider).toBeUndefined(); + // The input config is left untouched (the staging host threads the copy). + expect(config.providers['b']).toBeDefined(); + expect(config.models?.['b/m1']).toBeDefined(); + expect(config.defaultModel).toBe('my-b'); + }); + + it('keeps the default pointers when they do not dangle', () => { + const config = { + providers: { + a: { type: 'openai' }, + b: { type: 'openai' }, + }, + models: { + 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, + 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, + }, + defaultModel: 'a/m1', + defaultProvider: 'a', + } as unknown as KimiConfig; + + const next = removeProviderFromConfig(config, 'b'); + + expect(Object.keys(next.providers)).toEqual(['a']); + expect(Object.keys(next.models ?? {})).toEqual(['a/m1']); + expect(next.defaultModel).toBe('a/m1'); + expect(next.defaultProvider).toBe('a'); + }); +}); + +async function writeSkill(dir: string, name: string): Promise { await mkdir(dir, { recursive: true }); await writeFile( join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: Skill ${name} for the escape-hatch test\n---\n\nBody of ${name}.\n`,