From 188c0fcbf7c884d4a86bd4eebd012b0ab7aeb5da Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 23 Jul 2026 00:32:10 +0800 Subject: [PATCH] refactor(agent-core-v2): decouple kosong from config persistence (#2068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(agent-core-v2): decouple kosong from config persistence - keep the kosong provider/model registries in memory and sync them with config.toml through a new app/kosongConfig two-way bridge: hydrate on startup, push config section changes into the registries, and persist runtime mutations (discovery refresh, OAuth provisioning, default-model pointer changes) back to disk - declare the providers/models/thinking section constants, zod schemas, env bindings, and TOML transforms in a single app/kosongConfig configSection.ts; kosong keeps hand-written types only - pin every schema to its kosong type at compile time via AssertExact> (_base/utils/typeEquality) - register a transitional auth>kosongConfig exception in the domain-layer checker for the OAuth provisioning flows * chore(agent-core-v2): drop unused IConfigService import from authLegacy * fix(agent-core-v2): reconcile registry with env-pinned default pointers A registry-originated default-model/provider write lands only in the config user layer when an effective overlay pins the section (KIMI_MODEL_NAME pins defaultModel to the reserved env model): the effective value does not move and no change event fires, so the registry diverged from the effective config view — catalog/auth reads reported the user pick while profile resolution kept the pinned model. After persisting, the bridge now re-asserts the effective value into the registry, restoring the pre-refactor behavior where every default-pointer write was arbitrated by the effective view. * fix(agent-core-v2): await persistence before resolving kosong registry mutations ProviderService/ModelService mutations resolved as soon as the in-memory registry updated, while the kosongConfig bridge persisted the change on an unawaited private chain — callers (klient kosong.*, set_default route, OAuth provision, discovery refresh) could observe success before the write reached config.toml, and a restart right after could lose it. - fire registry change events through AsyncEmitter and await delivery in set/delete/replaceAll/setDefaultX, so a mutation resolves only after listeners' waitUntil work completes; loadAll keeps synchronous timing - the bridge hooks its persists into waitUntil, hoists the equality guards into the listeners so config-originated echoes stay fully synchronous, and serializes persists on the chain as before - retry a failed persist with bounded backoff (3 attempts); failures are logged, never rejected to callers, and the in-memory change stands - default-pointer change events now carry an { id } payload (fireAsync requires object events) - add the klient kosong-config stress example covering read-after-write, concurrent bursts, and restart durability --- .agents/skills/agent-core-dev/config.md | 10 +- .changeset/kosong-persistence-bridge.md | 5 + .../scripts/check-domain-layers.mjs | 36 +- .../src/_base/utils/typeEquality.ts | 26 ++ .../agent/llmRequester/llmRequesterService.ts | 9 +- .../src/agent/profile/profileService.ts | 2 +- .../agent-core-v2/src/app/auth/authService.ts | 19 +- .../src/app/auth/configSection.ts | 14 +- .../src/app/authLegacy/authLegacyService.ts | 24 +- .../src/app/bootstrap/bootstrap.ts | 5 + .../agent-core-v2/src/app/config/errors.ts | 6 +- .../src/app/kosongConfig/configSection.ts | 358 +++++++++++++++ .../model => app/kosongConfig}/discovery.ts | 12 +- .../kosongConfig}/discoveryService.ts | 129 ++++-- .../model => app/kosongConfig}/envOverlay.ts | 29 +- .../src/app/kosongConfig/kosongConfig.ts | 31 ++ .../app/kosongConfig/kosongConfigService.ts | 254 +++++++++++ .../src/app/kosongConfig/oauthTokenAdapter.ts | 61 +++ packages/agent-core-v2/src/index.ts | 21 +- .../src/kosong/contract/errors.ts | 10 + .../agent-core-v2/src/kosong/model/catalog.ts | 11 +- .../src/kosong/model/catalogService.ts | 62 +-- .../src/kosong/model/configSection.ts | 89 ---- .../kosong/model/discoveryConfigSection.ts | 33 -- .../agent-core-v2/src/kosong/model/model.ts | 150 ++++--- .../src/kosong/model/modelAuth.ts | 4 +- .../src/kosong/model/modelOAuth.ts | 33 ++ .../src/kosong/model/modelService.ts | 116 +++-- .../src/kosong/model/thinking.ts | 48 +- .../src/kosong/provider/configSection.ts | 113 ----- .../src/kosong/provider/provider.ts | 111 +++-- .../src/kosong/provider/providerService.ts | 127 ++++-- .../agent-core-v2/src/kosong/recordDiff.ts | 59 +++ .../fullCompaction/fullCompaction.test.ts | 14 +- .../llmRequester/llmRequesterService.test.ts | 4 + .../agent-core-v2/test/app/auth/auth.test.ts | 14 +- .../app/bootstrap/bootstrapService.test.ts | 27 +- .../test/app/config/config.test.ts | 6 +- .../kosongConfig}/discovery.test.ts | 91 ++-- .../kosongConfig}/envOverlay.test.ts | 6 +- .../kosongConfig/kosongConfigService.test.ts | 409 ++++++++++++++++++ .../test/app/model/model.test.ts | 103 +---- .../test/app/provider/provider.test.ts | 171 +------- .../agent-core-v2/test/app/provider/stubs.ts | 5 + packages/agent-core-v2/test/harness/agent.ts | 88 +++- .../test/kosong/model/catalog.test.ts | 108 +++-- .../test/kosong/model/modelService.test.ts | 115 +++-- .../kosong/provider/providerService.test.ts | 228 +++++----- packages/agent-core-v2/test/kosong/stubs.ts | 22 + .../kap-server/src/routes/modelCatalog.ts | 2 +- .../klient/examples/kosong-config-stress.ts | 266 ++++++++++++ packages/klient/package.json | 1 + .../src/contract/global/providerDiscovery.ts | 2 +- packages/klient/src/core/facade/global.ts | 2 +- .../src/transports/memory/serviceRegistry.ts | 2 +- packages/klient/test/contract-parity.ts | 2 +- 56 files changed, 2590 insertions(+), 1115 deletions(-) create mode 100644 .changeset/kosong-persistence-bridge.md create mode 100644 packages/agent-core-v2/src/_base/utils/typeEquality.ts create mode 100644 packages/agent-core-v2/src/app/kosongConfig/configSection.ts rename packages/agent-core-v2/src/{kosong/model => app/kosongConfig}/discovery.ts (79%) rename packages/agent-core-v2/src/{kosong/model => app/kosongConfig}/discoveryService.ts (71%) rename packages/agent-core-v2/src/{kosong/model => app/kosongConfig}/envOverlay.ts (90%) create mode 100644 packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts create mode 100644 packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts create mode 100644 packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/configSection.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/discoveryConfigSection.ts create mode 100644 packages/agent-core-v2/src/kosong/model/modelOAuth.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/configSection.ts create mode 100644 packages/agent-core-v2/src/kosong/recordDiff.ts rename packages/agent-core-v2/test/{kosong/model => app/kosongConfig}/discovery.test.ts (81%) rename packages/agent-core-v2/test/{kosong/model => app/kosongConfig}/envOverlay.test.ts (95%) create mode 100644 packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts create mode 100644 packages/klient/examples/kosong-config-stress.ts diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 84d66fa29..316f307f1 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -92,7 +92,7 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk. - `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`. - `src/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. -A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/profile/configSection.ts` for `thinking`, `src/loop/configSection.ts` for `loopControl`). A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the owning domain too (`src/provider/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. +A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` has no kosong-side type at all — its section is fully self-contained in `app/kosongConfig`.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). ## Scope @@ -236,7 +236,7 @@ This means registration order is never a correctness concern — you do not need ### `KIMI_MODEL_*` env overlay -When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`src/provider/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics. +When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics. ## Owner-owned sections @@ -246,13 +246,13 @@ When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`s | Section | Owner | Layer | Status | |---|---|---|---| -| `providers` | `provider` | L2 | owner-owned (`IProviderService` CRUD) | +| `providers` / `defaultProvider` | `kosongConfig` (types: `kosong/provider`) | L3/L2 | owner-owned (`IProviderService` CRUD) | | `experimental` | `flag` | L3 | owner-owned | -| `thinking` | `profile` | L4 | owner-owned | +| `thinking` | `kosongConfig` (type: `kosong/model/thinking`) | L3/L2 | owner-owned | | `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) | | `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) | | `session` | `config` | L2 | in config | -| `models` / `defaultModel` / `defaultProvider` | `kosong` | L1 | owner-owned (read by `ProviderManager`) | +| `models` / `defaultModel` | `kosongConfig` (types: `kosong/model`) | L3/L2 | owner-owned (`IModelService` CRUD) | | `hooks` | `externalHooks` | L4 | owner-owned | | `permission` | `permissionRules` | L3 | owner-owned | | `background` | `background` | L5 | owner-owned | diff --git a/.changeset/kosong-persistence-bridge.md b/.changeset/kosong-persistence-bridge.md new file mode 100644 index 000000000..d6d58e93f --- /dev/null +++ b/.changeset/kosong-persistence-bridge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Decouple provider and model management from config persistence on the experimental engine: the runtime keeps its own provider/model registry, and a dedicated sync layer hydrates it from config.toml at startup and writes runtime changes (added providers, discovered models, default-model selection) back to disk. diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 08e28ea22..5bc5f5580 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -251,6 +251,14 @@ const DOMAIN_LAYER = new Map([ ['kosong/protocol', 1], ['kosong/provider', 2], ['kosong/model', 2], + // `kosongConfig` (App, L3) is the persistence wrapper over kosong: it + // declares the kosong-owned config sections (constants + zod schemas + // re-derived from kosong's pure types, compile-time pinned) and their + // env-overlay registrations, the two-way config ↔ kosong sync bridge, the + // OAuth token adapter, and the discovery orchestrator. It may import + // `config`/`auth`/`event` (L1–L2) and every kosong layer; kosong never + // imports it back. + ['kosongConfig', 3], ]); const V1_PACKAGE = '@moonshot-ai/agent-core'; @@ -281,11 +289,14 @@ const KOSONG_LAYER = new Map([ ]); /** - * Kosong subdomains whose non-kosong imports are restricted to `_base` - * utilities (`contract` is the pure wire contract; `protocol` is L1 trait - * interfaces — only `_base` + `contract`). + * Kosong is a pure provider/model abstraction layer: NO kosong subdomain may + * import another v2 domain outside kosong itself — only `_base` utilities + * are allowed. (`protocol` additionally sees `kosong/contract`, handled by + * Rule 3b above.) Config persistence, OAuth tokens, events, and discovery + * orchestration all live in the upper `app/kosongConfig` wrapper — kosong + * must never reach up to them. */ -const KOSONG_BASE_ONLY_SUBDOMAINS = new Set(['contract', 'protocol']); +const KOSONG_BASE_ONLY_SUBDOMAINS = new Set(['contract', 'protocol', 'provider', 'model']); /** * Wire SDK packages the pure kosong layers must never import — not even @@ -398,6 +409,9 @@ const ALLOWED_EXCEPTIONS = new Set([ 'bootstrap>skillCatalog', // bootstrap is the composition root — it wires backends by design. 'bootstrap>persistence/backends', + // bootstrap instantiates the kosong persistence bridge eagerly so kosong's + // registries are hydrated before any consumer can await their `ready`. + 'bootstrap>kosongConfig', // `auth` (KimiOAuth, L2) owns the OAuth-backed `WebSearch` tool and registers // it through the tool contribution API, so it reaches up to the L3 tool // contract and registry. Surfaced for review: the tool needs an authenticated @@ -405,6 +419,11 @@ const ALLOWED_EXCEPTIONS = new Set([ // auth-independent `web` domain. 'auth>tool', 'auth>toolRegistry', + // Transitional: `auth` (L2) reads/writes the kosong-owned config sections + // (providers/models/thinking), whose constants and schemas are declared by + // the `kosongConfig` persistence wrapper (L3), when provisioning or clearing + // OAuth-managed config. Slated for cleanup with the auth layering rework. + 'auth>kosongConfig', // `toolApproval` (Agent, L3) owns the approval round-trip for permissionGate // asks and plan/goal reviews, driven through the Session approval broker. 'toolApproval>approval', @@ -617,16 +636,17 @@ export function checkSource(source, absFile) { continue; } - // Rule 3c: outside the kosong subtree, the pure layers may only depend - // on `_base` utilities (`protocol` additionally sees `kosong/contract`, - // handled by Rule 3b above). + // Rule 3c: outside the kosong subtree, kosong code may only depend on + // `_base` utilities (`protocol` additionally sees `kosong/contract`, + // handled by Rule 3b above). This is what keeps kosong a pure + // abstraction layer with no upward dependencies. if (sourceKosong !== undefined && KOSONG_BASE_ONLY_SUBDOMAINS.has(sourceKosong.sub)) { const targetDomain = targetDomainOf(targetAbs); if (targetDomain !== '_base') { violations.push({ file: absFile, line, - message: `'kosong/${sourceKosong.sub}' must not import domain '${targetDomain ?? specifier}' via '${specifier}' — only _base utilities are allowed outside the kosong subtree`, + message: `'kosong/${sourceKosong.sub}' must not import domain '${targetDomain ?? specifier}' via '${specifier}' — kosong is a pure abstraction layer: only _base utilities are allowed outside the kosong subtree (persistence/OAuth/discovery live in app/kosongConfig)`, }); } continue; diff --git a/packages/agent-core-v2/src/_base/utils/typeEquality.ts b/packages/agent-core-v2/src/_base/utils/typeEquality.ts new file mode 100644 index 000000000..8f078f162 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/typeEquality.ts @@ -0,0 +1,26 @@ +/** + * Compile-time type equality. + * + * Used to pin a hand-written type to the zod schema that re-derives it — + * e.g. kosong's persistence-free types vs the section schemas their + * `kosongConfig` wrapper registers: a drift in either direction (added / + * removed field, changed field type, optionality flip) fails typecheck. + * + * `Equal` compares by mutual assignability through a contravariant + * function-type trick, so it is stricter than a one-way `A extends B` + * check. Both sides are flattened first (a homomorphic mapped type), so a + * schema-side intersection (e.g. the `{...} & { [k: string]: unknown }` + * that a passthrough object infers to) compares equal to the equivalent + * hand-written object type instead of failing on type-node shape. The + * comparison cannot see `readonly` modifiers (an inherent TS limitation), + * so hand-written types should match zod's mutable inference exactly. + */ + +type Flatten = { [K in keyof T]: T[K] } & {}; + +export type Equal = + (() => T extends Flatten ? 1 : 2) extends () => T extends Flatten ? 1 : 2 + ? true + : false; + +export type AssertExact = T; diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index e8589fdd7..3543e437e 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -68,9 +68,10 @@ import { type ModelRequestTiming, } from '#/kosong/model/modelRequester'; import type { ModelOverrides } from '#/kosong/model/model.types'; -import { MODELS_SECTION, type ModelsSection } from '#/kosong/model/model'; +import { IModelService } from '#/kosong/model/model'; import { completionBudgetParams, resolveCompletionBudget } from '#/kosong/model/completionBudget'; -import { resolveThinkingKeep, THINKING_SECTION, type ThinkingConfig } from '#/kosong/model/thinking'; +import { resolveThinkingKeep, type ThinkingConfig } from '#/kosong/model/thinking'; +import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import type { Protocol } from '#/kosong/protocol/protocol'; import type { ApiErrorEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -158,6 +159,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentUsageService private readonly usage: IAgentUsageService, @IConfigService private readonly config: IConfigService, + @IModelService private readonly modelService: IModelService, @IModelCatalog private readonly modelCatalog: IModelCatalog, @ILogService private readonly log: ILogService, @ITelemetryService private readonly telemetry: ITelemetryService, @@ -620,9 +622,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const systemPromptHash = fingerprint(input.systemPrompt); const overrides = this.config.get('modelOverrides'); const thinkingConfig = this.config.get(THINKING_SECTION); - const models = this.config.get(MODELS_SECTION); const modelConfig = - input.modelAlias === undefined ? undefined : models?.[input.modelAlias]; + input.modelAlias === undefined ? undefined : this.modelService.get(input.modelAlias); const payload: PayloadOf = { kind: requestKindForRecord(fields), provider: input.protocol, diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index fe6c93455..2dcd41955 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -62,10 +62,10 @@ import { resolveForcedThinkingEffort, resolveThinkingEffortForModel, resolveThinkingKeep, - THINKING_SECTION, requiresStrictThinkingValidation, type ThinkingConfig, } from '#/kosong/model/thinking'; +import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { ErrorCodes, Error2 } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 42bee9790..2d09d0b1e 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -55,13 +55,18 @@ import { nonEmpty, resolveModelAuthMaterial, } from '#/kosong/model/modelAuth'; -import { DEFAULT_MODEL_SECTION, type ModelRecord, MODELS_SECTION } from '#/kosong/model/model'; +import { IModelService, type ModelRecord } from '#/kosong/model/model'; +import { + DEFAULT_MODEL_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, + THINKING_SECTION, +} from '#/app/kosongConfig/configSection'; import { IProviderService, type OAuthRef, type ProviderConfig, type ProvidersChangedEvent, - PROVIDERS_SECTION, } from '#/kosong/provider/provider'; import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -78,7 +83,6 @@ import { const TERMINAL_RETENTION_MS = 5 * 60 * 1000; const DEFAULT_DEVICE_EXPIRES_IN_SEC = 15 * 60; -const THINKING_SECTION = 'thinking'; const SERVICES_SECTION = 'services'; interface FlowState { @@ -585,6 +589,7 @@ export class AuthSummaryService implements IAuthSummaryService { constructor( @IProviderService private readonly providerService: IProviderService, + @IModelService private readonly modelService: IModelService, @IConfigService private readonly config: IConfigService, @IOAuthService private readonly oauth: IOAuthService, @ILogService private readonly log: ILogService, @@ -614,10 +619,14 @@ export class AuthSummaryService implements IAuthSummaryService { } async ensureReady(modelOverride?: string): Promise { + // Reload so external file edits reach the kosong registries through the + // persistence bridge, then read the RUNTIME state from the registries — + // the config sections are only their persistence and may lag a pending + // kosong-originated persist. await this.config.reload(); const providers = this.providerService.list(); - const models = this.config.get | undefined>(MODELS_SECTION) ?? {}; - const modelId = modelOverride ?? this.config.get(DEFAULT_MODEL_SECTION); + const models = this.modelService.list(); + const modelId = modelOverride ?? this.modelService.getDefaultModel(); const configured = modelId === undefined || modelId === '' ? undefined : models[modelId]; if (Object.keys(providers).length === 0 && !isProviderlessModel(configured)) { throw new AuthProvisioningRequiredError(); diff --git a/packages/agent-core-v2/src/app/auth/configSection.ts b/packages/agent-core-v2/src/app/auth/configSection.ts index 79a35009f..1d251d5cf 100644 --- a/packages/agent-core-v2/src/app/auth/configSection.ts +++ b/packages/agent-core-v2/src/app/auth/configSection.ts @@ -26,12 +26,24 @@ import { snakeToCamel, transformPlainObject, } from '#/app/config/toml'; -import { OAuthRefSchema } from '#/kosong/provider/provider'; +import { type AssertExact, type Equal } from '#/_base/utils/typeEquality'; +import type { OAuthRef } from '#/kosong/provider/provider'; export const SERVICES_SECTION = 'services'; const StringRecordSchema = z.record(z.string(), z.string()); +// Local re-derivation of kosong's `OAuthRef` type: the canonical section +// schema lives in `app/kosongConfig` (L3), which this L2 domain must not +// import. The `AssertExact` pin keeps this copy in lockstep with the type. +const OAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), + oauthHost: z.string().min(1).optional(), +}); + +type _AssertOAuthRef = AssertExact, OAuthRef>>; + export const MoonshotServiceConfigSchema = z.object({ baseUrl: z.string().optional(), apiKey: z.string().optional(), diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts index 6489eb46b..b5eeddbab 100644 --- a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts @@ -2,12 +2,13 @@ * `authLegacy` domain — `IAuthLegacyService` implementation. * * Stateless App-scope projector: reads the configured providers through - * `provider`, the global default-model selection through `config`, and the - * managed OAuth provider's cached-token state through `auth`, then assembles - * the v1 `AuthSummary`. The computation mirrors v1's `AuthSummaryService.get()` - * so the `/api/v1/auth` envelope is byte-compatible. No business logic is - * duplicated; the native `IAuthSummaryService` (which serves `/api/v2`) is not - * involved. + * `provider`, the global default-model selection through `model` (the + * kosong registry is the runtime source of truth; config is only its + * persistence), and the managed OAuth provider's cached-token state through + * `auth`, then assembles the v1 `AuthSummary`. The computation mirrors v1's + * `AuthSummaryService.get()` so the `/api/v1/auth` envelope is + * byte-compatible. No business logic is duplicated; the native + * `IAuthSummaryService` (which serves `/api/v2`) is not involved. */ import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; @@ -16,8 +17,7 @@ import type { AuthSummary } from './authLegacy'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; -import { IConfigService } from '#/app/config/config'; -import { DEFAULT_MODEL_SECTION } from '#/kosong/model/model'; +import { IModelService } from '#/kosong/model/model'; import { IProviderService } from '#/kosong/provider/provider'; import { IAuthLegacyService } from './authLegacy'; @@ -29,16 +29,18 @@ export class AuthLegacyService implements IAuthLegacyService { constructor( @IProviderService private readonly providerService: IProviderService, - @IConfigService private readonly config: IConfigService, + @IModelService private readonly modelService: IModelService, @IOAuthService private readonly oauth: IOAuthService, ) {} async get(): Promise { - await this.config.ready; + // The kosong registries become ready once the persistence bridge has + // hydrated them from config — that is the readiness this projection needs. + await this.modelService.ready; const providers = this.providerService.list(); const providers_count = Object.keys(providers).length; - const default_model = nonEmpty(this.config.get(DEFAULT_MODEL_SECTION)); + const default_model = nonEmpty(this.modelService.getDefaultModel()); let managed_provider: AuthSummary['managed_provider'] = null; if (providers[MANAGED_PROVIDER_NAME] !== undefined) { diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 9c54118cf..7bb520fbd 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -27,6 +27,7 @@ import { import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig'; export interface IBootstrapOptions { readonly homeDir: string; @@ -120,6 +121,10 @@ export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = [] const app = createAppScope({ extra: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds], }); + // Instantiate the kosong persistence bridge eagerly: kosong's registries + // only become `ready` once the bridge has hydrated them from config, and + // Eager registration alone never constructs a service. + app.accessor.get(IKosongConfigService); return { app }; } diff --git a/packages/agent-core-v2/src/app/config/errors.ts b/packages/agent-core-v2/src/app/config/errors.ts index b263e6336..3f9f7fb35 100644 --- a/packages/agent-core-v2/src/app/config/errors.ts +++ b/packages/agent-core-v2/src/app/config/errors.ts @@ -1,12 +1,16 @@ /** * `config` domain error codes. + * + * The `config.invalid` code string is owned by the kosong L0 wire contract + * (`kosong/contract/errors.ts`); this module only registers it. */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; +import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; export const ConfigErrors = { codes: { - CONFIG_INVALID: 'config.invalid', + CONFIG_INVALID: CONFIG_INVALID_ERROR_CODE, }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts new file mode 100644 index 000000000..bf4a4329f --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -0,0 +1,358 @@ +/** + * `kosongConfig` domain (L3) — config-section declarations for kosong. + * + * The persistence wrapper for kosong's provider/model registries and the + * thinking / model-catalog preferences: declares every kosong-owned section + * constant and its zod schema, plus the env bindings / write-path strips + * and the snake_case ↔ camelCase TOML transforms. Where kosong owns a pure + * type (`providers` / `models` / `thinking`), the schema is re-derived from + * it and pinned by an `AssertExact` assertion (schema ≡ type at compile + * time); `modelCatalog` has no kosong-side type and keeps its own local + * schema. Self-registered at module load via `registerConfigSection`, so + * the `config` domain never imports kosong types. + * + * `ProviderTypeSchema` is deliberately free-form text: vendor identity is + * NOT enumerated at parse time. Validation happens at resolve time against + * kosong's provider-definition registry, which is what allows external + * packages to register new vendors without touching this schema. + * + * Side-effect module: production gets it from the `src/index.ts` + * side-effect block; tests import it on demand. + */ + +import { z } from 'zod'; + +import { type ConfigStripEnv, envBindings } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { + camelToSnake, + cloneRecord, + isPlainObject, + plainObjectToToml, + setDefined, + snakeToCamel, + transformPlainObject, +} from '#/app/config/toml'; +import { type AssertExact, type Equal } from '#/_base/utils/typeEquality'; +import type { ModelOverride, ModelRecord, ModelsSection } from '#/kosong/model/model'; +import type { ThinkingConfig } from '#/kosong/model/thinking'; +import type { OAuthRef, ProviderConfig, ProvidersSection } from '#/kosong/provider/provider'; +import { ProtocolSchema } from '#/kosong/protocol/protocol'; + +// --------------------------------------------------------------------------- +// `providers` — kosong's provider registry (types: `kosong/provider/provider`) +// --------------------------------------------------------------------------- + +export const PROVIDERS_SECTION = 'providers'; + +export const DEFAULT_PROVIDER_SECTION = 'defaultProvider'; + +export const ENV_MODEL_PROVIDER_KEY = '__kimi_env__'; + +export const ProviderTypeSchema = z.string(); + +export const OAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), + oauthHost: z.string().min(1).optional(), +}); + +export const ModelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']); + +const StringRecordSchema = z.record(z.string(), z.string()); + +export const ProviderConfigSchema = z.object({ + modelSource: ModelSourceSchema.optional(), + + baseUrl: z.string().optional(), + customHeaders: StringRecordSchema.optional(), + defaultModel: z.string().optional(), + + type: ProviderTypeSchema.optional(), + apiKey: z.string().optional(), + oauth: OAuthRefSchema.optional(), + env: StringRecordSchema.optional(), + source: z.record(z.string(), z.unknown()).optional(), +}); + +export const ProvidersSectionSchema = z.record(z.string(), ProviderConfigSchema); + +// Compile-time pins: the schemas must stay in lockstep with kosong's +// hand-written types (drift in either direction fails typecheck). +type _AssertOAuthRef = AssertExact, OAuthRef>>; +type _AssertProviderConfig = AssertExact< + Equal, ProviderConfig> +>; +type _AssertProvidersSection = AssertExact< + Equal, ProvidersSection> +>; + +// The `KIMI_MODEL_PROVIDER_TYPE` / `KIMI_MODEL_API_KEY` / `KIMI_MODEL_BASE_URL` +// environment bindings synthesize the reserved `__kimi_env__` provider entry. +export const providersEnvBindings = envBindings(ProvidersSectionSchema, { + [ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, { + apiKey: 'KIMI_MODEL_API_KEY', + type: 'KIMI_MODEL_PROVIDER_TYPE', + baseUrl: 'KIMI_MODEL_BASE_URL', + }), +}); + +export const stripProvidersEnv: ConfigStripEnv> = (value) => { + if (value === undefined || value === null || typeof value !== 'object') return value; + if (!(ENV_MODEL_PROVIDER_KEY in value)) return value; + const out = { ...value }; + delete out[ENV_MODEL_PROVIDER_KEY]; + return out; +}; + +export const providersFromToml = (rawSnake: unknown): unknown => { + if (!isPlainObject(rawSnake)) return rawSnake; + const out: Record = {}; + for (const [name, entry] of Object.entries(rawSnake)) { + out[name] = isPlainObject(entry) ? providerEntryFromToml(entry) : entry; + } + return out; +}; + +function providerEntryFromToml(data: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + const targetKey = snakeToCamel(key); + if (targetKey === 'oauth') { + out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value; + } else if (targetKey === 'env' || targetKey === 'customHeaders') { + out[targetKey] = isPlainObject(value) ? cloneRecord(value) : value; + } else { + out[targetKey] = value; + } + } + return out; +} + +export const providersToToml = (value: unknown, rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + const rawSub = cloneRecord(rawSnake); + const out: Record = {}; + for (const [name, entry] of Object.entries(value)) { + out[name] = isPlainObject(entry) ? providerEntryToToml(entry, rawSub[name]) : entry; + } + return out; +}; + +function providerEntryToToml( + provider: Record, + rawProvider: unknown, +): Record { + const out = cloneRecord(rawProvider); + for (const [key, value] of Object.entries(provider)) { + if (key === 'oauth' && isPlainObject(value)) { + out[camelToSnake(key)] = plainObjectToToml(value, undefined); + } else if ((key === 'env' || key === 'customHeaders') && value !== undefined) { + out[camelToSnake(key)] = cloneRecord(value); + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +registerConfigSection(PROVIDERS_SECTION, ProvidersSectionSchema, { + defaultValue: {}, + env: providersEnvBindings, + stripEnv: stripProvidersEnv, + fromToml: providersFromToml, + toToml: providersToToml, +}); + +// --------------------------------------------------------------------------- +// `models` — kosong's model registry (types: `kosong/model/model`) +// --------------------------------------------------------------------------- + +export const MODELS_SECTION = 'models'; + +/** + * The global default-model pointer: a single model id from `[models.*]` used + * whenever a call site does not name a model explicitly. Cross-domain by + * nature — written by `IModelCatalog.setDefaultModel` and the OAuth login / + * refresh flows (`app/auth`), read by runtime resolution fallbacks. The sole + * owner of the key constant lives here; every consumer imports it. + */ +export const DEFAULT_MODEL_SECTION = 'defaultModel'; + +const ModelBaseSchema = z.object({ + providerId: z.string().optional(), + + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + oauth: OAuthRefSchema.optional(), + + protocol: ProtocolSchema.optional(), + + name: z.string().optional(), + aliases: z.array(z.string()).optional(), + + provider: z.string().optional(), + model: z.string().optional(), + maxContextSize: z.number().int().min(1).optional(), + maxInputSize: z.number().int().min(1).optional(), + maxOutputSize: z.number().int().min(1).optional(), + capabilities: z.array(z.string()).optional(), + displayName: z.string().optional(), + reasoningKey: z.string().optional(), + adaptiveThinking: z.boolean().optional(), + betaApi: z.boolean().optional(), + supportEfforts: z.array(z.string()).optional(), + defaultEffort: z.string().optional(), + offEffort: z.string().optional(), +}); + +export const ModelOverrideSchema = ModelBaseSchema.omit({ + providerId: true, + baseUrl: true, + apiKey: true, + oauth: true, + protocol: true, + name: true, + aliases: true, + provider: true, + model: true, + betaApi: true, +}).partial(); + +export const ModelRecordSchema = ModelBaseSchema.extend({ + overrides: ModelOverrideSchema.optional(), +}).passthrough(); + +export const ModelsSectionSchema = z.record(z.string(), ModelRecordSchema); + +// Compile-time pins: the schemas must stay in lockstep with kosong's +// hand-written types (drift in either direction fails typecheck). +type _AssertModelOverride = AssertExact< + Equal, ModelOverride> +>; +type _AssertModelRecord = AssertExact, ModelRecord>>; +type _AssertModelsSection = AssertExact< + Equal, ModelsSection> +>; + +// The transforms preserve user-defined model ids (record keys) while +// converting each id's fields. +export const modelsFromToml = (rawSnake: unknown): unknown => { + if (!isPlainObject(rawSnake)) return rawSnake; + const out: Record = {}; + for (const [id, entry] of Object.entries(rawSnake)) { + if (!isPlainObject(entry)) { + out[id] = entry; + continue; + } + const converted = transformPlainObject(entry); + if (isPlainObject(converted['overrides'])) { + converted['overrides'] = transformPlainObject(converted['overrides']); + } + out[id] = converted; + } + return out; +}; + +export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + const rawSub = cloneRecord(rawSnake); + const out: Record = {}; + for (const [id, entry] of Object.entries(value)) { + if (!isPlainObject(entry)) { + out[id] = entry; + continue; + } + const rawEntry = cloneRecord(rawSub[id]); + const converted: Record = {}; + for (const [key, field] of Object.entries(entry)) { + if (key === 'capabilities' && Array.isArray(field)) { + converted[camelToSnake(key)] = [...field]; + } else if (key === 'overrides' && isPlainObject(field)) { + converted['overrides'] = modelOverridesToToml(field, rawEntry['overrides']); + } else { + setDefined(converted, camelToSnake(key), field); + } + } + out[id] = { ...rawEntry, ...converted }; + } + return out; +}; + +function modelOverridesToToml( + overrides: Record, + rawSnake: unknown, +): Record { + const out = cloneRecord(rawSnake); + for (const [key, value] of Object.entries(overrides)) { + if (key === 'capabilities' && Array.isArray(value)) { + out[camelToSnake(key)] = [...value]; + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +registerConfigSection(MODELS_SECTION, ModelsSectionSchema, { + defaultValue: {}, + fromToml: modelsFromToml, + toToml: modelsToToml, +}); + +// --------------------------------------------------------------------------- +// `thinking` — thinking defaults (type: `kosong/model/thinking`) +// --------------------------------------------------------------------------- + +export const THINKING_SECTION = 'thinking'; + +export const ThinkingConfigSchema = z.object({ + enabled: z.boolean().optional(), + effort: z.string().optional(), + forcedEffort: z.string().optional(), + keep: z.string().optional(), +}); + +// Compile-time pin: the schema must stay in lockstep with kosong's +// hand-written `ThinkingConfig` type (drift in either direction fails +// typecheck). +type _AssertThinkingConfig = AssertExact< + Equal, ThinkingConfig> +>; + +// The `KIMI_MODEL_THINKING_EFFORT` env binding is an env-only forcedEffort +// override; the strip keeps it out of `config.toml`. +export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, { + forcedEffort: 'KIMI_MODEL_THINKING_EFFORT', +}); + +export const stripThinkingEnv: ConfigStripEnv = (value) => { + const result = { ...value }; + delete result.forcedEffort; + return result; +}; + +registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { + env: thinkingEnvBindings, + stripEnv: stripThinkingEnv, +}); + +// --------------------------------------------------------------------------- +// `modelCatalog` — provider-model catalog auto-refresh cadence (no kosong type) +// --------------------------------------------------------------------------- + +// Read by the kap-server model-catalog refresh scheduler to decide the +// refresh interval and whether to refresh once on start. Env vars +// (`KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS`, +// `KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START`) override these values at the +// scheduler edge. +export const MODEL_CATALOG_SECTION = 'modelCatalog'; + +export const ModelCatalogConfigSchema = z.object({ + refreshIntervalMs: z.number().int().min(0).optional(), + refreshOnStart: z.boolean().optional(), +}); + +export type ModelCatalogConfig = z.infer; + +registerConfigSection(MODEL_CATALOG_SECTION, ModelCatalogConfigSchema); diff --git a/packages/agent-core-v2/src/kosong/model/discovery.ts b/packages/agent-core-v2/src/app/kosongConfig/discovery.ts similarity index 79% rename from packages/agent-core-v2/src/kosong/model/discovery.ts rename to packages/agent-core-v2/src/app/kosongConfig/discovery.ts index ddea05b04..93e5a9a5b 100644 --- a/packages/agent-core-v2/src/kosong/model/discovery.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discovery.ts @@ -1,14 +1,16 @@ /** - * `kosong/model` domain (L2) — `IProviderDiscoveryService`: remote model + * `kosongConfig` domain (L3) — `IProviderDiscoveryService`: remote model * discovery and config sync. * * Refreshes the `[models.*]` / `[providers.*]` configuration from what each * provider actually serves (managed OAuth catalogs, open platforms, custom * registries) through the shared `@moonshot-ai/kimi-code-oauth` orchestrator, - * and publishes `event.model_catalog.changed` on change. This is a WRITE - * path (external world → config), deliberately separate from the read-only - * `IModelCatalog` materialization/query surface (config → runtime). The - * OAuth-only managed-provider refresh additionally lives in `auth` + * applies the result to kosong's in-memory registries (the persistence + * bridge writes it back to config), and publishes + * `event.model_catalog.changed` on change. This is a WRITE path (external + * world → kosong → config), deliberately separate from the read-only + * `IModelCatalog` materialization/query surface. The OAuth-only + * managed-provider refresh additionally lives in `auth` * (`IOAuthService.refreshOAuthProviderModels`). */ diff --git a/packages/agent-core-v2/src/kosong/model/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts similarity index 71% rename from packages/agent-core-v2/src/kosong/model/discoveryService.ts rename to packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index 1b9dd00ef..738cb15e5 100644 --- a/packages/agent-core-v2/src/kosong/model/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -1,11 +1,12 @@ /** - * `kosong/model` domain (L2) — `IProviderDiscoveryService` implementation. + * `kosongConfig` domain (L3) — `IProviderDiscoveryService` implementation. * * Owns the all-provider model refresh: delegates to the shared * `@moonshot-ai/kimi-code-oauth` orchestrator (managed OAuth + open - * platforms + custom registries), writes the discovered providers/models - * back through `config`, and publishes `event.model_catalog.changed` on - * change. Bound at App scope. + * platforms + custom registries), applies the discovered providers/models + * to kosong's in-memory registries (the persistence bridge writes them back + * to config), and publishes `event.model_catalog.changed` on change. Bound + * at App scope. * * `modelSource: 'static'` short-circuits refresh: a provider whose effective * model source is `static` (config-declared, or declared by its vendor @@ -16,6 +17,16 @@ * and merged back verbatim on every write, so the orchestrator can neither * refresh them nor drop them (or a default model pointing at them). * + * Two write-path details preserve the legacy semantics exactly: + * - Registry replaces preserve the entries the orchestrator could not see: + * the static exclusion AND the config-file-external entries (the + * env-synthesized `__kimi_env__` slice), which the orchestrator's + * user-value view does not contain. + * - `defaultModel` / `thinking` stay direct `config.replace` writes (like + * the OAuth flows): the env overlay may pin the runtime default to the + * env-synthesized model, and only the config effective view knows that — + * the bridge then syncs the effective pointer into the registry. + * * Credential detection goes through the provider-definition registry * (`resolveProviderEndpoint` against the provider's config env bag), not a * per-protocol env table. @@ -33,32 +44,30 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import { IOAuthService } from '#/app/auth/auth'; +import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; - -import { IConfigService } from '../../app/config/config'; -import { ModelCatalogErrors } from './errors'; -import { IHostRequestHeaders } from './hostRequestHeaders'; -import { - DEFAULT_MODEL_SECTION, - IModelService, - MODELS_SECTION, - type ModelRecord, -} from './model'; -import { THINKING_SECTION } from './thinking'; - -import { - IProviderDiscoveryService, - type RefreshProviderModelsOptions, - type RefreshProviderModelsResponse, -} from './discovery'; +import { ModelCatalogErrors } from '#/kosong/model/errors'; +import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { IModelService, type ModelRecord } from '#/kosong/model/model'; import { IProviderService, type ModelSource, type OAuthRef, type ProviderConfig, +} from '#/kosong/provider/provider'; +import { getProviderDefinition } from '#/kosong/provider/providerDefinition'; + +import { + DEFAULT_MODEL_SECTION, + MODELS_SECTION, PROVIDERS_SECTION, -} from '../provider/provider'; -import { getProviderDefinition } from '../provider/providerDefinition'; + THINKING_SECTION, +} from './configSection'; +import { + IProviderDiscoveryService, + type RefreshProviderModelsOptions, + type RefreshProviderModelsResponse, +} from './discovery'; /** * Statically-sourced providers (and their bound models) hidden from the @@ -203,6 +212,23 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { }; } + /** + * The registry entries the orchestrator's user-value view cannot see (the + * env-synthesized slice): preserved verbatim across every registry + * replace, or a refresh would drop the runtime env model/provider. + */ + private syntheticProviders( + userProviders: Readonly>, + ): Record { + return withoutKeys(this.providerService.list(), userProviders); + } + + private syntheticModels( + userModels: Readonly>, + ): Record { + return withoutKeys(this.modelService.list(), userModels); + } + private async removeProviderForRefresh(providerId: string): Promise { const current = this.readUserConfigShape(); const providers = current.providers as Record; @@ -213,8 +239,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { const restModels = Object.fromEntries( Object.entries(models).filter(([, record]) => record.provider !== providerId), ); - await this.config.replace(PROVIDERS_SECTION, restProviders); - await this.config.replace(MODELS_SECTION, restModels); + await this.providerService.replaceAll({ + ...this.syntheticProviders(providers), + ...restProviders, + }); + await this.modelService.replaceAll({ ...this.syntheticModels(models), ...restModels }); return { ...current, providers: restProviders, @@ -226,14 +255,27 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { patch: ManagedKimiConfigShape, exclusion: StaticExclusion, ): Promise { + const userProviders = + this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; + const userModels = + this.config.inspect>(MODELS_SECTION).userValue ?? {}; if (patch.providers !== undefined) { - await this.config.replace(PROVIDERS_SECTION, { + await this.providerService.replaceAll({ + ...this.syntheticProviders(userProviders), ...exclusion.providers, ...patch.providers, }); } if (patch.models !== undefined) { - await this.config.replace(MODELS_SECTION, { ...exclusion.models, ...patch.models }); + await this.modelService.replaceAll({ + ...this.syntheticModels(userModels), + ...exclusion.models, + // The orchestrator's alias shape is a structural superset of + // ModelRecord at runtime (its protocol union additionally allows + // vendor spellings the records never actually carry); the legacy + // config.write path took `unknown`, so cast here. + ...(patch.models as Record), + }); } // The refresh orchestrator always sends all four keys, so key presence is // the write intent and an explicit `undefined` means CLEAR, not "leave @@ -246,6 +288,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { // Exception: when the user's default points at a statically-sourced model // the orchestrator could not see, its clamp/restore logic would silently // clear or re-point the selection (and its thinking) — restore both. + // + // `defaultModel` / `thinking` go through config directly (not the + // registry): the env overlay may pin the runtime default, and only the + // config effective view knows — the bridge syncs the effective pointer + // into the registry afterwards. const restoreDefault = exclusion.defaultModel !== undefined; if ('defaultModel' in patch) { await this.config.replace( @@ -259,7 +306,31 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { restoreDefault ? exclusion.thinking : patch.thinking, ); } - return this.readUserConfigShape(); + // The writes above landed in the registries / config; compute the + // post-patch shape in memory (re-reading config would race the bridge's + // asynchronous persist of the registry changes). + return { + providers: + patch.providers !== undefined + ? ({ ...exclusion.providers, ...patch.providers } as ManagedKimiConfigShape['providers']) + : (userProviders as ManagedKimiConfigShape['providers']), + models: + patch.models !== undefined + ? ({ ...exclusion.models, ...patch.models } as ManagedKimiConfigShape['models']) + : (userModels as ManagedKimiConfigShape['models']), + defaultModel: + 'defaultModel' in patch + ? restoreDefault + ? exclusion.defaultModel + : patch.defaultModel + : this.config.inspect(DEFAULT_MODEL_SECTION).userValue, + thinking: + 'thinking' in patch + ? restoreDefault + ? exclusion.thinking + : patch.thinking + : this.config.inspect(THINKING_SECTION).userValue, + }; } private async resolveOAuthToken( @@ -307,5 +378,5 @@ registerScopedService( IProviderDiscoveryService, ProviderDiscoveryService, InstantiationType.Eager, - 'modelCatalog', + 'kosongConfig', ); diff --git a/packages/agent-core-v2/src/kosong/model/envOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts similarity index 90% rename from packages/agent-core-v2/src/kosong/model/envOverlay.ts rename to packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts index bf4c33612..1abcdee80 100644 --- a/packages/agent-core-v2/src/kosong/model/envOverlay.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts @@ -1,18 +1,18 @@ /** - * `kosong/model` domain (L2) — `KIMI_MODEL_*` effective-config overlay. + * `kosongConfig` domain (L3) — `KIMI_MODEL_*` effective-config overlay. * * When `KIMI_MODEL_NAME` is set, synthesizes one model id (bound to the - * reserved `__kimi_env__` provider owned by the `provider` domain) from the + * reserved `__kimi_env__` provider whose schema kosong owns) from the * `KIMI_MODEL_*` environment variables and overlays it onto the resolved * `effective` config: the reserved model entry, `defaultModel`, and the request * `modelOverrides`. The overlay is applied ONLY to the in-memory `effective` * view; its `strip` removes the synthesized values on the write path so they * never reach `config.toml`. Self-registered into `IConfigRegistry` at module * load (see `configOverlayContributions.ts`), so the `config` domain never - * imports this domain's model semantics, and so the overlay takes effect even - * when `ModelService` is never instantiated. + * imports kosong semantics, and so the overlay takes effect even when the + * kosong registry services are never instantiated. * - * The env provider's default `baseUrl` is resolved through the + * The env provider's default `baseUrl` is resolved through kosong's * provider-definition registry (`resolveProviderEndpoint` against the same * env the overlay reads), not from a hardcoded vendor table — for Kimi that * is the `KIMI_BASE_URL` → `https://api.moonshot.ai/v1` chain declared by the @@ -22,11 +22,12 @@ import { parseBooleanEnv } from '#/_base/utils/env'; import { Error2 } from '#/_base/errors/errors'; -import type { ConfigEffectiveOverlay } from '../../app/config/config'; -import { registerConfigOverlay } from '../../app/config/configOverlayContributions'; -import { ConfigErrors } from '../../app/config/errors'; -import { ENV_MODEL_PROVIDER_KEY } from '../provider/provider'; -import { resolveProviderEndpoint } from '../provider/providerDefinition'; +import type { ConfigEffectiveOverlay } from '#/app/config/config'; +import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; +import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; +import { resolveProviderEndpoint } from '#/kosong/provider/providerDefinition'; + +import { ENV_MODEL_PROVIDER_KEY } from './configSection'; export const ENV_MODEL_ALIAS_KEY = '__kimi_env_model__'; @@ -40,7 +41,7 @@ function trimmed(value: string | undefined): string | undefined { } function fail(message: string): never { - throw new Error2(ConfigErrors.codes.CONFIG_INVALID, message); + throw new Error2(CONFIG_INVALID_ERROR_CODE, message); } function parsePositiveInt(raw: string, varName: string): number { @@ -141,9 +142,9 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { const maxOutputRaw = trimmed(getEnv('KIMI_MODEL_MAX_OUTPUT_SIZE')); const maxOutputSize = - maxOutputRaw !== undefined - ? parsePositiveInt(maxOutputRaw, 'KIMI_MODEL_MAX_OUTPUT_SIZE') - : undefined; + maxOutputRaw === undefined + ? undefined + : parsePositiveInt(maxOutputRaw, 'KIMI_MODEL_MAX_OUTPUT_SIZE'); const capabilities = parseCapabilities(getEnv('KIMI_MODEL_CAPABILITIES')) ?? DEFAULT_CAPABILITIES; const displayName = trimmed(getEnv('KIMI_MODEL_DISPLAY_NAME')); const reasoningKey = trimmed(getEnv('KIMI_MODEL_REASONING_KEY')); diff --git a/packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts b/packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts new file mode 100644 index 000000000..0b7565b08 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts @@ -0,0 +1,31 @@ +/** + * `kosongConfig` domain (L3) — the kosong persistence bridge contract. + * + * `IKosongConfigService` is the two-way sync between the config service + * (persistence) and kosong's in-memory provider/model registries: + * + * - **Startup / config → kosong**: once config is ready, the registries are + * hydrated from the effective config view; later config section changes + * (TOML edits, `config.reload`, direct `config.set/replace` writes such as + * the OAuth flows) are pushed into kosong the same way. + * - **kosong → config**: mutations that land in kosong (klient `addProvider`, + * discovery refresh results, default-pointer changes) fire kosong change + * events, which the bridge persists back through `config.replace`. + * + * Kosong itself never sees the config service — this bridge is the only + * component that knows both sides. Bound at App scope; instantiated by the + * composition root (`bootstrap`) so hydration is guaranteed before any + * consumer can await the kosong registries' `ready`. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IKosongConfigService { + readonly _serviceBrand: undefined; + + /** Resolves once the initial config → kosong hydration has completed. */ + readonly ready: Promise; +} + +export const IKosongConfigService: ServiceIdentifier = + createDecorator('kosongConfigService'); diff --git a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts new file mode 100644 index 000000000..b32383e74 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts @@ -0,0 +1,254 @@ +/** + * `kosongConfig` domain (L3) — `IKosongConfigService` implementation. + * + * The two-way persistence bridge between `IConfigService` and kosong's + * in-memory provider/model registries. See `kosongConfig.ts` for the + * contract-level description. + * + * Both sync directions are idempotent by deep comparison, which is what + * makes the loop terminate without any reentrancy flags: + * + * - config → kosong: the registries' writes are silent when the value is + * equal, so a config-originated push never echoes back as a persist. + * - kosong → config: the persist handlers skip the write when the config + * value already matches the registry state (the case for every + * config-originated push), so a persist never echoes back as a sync. + * - env-pinned pointers: a registry-originated default-pointer write lands + * in the user layer even when an effective overlay pins the section + * (`KIMI_MODEL_NAME` → `defaultModel`); the bridge then re-asserts the + * pinned effective value into the registry, so a registry read can never + * diverge from the effective config view. + * + * Persists are serialized through a promise chain so rapid mutation bursts + * reach the disk in event order, and each persist is hooked into the + * registry's change event through `waitUntil` — so an awaited registry + * mutation (`providers.set(...)`, `models.setDefaultModel(...)`, ...) only + * resolves once the write has actually landed in config. A failed persist is + * retried with backoff before the failure is logged; the mutation's caller is + * never rejected (the in-memory change stands either way). + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; + +import { type ConfigSectionChangedEvent, IConfigService } from '#/app/config/config'; +import { describeUnknownError } from '#/app/config/configPure'; +import { deepEqual } from '#/app/config/sectionDiff'; +import { IModelService, type ModelsSection } from '#/kosong/model/model'; +import { IProviderService, type ProvidersSection } from '#/kosong/provider/provider'; + +import { IKosongConfigService } from './kosongConfig'; +import { + DEFAULT_MODEL_SECTION, + DEFAULT_PROVIDER_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, +} from './configSection'; + +/** Persist attempts per write; see `replaceWithRetry` for why this stays small. */ +const PERSIST_MAX_ATTEMPTS = 3; + +export class KosongConfigService extends Disposable implements IKosongConfigService { + declare readonly _serviceBrand: undefined; + + readonly ready: Promise; + + private persistChain: Promise = Promise.resolve(); + + constructor( + @IConfigService private readonly config: IConfigService, + @IProviderService private readonly providers: IProviderService, + @IModelService private readonly models: IModelService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.ready = this.initialize(); + // The composition root instantiates the bridge without awaiting it; log + // initialization failures instead of surfacing an unhandled rejection. + void this.ready.catch((error) => { + this.log.warn('kosong config bridge initialization failed', { + error: describeUnknownError(error), + }); + }); + } + + private async initialize(): Promise { + await this.config.ready; + // Hydrate first, subscribe after: the initial load comes FROM config, so + // it must not echo back as a persist (the equality guards would catch it + // anyway, but skipping the round trip keeps startup quiet). + this.providers.loadAll( + this.config.get(PROVIDERS_SECTION) ?? {}, + this.config.get(DEFAULT_PROVIDER_SECTION), + ); + this.models.loadAll( + this.config.get(MODELS_SECTION) ?? {}, + this.config.get(DEFAULT_MODEL_SECTION), + ); + this._register(this.config.onDidSectionChange((e) => this.onConfigSectionChanged(e))); + // The guards mirror the ones inside the persist tasks: skipping the echo + // here (instead of registering a no-op `waitUntil`) keeps config-originated + // syncs fully synchronous for every other listener, even while the persist + // chain is busy with a retrying write. + this._register( + this.providers.onDidChangeProviders((e) => { + if ( + deepEqual(this.config.get(PROVIDERS_SECTION) ?? {}, this.providers.list()) + ) { + return; + } + e.waitUntil(this.enqueuePersistProviders()); + }), + ); + this._register( + this.providers.onDidChangeDefaultProvider((e) => { + if (this.config.get(DEFAULT_PROVIDER_SECTION) === e.id) return; + e.waitUntil(this.enqueuePersistDefaultPointer(DEFAULT_PROVIDER_SECTION, e.id)); + }), + ); + this._register( + this.models.onDidChangeModels((e) => { + if (deepEqual(this.config.get(MODELS_SECTION) ?? {}, this.models.list())) { + return; + } + e.waitUntil(this.enqueuePersistModels()); + }), + ); + this._register( + this.models.onDidChangeDefaultModel((e) => { + if (this.config.get(DEFAULT_MODEL_SECTION) === e.id) return; + e.waitUntil(this.enqueuePersistDefaultPointer(DEFAULT_MODEL_SECTION, e.id)); + }), + ); + } + + // ------------------------------------------------------------------------- + // config → kosong + // ------------------------------------------------------------------------- + + private onConfigSectionChanged(e: ConfigSectionChangedEvent): void { + switch (e.domain) { + case PROVIDERS_SECTION: + this.providers.loadAll( + (e.value as ProvidersSection | undefined) ?? {}, + // Sync the RECORDS only: the default pointer has its own domain + // event below. Re-applying config's pointer here would resurrect a + // stale value over a newer registry pointer — e.g. the cleared + // pointer of a default-provider delete, whose own persist has not + // run yet — and the two-way sync would livelock. + this.providers.getDefaultProvider(), + ); + break; + case MODELS_SECTION: + this.models.loadAll( + (e.value as ModelsSection | undefined) ?? {}, + // See PROVIDERS_SECTION above: the pointer syncs through its own + // DEFAULT_MODEL_SECTION event. + this.models.getDefaultModel(), + ); + break; + case DEFAULT_PROVIDER_SECTION: + void this.providers + .setDefaultProvider(e.value as string | undefined) + .catch((error) => this.logPersistFailure(error)); + break; + case DEFAULT_MODEL_SECTION: + void this.models + .setDefaultModel(e.value as string | undefined) + .catch((error) => this.logPersistFailure(error)); + break; + } + } + + // ------------------------------------------------------------------------- + // kosong → config + // ------------------------------------------------------------------------- + + private enqueuePersistProviders(): Promise { + return this.enqueue(async () => { + const next = this.providers.list(); + if (deepEqual(this.config.get(PROVIDERS_SECTION) ?? {}, next)) return; + await this.replaceWithRetry(PROVIDERS_SECTION, next); + }); + } + + private enqueuePersistModels(): Promise { + return this.enqueue(async () => { + const next = this.models.list(); + if (deepEqual(this.config.get(MODELS_SECTION) ?? {}, next)) return; + await this.replaceWithRetry(MODELS_SECTION, next); + }); + } + + private enqueuePersistDefaultPointer(domain: string, value: string | undefined): Promise { + return this.enqueue(async () => { + if (this.config.get(domain) === value) return; + await this.replaceWithRetry(domain, value); + // An effective overlay may pin the section (e.g. `KIMI_MODEL_NAME` + // pins `defaultModel` to the reserved env model): the write then lands + // only in the user layer — the effective value does not move and no + // change event fires — while the registry keeps the unpinned value. + // Re-assert the effective value so a registry read can never diverge + // from the pinned view; the re-assert's own change event no-ops back + // into this persist through the equality guard above. + const effective = this.config.get(domain); + if (effective === value) return; + // Fire-and-forget: the re-assert's persist is a guaranteed no-op, and + // awaiting it from inside the persist chain would deadlock — its own + // `waitUntil` would queue behind this very task. + if (domain === DEFAULT_PROVIDER_SECTION) { + void this.providers + .setDefaultProvider(effective) + .catch((error) => this.logPersistFailure(error)); + } else if (domain === DEFAULT_MODEL_SECTION) { + void this.models + .setDefaultModel(effective) + .catch((error) => this.logPersistFailure(error)); + } + }); + } + + /** + * Disk-write failures are rare and usually transient, so a failed persist is + * retried with backoff before giving up to the log (via the chain's catch). + * The retry budget stays small on purpose: the persist chain serializes + * every mutation's await behind it, so a multi-minute budget would stall + * all callers instead of just logging the failure. + */ + private async replaceWithRetry(domain: string, value: unknown): Promise { + const delays = retryBackoffDelays(PERSIST_MAX_ATTEMPTS); + for (let attempt = 0; ; attempt += 1) { + try { + await this.config.replace(domain, value); + return; + } catch (error) { + const delay = delays[attempt]; + if (delay === undefined) throw error; + await sleepForRetry(delay); + } + } + } + + private enqueue(task: () => Promise): Promise { + // The chain itself always recovers so one failed task cannot poison the + // persists queued behind it; the returned promise is what the registry's + // `waitUntil` (and thereby the mutation's caller) observes. + this.persistChain = this.persistChain.then(task).catch((error) => this.logPersistFailure(error)); + return this.persistChain; + } + + private logPersistFailure(error: unknown): void { + this.log.warn('kosong config persist failed', { error: describeUnknownError(error) }); + } +} + +registerScopedService( + LifecycleScope.App, + IKosongConfigService, + KosongConfigService, + InstantiationType.Eager, + 'kosongConfig', +); diff --git a/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts new file mode 100644 index 000000000..bf555187e --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts @@ -0,0 +1,61 @@ +/** + * `kosongConfig` domain (L3) — `IModelOAuthTokens` implementation. + * + * Delegates kosong's OAuth token port to `IOAuthService` and owns the + * `auth.login_required` error contract (the code is registered by + * `app/auth/errors`): kosong's model catalog only sees the port. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { Error2 } from '#/_base/errors/errors'; + +import { IOAuthService } from '#/app/auth/auth'; +import { AuthErrors } from '#/app/auth/errors'; +import { nonEmpty } from '#/kosong/model/modelAuth'; +import { IModelOAuthTokens } from '#/kosong/model/modelOAuth'; +import type { OAuthRef } from '#/kosong/provider/provider'; + +export class ModelOAuthTokenAdapter implements IModelOAuthTokens { + declare readonly _serviceBrand: undefined; + + constructor(@IOAuthService private readonly oauth: IOAuthService) {} + + async hasCachedAccessToken(provider: string, oauthRef: OAuthRef): Promise { + try { + const token = await this.oauth.getCachedAccessToken(provider, oauthRef); + return nonEmpty(token) !== undefined; + } catch { + return false; + } + } + + async getAccessToken( + provider: string, + oauthRef: OAuthRef, + options?: { readonly force?: boolean }, + ): Promise { + const tokenProvider = this.oauth.resolveTokenProvider(provider, oauthRef); + if (tokenProvider === undefined) throw loginRequired(provider); + const token = await tokenProvider.getAccessToken( + options?.force === true ? { force: true } : undefined, + ); + if (token.trim().length === 0) throw loginRequired(provider); + return token; + } +} + +function loginRequired(providerKey: string): Error2 { + return new Error2( + AuthErrors.codes.AUTH_LOGIN_REQUIRED, + `OAuth provider "${providerKey}" requires login before it can be used.`, + ); +} + +registerScopedService( +LifecycleScope.App, + IModelOAuthTokens, + ModelOAuthTokenAdapter, + InstantiationType.Eager, + 'kosongConfig', +); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 03a153a97..e46356dc1 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -83,7 +83,7 @@ export * from '#/session/sessionToolPolicy/sessionToolPolicy'; export * from '#/session/sessionToolPolicy/sessionToolPolicyService'; export * from '#/app/config/config'; export * from '#/app/config/configService'; -import '#/kosong/provider/configSection'; +import '#/app/kosongConfig/configSection'; export * from '#/kosong/provider/provider'; export * from '#/kosong/provider/providerService'; export * from '#/kosong/provider/providerDefinition'; @@ -94,9 +94,7 @@ export * from '#/kosong/protocol/errors'; export * from '#/kosong/protocol/protocol'; export * from '#/kosong/protocol/protocolBase'; export * from '#/kosong/protocol/protocolTrait'; -import '#/kosong/model/configSection'; -import '#/kosong/model/envOverlay'; -import '#/kosong/model/thinking'; +import '#/app/kosongConfig/envOverlay'; export * from '#/kosong/model/completionBudget'; export * from '#/kosong/model/hostRequestHeaders'; export * from '#/kosong/model/model'; @@ -107,13 +105,20 @@ export * from '#/kosong/model/catalog'; export * from '#/kosong/model/catalogService'; export * from '#/kosong/model/modelRequester'; import '#/kosong/model/errors'; -import '#/kosong/model/discoveryConfigSection'; // `ModelCatalogConfig` / `MODEL_CATALOG_SECTION` live in the configSection // side-effect module but the edge (kap-server's refresh scheduler) consumes // them from the package root — re-export here. -export * from '#/kosong/model/discoveryConfigSection'; -export * from '#/kosong/model/discovery'; -export * from '#/kosong/model/discoveryService'; +export { + MODEL_CATALOG_SECTION, + ModelCatalogConfigSchema, + type ModelCatalogConfig, +} from '#/app/kosongConfig/configSection'; +export * from '#/app/kosongConfig/kosongConfig'; +export * from '#/app/kosongConfig/kosongConfigService'; +export * from '#/kosong/model/modelOAuth'; +export * from '#/app/kosongConfig/oauthTokenAdapter'; +export * from '#/app/kosongConfig/discovery'; +export * from '#/app/kosongConfig/discoveryService'; // kosong wire composition roots — importing these modules registers the four // protocol bases and every provider definition (kimi + the canonical vendor // endpoints); without them the adapter registry stays empty. diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index 43b9ee5dd..f7b571ca4 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -18,6 +18,16 @@ import type { FinishReason } from './provider'; +/** + * Wire error code for invalid model/provider configuration (`config.invalid`). + * The code string is a wire contract matched downstream (protocol event + * schemas, clients), so it is declared here in the L0 contract; the error + * registry entry is owned by the `config` domain's error module, which + * registers this constant. Kosong code throws it without registering, keeping + * a single registry owner. + */ +export const CONFIG_INVALID_ERROR_CODE = 'config.invalid'; + export class ChatProviderError extends Error { constructor(message: string) { super(message); diff --git a/packages/agent-core-v2/src/kosong/model/catalog.ts b/packages/agent-core-v2/src/kosong/model/catalog.ts index 225729ddf..8f4cee97e 100644 --- a/packages/agent-core-v2/src/kosong/model/catalog.ts +++ b/packages/agent-core-v2/src/kosong/model/catalog.ts @@ -21,10 +21,9 @@ * Enumeration (`listModels` / `listProviders` / `getProvider`) projects the * SAME materialization `get` serves into the wire catalog shapes below, so * the management surface can never drift from what the runtime resolves. - * `setDefaultModel` writes the global default-model pointer - * (`DEFAULT_MODEL_SECTION`); it is the catalog's only write, validated - * against materialization so an unresolvable model can never become the - * default. + * `setDefaultModel` writes the global default-model pointer (through + * `IModelService`); it is the catalog's only write, validated against + * materialization so an unresolvable model can never become the default. * * The catalog caches assembled Models by id and invalidates on the * model/provider config-change events. Tests that mutate config @@ -272,8 +271,8 @@ export interface IModelCatalog { /** One provider by id; throws `provider.not_found` when unconfigured. */ getProvider(providerId: string): Promise; /** - * The catalog's only write: point the global default (`DEFAULT_MODEL_SECTION`) - * at `modelId`. Unknown ids throw `model.not_found`; ids that fail + * The catalog's only write: point the global default-model pointer at + * `modelId`. Unknown ids throw `model.not_found`; ids that fail * materialization are rejected with the materialization error. */ setDefaultModel(modelId: string): Promise; diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index 7921d00df..1018249fb 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -40,9 +40,9 @@ * config-only projection for models that fail to materialize, so broken * config stays visible); `listProviders` / `getProvider` project the * provider registry plus credential state. `setDefaultModel` writes the - * global default-model pointer (`DEFAULT_MODEL_SECTION`) after a + * global default-model pointer (through `IModelService`) after a * materialization gate — the catalog's only write. The remote-discovery - * refresh lives in `kosong/provider/discovery`, not here. + * refresh lives in `app/kosongConfig`, not here. */ import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; @@ -51,8 +51,6 @@ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; -import { IOAuthService } from '#/app/auth/auth'; -import { AuthErrors } from '#/app/auth/errors'; import type { ModelCapability } from '#/kosong/contract/capability'; import type { ProviderRequestAuth } from '#/kosong/contract/provider'; import type { TokenUsage } from '#/kosong/contract/usage'; @@ -63,15 +61,13 @@ import { type ProtocolProviderOptions, } from '#/kosong/protocol/protocol'; -import { IConfigService } from '../../app/config/config'; -import { ConfigErrors } from '../../app/config/errors'; +import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; import { LATEST_OPUS_PROFILE, matchKnownAnthropicModelProfile, matchUnknownClaudeProfile, } from '../provider/bases/anthropic/anthropic-profile'; import { - DEFAULT_PROVIDER_SECTION, IProviderService, type ProviderConfig, } from '../provider/provider'; @@ -105,13 +101,14 @@ import { ResolutionTraceCollector, TRACE, } from './inspection'; -import { DEFAULT_MODEL_SECTION, IModelService, type ModelRecord } from './model'; +import { IModelService, type ModelRecord } from './model'; import { deriveProviderId, effectiveModelConfig, nonEmpty, resolveModelAuthMaterial, } from './modelAuth'; +import { IModelOAuthTokens } from './modelOAuth'; import type { ResolvedModelAuthMaterial } from './model.types'; import type { ModelRequester } from './modelRequester'; import { ModelRequesterImpl } from './modelRequesterImpl'; @@ -134,10 +131,9 @@ export class ModelCatalog extends Disposable implements IModelCatalog { private readonly cache = new Map(); constructor( - @IConfigService private readonly config: IConfigService, @IProviderService private readonly providers: IProviderService, @IModelService private readonly models: IModelService, - @IOAuthService private readonly oauth: IOAuthService, + @IModelOAuthTokens private readonly oauth: IModelOAuthTokens, @IProtocolAdapterRegistry private readonly protocolRegistry: IProtocolAdapterRegistry, @IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders, @@ -248,7 +244,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { async listProviders(): Promise { const providers = this.providers.list(); const models = this.models.list(); - const globalDefaultModel = this.config.get(DEFAULT_MODEL_SECTION); + const globalDefaultModel = this.models.getDefaultModel(); const out: ProviderCatalogItem[] = []; for (const [providerId, provider] of Object.entries(providers)) { out.push(await this.toCatalogProvider(providerId, provider, models, globalDefaultModel)); @@ -265,7 +261,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { ); } const models = this.models.list(); - const globalDefaultModel = this.config.get(DEFAULT_MODEL_SECTION); + const globalDefaultModel = this.models.getDefaultModel(); return this.toCatalogProvider(providerId, provider, models, globalDefaultModel); } @@ -280,7 +276,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { // Materialization gate: a model that cannot resolve (dangling provider // reference, conflicting credentials, ...) must not become the default. const model = this.get(modelId); - await this.config.set(DEFAULT_MODEL_SECTION, modelId); + await this.models.setDefaultModel(modelId); return { default_model: modelId, model: toProtocolModel(model, record, this.providerTypeOf(record)), @@ -309,17 +305,12 @@ export class ModelCatalog extends Disposable implements IModelCatalog { private async hasCachedToken(providerId: string, provider: ProviderConfig): Promise { if (provider.oauth === undefined) return false; - try { - const token = await this.oauth.getCachedAccessToken(providerId, provider.oauth); - return nonEmpty(token) !== undefined; - } catch { - return false; - } + return this.oauth.hasCachedAccessToken(providerId, provider.oauth); } private providerTypeOf(record: ModelRecord): string | undefined { const providerId = - record.providerId ?? record.provider ?? this.config.get(DEFAULT_PROVIDER_SECTION); + record.providerId ?? record.provider ?? this.providers.getDefaultProvider(); return this.providers.get(providerId ?? '')?.type ?? record.protocol; } @@ -327,7 +318,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { const configuredModel = this.models.get(id); if (configuredModel === undefined) { throw new Error2( - ConfigErrors.codes.CONFIG_INVALID, + CONFIG_INVALID_ERROR_CODE, `Model "${id}" is not configured in config.toml.`, ); } @@ -376,13 +367,13 @@ export class ModelCatalog extends Disposable implements IModelCatalog { : rawBaseUrl; if (wireName === undefined) { throw new Error2( - ConfigErrors.codes.CONFIG_INVALID, + CONFIG_INVALID_ERROR_CODE, `Model "${id}" must define a wire-facing name in config.toml.`, ); } if (model.maxContextSize === undefined) { throw new Error2( - ConfigErrors.codes.CONFIG_INVALID, + CONFIG_INVALID_ERROR_CODE, `Model "${id}" must define a positive max_context_size in config.toml.`, ); } @@ -449,7 +440,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { readonly resolvedBaseUrl: string | undefined; } { const providerId = - model.providerId ?? model.provider ?? this.config.get('defaultProvider'); + model.providerId ?? model.provider ?? this.providers.getDefaultProvider(); if (providerId !== undefined) { trace.record('provider', { kind: 'config', @@ -464,7 +455,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { const providerConfig = this.providers.get(providerId); if (providerConfig === undefined) { throw new Error2( - ConfigErrors.codes.CONFIG_INVALID, + CONFIG_INVALID_ERROR_CODE, `Provider "${providerId}" referenced by model "${id}" is not configured.`, ); } @@ -505,7 +496,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { const modelBaseUrl = nonEmpty(model.baseUrl); if (modelBaseUrl === undefined) { throw new Error2( - ConfigErrors.codes.CONFIG_INVALID, + CONFIG_INVALID_ERROR_CODE, `Model "${id}" must set either providerId or baseUrl in config.toml.`, ); } @@ -559,7 +550,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { } } throw new Error2( - ConfigErrors.codes.CONFIG_INVALID, + CONFIG_INVALID_ERROR_CODE, `Model "${id}" must declare a wire protocol (config: models..protocol).`, ); } @@ -571,22 +562,13 @@ export class ModelCatalog extends Disposable implements IModelCatalog { if (auth.oauth !== undefined) { const oauthRef = auth.oauth; const providerKey = auth.oauthProviderKey ?? providerName; - const oauthService = this.oauth; - const loginRequired = (cause?: unknown): Error2 => - new Error2( - AuthErrors.codes.AUTH_LOGIN_REQUIRED, - `OAuth provider "${providerKey}" requires login before it can be used.`, - cause === undefined ? undefined : { cause }, - ); + const tokens = this.oauth; return { canRefresh: true, async getAuth(options): Promise { - const tokenProvider = oauthService.resolveTokenProvider(providerKey, oauthRef); - if (tokenProvider === undefined) throw loginRequired(); - const apiKey = await tokenProvider.getAccessToken( - options?.force === true ? { force: true } : undefined, - ); - if (apiKey.trim().length === 0) throw loginRequired(); + const apiKey = await tokens.getAccessToken(providerKey, oauthRef, { + force: options?.force === true, + }); return { apiKey }; }, }; diff --git a/packages/agent-core-v2/src/kosong/model/configSection.ts b/packages/agent-core-v2/src/kosong/model/configSection.ts deleted file mode 100644 index c94e98097..000000000 --- a/packages/agent-core-v2/src/kosong/model/configSection.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * `kosong/model` domain (L2) — `models` config-section TOML transforms. - * - * Snake_case ↔ camelCase transforms that preserve user-defined model ids - * (record keys) while converting each id's fields. Self-registered at module - * load via `registerConfigSection`, so the `config` domain never imports this - * domain's types. - * - * Side-effect module: production gets it from the `src/index.ts` - * side-effect block; tests import it on demand. This module is the sole - * owner of the section — the legacy `app/model/configSection` is gone. - * - * Note: the `app/config` imports below are deliberately RELATIVE paths — see - * `modelService.ts` for the rationale. - */ - -import { registerConfigSection } from '../../app/config/configSectionContributions'; -import { - camelToSnake, - cloneRecord, - isPlainObject, - setDefined, - transformPlainObject, -} from '../../app/config/toml'; - -import { MODELS_SECTION, ModelsSectionSchema } from './model'; - -export const modelsFromToml = (rawSnake: unknown): unknown => { - if (!isPlainObject(rawSnake)) return rawSnake; - const out: Record = {}; - for (const [id, entry] of Object.entries(rawSnake)) { - if (!isPlainObject(entry)) { - out[id] = entry; - continue; - } - const converted = transformPlainObject(entry); - if (isPlainObject(converted['overrides'])) { - converted['overrides'] = transformPlainObject(converted['overrides']); - } - out[id] = converted; - } - return out; -}; - -export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => { - if (!isPlainObject(value)) return value; - const rawSub = cloneRecord(rawSnake); - const out: Record = {}; - for (const [id, entry] of Object.entries(value)) { - if (!isPlainObject(entry)) { - out[id] = entry; - continue; - } - const rawEntry = cloneRecord(rawSub[id]); - const converted: Record = {}; - for (const [key, field] of Object.entries(entry)) { - if (key === 'capabilities' && Array.isArray(field)) { - converted[camelToSnake(key)] = [...field]; - } else if (key === 'overrides' && isPlainObject(field)) { - converted['overrides'] = modelOverridesToToml(field, rawEntry['overrides']); - } else { - setDefined(converted, camelToSnake(key), field); - } - } - out[id] = { ...rawEntry, ...converted }; - } - return out; -}; - -function modelOverridesToToml( - overrides: Record, - rawSnake: unknown, -): Record { - const out = cloneRecord(rawSnake); - for (const [key, value] of Object.entries(overrides)) { - if (key === 'capabilities' && Array.isArray(value)) { - out[camelToSnake(key)] = [...value]; - } else { - setDefined(out, camelToSnake(key), value); - } - } - return out; -} - -registerConfigSection(MODELS_SECTION, ModelsSectionSchema, { - defaultValue: {}, - fromToml: modelsFromToml, - toToml: modelsToToml, -}); diff --git a/packages/agent-core-v2/src/kosong/model/discoveryConfigSection.ts b/packages/agent-core-v2/src/kosong/model/discoveryConfigSection.ts deleted file mode 100644 index e899e87ab..000000000 --- a/packages/agent-core-v2/src/kosong/model/discoveryConfigSection.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * `kosong/model` domain (L2) — `modelCatalog` config-section schema. - * - * Owns the `[modelCatalog]` configuration section (provider-model catalog - * auto-refresh cadence). Self-registered at module load via - * `registerConfigSection`, mirroring the per-domain `configSection.ts` - * convention, so the `config` domain never imports this domain's types. - * - * Read by the kap-server model-catalog refresh scheduler to decide the - * refresh interval and whether to refresh once on start. Env vars - * (`KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS`, - * `KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START`) override these values at the - * scheduler edge. - * - * Side-effect module: production gets it from the `src/index.ts` - * side-effect block; tests import it on demand. This module is the sole - * owner of the section — the legacy `app/modelCatalog/configSection` is gone. - */ - -import { z } from 'zod'; - -import { registerConfigSection } from '../../app/config/configSectionContributions'; - -export const MODEL_CATALOG_SECTION = 'modelCatalog'; - -export const ModelCatalogConfigSchema = z.object({ - refreshIntervalMs: z.number().int().min(0).optional(), - refreshOnStart: z.boolean().optional(), -}); - -export type ModelCatalogConfig = z.infer; - -registerConfigSection(MODEL_CATALOG_SECTION, ModelCatalogConfigSchema); diff --git a/packages/agent-core-v2/src/kosong/model/model.ts b/packages/agent-core-v2/src/kosong/model/model.ts index 7e11d8390..dc545002b 100644 --- a/packages/agent-core-v2/src/kosong/model/model.ts +++ b/packages/agent-core-v2/src/kosong/model/model.ts @@ -1,9 +1,13 @@ /** * `kosong/model` domain (L2) — model configuration registry contract. * - * Owns the `ModelRecord` config record (id → resolution recipe) and the - * `models` config section; exposes CRUD and persists through `config`. App- - * scoped — model configuration is global and shared across sessions. + * Owns the `ModelRecord` config record type (id → resolution recipe) and the + * in-memory model registry contract. App-scoped — model configuration is + * global and shared across sessions. Kosong has no persistence — it defines + * types only: the `models` / `defaultModel` section constants, the zod + * schemas (compile-time pinned to these types), and the TOML transforms all + * live in the persistence wrapper (`app/kosongConfig/configSection`). + * Persisting mutations is the upper layer's job, not this domain's. * * Two configuration paths are supported: * - **Structured**: `providerId` references an entry in `[providers.*]`. @@ -25,74 +29,70 @@ * `type`, never as a protocol). */ -import { z } from 'zod'; - import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event } from '#/_base/event'; -import { ProtocolSchema } from '#/kosong/protocol/protocol'; +import type { Event, IWaitUntil } from '#/_base/event'; +import type { Protocol } from '#/kosong/protocol/protocol'; -import { OAuthRefSchema } from '../provider/provider'; - -export const MODELS_SECTION = 'models'; +import type { OAuthRef } from '../provider/provider'; /** - * The global default-model pointer: a single model id from `[models.*]` used - * whenever a call site does not name a model explicitly. Cross-domain by - * nature — written by `IModelCatalog.setDefaultModel` and the OAuth login / - * refresh flows (`app/auth`), read by runtime resolution fallbacks. The sole - * owner of the key constant lives here; every consumer imports it. + * The per-model `overrides` sub-record: the tunable subset of the base + * fields, all optional (runtime-fetched catalog metadata may refine the + * configured record without touching identity/auth fields). */ -export const DEFAULT_MODEL_SECTION = 'defaultModel'; +export interface ModelOverride { + maxContextSize?: number; + maxInputSize?: number; + maxOutputSize?: number; + capabilities?: string[]; + displayName?: string; + reasoningKey?: string; + adaptiveThinking?: boolean; + supportEfforts?: string[]; + defaultEffort?: string; + offEffort?: string; +} -const ModelBaseSchema = z.object({ - providerId: z.string().optional(), +/** + * The persisted section schema is a passthrough object: unknown fields + * survive parsing (lossless config round-trip), so the type carries the + * catch-all index signature too. Declared as a single flat interface (not a + * `Fields & { [key: string]: unknown }` intersection) so the compile-time + * schema ≡ type pin in `app/kosongConfig/configSection` compares equal + * to zod's flattened passthrough inference. + */ +export interface ModelRecord { + providerId?: string; - baseUrl: z.string().optional(), - apiKey: z.string().optional(), - oauth: OAuthRefSchema.optional(), + baseUrl?: string; + apiKey?: string; + oauth?: OAuthRef; - protocol: ProtocolSchema.optional(), + protocol?: Protocol; - name: z.string().optional(), - aliases: z.array(z.string()).optional(), + name?: string; + aliases?: string[]; - provider: z.string().optional(), - model: z.string().optional(), - maxContextSize: z.number().int().min(1).optional(), - maxInputSize: z.number().int().min(1).optional(), - maxOutputSize: z.number().int().min(1).optional(), - capabilities: z.array(z.string()).optional(), - displayName: z.string().optional(), - reasoningKey: z.string().optional(), - adaptiveThinking: z.boolean().optional(), - betaApi: z.boolean().optional(), - supportEfforts: z.array(z.string()).optional(), - defaultEffort: z.string().optional(), - offEffort: z.string().optional(), -}); + provider?: string; + model?: string; + maxContextSize?: number; + maxInputSize?: number; + maxOutputSize?: number; + capabilities?: string[]; + displayName?: string; + reasoningKey?: string; + adaptiveThinking?: boolean; + betaApi?: boolean; + supportEfforts?: string[]; + defaultEffort?: string; + offEffort?: string; -export const ModelOverrideSchema = ModelBaseSchema.omit({ - providerId: true, - baseUrl: true, - apiKey: true, - oauth: true, - protocol: true, - name: true, - aliases: true, - provider: true, - model: true, - betaApi: true, -}).partial(); + overrides?: ModelOverride; -export const ModelRecordSchema = ModelBaseSchema.extend({ - overrides: ModelOverrideSchema.optional(), -}).passthrough(); + [key: string]: unknown; +} -export type ModelRecord = z.infer; - -export const ModelsSectionSchema = z.record(z.string(), ModelRecordSchema); - -export type ModelsSection = z.infer; +export type ModelsSection = Record; export interface ModelsChangedEvent { readonly added: readonly string[]; @@ -100,14 +100,46 @@ export interface ModelsChangedEvent { readonly changed: readonly string[]; } +export interface DefaultModelChangedEvent { + readonly id: string | undefined; +} + +/** + * The in-memory model registry. Kosong owns the state and the change events; + * persistence is the upper layer's concern (a bridge service hydrates the + * registry via `loadAll` and subscribes to the change events to persist + * them), so this domain never touches config storage itself. + * + * Mutations (`set` / `delete` / `replaceAll` / `setDefaultModel`) wait for + * hydration (`ready`) so a caller can never race the initial load, and resolve + * only after every change listener has finished the work it registered through + * `waitUntil` — the persistence bridge participates this way, so an awaited + * mutation means the write has also been persisted. Writes that land an equal + * value are silent — no event fires — which is what makes the persistence + * bridge's two-way sync terminate. + */ export interface IModelService { readonly _serviceBrand: undefined; - readonly onDidChangeModels: Event; + /** Resolves when the registry has been hydrated (the first `loadAll`). */ + readonly ready: Promise; + readonly onDidChangeModels: Event; + /** Fires when the default-model pointer changes value (incl. clearing). */ + readonly onDidChangeDefaultModel: Event; get(id: string): ModelRecord | undefined; list(): Readonly>; + getDefaultModel(): string | undefined; set(id: string, model: ModelRecord): Promise; delete(id: string): Promise; + /** + * Bulk hydration by the persistence owner: replaces the whole registry and + * the default-model pointer. The first call resolves `ready`; later calls + * behave like a deep-equal-aware sync (only real diffs fire events). + */ + loadAll(models: ModelsSection, defaultModel: string | undefined): void; + /** Replaces every model record; the default-model pointer is kept. */ + replaceAll(models: ModelsSection): Promise; + setDefaultModel(id: string | undefined): Promise; } // The decorator name matches the deleted legacy `app/model` contract diff --git a/packages/agent-core-v2/src/kosong/model/modelAuth.ts b/packages/agent-core-v2/src/kosong/model/modelAuth.ts index 6ce82b6c4..835ea9ce2 100644 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ b/packages/agent-core-v2/src/kosong/model/modelAuth.ts @@ -21,9 +21,9 @@ */ import { Error2 } from '#/_base/errors/errors'; +import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; import type { ResolutionTrace } from '#/kosong/contract/inspection'; -import { ConfigErrors } from '../../app/config/errors'; import { BUDGET_THINKING_EFFORTS, matchKnownAnthropicModelProfile, @@ -171,7 +171,7 @@ export function nonEmpty(value: string | undefined): string | undefined { function authConflictError(kind: string, name: string): Error2 { return new Error2( - ConfigErrors.codes.CONFIG_INVALID, + CONFIG_INVALID_ERROR_CODE, `${kind} "${name}" has both apiKey and oauth set in config.toml - they are mutually exclusive. Remove one.`, ); } diff --git a/packages/agent-core-v2/src/kosong/model/modelOAuth.ts b/packages/agent-core-v2/src/kosong/model/modelOAuth.ts new file mode 100644 index 000000000..0d7d954b4 --- /dev/null +++ b/packages/agent-core-v2/src/kosong/model/modelOAuth.ts @@ -0,0 +1,33 @@ +/** + * `kosong/model` domain (L2) — the OAuth token port. + * + * Kosong needs OAuth tokens at model-assembly time: probing the cached + * credential state (catalog listings) and building the refreshable request + * auth closure. The port is owned here so kosong stays free of the + * `app/auth` service; the implementation lives in the upper layer + * (`app/kosongConfig/oauthTokenAdapter.ts`), which delegates to + * `IOAuthService` and owns the `auth.login_required` error contract. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { OAuthRef } from '../provider/provider'; + +export interface IModelOAuthTokens { + readonly _serviceBrand: undefined; + + /** Probes whether a usable cached token exists (never throws). */ + hasCachedAccessToken(provider: string, oauthRef: OAuthRef): Promise; + /** + * Returns a usable access token, refreshing when needed. Throws + * `auth.login_required` when the provider is not logged in. + */ + getAccessToken( + provider: string, + oauthRef: OAuthRef, + options?: { readonly force?: boolean }, + ): Promise; +} + +export const IModelOAuthTokens: ServiceIdentifier = + createDecorator('modelOAuthTokens'); diff --git a/packages/agent-core-v2/src/kosong/model/modelService.ts b/packages/agent-core-v2/src/kosong/model/modelService.ts index 7aebfe3e8..c3e1ca221 100644 --- a/packages/agent-core-v2/src/kosong/model/modelService.ts +++ b/packages/agent-core-v2/src/kosong/model/modelService.ts @@ -1,70 +1,112 @@ /** * `kosong/model` domain (L2) — `IModelService` implementation. * - * Owns the in-memory view of the `models` config section, persists changes - * through `config`, and forwards section changes as `onDidChangeModels` - * (computed with the shared `sectionDiff.diffRecords`). The section schema - * self-registers at module load via `configSection.ts`, and the `KIMI_MODEL_*` - * effective overlay self-registers via `envOverlay.ts`. Bound at App scope. - * - * Note: the `app/config` imports below are deliberately RELATIVE paths — this - * L2 domain may depend on the L2 config domain, and keeping the dependency - * off the `#/` alias makes it visible at a glance. + * The in-memory model registry plus the default-model pointer. Holds no + * config dependency: the persistence bridge hydrates it via `loadAll` and + * persists the change events it fires. Bound at App scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { Emitter, type Event } from '#/_base/event'; +import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event'; + +import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff'; -import { IConfigService } from '../../app/config/config'; -import { diffRecords } from '../../app/config/sectionDiff'; import { + type DefaultModelChangedEvent, IModelService, - MODELS_SECTION, type ModelRecord, type ModelsChangedEvent, type ModelsSection, } from './model'; +/** Registry mutations are not abortable; `fireAsync` still requires a signal. */ +const NO_ABORT = new AbortController().signal; + export class ModelService extends Disposable implements IModelService { declare readonly _serviceBrand: undefined; - private readonly _onDidChangeModels = this._register(new Emitter()); - readonly onDidChangeModels: Event = this._onDidChangeModels.event; - constructor(@IConfigService private readonly config: IConfigService) { - super(); - this._register( - config.onDidChangeConfiguration((e) => { - if (e.domain === MODELS_SECTION) { - this._onDidChangeModels.fire( - diffRecords( - e.previousValue as ModelsSection | undefined, - e.value as ModelsSection | undefined, - ), - ); - } - }), - ); - } + private models: Readonly> = {}; + private defaultModel: string | undefined; + private hydrated = false; + private resolveReady!: () => void; + readonly ready: Promise = new Promise((resolve) => { + this.resolveReady = resolve; + }); + + private readonly _onDidChangeModels = this._register( + new AsyncEmitter(), + ); + readonly onDidChangeModels: Event = + this._onDidChangeModels.event; + private readonly _onDidChangeDefaultModel = this._register( + new AsyncEmitter(), + ); + readonly onDidChangeDefaultModel: Event = + this._onDidChangeDefaultModel.event; get(id: string): ModelRecord | undefined { - return this.config.get(MODELS_SECTION)?.[id]; + return this.models[id]; } list(): Readonly> { - return this.config.get(MODELS_SECTION) ?? {}; + return this.models; + } + + getDefaultModel(): string | undefined { + return this.defaultModel; + } + + loadAll(models: ModelsSection, defaultModel: string | undefined): void { + // Fire-and-forget on purpose: hydration has no persistence participant + // yet, and `fireAsync` still invokes every listener synchronously up to + // its own first await, so config-originated syncs keep their timing. + void this.applyRecords(models); + void this.applyDefaultModel(defaultModel); + if (!this.hydrated) { + this.hydrated = true; + this.resolveReady(); + } + } + + async replaceAll(models: ModelsSection): Promise { + await this.ready; + await this.applyRecords(models); } async set(id: string, model: ModelRecord): Promise { - await this.config.set(MODELS_SECTION, { [id]: model }); + await this.ready; + if (deepEqual(this.models[id], model)) return; + await this.applyRecords({ ...this.models, [id]: model }); } async delete(id: string): Promise { - const current = this.config.get(MODELS_SECTION) ?? {}; - if (!(id in current)) return; - const { [id]: _removed, ...rest } = current; - await this.config.replace(MODELS_SECTION, rest); + await this.ready; + if (!(id in this.models)) return; + const { [id]: _removed, ...rest } = this.models; + await this.applyRecords(rest); + } + + async setDefaultModel(id: string | undefined): Promise { + await this.ready; + await this.applyDefaultModel(id); + } + + private async applyRecords(next: Readonly>): Promise { + const diff = diffRecords(this.models, next); + if (isEmptyDiff(diff)) return; + this.models = { ...next }; + // Awaiting delivery is what lets a mutation's caller rely on the + // persistence bridge's `waitUntil` participation: the returned promise + // only settles once the write has reached the config layer. + await this._onDidChangeModels.fireAsync(diff, NO_ABORT); + } + + private async applyDefaultModel(id: string | undefined): Promise { + if (this.defaultModel === id) return; + this.defaultModel = id; + await this._onDidChangeDefaultModel.fireAsync({ id }, NO_ABORT); } } diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 341d5b8c2..58949eae3 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -3,12 +3,11 @@ * * Three kinds of knowledge live here, and nowhere else: * - * 1. The `thinking` config section (`[thinking]`: enabled / effort / keep, - * plus the env-only `KIMI_MODEL_THINKING_EFFORT` force override). The - * section self-registers at module load — a side effect; production gets - * it from the `src/index.ts` side-effect block and tests import this - * module on demand. This module is the sole owner of the section — the - * legacy `agent/profile/configSection` is gone. + * 1. The `thinking` config-section type (`[thinking]`: enabled / effort / + * keep, plus the env-only `forcedEffort` field). Kosong owns only the + * type; the section constant, zod schema (compile-time pinned to the + * type), registration, env binding, and write-path strip all live in the + * persistence wrapper (`app/kosongConfig/configSection`). * 2. Effort/keep resolution: pure helpers that fold a requested effort, the * config defaults, and the model's declared thinking metadata into the * effective `ThinkingEffort`, and that resolve the thinking-keep value. @@ -32,46 +31,23 @@ * `kimiAnthropicTrait`. */ -import { z } from 'zod'; - import type { ThinkingEffort } from '#/kosong/contract/provider'; import type { IProtocolAdapterRegistry, Protocol } from '#/kosong/protocol/protocol'; -import { type ConfigStripEnv, envBindings } from '../../app/config/config'; -import { registerConfigSection } from '../../app/config/configSectionContributions'; import { getProviderDefinitions } from '../provider/providerDefinition'; import type { ModelThinkingMetadata, ThinkingDefaults } from './model.types'; // --------------------------------------------------------------------------- -// `thinking` config section (side-effect registration) +// `thinking` config-section type (constant + schema live in `app/kosongConfig`) // --------------------------------------------------------------------------- -export const THINKING_SECTION = 'thinking'; - -export const ThinkingConfigSchema = z.object({ - enabled: z.boolean().optional(), - effort: z.string().optional(), - forcedEffort: z.string().optional(), - keep: z.string().optional(), -}); - -export type ThinkingConfig = z.infer; - -export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, { - forcedEffort: 'KIMI_MODEL_THINKING_EFFORT', -}); - -export const stripThinkingEnv: ConfigStripEnv = (value) => { - const result = { ...value }; - delete result.forcedEffort; - return result; -}; - -registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { - env: thinkingEnvBindings, - stripEnv: stripThinkingEnv, -}); +export interface ThinkingConfig { + enabled?: boolean; + effort?: string; + forcedEffort?: string; + keep?: string; +} // --------------------------------------------------------------------------- // Registry-driven vendor verdicts diff --git a/packages/agent-core-v2/src/kosong/provider/configSection.ts b/packages/agent-core-v2/src/kosong/provider/configSection.ts deleted file mode 100644 index 8e87fffc9..000000000 --- a/packages/agent-core-v2/src/kosong/provider/configSection.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * `kosong/provider` domain (L2) — `providers` config-section schema, env - * bindings, and TOML transforms. - * - * Owns the `[providers.]` configuration section: its schema (with the - * free-form `type` field), the `KIMI_MODEL_PROVIDER_TYPE` / - * `KIMI_MODEL_API_KEY` / `KIMI_MODEL_BASE_URL` environment bindings that - * synthesize the reserved `__kimi_env__` provider entry, and the snake_case ↔ - * camelCase TOML transforms (including the nested `oauth` / `env` / - * `custom_headers` normalization). Self-registered at module load via - * `registerConfigSection`, so the `config` domain never imports this domain's - * types. - * - * Side-effect module: production gets it from the `src/index.ts` - * side-effect block; tests import it on demand. This module is the sole - * owner of the section — the legacy `app/provider/configSection` is gone. - * - * Note: the `app/config` imports below are deliberately RELATIVE paths — see - * `providerService.ts` for the rationale. - */ - -import { type ConfigStripEnv, envBindings } from '../../app/config/config'; -import { registerConfigSection } from '../../app/config/configSectionContributions'; -import { - camelToSnake, - cloneRecord, - isPlainObject, - plainObjectToToml, - setDefined, - snakeToCamel, - transformPlainObject, -} from '../../app/config/toml'; -import { - ENV_MODEL_PROVIDER_KEY, - PROVIDERS_SECTION, - ProviderConfigSchema, - ProvidersSectionSchema, -} from './provider'; - -export const providersEnvBindings = envBindings(ProvidersSectionSchema, { - [ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, { - apiKey: 'KIMI_MODEL_API_KEY', - type: 'KIMI_MODEL_PROVIDER_TYPE', - baseUrl: 'KIMI_MODEL_BASE_URL', - }), -}); - -export const stripProvidersEnv: ConfigStripEnv> = (value) => { - if (value === undefined || value === null || typeof value !== 'object') return value; - if (!(ENV_MODEL_PROVIDER_KEY in value)) return value; - const out = { ...value }; - delete out[ENV_MODEL_PROVIDER_KEY]; - return out; -}; - -export const providersFromToml = (rawSnake: unknown): unknown => { - if (!isPlainObject(rawSnake)) return rawSnake; - const out: Record = {}; - for (const [name, entry] of Object.entries(rawSnake)) { - out[name] = isPlainObject(entry) ? providerEntryFromToml(entry) : entry; - } - return out; -}; - -function providerEntryFromToml(data: Record): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(data)) { - const targetKey = snakeToCamel(key); - if (targetKey === 'oauth') { - out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value; - } else if (targetKey === 'env' || targetKey === 'customHeaders') { - out[targetKey] = isPlainObject(value) ? cloneRecord(value) : value; - } else { - out[targetKey] = value; - } - } - return out; -} - -export const providersToToml = (value: unknown, rawSnake: unknown): unknown => { - if (!isPlainObject(value)) return value; - const rawSub = cloneRecord(rawSnake); - const out: Record = {}; - for (const [name, entry] of Object.entries(value)) { - out[name] = isPlainObject(entry) ? providerEntryToToml(entry, rawSub[name]) : entry; - } - return out; -}; - -function providerEntryToToml( - provider: Record, - rawProvider: unknown, -): Record { - const out = cloneRecord(rawProvider); - for (const [key, value] of Object.entries(provider)) { - if (key === 'oauth' && isPlainObject(value)) { - out[camelToSnake(key)] = plainObjectToToml(value, undefined); - } else if ((key === 'env' || key === 'customHeaders') && value !== undefined) { - out[camelToSnake(key)] = cloneRecord(value); - } else { - setDefined(out, camelToSnake(key), value); - } - } - return out; -} - -registerConfigSection(PROVIDERS_SECTION, ProvidersSectionSchema, { - defaultValue: {}, - env: providersEnvBindings, - stripEnv: stripProvidersEnv, - fromToml: providersFromToml, - toToml: providersToToml, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/provider.ts b/packages/agent-core-v2/src/kosong/provider/provider.ts index 4cbe5f242..ba68d9b11 100644 --- a/packages/agent-core-v2/src/kosong/provider/provider.ts +++ b/packages/agent-core-v2/src/kosong/provider/provider.ts @@ -7,68 +7,54 @@ * serves (static list from `[models.*]`, `/v1/models` discovery, or an * OAuth-managed catalog). * - * `ProviderTypeSchema` is deliberately free-form text: vendor identity is NOT - * enumerated at parse time. Validation happens at resolve time against the - * provider-definition registry (`getProviderDefinition`), which is what + * `ProviderType` is deliberately free-form text: vendor identity is NOT + * enumerated at the type level. Validation happens at resolve time against + * the provider-definition registry (`getProviderDefinition`), which is what * allows external packages to register new vendors without touching this - * schema. + * contract. * - * Owns the `ProviderConfig` / `OAuthRef` models and the `providers` config - * section; App-scoped. Higher-level services (auth, model catalog, CLI, UI) - * mutate providers through this domain instead of writing config directly. + * Owns the `ProviderConfig` / `OAuthRef` types and the in-memory provider + * registry contract; App-scoped. Kosong has no persistence — it defines + * types only: the `providers` section constant, its zod schema + * (compile-time pinned to these types), the env bindings, and the TOML + * transforms all live in the persistence wrapper + * (`app/kosongConfig/configSection`). Higher-level services (auth, + * model catalog, CLI, UI) mutate providers through this domain; persisting + * those mutations is the upper layer's job. */ -import { z } from 'zod'; - import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event } from '#/_base/event'; +import type { Event, IWaitUntil } from '#/_base/event'; /** * Free-form vendor identity (e.g. `'kimi'`). Not an enum, by design — see the * module header. */ -export const ProviderTypeSchema = z.string(); +export type ProviderType = string; -export type ProviderType = z.infer; +export interface OAuthRef { + storage: 'file' | 'keyring'; + key: string; + oauthHost?: string; +} -export const OAuthRefSchema = z.object({ - storage: z.enum(['file', 'keyring']), - key: z.string().min(1), - oauthHost: z.string().min(1).optional(), -}); +export type ModelSource = 'static' | 'discover' | 'oauth-catalog'; -export type OAuthRef = z.infer; +export interface ProviderConfig { + modelSource?: ModelSource; -const StringRecordSchema = z.record(z.string(), z.string()); + baseUrl?: string; + customHeaders?: Record; + defaultModel?: string; -export const ModelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']); -export type ModelSource = z.infer; + type?: ProviderType; + apiKey?: string; + oauth?: OAuthRef; + env?: Record; + source?: Record; +} -export const ProviderConfigSchema = z.object({ - modelSource: ModelSourceSchema.optional(), - - baseUrl: z.string().optional(), - customHeaders: StringRecordSchema.optional(), - defaultModel: z.string().optional(), - - type: ProviderTypeSchema.optional(), - apiKey: z.string().optional(), - oauth: OAuthRefSchema.optional(), - env: StringRecordSchema.optional(), - source: z.record(z.string(), z.unknown()).optional(), -}); - -export type ProviderConfig = z.infer; - -export const PROVIDERS_SECTION = 'providers'; - -export const DEFAULT_PROVIDER_SECTION = 'defaultProvider'; - -export const ENV_MODEL_PROVIDER_KEY = '__kimi_env__'; - -export const ProvidersSectionSchema = z.record(z.string(), ProviderConfigSchema); - -export type ProvidersSection = z.infer; +export type ProvidersSection = Record; export interface ProvidersChangedEvent { readonly added: readonly string[]; @@ -76,15 +62,46 @@ export interface ProvidersChangedEvent { readonly changed: readonly string[]; } +export interface DefaultProviderChangedEvent { + readonly id: string | undefined; +} + +/** + * The in-memory provider registry. Kosong owns the state and the change + * events; persistence is the upper layer's concern (a bridge service hydrates + * the registry via `loadAll` and subscribes to the change events to persist + * them), so this domain never touches config storage itself. + * + * Mutations (`set` / `delete` / `replaceAll` / `setDefaultProvider`) wait for + * hydration (`ready`) so a caller can never race the initial load, and resolve + * only after every change listener has finished the work it registered through + * `waitUntil` — the persistence bridge participates this way, so an awaited + * mutation means the write has also been persisted. Writes that land an equal + * value are silent — no event fires — which is what makes the persistence + * bridge's two-way sync terminate. + */ export interface IProviderService { readonly _serviceBrand: undefined; + /** Resolves when the registry has been hydrated (the first `loadAll`). */ readonly ready: Promise; - readonly onDidChangeProviders: Event; + readonly onDidChangeProviders: Event; + /** Fires when the default-provider pointer changes value (incl. clearing). */ + readonly onDidChangeDefaultProvider: Event; get(name: string): ProviderConfig | undefined; list(): Readonly>; + getDefaultProvider(): string | undefined; set(name: string, config: ProviderConfig): Promise; delete(name: string): Promise; + /** + * Bulk hydration by the persistence owner: replaces the whole registry and + * the default-provider pointer. The first call resolves `ready`; later + * calls behave like a deep-equal-aware sync (only real diffs fire events). + */ + loadAll(providers: ProvidersSection, defaultProvider: string | undefined): void; + /** Replaces every provider record; the default-provider pointer is kept. */ + replaceAll(providers: ProvidersSection): Promise; + setDefaultProvider(id: string | undefined): Promise; } export const IProviderService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/kosong/provider/providerService.ts b/packages/agent-core-v2/src/kosong/provider/providerService.ts index 8b38adaea..bf8a29e15 100644 --- a/packages/agent-core-v2/src/kosong/provider/providerService.ts +++ b/packages/agent-core-v2/src/kosong/provider/providerService.ts @@ -1,79 +1,118 @@ /** * `kosong/provider` domain (L2) — `IProviderService` implementation. * - * Owns the in-memory view of the `providers` config section, persists changes - * through `config`, and forwards section changes as `onDidChangeProviders` - * (computed with the shared `sectionDiff.diffRecords`). The section schema - * self-registers at module load via `configSection.ts`. Bound at App scope. - * - * Note: the `app/config` imports below are deliberately RELATIVE paths — this - * L2 domain may depend on the L2 config domain, and keeping the dependency - * off the `#/` alias makes it visible at a glance. + * The in-memory provider registry plus the default-provider pointer. Holds no + * config dependency: the persistence bridge hydrates it via `loadAll` and + * persists the change events it fires. Bound at App scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { Emitter, type Event } from '#/_base/event'; +import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event'; + +import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff'; -import { IConfigService } from '../../app/config/config'; -import { diffRecords } from '../../app/config/sectionDiff'; import { - DEFAULT_PROVIDER_SECTION, + type DefaultProviderChangedEvent, type ProviderConfig, type ProvidersChangedEvent, type ProvidersSection, IProviderService, - PROVIDERS_SECTION, } from './provider'; +/** Registry mutations are not abortable; `fireAsync` still requires a signal. */ +const NO_ABORT = new AbortController().signal; + export class ProviderService extends Disposable implements IProviderService { declare readonly _serviceBrand: undefined; - readonly ready: Promise; - private readonly _onDidChangeProviders = this._register(new Emitter()); - readonly onDidChangeProviders: Event = this._onDidChangeProviders.event; - constructor(@IConfigService private readonly config: IConfigService) { - super(); - this.ready = config.ready; - this._register( - config.onDidChangeConfiguration((e) => { - if (e.domain === PROVIDERS_SECTION) { - this._onDidChangeProviders.fire( - diffRecords( - e.previousValue as ProvidersSection | undefined, - e.value as ProvidersSection | undefined, - ), - ); - } - }), - ); - } + private providers: Readonly> = {}; + private defaultProvider: string | undefined; + private hydrated = false; + private resolveReady!: () => void; + readonly ready: Promise = new Promise((resolve) => { + this.resolveReady = resolve; + }); + + private readonly _onDidChangeProviders = this._register( + new AsyncEmitter(), + ); + readonly onDidChangeProviders: Event = + this._onDidChangeProviders.event; + private readonly _onDidChangeDefaultProvider = this._register( + new AsyncEmitter(), + ); + readonly onDidChangeDefaultProvider: Event = + this._onDidChangeDefaultProvider.event; get(name: string): ProviderConfig | undefined { - return this.config.get(PROVIDERS_SECTION)?.[name]; + return this.providers[name]; } list(): Readonly> { - return this.config.get(PROVIDERS_SECTION) ?? {}; + return this.providers; + } + + getDefaultProvider(): string | undefined { + return this.defaultProvider; + } + + loadAll(providers: ProvidersSection, defaultProvider: string | undefined): void { + // Fire-and-forget on purpose: hydration has no persistence participant + // yet, and `fireAsync` still invokes every listener synchronously up to + // its own first await, so config-originated syncs keep their timing. + void this.applyRecords(providers); + void this.applyDefaultProvider(defaultProvider); + if (!this.hydrated) { + this.hydrated = true; + this.resolveReady(); + } + } + + async replaceAll(providers: ProvidersSection): Promise { + await this.ready; + await this.applyRecords(providers); } async set(name: string, config: ProviderConfig): Promise { - await this.config.set(PROVIDERS_SECTION, { [name]: config }); + await this.ready; + if (deepEqual(this.providers[name], config)) return; + await this.applyRecords({ ...this.providers, [name]: config }); } async delete(name: string): Promise { - const current = this.config.get(PROVIDERS_SECTION) ?? {}; - if (!(name in current)) return; - const { [name]: _removed, ...rest } = current; - await this.config.replace(PROVIDERS_SECTION, rest); - if (this.config.get(DEFAULT_PROVIDER_SECTION) === name) { - // Delete, not merge: `set(domain, undefined)` resolves back to the base - // value by design — only `replace(domain, undefined)` removes the key, - // otherwise the default-provider id dangles to a deleted provider. - await this.config.replace(DEFAULT_PROVIDER_SECTION, undefined); + await this.ready; + if (!(name in this.providers)) return; + const { [name]: _removed, ...rest } = this.providers; + await this.applyRecords(rest); + // Deleting the provider the default pointer targets must clear the + // pointer too, otherwise it dangles to a deleted provider. + if (this.defaultProvider === name) { + await this.applyDefaultProvider(undefined); } } + + async setDefaultProvider(id: string | undefined): Promise { + await this.ready; + await this.applyDefaultProvider(id); + } + + private async applyRecords(next: Readonly>): Promise { + const diff = diffRecords(this.providers, next); + if (isEmptyDiff(diff)) return; + this.providers = { ...next }; + // Awaiting delivery is what lets a mutation's caller rely on the + // persistence bridge's `waitUntil` participation: the returned promise + // only settles once the write has reached the config layer. + await this._onDidChangeProviders.fireAsync(diff, NO_ABORT); + } + + private async applyDefaultProvider(id: string | undefined): Promise { + if (this.defaultProvider === id) return; + this.defaultProvider = id; + await this._onDidChangeDefaultProvider.fireAsync({ id }, NO_ABORT); + } } registerScopedService( diff --git a/packages/agent-core-v2/src/kosong/recordDiff.ts b/packages/agent-core-v2/src/kosong/recordDiff.ts new file mode 100644 index 000000000..d6ff24f2e --- /dev/null +++ b/packages/agent-core-v2/src/kosong/recordDiff.ts @@ -0,0 +1,59 @@ +/** + * kosong internal — record-level diffing for the provider/model registries. + * + * `diffRecords` computes the added/removed/changed keys between two snapshots + * of a record-shaped registry state, `deepEqual` is the value comparison it + * uses. Pure functions; the registries use them to keep change events quiet + * when a write lands an equal value (which is also what makes the + * persistence bridge's two-way sync terminate). + */ + +export interface RecordDiff { + readonly added: readonly string[]; + readonly removed: readonly string[]; + readonly changed: readonly string[]; +} + +export function isEmptyDiff(diff: RecordDiff): boolean { + return diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0; +} + +export function diffRecords( + previous: Readonly> | undefined, + current: Readonly> | undefined, +): RecordDiff { + const prev = previous ?? {}; + const curr = current ?? {}; + const added: string[] = []; + const removed: string[] = []; + const changed: string[] = []; + for (const key of Object.keys(curr)) { + if (!(key in prev)) { + added.push(key); + } else if (!deepEqual(prev[key], curr[key])) { + changed.push(key); + } + } + for (const key of Object.keys(prev)) { + if (!(key in curr)) { + removed.push(key); + } + } + return { added, removed, changed }; +} + +export function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false; + } + } + return true; +} diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index a3586b77a..d74d73dbd 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -39,7 +39,7 @@ import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } fro import { appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent } from '../../harness'; import { IAgentFullCompactionService, - IOAuthService, + IModelOAuthTokens, IAgentProfileService, IAgentToolRegistryService, ISessionTodoService, @@ -2935,9 +2935,15 @@ function oauthTestAgentOptions( }, }, services: appServices((reg) => { - reg.definePartialInstance(IOAuthService, { - resolveTokenProvider: () => ({ getAccessToken }), - }); + // The catalog's OAuth port is `IModelOAuthTokens` (the app/kosongConfig + // adapter delegates it to IOAuthService in production); stub the port + // directly, mirroring the adapter's force-flag normalization. + reg.defineInstance(IModelOAuthTokens, { + _serviceBrand: undefined, + hasCachedAccessToken: () => Promise.resolve(true), + getAccessToken: (_provider, _oauthRef, options) => + getAccessToken(options?.force === true ? { force: true } : undefined), + } satisfies IModelOAuthTokens); }), }; } diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index a91ddfb3d..6afd2e982 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -48,6 +48,7 @@ import type { Message } from '#/kosong/contract/message'; import type { ThinkingEffort } from '#/kosong/contract/provider'; import type { ModelCapability } from '#/kosong/contract/capability'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; import { type ModelRequestEvent, type ModelRequestInput, @@ -223,6 +224,9 @@ function createService( getRequester: () => requester, findByName: () => [], }); + ix.stub(IModelService, { + get: () => undefined, + }); const records: WireRecord[] = []; registerTestAgentWire(ix, 'wire/llm-requester', { log: recordingWireLog(records), 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 6ae149d54..d0770af6f 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -33,7 +33,8 @@ 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 { MODELS_SECTION, type ModelRecord } from '#/kosong/model/model'; +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'; // Side-effect registration: the OAuth-catalog verdict @@ -162,7 +163,7 @@ describe('OAuthService', () => { get: ((name: string) => providers[name]) as IProviderService['get'], list: (() => providers) as IProviderService['list'], set: providerSet as unknown as IProviderService['set'], - onDidChangeProviders: providerChangedEmitter.event, + onDidChangeProviders: providerChangedEmitter.event as IProviderService['onDidChangeProviders'], }); reg.definePartialInstance(IConfigService, { get: ((domain: string) => configBacking()[domain]) as IConfigService['get'], @@ -1141,6 +1142,11 @@ describe('AuthSummaryService', () => { get: ((name: string) => providers[name]) as IProviderService['get'], list: (() => providers) as IProviderService['list'], }); + reg.definePartialInstance(IModelService, { + get: ((id: string) => models[id]) as IModelService['get'], + list: (() => models) as IModelService['list'], + getDefaultModel: (() => defaultModel) as IModelService['getDefaultModel'], + }); reg.definePartialInstance(IConfigService, { get: ((domain: string) => { if (domain === MODELS_SECTION) return models; @@ -1278,6 +1284,10 @@ describe('AuthLegacyService', () => { reg.definePartialInstance(IProviderService, { list: (() => providers) as IProviderService['list'], }); + reg.definePartialInstance(IModelService, { + ready: Promise.resolve(), + getDefaultModel: (() => defaultModel) as IModelService['getDefaultModel'], + }); reg.definePartialInstance(IConfigService, { ready: Promise.resolve(), get: ((domain: string) => diff --git a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts index d3272107e..b63104f28 100644 --- a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts +++ b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts @@ -1,17 +1,26 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { createScopedTestHost } from '#/_base/di/test'; -import { IBootstrapService, bootstrapSeed, resolveBootstrapOptions } from '#/app/bootstrap/bootstrap'; -import { bootstrap } from '#/app/bootstrap/bootstrap'; +import { + IBootstrapService, + bootstrap, + bootstrapSeed, + resolveBootstrapOptions, +} from '#/app/bootstrap/bootstrap'; import { BootstrapService } from '#/app/bootstrap/bootstrapService'; +import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; describe('BootstrapService (scoped)', () => { beforeEach(() => { - _clearScopedRegistryForTests(); + // No `_clearScopedRegistryForTests()` here: the registry is process-wide, + // and wiping it would break other suites sharing this worker. + // Re-registering is enough — later registrations win in the scope + // collection. registerScopedService( LifecycleScope.App, IBootstrapService, @@ -49,7 +58,15 @@ describe('resolveBootstrapOptions', () => { describe('bootstrap() storage seeding', () => { it('seeds IFileSystemStorageService as a FileStorageService instance', () => { - const { app } = bootstrap({ homeDir: '/tmp/kimi-home' }); + // `bootstrap()` eagerly instantiates the kosong persistence bridge; stub + // it out so this test stays focused on the storage seed instead of + // pulling the whole config/kosong graph into the module imports. + const { app } = bootstrap({ homeDir: '/tmp/kimi-home' }, [ + [ + IKosongConfigService as ServiceIdentifier, + { _serviceBrand: undefined, ready: Promise.resolve() }, + ], + ]); try { const storage = app.accessor.get(IFileSystemStorageService); expect(storage).toBeInstanceOf(FileStorageService); 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 5bf59b6cf..f957d72e7 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -43,10 +43,8 @@ import { LOOP_MAX_STEPS_PER_TURN_ENV, type LoopControl, } from '#/agent/loop/configSection'; -import { - THINKING_SECTION, - type ThinkingConfig, -} from '#/kosong/model/thinking'; +import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; +import { type ThinkingConfig } from '#/kosong/model/thinking'; import { KEEP_ALIVE_ON_EXIT_ENV, MAX_RUNNING_TASKS_ENV, diff --git a/packages/agent-core-v2/test/kosong/model/discovery.test.ts b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts similarity index 81% rename from packages/agent-core-v2/test/kosong/model/discovery.test.ts rename to packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts index c302734ee..7b2430ee4 100644 --- a/packages/agent-core-v2/test/kosong/model/discovery.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts @@ -1,5 +1,5 @@ /** - * `kosong/model` discovery tests — `IProviderDiscoveryService`: + * `app/kosongConfig` discovery tests — `IProviderDiscoveryService`: * * - `refreshProviderModels` short-circuits `modelSource: 'static'`: a scoped * refresh answers `unchanged` without any I/O, and an unscoped refresh @@ -8,10 +8,12 @@ * at them all survive; * - concurrent refreshes serialize (never overlap); * - custom-registry fetches carry the host `User-Agent`; - * - whole-section writes merge discovered aliases into user-owned provider - * records, restore a surviving default selection, and CLEAR a default - * (plus its thinking) whose alias the upstream dropped — through - * `replace`, never a dangling `set`; + * - provider/model patches land in kosong's in-memory registries via + * `replaceAll` (the persistence bridge writes them back to config), + * merging discovered aliases into user-owned provider records, while + * `defaultModel` / `thinking` still go through `config.replace` directly — + * restoring a surviving default selection and CLEARing a default (plus its + * thinking) whose alias the upstream dropped, never a dangling `set`; * - the `[modelCatalog]` config section self-registers and validates. */ @@ -24,19 +26,27 @@ import { IOAuthService } from '#/app/auth/auth'; import { IConfigService } from '#/app/config/config'; import { ConfigRegistry } from '#/app/config/configService'; import { IEventService } from '#/app/event/event'; +import { IProviderDiscoveryService } from '#/app/kosongConfig/discovery'; +import '#/app/kosongConfig/discoveryService'; +import { MODEL_CATALOG_SECTION } from '#/app/kosongConfig/configSection'; import '#/kosong/model/errors'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; -import type { ModelRecord } from '#/kosong/model/model'; +import { + IModelService, + type ModelRecord, + type ModelsSection, +} from '#/kosong/model/model'; import '#/kosong/model/modelService'; -import { IProviderDiscoveryService } from '#/kosong/model/discovery'; -import '#/kosong/model/discoveryService'; -import { MODEL_CATALOG_SECTION } from '#/kosong/model/discoveryConfigSection'; -import type { ProviderConfig } from '#/kosong/provider/provider'; +import { + IProviderService, + type ProviderConfig, + type ProvidersSection, +} from '#/kosong/provider/provider'; import '#/kosong/provider/providerService'; import '#/kosong/provider/providers/kimi/kimi.contrib'; import '#/kosong/provider/providers/standard.contrib'; -import { StubConfigService, stubOAuthService, stubTokenProvider } from '../stubs'; +import { StubConfigService, stubOAuthService, stubTokenProvider } from '../../kosong/stubs'; function stubEvents(): IEventService & { published: Array<{ type: string; payload: unknown }> } { const published: Array<{ type: string; payload: unknown }> = []; @@ -59,6 +69,8 @@ function createHost( config: StubConfigService; events: ReturnType; discovery: IProviderDiscoveryService; + providers: IProviderService; + models: IModelService; } { const config = new StubConfigService(sections); const events = stubEvents(); @@ -68,7 +80,26 @@ function createHost( [IEventService, events], [IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'kimi-test/1.0' })], ]); - return { host, config, events, discovery: host.app.accessor.get(IProviderDiscoveryService) }; + // Seed the in-memory registries from the fixture sections (the persistence + // bridge that normally does this is not instantiated here). + const providers = host.app.accessor.get(IProviderService); + providers.loadAll( + (sections['providers'] ?? {}) as ProvidersSection, + sections['defaultProvider'] as string | undefined, + ); + const models = host.app.accessor.get(IModelService); + models.loadAll( + (sections['models'] ?? {}) as ModelsSection, + sections['defaultModel'] as string | undefined, + ); + return { + host, + config, + events, + discovery: host.app.accessor.get(IProviderDiscoveryService), + providers, + models, + }; } afterEach(() => { @@ -133,7 +164,7 @@ describe('refreshProviderModels modelSource short-circuit', () => { ); vi.stubGlobal('fetch', fetchMock); - const { host, config, discovery, events } = createHost({ + const { host, config, discovery, events, providers, models } = createHost({ providers: { ...staticProviders, acme: { @@ -159,13 +190,15 @@ describe('refreshProviderModels modelSource short-circuit', () => { ]); // Static provider, its model, the default selection, and its thinking - // all survived the orchestrator's whole-section writes. - const providers = config.get>('providers'); - expect(Object.keys(providers).toSorted()).toEqual(['acme', 'static-p']); - expect(providers['static-p']).toEqual({ type: 'openai', modelSource: 'static', apiKey: 'sk-static' }); - const models = config.get>('models'); - expect(models['s1']).toEqual({ provider: 'static-p', model: 'static-model', maxContextSize: 1000 }); - expect(models['acme/m1']).toBeDefined(); + // all survived the orchestrator's whole-section writes. Providers and + // models land in the in-memory registries (the persistence bridge owns + // the config write-back); defaultModel/thinking go through config. + const providerRecords = providers.list(); + expect(Object.keys(providerRecords).toSorted()).toEqual(['acme', 'static-p']); + expect(providerRecords['static-p']).toEqual({ type: 'openai', modelSource: 'static', apiKey: 'sk-static' }); + const modelRecords = models.list(); + expect(modelRecords['s1']).toEqual({ provider: 'static-p', model: 'static-model', maxContextSize: 1000 }); + expect(modelRecords['acme/m1']).toBeDefined(); expect(config.get('defaultModel')).toBe('s1'); expect(config.get('thinking')).toEqual({ enabled: true }); } finally { @@ -304,7 +337,7 @@ describe('refreshProviderModels write behavior', () => { ); vi.stubGlobal('fetch', fetchMock); - const { host, config, discovery, events } = createHost({ + const { host, config, discovery, events, providers, models } = createHost({ providers: { 'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' }, }, @@ -335,14 +368,14 @@ describe('refreshProviderModels write behavior', () => { }), ); // The user-owned provider record survives; only model aliases are merged. - expect(config.get>('providers')['my-kimi']).toEqual({ + expect(providers.list()['my-kimi']).toEqual({ type: 'kimi', baseUrl, apiKey: 'sk-distributed-key', }); - const models = config.get>('models'); - expect(models['my-kimi/kimi-k2']?.displayName).toBe('Fresh K2'); - expect(models['my-kimi/kimi-k2.5']).toBeDefined(); + const modelRecords = models.list(); + expect(modelRecords['my-kimi/kimi-k2']?.displayName).toBe('Fresh K2'); + expect(modelRecords['my-kimi/kimi-k2.5']).toBeDefined(); // The surviving default selection is written back, not cleared. expect(config.get('defaultModel')).toBe('my-kimi/kimi-k2'); } finally { @@ -364,7 +397,7 @@ describe('refreshProviderModels write behavior', () => { ); vi.stubGlobal('fetch', fetchMock); - const { host, config, discovery } = createHost({ + const { host, config, discovery, models } = createHost({ providers: { 'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' }, }, @@ -392,9 +425,9 @@ describe('refreshProviderModels write behavior', () => { // back to the stale base value. expect(config.get('defaultModel')).toBeUndefined(); expect(config.get('thinking')).toBeUndefined(); - const models = config.get>('models'); - expect(models['my-kimi/kimi-k3']).toBeDefined(); - expect(models['my-kimi/kimi-k2']).toBeUndefined(); + const modelRecords = models.list(); + expect(modelRecords['my-kimi/kimi-k3']).toBeDefined(); + expect(modelRecords['my-kimi/kimi-k2']).toBeUndefined(); } finally { host.dispose(); } diff --git a/packages/agent-core-v2/test/kosong/model/envOverlay.test.ts b/packages/agent-core-v2/test/app/kosongConfig/envOverlay.test.ts similarity index 95% rename from packages/agent-core-v2/test/kosong/model/envOverlay.test.ts rename to packages/agent-core-v2/test/app/kosongConfig/envOverlay.test.ts index a0ba70302..7c2c574ad 100644 --- a/packages/agent-core-v2/test/kosong/model/envOverlay.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/envOverlay.test.ts @@ -1,5 +1,5 @@ /** - * `kosong/model` envOverlay tests — the `KIMI_MODEL_*` effective overlay: + * `app/kosongConfig` envOverlay tests — the `KIMI_MODEL_*` effective overlay: * * - with `KIMI_MODEL_NAME` set it synthesizes the reserved env model + * provider entries and selects the model; without it only the @@ -13,10 +13,10 @@ import { describe, expect, it } from 'vitest'; -import { ENV_MODEL_PROVIDER_KEY } from '#/kosong/provider/provider'; +import { ENV_MODEL_PROVIDER_KEY } from '#/app/kosongConfig/configSection'; import '#/kosong/provider/providers/kimi/kimi.contrib'; import '#/kosong/provider/providers/standard.contrib'; -import { ENV_MODEL_ALIAS_KEY, kimiModelEnvOverlay } from '#/kosong/model/envOverlay'; +import { ENV_MODEL_ALIAS_KEY, kimiModelEnvOverlay } from '#/app/kosongConfig/envOverlay'; type Env = Record; diff --git a/packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts b/packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts new file mode 100644 index 000000000..f5abe0bfd --- /dev/null +++ b/packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts @@ -0,0 +1,409 @@ +/** + * `kosongConfig` bridge tests — `KosongConfigService`, the two-way sync + * between `IConfigService` (persistence) and kosong's in-memory + * provider/model registries: + * + * - startup hydration: after `config.ready` the registries are loaded from + * the effective config view (records + default pointers) and become + * `ready` themselves; + * - kosong → config: registry mutations (`set` / `setDefaultModel` / ...) + * persist through `config.replace`, serialized in event order; an awaited + * mutation only resolves after the persist has landed, and a failed + * persist is retried with backoff before being logged; + * - config → kosong: section writes (`config.set` / `config.replace`) land + * in the registries; + * - loop termination: equal writes are silent on the registry side and the + * persist handlers skip writes when config already matches, so neither + * direction echoes back into the other; + * - deleting the default provider clears the pointer and persists the + * cleared pointer. + * + * The bridge is instantiated directly with the real registries, the shared + * `StubConfigService`, and a stub log — no DI involved. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { ILogService, type LogPayload } from '#/_base/log/log'; +import { + DEFAULT_MODEL_SECTION, + DEFAULT_PROVIDER_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, +} from '#/app/kosongConfig/configSection'; +import { type ModelRecord } from '#/kosong/model/model'; +import { ModelService } from '#/kosong/model/modelService'; +import { type ProviderConfig } from '#/kosong/provider/provider'; +import { ProviderService } from '#/kosong/provider/providerService'; + +import { StubConfigService } from '../../kosong/stubs'; +import { KosongConfigService } from '#/app/kosongConfig/kosongConfigService'; + +function stubLogService(): ILogService & { warnings: Array<{ message: string; payload?: LogPayload }> } { + const warnings: Array<{ message: string; payload?: LogPayload }> = []; + return { + warnings, + _serviceBrand: undefined, + level: 'debug', + setLevel: () => {}, + flush: async () => {}, + error: () => {}, + warn: (message: string, payload?: LogPayload) => { + warnings.push({ message, payload }); + }, + info: () => {}, + debug: () => {}, + child: () => { + throw new Error('child loggers are not used by KosongConfigService'); + }, + } satisfies ILogService & { warnings: Array<{ message: string; payload?: LogPayload }> }; +} + +interface BridgeFixture { + readonly config: StubConfigService; + readonly providers: ProviderService; + readonly models: ModelService; + readonly log: ReturnType; + readonly bridge: KosongConfigService; +} + +async function createBridge(sections: Record = {}): Promise { + const config = new StubConfigService(sections); + const providers = new ProviderService(); + const models = new ModelService(); + const log = stubLogService(); + const bridge = new KosongConfigService(config, providers, models, log); + await bridge.ready; + return { config, providers, models, log, bridge }; +} + +/** Let the bridge's serialized persist chain (and event handlers) run out. */ +async function flush(): Promise { + for (let i = 0; i < 10; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +const KIMI_PROVIDER: ProviderConfig = { type: 'kimi', apiKey: 'sk-test' }; +const K1_MODEL: ModelRecord = { provider: 'kimi', model: 'kimi-k2', maxContextSize: 1000 }; + +const seededSections: Record = { + providers: { kimi: KIMI_PROVIDER }, + models: { k1: K1_MODEL }, + defaultProvider: 'kimi', + defaultModel: 'k1', +}; + +describe('KosongConfigService startup hydration', () => { + it('loads providers, models, and the default pointers from config and readies the registries', async () => { + const { providers, models } = await createBridge(seededSections); + + expect(providers.list()).toEqual({ kimi: KIMI_PROVIDER }); + expect(providers.getDefaultProvider()).toBe('kimi'); + expect(models.list()).toEqual({ k1: K1_MODEL }); + expect(models.getDefaultModel()).toBe('k1'); + // Both registries are hydrated — `ready` has resolved. + await expect(providers.ready).resolves.toBeUndefined(); + await expect(models.ready).resolves.toBeUndefined(); + }); + + it('hydrates empty registries from an empty config', async () => { + const { providers, models } = await createBridge(); + + expect(providers.list()).toEqual({}); + expect(providers.getDefaultProvider()).toBeUndefined(); + expect(models.list()).toEqual({}); + expect(models.getDefaultModel()).toBeUndefined(); + }); +}); + +describe('KosongConfigService kosong → config persistence', () => { + it('persists provider set/delete through config.replace with the whole section', async () => { + const { config, providers, bridge } = await createBridge(seededSections); + try { + const replaceSpy = vi.spyOn(config, 'replace'); + + await providers.set('openai', { type: 'openai', apiKey: 'sk-o' }); + await flush(); + expect(replaceSpy).toHaveBeenCalledWith(PROVIDERS_SECTION, { + kimi: KIMI_PROVIDER, + openai: { type: 'openai', apiKey: 'sk-o' }, + }); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + kimi: KIMI_PROVIDER, + openai: { type: 'openai', apiKey: 'sk-o' }, + }); + + await providers.delete('openai'); + await flush(); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + kimi: KIMI_PROVIDER, + }); + } finally { + bridge.dispose(); + } + }); + + it('persists model records and the default-model pointer', async () => { + const { config, models, bridge } = await createBridge(seededSections); + try { + await models.set('k2', { provider: 'kimi', model: 'kimi-k2.5', maxContextSize: 2000 }); + await models.setDefaultModel('k2'); + await flush(); + + expect(config.get>(MODELS_SECTION)).toEqual({ + k1: K1_MODEL, + k2: { provider: 'kimi', model: 'kimi-k2.5', maxContextSize: 2000 }, + }); + expect(config.get(DEFAULT_MODEL_SECTION)).toBe('k2'); + } finally { + bridge.dispose(); + } + }); + + it('persists the default-provider pointer', async () => { + const { config, providers, bridge } = await createBridge(seededSections); + try { + await providers.set('openai', { type: 'openai' }); + await providers.setDefaultProvider('openai'); + await flush(); + + expect(config.get(DEFAULT_PROVIDER_SECTION)).toBe('openai'); + } finally { + bridge.dispose(); + } + }); +}); + +describe('KosongConfigService awaited-mutation semantics', () => { + it('an awaited registry mutation resolves only after the write has landed in config', async () => { + const { config, providers, models, bridge } = await createBridge(seededSections); + try { + // No flush(): the mutation's own await already covers persistence. + await providers.set('openai', { type: 'openai', apiKey: 'sk-o' }); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + kimi: KIMI_PROVIDER, + openai: { type: 'openai', apiKey: 'sk-o' }, + }); + + await models.setDefaultModel('k1'); + expect(config.get(DEFAULT_MODEL_SECTION)).toBe('k1'); + } finally { + bridge.dispose(); + } + }); + + it('retries a failed persist instead of surfacing it to the caller', async () => { + const { config, providers, log, bridge } = await createBridge(seededSections); + try { + let failuresLeft = 1; + const original = config.replace.bind(config); + vi.spyOn(config, 'replace').mockImplementation(async (domain: string, value: unknown) => { + if (domain === PROVIDERS_SECTION && failuresLeft > 0) { + failuresLeft -= 1; + throw new Error('disk busy'); + } + return original(domain, value); + }); + + vi.useFakeTimers(); + try { + const pending = providers.set('openai', { type: 'openai' }); + // The first backoff is ~500ms; advancing past it lets the retry run. + await vi.advanceTimersByTimeAsync(1000); + await pending; + } finally { + vi.useRealTimers(); + } + + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + kimi: KIMI_PROVIDER, + openai: { type: 'openai' }, + }); + expect(log.warnings).toHaveLength(0); + } finally { + bridge.dispose(); + } + }); + + it('logs and resolves after the retry budget is spent, and the chain stays alive', async () => { + const { config, providers, log, bridge } = await createBridge(seededSections); + try { + const replaceSpy = vi.spyOn(config, 'replace').mockRejectedValue(new Error('disk gone')); + + vi.useFakeTimers(); + try { + const pending = providers.set('openai', { type: 'openai' }); + // Two backoffs (~500ms + ~1000ms, plus jitter) before the budget is spent. + await vi.advanceTimersByTimeAsync(2500); + // The caller is never rejected: the in-memory change stands. + await pending; + } finally { + vi.useRealTimers(); + } + + expect(providers.get('openai')).toEqual({ type: 'openai' }); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + kimi: KIMI_PROVIDER, + }); + expect(log.warnings).toHaveLength(1); + expect(log.warnings[0]?.message).toBe('kosong config persist failed'); + + // A poisoned task must not stall the persists queued behind it. + replaceSpy.mockRestore(); + await providers.set('mistral', { type: 'mistral' }); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + kimi: KIMI_PROVIDER, + openai: { type: 'openai' }, + mistral: { type: 'mistral' }, + }); + } finally { + bridge.dispose(); + } + }); +}); + +describe('KosongConfigService config → kosong sync', () => { + it('pushes config section writes into the registries', async () => { + const { config, providers, models, bridge } = await createBridge(seededSections); + try { + await config.set(PROVIDERS_SECTION, { openai: { type: 'openai', apiKey: 'sk-o' } }); + expect(providers.get('openai')).toEqual({ type: 'openai', apiKey: 'sk-o' }); + expect(providers.get('kimi')).toEqual(KIMI_PROVIDER); + + await config.replace(MODELS_SECTION, { k2: { provider: 'openai', model: 'gpt-5' } }); + expect(models.list()).toEqual({ k2: { provider: 'openai', model: 'gpt-5' } }); + + await config.replace(DEFAULT_MODEL_SECTION, 'k2'); + await flush(); + expect(models.getDefaultModel()).toBe('k2'); + + await config.replace(DEFAULT_PROVIDER_SECTION, 'openai'); + await flush(); + expect(providers.getDefaultProvider()).toBe('openai'); + } finally { + bridge.dispose(); + } + }); +}); + +describe('KosongConfigService loop termination', () => { + it('a kosong-originated persist does not echo back as a kosong change', async () => { + const { config, providers, bridge } = await createBridge(seededSections); + try { + const replaceSpy = vi.spyOn(config, 'replace'); + const events: string[] = []; + providers.onDidChangeProviders(() => events.push('providers')); + providers.onDidChangeDefaultProvider(() => events.push('defaultProvider')); + + await providers.set('openai', { type: 'openai' }); + await providers.setDefaultProvider('openai'); + await flush(); + + // Exactly one event per mutation; the config write the persist caused + // synced back equal values, which are silent. + expect(events).toEqual(['providers', 'defaultProvider']); + // And the persist did not re-persist after the echo: exactly one + // providers-section replace plus one pointer replace. + expect( + replaceSpy.mock.calls.filter(([domain]) => domain === PROVIDERS_SECTION), + ).toHaveLength(1); + expect( + replaceSpy.mock.calls.filter(([domain]) => domain === DEFAULT_PROVIDER_SECTION), + ).toHaveLength(1); + } finally { + bridge.dispose(); + } + }); + + it('a config-originated sync does not echo back as a config persist', async () => { + const { config, providers, models, bridge } = await createBridge(seededSections); + try { + const replaceSpy = vi.spyOn(config, 'replace'); + replaceSpy.mockClear(); + + await config.set(PROVIDERS_SECTION, { openai: { type: 'openai' } }); + await config.set(MODELS_SECTION, { k2: { provider: 'openai', model: 'gpt-5' } }); + await flush(); + + expect(providers.get('openai')).toEqual({ type: 'openai' }); + expect(models.get('k2')).toEqual({ provider: 'openai', model: 'gpt-5' }); + // The registry diffs fired, but the persist handlers saw config already + // matching and skipped the write-back. + expect(replaceSpy).not.toHaveBeenCalled(); + } finally { + bridge.dispose(); + } + }); +}); + +describe('KosongConfigService default-provider deletion', () => { + it('clears the pointer when the default provider is deleted and persists the cleared pointer', async () => { + const { config, providers, bridge } = await createBridge({ + ...seededSections, + providers: { kimi: KIMI_PROVIDER, openai: { type: 'openai' } }, + }); + try { + const replaceSpy = vi.spyOn(config, 'replace'); + + await providers.delete('kimi'); + await flush(); + + // The registry cleared the dangling pointer, and the cleared pointer + // persisted. + expect(providers.getDefaultProvider()).toBeUndefined(); + expect(replaceSpy).toHaveBeenCalledWith(PROVIDERS_SECTION, { + openai: { type: 'openai' }, + }); + expect(replaceSpy).toHaveBeenCalledWith(DEFAULT_PROVIDER_SECTION, undefined); + expect(config.get(DEFAULT_PROVIDER_SECTION)).toBeUndefined(); + } finally { + bridge.dispose(); + } + }); +}); + +describe('KosongConfigService env-pinned default pointer', () => { + /** + * Emulates an effective-overlay pin (e.g. `KIMI_MODEL_NAME` → + * `defaultModel`): user-layer writes are accepted, but the effective read + * (`get`) keeps returning the pinned value and no change event fires. + */ + class PinnedConfigService extends StubConfigService { + constructor( + private readonly pinnedDomain: string, + pinnedValue: unknown, + sections: Record, + ) { + super({ ...sections, [pinnedDomain]: pinnedValue }); + } + + override replace(domain: string, value: unknown): Promise { + if (domain === this.pinnedDomain) return Promise.resolve(); + return super.replace(domain, value); + } + } + + it('re-asserts the pinned effective default model into the registry after a registry-originated write', async () => { + const config = new PinnedConfigService(DEFAULT_MODEL_SECTION, 'env-model', seededSections); + const providers = new ProviderService(); + const models = new ModelService(); + const bridge = new KosongConfigService(config, providers, models, stubLogService()); + await bridge.ready; + try { + // Hydration reads the effective view: the pinned value, not the seeded one. + expect(models.getDefaultModel()).toBe('env-model'); + const replaceSpy = vi.spyOn(config, 'replace'); + + await models.setDefaultModel('k1'); + await flush(); + + // The write landed in the user layer, but the pinned effective view + // did not move, and the bridge reconciled the registry back to the pin. + expect(replaceSpy).toHaveBeenCalledWith(DEFAULT_MODEL_SECTION, 'k1'); + expect(models.getDefaultModel()).toBe('env-model'); + } finally { + bridge.dispose(); + } + }); +}); diff --git a/packages/agent-core-v2/test/app/model/model.test.ts b/packages/agent-core-v2/test/app/model/model.test.ts index 8891e6186..91179fcef 100644 --- a/packages/agent-core-v2/test/app/model/model.test.ts +++ b/packages/agent-core-v2/test/app/model/model.test.ts @@ -1,25 +1,26 @@ /** - * `model` domain tests — covers `ModelService` CRUD over the `models` config - * section, schema registration, and the delete-via-replace semantics. + * `model` domain tests — covers `effectiveModelConfig`, the `models` config + * section registration + TOML transforms (now owned by the app/kosongConfig + * persistence wrapper), and the `KIMI_MODEL_*` env overlay. + * + * The registry itself (`ModelService`) is a pure in-memory store covered by + * `test/kosong/model/modelService.test.ts`; persistence through the config + * bridge is covered by `test/app/kosongConfig/kosongConfigService.test.ts`. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import { IConfigRegistry, IConfigService } from '#/app/config/config'; import { ConfigRegistry } from '#/app/config/configService'; import { ErrorCodes, Error2 } from '#/errors'; -import { kimiModelEnvOverlay, ENV_MODEL_ALIAS_KEY } from '#/kosong/model/envOverlay'; +import { kimiModelEnvOverlay, ENV_MODEL_ALIAS_KEY } from '#/app/kosongConfig/envOverlay'; import { - IModelService, - type ModelRecord, + ENV_MODEL_PROVIDER_KEY, MODELS_SECTION, ModelsSectionSchema, -} from '#/kosong/model/model'; -import { modelsFromToml, modelsToToml } from '#/kosong/model/configSection'; -import { ModelService } from '#/kosong/model/modelService'; -import { ENV_MODEL_PROVIDER_KEY } from '#/kosong/provider/provider'; + modelsFromToml, + modelsToToml, +} from '#/app/kosongConfig/configSection'; +import { type ModelRecord } from '#/kosong/model/model'; import { effectiveModelConfig } from '#/kosong/model/modelAuth'; // Side-effect registrations: endpoint defaults and the trait-driven-thinking @@ -192,79 +193,9 @@ describe('effectiveModelConfig', () => { }); }); -describe('ModelService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - let registry: ConfigRegistry; - let models: Record; - let configSet: ReturnType; - let configReplace: ReturnType; - - beforeEach(() => { - disposables = new DisposableStore(); - registry = new ConfigRegistry(); - models = {}; - configSet = vi.fn().mockResolvedValue(undefined); - configReplace = vi.fn().mockResolvedValue(undefined); - ix = createServices(disposables, { - additionalServices: (reg) => { - reg.defineInstance(IConfigRegistry, registry); - reg.definePartialInstance(IConfigService, { - get: ((domain: string) => - domain === MODELS_SECTION ? models : undefined) as IConfigService['get'], - set: configSet as unknown as IConfigService['set'], - replace: configReplace as unknown as IConfigService['replace'], - onDidChangeConfiguration: (() => ({ dispose: () => { } })) as IConfigService['onDidChangeConfiguration'], - }); - reg.define(IModelService, ModelService); - }, - }); - }); - afterEach(() => disposables.dispose()); - - it('registers the models section schema from configSection import', () => { - expect(registry.getSection(MODELS_SECTION)).toBeDefined(); - }); - - it('set delegates to config.set with a single-alias patch', async () => { - const svc = ix.get(IModelService); - await svc.set('m1', { provider: 'p', model: 'x', maxContextSize: 1000 }); - expect(configSet).toHaveBeenCalledWith(MODELS_SECTION, { - m1: { provider: 'p', model: 'x', maxContextSize: 1000 }, - }); - }); - - it('get reads a single alias from config', () => { - models['m1'] = { provider: 'p', model: 'x', maxContextSize: 1000 }; - const svc = ix.get(IModelService); - expect(svc.get('m1')).toEqual({ provider: 'p', model: 'x', maxContextSize: 1000 }); - expect(svc.get('missing')).toBeUndefined(); - }); - - it('list returns all aliases', () => { - models['m1'] = { provider: 'p', model: 'x', maxContextSize: 1000 }; - models['m2'] = { provider: 'p', model: 'y', maxContextSize: 2000 }; - const svc = ix.get(IModelService); - expect(svc.list()).toEqual({ - m1: { provider: 'p', model: 'x', maxContextSize: 1000 }, - m2: { provider: 'p', model: 'y', maxContextSize: 2000 }, - }); - }); - - it('delete removes the alias and replaces the whole section', async () => { - models['m1'] = { provider: 'p', model: 'x', maxContextSize: 1000 }; - models['m2'] = { provider: 'p', model: 'y', maxContextSize: 2000 }; - const svc = ix.get(IModelService); - await svc.delete('m1'); - expect(configReplace).toHaveBeenCalledWith(MODELS_SECTION, { - m2: { provider: 'p', model: 'y', maxContextSize: 2000 }, - }); - }); - - it('delete is a no-op when the alias is absent', async () => { - const svc = ix.get(IModelService); - await svc.delete('missing'); - expect(configReplace).not.toHaveBeenCalled(); +describe('models config section', () => { + it('self-registers the models section schema', () => { + expect(new ConfigRegistry().getSection(MODELS_SECTION)).toBeDefined(); }); }); diff --git a/packages/agent-core-v2/test/app/provider/provider.test.ts b/packages/agent-core-v2/test/app/provider/provider.test.ts index aa4c9c1c4..58026edbc 100644 --- a/packages/agent-core-v2/test/app/provider/provider.test.ts +++ b/packages/agent-core-v2/test/app/provider/provider.test.ts @@ -1,177 +1,34 @@ /** - * `provider` domain tests — covers `ProviderService` CRUD over the `providers` - * config section, schema registration, and the delete-via-replace semantics. + * `provider` config-section tests — the `providers` section registration + * (schema + env bindings + strip hook, self-registered by the app/kosongConfig + * persistence wrapper) and the TOML/env helper transforms. + * + * The registry itself (`ProviderService`) is a pure in-memory store covered + * by `test/kosong/provider/providerService.test.ts`; persistence through the + * config bridge is covered by `test/app/kosongConfig/kosongConfigService.test.ts`. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { Emitter } from '#/_base/event'; -import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import { type ConfigChangedEvent, IConfigRegistry, IConfigService } from '#/app/config/config'; import { ConfigRegistry } from '#/app/config/configService'; import { + ENV_MODEL_PROVIDER_KEY, + PROVIDERS_SECTION, providersEnvBindings, providersFromToml, providersToToml, stripProvidersEnv, -} from '#/kosong/provider/configSection'; -import { - ENV_MODEL_PROVIDER_KEY, - IProviderService, - type ProviderConfig, - PROVIDERS_SECTION, -} from '#/kosong/provider/provider'; -import { ProviderService } from '#/kosong/provider/providerService'; +} from '#/app/kosongConfig/configSection'; -describe('ProviderService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - let registry: ConfigRegistry; - let providers: Record; - let defaultProvider: string | undefined; - let configSet: ReturnType; - let configReplace: ReturnType; - - beforeEach(() => { - disposables = new DisposableStore(); - registry = new ConfigRegistry(); - providers = {}; - defaultProvider = undefined; - configSet = vi.fn().mockResolvedValue(undefined); - configReplace = vi.fn().mockResolvedValue(undefined); - ix = createServices(disposables, { - additionalServices: (reg) => { - reg.defineInstance(IConfigRegistry, registry); - reg.definePartialInstance(IConfigService, { - get: ((domain: string) => { - if (domain === PROVIDERS_SECTION) return providers; - if (domain === 'defaultProvider') return defaultProvider; - return undefined; - }) as IConfigService['get'], - set: configSet as unknown as IConfigService['set'], - replace: configReplace as unknown as IConfigService['replace'], - onDidChangeConfiguration: (() => ({ dispose: () => { } })) as IConfigService['onDidChangeConfiguration'], - }); - reg.define(IProviderService, ProviderService); - }, - }); - }); - afterEach(() => disposables.dispose()); - - it('registers the providers section schema on construction', () => { - ix.get(IProviderService); +describe('providers config section', () => { + it('self-registers the schema with the env bindings and strip hook', () => { + const registry = new ConfigRegistry(); expect(registry.getSection(PROVIDERS_SECTION)).toMatchObject({ domain: PROVIDERS_SECTION, env: providersEnvBindings, stripEnv: stripProvidersEnv, }); }); - - it('set delegates to config.set with a single-provider patch', async () => { - const svc = ix.get(IProviderService); - await svc.set('p1', { type: 'openai', apiKey: 'sk' }); - expect(configSet).toHaveBeenCalledWith(PROVIDERS_SECTION, { - p1: { type: 'openai', apiKey: 'sk' }, - }); - }); - - it('get reads a single provider from config', () => { - providers['p1'] = { type: 'openai', apiKey: 'sk' }; - const svc = ix.get(IProviderService); - expect(svc.get('p1')).toEqual({ type: 'openai', apiKey: 'sk' }); - expect(svc.get('missing')).toBeUndefined(); - }); - - it('list returns all providers', () => { - providers['p1'] = { type: 'openai' }; - providers['p2'] = { type: 'kimi' }; - const svc = ix.get(IProviderService); - expect(svc.list()).toEqual({ - p1: { type: 'openai' }, - p2: { type: 'kimi' }, - }); - }); - - it('delete removes the provider and replaces the whole section', async () => { - providers['p1'] = { type: 'openai' }; - providers['p2'] = { type: 'kimi' }; - const svc = ix.get(IProviderService); - await svc.delete('p1'); - expect(configReplace).toHaveBeenCalledWith(PROVIDERS_SECTION, { - p2: { type: 'kimi' }, - }); - }); - - it('delete is a no-op when the provider is absent', async () => { - const svc = ix.get(IProviderService); - await svc.delete('missing'); - expect(configReplace).not.toHaveBeenCalled(); - }); - - it('delete clears defaultProvider when removing the default provider', async () => { - providers['p1'] = { type: 'openai' }; - providers['p2'] = { type: 'kimi' }; - defaultProvider = 'p1'; - const svc = ix.get(IProviderService); - await svc.delete('p1'); - expect(configReplace).toHaveBeenCalledWith(PROVIDERS_SECTION, { - p2: { type: 'kimi' }, - }); - expect(configReplace).toHaveBeenCalledWith('defaultProvider', undefined); - }); - - it('delete leaves defaultProvider when removing a different provider', async () => { - providers['p1'] = { type: 'openai' }; - providers['p2'] = { type: 'kimi' }; - defaultProvider = 'p2'; - const svc = ix.get(IProviderService); - await svc.delete('p1'); - expect(configReplace).not.toHaveBeenCalledWith('defaultProvider', expect.anything()); - }); - - it('forwards providers section changes as onDidChangeProviders with a diff', () => { - const configEvents = disposables.add(new Emitter()); - const local = createServices(disposables, { - additionalServices: (reg) => { - reg.defineInstance(IConfigRegistry, registry); - reg.definePartialInstance(IConfigService, { - get: (() => undefined) as unknown as IConfigService['get'], - onDidChangeConfiguration: configEvents.event, - }); - reg.define(IProviderService, ProviderService); - }, - }); - const svc = local.get(IProviderService); - const diffs: { added: readonly string[]; removed: readonly string[]; changed: readonly string[] }[] = []; - disposables.add(svc.onDidChangeProviders((e) => diffs.push(e))); - - configEvents.fire({ - domain: PROVIDERS_SECTION, - source: 'set', - value: { p1: { type: 'openai' } }, - previousValue: {}, - }); - configEvents.fire({ - domain: PROVIDERS_SECTION, - source: 'set', - value: { p1: { type: 'kimi' } }, - previousValue: { p1: { type: 'openai' } }, - }); - configEvents.fire({ - domain: PROVIDERS_SECTION, - source: 'set', - value: {}, - previousValue: { p1: { type: 'kimi' } }, - }); - configEvents.fire({ domain: 'models', source: 'set', value: {}, previousValue: {} }); - - expect(diffs).toEqual([ - { added: ['p1'], removed: [], changed: [] }, - { added: [], removed: [], changed: ['p1'] }, - { added: [], removed: ['p1'], changed: [] }, - ]); - }); }); describe('provider config section helpers', () => { diff --git a/packages/agent-core-v2/test/app/provider/stubs.ts b/packages/agent-core-v2/test/app/provider/stubs.ts index 7a3c60d53..d1d7e32fe 100644 --- a/packages/agent-core-v2/test/app/provider/stubs.ts +++ b/packages/agent-core-v2/test/app/provider/stubs.ts @@ -14,9 +14,14 @@ export function stubProviderService( _serviceBrand: undefined, ready, onDidChangeProviders: () => ({ dispose: () => {} }), + onDidChangeDefaultProvider: () => ({ dispose: () => {} }), get: (name: string) => providers[name], list: () => providers, + getDefaultProvider: () => undefined, set: async () => {}, delete: async () => {}, + loadAll: () => {}, + replaceAll: async () => {}, + setDefaultProvider: async () => {}, }; } diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index d27b01575..6282acd2c 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -64,7 +64,6 @@ import type { } from '#/tool/toolContract'; import { AGENT_WIRE_RECORD_KEY, wireRecordToPayload, type WireRecord } from '#/wire/record'; import { OP_REGISTRY } from '#/wire/op'; -import { IOAuthService } from '#/app/auth/auth'; import { IProtocolAdapterRegistry, type ProtocolAdapterConfig } from '#/kosong/protocol/protocol'; import { ProtocolAdapterRegistry } from '#/kosong/provider/protocolAdapterRegistry'; import { hasProviderDefinition } from '#/kosong/provider/providerDefinition'; @@ -145,12 +144,23 @@ import { import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; import { WireService } from '#/wire/wireService'; -import { IModelService } from '#/kosong/model/model'; +import { IModelService, type ModelsSection } from '#/kosong/model/model'; +import { + DEFAULT_MODEL_SECTION, + DEFAULT_PROVIDER_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, +} from '#/app/kosongConfig/configSection'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { ModelCatalog } from '#/kosong/model/catalogService'; +import { IModelOAuthTokens } from '#/kosong/model/modelOAuth'; import type { ModelRequestParams, ModelRequester } from '#/kosong/model/modelRequester'; import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; -import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider'; +import { + IProviderService, + type ProviderConfig, + type ProvidersSection, +} from '#/kosong/provider/provider'; import type { ApprovalResponse } from '#/session/approval/approval'; import { ISessionInteractionService, @@ -895,17 +905,43 @@ class PersistenceAppendLogStore implements IAppendLogStore { class ConfigBackedModelCatalog extends ModelCatalog { constructor( private readonly options: TestModelProviderOptions = {}, - @IConfigService config: IConfigService, - @IProviderService providers: IProviderService, - @IModelService models: IModelService, - @IOAuthService oauth: IOAuthService, + @IConfigService private readonly config: IConfigService, + @IProviderService private readonly providerRegistry: IProviderService, + @IModelService private readonly modelRegistry: IModelService, + @IModelOAuthTokens oauthTokens: IModelOAuthTokens, @IProtocolAdapterRegistry protocolRegistry: IProtocolAdapterRegistry, @IHostRequestHeaders hostRequestHeaders: IHostRequestHeaders, ) { - super(config, providers, models, oauth, protocolRegistry, hostRequestHeaders); + super(providerRegistry, modelRegistry, oauthTokens, protocolRegistry, hostRequestHeaders); + } + + /** + * The harness mutates `kimiConfig` BEHIND the config services' backs (no + * section-change events fire), so nothing pushes the new values into the + * kosong registries. Re-hydrate them from the live config view before every + * read: `loadAll` is deep-equal-aware, so an unchanged config is a no-op + * and a changed one fires the diff events that drop the assembled-Model + * cache — preserving the old read-config-live semantics through the new + * in-memory registries. + */ + private syncRegistriesFromConfig(): void { + this.providerRegistry.loadAll( + this.config.get(PROVIDERS_SECTION) ?? {}, + this.config.get(DEFAULT_PROVIDER_SECTION), + ); + this.modelRegistry.loadAll( + this.config.get(MODELS_SECTION) ?? {}, + this.config.get(DEFAULT_MODEL_SECTION), + ); + } + + override get(id: string): Model { + this.syncRegistriesFromConfig(); + return super.get(id); } override getRequester(id: string): ModelRequester { + this.syncRegistriesFromConfig(); const requester = super.getRequester(id); const cacheKey = this.options.promptCacheKey; if (cacheKey === undefined) return requester; @@ -918,6 +954,11 @@ class ConfigBackedModelCatalog extends ModelCatalog { ) => requester.request(input, signal, { cacheKey, ...params }), }; } + + override findByName(name: string): readonly string[] { + this.syncRegistriesFromConfig(); + return super.findByName(name); + } } function renderPluginSessionStartReminder( @@ -1024,6 +1065,19 @@ export class AgentTestContext { options.generate ?? this.scriptedGenerate.generate, ), ); + reg.defineInstance( + IModelOAuthTokens, + { + _serviceBrand: undefined, + hasCachedAccessToken: () => Promise.resolve(false), + getAccessToken: () => + Promise.reject( + new Error( + 'IModelOAuthTokens.getAccessToken is not supported in the test harness', + ), + ), + } satisfies IModelOAuthTokens, + ); reg.defineDescriptor( IModelCatalog, new SyncDescriptor(ConfigBackedModelCatalog, [{}]), @@ -1060,6 +1114,24 @@ export class AgentTestContext { ); this.root = createAppScope({ extra: appSeeds }); + // Hydrate the kosong registries from the (possibly overridden) config so + // direct IProviderService/IModelService reads work before the first + // catalog access; ConfigBackedModelCatalog re-syncs on every read after + // that (the harness mutates kimiConfig behind the config events' backs). + const initialConfig = this.root.accessor.get(IConfigService); + this.root.accessor + .get(IProviderService) + .loadAll( + initialConfig.get(PROVIDERS_SECTION) ?? {}, + initialConfig.get(DEFAULT_PROVIDER_SECTION), + ); + this.root.accessor + .get(IModelService) + .loadAll( + initialConfig.get(MODELS_SECTION) ?? {}, + initialConfig.get(DEFAULT_MODEL_SECTION), + ); + const bootstrap = this.root.accessor.get(IBootstrapService); const workspaceId = 'test-workspace'; const agentTelemetry = this.root.accessor 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 a02ba10cd..dc108c531 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -13,7 +13,7 @@ * definition's `hostHeaders`, and the Anthropic effort profile is inferred * only for vendors whose thinking is not trait-driven; * - `get`/`getRequester` cache per id; the cache drops on the - * model/provider change events — and ONLY there: a config write + * model/provider change events — and ONLY there: a registry write * that bypasses the events keeps serving the stale Model until * `notifyConfigChanged()` (the load-bearing test-harness contract). */ @@ -23,7 +23,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createScopedTestHost } from '#/_base/di/test'; import { isErrorCode } from '#/_base/errors/codes'; import { isError2 } from '#/_base/errors/errors'; -import { IOAuthService } from '#/app/auth/auth'; import { IConfigService } from '#/app/config/config'; import { ConfigErrors } from '#/app/config/errors'; import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; @@ -36,7 +35,11 @@ import '#/kosong/provider/bases/openai/index'; import '#/kosong/provider/protocolAdapterRegistry'; import '#/kosong/provider/providers/kimi/kimi.contrib'; import '#/kosong/provider/providers/standard.contrib'; -import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider'; +import { + IProviderService, + type ProviderConfig, + type ProvidersSection, +} from '#/kosong/provider/provider'; import '#/kosong/provider/providerService'; import { globalDefaultForProvider, @@ -50,16 +53,17 @@ import { import { ModelCatalog } from '#/kosong/model/catalogService'; import '#/kosong/model/errors'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; -import { IModelService, type ModelRecord } from '#/kosong/model/model'; +import { IModelService, type ModelRecord, type ModelsSection } from '#/kosong/model/model'; import '#/kosong/model/modelService'; +import { IModelOAuthTokens } from '#/kosong/model/modelOAuth'; -import { StubConfigService, stubOAuthService, stubTokenProvider } from '../stubs'; +import { StubConfigService, stubModelOAuthTokens, stubTokenProvider } from '../stubs'; const HOST_HEADERS = { 'User-Agent': 'kimi-test/1.0', 'X-Msh-Device-Id': 'device-1' }; function createHost( sections: Record = {}, - oauth: IOAuthService = stubOAuthService(), + oauthTokens: IModelOAuthTokens = stubModelOAuthTokens(), ): { host: ReturnType; config: StubConfigService; @@ -70,15 +74,27 @@ function createHost( const config = new StubConfigService(sections); const host = createScopedTestHost([ [IConfigService, config], - [IOAuthService, oauth], + [IModelOAuthTokens, oauthTokens], [IHostRequestHeaders, new HostRequestHeaders(HOST_HEADERS)], ]); + // Kosong's registries are pure in-memory stores now (persistence lives in + // the app/kosongConfig bridge): seed them from the fixture sections. + const providers = host.app.accessor.get(IProviderService); + providers.loadAll( + (sections['providers'] ?? {}) as ProvidersSection, + sections['defaultProvider'] as string | undefined, + ); + const models = host.app.accessor.get(IModelService); + models.loadAll( + (sections['models'] ?? {}) as ModelsSection, + sections['defaultModel'] as string | undefined, + ); return { host, config, catalog: host.app.accessor.get(IModelCatalog) as ModelCatalog, - models: host.app.accessor.get(IModelService), - providers: host.app.accessor.get(IProviderService), + models, + providers, }; } @@ -91,6 +107,16 @@ const kimiSections: Record = { }, }; +/** + * Mutate the model registry store WITHOUT firing the change events — the + * silent-write escape hatch for the cache-invalidation tests (replaces the + * old `StubConfigService.setSilent`, which the in-memory registries can no + * longer see). + */ +function silentModelWrite(models: IModelService, records: Record): void { + (models as unknown as { models: Record }).models = records; +} + let savedCustomHeaders: string | undefined; beforeEach(() => { @@ -389,19 +415,17 @@ describe('Model assembly (pure data)', () => { it('builds a refreshable OAuth auth provider for oauth-backed models', async () => { const tokenProvider = stubTokenProvider(['tok-1']); - const config = new StubConfigService({ - providers: { - kimi: { type: 'kimi', oauth: { storage: 'file', key: 'kimi' }, baseUrl: 'https://api.moonshot.ai/v1' }, + const { host, catalog } = createHost( + { + providers: { + kimi: { type: 'kimi', oauth: { storage: 'file', key: 'kimi' }, baseUrl: 'https://api.moonshot.ai/v1' }, + }, + models: { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 1 } }, }, - models: { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 1 } }, - }); - const host = createScopedTestHost([ - [IConfigService, config], - [IOAuthService, stubOAuthService(tokenProvider)], - [IHostRequestHeaders, new HostRequestHeaders({})], - ]); + stubModelOAuthTokens(tokenProvider), + ); try { - const model = (host.app.accessor.get(IModelCatalog) as ModelCatalog).get('k1'); + const model = catalog.get('k1'); expect(model.authProvider.canRefresh).toBe(true); await expect(model.authProvider.getAuth()).resolves.toEqual({ apiKey: 'tok-1' }); } finally { @@ -440,14 +464,14 @@ describe('ModelCatalog caching and config-event invalidation', () => { } }); - it('keeps serving the stale Model on a silent config write until notifyConfigChanged()', async () => { - const { host, catalog, config } = createHost(kimiSections); + it('keeps serving the stale Model on a silent registry write until notifyConfigChanged()', async () => { + const { host, catalog, models } = createHost(kimiSections); try { const before = catalog.get('k1'); // Bypass the change events entirely: the catalog cache is the only // stale layer, and only an explicit notify drops it. - config.setSilent('models', { + silentModelWrite(models, { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 262144, displayName: 'silent' }, }); expect(catalog.get('k1')).toBe(before); @@ -520,16 +544,16 @@ describe('ModelCatalog inspect', () => { }); it('serves the same resolution as get (chain consistency, same cache generation)', () => { - const { host, catalog, config } = createHost(kimiSections); + const { host, catalog, models } = createHost(kimiSections); try { const model = catalog.get('k1'); const view = catalog.inspect('k1'); const { authProvider: _auth, id: _id, name, ...rest } = model; expect(view.resolved).toMatchObject({ ...rest, wireName: name }); - // A silent config write keeps the stale generation: inspect reflects + // A silent registry write keeps the stale generation: inspect reflects // THAT generation (what get keeps serving), never a re-resolution. - config.setSilent('models', { + silentModelWrite(models, { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 262144, displayName: 'silent' }, }); expect(catalog.inspect('k1').resolved.displayName).toBeUndefined(); @@ -743,12 +767,7 @@ describe('ModelCatalog inspect', () => { describe('ModelCatalog ping', () => { it('returns the streamed text and usage on a live success', async () => { - const config = new StubConfigService(kimiSections); - const host = createScopedTestHost([ - [IConfigService, config], - [IOAuthService, stubOAuthService()], - [IHostRequestHeaders, new HostRequestHeaders({})], - ]); + const { host, models, providers } = createHost(kimiSections, stubModelOAuthTokens()); try { const fakeProvider: ChatProvider = { name: 'fake-base', @@ -784,10 +803,9 @@ describe('ModelCatalog ping', () => { createChatProvider: () => fakeProvider, } as unknown as IProtocolAdapterRegistry; const catalog = new ModelCatalog( - config, - host.app.accessor.get(IProviderService), - host.app.accessor.get(IModelService), - host.app.accessor.get(IOAuthService), + providers, + models, + stubModelOAuthTokens(), registry, new HostRequestHeaders({}), ); @@ -1199,16 +1217,12 @@ describe('ModelCatalog enumeration', () => { }); it('marks an OAuth provider connected when a cached token exists', async () => { - const oauth = { - ...stubOAuthService(), - getCachedAccessToken: async () => 'cached-token', - } as unknown as IOAuthService; const { host, catalog } = createHost( { providers: { acme: { type: 'kimi', oauth: { storage: 'file', key: 'oauth/acme' } } }, models: {}, }, - oauth, + stubModelOAuthTokens(undefined, 'cached-token'), ); try { const [provider] = await catalog.listProviders(); @@ -1238,8 +1252,8 @@ describe('ModelCatalog enumeration', () => { }); describe('ModelCatalog setDefaultModel', () => { - it('persists through config and returns the wire model', async () => { - const { host, config, catalog } = createHost(catalogSections); + it('moves the registry default pointer and returns the wire model', async () => { + const { host, models, catalog } = createHost(catalogSections); try { await expect(catalog.setDefaultModel('turbo')).resolves.toEqual({ default_model: 'turbo', @@ -1250,7 +1264,9 @@ describe('ModelCatalog setDefaultModel', () => { max_context_size: 32768, }, }); - expect(config.get('defaultModel')).toBe('turbo'); + // The catalog writes the in-memory pointer; persisting it to config is + // the app/kosongConfig bridge's job. + expect(models.getDefaultModel()).toBe('turbo'); } finally { host.dispose(); } @@ -1268,7 +1284,7 @@ describe('ModelCatalog setDefaultModel', () => { }); it('rejects a model that fails materialization', async () => { - const { host, config, catalog } = createHost({ + const { host, models, catalog } = createHost({ providers: {}, models: { bad: { @@ -1281,7 +1297,7 @@ describe('ModelCatalog setDefaultModel', () => { }); try { await expect(catalog.setDefaultModel('bad')).rejects.toThrow(); - expect(config.get('defaultModel')).toBeUndefined(); + expect(models.getDefaultModel()).toBeUndefined(); } finally { host.dispose(); } diff --git a/packages/agent-core-v2/test/kosong/model/modelService.test.ts b/packages/agent-core-v2/test/kosong/model/modelService.test.ts index 3d4849ae3..10d44bb1c 100644 --- a/packages/agent-core-v2/test/kosong/model/modelService.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelService.test.ts @@ -4,19 +4,16 @@ * * - the section TOML transforms round-trip snake_case ↔ camelCase (including * the nested `overrides` object); - * - `ModelService` CRUD persists through config and diffs section changes - * into `onDidChangeModels` (added/changed/removed). + * - `ModelService` is an in-memory registry: `loadAll` hydrates and resolves + * `ready`, CRUD diffs state changes into `onDidChangeModels` + * (added/changed/removed), and equal writes stay silent. */ import { describe, expect, it } from 'vitest'; -import { createScopedTestHost } from '#/_base/di/test'; -import { IConfigService } from '#/app/config/config'; -import { modelsFromToml, modelsToToml } from '#/kosong/model/configSection'; -import { IModelService, type ModelRecord } from '#/kosong/model/model'; -import '#/kosong/model/modelService'; - -import { StubConfigService } from '../stubs'; +import { modelsFromToml, modelsToToml } from '#/app/kosongConfig/configSection'; +import { type ModelRecord } from '#/kosong/model/model'; +import { ModelService } from '#/kosong/model/modelService'; describe('models TOML transforms', () => { it('converts snake_case entries to camelCase and back', () => { @@ -67,46 +64,76 @@ describe('models TOML transforms', () => { }); describe('ModelService', () => { - function createHost(): { - host: ReturnType; - service: IModelService; - } { - const config = new StubConfigService(); - const host = createScopedTestHost([[IConfigService, config]]); - const service = host.app.accessor.get(IModelService); - return { host, service }; + function createService(models: Readonly> = {}): ModelService { + const service = new ModelService(); + service.loadAll({ ...models }, undefined); + return service; } - it('supports CRUD and diffs section changes into onDidChangeModels', async () => { - const { host, service } = createHost(); - try { - const events: Array<{ - added: readonly string[]; - removed: readonly string[]; - changed: readonly string[]; - }> = []; - service.onDidChangeModels((e) => events.push(e)); + it('resolves ready on the first loadAll and exposes the default pointer', async () => { + const service = new ModelService(); + let ready = false; + void service.ready.then(() => { + ready = true; + }); + await Promise.resolve(); + expect(ready).toBe(false); - const k1: ModelRecord = { provider: 'moonshot', model: 'kimi-k2', maxContextSize: 262144 }; - await service.set('k1', k1); - expect(service.get('k1')).toEqual(k1); - expect(service.list()).toEqual({ k1 }); - expect(events).toEqual([{ added: ['k1'], removed: [], changed: [] }]); + service.loadAll({ k1: { model: 'kimi-k2', maxContextSize: 262144 } }, 'k1'); + await service.ready; + expect(ready).toBe(true); + expect(service.getDefaultModel()).toBe('k1'); + }); - const updated: ModelRecord = { ...k1, displayName: 'K2' }; - await service.set('k1', updated); - expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['k1'] }); + it('supports CRUD and diffs state changes into onDidChangeModels', async () => { + const service = createService(); + const events: Array<{ + added: readonly string[]; + removed: readonly string[]; + changed: readonly string[]; + }> = []; + service.onDidChangeModels((e) => + events.push({ added: e.added, removed: e.removed, changed: e.changed }), + ); - // Rewriting with an identical record still fires the config event but - // diffs to no changed keys. - await service.set('k1', updated); - expect(events.at(-1)).toEqual({ added: [], removed: [], changed: [] }); + const k1: ModelRecord = { provider: 'moonshot', model: 'kimi-k2', maxContextSize: 262144 }; + await service.set('k1', k1); + expect(service.get('k1')).toEqual(k1); + expect(service.list()).toEqual({ k1 }); + expect(events).toEqual([{ added: ['k1'], removed: [], changed: [] }]); - await service.delete('k1'); - expect(service.get('k1')).toBeUndefined(); - expect(events.at(-1)).toEqual({ added: [], removed: ['k1'], changed: [] }); - } finally { - host.dispose(); - } + const updated: ModelRecord = { ...k1, displayName: 'K2' }; + await service.set('k1', updated); + expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['k1'] }); + + // Rewriting with an identical record is silent — no event fires. + await service.set('k1', updated); + expect(events).toHaveLength(2); + + await service.delete('k1'); + expect(service.get('k1')).toBeUndefined(); + expect(events.at(-1)).toEqual({ added: [], removed: ['k1'], changed: [] }); + }); + + it('replaceAll replaces the records and keeps the default pointer', async () => { + const service = createService({ a: { model: 'm-a' }, b: { model: 'm-b' } }); + await service.setDefaultModel('a'); + + await service.replaceAll({ c: { model: 'm-c' } }); + expect(service.list()).toEqual({ c: { model: 'm-c' } }); + expect(service.getDefaultModel()).toBe('a'); + }); + + it('fires the pointer event only on real pointer changes', async () => { + const service = createService({ k1: { model: 'kimi-k2' } }); + const pointerEvents: Array = []; + service.onDidChangeDefaultModel((e) => pointerEvents.push(e.id)); + + await service.setDefaultModel('k1'); + await service.setDefaultModel('k1'); + expect(pointerEvents).toEqual(['k1']); + + await service.setDefaultModel(undefined); + expect(pointerEvents).toEqual(['k1', undefined]); }); }); diff --git a/packages/agent-core-v2/test/kosong/provider/providerService.test.ts b/packages/agent-core-v2/test/kosong/provider/providerService.test.ts index b57439d47..064c1b186 100644 --- a/packages/agent-core-v2/test/kosong/provider/providerService.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/providerService.test.ts @@ -5,86 +5,21 @@ * - `ProviderTypeSchema` is free-form text: unregistered vendor names parse * (validation happens at resolve time, not parse time); * - the section TOML transforms round-trip snake_case ↔ camelCase; - * - `ProviderService` CRUD persists through config, diffs section changes - * into `onDidChangeProviders` (added/changed/removed), and clears - * `defaultProvider` when the default provider is deleted. + * - `ProviderService` is an in-memory registry: `loadAll` hydrates and + * resolves `ready`, CRUD diffs state changes into `onDidChangeProviders` + * (added/changed/removed), equal writes stay silent, and deleting the + * default provider clears the `defaultProvider` pointer. */ import { describe, expect, it } from 'vitest'; -import { createScopedTestHost } from '#/_base/di/test'; -import { Emitter, type Event } from '#/_base/event'; import { - type ConfigChangedEvent, - type ConfigDiagnostic, - type ConfigInspectValue, - IConfigService, - type ResolvedConfig, -} from '#/app/config/config'; -import { providersFromToml, providersToToml } from '#/kosong/provider/configSection'; -import '#/kosong/provider/providerService'; -import { - DEFAULT_PROVIDER_SECTION, - IProviderService, - type ProviderConfig, ProvidersSectionSchema, -} from '#/kosong/provider/provider'; - -class StubConfigService implements IConfigService { - declare readonly _serviceBrand: undefined; - readonly ready = Promise.resolve(); - private readonly _onDidChange = new Emitter(); - readonly onDidChangeConfiguration: Event = this._onDidChange.event; - readonly onDidSectionChange: Event = this._onDidChange.event; - private readonly _values = new Map(); - - get(domain: string): T { - return this._values.get(domain) as T; - } - - inspect(domain: string): ConfigInspectValue { - return { - value: this._values.get(domain) as T | undefined, - defaultValue: undefined, - userValue: undefined, - memoryValue: undefined, - }; - } - - getAll(): ResolvedConfig { - return Object.fromEntries(this._values) as ResolvedConfig; - } - - set(domain: string, patch: unknown): Promise { - const previousValue = this._values.get(domain); - const value = - patch !== null && typeof patch === 'object' - ? { ...(previousValue as Record | undefined), ...patch } - : patch; - this._values.set(domain, value); - this._onDidChange.fire({ domain, source: 'set', value, previousValue }); - return Promise.resolve(); - } - - replace(domain: string, value: unknown): Promise { - const previousValue = this._values.get(domain); - if (value === undefined) { - this._values.delete(domain); - } else { - this._values.set(domain, value); - } - this._onDidChange.fire({ domain, source: 'set', value, previousValue }); - return Promise.resolve(); - } - - reload(): Promise { - return Promise.resolve(); - } - - diagnostics(): readonly ConfigDiagnostic[] { - return []; - } -} + providersFromToml, + providersToToml, +} from '#/app/kosongConfig/configSection'; +import { ProviderService } from '#/kosong/provider/providerService'; +import { type ProviderConfig } from '#/kosong/provider/provider'; describe('ProviderTypeSchema (free-form vendor identity)', () => { it('parses unregistered vendor names — resolve-time validation, not parse-time', () => { @@ -126,61 +61,106 @@ describe('providers TOML transforms', () => { }); describe('ProviderService', () => { - function createHost(): { - host: ReturnType; - service: IProviderService; - config: StubConfigService; - } { - const config = new StubConfigService(); - const host = createScopedTestHost([[IConfigService, config]]); - const service = host.app.accessor.get(IProviderService); - return { host, service, config }; + function createService(providers: Readonly> = {}): ProviderService { + const service = new ProviderService(); + service.loadAll({ ...providers }, undefined); + return service; } - it('supports CRUD and diffs section changes into onDidChangeProviders', async () => { - const { host, service } = createHost(); - try { - const events: Array<{ - added: readonly string[]; - removed: readonly string[]; - changed: readonly string[]; - }> = []; - service.onDidChangeProviders((e) => events.push(e)); + it('resolves ready on the first loadAll and gates mutations on it', async () => { + const service = new ProviderService(); + let ready = false; + void service.ready.then(() => { + ready = true; + }); + await Promise.resolve(); + expect(ready).toBe(false); - const moonshot: ProviderConfig = { type: 'kimi', baseUrl: 'https://api.moonshot.ai/v1' }; - await service.set('moonshot', moonshot); - expect(service.get('moonshot')).toEqual(moonshot); - expect(service.list()).toEqual({ moonshot }); - expect(events).toEqual([{ added: ['moonshot'], removed: [], changed: [] }]); - - const updated: ProviderConfig = { ...moonshot, apiKey: 'sk-1' }; - await service.set('moonshot', updated); - expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['moonshot'] }); - - // Rewriting with an identical record still fires the config event but - // diffs to no changed keys. - await service.set('moonshot', updated); - expect(events.at(-1)).toEqual({ added: [], removed: [], changed: [] }); - - await service.delete('moonshot'); - expect(service.get('moonshot')).toBeUndefined(); - expect(events.at(-1)).toEqual({ added: [], removed: ['moonshot'], changed: [] }); - } finally { - host.dispose(); - } + service.loadAll({ moonshot: { type: 'kimi' } }, 'moonshot'); + await service.ready; + expect(ready).toBe(true); + expect(service.get('moonshot')).toEqual({ type: 'kimi' }); + expect(service.getDefaultProvider()).toBe('moonshot'); }); - it('clears defaultProvider when the default provider is deleted', async () => { - const { host, service, config } = createHost(); - try { - await service.set('moonshot', { type: 'kimi' }); - await config.replace(DEFAULT_PROVIDER_SECTION, 'moonshot'); - expect(config.get(DEFAULT_PROVIDER_SECTION)).toBe('moonshot'); + it('supports CRUD and diffs state changes into onDidChangeProviders', async () => { + const service = createService(); + const events: Array<{ + added: readonly string[]; + removed: readonly string[]; + changed: readonly string[]; + }> = []; + service.onDidChangeProviders((e) => + events.push({ added: e.added, removed: e.removed, changed: e.changed }), + ); - await service.delete('moonshot'); - expect(config.get(DEFAULT_PROVIDER_SECTION)).toBeUndefined(); - } finally { - host.dispose(); - } + const moonshot: ProviderConfig = { type: 'kimi', baseUrl: 'https://api.moonshot.ai/v1' }; + await service.set('moonshot', moonshot); + expect(service.get('moonshot')).toEqual(moonshot); + expect(service.list()).toEqual({ moonshot }); + expect(events).toEqual([{ added: ['moonshot'], removed: [], changed: [] }]); + + const updated: ProviderConfig = { ...moonshot, apiKey: 'sk-1' }; + await service.set('moonshot', updated); + expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['moonshot'] }); + + // Rewriting with an identical record is silent — no event fires. + await service.set('moonshot', updated); + expect(events).toHaveLength(2); + + await service.delete('moonshot'); + expect(service.get('moonshot')).toBeUndefined(); + expect(events.at(-1)).toEqual({ added: [], removed: ['moonshot'], changed: [] }); + }); + + it('loadAll fires only for real diffs on re-sync', async () => { + const service = createService({ moonshot: { type: 'kimi' } }); + const events: unknown[] = []; + service.onDidChangeProviders((e) => + events.push({ added: e.added, removed: e.removed, changed: e.changed }), + ); + + service.loadAll({ moonshot: { type: 'kimi' } }, undefined); + expect(events).toHaveLength(0); + + service.loadAll({ moonshot: { type: 'kimi' }, other: { baseUrl: 'https://example.com' } }, undefined); + expect(events).toEqual([{ added: ['other'], removed: [], changed: [] }]); + }); + + it('replaceAll replaces the records and keeps the default pointer', async () => { + const service = createService({ a: { type: 'kimi' }, b: { type: 'kimi' } }); + await service.setDefaultProvider('a'); + + await service.replaceAll({ c: { type: 'kimi' } }); + expect(service.list()).toEqual({ c: { type: 'kimi' } }); + expect(service.getDefaultProvider()).toBe('a'); + }); + + it('clears the defaultProvider pointer when the default provider is deleted', async () => { + const service = createService({ moonshot: { type: 'kimi' } }); + const pointerEvents: Array = []; + service.onDidChangeDefaultProvider((e) => pointerEvents.push(e.id)); + + await service.setDefaultProvider('moonshot'); + expect(service.getDefaultProvider()).toBe('moonshot'); + + await service.delete('moonshot'); + expect(service.getDefaultProvider()).toBeUndefined(); + expect(pointerEvents).toEqual(['moonshot', undefined]); + }); + + it('a mutation resolves only after the listeners’ waitUntil work completes', async () => { + const service = createService(); + let persistDone = false; + service.onDidChangeProviders((e) => { + e.waitUntil( + new Promise((resolve) => setTimeout(resolve, 50)).then(() => { + persistDone = true; + }), + ); + }); + + await service.set('moonshot', { type: 'kimi' }); + expect(persistDone).toBe(true); }); }); diff --git a/packages/agent-core-v2/test/kosong/stubs.ts b/packages/agent-core-v2/test/kosong/stubs.ts index 1903e911e..7ab26a4a0 100644 --- a/packages/agent-core-v2/test/kosong/stubs.ts +++ b/packages/agent-core-v2/test/kosong/stubs.ts @@ -14,6 +14,7 @@ import { IConfigService, type ResolvedConfig, } from '#/app/config/config'; +import type { IModelOAuthTokens } from '#/kosong/model/modelOAuth'; export class StubConfigService implements IConfigService { declare readonly _serviceBrand: undefined; @@ -123,3 +124,24 @@ export function stubOAuthService(tokenProvider?: StubTokenProvider): IOAuthServi getCachedAccessToken: () => Promise.resolve(undefined), } as unknown as IOAuthService; } + +/** + * The kosong-side OAuth port stub (`IModelOAuthTokens`), mirroring what the + * real `app/kosongConfig` adapter does over `IOAuthService`: a programmable + * token provider for `getAccessToken` and a probeable cached-token flag. + */ +export function stubModelOAuthTokens( + tokenProvider?: StubTokenProvider, + cachedToken?: string, +): IModelOAuthTokens { + return { + _serviceBrand: undefined, + hasCachedAccessToken: () => Promise.resolve(cachedToken !== undefined), + getAccessToken: (_provider, _oauthRef, options) => + tokenProvider === undefined + ? Promise.reject(new Error('auth.login_required')) + : tokenProvider.getAccessToken( + options?.force === true ? { force: true } : undefined, + ), + }; +} diff --git a/packages/kap-server/src/routes/modelCatalog.ts b/packages/kap-server/src/routes/modelCatalog.ts index beabdf3cb..9f0fa60df 100644 --- a/packages/kap-server/src/routes/modelCatalog.ts +++ b/packages/kap-server/src/routes/modelCatalog.ts @@ -30,7 +30,7 @@ import { type Scope, } from '@moonshot-ai/agent-core-v2'; import { setDefaultModelResponseSchema } from '@moonshot-ai/agent-core-v2/kosong/model/catalog'; -import { refreshProviderModelsResponseSchema } from '@moonshot-ai/agent-core-v2/kosong/model/discovery'; +import { refreshProviderModelsResponseSchema } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; diff --git a/packages/klient/examples/kosong-config-stress.ts b/packages/klient/examples/kosong-config-stress.ts new file mode 100644 index 000000000..74599a1c8 --- /dev/null +++ b/packages/klient/examples/kosong-config-stress.ts @@ -0,0 +1,266 @@ +/** + * Stress check for the kosong ⇄ kosongConfig read/write path, against an + * in-process engine (memory transport) bootstrapped on a throwaway home. + * + * The contract under pressure: an awaited kosong mutation resolves only after + * the write has been persisted by the kosongConfig bridge (with retries on + * transient disk failures), and config-originated writes land in the kosong + * registries synchronously. Every phase asserts read-after-write visibility + * through BOTH facades — `kosong.*` (registry view) and `config.get` (the + * persisted section the bridge writes) — with no sleeps or flush helpers: + * any settling gap shows up here as a failed assertion. + * + * Phases: + * 1. sequential provider add/remove read-after-write; + * 2. same-name add/remove flip-flop (hammering the persist chain with + * alternating real writes and no-op merges); + * 3. sequential default-model churn; + * 4. concurrent burst adds (persist-chain serialization + merge); + * 5. concurrent mixed sections: removes racing adds racing a default flip; + * 6. config → kosong direction (config.replace must be visible to the + * registry facade immediately); + * 7. restart durability: dispose the app, re-bootstrap on the same home, + * and compare every section against the pre-restart snapshot. + * + * pnpm -C packages/klient stress:kosong-config + * + * Env: KIMI_MODEL_NAME is unset for the run (it would pin `defaultModel` and + * break the pointer assertions); restored on exit. + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; +import { IKosongConfigService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/kosongConfig'; +import { type Klient } from '@moonshot-ai/klient'; +import { createKlient } from '@moonshot-ai/klient/memory'; + +function assert(cond: boolean, message: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${message}`); +} + +interface ProvidersSectionView { + readonly [name: string]: { readonly apiKey?: string } | undefined; +} + +interface ModelsSectionView { + readonly [id: string]: { readonly provider?: string; readonly model?: string } | undefined; +} + +const apiKeyProvider = (apiKey: string) => ({ + type: 'openai', + auth: { method: 'api-key' as const, apiKey }, +}); + +const anonymousModel = (id: string) => ({ + id, + model: `${id}-model`, + protocol: 'openai', + baseUrl: 'http://127.0.0.1:1', + auth: { method: 'api-key' as const, apiKey: `sk-${id}` }, + maxContextSize: 8192, +}); + +/** Key-order-independent deep equality (post-restart TOML round-trips reorder keys). */ +const stable = (value: unknown): string => + JSON.stringify(value, (_key, v: unknown) => + v !== null && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries( + Object.entries(v as Record).toSorted(([a], [b]) => a.localeCompare(b)), + ) + : v, + ); + +async function phase(label: string, ops: number, run: () => Promise): Promise { + const startedAt = Date.now(); + await run(); + const elapsed = Date.now() - startedAt; + console.log( + `[ok] ${label} ${String(ops)} ops in ${String(elapsed)}ms (${((ops * 1000) / Math.max(elapsed, 1)).toFixed(0)} ops/s)`, + ); +} + +async function main(): Promise { + const homeDir = await mkdtemp(join(tmpdir(), 'klient-kosong-stress-')); + const { app } = bootstrap({ homeDir }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + // Filled in right before the restart phase. + let snapshot: Record = {}; + try { + const klient = createKlient({ scope: app }); + const config = klient.global.config; + const kosong = klient.global.kosong; + + // 1) Sequential read-after-write: each awaited add must already be in the + // persisted section when it resolves — no settling window allowed. + await phase('sequential provider add read-after-write', 30, async () => { + for (let i = 0; i < 30; i += 1) { + const name = `seq_${String(i)}`; + await kosong.addProvider(name, apiKeyProvider(`sk-seq-${String(i)}`)); + const providers = await config.get('providers'); + assert( + providers[name]?.apiKey === `sk-seq-${String(i)}`, + `providers.${name} persisted before addProvider resolved`, + ); + const got = await kosong.getProvider(name); + assert(got !== undefined, `providers.${name} visible to the registry facade`); + } + }); + + // 2) Same-name flip-flop: add → remove → add on one key, asserting both + // directions of the write land before the await returns. + await phase('same-name add/remove flip-flop', 30, async () => { + for (let i = 0; i < 15; i += 1) { + await kosong.addProvider('flip', apiKeyProvider(`sk-flip-${String(i)}`)); + let providers = await config.get('providers'); + assert(providers['flip']?.apiKey === `sk-flip-${String(i)}`, 'flip add persisted'); + await kosong.removeProvider('flip'); + providers = await config.get('providers'); + assert(providers['flip'] === undefined, 'flip remove persisted'); + } + }); + + // 3) Sequential default-model churn: every awaited pointer write must be + // visible in the persisted defaultModel section immediately. + await phase('default-model churn', 22, async () => { + await kosong.addProvider(anonymousModel('churn_a')); + await kosong.addProvider(anonymousModel('churn_b')); + for (let i = 0; i < 20; i += 1) { + const id = i % 2 === 0 ? 'churn_a' : 'churn_b'; + await kosong.setDefaultModel(id); + const def = await config.get('defaultModel'); + assert(def === id, `defaultModel=${id} persisted before setDefaultModel resolved`); + } + }); + + // 4) Concurrent burst: 40 adds racing on one section. The persist chain + // must serialize them and converge to the full set — no lost updates. + await phase('concurrent burst adds', 40, async () => { + await Promise.all( + Array.from({ length: 40 }, (_, i) => + kosong.addProvider(`burst_${String(i)}`, apiKeyProvider(`sk-burst-${String(i)}`)), + ), + ); + const providers = await config.get('providers'); + for (let i = 0; i < 40; i += 1) { + assert( + providers[`burst_${String(i)}`]?.apiKey === `sk-burst-${String(i)}`, + `burst_${String(i)} survived the concurrent burst`, + ); + } + }); + + // 5) Mixed sections racing: removes of phase-1 providers + anonymous-model + // adds (models section) + a default flip, all in flight at once. + await phase('concurrent mixed sections', 41, async () => { + await Promise.all([ + ...Array.from({ length: 30 }, (_, i) => kosong.removeProvider(`seq_${String(i)}`)), + ...Array.from({ length: 10 }, (_, i) => kosong.addProvider(anonymousModel(`mix_${String(i)}`))), + kosong.setDefaultModel('churn_b'), + ]); + const providers = await config.get('providers'); + const models = await config.get('models'); + for (let i = 0; i < 30; i += 1) { + assert(providers[`seq_${String(i)}`] === undefined, `seq_${String(i)} removal persisted`); + } + for (let i = 0; i < 10; i += 1) { + assert(models[`mix_${String(i)}`]?.model === `mix_${String(i)}-model`, `mix_${String(i)} persisted`); + } + assert((await config.get('defaultModel')) === 'churn_b', 'default flip persisted'); + }); + + // 6) config → kosong: a config-originated write must be visible to the + // registry facade as soon as its await resolves. + await phase('config-originated replace visible to registry', 4, async () => { + await config.replace({ + domain: 'providers', + value: { + ...(await config.get('providers')), + cfg_only: { type: 'openai', apiKey: 'sk-cfg-only' }, + }, + }); + const got = await kosong.getProvider('cfg_only'); + assert(got !== undefined, 'config.replace(providers) visible to kosong.getProvider'); + + await config.replace({ + domain: 'models', + value: { + ...(await config.get('models')), + cfg_model: { provider: 'cfg_only', model: 'cfg-model', maxContextSize: 4096 }, + }, + }); + const modelIds = (await kosong.listModels()).map((m) => m.model); + assert(modelIds.includes('cfg_model'), 'config.replace(models) visible to kosong.listModels'); + + await config.set({ domain: 'providers', patch: { cfg_only: { apiKey: 'sk-cfg-updated' } } }); + const providers = await config.get('providers'); + assert(providers['cfg_only']?.apiKey === 'sk-cfg-updated', 'config.set merge persisted'); + + await kosong.removeProvider('cfg_only'); + assert( + (await config.get('providers'))['cfg_only'] === undefined, + 'registry remove persisted over the config-originated entry', + ); + }); + + snapshot = { + providers: await config.get('providers'), + models: await config.get('models'), + defaultProvider: await config.get('defaultProvider'), + defaultModel: await config.get('defaultModel'), + }; + + await klient.close(); + } finally { + app.dispose(); + } + + // 7) Restart durability: a fresh engine on the SAME home must rehydrate the + // exact pre-restart state — the ultimate proof the writes hit the disk. + await phase('restart durability', 1, async () => { + const { app: app2 } = bootstrap({ homeDir }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + try { + // Reads race the async startup otherwise: config loads from disk, then + // the bridge hydrates the registries from it. + await app2.accessor.get(IConfigService).ready; + await app2.accessor.get(IKosongConfigService).ready; + const klient2: Klient = createKlient({ scope: app2 }); + for (const [section, expected] of Object.entries(snapshot)) { + const actual: unknown = await klient2.global.config.get(section); + if (stable(actual) !== stable(expected)) { + console.error(`[diff] ${section}\n expected: ${stable(expected)}\n actual: ${stable(actual)}`); + } + assert( + stable(actual) === stable(expected), + `${section} rehydrated from disk equal to the pre-restart snapshot`, + ); + } + const providerIds = new Set((await klient2.global.kosong.listProviders()).map((p) => p.id)); + for (const name of Object.keys(snapshot['providers'] as Record)) { + assert(providerIds.has(name), `provider ${name} listed after restart`); + } + await klient2.close(); + } finally { + app2.dispose(); + } + }); + + await rm(homeDir, { recursive: true, force: true }); + console.log('kosong-config stress: OK'); +} + +const pinnedModelEnv = process.env['KIMI_MODEL_NAME']; +delete process.env['KIMI_MODEL_NAME']; +try { + await main(); +} catch (error) { + console.error(error); + process.exit(1); +} finally { + if (pinnedModelEnv !== undefined) process.env['KIMI_MODEL_NAME'] = pinnedModelEnv; +} diff --git a/packages/klient/package.json b/packages/klient/package.json index 530940826..c3d6d499c 100644 --- a/packages/klient/package.json +++ b/packages/klient/package.json @@ -38,6 +38,7 @@ "smoke": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/smoke.ts", "smoke:boundary": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/model-requester-boundary.ts", "smoke:select-tools": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/kimi-select-tools.ts", + "stress:kosong-config": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/kosong-config-stress.ts", "clean": "rm -rf dist", "docker:e2e": "bash scripts/run-docker-e2e.sh" }, diff --git a/packages/klient/src/contract/global/providerDiscovery.ts b/packages/klient/src/contract/global/providerDiscovery.ts index 638db684b..eac3bfc33 100644 --- a/packages/klient/src/contract/global/providerDiscovery.ts +++ b/packages/klient/src/contract/global/providerDiscovery.ts @@ -1,7 +1,7 @@ /** * `providerDiscovery` — the engine's `IProviderDiscoveryService`: remote * provider-model discovery and config sync. Mirrors - * `agent-core-v2/kosong/model/discovery.ts`. + * `agent-core-v2/app/kosongConfig/discovery.ts`. */ import { z } from 'zod'; diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index f75999ed4..72be8c00a 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -33,7 +33,7 @@ import type { } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; import type { ModelRecord } from '@moonshot-ai/agent-core-v2/kosong/model/model'; import type { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog'; -import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/kosong/model/discovery'; +import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery'; import type { AnonymousProviderInput, GenerateEvent, GenerateInput, GenerateParams, ProviderInput } from './kosong-types.js'; import type { diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index bf592ab30..354089a17 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -11,7 +11,7 @@ import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/work import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; import { IModelService } from '@moonshot-ai/agent-core-v2/kosong/model/model'; import { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog'; -import { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/kosong/model/discovery'; +import { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery'; import { IProviderService } from '@moonshot-ai/agent-core-v2/kosong/provider/provider'; import { IAuthSummaryService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index b1f6ac4b5..461e0a3c7 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -84,7 +84,7 @@ import type { } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; import type { ModelRecord } from '@moonshot-ai/agent-core-v2/kosong/model/model'; import type { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog'; -import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/kosong/model/discovery'; +import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery'; import type { GetPluginInfoInput, InstallPluginInput,