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
5 changes: 4 additions & 1 deletion packages/agent-core-v2/src/agent/profile/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -127,6 +129,7 @@ export interface IAgentProfileService {
bind(input: BindAgentInput): Promise<void>;
setModel(model: string): Promise<ProfileSetModelResult>;
setThinking(level: string): void;
republishStatus(): void;
getModel(): string;
useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void;
applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise<void>;
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core-v2/src/agent/profile/profileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +26,7 @@ export interface SecondaryModelWarning {
export interface ISessionSecondaryModelWarningService {
readonly _serviceBrand: undefined;
getSecondaryModelWarning(): SecondaryModelWarning | undefined;
recheckSecondaryModelWarning(): SecondaryModelWarning | undefined;
}

export const ISessionSecondaryModelWarningService: ServiceIdentifier<ISessionSecondaryModelWarningService> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions packages/agent-core-v2/test/agent/profile/config-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('SessionInitService', () => {
let events: unknown[];
let appendSystemReminder: ReturnType<typeof vi.fn>;
let flush: ReturnType<typeof vi.fn>;
let republishStatus: ReturnType<typeof vi.fn>;
let create: ReturnType<typeof vi.fn>;
let run: ReturnType<typeof vi.fn>;
let runCompletion: Promise<{ summary: string; usage?: undefined }>;
Expand All @@ -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<string, { id: string; accessor: { get: (id: unknown) => unknown } }> = {};
Expand Down Expand Up @@ -89,6 +93,7 @@ describe('SessionInitService', () => {
accessor: {
get: (id: unknown) => {
if (id === IAgentPermissionModeService) return permissionMode;
if (id === IAgentProfileService) return { republishStatus };
return undefined;
},
},
Expand Down Expand Up @@ -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' }),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('SessionSecondaryModelWarningService', () => {
let handles: Map<string, IAgentScopeHandle>;
let published: DomainEvent[];
let modelIds: Record<string, Model>;
let config: StubConfigService;

beforeEach(() => {
disposables = new DisposableStore();
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,7 @@ function profileService(data: ProfileData): IAgentProfileService {
update: (changed) => {
current = { ...current, ...changed };
},
republishStatus: () => {},
} as IAgentProfileService;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/test/tool/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen
_serviceBrand: undefined,
data: () => ({ profileName: profileByAgentId.get(agentId) }),
update: () => {},
republishStatus: () => {},
isToolActive: () => false,
} as never;
}
Expand Down Expand Up @@ -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({
Expand Down
23 changes: 22 additions & 1 deletion packages/kap-server/src/services/legacyStatus/legacyStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {
IAgentContextSizeService,
IAgentProfileService,
IAgentUsageService,
IModelCatalog,
IWireService,
SECONDARY_DERIVED_MODEL_ID,
type IAgentScopeHandle,
type UsageStatus,
} from '@moonshot-ai/agent-core-v2';
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading