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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .agents/skills/agent-core-dev/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Equal<z.infer<typeof Schema>, 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

Expand Down Expand Up @@ -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

Expand All @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions .changeset/kosong-persistence-bridge.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 28 additions & 8 deletions packages/agent-core-v2/scripts/check-domain-layers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -398,13 +409,21 @@ 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
// backend, which is why it lives beside the OAuth toolkit rather than in the
// 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',
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions packages/agent-core-v2/src/_base/utils/typeEquality.ts
Original file line number Diff line number Diff line change
@@ -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<T> = { [K in keyof T]: T[K] } & {};

export type Equal<A, B> =
(<T>() => T extends Flatten<A> ? 1 : 2) extends <T>() => T extends Flatten<B> ? 1 : 2
? true
: false;

export type AssertExact<T extends true> = T;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -620,9 +622,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
const systemPromptHash = fingerprint(input.systemPrompt);
const overrides = this.config.get<ModelOverrides>('modelOverrides');
const thinkingConfig = this.config.get<ThinkingConfig>(THINKING_SECTION);
const models = this.config.get<ModelsSection>(MODELS_SECTION);
const modelConfig =
input.modelAlias === undefined ? undefined : models?.[input.modelAlias];
input.modelAlias === undefined ? undefined : this.modelService.get(input.modelAlias);
const payload: PayloadOf<typeof llmRequest> = {
kind: requestKindForRecord(fields),
provider: input.protocol,
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/agent/profile/profileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
19 changes: 14 additions & 5 deletions packages/agent-core-v2/src/app/auth/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -614,10 +619,14 @@ export class AuthSummaryService implements IAuthSummaryService {
}

async ensureReady(modelOverride?: string): Promise<void> {
// 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<Record<string, ModelRecord> | undefined>(MODELS_SECTION) ?? {};
const modelId = modelOverride ?? this.config.get<string | undefined>(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();
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-core-v2/src/app/auth/configSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Equal<z.infer<typeof OAuthRefSchema>, OAuthRef>>;

export const MoonshotServiceConfigSchema = z.object({
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
Expand Down
24 changes: 13 additions & 11 deletions packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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<AuthSummary> {
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<string>(DEFAULT_MODEL_SECTION));
const default_model = nonEmpty(this.modelService.getDefaultModel());

let managed_provider: AuthSummary['managed_provider'] = null;
if (providers[MANAGED_PROVIDER_NAME] !== undefined) {
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core-v2/src/app/bootstrap/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}

Expand Down
Loading
Loading