From 08d0f9bdb893eafa28f93a438b187f37dd025dae Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 25 May 2026 17:19:05 +0100 Subject: [PATCH] feat(agent): fill Skills/MCP/Rules categories in context breakdown --- .../agent/src/adapters/claude/claude-agent.ts | 48 ++++++++++++++++++- .../adapters/claude/context-breakdown.test.ts | 46 ++++++++++++++++++ .../src/adapters/claude/context-breakdown.ts | 38 +++++++++++++++ .../src/adapters/claude/mcp/tool-metadata.ts | 6 +++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index a8824c8fb..410fee95b 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -71,6 +71,9 @@ import { resolveTaskId } from "../session-meta"; import { buildBreakdown, emptyBaseline, + estimateMcpTokens, + estimateRulesTokens, + estimateSkillsTokens, estimateSystemPrompt, } from "./context-breakdown"; import { promptToClaude } from "./conversion/acp-to-sdk"; @@ -84,6 +87,7 @@ import type { EnrichedReadCache } from "./hooks"; import { createLocalToolsMcpServer } from "./mcp/local-tools"; import { fetchMcpToolMetadata, + getCachedMcpTools, getConnectedMcpServerNames, setMcpToolApprovalStates, } from "./mcp/tool-metadata"; @@ -123,6 +127,26 @@ const SESSION_VALIDATION_TIMEOUT_MS = 30_000; const MAX_TITLE_LENGTH = 256; const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); +/** Read CLAUDE.md from the project root so the context breakdown can size the + * Rules category. Best-effort: silent on a missing file, logs otherwise so + * permission errors aren't lost. */ +function readClaudeMdQuietly( + cwd: string, + logger: Logger, +): string | undefined { + try { + return fs.readFileSync(path.join(cwd, "CLAUDE.md"), "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { + logger.warn("Failed to read CLAUDE.md for context breakdown", { + cwd, + error: err instanceof Error ? err.message : String(err), + }); + } + return undefined; + } +} + function sanitizeTitle(text: string): string { const sanitized = text .replace(/[\r\n]+/g, " ") @@ -1241,6 +1265,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { contextBreakdownBaseline: { ...emptyBaseline(), systemPrompt: estimateSystemPrompt(systemPrompt), + rules: estimateRulesTokens(readClaudeMdQuietly(cwd, this.logger)), }, // Custom properties @@ -1568,13 +1593,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent { private async sendAvailableCommandsUpdate(): Promise { const commands = await this.session.query.supportedCommands(); + const available = getAvailableSlashCommands(commands); await this.client.sessionUpdate({ sessionId: this.sessionId, update: { sessionUpdate: "available_commands_update", - availableCommands: getAvailableSlashCommands(commands), + availableCommands: available, }, }); + this.updateBreakdownCategory("skills", estimateSkillsTokens(available)); + } + + /** Update one category of the context-breakdown baseline so the next + * `_posthog/usage_update` carries fresher numbers. No-op when the baseline + * hasn't been initialized yet (e.g. in a unit-test session). */ + private updateBreakdownCategory( + key: keyof NonNullable, + tokens: number, + ): void { + if (!this.session?.contextBreakdownBaseline) return; + if (this.session.contextBreakdownBaseline[key] === tokens) return; + this.session.contextBreakdownBaseline = { + ...this.session.contextBreakdownBaseline, + [key]: tokens, + }; } private async replaySessionHistory(sessionId: string): Promise { @@ -1631,6 +1673,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.sendAvailableCommandsUpdate(), ), fetchMcpToolMetadata(q, this.logger).then(() => { + this.updateBreakdownCategory( + "mcp", + estimateMcpTokens(getCachedMcpTools()), + ); const serverNames = getConnectedMcpServerNames(); if (serverNames.length > 0) { this.options?.onMcpServersReady?.(serverNames); diff --git a/packages/agent/src/adapters/claude/context-breakdown.test.ts b/packages/agent/src/adapters/claude/context-breakdown.test.ts index cf8660e73..d1a5677e9 100644 --- a/packages/agent/src/adapters/claude/context-breakdown.test.ts +++ b/packages/agent/src/adapters/claude/context-breakdown.test.ts @@ -3,6 +3,9 @@ import { buildBreakdown, emptyBaseline, estimateJsonTokens, + estimateMcpTokens, + estimateRulesTokens, + estimateSkillsTokens, estimateSystemPrompt, estimateTokens, } from "./context-breakdown"; @@ -57,6 +60,49 @@ describe("estimateSystemPrompt", () => { }); }); +describe("estimateSkillsTokens", () => { + it("is 0 for an empty command list", () => { + expect(estimateSkillsTokens([])).toBe(0); + }); + + it("counts the JSON of name/description/hint", () => { + // [{"name":"review","description":"Review a PR","hint":"[pr]"}] ~ 55 chars + const result = estimateSkillsTokens([ + { name: "review", description: "Review a PR", input: { hint: "[pr]" } }, + ]); + expect(result).toBeGreaterThan(10); + expect(result).toBeLessThan(20); + }); +}); + +describe("estimateMcpTokens", () => { + it("is 0 for no connected tools", () => { + expect(estimateMcpTokens([])).toBe(0); + }); + + it("scales with tool count", () => { + const one = estimateMcpTokens([{ name: "get_user", description: "x" }]); + const many = estimateMcpTokens( + Array.from({ length: 50 }, (_, i) => ({ + name: `tool_${i}`, + description: "x", + })), + ); + expect(many).toBeGreaterThan(one * 10); + }); +}); + +describe("estimateRulesTokens", () => { + it("is 0 for missing rules", () => { + expect(estimateRulesTokens(undefined)).toBe(0); + expect(estimateRulesTokens("")).toBe(0); + }); + + it("counts the rules content", () => { + expect(estimateRulesTokens("a".repeat(350))).toBe(100); + }); +}); + describe("buildBreakdown", () => { it("derives conversation from input - stable sum", () => { const baseline = { diff --git a/packages/agent/src/adapters/claude/context-breakdown.ts b/packages/agent/src/adapters/claude/context-breakdown.ts index 2775cbc28..421d47a45 100644 --- a/packages/agent/src/adapters/claude/context-breakdown.ts +++ b/packages/agent/src/adapters/claude/context-breakdown.ts @@ -37,6 +37,44 @@ export function estimateJsonTokens(value: unknown): number { } } +interface SlashCommandLike { + name?: string; + description?: string; + input?: { hint?: string } | null; +} + +/** Tokens for the slash-command list the SDK injects into the system prompt. */ +export function estimateSkillsTokens(commands: SlashCommandLike[]): number { + if (!commands.length) return 0; + return estimateJsonTokens( + commands.map((c) => ({ + name: c.name, + description: c.description, + hint: c.input?.hint, + })), + ); +} + +interface McpToolLike { + name?: string; + description?: string; +} + +/** Tokens for the connected MCP tools' name + description. The SDK doesn't + * inject their full input schemas into the prompt by default (it relies on + * tool search), so this is a conservative estimate of what's resident. */ +export function estimateMcpTokens(tools: McpToolLike[]): number { + if (!tools.length) return 0; + return estimateJsonTokens( + tools.map((t) => ({ name: t.name, description: t.description })), + ); +} + +/** Tokens for the rules content appended to the system prompt (CLAUDE.md). */ +export function estimateRulesTokens(rules: string | undefined): number { + return estimateTokens(rules); +} + export interface ContextBreakdownBaseline { systemPrompt: number; tools: number; diff --git a/packages/agent/src/adapters/claude/mcp/tool-metadata.ts b/packages/agent/src/adapters/claude/mcp/tool-metadata.ts index 72ef0451e..0f13d589a 100644 --- a/packages/agent/src/adapters/claude/mcp/tool-metadata.ts +++ b/packages/agent/src/adapters/claude/mcp/tool-metadata.ts @@ -116,6 +116,12 @@ export function getConnectedMcpServerNames(): string[] { return [...names]; } +/** Snapshot of every tool currently in the metadata cache. Used by the + * context-breakdown estimator to size the MCP category. */ +export function getCachedMcpTools(): McpToolMetadata[] { + return [...mcpToolMetadataCache.values()]; +} + export function getMcpToolApprovalState( toolName: string, ): McpToolApprovalState | undefined {