From 610a957e8055ae2f6fa73e548ee055753c9b7b7d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 13 Jul 2026 20:09:30 +0800 Subject: [PATCH 1/2] fix(agent-core-v2): support services.moonshot_search config for WebSearch The v2 engine only resolved the WebSearch backend from the managed Kimi OAuth provider, so an environment that configures web search purely via [services.moonshot_search] (api key, no login) got no provider and the WebSearch tool stayed hidden. Register the services config section and resolve the provider config-first, mirroring v1 precedence. --- .changeset/v2-moonshot-search-config.md | 5 + .../src/app/auth/configSection.ts | 112 +++++++++++ .../app/auth/webSearch/webSearchService.ts | 57 ++++-- packages/agent-core-v2/src/index.ts | 1 + .../agent-core-v2/test/app/auth/auth.test.ts | 176 ++++++++++++++++++ 5 files changed, 340 insertions(+), 11 deletions(-) create mode 100644 .changeset/v2-moonshot-search-config.md create mode 100644 packages/agent-core-v2/src/app/auth/configSection.ts diff --git a/.changeset/v2-moonshot-search-config.md b/.changeset/v2-moonshot-search-config.md new file mode 100644 index 0000000000..bbadfae582 --- /dev/null +++ b/.changeset/v2-moonshot-search-config.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Support the `services.moonshot_search` api-key config for WebSearch in the v2 engine, matching v1: the tool is now available without an OAuth login, and explicit config takes precedence over the OAuth-derived provider. diff --git a/packages/agent-core-v2/src/app/auth/configSection.ts b/packages/agent-core-v2/src/app/auth/configSection.ts new file mode 100644 index 0000000000..160a52fb31 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/configSection.ts @@ -0,0 +1,112 @@ +/** + * `auth` domain (L2) — `services` config-section schema and TOML transforms. + * + * Owns the `[services]` configuration section (`moonshot_search` / + * `moonshot_fetch`), mirroring v1's `ServicesConfigSchema`: the schema, and the + * snake_case ↔ camelCase TOML transforms (including the nested `oauth` and + * `custom_headers` normalization, with `custom_headers` record keys preserved + * verbatim). Self-registered at module load via `registerConfigSection`, so the + * `config` domain never imports this domain's types. + * + * The `auth` domain owns this section because its OAuth login/logout flows + * provision and clear it (see `authService`) and its `WebSearchProviderService` + * consumes `moonshot_search`; the `web` domain reads `moonshot_fetch` from the + * same section. Bound at App scope. + */ + +import { z } from 'zod'; + +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { + camelToSnake, + cloneRecord, + isPlainObject, + plainObjectToToml, + setDefined, + snakeToCamel, + transformPlainObject, +} from '#/app/config/toml'; +import { OAuthRefSchema } from '#/app/provider/provider'; + +export const SERVICES_SECTION = 'services'; + +const StringRecordSchema = z.record(z.string(), z.string()); + +export const MoonshotServiceConfigSchema = z.object({ + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + oauth: OAuthRefSchema.optional(), + customHeaders: StringRecordSchema.optional(), +}); + +export type MoonshotServiceConfig = z.infer; + +export const ServicesConfigSchema = z.object({ + moonshotSearch: MoonshotServiceConfigSchema.optional(), + moonshotFetch: MoonshotServiceConfigSchema.optional(), +}); + +export type ServicesConfig = z.infer; + +/** Read transform: snake_case file → camelCase in-memory `services` object. */ +export const servicesFromToml = (rawSnake: unknown): unknown => { + if (!isPlainObject(rawSnake)) return rawSnake; + const out: Record = {}; + for (const [name, entry] of Object.entries(rawSnake)) { + out[snakeToCamel(name)] = isPlainObject(entry) ? serviceEntryFromToml(entry) : entry; + } + return out; +}; + +function serviceEntryFromToml(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 === 'customHeaders') { + out[targetKey] = isPlainObject(value) ? cloneRecord(value) : value; + } else { + out[targetKey] = value; + } + } + return out; +} + +/** Write transform: camelCase in-memory `services` object → snake_case file. */ +export const servicesToToml = (value: unknown, rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + // Preserve unknown top-level entries (e.g. a user-defined service) verbatim, + // mirroring v1's `servicesToToml` cloning `rawServices`. + const out = cloneRecord(rawSnake); + writeService(out, 'moonshot_search', value['moonshotSearch']); + writeService(out, 'moonshot_fetch', value['moonshotFetch']); + return out; +}; + +function writeService(out: Record, snakeKey: string, service: unknown): void { + if (isPlainObject(service)) { + out[snakeKey] = serviceEntryToToml(service); + } else { + delete out[snakeKey]; + } +} + +function serviceEntryToToml(service: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(service)) { + if (key === 'oauth' && isPlainObject(value)) { + out[camelToSnake(key)] = plainObjectToToml(value, undefined); + } else if (key === 'customHeaders' && value !== undefined) { + out[camelToSnake(key)] = cloneRecord(value); + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +registerConfigSection(SERVICES_SECTION, ServicesConfigSchema, { + fromToml: servicesFromToml, + toToml: servicesToToml, +}); diff --git a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts index 5a639402cf..0544fd7c83 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts @@ -1,17 +1,22 @@ /** * `auth` domain (cross-cutting) — `IWebSearchProviderService` implementation. * - * Resolves the OAuth-backed `WebSearch` backend for the managed Kimi OAuth - * provider. When `managed:kimi-code` is configured with an `oauth` ref (the - * state after a successful Kimi login), this service builds a - * `MoonshotWebSearchProvider` whose bearer token comes from - * `IOAuthService.resolveTokenProvider(...)` and whose default headers are the - * host's Kimi identity headers (`IHostRequestHeaders`, mirroring v1's - * `kimiRequestHeaders`); otherwise it yields `undefined` so the - * self-registering `WebSearch` tool stays hidden. Owns no tool registration — - * the `WebSearch` tool self-registers via `registerTool(...)` and reads this - * service from the Agent-scope accessor. Tests and hosts that need a custom - * backend bind `IWebSearchProviderService` directly. Bound at App scope. + * Resolves the `WebSearch` backend from two sources, in precedence order: + * (1) an explicit `[services.moonshot_search]` config section (read through + * `config`, mirroring v1 where that section is the single authoritative + * web-search source) — built with its `apiKey` and/or an `oauth` ref resolved + * through `IOAuthService.resolveTokenProvider(...)`; and (2) the managed Kimi + * OAuth provider (`managed:kimi-code`) when it carries an `oauth` ref (the + * state after a successful Kimi login), whose bearer token comes from + * `IOAuthService.resolveTokenProvider(...)` and whose base URL is derived from + * the provider's `baseUrl`. The explicit config wins over the managed + * derivation. Both use the host's Kimi identity headers (`IHostRequestHeaders`, + * mirroring v1's `kimiRequestHeaders`) as default headers. When neither source + * is configured it yields `undefined` so the self-registering `WebSearch` tool + * stays hidden. Owns no tool registration — the `WebSearch` tool self-registers + * via `registerTool(...)` and reads this service from the Agent-scope accessor. + * Tests and hosts that need a custom backend bind `IWebSearchProviderService` + * directly. Bound at App scope. */ import { @@ -22,9 +27,11 @@ import { 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 { IHostRequestHeaders } from '#/app/model/hostRequestHeaders'; import { IProviderService } from '#/app/provider/provider'; +import { SERVICES_SECTION, type ServicesConfig } from '../configSection'; import { MoonshotWebSearchProvider } from './providers/moonshot-web-search'; import type { WebSearchProvider } from './tools/web-search'; import { IWebSearchProviderService } from './webSearch'; @@ -36,9 +43,32 @@ export class WebSearchProviderService implements IWebSearchProviderService { @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, + @IConfigService private readonly config: IConfigService, ) {} getWebSearchProvider(): WebSearchProvider | undefined { + return this.fromServicesConfig() ?? this.fromManagedOAuth(); + } + + private fromServicesConfig(): WebSearchProvider | undefined { + const search = this.config.get(SERVICES_SECTION)?.moonshotSearch; + if (search?.baseUrl === undefined) { + return undefined; + } + const tokenProvider = + search.oauth === undefined + ? undefined + : this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, search.oauth); + return new MoonshotWebSearchProvider({ + baseUrl: search.baseUrl, + tokenProvider, + apiKey: nonEmptyString(search.apiKey), + defaultHeaders: { ...this.hostHeaders.headers }, + customHeaders: search.customHeaders, + }); + } + + private fromManagedOAuth(): WebSearchProvider | undefined { const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); if (provider?.type !== 'kimi' || provider.oauth === undefined) { return undefined; @@ -60,6 +90,11 @@ export class WebSearchProviderService implements IWebSearchProviderService { } } +function nonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + registerScopedService( LifecycleScope.App, IWebSearchProviderService, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 94e31c4ef4..1118082544 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -296,6 +296,7 @@ export * from '#/persistence/backends/memory/inMemoryStorageService'; import '#/app/auth/webSearch/tools/web-search'; export * from '#/app/auth/auth'; export * from '#/app/auth/authService'; +export * from '#/app/auth/configSection'; export * from '#/app/auth/webSearch/webSearch'; export * from '#/app/auth/webSearch/webSearchService'; export * from '#/app/auth/webSearch/providers/moonshot-web-search'; 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 17517172a0..0e3fae8ceb 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -13,11 +13,19 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IAuthSummaryService, IOAuthService, IOAuthToolkit } from '#/app/auth/auth'; import { AuthSummaryService, OAuthService } from '#/app/auth/authService'; +import { + SERVICES_SECTION, + servicesFromToml, + servicesToToml, + ServicesConfigSchema, + type ServicesConfig, +} from '#/app/auth/configSection'; import { IWebSearchProviderService } from '#/app/auth/webSearch/webSearch'; import { WebSearchProviderService } from '#/app/auth/webSearch/webSearchService'; import { IAuthLegacyService } from '#/app/authLegacy/authLegacy'; import { AuthLegacyService } from '#/app/authLegacy/authLegacyService'; import { IConfigService } from '#/app/config/config'; +import { ConfigRegistry } from '#/app/config/configService'; import { type DomainEvent, IEventService } from '#/app/event/event'; import { ILogService } from '#/_base/log/log'; import { IHostRequestHeaders } from '#/app/model/hostRequestHeaders'; @@ -628,11 +636,13 @@ describe('WebSearchProviderService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let providers: Record; + let servicesConfig: ServicesConfig | undefined; let resolveTokenProvider: ReturnType; beforeEach(() => { disposables = new DisposableStore(); providers = {}; + servicesConfig = undefined; resolveTokenProvider = vi .fn() .mockReturnValue({ getAccessToken: async () => 'access-token' }); @@ -651,6 +661,10 @@ describe('WebSearchProviderService', () => { 'X-Msh-Device-Id': 'device-test', }, }); + reg.definePartialInstance(IConfigService, { + get: ((domain: string) => + domain === SERVICES_SECTION ? servicesConfig : undefined) as IConfigService['get'], + }); reg.define(IWebSearchProviderService, WebSearchProviderService); }, }); @@ -737,6 +751,168 @@ describe('WebSearchProviderService', () => { expect(headers['X-Custom']).toBe('yes'); expect(JSON.parse(init.body as string)).toEqual({ text_query: 'hello' }); }); + + it('builds a search provider from the services.moonshot_search api_key config', async () => { + servicesConfig = { + moonshotSearch: { + baseUrl: 'https://search.example.com/search', + apiKey: 'search-key', + customHeaders: { 'X-Custom': 'yes' }, + }, + }; + const fetchMock = vi.fn().mockResolvedValue({ + status: 200, + json: async () => ({ + search_results: [{ title: 'Title', url: 'https://example.com', snippet: 'Snippet' }], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const provider = createService().getWebSearchProvider(); + expect(provider).not.toBeUndefined(); + expect(resolveTokenProvider).not.toHaveBeenCalled(); + const results = await provider!.search('hello'); + + expect(results).toEqual([ + { title: 'Title', url: 'https://example.com', snippet: 'Snippet' }, + ]); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://search.example.com/search'); + const headers = init.headers as Record; + expect(headers['Authorization']).toBe('Bearer search-key'); + expect(headers['User-Agent']).toBe('kimi-code-cli/test'); + expect(headers['X-Msh-Device-Id']).toBe('device-test'); + expect(headers['X-Custom']).toBe('yes'); + }); + + it('prefers the services.moonshot_search config over the managed oauth provider', async () => { + servicesConfig = { + moonshotSearch: { baseUrl: 'https://config.example.com/search', apiKey: 'config-key' }, + }; + providers = { + [OAUTH_PROVIDER]: { + type: 'kimi', + baseUrl: 'https://managed.example.com/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }; + const fetchMock = vi.fn().mockResolvedValue({ + status: 200, + json: async () => ({ search_results: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + + const provider = createService().getWebSearchProvider(); + expect(provider).not.toBeUndefined(); + await provider!.search('hello'); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://config.example.com/search'); + const headers = init.headers as Record; + expect(headers['Authorization']).toBe('Bearer config-key'); + expect(resolveTokenProvider).not.toHaveBeenCalled(); + }); + + it('builds a search provider from the services.moonshot_search oauth ref', async () => { + servicesConfig = { + moonshotSearch: { + baseUrl: 'https://search.example.com/search', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }; + const fetchMock = vi.fn().mockResolvedValue({ + status: 200, + json: async () => ({ search_results: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + + const provider = createService().getWebSearchProvider(); + expect(provider).not.toBeUndefined(); + expect(resolveTokenProvider).toHaveBeenCalledWith(OAUTH_PROVIDER, { + storage: 'file', + key: 'oauth/kimi-code', + }); + await provider!.search('hello'); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record)['Authorization']).toBe('Bearer access-token'); + }); + + it('returns undefined when services.moonshot_search has no baseUrl and no managed oauth', () => { + servicesConfig = { moonshotSearch: { apiKey: 'search-key' } }; + expect(createService().getWebSearchProvider()).toBeUndefined(); + expect(resolveTokenProvider).not.toHaveBeenCalled(); + }); +}); + +describe('services config section', () => { + it('registers the services section and validates its schema', () => { + const registry = new ConfigRegistry(); + + expect(registry.getSection(SERVICES_SECTION)).toBeDefined(); + expect( + registry.validate(SERVICES_SECTION, { + moonshotSearch: { baseUrl: 'https://api.example.com/search', apiKey: 'search-key' }, + moonshotFetch: { baseUrl: 'https://api.example.com/fetch' }, + }), + ).toEqual({ + moonshotSearch: { baseUrl: 'https://api.example.com/search', apiKey: 'search-key' }, + moonshotFetch: { baseUrl: 'https://api.example.com/fetch' }, + }); + expect(() => + registry.validate(SERVICES_SECTION, { moonshotSearch: { baseUrl: 42 } }), + ).toThrow(); + }); + + it('maps services from TOML snake_case to camelCase', () => { + expect( + servicesFromToml({ + moonshot_search: { + base_url: 'https://api.example.com/search', + api_key: 'search-key', + custom_headers: { 'X-Search': '1' }, + oauth: { storage: 'file', key: 'oauth/kimi-code', oauth_host: 'https://auth.example.com' }, + }, + moonshot_fetch: { base_url: 'https://api.example.com/fetch', api_key: 'fetch-key' }, + }), + ).toEqual({ + moonshotSearch: { + baseUrl: 'https://api.example.com/search', + apiKey: 'search-key', + customHeaders: { 'X-Search': '1' }, + oauth: { storage: 'file', key: 'oauth/kimi-code', oauthHost: 'https://auth.example.com' }, + }, + moonshotFetch: { baseUrl: 'https://api.example.com/fetch', apiKey: 'fetch-key' }, + }); + }); + + it('maps services back to TOML snake_case, preserving unknown entries', () => { + expect( + servicesToToml( + { + moonshotSearch: { + baseUrl: 'https://api.example.com/search', + apiKey: 'search-key', + customHeaders: { 'X-Search': '1' }, + oauth: { + storage: 'file', + key: 'oauth/kimi-code', + oauthHost: 'https://auth.example.com', + }, + }, + }, + { custom_service: { base_url: 'https://service.example.com' } }, + ), + ).toEqual({ + moonshot_search: { + base_url: 'https://api.example.com/search', + api_key: 'search-key', + custom_headers: { 'X-Search': '1' }, + oauth: { storage: 'file', key: 'oauth/kimi-code', oauth_host: 'https://auth.example.com' }, + }, + custom_service: { base_url: 'https://service.example.com' }, + }); + }); }); describe('AuthSummaryService', () => { From c947a678f11c055c4b97e3eb91f715a434ae5845 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 13 Jul 2026 20:28:51 +0800 Subject: [PATCH 2/2] fix(agent-core-v2): preserve custom services config --- .changeset/v2-moonshot-search-config.md | 2 +- .../src/app/auth/configSection.ts | 10 +++-- .../agent-core-v2/test/app/auth/auth.test.ts | 39 +++++++++++++++++-- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.changeset/v2-moonshot-search-config.md b/.changeset/v2-moonshot-search-config.md index bbadfae582..e871dd8fe4 100644 --- a/.changeset/v2-moonshot-search-config.md +++ b/.changeset/v2-moonshot-search-config.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch --- Support the `services.moonshot_search` api-key config for WebSearch in the v2 engine, matching v1: the tool is now available without an OAuth login, and explicit config takes precedence over the OAuth-derived provider. diff --git a/packages/agent-core-v2/src/app/auth/configSection.ts b/packages/agent-core-v2/src/app/auth/configSection.ts index 160a52fb31..42e50066a5 100644 --- a/packages/agent-core-v2/src/app/auth/configSection.ts +++ b/packages/agent-core-v2/src/app/auth/configSection.ts @@ -41,10 +41,12 @@ export const MoonshotServiceConfigSchema = z.object({ export type MoonshotServiceConfig = z.infer; -export const ServicesConfigSchema = z.object({ - moonshotSearch: MoonshotServiceConfigSchema.optional(), - moonshotFetch: MoonshotServiceConfigSchema.optional(), -}); +export const ServicesConfigSchema = z + .object({ + moonshotSearch: MoonshotServiceConfigSchema.optional(), + moonshotFetch: MoonshotServiceConfigSchema.optional(), + }) + .passthrough(); export type ServicesConfig = z.infer; 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 0e3fae8ceb..6d7872fced 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -6,7 +6,10 @@ */ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; -import { resolveKimiCodeRuntimeAuth } from '@moonshot-ai/kimi-code-oauth'; +import { + clearManagedKimiCodeConfig, + resolveKimiCodeRuntimeAuth, +} from '@moonshot-ai/kimi-code-oauth'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; @@ -462,7 +465,7 @@ describe('OAuthService', () => { }); it('logout removes managed web services while preserving unrelated services', async () => { - services = { + services = ServicesConfigSchema.parse({ moonshotSearch: { baseUrl: 'https://api.example.com/search', apiKey: '', @@ -476,7 +479,7 @@ describe('OAuthService', () => { customService: { baseUrl: 'https://service.example.com', }, - }; + }); const svc = createService(); await expect(svc.logout(OAUTH_PROVIDER)).resolves.toEqual({ @@ -854,10 +857,12 @@ describe('services config section', () => { registry.validate(SERVICES_SECTION, { moonshotSearch: { baseUrl: 'https://api.example.com/search', apiKey: 'search-key' }, moonshotFetch: { baseUrl: 'https://api.example.com/fetch' }, + customService: { baseUrl: 'https://service.example.com', retries: 3 }, }), ).toEqual({ moonshotSearch: { baseUrl: 'https://api.example.com/search', apiKey: 'search-key' }, moonshotFetch: { baseUrl: 'https://api.example.com/fetch' }, + customService: { baseUrl: 'https://service.example.com', retries: 3 }, }); expect(() => registry.validate(SERVICES_SECTION, { moonshotSearch: { baseUrl: 42 } }), @@ -913,6 +918,34 @@ describe('services config section', () => { custom_service: { base_url: 'https://service.example.com' }, }); }); + + it('preserves unknown services when managed services are removed', () => { + const rawServices = { + moonshot_search: { + base_url: 'https://api.example.com/search', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + moonshot_fetch: { + base_url: 'https://api.example.com/fetch', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + custom_service: { + base_url: 'https://service.example.com', + retries: 3, + }, + }; + const services = ServicesConfigSchema.parse(servicesFromToml(rawServices)); + const config = { providers: {}, services }; + + clearManagedKimiCodeConfig(config); + + expect(servicesToToml(config.services, rawServices)).toEqual({ + custom_service: { + base_url: 'https://service.example.com', + retries: 3, + }, + }); + }); }); describe('AuthSummaryService', () => {