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: 5 additions & 0 deletions .changeset/soft-agents-md-warning.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ export class KimiTUI {
}
if (this.session !== undefined) {
this.sessionEventHandler.startSubscription();
void this.showSessionWarnings(this.session);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show warnings for sessions created with /new

This warning pull only runs from finishStartup, so it misses sessions started after launch via the /new command. In that path, createNewSession() creates and bootstraps the session before startSubscription() is installed, so the new agent.emitEvent({ type: 'warning', ... }) is not observed, and the path never calls showSessionWarnings; with an oversized AGENTS.md, users who start a fresh session from the TUI get no visible warning. Reuse this helper in the post-create/reload session paths as well.

Useful? React with 👍 / 👎.

}
void this.fetchSessions();
if (this.session !== undefined) {
Expand All @@ -567,6 +568,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;
Expand Down Expand Up @@ -1355,6 +1369,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> {
Expand Down Expand Up @@ -1383,6 +1398,7 @@ export class KimiTUI {
this.showStatus(`Warning: ${resumeState.warning}`, 'warning');
}
this.showStatus(statusMessage);
void this.showSessionWarnings(session);
}

async createNewSession(): Promise<void> {
Expand Down Expand Up @@ -1420,6 +1436,7 @@ export class KimiTUI {
this.sessionEventHandler.startSubscription();
this.clearTranscriptAndRedraw();
this.showStatus(`Started a new session (${session.id}).`);
void this.showSessionWarnings(session);
void this.showConfigWarningsIfAny();
}

Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import type {
WireProviderRefreshResult,
WireSession,
WireSessionAbortResult,
WireSessionWarning,
WireSessionWarningsResponse,
WireSessionRuntimeStatus,
WireSessionSnapshot,
WireWorkspace,
Expand Down Expand Up @@ -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`,
Expand Down
11 changes: 11 additions & 0 deletions apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>;
Expand All @@ -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. */
Expand Down
17 changes: 17 additions & 0 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,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();
Expand Down Expand Up @@ -980,6 +996,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)) {
Expand Down
121 changes: 43 additions & 78 deletions packages/agent-core/src/profile/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 8K–20K tokens (≈1.5–3% 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[];
Expand All @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface warnings for /init-generated AGENTS.md

Session.generateAgentsMd() still calls loadAgentsMd() directly after /init writes the file (packages/agent-core/src/session/index.ts:532), so returning only the content here leaves that path with no way to observe result.warning. When /init creates an AGENTS.md over 32 KB, the new full content is appended to the current session, but the startup/switch warning checks have already run and this path emits no warning event, so users keep paying the cost silently until they reload or switch sessions. Please expose the warning to this caller and emit/cache it after loading the generated file.

Useful? React with 👍 / 👎.

}

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>();

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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`;
}


2 changes: 2 additions & 0 deletions packages/agent-core/src/rpc/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/services/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type SessionCreate,
type SessionFork,
type SessionStatusResponse,
type SessionWarning,
type SessionUpdate,
type UndoSessionRequest,
type UndoSessionResponse,
Expand Down Expand Up @@ -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>;
Expand Down
Loading
Loading