diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 72e5359e0e..84f39fe8c0 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -10,7 +10,9 @@ * state, so the effort is validated against the model's supported efforts and * the bind rejects up front when unsupported — internal spawns pass inherited * thinking without the flag, and a persisted effort that drifted out of the - * model's support list clamps instead of breaking the spawn. + * model's support list clamps instead of breaking the spawn. The profile + * contract also owns live status re-publication for consumers that attach to + * an agent after its initial model binding. */ import type { AgentProfile, AgentProfileContext } from '#/app/agentProfileCatalog/agentProfileCatalog'; @@ -127,6 +129,7 @@ export interface IAgentProfileService { bind(input: BindAgentInput): Promise; setModel(model: string): Promise; setThinking(level: string): void; + republishStatus(): void; getModel(): string; useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void; applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise; diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 9c282609b5..81e9df46a3 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -645,6 +645,10 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }); } + republishStatus(): void { + this.emitStatusUpdated(true); + } + private get profileState(): ProfileModelState { return this.wire.getModel(ProfileModel); } diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 7a31d9cc52..a850f5d221 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -146,7 +146,7 @@ export function buildSubagentModelDescriptions( export function wrapSubagentModelError( error: unknown, boundModel: string, - callerModelAlias: string, + callerModelAlias: string | undefined, ): unknown { if (boundModel === callerModelAlias) return error; if (!isError2(error) || error.code !== ErrorCodes.CONFIG_INVALID) return error; diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 4f17219079..503a860337 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -24,6 +24,7 @@ import type { IAgentScopeHandle } from '#/_base/di/scope'; import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; +import { IAgentProfileService } from '#/agent/profile/profile'; import { isProviderRateLimitError } from '#/kosong/contract/errors'; import { type TokenUsage } from '#/kosong/contract/usage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -108,6 +109,11 @@ export function emitAgentRunSpawned( swarmIndex: meta.swarmIndex, runInBackground: meta.runInBackground ?? false, }); + requester.accessor + .get(IAgentLifecycleService) + ?.get(targetAgentId) + ?.accessor.get(IAgentProfileService) + ?.republishStatus(); requester.accessor.get(ITelemetryService)?.track2('subagent_created', { subagent_name: meta.profileName, run_in_background: meta.runInBackground ?? false, diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts index 979734139e..d1d7eb2913 100644 --- a/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts +++ b/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts @@ -8,7 +8,9 @@ * front-loads the same resolution to session start (main-agent creation): an * unresolvable model or an effort the model does not list becomes a `warning` * event on the main agent's event bus, and stays cached for the edge to pull - * (`GET /sessions/{id}/warnings`). Session-scoped — one instance per session. + * (`GET /sessions/{id}/warnings`). A mid-session `[secondary_model]` change + * (the SDK's `applyPersistedSecondaryModel` path) refreshes the cache through + * `recheckSecondaryModelWarning`. Session-scoped — one instance per session. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -24,6 +26,7 @@ export interface SecondaryModelWarning { export interface ISessionSecondaryModelWarningService { readonly _serviceBrand: undefined; getSecondaryModelWarning(): SecondaryModelWarning | undefined; + recheckSecondaryModelWarning(): SecondaryModelWarning | undefined; } export const ISessionSecondaryModelWarningService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts index e2144779e0..9274b4d131 100644 --- a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts +++ b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts @@ -9,8 +9,10 @@ * `supportEfforts` (what the derived entry will carry) — on failure, caches a * warning and publishes it as a `warning` event on the main agent's * `eventBus`, and stays cached for the edge to pull - * (`GET /sessions/{id}/warnings`). Never throws: a broken secondary model - * demotes to a notice here, with spawn-time resolution + * (`GET /sessions/{id}/warnings`). `recheckSecondaryModelWarning` recomputes + * the cache after a mid-session `[secondary_model]` change, re-publishing + * only when the warning actually changed. Never throws: a broken secondary + * model demotes to a notice here, with spawn-time resolution * (`resolveSubagentBinding` + `wrapSubagentModelError`) staying as the * backstop. Bound at Session scope. */ @@ -74,6 +76,24 @@ export class SessionSecondaryModelWarningService return this.warning; } + recheckSecondaryModelWarning(): SecondaryModelWarning | undefined { + const previous = this.warning; + this.warning = this.computeWarning(); + const changed = + previous?.code !== this.warning?.code || previous?.message !== this.warning?.message; + if (changed && this.warning !== undefined) { + this.agentLifecycle + .get(MAIN_AGENT_ID) + ?.accessor.get(IEventBus) + .publish({ + type: 'warning', + code: this.warning.code, + message: this.warning.message, + }); + } + return this.warning; + } + private check(main: IAgentScopeHandle): void { if (this.checked) return; this.checked = true; diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 96fd02a552..caf8ea48d8 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -88,6 +88,37 @@ describe('ConfigState model capabilities', () => { }); }); + it('republishes the model status slice on demand', () => { + kimiConfig = { + providers: { + kimi: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test/v1', + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: 'kimi', + model: 'kimi-for-coding', + maxContextSize: 1_000_000, + supportEfforts: ['low', 'high'], + }, + }, + }; + profile.update({ modelAlias: 'kimi-code/kimi-for-coding' }); + const before = ctx.allEvents.filter((entry) => entry.event === 'agent.status.updated').length; + + profile.republishStatus(); + + const statuses = ctx.allEvents.filter((entry) => entry.event === 'agent.status.updated'); + expect(statuses).toHaveLength(before + 1); + expect(statuses.at(-1)?.args).toMatchObject({ + model: 'kimi-code/kimi-for-coding', + maxContextTokens: 1_000_000, + }); + }); + it('tracks thinking_toggle with the effort payload when effort changes', () => { kimiConfig = { providers: { diff --git a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts index 81f9918afd..044f5778e6 100644 --- a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts +++ b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts @@ -31,6 +31,7 @@ describe('SessionInitService', () => { let events: unknown[]; let appendSystemReminder: ReturnType; let flush: ReturnType; + let republishStatus: ReturnType; let create: ReturnType; let run: ReturnType; let runCompletion: Promise<{ summary: string; usage?: undefined }>; @@ -41,6 +42,9 @@ describe('SessionInitService', () => { events = []; appendSystemReminder = vi.fn(); flush = vi.fn(async () => {}); + republishStatus = vi.fn(() => { + events.push({ type: 'agent.status.updated', model: 'mock-model' }); + }); runCompletion = Promise.resolve({ summary: 'Explored and wrote AGENTS.md', usage: undefined }); const handles: Record unknown } }> = {}; @@ -89,6 +93,7 @@ describe('SessionInitService', () => { accessor: { get: (id: unknown) => { if (id === IAgentPermissionModeService) return permissionMode; + if (id === IAgentProfileService) return { republishStatus }; return undefined; }, }, @@ -155,6 +160,10 @@ describe('SessionInitService', () => { callerAgentId: 'main', }), ); + expect(republishStatus).toHaveBeenCalledTimes(1); + const eventTypes = events.map((event) => (event as { type?: string }).type); + const spawnedIndex = eventTypes.indexOf('subagent.spawned'); + expect(eventTypes[spawnedIndex + 1]).toBe('agent.status.updated'); expect(events).toContainEqual( expect.objectContaining({ type: 'subagent.completed', subagentId: 'agent-0' }), ); diff --git a/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts b/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts index 96ac4fd4bf..31dec110d6 100644 --- a/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts +++ b/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts @@ -33,6 +33,7 @@ describe('SessionSecondaryModelWarningService', () => { let handles: Map; let published: DomainEvent[]; let modelIds: Record; + let config: StubConfigService; beforeEach(() => { disposables = new DisposableStore(); @@ -52,7 +53,8 @@ describe('SessionSecondaryModelWarningService', () => { onDidCreate: onDidCreate.event, get: (agentId: string) => handles.get(agentId), } as unknown as IAgentLifecycleService); - ix.stub(IConfigService, new StubConfigService(configValues)); + config = new StubConfigService(configValues); + ix.stub(IConfigService, config); ix.stub( IFlagService, stubFlag((id) => flagEnabled && id === SECONDARY_MODEL_FLAG_ID), @@ -203,6 +205,51 @@ describe('SessionSecondaryModelWarningService', () => { expect(svc.getSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); expect(published).toHaveLength(1); }); + + it('recheck publishes a newly broken recipe once and stays quiet while it is unchanged', async () => { + modelIds['provider/secondary'] = modelStub({}); + setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/secondary' } }); + const svc = ix.get(ISessionSecondaryModelWarningService); + createMain(); + expect(svc.getSecondaryModelWarning()).toBeUndefined(); + + await config.replace(SECONDARY_MODEL_SECTION, { model: 'provider/typo' }); + const warning = svc.recheckSecondaryModelWarning(); + expect(warning?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); + expect(svc.getSecondaryModelWarning()).toEqual(warning); + expect(published).toEqual([{ type: 'warning', code: warning?.code, message: warning?.message }]); + + expect(svc.recheckSecondaryModelWarning()).toEqual(warning); + expect(published).toHaveLength(1); + }); + + it('recheck clears the cached warning when the recipe is fixed or removed', async () => { + setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); + const svc = ix.get(ISessionSecondaryModelWarningService); + createMain(); + expect(svc.getSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); + expect(published).toHaveLength(1); + + modelIds['provider/secondary'] = modelStub({}); + await config.replace(SECONDARY_MODEL_SECTION, { model: 'provider/secondary' }); + expect(svc.recheckSecondaryModelWarning()).toBeUndefined(); + expect(svc.getSecondaryModelWarning()).toBeUndefined(); + + await config.replace(SECONDARY_MODEL_SECTION, { model: 'provider/typo' }); + expect(svc.recheckSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); + await config.replace(SECONDARY_MODEL_SECTION, undefined); + expect(svc.recheckSecondaryModelWarning()).toBeUndefined(); + expect(published).toHaveLength(2); + }); + + it('recheck before the main agent exists caches silently; the initial check still publishes', async () => { + setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); + const svc = ix.get(ISessionSecondaryModelWarningService); + expect(svc.recheckSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); + expect(published).toHaveLength(0); + createMain(); + expect(published).toHaveLength(1); + }); }); function agentHandle(id: string, published: DomainEvent[]): IAgentScopeHandle { diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 7d22f507ae..85721a0a45 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -1385,6 +1385,7 @@ function profileService(data: ProfileData): IAgentProfileService { update: (changed) => { current = { ...current, ...changed }; }, + republishStatus: () => {}, } as IAgentProfileService; } diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index fa98629ac1..a039afa8bb 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -262,6 +262,7 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen _serviceBrand: undefined, data: () => ({ profileName: profileByAgentId.get(agentId) }), update: () => {}, + republishStatus: () => {}, isToolActive: () => false, } as never; } @@ -1406,6 +1407,7 @@ describe('Agent tool execution contract', () => { _serviceBrand: undefined, data: () => ({ profileName: 'explore', modelAlias: 'stale-model' }), update: vi.fn(), + republishStatus: vi.fn(), isToolActive: () => false, } as unknown as IAgentProfileService; const lifecycle = createAgentLifecycleStub({ diff --git a/packages/kap-server/src/services/legacyStatus/legacyStatus.ts b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts index c70057c034..8d1771d9fc 100644 --- a/packages/kap-server/src/services/legacyStatus/legacyStatus.ts +++ b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts @@ -19,7 +19,9 @@ import { IAgentContextSizeService, IAgentProfileService, IAgentUsageService, + IModelCatalog, IWireService, + SECONDARY_DERIVED_MODEL_ID, type IAgentScopeHandle, type UsageStatus, } from '@moonshot-ai/agent-core-v2'; @@ -135,10 +137,29 @@ export function readLegacyStatus(agent: IAgentScopeHandle): LegacyStatusSnapshot const contextTokens = Math.max(contextSize.get().size, measured.tokens); const capabilities = profile.getModelCapabilities(); const maxContextTokens = capabilities.max_input_tokens ?? capabilities.max_context_tokens; - const model = profile.getModel(); + const model = displayModelAlias(agent, profile.getModel()); return { usage, contextTokens, maxContextTokens, model }; } +/** + * The wire `model` is normally the bound alias, which clients resolve against + * the model listing into a display name. The secondary-model derived entry is + * synthesized runtime state hidden from that listing, so resolve it here to + * the pointed entry's display string (the client's own + * `displayName ?? wireName` priority) instead of leaking the reserved id. + */ +function displayModelAlias(agent: IAgentScopeHandle, alias: string): string { + if (alias !== SECONDARY_DERIVED_MODEL_ID) return alias; + const catalog = agent.accessor.get(IModelCatalog) as IModelCatalog | undefined; + if (catalog === undefined) return alias; + try { + const model = catalog.get(alias); + return model.displayName ?? model.name; + } catch { + return alias; + } +} + /** * Map the native v2 `AgentActivityState` to the legacy v1 `AgentPhase` * (`agent.status.updated` payload). Pure function — kept at the kap-server diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 2d968c8e00..c1d6ebe8c7 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -1002,7 +1002,13 @@ export class SessionEventBroadcaster { const disposables: IDisposable[] = [ eventBus.subscribe((event) => { let projected = event; - if (handle.id === MAIN_AGENT_ID && event.type === 'agent.status.updated') { + if (event.type === 'agent.status.updated') { + // v2 emits status in slices, and the model slice rides only the + // bind-time emission — for a subagent that lands before the client + // has seen `subagent.spawned` and is dropped there, leaving the + // subagent card without a model. Fold the full legacy snapshot + // (usage + context + model) into every agent's status event so the + // v1 combined-payload contract holds regardless of slice timing. const snapshot = readLegacyStatus(handle); if (snapshot !== undefined) { lastLegacyStatus = JSON.stringify(snapshot); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 71c900f2cf..8c8c76466a 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -24,12 +24,14 @@ import { IAgentUsageService, IEventBus, IEventService, + IModelCatalog, ISessionActivityView, ISessionInteractionService, ISessionLifecycleService, IWireService, ISessionMetadata, MAIN_AGENT_ID, + SECONDARY_DERIVED_MODEL_ID, SessionInteractionService, StateRegistry, } from '@moonshot-ai/agent-core-v2'; @@ -549,6 +551,91 @@ describe('SessionEventBroadcaster', () => { ]); }); + it('folds the legacy status snapshot into subagent status events too', async () => { + const lc = new FakeLifecycle(); + lc.addAgent('main'); + const sub = lc.addAgent('agent-1'); + const usage = { + total: { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, + }; + sub.set(IAgentContextSizeService, { get: () => ({ size: 10 }) }); + sub.set(IAgentProfileService, { + getModel: () => 'sub-model', + getModelCapabilities: () => ({ max_context_tokens: 128_000 }), + }); + sub.set(IAgentUsageService, { status: () => usage }); + sub.set(IWireService, { + getModel: (model: unknown) => { + expect(model).toBe(ContextSizeModel); + return { length: 0, tokens: 8 }; + }, + }); + sessions.set('s1', lc); + const { target, envelopes } = collectingTarget(); + await bc.subscribe('s1', target); + + // The v2 model slice rides only the subagent's bind-time emission, which + // reaches clients before `subagent.spawned` and is dropped there; a later + // usage-only slice must still carry the model at the v1 edge. + sub.bus.emit(agentEvent('agent.status.updated', { usage })); + await bc.getCursor('s1'); + + const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); + expect(statuses).toHaveLength(1); + expect(statuses[0]!.payload).toMatchObject({ + type: 'agent.status.updated', + agentId: 'agent-1', + usage, + contextTokens: 10, + maxContextTokens: 128_000, + model: 'sub-model', + }); + }); + + it('resolves the secondary derived model id to a display string in status events', async () => { + const lc = new FakeLifecycle(); + const main = lc.addAgent('main'); + main.set(IAgentContextSizeService, { get: () => ({ size: 10 }) }); + main.set(IAgentProfileService, { + getModel: () => SECONDARY_DERIVED_MODEL_ID, + getModelCapabilities: () => ({ max_context_tokens: 128_000 }), + }); + main.set(IAgentUsageService, { status: () => ({}) }); + main.set(IWireService, { getModel: () => ({ length: 0, tokens: 8 }) }); + main.set(IModelCatalog, { + get: (id: string) => { + expect(id).toBe(SECONDARY_DERIVED_MODEL_ID); + return { id, name: 'kimi-k2-wire', displayName: 'Kimi K2' }; + }, + }); + sessions.set('s1', lc); + const { target, envelopes } = collectingTarget(); + await bc.subscribe('s1', target); + + main.bus.emit(agentEvent('agent.status.updated', {})); + // Without a displayName the pointed entry's wire name is shown. + main.set(IModelCatalog, { + get: (id: string) => ({ id, name: 'kimi-k2-wire' }), + }); + main.bus.emit(agentEvent('agent.status.updated', {})); + // A resolution failure falls back to the raw alias. + main.set(IModelCatalog, { + get: () => { + throw new Error('unknown model'); + }, + }); + main.bus.emit(agentEvent('agent.status.updated', {})); + await bc.getCursor('s1'); + + const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); + expect(statuses).toHaveLength(3); + expect(statuses.map((envelope) => envelope.payload)).toMatchObject([ + { model: 'Kimi K2' }, + { model: 'kimi-k2-wire' }, + { model: SECONDARY_DERIVED_MODEL_ID }, + ]); + }); + it('publishes the input cap as the status context limit when declared', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 99b4cda7ed..81848610c6 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -115,6 +115,13 @@ * injection point — see the session-lifecycle section header), and * `toolCall` keeps the base class's "not supported" answer, which the * interaction bridge already relies on. + * - `applyPersistedSecondaryModel` → the reload + loud validations + warning + * refresh of v1's contract, rebuilt over the live `IConfigService` recipe + * and `ISessionSecondaryModelWarningService.recheckSecondaryModelWarning` + * (the v2 spawn binding resolves the secondary model at spawn time, so + * there is no session snapshot to push). `getSessionWarnings` also + * surfaces the v2 secondary-model warning next to the AGENTS.md one, + * matching v1's aggregate. */ import { randomUUID } from 'node:crypto'; import { readdir } from 'node:fs/promises'; @@ -139,7 +146,9 @@ import { type BeginAuthorizationResult, } from '@moonshot-ai/agent-core-v2/agent/mcp/oauth/service'; import { createMcpOAuthStore } from '@moonshot-ai/agent-core-v2/agent/mcp/oauth/store'; +import { SECONDARY_MODEL_SECTION } from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection'; import { IAtomicDocumentStore } from '@moonshot-ai/agent-core-v2/persistence/interface/atomicDocumentStore'; +import { wrapSubagentModelError } from '@moonshot-ai/agent-core-v2/session/subagent/configSection'; import { applyPromptMetadataUpdate, bootstrap, @@ -167,6 +176,7 @@ import { IEventService, IHostEnvironment, IHostFileSystem, + IModelCatalog, IModelService, IProjectLocalConfigService, IProviderService, @@ -179,6 +189,7 @@ import { ISessionLifecycleService, ISessionMcpService, ISessionMetadata, + ISessionSecondaryModelWarningService, ISessionSkillCatalog, ISessionWorkspaceCommandService, ISessionWorkspaceContext, @@ -207,6 +218,7 @@ import { type IDisposable, type ISessionScopeHandle, type Scope, + type SecondaryModelConfig, type ServicesAccessor, } from '@moonshot-ai/agent-core-v2'; import type { AgentHandle, Klient } from '@moonshot-ai/klient'; @@ -1240,6 +1252,43 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { agent.accessor.get(IAgentProfileService).setThinking(input.effort); } + /** + * v1 reloads the core config and pushes the resolved snapshot into the + * session: the spawn binding, the tool descriptions, and the cached + * startup warning all read that snapshot. The v2 engine resolves the + * secondary model live against `IConfigService` at spawn time + * (`resolveSubagentBinding`) and rebuilds the tool description on every + * read, so the preceding `setConfig` write already took effect + * session-wide — what remains of v1's contract is the reload (the recipe + * may have been persisted through another channel), the same loud + * validations, and the warning-cache refresh. The recipe read is NOT + * flag-gated, mirroring v1's `setSecondaryModelConfig` (the experiment + * gate lives at the spawn binding on both engines). + */ + override async applyPersistedSecondaryModel(input: SessionIdRpcInput): Promise { + const session = this.requireLiveSession(input.sessionId); + await this.klient.global.config.reload(); + await this.configReady; + await this.modelReady; + const secondary = this.engineAccessor + .get(IConfigService) + .get(SECONDARY_MODEL_SECTION); + if (secondary?.model === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Cannot set the secondary model: persist its recipe before applying it to a session.', + ); + } + try { + this.engineAccessor.get(IModelCatalog).get(secondary.model); + } catch (error) { + throw wrapSubagentModelError(error, secondary.model, undefined); + } + session.accessor + .get(ISessionSecondaryModelWarningService) + .recheckSecondaryModelWarning(); + } + override async setPermission(input: SetSessionPermissionRpcInput): Promise { const agent = await this.agentFacade(input.sessionId); return agent.setPermission(input.mode); @@ -1531,7 +1580,14 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * cache is empty — v1 recomputes on demand whenever no warning is cached, * so an AGENTS.md that outgrows the budget mid-session surfaces on both * engines. The single warning shape (`agents-md-oversized`, severity - * `warning`) mirrors v1's assembly. + * `warning`) mirrors v1's assembly. The secondary-model half comes from the + * session scope's `ISessionSecondaryModelWarningService` (v1's + * `computeSecondaryModelWarnings`): v1 computes it from the session's + * config snapshot while v2 caches the live-config check at main-agent + * creation, so the two agree on recipes applied through + * `applyPersistedSecondaryModel` (which refreshes the v2 cache) and on + * recipes present at session creation; a recipe persisted but never + * applied surfaces only on v2 (live config vs v1's snapshot). */ override async getSessionWarnings(input: SessionIdRpcInput) { const agent = await this.agentScope(input.sessionId); @@ -1549,8 +1605,17 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { ); warning = prepared.agentsMdWarning; } - if (warning === undefined) return []; - return [{ code: 'agents-md-oversized', message: warning, severity: 'warning' as const }]; + const warnings: { code: string; message: string; severity: 'warning' }[] = + warning === undefined + ? [] + : [{ code: 'agents-md-oversized', message: warning, severity: 'warning' as const }]; + const secondary = this.requireLiveSession(input.sessionId) + .accessor.get(ISessionSecondaryModelWarningService) + .getSecondaryModelWarning(); + if (secondary !== undefined) { + warnings.push({ code: secondary.code, message: secondary.message, severity: 'warning' }); + } + return warnings; } /** diff --git a/packages/node-sdk/src/v2/session-wiring.ts b/packages/node-sdk/src/v2/session-wiring.ts index ad5fedc41f..9ee53d70f1 100644 --- a/packages/node-sdk/src/v2/session-wiring.ts +++ b/packages/node-sdk/src/v2/session-wiring.ts @@ -33,12 +33,20 @@ import type { ToolInputDisplay, } from '@moonshot-ai/agent-core'; import { + ContextSizeModel, + IAgentContextSizeService, IAgentLifecycleService, + IAgentProfileService, + IAgentUsageService, IEventBus, + IModelCatalog, ISessionApprovalService, ISessionInteractionService, ISessionQuestionService, + IWireService, MAIN_AGENT_ID, + SECONDARY_DERIVED_MODEL_ID, + type DomainEvent, type IAgentScopeHandle, type IDisposable, type Interaction, @@ -145,7 +153,9 @@ export class SessionEventWiring { this.agentSubscriptions.set( agentId, agent.accessor.get(IEventBus).subscribe((event) => { - const translated = translateDomainEvent(event, sessionId, agentId); + const enriched = + event.type === 'agent.status.updated' ? withStatusSnapshot(agent, event) : event; + const translated = translateDomainEvent(enriched, sessionId, agentId); if (translated !== undefined) this.sink.receiveEvent(translated); }), ); @@ -250,3 +260,62 @@ export class SessionEventWiring { } } } + +/** + * v2 emits agent status in independent slices (see `agent/usage/usageOps.ts` + * in agent-core-v2), and the model slice rides only the bind-time emission — + * for a subagent that reaches the client before `subagent.spawned` and is + * dropped there, so subagent cards never learn the model. Fold a consistent + * usage + context + model snapshot into every status event at this edge, + * restoring the v1 combined-payload contract regardless of slice timing. + * Mirrors kap-server's `readLegacyStatus` bridge; the v1 edge lives in the + * two client-facing packages so the core engine stays free of v1 + * wire-compatibility concerns. + */ +function withStatusSnapshot(agent: IAgentScopeHandle, event: DomainEvent): DomainEvent { + const profile = agent.accessor.get(IAgentProfileService) as IAgentProfileService | undefined; + const usageService = agent.accessor.get(IAgentUsageService) as IAgentUsageService | undefined; + const contextSize = agent.accessor.get(IAgentContextSizeService) as + | IAgentContextSizeService + | undefined; + const wire = agent.accessor.get(IWireService) as IWireService | undefined; + if ( + profile === undefined || + usageService === undefined || + contextSize === undefined || + wire === undefined + ) { + return event; + } + const measured = wire.getModel(ContextSizeModel); + const contextTokens = Math.max(contextSize.get().size, measured.tokens); + const capabilities = profile.getModelCapabilities(); + const maxContextTokens = capabilities.max_input_tokens ?? capabilities.max_context_tokens; + return { + ...event, + usage: usageService.status(), + contextTokens, + maxContextTokens, + model: displayModelAlias(agent, profile.getModel()), + } as unknown as DomainEvent; +} + +/** + * The wire `model` is normally the bound alias, which clients resolve against + * the model listing into a display name. The secondary-model derived entry is + * synthesized runtime state hidden from that listing, so resolve it here to + * the pointed entry's display string (the client's own + * `displayName ?? wireName` priority) instead of leaking the reserved id. + * Mirrors kap-server's `displayModelAlias`. + */ +function displayModelAlias(agent: IAgentScopeHandle, alias: string): string { + if (alias !== SECONDARY_DERIVED_MODEL_ID) return alias; + const catalog = agent.accessor.get(IModelCatalog) as IModelCatalog | undefined; + if (catalog === undefined) return alias; + try { + const model = catalog.get(alias); + return model.displayName ?? model.name; + } catch { + return alias; + } +} diff --git a/packages/node-sdk/test/session-event-wiring.test.ts b/packages/node-sdk/test/session-event-wiring.test.ts new file mode 100644 index 0000000000..e01e2222fa --- /dev/null +++ b/packages/node-sdk/test/session-event-wiring.test.ts @@ -0,0 +1,207 @@ +/** + * `SessionEventWiring` — the in-process v1 edge over the v2 per-agent event + * bus. Covers the status-snapshot fold: v2 emits `agent.status.updated` in + * slices and the model slice rides only the bind-time emission, so the + * wiring merges a consistent usage + context + model snapshot into every + * status event (mirrors kap-server's broadcaster bridge), including the + * secondary-model derived id resolution. + * Run: pnpm exec vitest run test/session-event-wiring.test.ts + */ +import { describe, expect, it } from 'vitest'; + +import type { Event } from '@moonshot-ai/agent-core'; +import { + ContextSizeModel, + IAgentContextSizeService, + IAgentLifecycleService, + IAgentProfileService, + IAgentUsageService, + IEventBus, + IModelCatalog, + ISessionInteractionService, + IWireService, + SECONDARY_DERIVED_MODEL_ID, + type IAgentScopeHandle, + type ISessionScopeHandle, +} from '@moonshot-ai/agent-core-v2'; + +import { SessionEventWiring, type SessionEventSink } from '#/v2/session-wiring'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +type FakeBusEvent = { type: string } & Record; + +class FakeAgentBus { + private handlers: Array<(e: FakeBusEvent) => void> = []; + subscribe(handler: (e: FakeBusEvent) => void): { dispose(): void } { + this.handlers.push(handler); + return { + dispose: () => { + const i = this.handlers.indexOf(handler); + if (i >= 0) this.handlers.splice(i, 1); + }, + }; + } + emit(e: FakeBusEvent): void { + for (const h of [...this.handlers]) h(e); + } +} + +class FakeAgentHandle { + readonly kind = 2; + readonly bus = new FakeAgentBus(); + readonly accessor; + private readonly services = new Map(); + constructor(readonly id: string) { + this.services.set(IEventBus, this.bus); + this.accessor = { + get: (token: unknown) => this.services.get(token), + }; + } + set(token: unknown, service: unknown): void { + this.services.set(token, service); + } + dispose(): void {} +} + +function makeSession(agents: FakeAgentHandle[]): ISessionScopeHandle { + const lifecycle = { + list: () => agents, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + }; + const interactions = { + onDidChangePending: () => ({ dispose: () => {} }), + listPending: () => [], + }; + const accessor = { + get: (token: unknown): unknown => { + if (token === IAgentLifecycleService) return lifecycle; + if (token === ISessionInteractionService) return interactions; + return undefined; + }, + }; + return { id: 's1', kind: 1, accessor, dispose: () => {} } as unknown as ISessionScopeHandle; +} + +function collectingSink(): { sink: SessionEventSink; events: Event[] } { + const events: Event[] = []; + return { + events, + sink: { + receiveEvent: (event) => { + events.push(event); + }, + requestApproval: () => Promise.resolve('cancelled' as never), + requestQuestion: () => Promise.resolve(null), + toolCall: () => Promise.resolve({ output: 'not supported', isError: true }), + }, + }; +} + +const USAGE = { + total: { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, +}; + +function bindStatusServices(agent: FakeAgentHandle, model: string): void { + agent.set(IAgentContextSizeService, { get: () => ({ size: 10 }) }); + agent.set(IAgentProfileService, { + getModel: () => model, + getModelCapabilities: () => ({ max_context_tokens: 128_000 }), + }); + agent.set(IAgentUsageService, { status: () => USAGE }); + agent.set(IWireService, { + getModel: (requested: unknown) => { + expect(requested).toBe(ContextSizeModel); + return { length: 0, tokens: 8 }; + }, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SessionEventWiring status snapshot fold', () => { + it('folds a consistent usage + context + model snapshot into every status event', () => { + const sub = new FakeAgentHandle('agent-1'); + bindStatusServices(sub, 'sub-model'); + const { sink, events } = collectingSink(); + const wiring = new SessionEventWiring(makeSession([sub]), sink); + try { + // The v2 model slice rides only the subagent's bind-time emission, which + // reaches clients before `subagent.spawned` and is dropped there; a + // later usage-only slice must still carry the model at this edge. + sub.bus.emit({ type: 'agent.status.updated', usage: USAGE }); + // Non-status events pass through untouched. + sub.bus.emit({ type: 'assistant.delta', delta: 'Hi' }); + } finally { + wiring.dispose(); + } + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + type: 'agent.status.updated', + sessionId: 's1', + agentId: 'agent-1', + usage: USAGE, + contextTokens: 10, + maxContextTokens: 128_000, + model: 'sub-model', + }); + expect(events[1]).toMatchObject({ type: 'assistant.delta', delta: 'Hi' }); + expect(events[1]).not.toHaveProperty('model'); + }); + + it('resolves the secondary derived model id to a display string', () => { + const sub = new FakeAgentHandle('agent-1'); + bindStatusServices(sub, SECONDARY_DERIVED_MODEL_ID); + const { sink, events } = collectingSink(); + const wiring = new SessionEventWiring(makeSession([sub]), sink); + try { + sub.set(IModelCatalog, { + get: (id: string) => { + expect(id).toBe(SECONDARY_DERIVED_MODEL_ID); + return { id, name: 'kimi-k2-wire', displayName: 'Kimi K2' }; + }, + }); + sub.bus.emit({ type: 'agent.status.updated', usage: USAGE }); + // Without a displayName the pointed entry's wire name is shown. + sub.set(IModelCatalog, { get: (id: string) => ({ id, name: 'kimi-k2-wire' }) }); + sub.bus.emit({ type: 'agent.status.updated', usage: USAGE }); + // A resolution failure falls back to the raw alias. + sub.set(IModelCatalog, { + get: () => { + throw new Error('unknown model'); + }, + }); + sub.bus.emit({ type: 'agent.status.updated', usage: USAGE }); + } finally { + wiring.dispose(); + } + + expect(events.map((event) => (event as { model?: string }).model)).toEqual([ + 'Kimi K2', + 'kimi-k2-wire', + SECONDARY_DERIVED_MODEL_ID, + ]); + }); + + it('passes status events through unchanged when the agent services are incomplete', () => { + const sub = new FakeAgentHandle('agent-1'); + // No profile/usage/context/wire services bound — nothing to fold in. + const { sink, events } = collectingSink(); + const wiring = new SessionEventWiring(makeSession([sub]), sink); + try { + sub.bus.emit({ type: 'agent.status.updated', usage: USAGE }); + } finally { + wiring.dispose(); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: 'agent.status.updated', usage: USAGE }); + expect(events[0]).not.toHaveProperty('model'); + }); +}); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 89b954417e..88080ab80a 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -388,6 +388,10 @@ function projectResumedAgents( * model, where v2's bind requires a model and deliberately leaves the * agent unbound (the same model-less gap the getStatus parity pins). With * a configured model both bind the same profile and compare in full. + * - `config.subagentNames`: v1's config snapshot carries the bound profile's + * delegatable subagent roster (custom agent files are a v1-engine feature); + * v2's resumed agent state has no equivalent field. Engine-owned profile + * data, not resume data. * - `context.tokenCount`: the KNOWN_DIFFS.getContext divergence (v1's running * estimate vs v2's provider-measured prefix) — the history compares in * full, the count only in the empty state. @@ -415,6 +419,7 @@ function projectResumedAgent(agent: ResumedAgentState, home: HomePair): unknown const config = projected['config'] as Record; delete config['provider']; delete config['systemPrompt']; + delete config['subagentNames']; const modelLess = config['modelAlias'] === undefined; if (modelLess) { delete config['profileName']; @@ -605,6 +610,35 @@ api_key = "fixture-api-key" enabled = "not-a-boolean" `; +/** + * Secondary-model parity fixture: one resolvable model and the experiment + * enabled, no `[secondary_model]` recipe — the apply cases persist the recipe + * through `setConfig` mid-test. + */ +const SECONDARY_MODEL_CONFIG_TOML = ` +default_provider = "fixture-provider" +default_model = "fixture-model" + +[providers.fixture-provider] +type = "kimi" +api_key = "fixture-api-key" +base_url = "https://example.com/v1" + +[models.fixture-model] +provider = "fixture-provider" +model = "kimi-for-coding" +max_context_size = 262144 + +[experimental] +secondary-model = true +`; + +/** Same fixture with a dangling `[secondary_model]` pointer baked in. */ +const SECONDARY_MODEL_BROKEN_CONFIG_TOML = `${SECONDARY_MODEL_CONFIG_TOML} +[secondary_model] +model = "missing-model" +`; + function expectConfigParity(v1Config: KimiConfig, v2Config: KimiConfig): void { const project = KNOWN_DIFFS.getConfig; expect(normalize(project(v2Config), '')).toEqual(normalize(project(v1Config), '')); @@ -1123,10 +1157,14 @@ interface SessionParityPair { readonly workDir: string; } -async function makeSessionParityPair(): Promise { +async function makeSessionParityPair(configToml?: string): Promise { const v1HomeDir = await makeTempDir('kimi-sdk-parity-v1-home-'); const v2HomeDir = await makeTempDir('kimi-sdk-parity-v2-home-'); const workDir = await makeTempDir('kimi-sdk-parity-work-'); + if (configToml !== undefined) { + await writeFile(join(v1HomeDir, 'config.toml'), configToml, 'utf-8'); + await writeFile(join(v2HomeDir, 'config.toml'), configToml, 'utf-8'); + } return { v1: new SDKRpcClient({ homeDir: v1HomeDir, identity: TEST_IDENTITY }), v2: new SDKRpcClientV2({ homeDir: v2HomeDir, identity: TEST_IDENTITY }), @@ -2582,6 +2620,96 @@ describe('v1↔v2 agent interaction parity', () => { restoreEnv(); } }); + + it('applyPersistedSecondaryModel validates, applies, and refreshes warnings identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(SECONDARY_MODEL_CONFIG_TOML); + try { + await createOnBoth(pair, { id: 'session_parity_secondary_apply' }); + const input = { sessionId: 'session_parity_secondary_apply' } as const; + const applyError = (client: SDKRpcClient | SDKRpcClientV2) => + client.applyPersistedSecondaryModel(input).then( + () => undefined, + (error: unknown) => error as Error, + ); + + // No recipe persisted yet: both reject with v1's persist-first error. + const [v1NoRecipe, v2NoRecipe] = await Promise.all([ + applyError(pair.v1), + applyError(pair.v2), + ]); + expect(v1NoRecipe).toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); + expect(v2NoRecipe).toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); + expect(v2NoRecipe?.message).toBe(v1NoRecipe?.message); + + // A dangling recipe: both reject, pointing at [secondary_model]. + await Promise.all([ + pair.v1.setConfig({ secondaryModel: { model: 'missing-model' } }), + pair.v2.setConfig({ secondaryModel: { model: 'missing-model' } }), + ]); + const [v1Broken, v2Broken] = await Promise.all([ + applyError(pair.v1), + applyError(pair.v2), + ]); + expect(v1Broken).toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); + expect(v2Broken).toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); + expect(v1Broken?.message).toContain('[secondary_model].model'); + expect(v2Broken?.message).toContain('[secondary_model].model'); + + // A valid recipe: both apply cleanly. The warnings pull converges on + // empty — v1's snapshot never held the broken recipe (its apply + // validates before mutating), v2's live-config warning cache is + // refreshed by the successful apply. + await Promise.all([ + pair.v1.setConfig({ secondaryModel: { model: 'fixture-model' } }), + pair.v2.setConfig({ secondaryModel: { model: 'fixture-model' } }), + ]); + await Promise.all([ + pair.v1.applyPersistedSecondaryModel(input), + pair.v2.applyPersistedSecondaryModel(input), + ]); + const [v1Warnings, v2Warnings] = await Promise.all([ + pair.v1.getSessionWarnings(input), + pair.v2.getSessionWarnings(input), + ]); + expect(v2Warnings).toEqual(v1Warnings); + expect(v1Warnings).toEqual([]); + + await expect( + pair.v1.applyPersistedSecondaryModel({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + pair.v2.applyPersistedSecondaryModel({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('getSessionWarnings flags a creation-time broken secondary recipe on both engines', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(SECONDARY_MODEL_BROKEN_CONFIG_TOML); + try { + await createOnBoth(pair, { id: 'session_parity_secondary_broken' }); + const input = { sessionId: 'session_parity_secondary_broken' } as const; + const [v1Warnings, v2Warnings] = await Promise.all([ + pair.v1.getSessionWarnings(input), + pair.v2.getSessionWarnings(input), + ]); + // The message wording is engine-specific; the code + severity are the + // shared contract. + const codes = (warnings: readonly { code: string; severity: string }[]) => + warnings.map(({ code, severity }) => ({ code, severity })); + expect(codes(v2Warnings)).toEqual(codes(v1Warnings)); + expect(codes(v1Warnings)).toEqual([ + { code: 'secondary-model-invalid', severity: 'warning' }, + ]); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); }); // ---------------------------------------------------------------------------