feat: replace silent AGENTS.md truncation with a visible warning (#1040)

Oversized AGENTS.md files are no longer silently truncated. The full
content is injected, and a warning is shown in the TUI status bar and the
web UI when the combined AGENTS.md size exceeds the recommended 32 KB.

A generic session-warnings API backs this so future warning types can be
added without changing the API surface.
This commit is contained in:
Kai 2026-06-24 12:26:17 +08:00 committed by GitHub
parent 0bcd9843c1
commit 66640380eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 272 additions and 83 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Replace silent AGENTS.md truncation with a visible warning in the TUI status bar and web UI.

View file

@ -584,6 +584,7 @@ export class KimiTUI {
}
if (this.session !== undefined) {
this.sessionEventHandler.startSubscription();
void this.showSessionWarnings(this.session);
}
void this.fetchSessions();
if (this.session !== undefined) {
@ -592,6 +593,19 @@ export class KimiTUI {
void this.refreshSkillCommands(this.session);
}
private async showSessionWarnings(session: Session): Promise<void> {
try {
const warnings = await session.getSessionWarnings();
if (this.session !== session) return;
for (const warning of warnings) {
const severity = warning.severity === 'error' ? 'error' : 'warning';
this.showStatus(`Warning: ${warning.message}`, severity);
}
} catch {
// Best-effort: startup must not block on warning retrieval.
}
}
private async showTmuxKeyboardWarningIfNeeded(): Promise<void> {
const warning = await detectTmuxKeyboardWarning();
if (warning === undefined || this.aborted) return;
@ -1382,6 +1396,7 @@ export class KimiTUI {
this.showStatus(`Warning: ${resumeState.warning}`, 'warning');
}
this.showStatus(statusMessage);
void this.showSessionWarnings(session);
}
async reloadCurrentSessionView(session: Session, statusMessage: string): Promise<void> {
@ -1410,6 +1425,7 @@ export class KimiTUI {
this.showStatus(`Warning: ${resumeState.warning}`, 'warning');
}
this.showStatus(statusMessage);
void this.showSessionWarnings(session);
}
async createNewSession(): Promise<void> {
@ -1447,6 +1463,7 @@ export class KimiTUI {
this.sessionEventHandler.startSubscription();
this.clearTranscriptAndRedraw();
this.showStatus(`Started a new session (${session.id}).`);
void this.showSessionWarnings(session);
void this.showConfigWarningsIfAny();
}

View file

@ -74,6 +74,8 @@ import type {
WireProviderRefreshResult,
WireSession,
WireSessionAbortResult,
WireSessionWarning,
WireSessionWarningsResponse,
WireSessionRuntimeStatus,
WireSessionSnapshot,
WireWorkspace,
@ -394,6 +396,13 @@ export class DaemonKimiWebApi implements KimiWebApi {
};
}
async getSessionWarnings(sessionId: string): Promise<WireSessionWarning[]> {
const data = await this.http.get<WireSessionWarningsResponse>(
`/sessions/${encodeURIComponent(sessionId)}/warnings`,
);
return data.warnings ?? [];
}
async archiveSession(sessionId: string): Promise<{ archived: true }> {
const data = await this.http.post<WireArchiveResult>(
`/sessions/${encodeURIComponent(sessionId)}:archive`,

View file

@ -110,6 +110,17 @@ export interface WireSessionRuntimeStatus {
context_usage: number;
}
// GET /sessions/{id}/warnings — session-level warnings (e.g. oversized AGENTS.md).
export interface WireSessionWarning {
code: string;
message: string;
severity: 'info' | 'warning' | 'error';
}
export interface WireSessionWarningsResponse {
warnings: WireSessionWarning[];
}
// ---------------------------------------------------------------------------
// Workspace + daemon folder browser wire DTOs
// PRESUMED — not in the live daemon yet; isolated here, swap when backend ships.

View file

@ -598,6 +598,12 @@ export interface AppSkill {
// KimiWebApi — the app-facing interface
// ---------------------------------------------------------------------------
export interface AppSessionWarning {
code: string;
message: string;
severity: 'info' | 'warning' | 'error';
}
export interface KimiWebApi {
getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>;
getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record<string, boolean>; openInApps: string[] }>;
@ -607,6 +613,7 @@ export interface KimiWebApi {
getSession(sessionId: string): Promise<AppSession>;
updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise<AppSession>;
getSessionStatus(sessionId: string): Promise<AppSessionRuntimeStatus>;
getSessionWarnings(sessionId: string): Promise<AppSessionWarning[]>;
archiveSession(sessionId: string): Promise<{ archived: true }>;
listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise<Page<AppMessage>>;
/** v2 initial sync: atomic session state + `asOfSeq` watermark + epoch. */

View file

@ -939,6 +939,22 @@ async function handleSessionNotFound(sessionId: string): Promise<void> {
}
}
const sessionWarningsPulled = new Set<string>();
async function pullSessionWarnings(sessionId: string): Promise<void> {
if (sessionWarningsPulled.has(sessionId)) return;
sessionWarningsPulled.add(sessionId);
try {
const warnings = await getKimiWebApi().getSessionWarnings(sessionId);
const label = i18n.global.t('warnings.noteLabel');
for (const warning of warnings) {
pushWarning(`${label}: ${warning.message}`);
}
} catch {
// best-effort: never block session sync on warning retrieval.
}
}
async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionResult> {
try {
const api = getKimiWebApi();
@ -977,6 +993,7 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
eventConn.seedSnapshot(sessionId, snap);
eventConn.subscribe(sessionId, { seq: snap.asOfSeq, epoch: snap.epoch });
}
void pullSessionWarnings(sessionId);
return 'ok';
} catch (err) {
if (isSessionNotFoundError(err)) {

View file

@ -6,16 +6,21 @@ import { normalizeAdditionalDirs } from '../config';
import { listDirectory } from '../tools/support/list-directory';
import type { SystemPromptContext } from './types';
const AGENTS_MD_MAX_BYTES = 32 * 1024;
const AGENTS_MD_TRUNCATION_MARKER =
'<!-- Some AGENTS.md files were truncated or omitted to fit the 32 KB budget -->';
// Soft budget for the combined AGENTS.md content injected into the system
// prompt. ~32 KB is roughly 8K20K tokens (≈1.53% of a 262144-token context),
// large enough to leave the bulk of the context window to the conversation
// while still catching accidental oversized instruction files. Exceeding it no
// longer truncates content; it only surfaces a user-visible warning so the user
// can trim oversized instruction files.
const AGENTS_MD_RECOMMENDED_MAX_BYTES = 32 * 1024;
const S_IFMT = 0o170000;
const S_IFREG = 0o100000;
export type PreparedSystemPromptContext = Pick<
SystemPromptContext,
'cwdListing' | 'agentsMd' | 'additionalDirsInfo'
>;
export interface PreparedSystemPromptContext
extends Pick<SystemPromptContext, 'cwdListing' | 'agentsMd' | 'additionalDirsInfo'> {
/** Present when the combined AGENTS.md content exceeds the recommended size. */
readonly agentsMdWarning?: string;
}
export interface PrepareSystemPromptContextOptions {
readonly additionalDirs?: readonly string[];
@ -27,23 +32,34 @@ export async function prepareSystemPromptContext(
options?: PrepareSystemPromptContextOptions,
): Promise<PreparedSystemPromptContext> {
const additionalDirs = normalizeAdditionalDirs(options?.additionalDirs ?? []);
const [cwdListing, agentsMd, additionalDirsInfo] = await Promise.all([
const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([
listDirectory(kaos, undefined, { collapseHiddenDirs: true }),
loadAgentsMd(kaos, brandHome),
loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]),
loadAdditionalDirsInfo(kaos, additionalDirs),
]);
return { cwdListing, agentsMd, additionalDirsInfo };
return {
cwdListing,
agentsMd: agentsMdResult.content,
additionalDirsInfo,
agentsMdWarning: agentsMdResult.warning,
};
}
export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise<string> {
return loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]);
const result = await loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]);
return result.content;
}
interface LoadedAgentsMd {
readonly content: string;
readonly warning: string | undefined;
}
async function loadAgentsMdForRoots(
kaos: Kaos,
brandHome: string | undefined,
workDirs: readonly string[],
): Promise<string> {
): Promise<LoadedAgentsMd> {
const discovered: AgentFile[] = [];
const seen = new Set<string>();
@ -87,7 +103,15 @@ async function loadAgentsMdForRoots(
}
}
return renderAgentFiles(discovered);
const content = renderAgentFiles(discovered);
const totalBytes = byteLength(content);
const warning =
totalBytes > AGENTS_MD_RECOMMENDED_MAX_BYTES
? `AGENTS.md total ${formatKB(totalBytes)} KB exceeds the recommended ` +
`${formatKB(AGENTS_MD_RECOMMENDED_MAX_BYTES)} KB. Large instruction files ` +
`increase cost and may impact performance; consider trimming.`
: undefined;
return { content, warning };
}
async function loadAdditionalDirsInfo(
@ -163,77 +187,18 @@ async function isFile(kaos: Kaos, path: string): Promise<boolean> {
function renderAgentFiles(files: readonly AgentFile[]): string {
if (files.length === 0) return '';
let remaining = AGENTS_MD_MAX_BYTES;
let didTruncate = false;
const budgeted: Array<AgentFile | undefined> = Array.from({ length: files.length });
for (let i = files.length - 1; i >= 0; i--) {
const file = files[i];
if (file === undefined) continue;
const annotation = annotationFor(file.path);
const separator = i < files.length - 1 ? '\n\n' : '';
remaining -= byteLength(annotation) + byteLength(separator);
if (remaining <= 0) {
budgeted[i] = { path: file.path, content: '' };
remaining = 0;
didTruncate = true;
continue;
}
let content = file.content;
if (byteLength(content) > remaining) {
content = truncateUtf8(content, remaining).trim();
didTruncate = true;
}
remaining -= byteLength(content);
budgeted[i] = { path: file.path, content };
}
const rendered = budgeted
.filter((file): file is AgentFile => file !== undefined && file.content.length > 0)
.map((file) => `${annotationFor(file.path)}${file.content}`)
.join('\n\n');
return didTruncate ? `${AGENTS_MD_TRUNCATION_MARKER}\n${rendered}` : rendered;
}
function truncateUtf8(text: string, maxBytes: number): string {
if (maxBytes <= 0) return '';
if (byteLength(text) <= maxBytes) return text;
let low = 0;
let high = text.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
const candidate = text.slice(0, mid);
if (byteLength(candidate) <= maxBytes) {
low = mid;
} else {
high = mid - 1;
}
}
let result = text.slice(0, low);
while (endsWithUnpairedHighSurrogate(result)) {
result = result.slice(0, -1);
}
return result;
}
function endsWithUnpairedHighSurrogate(text: string): boolean {
if (text.length === 0) return false;
const codePoint = text.codePointAt(text.length - 1);
return codePoint !== undefined && codePoint >= 0xd800 && codePoint <= 0xdbff;
return files.map((file) => `${annotationFor(file.path)}${file.content}`).join('\n\n');
}
function byteLength(text: string): number {
return Buffer.byteLength(text, 'utf8');
}
function formatKB(bytes: number): string {
const kb = bytes / 1024;
return Number.isInteger(kb) ? String(kb) : kb.toFixed(1);
}
function annotationFor(path: string): string {
return `<!-- From: ${path} -->\n`;
}

View file

@ -19,6 +19,7 @@ import type { ExperimentalFeatureState } from '#/flags';
import type { ResumeSessionResult } from '#/rpc/resumed';
import type { SessionMeta } from '#/session';
import type { ContentPart } from '@moonshot-ai/kosong';
import type { SessionWarning } from '@moonshot-ai/protocol';
import type { PluginInfo, PluginSummary, ReloadSummary } from '#/plugin';
import type { UsageStatus } from './events';
@ -387,6 +388,7 @@ export interface SessionAPI extends AgentAPIWithId {
getMcpStartupMetrics: (payload: EmptyPayload) => McpStartupMetrics;
reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void;
generateAgentsMd: (payload: EmptyPayload) => void;
getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[];
addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult;
}

View file

@ -109,6 +109,7 @@ import type {
} from './core-api';
import type { ResumedAgentState, ResumeSessionResult } from './resumed';
import type { SDKRPC } from './sdk-api';
import type { SessionWarning } from '@moonshot-ai/protocol';
import { proxyWithExtraPayload } from './types';
import { KaosShellNotFoundError, LocalKaos, type Kaos } from '@moonshot-ai/kaos';
import type { ToolServices } from '../tools/support/services';
@ -742,6 +743,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.sessionApi(sessionId).generateAgentsMd(payload);
}
getSessionWarnings({ sessionId, ...payload }: SessionScopedPayload<EmptyPayload>): Promise<readonly SessionWarning[]> {
return this.sessionApi(sessionId).getSessionWarnings(payload);
}
addAdditionalDir({
sessionId,
...payload

View file

@ -14,6 +14,7 @@ import {
type SessionCreate,
type SessionFork,
type SessionStatusResponse,
type SessionWarning,
type SessionUpdate,
type UndoSessionRequest,
type UndoSessionResponse,
@ -55,6 +56,8 @@ export interface ISessionService {
getStatus(id: string): Promise<SessionStatusResponse>;
getSessionWarnings(id: string): Promise<readonly SessionWarning[]>;
compact(id: string, input: CompactSessionRequest): Promise<CompactSessionResponse>;
undo(id: string, input: UndoSessionRequest): Promise<UndoSessionResponse>;

View file

@ -17,6 +17,7 @@ import {
type SessionStatus,
type SessionStatusResponse,
type SessionUpdate,
type SessionWarning,
type UndoSessionRequest,
type UndoSessionResponse,
} from '@moonshot-ai/protocol';
@ -474,6 +475,23 @@ export class SessionService extends Disposable implements ISessionService {
};
}
async getSessionWarnings(id: string): Promise<readonly SessionWarning[]> {
const all = await this.core.rpc.listSessions({});
if (!all.some((s) => s.id === id)) {
throw new SessionNotFoundError(id);
}
try {
await this.core.rpc.resumeSession({ sessionId: id });
} catch {
// best-effort: the session may already be loaded in core memory.
}
try {
return await this.core.rpc.getSessionWarnings({ sessionId: id });
} catch {
return [];
}
}
async compact(id: string, input: CompactSessionRequest): Promise<CompactSessionResponse> {
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);

View file

@ -1,6 +1,7 @@
import { homedir } from 'node:os';
import { join } from 'pathe';
import type { Kaos } from '@moonshot-ai/kaos';
import type { SessionWarning } from '@moonshot-ai/protocol';
import { ErrorCodes, KimiError } from '#/errors';
import { getRootLogger, log } from '#/logging/logger';
@ -169,6 +170,7 @@ export class Session {
custom: {},
};
private writeMetadataPromise = Promise.resolve();
private agentsMdWarning: string | undefined;
constructor(public readonly options: SessionOptions) {
// Attach the per-session log sink up front so the constructor's
@ -467,6 +469,49 @@ export class Session {
{ additionalDirs: this.additionalDirs },
);
agent.useProfile(profile, context);
const { agentsMdWarning } = context;
if (agentsMdWarning !== undefined) {
this.agentsMdWarning = agentsMdWarning;
log.warn('AGENTS.md exceeds recommended size', { message: agentsMdWarning });
agent.emitEvent({
type: 'warning',
message: agentsMdWarning,
code: 'agents-md-oversized',
});
}
}
async getSessionWarnings(): Promise<readonly SessionWarning[]> {
const warnings: SessionWarning[] = [];
const agentsMdWarning = await this.computeAgentsMdWarning();
if (agentsMdWarning !== undefined) {
warnings.push({
code: 'agents-md-oversized',
message: agentsMdWarning,
severity: 'warning',
});
}
return warnings;
}
private async computeAgentsMdWarning(): Promise<string | undefined> {
if (this.agentsMdWarning !== undefined) {
return this.agentsMdWarning;
}
// Resumed sessions skip bootstrap when their system prompt is already set, so
// the cached value may be missing; recompute on demand so the warning still
// surfaces for long-lived sessions.
try {
const context = await prepareSystemPromptContext(
this.systemContextKaos(this.toolKaos.getcwd()),
this.options.kimiHomeDir,
{ additionalDirs: this.additionalDirs },
);
this.agentsMdWarning = context.agentsMdWarning;
} catch (error) {
log.warn('failed to compute AGENTS.md warning', { error });
}
return this.agentsMdWarning;
}
async generateAgentsMd(): Promise<void> {

View file

@ -1,4 +1,5 @@
import { ErrorCodes, KimiError } from '#/errors';
import type { SessionWarning } from '@moonshot-ai/protocol';
import type {
ActivateSkillPayload,
AddAdditionalDirPayload,
@ -93,6 +94,10 @@ export class SessionAPIImpl implements PromisableMethods<SessionAPI> {
return this.session.generateAgentsMd();
}
getSessionWarnings(_payload: EmptyPayload): Promise<readonly SessionWarning[]> {
return this.session.getSessionWarnings();
}
addAdditionalDir(payload: AddAdditionalDirPayload): Promise<AddAdditionalDirResult> {
return this.session.addAdditionalDir(payload.path, payload.persist);
}

View file

@ -115,16 +115,40 @@ describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => {
});
});
describe('loadAgentsMd truncation marker', () => {
it('adds a marker when AGENTS.md content is truncated', async () => {
describe('loadAgentsMd oversized content', () => {
it('keeps the full content when AGENTS.md exceeds the recommended size', async () => {
const largeContent = 'x'.repeat(40 * 1024);
await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8');
const result = await loadAgentsMd(testKaos);
expect(result).toContain('Some AGENTS.md files were truncated or omitted');
expect(result).toContain(`<!-- From: ${join(workDir, 'AGENTS.md')} -->`);
expect(result).not.toContain(largeContent);
expect(result).toContain(largeContent);
expect(result).not.toContain('truncated or omitted');
});
});
describe('prepareSystemPromptContext AGENTS.md size warning', () => {
it('returns agentsMdWarning and keeps full content when oversized', async () => {
const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-brand-'));
extraDirs.push(brandHome);
const largeContent = 'x'.repeat(40 * 1024);
await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8');
const result = await prepareSystemPromptContext(testKaos, brandHome);
expect(result.agentsMd).toContain(largeContent);
expect(result.agentsMdWarning).toBeDefined();
expect(result.agentsMdWarning).toContain('exceeds the recommended');
});
it('does not return agentsMdWarning when within the recommended size', async () => {
const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-brand-'));
extraDirs.push(brandHome);
await writeFile(join(workDir, 'AGENTS.md'), 'small instructions', 'utf-8');
const result = await prepareSystemPromptContext(testKaos, brandHome);
expect(result.agentsMdWarning).toBeUndefined();
});
});

View file

@ -309,6 +309,7 @@ function makeSessionService(): {
listChildren: vi.fn() as unknown as ISessionService['listChildren'],
createChild: vi.fn() as unknown as ISessionService['createChild'],
getStatus: vi.fn() as unknown as ISessionService['getStatus'],
getSessionWarnings: vi.fn() as unknown as ISessionService['getSessionWarnings'],
compact: vi.fn() as unknown as ISessionService['compact'],
undo: vi.fn() as unknown as ISessionService['undo'],
archive: vi.fn() as unknown as ISessionService['archive'],

View file

@ -136,6 +136,7 @@ function makeSessionService(sessions: Map<string, Session>): ISessionService {
getStatus: async () => {
throw new Error('not implemented');
},
getSessionWarnings: async () => [],
compact: async () => {
throw new Error('not implemented');
},

View file

@ -245,6 +245,11 @@ export abstract class SDKRpcClientBase {
return rpc.generateAgentsMd({ sessionId: input.sessionId });
}
async getSessionWarnings(input: SessionIdRpcInput) {
const rpc = await this.getRpc();
return rpc.getSessionWarnings({ sessionId: input.sessionId });
}
async addAdditionalDir(input: AddAdditionalDirInput): Promise<AddAdditionalDirResult> {
const rpc = await this.getRpc();
return rpc.addAdditionalDir({ sessionId: input.id, path: input.path, persist: input.persist });

View file

@ -124,6 +124,11 @@ export class Session {
await this.rpc.generateAgentsMd({ sessionId: this.id });
}
async getSessionWarnings() {
this.ensureOpen();
return this.rpc.getSessionWarnings({ sessionId: this.id });
}
async addAdditionalDir(
path: string,
options?: AddAdditionalDirOptions,

View file

@ -110,6 +110,18 @@ export const sessionStatusResponseSchema = z.object({
});
export type SessionStatusResponse = z.infer<typeof sessionStatusResponseSchema>;
export const sessionWarningSchema = z.object({
code: z.string(),
message: z.string(),
severity: z.enum(['info', 'warning', 'error']),
});
export type SessionWarning = z.infer<typeof sessionWarningSchema>;
export const sessionWarningsResponseSchema = z.object({
warnings: z.array(sessionWarningSchema),
});
export type SessionWarningsResponse = z.infer<typeof sessionWarningsResponseSchema>;
export const compactSessionRequestSchema = z.preprocess(
(value) => value === undefined ? {} : value,
z.object({

View file

@ -13,6 +13,7 @@ import {
pageResponseSchema,
sessionAbortResponseSchema,
sessionSchema,
sessionWarningsResponseSchema,
sessionStatusResponseSchema,
sessionStatusSchema,
startBtwSessionResponseSchema,
@ -575,6 +576,37 @@ export function registerSessionsRoutes(
);
app.get(statusRoute.path, statusRoute.options, statusRoute.handler as Parameters<SessionRouteHost['get']>[2]);
const sessionWarningsRoute = defineRoute(
{
method: 'GET',
path: '/sessions/{session_id}/warnings',
params: sessionIdParamSchema,
success: { data: sessionWarningsResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
},
description: 'Get session-level warnings (e.g. oversized AGENTS.md)',
tags: ['sessions'],
},
async (req, reply) => {
try {
const { session_id } = req.params;
const warnings = await ix.invokeFunction((a) =>
a.get(ISessionService).getSessionWarnings(session_id),
);
reply.send(okEnvelope({ warnings }, req.id));
} catch (err) {
sendMappedError(reply, req.id, err);
}
},
);
app.get(
sessionWarningsRoute.path,
sessionWarningsRoute.options,
sessionWarningsRoute.handler as Parameters<SessionRouteHost['get']>[2],
);
}
function sendMappedError(