diff --git a/.changeset/context-usage-command.md b/.changeset/context-usage-command.md new file mode 100644 index 0000000000..3d80cd88d7 --- /dev/null +++ b/.changeset/context-usage-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /context slash command to show a Claude Code-style context usage breakdown with estimated tokens per category (system prompt, tools, MCP tools, memory files, skills, messages). Run /context to see it. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..2f573308b3 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -37,7 +37,7 @@ import { showSettingsSelector, } from './config'; import { handleGoalCommand } from './goal'; -import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleFeedbackCommand, showContextReport, showMcpServers, showStatusReport, showUsage } from './info'; import { handleAddDirCommand } from './add-dir'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; @@ -79,7 +79,7 @@ export { showSettingsSelector, } from './config'; export { handleSwarmCommand } from './swarm'; -export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +export { handleFeedbackCommand, showContextReport, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand } from './goal'; @@ -318,6 +318,9 @@ async function handleBuiltInSlashCommand( case 'usage': void showUsage(host); return; + case 'context': + void showContextReport(host); + return; case 'status': void showStatusReport(host); return; diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index fd5d397f4b..82701dc63d 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -1,8 +1,9 @@ import { release as osRelease, type as osType } from 'node:os'; -import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; +import type { ContextBreakdownData, McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; +import { buildContextReportLines } from '../components/messages/context-panel'; import { buildStatusReportLines } from '../components/messages/status-panel'; import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; import { @@ -183,6 +184,41 @@ export async function showMcpServers(host: SlashCommandHost): Promise { host.state.ui.requestRender(); } +export async function showContextReport(host: SlashCommandHost): Promise { + const session = host.requireSession(); + // The MCP status list is decorative — a failure there must not sink the report. + const [breakdownResult, mcpServers] = await Promise.all([ + loadContextBreakdown(host), + session.listMcpServers().catch(() => undefined), + ]); + const panel = new UsagePanelComponent( + () => + buildContextReportLines({ + model: host.state.appState.model, + breakdown: breakdownResult.breakdown, + mcpServers, + error: breakdownResult.error, + }), + 'primary', + ' Context ', + ); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +interface ContextBreakdownResult { + readonly breakdown?: ContextBreakdownData; + readonly error?: string; +} + +async function loadContextBreakdown(host: SlashCommandHost): Promise { + try { + return { breakdown: await host.requireSession().getContextBreakdown() }; + } catch (error) { + return { error: formatErrorMessage(error) }; + } +} + async function loadSessionUsageReport(host: SlashCommandHost): Promise { try { return { usage: await host.requireSession().getUsage() }; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bfe..476385eefa 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -325,6 +325,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'context', + aliases: [], + description: 'Show context usage breakdown', + priority: 65, + availability: 'always', + }, { name: 'status', aliases: [], diff --git a/apps/kimi-code/src/tui/components/messages/context-panel.ts b/apps/kimi-code/src/tui/components/messages/context-panel.ts new file mode 100644 index 0000000000..53f3aa4eba --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/context-panel.ts @@ -0,0 +1,188 @@ +/** + * ContextPanelComponent — renders the `/context` report: a Claude Code-style + * context-usage panel with a block-grid visualization, model + context-window + * summary, and the estimated per-category token cost of the context + * (system prompt, tool schemas, MCP tools, memory files, skills, messages), + * with per-server / per-file / per-skill token detail under each section. + */ + +import type { + ContextBreakdownData, + McpServerInfo, +} from '@moonshot-ai/kimi-code-sdk'; + +import { formatTokenCount, ratioSeverity, safeUsageRatio } from '#/utils/usage/usage-format'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; + +const BLOCK_FILLED = '⛁'; +const BLOCK_EMPTY = '⛶'; +const GRID_COLS = 10; +const GRID_ROWS = 4; +const GRID_BLOCKS = GRID_COLS * GRID_ROWS; + +export interface ContextReportOptions { + readonly model: string; + readonly breakdown?: ContextBreakdownData; + readonly mcpServers?: readonly McpServerInfo[]; + readonly error?: string; +} + +function severityColor(sev: 'ok' | 'warn' | 'danger'): ColorToken { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +function renderBlockGrid(used: number, max: number): string[] { + const emptyCell = currentTheme.fg('textDim', BLOCK_EMPTY); + if (max <= 0) { + return Array.from({ length: GRID_ROWS }, () => emptyCell.repeat(GRID_COLS)); + } + const ratio = safeUsageRatio(used / max); + const usedBlocks = Math.round(ratio * GRID_BLOCKS); + const filledCell = currentTheme.fg(severityColor(ratioSeverity(ratio)), BLOCK_FILLED); + const lines: string[] = []; + for (let row = 0; row < GRID_ROWS; row += 1) { + const cells: string[] = []; + for (let col = 0; col < GRID_COLS; col += 1) { + const index = row * GRID_COLS + col; + cells.push(index < usedBlocks ? filledCell : emptyCell); + } + lines.push(cells.join(' ')); + } + return lines; +} + +/** Percentage of the context window with one decimal ("1.2"), like Claude Code. */ +function percentOf(tokens: number, max: number): string { + return ((tokens / max) * 100).toFixed(1); +} + +function categoryLine(icon: string, color: ColorToken, label: string, value: string): string { + return ` ${currentTheme.fg(color, icon)} ${currentTheme.boldFg('text', label)}: ${value}`; +} + +/** "2.4k tokens (1.2%)" — percent of the context window, or plain tokens when unknown. */ +function tokenValue(tokens: number, max: number): string { + const muted = (text: string) => currentTheme.fg('textDim', text); + const base = `${currentTheme.fg('text', formatTokenCount(tokens))} ${muted('tokens')}`; + return max > 0 ? `${base} ${muted(`(${percentOf(tokens, max)}%)`)}` : base; +} + +/** "~555 tokens" for detail lines, where the value is always an estimate. */ +function estimateValue(tokens: number): string { + return currentTheme.fg('textDim', `~${formatTokenCount(tokens)} tokens`); +} + +function buildMcpServerLines( + servers: readonly McpServerInfo[] | undefined, + breakdown: ContextBreakdownData, +): string[] { + if (servers === undefined || servers.length === 0) return []; + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const tokensByServer = new Map(breakdown.mcpServers.map((server) => [server.name, server.tokens])); + const lines: string[] = ['', currentTheme.boldFg('primary', `MCP servers · /mcp`)]; + const sorted = servers.toSorted((a, b) => a.name.localeCompare(b.name)); + for (let i = 0; i < sorted.length; i += 1) { + const server = sorted[i]!; + const isLast = i === sorted.length - 1; + const branch = isLast ? '└' : '├'; + const statusBadge = server.status === 'connected' ? currentTheme.fg('success', '●') : muted('○'); + const tokens = tokensByServer.get(server.name); + lines.push( + ` ${branch} ${statusBadge} ${value(server.name)} ${muted( + `(${server.toolCount} tool${server.toolCount === 1 ? '' : 's'})`, + )}${tokens === undefined ? '' : ` ${estimateValue(tokens)}`}`, + ); + } + return lines; +} + +function buildMemoryFileLines(breakdown: ContextBreakdownData): string[] { + const files = breakdown.memoryFileEntries; + if (files.length === 0) return []; + const value = (text: string) => currentTheme.fg('text', text); + const lines: string[] = ['', currentTheme.boldFg('roleUser', `Memory files`)]; + for (let i = 0; i < files.length; i += 1) { + const file = files[i]!; + const branch = i === files.length - 1 ? '└' : '├'; + lines.push(` ${branch} ${value(file.path)} ${estimateValue(file.tokens)}`); + } + return lines; +} + +function buildSkillLines(breakdown: ContextBreakdownData): string[] { + const skills = breakdown.skillEntries; + if (skills.length === 0) return []; + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const lines: string[] = ['', currentTheme.boldFg('shellMode', `Skills · /skills`)]; + const sorted = skills.toSorted((a, b) => a.name.localeCompare(b.name)); + for (let i = 0; i < sorted.length; i += 1) { + const skill = sorted[i]!; + const branch = i === sorted.length - 1 ? '└' : '├'; + lines.push( + ` ${branch} ${value(skill.name)} ${muted(`[${skill.source}]`)} ${estimateValue(skill.tokens)}`, + ); + } + return lines; +} + +export function buildContextReportLines(options: ContextReportOptions): string[] { + const accent = (text: string) => currentTheme.boldFg('primary', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + + const lines: string[] = [accent('Context Usage')]; + + if (options.error !== undefined || options.breakdown === undefined) { + lines.push(errorStyle(` ${options.error ?? 'Context breakdown unavailable.'}`)); + return lines; + } + const breakdown = options.breakdown; + + const modelName = options.model.length > 0 ? options.model : muted('No model selected'); + const grid = renderBlockGrid(breakdown.usedTokens, breakdown.maxContextTokens); + const hasWindow = breakdown.maxContextTokens > 0; + const tokenLine = hasWindow + ? `${value(formatTokenCount(breakdown.usedTokens))}/${value( + formatTokenCount(breakdown.maxContextTokens), + )} ${muted('tokens')} ${muted(`(${percentOf(breakdown.usedTokens, breakdown.maxContextTokens)}%)`)}` + : `${value(formatTokenCount(breakdown.usedTokens))} ${muted('tokens')}`; + + // Right-align the summary text next to the first two grid rows. + lines.push(` ${grid[0]} ${modelName}`); + lines.push(` ${grid[1]} ${tokenLine}`); + lines.push(` ${grid[2]}`); + lines.push(` ${grid[3]}`); + + lines.push(''); + lines.push(muted(' Estimated usage by category')); + + const max = breakdown.maxContextTokens; + const freeTokens = hasWindow ? Math.max(0, max - breakdown.usedTokens) : 0; + + lines.push(categoryLine('⛁', 'warning', 'System prompt', tokenValue(breakdown.systemPrompt, max))); + lines.push(categoryLine('⛁', 'accent', 'System tools', tokenValue(breakdown.systemTools, max))); + + const mcpToolCount = options.mcpServers?.reduce((sum, server) => sum + server.toolCount, 0) ?? 0; + const mcpSuffix = + mcpToolCount > 0 + ? muted(` · ${mcpToolCount} tool${mcpToolCount === 1 ? '' : 's'}`) + : ''; + lines.push( + categoryLine('⛁', 'primary', 'MCP tools', tokenValue(breakdown.mcpTools, max) + mcpSuffix), + ); + + lines.push(categoryLine('⛁', 'roleUser', 'Memory files', tokenValue(breakdown.memoryFiles, max))); + lines.push(categoryLine('⛁', 'shellMode', 'Skills', tokenValue(breakdown.skills, max))); + lines.push(categoryLine('⛁', 'text', 'Messages', tokenValue(breakdown.messages, max))); + lines.push(categoryLine('⛶', 'textDim', 'Free space', hasWindow ? tokenValue(freeTokens, max) : muted('unknown'))); + + lines.push(...buildMcpServerLines(options.mcpServers, breakdown)); + lines.push(...buildMemoryFileLines(breakdown)); + lines.push(...buildSkillLines(breakdown)); + + return lines; +} diff --git a/apps/kimi-code/test/tui/components/messages/context-panel.test.ts b/apps/kimi-code/test/tui/components/messages/context-panel.test.ts new file mode 100644 index 0000000000..6a7587e387 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/context-panel.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import type { ContextBreakdownData } from '@moonshot-ai/kimi-code-sdk'; + +import { buildContextReportLines } from '#/tui/components/messages/context-panel'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +const BREAKDOWN: ContextBreakdownData = { + contextTokens: 28300, + usedTokens: 28300, + maxContextTokens: 200000, + systemPrompt: 2400, + systemTools: 8000, + mcpTools: 555, + mcpServers: [{ name: 'MiniMax', tokens: 555 }], + memoryFiles: 5100, + memoryFileEntries: [ + { path: '/home/user/.kimi-code/AGENTS.md', tokens: 4200 }, + { path: '/repo/AGENTS.md', tokens: 900 }, + ], + skills: 2000, + skillEntries: [ + { name: 'code-review', source: 'user', tokens: 140 }, + { name: 'tdd', source: 'project', tokens: 60 }, + ], + messages: 10200, +}; + +describe('context panel report lines', () => { + it('renders the model, window usage, and per-category token estimates', () => { + const lines = buildContextReportLines({ + model: 'MiniMax-M2.7-highspeed', + breakdown: BREAKDOWN, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Context Usage'); + expect(output).toContain('MiniMax-M2.7-highspeed'); + expect(output).toContain('27.6k/195k tokens (14.1%)'); + expect(output).toContain('Estimated usage by category'); + expect(output).toContain('System prompt: 2.3k tokens (1.2%)'); + expect(output).toContain('System tools: 7.8k tokens (4.0%)'); + expect(output).toContain('MCP tools: 555 tokens (0.3%)'); + expect(output).toContain('Memory files: 5k tokens (2.5%)'); + expect(output).toContain('Skills: 2k tokens (1.0%)'); + expect(output).toContain('Messages: 10k tokens (5.1%)'); + expect(output).toContain('Free space: 168k tokens (85.9%)'); + }); + + it('renders per-server, per-file, and per-skill token detail', () => { + const lines = buildContextReportLines({ + model: 'k2', + breakdown: BREAKDOWN, + mcpServers: [ + { name: 'MiniMax', transport: 'stdio', status: 'connected', toolCount: 2 }, + { name: 'broken', transport: 'http', status: 'failed', toolCount: 0 }, + ], + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('MCP tools: 555 tokens (0.3%) · 2 tools'); + expect(output).toContain('MCP servers · /mcp'); + expect(output).toContain('MiniMax (2 tools) ~555 tokens'); + // A server without exposed tools gets no token estimate. + expect(output).toContain('broken (0 tools)'); + expect(output).not.toContain('broken (0 tools) ~'); + expect(output).toContain('Memory files'); + expect(output).toContain('/home/user/.kimi-code/AGENTS.md ~4.1k tokens'); + expect(output).toContain('/repo/AGENTS.md ~900 tokens'); + expect(output).toContain('Skills · /skills'); + expect(output).toContain('code-review [user] ~140 tokens'); + expect(output).toContain('tdd [project] ~60 tokens'); + }); + + it('omits percentages and free space when the context window is unknown', () => { + const lines = buildContextReportLines({ + model: 'k2', + breakdown: { ...BREAKDOWN, maxContextTokens: 0 }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('27.6k tokens'); + expect(output).toContain('System prompt: 2.3k tokens'); + expect(output).not.toContain('(1.2%)'); + expect(output).toContain('Free space: unknown'); + }); + + it('keeps free space consistent with the categories before the first LLM round-trip', () => { + const overhead = + BREAKDOWN.systemPrompt + + BREAKDOWN.systemTools + + BREAKDOWN.mcpTools + + BREAKDOWN.memoryFiles + + BREAKDOWN.skills; + const lines = buildContextReportLines({ + model: 'k2', + breakdown: { ...BREAKDOWN, contextTokens: 0, usedTokens: overhead, messages: 0 }, + }).map(strip); + + const output = lines.join('\n'); + // The header and free space follow the estimated category sum (18.1k), + // not the not-yet-known LLM total (0) — "100% free" would contradict the + // non-zero category rows. + expect(output).toContain('17.6k/195k tokens (9.0%)'); + expect(output).toContain('Messages: 0 tokens (0.0%)'); + expect(output).toContain('Free space: 178k tokens (91.0%)'); + }); + + it('renders the error instead of the report when the breakdown fails', () => { + const lines = buildContextReportLines({ + model: 'k2', + error: 'session closed', + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Context Usage'); + expect(output).toContain('session closed'); + expect(output).not.toContain('Estimated usage by category'); + }); +}); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index d3ab8bf4cb..7f9f8d6281 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -103,6 +103,7 @@ Prompt mode exits with code `0` when the goal completes, `3` when it blocks, and | `/help` | `/h`, `/?` | Show keyboard shortcuts and all available commands | Yes | | `/btw [question]` | — | Open a side conversation in a forked sub-Agent without affecting the current main Agent turn; without a question, opens the panel first to wait for input | Yes | | `/usage` | — | Show token usage, context consumption, and quota information | Yes | +| `/context` | — | Show the context window usage breakdown with estimated tokens per category (system prompt, tools, MCP tools, memory files, skills, messages) | Yes | | `/status` | — | Show the current session runtime state: version, model, working directory, permission mode, etc. | Yes | | `/mcp` | — | List MCP servers and their connection status in the current session | Yes | | `/plugins` | — | Open the interactive plugin manager | Yes | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index a1882cf87e..e6e865c6fb 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -101,6 +101,7 @@ Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 ` | `/help` | `/h`、`/?` | 显示快捷键和所有可用命令 | 是 | | `/btw [问题]` | — | 在 fork 出的子 Agent 中打开旁路对话,不改变当前主 Agent 轮次;不带问题时会先打开面板等待输入 | 是 | | `/usage` | — | 显示 token 用量、上下文占用以及配额信息 | 是 | +| `/context` | — | 显示上下文窗口的占用分布,按类别估算 token(系统提示词、工具、MCP 工具、记忆文件、Skill、消息) | 是 | | `/status` | — | 显示当前会话运行时状态:版本、模型、工作目录、权限模式等 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及连接状态 | 是 | | `/plugins` | — | 打开交互式 plugin 管理器 | 是 | diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts index f4f6f7a4e9..cff8bdb735 100644 --- a/packages/agent-core/src/agent/context/types.ts +++ b/packages/agent-core/src/agent/context/types.ts @@ -131,3 +131,77 @@ export interface AgentContextData { history: readonly ContextMessage[]; tokenCount: number; } + +/** + * Estimated per-category token cost of the agent's context, behind the + * `/context` report. All values except `contextTokens` are character-heuristic + * estimates (see `estimateTokens`); the real numbers only exist per LLM + * round-trip and are not attributed per category. + */ +export interface ContextBreakdownData { + /** + * Last known total context tokens (same source as the status bar): the most + * recent LLM-reported usage, which covers system prompt + tool schemas + + * messages + the last turn's output. 0 before the first LLM round-trip. + */ + contextTokens: number; + /** + * Effective used tokens for the report header and free-space calculation: + * `max(contextTokens, estimated category sum)`. Before the first LLM + * round-trip `contextTokens` is still 0 while the system-prompt/tool + * overhead is real, so the raw total would contradict the per-category + * rows (non-zero categories against "100% free"); the effective value + * keeps the panel self-consistent in both states. + */ + usedTokens: number; + /** Model context-window size in tokens; 0 when unknown. */ + maxContextTokens: number; + /** + * Estimated tokens of the base system prompt — the rendered template minus + * the injected memory (AGENTS.md) and skill-listing sections. + */ + systemPrompt: number; + /** Estimated schema tokens of the builtin + user tools currently exposed. */ + systemTools: number; + /** Estimated schema tokens of the MCP tools currently exposed inline. */ + mcpTools: number; + /** Per-server split of `mcpTools` (only servers with exposed tools appear). */ + mcpServers: readonly ContextBreakdownMcpServer[]; + /** Estimated tokens of the injected AGENTS.md memory content. */ + memoryFiles: number; + /** Per-file split of `memoryFiles`. */ + memoryFileEntries: readonly ContextBreakdownMemoryFile[]; + /** Estimated tokens of the skill listing injected into the system prompt. */ + skills: number; + /** + * Per-skill split of the model skill listing. Covers exactly the skills the + * model listing carries (invocable, non-sub-skills) — a subset of what + * `/skills` lists. Per-skill values exclude the listing's header and group + * labels, so they sum to slightly less than `skills`. + */ + skillEntries: readonly ContextBreakdownSkill[]; + /** + * Remainder attributed to the conversation: `usedTokens` minus every + * estimated category above. Because the LLM-reported total covers system + * prompt + tool schemas + the last turn's output, this residual also + * absorbs the output tokens and any estimation error of the other + * categories. It is 0 before the first LLM round-trip. + */ + messages: number; +} + +export interface ContextBreakdownMcpServer { + readonly name: string; + readonly tokens: number; +} + +export interface ContextBreakdownMemoryFile { + readonly path: string; + readonly tokens: number; +} + +export interface ContextBreakdownSkill { + readonly name: string; + readonly source: SkillSource; + readonly tokens: number; +} diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 7786c5eb04..2d1885efde 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -11,6 +11,8 @@ import { generate, type ChatProvider } from '@moonshot-ai/kosong'; import type { EnabledPluginSessionStart, PluginCommandDef } from '#/plugin'; import { expandCommandArguments } from '../plugin/commands'; import type { PluginCommandOrigin } from './context'; +import type { ContextBreakdownData } from './context/types'; +import { estimateTokens } from '../utils/tokens'; import type { McpConnectionManager } from '../mcp'; import { FlagResolver, type ExperimentalFlagResolver } from '../flags'; @@ -477,6 +479,63 @@ export class Agent { this.config.update({ profileName: profile.name, systemPrompt }); } + /** + * Estimated per-category token cost behind the `/context` report. Re-gathers + * the system-prompt context (AGENTS.md, skill listing) so the memory/skills + * split reflects the current filesystem, then attributes the rendered system + * prompt between the base template and those injected sections. + * + * `contextTokens` (the status-bar total) is the last LLM-reported usage and + * therefore already covers system prompt + tool schemas + messages + the + * last turn's output — so messages are reported as the residual after + * subtracting every estimated category from the effective total + * (`usedTokens`, which falls back to the category sum before the first + * round-trip), keeping the panel consistent with the header instead of + * double-counting the overhead. + */ + async contextBreakdownData(): Promise { + const context = this.systemPromptContextProvider === undefined + ? await prepareSystemPromptContext(this.kaos, this.brandHome, { + additionalDirs: this.additionalDirs, + }) + : await this.systemPromptContextProvider(); + const memoryFiles = estimateTokens(context.agentsMd ?? ''); + const skills = estimateTokens(this.skills?.registry.getModelSkillListing() ?? ''); + const systemPrompt = Math.max( + 0, + estimateTokens(this.config.systemPrompt) - memoryFiles - skills, + ); + const { systemTools, mcpTools, mcpServers } = this.tools.contextToolBreakdown(); + const overhead = systemPrompt + systemTools + mcpTools + memoryFiles + skills; + // Before the first LLM round-trip the reported total is still 0 while the + // system-prompt/tool overhead is real; report the larger of the two so the + // header, free space, and the messages residual never contradict the + // per-category rows. + const usedTokens = Math.max(this.context.tokenCount, overhead); + const capability = this.config.modelCapabilities; + return { + contextTokens: this.context.tokenCount, + usedTokens, + maxContextTokens: capability.max_input_tokens ?? capability.max_context_tokens ?? 0, + systemPrompt, + systemTools, + mcpTools, + mcpServers, + memoryFiles, + memoryFileEntries: context.agentsMdFiles.map((file) => ({ + path: file.path, + tokens: estimateTokens(file.content), + })), + skills, + skillEntries: (this.skills?.registry.getModelSkillListingEntries() ?? []).map((entry) => ({ + name: entry.name, + source: entry.source, + tokens: estimateTokens(entry.text), + })), + messages: usedTokens - overhead, + }; + } + async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { const result = await this.records.replay(options); this.flushPendingAnthropicThinkingEffortWarnings(); @@ -660,6 +719,7 @@ export class Agent { getCronTasks: () => ({ tasks: this.cron?.listTaskSnapshots() ?? [] }), getBackgroundOutput: (payload) => this.background.readOutput(payload.taskId, payload.tail), getContext: () => this.context.data(), + getContextBreakdown: () => this.contextBreakdownData(), getConfig: () => this.config.data(), getPermission: () => this.permission.data(), getPlan: () => this.planMode.data(), diff --git a/packages/agent-core/src/agent/skill/types.ts b/packages/agent-core/src/agent/skill/types.ts index 19413b8a2c..7db6e23ad8 100644 --- a/packages/agent-core/src/agent/skill/types.ts +++ b/packages/agent-core/src/agent/skill/types.ts @@ -1,4 +1,4 @@ -import type { SkillDefinition } from '../../skill'; +import type { SkillDefinition, SkillSource } from '../../skill'; export interface SkillRegistry { getSkill(name: string): SkillDefinition | undefined; @@ -7,4 +7,5 @@ export interface SkillRegistry { listInvocableSkills(): readonly SkillDefinition[]; getSkillRoots(): readonly string[]; getModelSkillListing(): string; + getModelSkillListingEntries(): readonly { name: string; source: SkillSource; text: string }[]; } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 818571a7a1..a4a2d5816d 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -17,6 +17,7 @@ import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; import { DEFAULT_AGENT_PROFILES } from '../../profile'; import { resolveSubagentTimeoutMs } from '../../session/subagent-host'; import { extendWorkspaceWithSkillRoots } from '../../skill'; +import { estimateTokensForTools } from '../../utils/tokens'; import { fingerprint } from '../llm-request-logger'; import * as b from '../../tools/builtin'; import type { ToolStore, ToolStoreData, ToolStoreKey } from '../../tools/store'; @@ -747,6 +748,38 @@ export class ToolManager { return Array.from(this.toolInfos()); } + /** + * Estimated schema tokens of the tools currently exposed to the model, + * split into MCP and non-MCP (builtin + user) buckets for the `/context` + * report. Deferred tools are skipped: under progressive disclosure their + * schemas travel as context messages instead of the top-level `tools[]`, + * so their cost is already covered by the message estimate. + */ + contextToolBreakdown(): { + systemTools: number; + mcpTools: number; + mcpServers: readonly { name: string; tokens: number }[]; + } { + let systemTools = 0; + let mcpTools = 0; + const perServer = new Map(); + for (const tool of this.loopTools) { + if (tool.deferred === true) continue; + const estimate = estimateTokensForTools([tool]); + const mcpEntry = this.mcpTools.get(tool.name); + if (mcpEntry === undefined) { + systemTools += estimate; + } else { + mcpTools += estimate; + perServer.set(mcpEntry.serverName, (perServer.get(mcpEntry.serverName) ?? 0) + estimate); + } + } + const mcpServers = [...perServer.entries()] + .map(([name, tokens]) => ({ name, tokens })) + .toSorted((a, b) => a.name.localeCompare(b.name)); + return { systemTools, mcpTools, mcpServers }; + } + storeData(): Readonly> { return { ...this.store }; } diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 46b97172a5..e61e59cd60 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -36,6 +36,10 @@ export { renderToolResultForModel } from './agent/context/tool-result-render'; export type { RenderableToolResult } from './agent/context/tool-result-render'; export type { AgentContextData, + ContextBreakdownData, + ContextBreakdownMcpServer, + ContextBreakdownMemoryFile, + ContextBreakdownSkill, ContextMessage, PromptOrigin, UserPromptOrigin, diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index a701b8b735..449b46ab62 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -20,6 +20,11 @@ export interface PreparedSystemPromptContext extends Pick { /** Present when the combined AGENTS.md content exceeds the recommended size. */ readonly agentsMdWarning?: string; + /** + * Per-file split of `agentsMd` (content without the `` + * annotation), for per-file token attribution in the `/context` report. + */ + readonly agentsMdFiles: readonly { path: string; content: string }[]; } export interface PrepareSystemPromptContextOptions { @@ -42,6 +47,7 @@ export async function prepareSystemPromptContext( agentsMd: agentsMdResult.content, additionalDirsInfo, agentsMdWarning: agentsMdResult.warning, + agentsMdFiles: agentsMdResult.files, }; } @@ -53,6 +59,7 @@ export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise GetCronTasksResult; getBackgroundOutput: (payload: GetBackgroundOutputPayload) => string; getContext: (payload: EmptyPayload) => AgentContextData; + getContextBreakdown: (payload: EmptyPayload) => ContextBreakdownData; getConfig: (payload: EmptyPayload) => AgentConfigData; getPermission: (payload: EmptyPayload) => PermissionData; getPlan: (payload: EmptyPayload) => PlanData; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 06e97a5e44..e8ec34749b 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -931,6 +931,10 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).getContext(payload); } + getContextBreakdown({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getContextBreakdown(payload); + } + getConfig({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).getConfig(payload); } diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index d12b44b6ec..0a59cd5000 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -285,6 +285,10 @@ export class SessionAPIImpl implements PromisableMethods { return (await this.getAgent(agentId)).getContext(payload); } + async getContextBreakdown({ agentId, ...payload }: AgentScopedPayload) { + return (await this.getAgent(agentId)).getContextBreakdown(payload); + } + async getConfig({ agentId, ...payload }: AgentScopedPayload) { return (await this.getAgent(agentId)).getConfig(payload); } diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index 65b207e274..495f884ffe 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -141,6 +141,20 @@ export class SessionSkillRegistry implements AgentSkillRegistry { } return lines.length === 1 ? '' : lines.join('\n'); } + + /** + * Per-skill rendering of {@link getModelSkillListing}'s selection (invocable, + * non-sub-skills), for per-skill token attribution in the `/context` report. + */ + getModelSkillListingEntries(): readonly { name: string; source: SkillSource; text: string }[] { + return this.listInvocableSkills() + .filter((skill) => skill.metadata.isSubSkill !== true) + .map((skill) => ({ + name: skill.name, + source: skill.source, + text: formatModelSkill(skill).join('\n'), + })); + } } function pluginSkillKey(pluginId: string, skillName: string): string { diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index ecaed3318f..6d35f23578 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -96,6 +96,7 @@ describe('Agent config', () => { cwdListing: 'cwd listing', agentsMd: 'agents md', additionalDirsInfo: '### /extra\nextra-file.txt', + agentsMdFiles: [], }); expect(ctx.agent.config.systemPrompt).toBe( diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 432187410a..c718fcef52 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -8,7 +8,7 @@ import { renderNotificationXml } from '../../src/agent/context/notification-xml' import { project } from '../../src/agent/context/projector'; import type { ContextMessage } from '../../src/agent/context/types'; import { buildImageCompressionCaption } from '../../src/tools/support/image-compress'; -import { estimateTokensForMessages } from '../../src/utils/tokens'; +import { estimateTokens, estimateTokensForMessages } from '../../src/utils/tokens'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; import { testAgent } from './harness/agent'; @@ -1541,4 +1541,50 @@ describe('strictMessages duplicate tool call ids', () => { expect(strictResults).toHaveLength(1); expect(textOf(strictResults[0]!)).toBe('result 1'); }); + + it('estimates per-category context tokens for the /context report', async () => { + const ctx = testAgent({ + systemPromptContextProvider: async () => ({ + cwdListing: '', + agentsMd: '', + additionalDirsInfo: '', + agentsMdFiles: [], + }), + }); + ctx.configure({ tools: ['Bash', 'Read'] }); + + ctx.agent.context.appendUserMessage([{ type: 'text', text: 'hello' }]); + + const breakdown = await ctx.agent.contextBreakdownData(); + expect(breakdown.contextTokens).toBe(ctx.agent.context.tokenCount); + expect(breakdown.maxContextTokens).toBeGreaterThan(0); + // The base system prompt is the rendered template minus the (empty) + // memory/skills sections. + expect(breakdown.systemPrompt).toBe(estimateTokens('You are a deterministic test agent.')); + expect(breakdown.systemTools).toBeGreaterThan(0); + // No MCP servers, no memory files, no skills in the test harness. + expect(breakdown.mcpTools).toBe(0); + expect(breakdown.mcpServers).toEqual([]); + expect(breakdown.memoryFiles).toBe(0); + expect(breakdown.memoryFileEntries).toEqual([]); + expect(breakdown.skills).toBe(0); + expect(breakdown.skillEntries).toEqual([]); + + // Messages are the residual: the LLM-reported total already covers the + // system prompt and tool schemas, so they must not be counted twice. + // Before any LLM round-trip the raw total is 0, so the effective total + // falls back to the category sum and the residual is 0. + expect(breakdown.messages).toBe(0); + const overhead = + breakdown.systemPrompt + + breakdown.systemTools + + breakdown.mcpTools + + breakdown.memoryFiles + + breakdown.skills; + expect(breakdown.usedTokens).toBe(overhead); + ctx.agent.context.updateTokenCount(overhead + 5000); + const updated = await ctx.agent.contextBreakdownData(); + expect(updated.usedTokens).toBe(overhead + 5000); + expect(updated.messages).toBe(5000); + }); }); diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 06041c2599..903fb28d86 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -109,6 +109,7 @@ export interface TestAgentOptions { readonly telemetry?: TelemetryClient | undefined; readonly log?: Logger; readonly experimentalFlags?: AgentOptions['experimentalFlags']; + readonly systemPromptContextProvider?: AgentOptions['systemPromptContextProvider']; } interface ConfigureOptions { @@ -197,6 +198,7 @@ export class AgentTestContext { telemetry: options.telemetry, log: options.log, experimentalFlags: options.experimentalFlags, + systemPromptContextProvider: options.systemPromptContextProvider, }); if (options.goal !== undefined) { (this.agent as unknown as { goal: GoalMode }).goal = options.goal; diff --git a/packages/agent-core/test/agent/skill-tool-manager.test.ts b/packages/agent-core/test/agent/skill-tool-manager.test.ts index 51b42a74e0..3e4ed303e2 100644 --- a/packages/agent-core/test/agent/skill-tool-manager.test.ts +++ b/packages/agent-core/test/agent/skill-tool-manager.test.ts @@ -136,6 +136,7 @@ describe('ToolManager SkillTool registration', () => { listInvocableSkills: () => [skill], getSkillRoots: () => ['/skills/review'], getModelSkillListing: () => '- review: desc for review', + getModelSkillListingEntries: () => [], }; const agent = makeAgent(skills); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 1315812c0b..a89d3505c4 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -4,6 +4,7 @@ import { ErrorCodes, makeErrorPayload, type AgentContextData, + type ContextBreakdownData, type ApprovalRequest, type ApprovalResponse, type BeginGlobalMcpServerAuthResult, @@ -543,6 +544,14 @@ export abstract class SDKRpcClientBase { }); } + async getContextBreakdown(input: SessionIdRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.getContextBreakdown({ + sessionId: input.sessionId, + agentId: this.interactiveAgentId, + }); + } + async getUsage(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); return rpc.getUsage({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 571c2355ff..c7bd06a73b 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -2,6 +2,7 @@ import { ErrorCodes, KimiError, type AgentContextData, + type ContextBreakdownData, type KimiErrorCode, type SwarmModeTrigger, } from '@moonshot-ai/agent-core'; @@ -314,6 +315,12 @@ export class Session { return this.rpc.getContext({ sessionId: this.id }); } + /** Estimated per-category token cost of the interactive agent's context. */ + async getContextBreakdown(): Promise { + this.ensureOpen(); + return this.rpc.getContextBreakdown({ sessionId: this.id }); + } + async getUsage(): Promise { this.ensureOpen(); return this.rpc.getUsage({ sessionId: this.id }); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 0968d96a18..fcda4f7261 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -19,10 +19,15 @@ export type Unsubscribe = () => void; export type { AgentReplayRecord, AgentBackgroundTaskInfo, + AgentContextData, BackgroundConfig, BackgroundTaskInfo, BackgroundTaskStatus, ConfigDiagnostics, + ContextBreakdownData, + ContextBreakdownMcpServer, + ContextBreakdownMemoryFile, + ContextBreakdownSkill, ContextMessage, CronTaskSnapshot, ExperimentalFeatureState,