diff --git a/CHANGELOG.md b/CHANGELOG.md index 2485e772..783350a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ new version heading in the same commit. ## [Unreleased] +## [0.470.0] - 2026-11-10 +### Added +- **An agent's CLAUDE.md has a size budget.** Self-improvement only ever appends — an agent that learns a + lesson writes it into its own prompt and nothing consolidates — so on globex `onboarding-assistant` + reached 400k chars over 34 self-edits and `qa` 98k over 61, against a fleet median near 11k, and every + run paid for every rule ever written (past Claude Code's 150k instruction budget the CLI also warns on + each launch). `agent_update` and `agent_propose_update` now refuse an edit that GROWS a prompt past + 40,000 chars (`CLAUDE_MD_BUDGET`, `assessClaudeMdSize` in `src/state/agent-edit.ts`); shrinking is always + accepted, so an already-oversized prompt can be trimmed back, and `confirmRewrite` does not override it. + The refusal says where the content belongs (procedure → a skill, reference → the KB, one-off fact → + memory); `agent_get`, dry runs and successful edits carry a note at ≥80% of the budget or over it. + Proposals are checked before any lane, so neither a maturity tier nor an approval card waves the growth + through. Owners editing in the console are not capped. Audited `agent.claudemd.over_budget`. + **For admins:** agents can no longer grow their own instructions past 40,000 characters — an + oversized agent has to move material into skills, the Knowledge Base or memory before adding more. + ## [0.469.6] - 2026-11-10 ### Fixed - **The deploy gate no longer depends on whether the box itself is signed in to Claude** (#900). Launch-path diff --git a/CLAUDE.md b/CLAUDE.md index ce11733b..1ad9ba67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -483,7 +483,7 @@ Key modules: that target: maturity predicts intent, not correctness of transcription). Both tools echo the **server-composed `message`** rather than writing their own outcome sentence — an MCP process outlives a server upgrade, which is how a live session reported "NOTHING changes until an owner approves" about - an already-applied edit. Pinned by `scripts/agent-edit-guard-test.cjs`. Goal metrics (outcome, not activity): `goal_measure` — records one measured READING of a goal's metric + an already-applied edit. Both lanes also refuse an edit that GROWS a prompt past `CLAUDE_MD_BUDGET` (40k chars; shrinking always allowed, humans in the console uncapped) — self-improvement only appends, and globex reached 400k. Pinned by `scripts/agent-edit-guard-test.cjs`. Goal metrics (outcome, not activity): `goal_measure` — records one measured READING of a goal's metric into `goal_readings`. `goals` gained `metric*` columns (name/unit/target/baseline/direction/everyDays); `GoalStore.metricStatus` turns readings into a deterministic verdict (`measuring`/`flat`/`regressing`/ `achieved`/`unmeasured`/`new`) that needs a SAMPLE (≥3 readings) spanning the metric's own interval and diff --git a/docs/agent-mcp-tools.md b/docs/agent-mcp-tools.md index 0ebf6d5d..936e2df3 100644 --- a/docs/agent-mcp-tools.md +++ b/docs/agent-mcp-tools.md @@ -107,7 +107,7 @@ scope/parity properties, but not the cost. | `goal_update` | `POST /api/goals/update` | `TerminalManager.proposeGoalUpdate` → `GoalStore.update` | W | edit an EXISTING goal (status/title/body/target/labels/dueAt/note) with a required `rationale`, **maturity-tiered** on the shared `agentProposalTrust` (like `agent_propose_update`): below `minMaturity` → refused; middle band → a `goal.update.proposed` review card (nothing applied); ≥ `autoApplyAt` → applied immediately, owner notified. **Shape beats score:** activating / abandoning / reopening a goal, or a premature `achieved`, ALWAYS demote to human review whatever the tier; only ordinary edits + `achieved`-at-100% auto-apply. Owner/admin approve at `POST /api/goals/proposals/:id/approve`. Revertable via the `goal_events` log. `dryRun` names the lane without writing | | `agent_create` | `POST /api/agents/create` | `AgentOS.registerAgent` | W | writes `/agents//{agent.json,CLAUDE.md}` + registers live; author `agent:`; audited `agent.created` | | `agent_get` | `POST /api/agents/get` | `readAgentSnapshot` | R | reads an agent's current editable config **including the full CLAUDE.md** + a `baseHash`; defaults to self, any user-home claude-code agent is readable (the same set `agent_propose_update` can target); a cross-agent read is audited `agent.config.read` | -| `agent_update` | `POST /api/agents/update` | `applyAgentEdit` + `AgentRevisions.commit` | W | **self-only** (edits the caller's OWN manifest/CLAUDE.md — a body `id` must equal the session's agent); user-home agents only; accepts `claudeMdEdits`/`claudeMdAppend` (anchored patch) as well as a full `claudeMd`; `baseHash` precondition, `dryRun`, and a destructive-rewrite guard needing `confirmRewrite`; snapshots a revision; audited `agent.config.updated` | +| `agent_update` | `POST /api/agents/update` | `applyAgentEdit` + `AgentRevisions.commit` | W | **self-only** (edits the caller's OWN manifest/CLAUDE.md — a body `id` must equal the session's agent); user-home agents only; accepts `claudeMdEdits`/`claudeMdAppend` (anchored patch) as well as a full `claudeMd`; `baseHash` precondition, `dryRun`, a destructive-rewrite guard needing `confirmRewrite`, and a 40k-char growth budget; snapshots a revision; audited `agent.config.updated` | | `agent_propose_update` | `POST /api/agent/agent/propose` | `TerminalManager.proposeAgentUpdate` | W(tiered) | **cross-agent** — proposes an edit to ANOTHER agent's listing/CLAUDE.md, routed by the **proposer's maturity** against the workspace `AgentProposalTrust` (Settings → Runtime): below `minMaturity` (0.4) **refused** (audited `agent.update.proposal.blocked`); middle band = propose-don't-apply, an owner-addressed `agent.update.proposed` card applied only when an **owner who can run the target** approves (`POST /api/agents/proposals/:id/approve`); at/above `autoApplyAt` (0.8, switchable off) **applied immediately** by `applyAgentEdit` with an admin-addressed notice after the fact (audited `agent.update.applied`) — **unless the edit's SHAPE demotes it** (a destructive rewrite, or the proposer's first-ever edit of that target, always waits for a human). Same patch/`baseHash`/`dryRun` surface as `agent_update`. All lanes share one write path (`src/state/agent-edit.ts`) + `AgentRevisions.commit`, so every outcome is revertable; user-home claude-code targets only; 10-open cap + identical-delta dedupe | | `agent_history` | `POST /api/agents/history` | `AgentRevisions.list` | R | the caller's own listing revisions (rev/author/summary/date), newest first | | `agent_revert` | `POST /api/agents/revert` | `AgentRevisions` + `AgentOS.registerAgent` | W | **self-only**; restores a prior revision (description/prompts/tuning/CLAUDE.md), records the revert as a new revision; audited `agent.config.reverted` | @@ -336,6 +336,14 @@ loss. Four guards now stop them at the source, in `src/state/agent-edit.ts` and like an accident. The diff stat (`−6,348 chars`, dropped section names) rides on the approval card so a human sees the damage without opening the document. `dryRun: true` reports the lane and the diff stat while writing nothing, which is what makes these rules discoverable instead of surprising. +- **A size budget, because self-improvement only appends.** `assessClaudeMdSize` refuses any agent-driven + edit (self lane AND proposals, before any tier or card) that would GROW a CLAUDE.md past + `CLAUDE_MD_BUDGET` (40,000 chars); shrinking is always accepted, `confirmRewrite` does not override it, + and a human editing in the console is not capped. The refusal tells the agent where content belongs + (procedure → `skill_propose`, reference → `kb_write`, one-off fact → `remember`); `agent_get` and every + successful edit carry a note at ≥80% of the budget or over it. Audited `agent.claudemd.over_budget`. + Motivation: on globex, `onboarding-assistant` reached 400k chars over 34 self-edits and `qa` 98k over 61 + (fleet median ~11k) — past Claude Code's 150k instruction budget, with every run paying for every rule. **The reply is composed server-side.** Both write tools return the server's `message` verbatim rather than composing their own sentence. An MCP server is spawned per session and lives as long as it, while the lane diff --git a/package-lock.json b/package-lock.json index 53b49839..d2e680f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.469.6", + "version": "0.470.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.469.6", + "version": "0.470.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 73aa37c2..6b2f6e12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.469.6", + "version": "0.470.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/agent-edit-guard-test.cjs b/scripts/agent-edit-guard-test.cjs index 32a4572a..fc972179 100644 --- a/scripts/agent-edit-guard-test.cjs +++ b/scripts/agent-edit-guard-test.cjs @@ -29,7 +29,7 @@ delete process.env.AGENT_OS_SECRET_KEY; let pass = 0, fail = 0; const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d !== undefined ? ' — ' + JSON.stringify(d).slice(0, 400) : ''}`)); -const { resolveClaudeMd, assessClaudeMdEdit, contentHash } = require(path.join(ROOT, 'dist/state/agent-edit.js')); +const { resolveClaudeMd, assessClaudeMdEdit, assessClaudeMdSize, CLAUDE_MD_BUDGET, contentHash } = require(path.join(ROOT, 'dist/state/agent-edit.js')); const PROMPT = [ '# Release orchestrator', @@ -112,6 +112,22 @@ console.log('\n\x1b[1m2) scoring the damage — what separates "added a section" 'contentHash is stable and sensitive to a one-byte change'); } +console.log('\n\x1b[1m2b) the size budget — self-improvement only appends\x1b[0m'); +{ + const big = 'x'.repeat(CLAUDE_MD_BUDGET - 10); + const s1 = assessClaudeMdSize(big, big + 'y'.repeat(100)); + assert(s1.over && /budget/.test(s1.error) && /skill_propose/.test(s1.error), 'growing past the budget is refused, and the error says where content belongs', s1); + const huge = 'x'.repeat(CLAUDE_MD_BUDGET * 10); + const s2 = assessClaudeMdSize(huge, huge.slice(1000)); + assert(!s2.over && /over the/.test(s2.note), 'an over-budget prompt may always SHRINK (the way back under), with a note', s2); + const s3 = assessClaudeMdSize(huge, huge); + assert(!s3.over, 'an unchanged over-budget prompt is not refused (e.g. a description-only edit)', s3); + const s4 = assessClaudeMdSize('a', 'x'.repeat(Math.round(CLAUDE_MD_BUDGET * 0.9))); + assert(!s4.over && /consolidate/.test(s4.note), 'near the budget a successful edit carries a consolidate note', s4); + const s5 = assessClaudeMdSize('a', 'small prompt'); + assert(!s5.over && !s5.note, 'a normal-sized prompt gets no note at all', s5); +} + console.log('\n\x1b[1m3) the self-edit route end to end (agent_get → agent_update)\x1b[0m'); (async () => { const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); @@ -194,6 +210,29 @@ console.log('\n\x1b[1m3) the self-edit route end to end (agent_get → agent_upd const baseline = hist.revisions[hist.revisions.length - 1]; assert(baseline.claudeChars > 0, 'including a baseline of the pre-edit state', baseline); + // ── the size budget on the self lane ── + const sizeFresh = await call('/api/agents/get', {}); + const padded = sizeFresh.claudeMd + '\n' + 'z'.repeat(CLAUDE_MD_BUDGET); + const dryBig = await call('/api/agents/update', { claudeMd: padded, dryRun: true }); + assert(dryBig.ok && dryBig.outcome === 'dry_run' && /REFUSED/.test(dryBig.message), 'dryRun says an over-budget edit would be refused', dryBig.message); + const grow = await call('/api/agents/update', { claudeMdAppend: 'z'.repeat(CLAUDE_MD_BUDGET) }); + assert(!grow.ok && grow.overBudget && grow.budget === CLAUDE_MD_BUDGET, 'a self-edit that grows the prompt past the budget is REFUSED', grow); + assert(!diskOf(EDITOR).includes('zzzz'), 'and nothing was written'); + const grow2 = await call('/api/agents/update', { claudeMdAppend: 'z'.repeat(CLAUDE_MD_BUDGET), confirmRewrite: true }); + assert(!grow2.ok && grow2.overBudget, 'confirmRewrite does not override the budget', grow2); + // A prompt that is ALREADY over (written before the cap existed) can still be trimmed, and agent_get flags it. + const bloated = '# Editor\n\n## Method\n\nDo the work.\n\n' + 'old incident note\n'.repeat(Math.ceil(CLAUDE_MD_BUDGET / 17) + 500); + fs.writeFileSync(path.join(aos.paths.userAgents, EDITOR, 'CLAUDE.md'), bloated); + const gotBig = await call('/api/agents/get', {}); + assert(gotBig.budget === CLAUDE_MD_BUDGET && /over the/.test(gotBig.budgetNote || ''), 'agent_get reports an over-budget prompt', gotBig.budgetNote); + const trim = await call('/api/agents/update', { claudeMd: bloated.slice(0, bloated.length - 'old incident note\n'.length * 100) }); + assert(trim.ok && trim.outcome === 'applied' && /over the/.test(trim.message), 'an over-budget prompt can shrink, and the reply says it is still over', trim); + const stillGrow = await call('/api/agents/update', { claudeMdAppend: '## New rule\n\nOne more.' }); + assert(!stillGrow.ok && stillGrow.overBudget, 'but it cannot grow again until it is under', stillGrow); + const desc = await call('/api/agents/update', { description: 'still editable' }); + assert(desc.ok, 'a description-only edit is unaffected by an over-budget prompt', desc); + fs.writeFileSync(path.join(aos.paths.userAgents, EDITOR, 'CLAUDE.md'), sizeFresh.claudeMd); + console.log('\n\x1b[1m4) the cross-agent lane — shape overrides the maturity tier\x1b[0m'); // Force the top tier for this proposer, so ONLY the shape guards can stop an auto-apply. aos.settings.setAgentProposalTrust({ minMaturity: 0, autoApplyAt: 0, autoApply: true }, 'test'); @@ -237,6 +276,12 @@ console.log('\n\x1b[1m3) the self-edit route end to end (agent_get → agent_upd assert(dry2.ok && dry2.outcome === 'dry_run' && dry2.destructive, 'dryRun reports a destructive cross-agent edit', dry2); assert(tm.openAgentUpdateProposals().length === openBefore, 'and posts no card'); + // The budget applies to proposals too — refused before any lane, so neither a tier nor a human card waves it through. + const openBefore2 = tm.openAgentUpdateProposals().length; + const bigProp = tm.proposeAgentUpdate(session.id, EDITOR, { id: TARGET, rationale: 'a huge addition', claudeMdAppend: 'q'.repeat(CLAUDE_MD_BUDGET) }); + assert(!bigProp.ok && bigProp.overBudget && new RegExp(TARGET).test(bigProp.error), 'a proposal that grows the target past the budget is refused', bigProp); + assert(tm.openAgentUpdateProposals().length === openBefore2 && !diskOf(TARGET).includes('qqqq'), 'and neither applies nor queues a card'); + server.close(); console.log(`\n${fail ? '\x1b[31m' : '\x1b[32m'}${pass} passed, ${fail} failed\x1b[0m`); fs.rmSync(HOME, { recursive: true, force: true }); diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index 4eee3b06..32c351e0 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -1626,7 +1626,9 @@ const TOOLS = [ 'description, category, model, effort, example prompts, or icon). This is how you self-improve: when ' + "you notice a recurring gap in your instructions or a better way to describe what you do, refine it " + 'here. Read your current prompt with agent_get FIRST, then prefer claudeMdEdits/claudeMdAppend over ' + - 'retyping the whole document — a hand-retyped claudeMd silently loses whatever you forget. Takes ' + + 'retyping the whole document — a hand-retyped claudeMd silently loses whatever you forget. Your ' + + 'CLAUDE.md has a 40,000-char budget: an edit that grows it past that is refused (shrinking is always ' + + 'fine), so put procedures in skills, reference in the KB and one-off facts in memory. Takes ' + 'effect on your next session. Every edit is snapshotted — inspect them with agent_history and undo ' + 'with agent_revert. You can only edit yourself (the id defaults to you); to edit ANOTHER agent, use ' + 'agent_propose_update.', @@ -3328,7 +3330,7 @@ async function agentGet(args: Record): Promise { const d = (await res.json()) as { ok?: boolean; id?: string; self?: boolean; description?: string; category?: string; icon?: string; model?: string; effort?: string; outputStyle?: string; examplePrompts?: string[]; - claudeMd?: string; chars?: number; baseHash?: string; latestRev?: number | null; error?: string; + claudeMd?: string; chars?: number; budget?: number; budgetNote?: string; baseHash?: string; latestRev?: number | null; error?: string; }; if (!d.ok) return `Could not read that agent: ${d.error ?? 'unknown error'}`; const meta = [ @@ -3338,7 +3340,8 @@ async function agentGet(args: Record): Promise { d.model ? `model: ${d.model}` : null, d.effort ? `effort: ${d.effort}` : null, d.examplePrompts?.length ? `starter prompts: ${d.examplePrompts.map((p) => `"${p}"`).join(', ')}` : null, - `CLAUDE.md: ${d.chars} chars${d.latestRev ? ` · latest rev ${d.latestRev}` : ''}`, + `CLAUDE.md: ${d.chars} chars${d.budget ? ` of a ${d.budget}-char budget` : ''}${d.latestRev ? ` · latest rev ${d.latestRev}` : ''}`, + d.budgetNote ? `⚠ ${d.budgetNote}` : null, `baseHash: ${d.baseHash} ← pass this as baseHash when you edit, so a stale copy is refused instead of clobbering`, ].filter(Boolean).join('\n'); return `${meta}\n\n----- CLAUDE.md (verbatim) -----\n${d.claudeMd ?? ''}\n----- end CLAUDE.md -----\n\nTo change part of this, use claudeMdEdits (anchored) or claudeMdAppend — NOT a hand-retyped claudeMd.`; diff --git a/src/server.ts b/src/server.ts index 5c14ff0d..9497b897 100644 --- a/src/server.ts +++ b/src/server.ts @@ -100,7 +100,7 @@ import { MAX_LIVE_BETS, MAX_LIVE_BETS_CEILING, resolveLiveBetCap } from './state import { AgentConfigSnapshot } from './state/agent-revisions'; import { FeedFilter } from './state/feed'; import { computeAgentStats, computeAgentStat } from './state/agent-stats'; -import { agentEditable, applyAgentEdit, assessClaudeMdEdit, contentHash, diffStat, manifestToSnapshot, readAgentSnapshot, resolveClaudeMd } from './state/agent-edit'; +import { agentEditable, applyAgentEdit, assessClaudeMdEdit, assessClaudeMdSize, contentHash, diffStat, manifestToSnapshot, readAgentSnapshot, resolveClaudeMd } from './state/agent-edit'; /** Sum busy + idle CPU tick counters across all cores (for a sampled utilization %). */ function cpuTicks(): { idle: number; total: number } { @@ -2710,6 +2710,8 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: model: snap.model, effort: snap.effort, outputStyle: snap.outputStyle, examplePrompts: snap.examplePrompts, claudeMd: snap.claudeMd, chars: snap.claudeMd.length, + budget: assessClaudeMdSize(snap.claudeMd, snap.claudeMd).budget, + budgetNote: assessClaudeMdSize(snap.claudeMd, snap.claudeMd).note, baseHash: contentHash(snap.claudeMd), latestRev: revs[0]?.rev ?? null, }); @@ -2743,6 +2745,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const resolved = resolveClaudeMd(before.claudeMd, b); if (!resolved.ok) return sendJson(res, 200, { ok: false, outcome: 'refused', error: resolved.error }); const risk = resolved.text !== undefined ? assessClaudeMdEdit(before.claudeMd, resolved.text) : undefined; + const size = resolved.text !== undefined ? assessClaudeMdSize(before.claudeMd, resolved.text) : undefined; const fields: Record = {}; for (const k of ['description', 'category', 'model', 'effort', 'icon', 'outputStyle', 'examplePrompts', 'shellSecrets', 'skills', 'tools'] as const) { if (k in b) fields[k] = b[k]; @@ -2756,9 +2759,16 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: return sendJson(res, 200, { ok: true, id, outcome: 'dry_run', fields: Object.keys(fields), ...(risk ? { bytesBefore: risk.bytesBefore, bytesAfter: risk.bytesAfter, droppedHeadings: risk.droppedHeadings, destructive: risk.destructive } : {}), - message: `Dry run — nothing was written. This would change ${Object.keys(fields).join(', ')}${risk ? `; CLAUDE.md ${diffStat(risk)}` : ''}${risk?.destructive ? `. It is DESTRUCTIVE (${risk.reason}) and would be REFUSED unless you pass confirmRewrite: true` : ''}.`, + message: `Dry run — nothing was written. This would change ${Object.keys(fields).join(', ')}${risk ? `; CLAUDE.md ${diffStat(risk)}` : ''}${risk?.destructive ? `. It is DESTRUCTIVE (${risk.reason}) and would be REFUSED unless you pass confirmRewrite: true` : ''}.${size?.over ? ` It would be REFUSED: ${size.error!.replace(/^refused: /, '')}` : size?.note ? ` ${size.note}` : ''}`, }); } + // ── the size budget: self-improvement only appends, so growth past the budget is refused ── + // No override flag on purpose — confirmRewrite exists because a big deletion can be deliberate, while + // growing past the budget is never the only way to make a change (replace a rule, move one out). + if (size?.over) { + os.audit.append({ ts: Date.now(), runId: session, tenant: os.tenant, principal: `agent:${agent}`, type: 'agent.claudemd.over_budget', data: { agent: id, lane: 'self', bytesBefore: before.claudeMd.length, bytesAfter: size.chars, budget: size.budget } }); + return sendJson(res, 200, { ok: false, outcome: 'refused', overBudget: true, bytesBefore: before.claudeMd.length, bytesAfter: size.chars, budget: size.budget, error: size.error }); + } // ── the shrink guard: an accidental fragment looks exactly like this ── if (risk?.destructive && b.confirmRewrite !== true) { return sendJson(res, 200, { @@ -2777,7 +2787,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: ok: true, id, rev: out.rev, outcome: 'applied', ...(risk ? { bytesBefore: risk.bytesBefore, bytesAfter: risk.bytesAfter } : {}), baseHash: resolved.text !== undefined ? contentHash(resolved.text) : contentHash(before.claudeMd), - message: `Applied to your listing "${id}" (${Object.keys(fields).join(', ')})${risk ? ` — CLAUDE.md ${diffStat(risk)}` : ''}.${undo} The next session you run will use it.`, + message: `Applied to your listing "${id}" (${Object.keys(fields).join(', ')})${risk ? ` — CLAUDE.md ${diffStat(risk)}` : ''}.${undo} The next session you run will use it.${size?.note ? ` ${size.note}` : ''}`, }); } // Agent reads its OWN revision history (self-scoped) — pick a rev to revert to. diff --git a/src/state/agent-edit.ts b/src/state/agent-edit.ts index 659207ff..2374e1bb 100644 --- a/src/state/agent-edit.ts +++ b/src/state/agent-edit.ts @@ -165,6 +165,61 @@ export function assessClaudeMdEdit(before: string, after: string): ClaudeMdRisk return { bytesBefore, bytesAfter, removedPct, droppedHeadings, destructive, reason: parts.join(' and ') || undefined }; } +/** + * The size budget for an agent's CLAUDE.md, in characters (Claude Code's own unit). + * + * Self-improvement only ever APPENDS: an agent that learns a lesson writes it into its prompt, and nothing + * ever consolidates. On globex that grew `onboarding-assistant` to 400k chars over 34 self-edits and `qa` + * to 98k over 61, while the fleet median sits near 11k. Past Claude Code's 150k instruction budget the CLI + * warns on every launch, and well before that every run pays for every rule ever written, most of them + * one-off incidents. So an agent-driven edit may not GROW a prompt past this budget. Shrinking is always + * allowed (that's the way back under), and a human editing in the console is not capped: the budget is a + * brake on the append-only loop, not on an owner. + */ +export const CLAUDE_MD_BUDGET = 40_000; +/** Above this share of the budget a successful edit carries a "consolidate soon" note. */ +export const CLAUDE_MD_BUDGET_WARN = 0.8; + +export interface ClaudeMdSize { + chars: number; + budget: number; + /** The edit grows the prompt past the budget — refuse it. */ + over: boolean; + /** Sentence for the refusal. */ + error?: string; + /** Sentence to append to a success/dry-run/read message, when the result is near or over budget. */ + note?: string; +} + +const WHERE_IT_BELONGS = + 'Move what does not need to be in front of you on EVERY run out of the prompt: a repeatable procedure → ' + + 'a skill (skill_propose), reference material or long examples → a KB page (kb_write) with a one-line ' + + 'pointer here, a one-off fact or incident → memory (remember). Then merge rules that say the same thing ' + + 'and delete dated incident notes whose lesson is already a rule.'; + +/** Size verdict for a CLAUDE.md change (`before` → `after`), or for a read (`before === after`). */ +export function assessClaudeMdSize(before: string, after: string): ClaudeMdSize { + const chars = after.length; + const budget = CLAUDE_MD_BUDGET; + const fmt = (n: number) => n.toLocaleString('en-US'); + const over = chars > budget && chars > before.length; + if (over) { + return { + chars, budget, over, + error: `refused: this would grow the CLAUDE.md to ${fmt(chars)} chars, over the ${fmt(budget)}-char budget ` + + `(currently ${fmt(before.length)}). Edits that shrink it are always accepted. ${WHERE_IT_BELONGS} ` + + `Then make your change in the space that frees up, or use claudeMdEdits to REPLACE an existing rule rather than adding one.`, + }; + } + let note: string | undefined; + if (chars > budget) { + note = `CLAUDE.md is ${fmt(chars)} chars — still ${fmt(chars - budget)} over the ${fmt(budget)}-char budget, so it cannot grow until it is trimmed. ${WHERE_IT_BELONGS}`; + } else if (chars > budget * CLAUDE_MD_BUDGET_WARN) { + note = `CLAUDE.md is ${fmt(chars)} chars, ${Math.round((chars / budget) * 100)}% of the ${fmt(budget)}-char budget — consolidate before adding more; past the budget, edits that grow it are refused.`; + } + return { chars, budget, over, note }; +} + /** `−1,234 / +56` — the diff stat a human reads at a glance on an approval card. */ export function diffStat(risk: ClaudeMdRisk): string { const delta = risk.bytesAfter - risk.bytesBefore; diff --git a/src/terminal.ts b/src/terminal.ts index 808f2a4f..847e4262 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -17,7 +17,7 @@ import { containedPath, mimeOf } from './state/artifacts'; import { clipText } from './state/session-activity'; import { ProgressPosition } from './state/session-progress'; import { computeAgentStat } from './state/agent-stats'; -import { agentEditable, applyAgentEdit, assessClaudeMdEdit, contentHash, diffStat, readAgentSnapshot, resolveClaudeMd } from './state/agent-edit'; +import { agentEditable, applyAgentEdit, assessClaudeMdEdit, assessClaudeMdSize, contentHash, diffStat, readAgentSnapshot, resolveClaudeMd } from './state/agent-edit'; import { mintToolRouterSessionAsync, COMPOSIO_KEY_HEADER, serviceUserId, type MintOptions } from './connectors/composio'; import { listConnectedAccounts } from './connectors/composio'; import { activeToolkits, resolveIdentities } from './connectors/composio-identity'; @@ -9091,7 +9091,7 @@ export class TerminalManager { * particular target both force the gated lane whatever the tier says — the two shapes where "a trusted * agent meant well" and "a teammate's prompt was just clobbered" are indistinguishable from the score. */ - proposeAgentUpdate(sessionId: string, proposer: string, body: Record): { ok: boolean; preview?: string; applied?: boolean; rev?: number | null; maturity?: number; outcome?: string; message?: string; error?: string; conflict?: boolean; baseHash?: string; bytesBefore?: number; bytesAfter?: number; droppedHeadings?: string[]; destructive?: boolean } { + proposeAgentUpdate(sessionId: string, proposer: string, body: Record): { ok: boolean; preview?: string; applied?: boolean; rev?: number | null; maturity?: number; outcome?: string; message?: string; error?: string; conflict?: boolean; baseHash?: string; bytesBefore?: number; bytesAfter?: number; droppedHeadings?: string[]; destructive?: boolean; overBudget?: boolean; budget?: number } { const target = String(body.id ?? '').trim().toLowerCase(); if (!target) return { ok: false, outcome: 'refused', error: 'id (the agent to edit) is required' }; if (target === proposer) return { ok: false, outcome: 'refused', error: 'use agent_update to edit your own listing' }; @@ -9111,6 +9111,13 @@ export class TerminalManager { const resolved = resolveClaudeMd(current, body); if (!resolved.ok) return { ok: false, outcome: 'refused', error: resolved.error }; const risk = resolved.text !== undefined ? assessClaudeMdEdit(current, resolved.text) : undefined; + // ── the size budget — same rule as the self lane, checked before ANY lane (a human approving a card + // would otherwise wave through the growth the self lane refuses) ── + const size = resolved.text !== undefined ? assessClaudeMdSize(current, resolved.text) : undefined; + if (size?.over) { + this.audit(sessionId, proposer, 'agent.claudemd.over_budget', { agent: target, lane: 'proposal', bytesBefore: current.length, bytesAfter: size.chars, budget: size.budget }); + return { ok: false, outcome: 'refused', overBudget: true, bytesBefore: current.length, bytesAfter: size.chars, budget: size.budget, error: size.error!.replace('refused: this would grow the CLAUDE.md', `refused: this would grow "${target}"'s CLAUDE.md`) }; + } // Only the fields actually present become the delta; store them verbatim on the card for the approve route. const fields: Record = {}; for (const k of ['description', 'category', 'model', 'effort', 'icon'] as const) {