fix(agent-core-v2): omit max_context_tokens in REST session status when unknown (#2696)

- align the REST status rollup with the WS push: a bound alias that no
  longer resolves omits max_context_tokens instead of reporting 0 (0 is the
  engine's UNKNOWN_CAPABILITY marker, not a real limit)
- fall back to the default model's limit only when no model is bound,
  resolved through IModelService like the WS side
- mark max_context_tokens optional in the shared session status schema
This commit is contained in:
Haozhe 2026-08-06 18:50:16 +08:00 committed by GitHub
parent 8c766a6c30
commit 4d39f4fa6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 19 deletions

View file

@ -29,12 +29,12 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types';
import { IAgentPlanService } from '#/features/plan/plan';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentSwarmService } from '#/agent/swarm/swarm';
import { IConfigService } from '#/app/config/config';
import {
getLiveSessionById,
resumeSessionById,
} from '#/app/workspaceLifecycle/sessionLookup';
import { IModelCatalog } from '#/kosong/model/catalog';
import { IModelService } from '#/kosong/model/model';
import { ErrorCodes, Error2 } from '#/errors';
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
@ -176,14 +176,16 @@ export class SessionLegacyService implements ISessionLegacyService {
const swarm = agent.accessor.get(IAgentSwarmService);
const model = profile.getModel();
const caps = profile.getModelCapabilities() as {
max_context_tokens?: number;
max_input_tokens?: number;
};
const maxTokens =
model === ''
? resolveDefaultModelContextTokens(agent)
: (caps.max_input_tokens ?? caps.max_context_tokens ?? 0);
const capabilities = profile.getModelCapabilities();
// An alias that no longer resolves yields UNKNOWN_CAPABILITY whose
// max_context_tokens is 0 — the "unknown" marker, not a real limit. Only
// an unbound session falls back to the default model's limit; when the
// limit stays unknown the field is omitted (never 0), mirroring the WS
// status push (`readLegacyStatus`).
let maxTokens = capabilities.max_input_tokens ?? capabilities.max_context_tokens;
if (maxTokens === 0 && model === '') {
maxTokens = resolveDefaultModelContextTokens(agent) ?? 0;
}
const tokens = tokenCounting.statusSize();
const planData = await plan.status();
@ -195,7 +197,7 @@ export class SessionLegacyService implements ISessionLegacyService {
plan_mode: planData !== null,
swarm_mode: swarm.isActive,
context_tokens: tokens,
max_context_tokens: maxTokens,
max_context_tokens: maxTokens > 0 ? maxTokens : undefined,
context_usage: maxTokens > 0 ? Math.min(1, tokens / maxTokens) : 0,
};
}
@ -216,14 +218,18 @@ export class SessionLegacyService implements ISessionLegacyService {
}
}
function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number {
const defaultModel = agent.accessor.get(IConfigService).get<string>('defaultModel');
if (typeof defaultModel !== 'string' || defaultModel.length === 0) return 0;
/**
* Context limit of the configured default model, or `undefined` when no
* default model is configured or it does not resolve.
*/
function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number | undefined {
const defaultModel = agent.accessor.get(IModelService).getDefaultModel();
if (defaultModel === undefined || defaultModel.length === 0) return undefined;
try {
const capabilities = agent.accessor.get(IModelCatalog).get(defaultModel).capabilities;
return capabilities.max_input_tokens ?? capabilities.max_context_tokens;
} catch {
return 0;
return undefined;
}
}

View file

@ -85,7 +85,7 @@ export const sessionStatusResponseSchema = z.object({
plan_mode: z.boolean(),
swarm_mode: z.boolean(),
context_tokens: z.number().int().nonnegative(),
max_context_tokens: z.number().int().nonnegative(),
max_context_tokens: z.number().int().nonnegative().optional(),
context_usage: z.number().min(0).max(1),
});
export type SessionStatusResponse = z.infer<typeof sessionStatusResponseSchema>;

View file

@ -20,8 +20,9 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo
import { IAgentPlanService } from '#/features/plan/plan';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentSwarmService } from '#/agent/swarm/swarm';
import { IConfigService } from '#/app/config/config';
import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability';
import { IModelCatalog } from '#/kosong/model/catalog';
import { IModelService } from '#/kosong/model/model';
import { ISessionLegacyService } from '#/app/sessionLegacy/sessionLegacy';
import { SessionLegacyService } from '#/app/sessionLegacy/sessionLegacyService';
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
@ -147,12 +148,14 @@ describe('Session legacy status (best-effort runtime state)', () => {
const status = await ix.get(ISessionLegacyService).status('session-test');
// The ghost alias resolves to UNKNOWN_CAPABILITY whose 0 means "unknown"
// — the rollup omits the field rather than reporting 0 (WS parity).
expect(status).toMatchObject({
busy: false,
model: 'removed-model',
thinking_level: 'high',
max_context_tokens: 0,
});
expect(status.max_context_tokens).toBeUndefined();
});
it('reports an empty thinking level for a never-bound main agent', async () => {
@ -178,7 +181,9 @@ describe('Session legacy status (best-effort runtime state)', () => {
[IAgentPermissionModeService, { mode: 'manual' }],
[IAgentPlanService, { status: () => Promise.resolve(null) }],
[IAgentSwarmService, { isActive: false }],
[IConfigService, { get: () => undefined }],
// Unbound: assembleStatus resolves the default model's context cap,
// which asks the model service first — no default model here.
[IModelService, { getDefaultModel: () => undefined }],
[
IAgentActivityView,
{ state: () => ({ lifecycle: 'ready', background: [] }) },
@ -210,6 +215,75 @@ describe('Session legacy status (best-effort runtime state)', () => {
model: undefined,
thinking_level: '',
});
// No bound model and no default model — the limit is unknown and omitted.
expect(status.max_context_tokens).toBeUndefined();
});
it('falls back to the default model limit when no model is bound', async () => {
// Draft-session shape: no model bound, so the capabilities are unknown;
// the rollup mirrors the WS push and reads the default model's limit.
const profile = {
_serviceBrand: undefined,
data: () => ({
cwd: '/workspace',
modelAlias: undefined,
modelCapabilities: UNKNOWN_CAPABILITY,
thinkingLevel: 'off',
systemPrompt: '',
}),
getModel: () => '',
getModelCapabilities: () => UNKNOWN_CAPABILITY,
getEffectiveThinkingLevel: () => 'off',
} as unknown as IAgentProfileService;
const agent: IAgentScopeHandle = {
id: 'main',
kind: LifecycleScope.Agent,
accessor: accessor([
[IAgentProfileService, profile],
[IAgentTokenCountingService, { get: () => ({ size: 0, measured: 0, estimated: 0 }), statusSize: () => 0 }],
[IAgentPermissionModeService, { mode: 'manual' }],
[IAgentPlanService, { status: () => Promise.resolve(null) }],
[IAgentSwarmService, { isActive: false }],
[IModelService, { getDefaultModel: () => 'default-model' }],
[
IModelCatalog,
{
get: (id: string) => {
if (id !== 'default-model') throw new Error(`unknown model ${id}`);
return { capabilities: { max_context_tokens: 200_000 } };
},
},
],
[
IAgentActivityView,
{ state: () => ({ lifecycle: 'ready', background: [] }) },
],
]),
dispose: () => {},
};
const agents = {
create: () => Promise.resolve(agent),
whenReady: () => Promise.resolve(agent),
list: () => [agent],
} as unknown as IAgentLifecycleService;
const session: ISessionScopeHandle = {
id: 'session-draft',
kind: LifecycleScope.Session,
accessor: accessor([
[IAgentLifecycleService, agents],
[ISessionCronService, { _serviceBrand: undefined }],
]),
dispose: () => {},
};
stubSessionChain(ix, session);
ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService));
const status = await ix.get(ISessionLegacyService).status('session-draft');
expect(status).toMatchObject({
model: undefined,
max_context_tokens: 200_000,
});
});
it('uses the input cap as the status denominator and clamps usage to 1', async () => {

View file

@ -410,6 +410,19 @@ describe('sessionStatusResponseSchema', () => {
expect(parsed.model).toBeUndefined();
});
it('accepts an omitted max_context_tokens (unknown context limit)', () => {
const parsed = sessionStatusResponseSchema.parse({
busy: false,
thinking_level: 'off',
permission: 'auto',
plan_mode: false,
swarm_mode: false,
context_tokens: 0,
context_usage: 0,
});
expect(parsed.max_context_tokens).toBeUndefined();
});
it('rejects missing busy', () => {
expect(
sessionStatusResponseSchema.safeParse({

View file

@ -140,7 +140,9 @@ export const sessionStatusResponseSchema = z.object({
plan_mode: z.boolean(),
swarm_mode: z.boolean(),
context_tokens: z.number().int().nonnegative(),
max_context_tokens: z.number().int().nonnegative(),
/** Omitted when the context limit is unknown 0 is the engine's "unknown"
* marker, never a real limit. */
max_context_tokens: z.number().int().nonnegative().optional(),
context_usage: z.number().min(0).max(1),
});
export type SessionStatusResponse = z.infer<typeof sessionStatusResponseSchema>;