mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(status): restore consistent session status context window
- bridge split v2 status slices into one combined `agent.status.updated` event via a LegacyStatus derived model at the kap-server edge, so a usage-only event no longer overwrites the live context window with a stale zero - fall back to the configured default model's context window when the agent has no model bound yet, so fresh sessions don't show "0/0" - use `size` (measured + estimated) for the live context token count to mirror v1's `context.tokenCount` - export `defineDerivedModel` / `DerivedModelDef` from agent-core-v2
This commit is contained in:
parent
c7ea60a01a
commit
b71cf5e751
5 changed files with 230 additions and 4 deletions
|
|
@ -12,6 +12,7 @@
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
|
|
@ -19,6 +20,7 @@ import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
|
|||
import { ErrorCodes, isKimiError, KimiError } from '#/errors';
|
||||
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
|
||||
import { IAgentGoalService } from '#/agent/goal/goal';
|
||||
import { IModelResolver } from '#/app/model/modelResolver';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
||||
import { IAgentPlanService } from '#/agent/plan/plan';
|
||||
|
|
@ -404,8 +406,18 @@ export class SessionLegacyService implements ISessionLegacyService {
|
|||
const profileData = profile.data();
|
||||
const model = profile.getModel();
|
||||
const caps = profile.getModelCapabilities() as { max_context_tokens?: number };
|
||||
const maxTokens = caps.max_context_tokens ?? 0;
|
||||
const tokens = contextSize.get().measured;
|
||||
// v1 binds the default model to the main agent at session creation, so its
|
||||
// status always reports a real context window. v2 creates the main agent
|
||||
// lazily without binding a model until the first prompt/profile update, so a
|
||||
// fresh session has no model and `max_context_tokens` resolves to 0 — the
|
||||
// status line then shows "0/0". Mirror v1 by falling back to the configured
|
||||
// default model's context window whenever the agent has no model bound yet.
|
||||
const maxTokens =
|
||||
model === '' ? resolveDefaultModelContextTokens(agent) : (caps.max_context_tokens ?? 0);
|
||||
// `size` (measured + estimated) mirrors v1's `context.tokenCount`: it
|
||||
// reflects the live context even before the first measured exchange, whereas
|
||||
// `measured` stays 0 until the first LLM response lands.
|
||||
const tokens = contextSize.get().size;
|
||||
const planData = await plan.status();
|
||||
|
||||
return {
|
||||
|
|
@ -428,6 +440,22 @@ function normalizeOptional(value: string | undefined): string | undefined {
|
|||
return trimmed.length === 0 ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the configured default model's context window for the status line
|
||||
* when the main agent has no model bound yet (fresh session before the first
|
||||
* prompt). Returns 0 when no default model is configured or it cannot be
|
||||
* resolved (e.g. auth not ready), matching v1's "unknown" fallback.
|
||||
*/
|
||||
function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number {
|
||||
const defaultModel = agent.accessor.get(IConfigService).get<string>('defaultModel');
|
||||
if (typeof defaultModel !== 'string' || defaultModel.length === 0) return 0;
|
||||
try {
|
||||
return agent.accessor.get(IModelResolver).resolve(defaultModel).capabilities.max_context_tokens;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of v1 `pageContextMessages`: project the post-undo history into a
|
||||
* newest-first wire page, clamping `page_size` to `[1, 100]` (default 50).
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export * from '#/_base/log/fileLog';
|
|||
export * from '#/_base/log/logService';
|
||||
export { IAgentWireService, ISessionWireService } from '#/wire/tokens';
|
||||
export { type IWireService, type WireEmission } from '#/wire/wireService';
|
||||
export { defineDerivedModel, type DerivedModelDef } from '#/wire/model';
|
||||
export * from '#/session/sessionLog/sessionLogService';
|
||||
export * from '#/app/telemetry/telemetry';
|
||||
export * from '#/app/telemetry/telemetryService';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* `LegacyStatus` — kap-server-layer derived model that re-derives the v1-style
|
||||
* combined `agent.status.updated` payload from the agent's native v2 services.
|
||||
*
|
||||
* v1 emits a single `agent.status.updated` carrying usage + contextTokens +
|
||||
* maxContextTokens + model together. v2 splits those into independent Models /
|
||||
* Ops (`usage.record`, `context_size.measured`, `config.update` …), so the
|
||||
* partial events reach clients separately and a usage-only event can overwrite
|
||||
* a previously-known contextTokens with a stale zero. This derived model
|
||||
* watches the status-affecting Ops and, on each, re-reads the authoritative
|
||||
* services and emits a fresh combined event so the edge always has a real,
|
||||
* consistent context-window value to forward.
|
||||
*
|
||||
* Temporary bridge while the v2 wire contract still exposes the slices
|
||||
* separately — defined at the kap-server edge rather than in agent-core-v2 so
|
||||
* the core engine stays free of v1 wire-compatibility concerns.
|
||||
*/
|
||||
|
||||
import {
|
||||
IAgentProfileService,
|
||||
IAgentUsageService,
|
||||
IAgentWireService,
|
||||
defineDerivedModel,
|
||||
type IAgentScopeHandle,
|
||||
type UsageStatus,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
import { ContextSizeModel } from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
interface LegacyStatusState {
|
||||
/** Monotonic change counter — only used to fan change notifications out. */
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reacts to the Ops that can change the status line. The state itself carries
|
||||
* no business data; the handler re-reads the authoritative services on every
|
||||
* bump so the emitted snapshot is always consistent with the live Models.
|
||||
*/
|
||||
export const LegacyStatusModel = defineDerivedModel<LegacyStatusState>(
|
||||
'legacyStatus',
|
||||
() => ({ version: 0 }),
|
||||
{
|
||||
'usage.record': (s) => ({ version: s.version + 1 }),
|
||||
'context_size.measured': (s) => ({ version: s.version + 1 }),
|
||||
'config.update': (s) => ({ version: s.version + 1 }),
|
||||
},
|
||||
);
|
||||
|
||||
export interface LegacyStatusSnapshot {
|
||||
readonly usage?: UsageStatus;
|
||||
readonly contextTokens: number;
|
||||
readonly maxContextTokens: number;
|
||||
readonly model: string;
|
||||
}
|
||||
|
||||
/** Read the current combined status from the agent's authoritative services. */
|
||||
export function readLegacyStatus(agent: IAgentScopeHandle): LegacyStatusSnapshot {
|
||||
const profile = agent.accessor.get(IAgentProfileService);
|
||||
const usage = agent.accessor.get(IAgentUsageService).status();
|
||||
const contextTokens = agent.accessor.get(IAgentWireService).getModel(ContextSizeModel).tokens;
|
||||
const maxContextTokens = profile.getModelCapabilities().max_context_tokens;
|
||||
const model = profile.getModel();
|
||||
return { usage, contextTokens, maxContextTokens, model };
|
||||
}
|
||||
|
|
@ -42,11 +42,13 @@ import type {
|
|||
} from '@moonshot-ai/agent-core-v2';
|
||||
import {
|
||||
IAgentLifecycleService,
|
||||
IAgentWireService,
|
||||
IEventBus,
|
||||
IEventService,
|
||||
ISessionInteractionService,
|
||||
ISessionIndex,
|
||||
ISessionLifecycleService,
|
||||
MAIN_AGENT_ID,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
import type {
|
||||
Event,
|
||||
|
|
@ -60,6 +62,7 @@ import { isVolatileEventType } from '@moonshot-ai/protocol';
|
|||
|
||||
import { toWireApproval } from '../../../routes/approvals';
|
||||
import { toWireQuestion } from '../../../routes/questions';
|
||||
import { LegacyStatusModel, readLegacyStatus } from '../../../services/legacyStatus/legacyStatus';
|
||||
import { InFlightTurnTracker } from './inFlightTurnTracker';
|
||||
import {
|
||||
type EventEnvelope,
|
||||
|
|
@ -371,8 +374,12 @@ export class SessionEventBroadcaster {
|
|||
const busD = eventBus.subscribe((event) =>
|
||||
this.onAgentEvent(sessionId, handle.id, event),
|
||||
);
|
||||
const disposables: IDisposable[] = [busD];
|
||||
if (handle.id === MAIN_AGENT_ID) {
|
||||
disposables.push(this.attachLegacyStatus(sessionId, handle));
|
||||
}
|
||||
state.agentDisposables.set(handle.id, {
|
||||
dispose: () => busD.dispose(),
|
||||
dispose: () => disposables.forEach((d) => d.dispose()),
|
||||
});
|
||||
};
|
||||
for (const handle of agents.list()) subscribeAgent(handle);
|
||||
|
|
@ -388,6 +395,43 @@ export class SessionEventBroadcaster {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge the v2 split status slices into a single v1-style combined
|
||||
* `agent.status.updated` event. The native v2 domains emit the usage /
|
||||
* context-window / model slices independently, so a usage-only event can
|
||||
* reach clients without the live contextTokens and overwrite it with a stale
|
||||
* zero. Attaching the {@link LegacyStatusModel} to the main agent's wire and
|
||||
* re-emitting a combined snapshot on every status-affecting Op keeps the
|
||||
* context window consistent on the wire.
|
||||
*/
|
||||
private attachLegacyStatus(sessionId: string, handle: IAgentScopeHandle): IDisposable {
|
||||
const wire = handle.accessor.get(IAgentWireService);
|
||||
// The wire service is only present on a fully-materialized agent; stub /
|
||||
// test agents and not-yet-restored agents may not expose it, in which case
|
||||
// the native partial events are simply forwarded unchanged.
|
||||
if (wire === undefined) return { dispose: () => {} };
|
||||
const attachD = wire.attach(LegacyStatusModel);
|
||||
let lastEmitted: string | undefined;
|
||||
const subD = wire.subscribe(LegacyStatusModel, () => {
|
||||
const snapshot = readLegacyStatus(handle);
|
||||
// Dedupe: the derived model bumps on every watched Op, but only fan out
|
||||
// when the projected status actually changed.
|
||||
const key = JSON.stringify(snapshot);
|
||||
if (key === lastEmitted) return;
|
||||
lastEmitted = key;
|
||||
this.onAgentEvent(sessionId, MAIN_AGENT_ID, {
|
||||
type: 'agent.status.updated',
|
||||
...snapshot,
|
||||
});
|
||||
});
|
||||
return {
|
||||
dispose: () => {
|
||||
subD.dispose();
|
||||
attachD.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private onAgentEvent(sessionId: string, agentId: string, event: DomainEvent): void {
|
||||
const state = this.sessions.get(sessionId);
|
||||
if (state === undefined) return;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
|
|
@ -682,3 +682,92 @@ describe('server-v2 /api/v1/sessions', () => {
|
|||
expect((meta?.payload as { title?: string } | undefined)?.title).toBe('hello web title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server-v2 /api/v1/sessions status context window', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-status-'));
|
||||
await writeFile(
|
||||
join(home, 'config.toml'),
|
||||
[
|
||||
'default_model = "k2"',
|
||||
'',
|
||||
'[providers.kimi]',
|
||||
'type = "kimi"',
|
||||
'api_key = "sk-test"',
|
||||
'base_url = "https://api.example.test/v1"',
|
||||
'',
|
||||
'[models.k2]',
|
||||
'provider = "kimi"',
|
||||
'model = "kimi-k2"',
|
||||
'max_context_size = 131072',
|
||||
'display_name = "Kimi K2"',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function postJson<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const hasBody = body !== undefined;
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(
|
||||
server as RunningServer,
|
||||
hasBody ? { 'content-type': 'application/json' } : {},
|
||||
),
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
} as never);
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
headers: authHeaders(server as RunningServer),
|
||||
} as never);
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
it('reports the default model context window before any model is bound', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
const { body } = await getJson<{
|
||||
status: string;
|
||||
model?: string;
|
||||
context_tokens: number;
|
||||
max_context_tokens: number;
|
||||
context_usage: number;
|
||||
}>(`/api/v1/sessions/${created.body.data.id}/status`);
|
||||
expect(body.code).toBe(0);
|
||||
// No model is bound to the lazily-created main agent yet, but the status
|
||||
// line should still show the configured default model's context window
|
||||
// instead of 0 (mirrors v1, which binds the default model at creation).
|
||||
expect(body.data.max_context_tokens).toBe(131072);
|
||||
expect(body.data.context_tokens).toBe(0);
|
||||
expect(body.data.context_usage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue