/** * Agent-listing budget — single source of truth for the AGT scanner. * * Claude Code injects a listing of every active agent's name+description into the * system prompt so the model knows which subagents it can delegate to. With many * installed agents that listing is a large always-loaded cost: re-sent every turn * whether or not a delegation actually happens. * * CRUCIAL HONESTY CAVEAT — this is why AGT differs from SKL. Unlike the skill * listing (documented ~2% allotment, CC 2.1.32; verified 1,536-char truncation * cap, CC 2.1.105), the agent-listing mechanism is NOT documented — agents are * absent from Claude Code's published context breakdown. Therefore: * - "always-loaded" is INFERRED (reasoned from the skill analogue + agents' * absence from the deferred/on-demand list), not documented. * - the token figure is an UPPER-BOUND ESTIMATE, not measured telemetry. * - there is no documented per-listing allotment, so the aggregate budget is a * config-audit heuristic anchored — by analogy to the skill listing — on a * conservative 200k window, and the evidence says so loudly. * - there is no verified per-description truncation cap, so (unlike CA-SKL-001) * each description contributes its FULL length to the aggregate. * * Zero external dependencies. */ import { estimateTokens, enumeratePlugins, enumerateAgents } from './active-config-reader.mjs'; import { readTextFile } from './file-discovery.mjs'; import { parseFrontmatter } from './yaml-parser.mjs'; import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas } from './context-window.mjs'; // Heuristic budget by analogy to the skill listing's documented ~2% allotment. // Agents have NO documented allotment of their own — disclosed loudly in // BUDGET_CALIBRATION_NOTE so the number is never mistaken for a CC guarantee. export const BUDGET_FRACTION = 0.02; export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000 export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000 export { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas }; // Per-agent soft cap for the description-bloat advisory. This is a HEURISTIC, // NOT a truncation cap: agents have no verified per-description limit (unlike // the skill listing's 1,536-char cap, CC 2.1.105). We reuse the 500-char // bloat threshold TOK pattern F already applies to SKILL.md descriptions, so a // long agent description is flagged for the same reason — every char re-enters // context in the always-loaded agent listing on every turn — without ever // claiming Claude Code drops the tail. export const PER_AGENT_DESC_SOFT_CAP = 500; // The honest framing required because (a) the mechanism is inferred and (b) the // budget depends on a context window we cannot observe. Appended to overflow evidence. export const BUDGET_CALIBRATION_NOTE = 'the agent-listing always-loaded mechanism is INFERRED, not documented (agents are absent from ' + "Claude Code's published context breakdown), so this token figure is an UPPER-BOUND ESTIMATE, not " + 'measured telemetry. unlike the skill listing there is no documented per-listing allotment, so this ' + 'budget is a config-audit heuristic anchored on a conservative 200k window; at ' + `${withCommas(LARGE_CONTEXT_WINDOW)} context the budget is ~${withCommas(LARGE_CONTEXT_BUDGET_TOKENS)} ` + 'tok and you are likely within it'; /** * @typedef {object} AgentBudgetAssessment * @property {number} scanned - number of agent descriptions assessed * @property {number} aggregateChars - sum of description lengths (no cap; no verified truncation) * @property {number} aggregateTokens - estimateTokens(aggregateChars, 'markdown') * @property {number} budgetTokens - AGGREGATE_BUDGET_TOKENS (the 200k-anchored heuristic) * @property {boolean} overBudget - aggregateTokens strictly greater than budgetTokens * @property {number} overBy - tokens over budget (0 when not over) */ /** * Pure aggregate-budget assessment. Each description contributes its full length: * agents have no verified truncation cap, so nothing is dropped from the estimate. * * @param {number[]} descLengths - one entry per active agent (description char count) * @returns {AgentBudgetAssessment} */ export function assessAgentListingBudget(descLengths) { let aggregateChars = 0; for (const len of descLengths) { const safe = (typeof len === 'number' && Number.isFinite(len) && len > 0) ? len : 0; aggregateChars += safe; } const aggregateTokens = estimateTokens(aggregateChars, 'markdown'); const overBudget = aggregateTokens > AGGREGATE_BUDGET_TOKENS; return { scanned: descLengths.length, aggregateChars, aggregateTokens, budgetTokens: AGGREGATE_BUDGET_TOKENS, overBudget, overBy: overBudget ? aggregateTokens - AGGREGATE_BUDGET_TOKENS : 0, }; } /** * @typedef {object} ActiveAgentEntry * @property {string} name * @property {'user'|'plugin'} source * @property {string|null} pluginName * @property {string} path * @property {number} descLength */ /** * Enumerate every active agent (user + plugin) and measure the listing budget. * HOME-scoped (mirrors the skill listing): resolves ~/.claude via process.env.HOME * and excludes project-scoped agents — those load only inside their own repo. * Callers that run under test MUST override HOME (runScannerWithHome pattern). * * @returns {Promise<{ agents: ActiveAgentEntry[], aggregate: AgentBudgetAssessment }>} */ export async function measureActiveAgentListing() { const plugins = await enumeratePlugins(); // enumerateAgents yields project + user + plugin; drop project (repo-local). const allAgents = await enumerateAgents('', plugins); const agents = []; for (const agent of allAgents) { if (!agent || agent.source === 'project' || typeof agent.path !== 'string') continue; const content = await readTextFile(agent.path); if (!content) continue; const fm = parseFrontmatter(content)?.frontmatter || null; const desc = (fm && typeof fm.description === 'string') ? fm.description : ''; agents.push({ name: agent.name, source: agent.source, pluginName: agent.pluginName, path: agent.path, descLength: desc.length, }); } const aggregate = assessAgentListingBudget(agents.map((a) => a.descLength)); return { agents, aggregate }; }