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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion docs/agent-mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<home>/agents/<id>/{agent.json,CLAUDE.md}` + registers live; author `agent:<id>`; 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` |
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
47 changes: 46 additions & 1 deletion scripts/agent-edit-guard-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 });
Expand Down
9 changes: 6 additions & 3 deletions src/memory/memory-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -3328,7 +3330,7 @@ async function agentGet(args: Record<string, unknown>): Promise<string> {
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 = [
Expand All @@ -3338,7 +3340,8 @@ async function agentGet(args: Record<string, unknown>): Promise<string> {
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.`;
Expand Down
Loading
Loading