perf(judge): earlier check outputs shrink to a reminder before the next round
deploy / test (push) Successful in 5m26s
deploy / build (push) Successful in 5m32s

Measured on prod: 7 of 9 verdicts ran to the 12-check cap. Every round
resends the whole history, and each check's output is bounded at 12 KB — so
by the last round the judge was paying for ~144 KB of outputs it had already
read, on top of up to 120 KB of evidence, and it paid that on every round.
That is the quadratic term in a verdict's cost, and the reason a single
blocked phase could empty a weekly plan.

Before this round's results go in, every earlier tool result compacts to an
800-byte head plus a marker saying the rest was shown when the check ran.
The round that just ran stays whole; a result already carrying the marker is
left alone. The budget of checks is unchanged — each one is cheaper to
remember, not fewer to run.

Also: docs/NEXT-SESSION.md rewritten for the state as of today.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-14 08:17:20 -05:00
co-authored by Claude Opus 5
parent 483de9f88a
commit 93a386e706
2 changed files with 199 additions and 110 deletions
+76
View File
@@ -767,6 +767,9 @@ async fn judge_with_tools(
content: Value::String(evidence),
});
}
// Everything the judge has already read shrinks to a reminder before
// this round's results go in full. See `compact_earlier_results`.
compact_earlier_results(&mut messages);
messages.push(ChatMessage {
role: ChatRole::User,
parts: results,
@@ -775,6 +778,44 @@ async fn judge_with_tools(
Err("evaluator exceeded its verification budget without reaching a verdict".into())
}
/// How much of an earlier check's output stays in the history.
///
/// Enough to recognise the command and its outcome — a test summary line, a
/// grep hit, an error — not enough to re-read the whole thing, which the judge
/// already did in the round it arrived.
const KEPT_OF_EARLIER_RESULT: usize = 800;
/// Shrink every tool result from EARLIER rounds to a short head.
///
/// The judge's history is resent whole on every round, and each check's
/// output is bounded at `evaluator_tools::MAX_OUTPUT_BYTES` (12 KB). Measured
/// on prod, 7 of 9 verdicts ran to the 12-check cap, so by the last round the
/// history carried ~144 KB of outputs the judge had already read, on top of
/// up to 120 KB of evidence — and every round paid for all of it again. That
/// is the quadratic term in a verdict's cost, and it is why one blocked phase
/// could empty a weekly plan.
///
/// The round that just ran keeps its results in full; only what came before
/// is compacted, and it is compacted once — a result already carrying the
/// marker is left alone. The judge's budget of checks is unchanged: this
/// makes each check cheaper to remember, not fewer to run.
fn compact_earlier_results(messages: &mut [cm_llm::ChatMessage]) {
use cm_llm::ContentPart;
const MARKER: &str = "\n[… output elided here — it was shown in full when this check ran]";
for m in messages.iter_mut() {
for part in m.parts.iter_mut() {
if let ContentPart::ToolResult { content, .. } = part {
if let Some(text) = content.as_str() {
if text.len() > KEPT_OF_EARLIER_RESULT && !text.ends_with(MARKER) {
let kept = head(text, KEPT_OF_EARLIER_RESULT);
*content = Value::String(format!("{kept}{MARKER}"));
}
}
}
}
}
}
/// Parse the model's reply into a verdict, failing closed.
fn parse_verdict(model: &str, text: &str) -> Verdict {
let trimmed = text.trim();
@@ -925,6 +966,41 @@ pub async fn latest(
#[cfg(test)]
mod cross_provider_tests {
/// The quadratic term: earlier outputs resent whole every round.
#[test]
fn earlier_tool_results_shrink_and_the_latest_stays_whole() {
use cm_llm::{ChatMessage, ChatRole, ContentPart};
let big = "line of output\n".repeat(900); // ~13 KB
let mut messages = vec![
ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text("judge this")],
},
ChatMessage {
role: ChatRole::User,
parts: vec![
ContentPart::ToolResult { tool_use_id: "a".into(), content: Value::String(big.clone()) },
ContentPart::ToolResult { tool_use_id: "b".into(), content: Value::String(big.clone()) },
],
},
];
compact_earlier_results(&mut messages);
for part in &messages[1].parts {
let ContentPart::ToolResult { content, .. } = part else { panic!() };
let s = content.as_str().unwrap();
assert!(s.len() < KEPT_OF_EARLIER_RESULT + 120, "not compacted: {} bytes", s.len());
assert!(s.starts_with("line of output"), "the head survives");
assert!(s.contains("elided"), "and says so");
}
// Idempotent: a second pass must not shrink the reminder further.
let once: Vec<String> = messages[1].parts.iter().map(|p| serde_json::to_string(p).unwrap()).collect();
compact_earlier_results(&mut messages);
let twice: Vec<String> = messages[1].parts.iter().map(|p| serde_json::to_string(p).unwrap()).collect();
assert_eq!(once, twice);
// Plain text parts are untouched.
assert!(matches!(&messages[0].parts[0], ContentPart::Text { text } if text == "judge this"));
}
/// `LlmEvent::Usage` arrives on every provider call. It was matched by
/// `Ok(_) => {}` and dropped, which is how two plan exhaustions happened
/// with no row anywhere saying a judge token was spent.
+123 -110
View File
@@ -1,138 +1,151 @@
# Where this left off — 2026-09-05
# Where this left off — 2026-09-14
`MISSION-EGRESS.md` has what a mission container can reach, `UPSTREAM-SCAN.md`
the fork-vs-upstream position, `SKILL-USE-BASELINE.md` the delivery measurement.
Nine days, ~20 commits, and every item on the last handoff's open list is
closed or explained. The platform is in the best-measured state it has been
in. Read the first section, then the open list; the middle is the record.
## Read this first — the judge is back, and the subagent path is proven
## Read this first — retrieval works now, and we know why it did not
The z.ai quota reset on schedule. `glm-5.3` answers again on the same key
(`sha256[0:12] = 616fb5076696`), and it has since passed a real `done_when`, not
just a ping. **Nothing is blocked on the judge any more.**
Mission agents were not fetching their skills. Five matched production runs
— same recipe, same task, same three skills on offer — said this precisely:
Mission `01a07498-d343-7953-9ed1-8ea9ba83b140` (2026-09-05, local, `index` arm)
was the previous validation run plus one change — a task that EXPLICITLY
instructs `Agent` delegation — and it closed both items the last pass left open:
| arm | mechanism | fetched |
|---|---|---|
| `index` | `ReadMcpResourceTool` via the MCP door | **1 of 9** |
| `files` | `Read` of `/mission/skills/<name>.md` | **7 of 9** |
| | |
|---|---|
| judge recovered | **CONFIRMED** — phase `completed` on iteration 0, first attempt. The verdict cites the actual arXiv ids it checked and the "what could not be established" section by name. A substantive read, not a rubber stamp. |
| **subagent attribution** | **OBSERVED, finally.** 87 tool calls: 43 from the main turn, **44 across 4 `general-purpose` subagents**, 4 distinct `subagent_id`s matching 4 `Agent` spawns. The field had only ever been unit-tested before this. |
The door tool is **deferred** in Claude Code: absent from the agent's default
list until `ToolSearch` loads it. Naming it in the prompt did nothing; telling
the agent to load it first did nothing (verified, `01a09877`: zero
`ToolSearch`, three narratives that never mention skills). `Read` is core,
never deferred, used in every run. So the `files` arm writes every visible
skill into the container at launch and the index points at paths.
The delegation had to be *demanded*. The earlier task invited it and got none;
naming the tool and saying "do not gather every approach yourself in a single
turn" produced four spawns. Worth remembering when designing any run that needs
to exercise the fan-out path.
**`files` is the code default now** (`skill_delivery::DEFAULT`). `index` and
`inline` stay selectable per mission (`config.skill_delivery`) so the
comparison remains runnable against one binary. The rule that came out of it:
a capability that depends on the model guessing a tool is loadable is not
delivered.
Two more things about skills:
- `always_inject` lives in the skill's frontmatter and the loader restores it
on boot (proved by forcing the DB column false and watching it come back).
`workspace-repo-commit-protocol` is the only one marked, and should stay the
only one: a marked skill leaves the Trigger sample.
- `web-search-triage` has a compliance check now (URLs fetched vs. a primary /
aggregator host list). All five runs pass it — including the three that
never opened the skill. The check catches the violation; it cannot tell
"followed the skill" from "would have done this anyway", and nothing
mechanical could on this skill.
The 429 that blocked the last pass was real (code `1310`, reset timestamp) and
the code was right to fail closed — `evaluator.rs:480` still refuses a
same-family fallback, and should keep refusing. If a `done_when` phase fails
again with the artifacts present and correct, check the quota before the mission.
## What shipped this pass
Seven commits on `main`, all deployed to gw-04 (server recreated
2026-08-29T02:07:48). Suite at the end: **108 binaries, 842 Rust tests, 92
frontend tests, tsc clean.**
### The judge's cost, three ways
### The gate's exfiltration rule matched a spelling production never writes
The z.ai plan for `glm-5.3` emptied twice (08-29, 09-09) and nothing recorded
a single judge token. Three commits, each measured:
`5a2ed8f`. One rule stood between an agent and sending the checkout off the
machine, anchored to a segment STARTING with `curl -X POST`. The tap held the
answer: 166 curl invocations across two production missions, every one a GET,
and every one beginning `curl -s`. The `-s` pushes the needle off position zero.
`curl -s -X POST … -d @secret`, `-d @file`, `-F`, `-T`, `--upload-file`,
`wget --post-file` — all allowed.
1. **The retry storm** (`8d6310f`). A blocked phase re-judged on the 10s sweep
for 30 minutes — 180 attempts, each up to 13 requests. Now exponential
backoff (~10 attempts) via `mission_phases.judge_retry_after`, and a 429
that names its own reset time fails immediately, naming it.
2. **Accounting** (`248948c`, `736b6a9`). `usage_events` gained `provider,
model, mission_id, requests`; every judge attempt writes a `kind='judge'`
row, refused requests included. The first rows read `tokens_in = 0`: z.ai
reports input in `message_delta`, Anthropic in `message_start`. Fixed.
3. **The quadratic term** (this pass). 7 of 9 verdicts ran to the 12-check
cap, and every round resent every earlier check's output (≤12 KB each)
whole. Earlier results now compact to an 800-byte head before the next
round; the round that just ran stays in full. Checks per verdict unchanged.
`Match::Carries` matches a segment that starts with the command and contains the
needle anywhere; `CarriesExact` exists for flags whose CASE is their meaning
(curl `-F` uploads, `-f` fails quietly, as in the ordinary `curl -fsSL`).
Segments are lowercased individually, not up front. All 158 recorded production
commands replay through the new script with **0 false positives**.
Ask the plan before it tells you:
### A subagent's tool calls are no longer credited to its parent
```sql
select provider, date_trunc('day', created_at), sum(requests),
sum(tokens_in), sum(tokens_out)
from usage_events where provider is not null group by 1, 2 order by 2;
```
`fe5c7d2`. Measured against the real claude binary, not reasoned about:
### Agent-side spend is visible too (this pass)
1. Subagent tool calls DO fire both hooks. `PreToolUse` blocked a subagent's
denied curl. **`Agent` is not a gate bypass.**
2. They carry the PARENT's `session_id` — which is why `attribute_sessions`
keeps working; a subagent never adds a session.
3. Only `agent_type`/`agent_id` tell them apart, and `parse()` read past both.
The runtime's `done` frame always carried `model` and `provider`; the
executor read only the two token counts. `TurnOutcome` and `StepRecord` now
carry a `Spend` (split + provider + model), `cm_billing::charge` writes it,
and the chat runtime records its requested model (it drives one provider,
no chain, so requested is answered). Bare model names are recorded without a
guessed family.
The tap appends the raw payload, so those fields were always on disk. Host-side
fix only, no image rebuild.
### Three things that were known and written nowhere (`248948c`)
### The rest
- `gate.installed` / `gate.absent` mission events — the hook install outcome
used to go to stderr in a container that is later deleted.
- `gate.inert` — the marker the gate writes when it cannot parse now has a
production reader (`drain_inert`), not only a unit test.
- Judge `LlmEvent::Usage` was `Ok(_) => {}`.
- `bd7fd46` — a mission that cannot attach to `clawmates_edge` now FAILS to
launch. `clawmates_core` is `internal: true` with no default route, so a
discarded attach error meant a mission running with zero egress while
reporting success. **Behaviour change: it can now stop a mission starting.**
- `2f1a870``skills.always_inject` (migration 0083). Delivery and scoring
both, because they are different mistakes.
- `f26de3b``agent.last_run`, the metric band's historical half.
- `fde1341`, `563b074` — the egress measurement and the upstream scan.
### Infra
## Verified live, and one honest negative
A validation mission ran locally on the `index` arm
(`01a04f74-564b-7cd3-9b55-9d8f29d3e2f4`):
| | |
|---|---|
| `always_inject` | **CONFIRMED** — same prompt, same arm: `workspace-repo-commit-protocol` inlined as a full body, `scientific-writing-conventions` offered as a uri |
| retrieval still works | **CONFIRMED**`ReadMcpResourceTool(uri="skill:global/web-search-triage")`; the flag did not collapse the arm |
| the corrected gate | **CONFIRMED installed** — new rules present in the live container, node present, no inert marker, 0 denials against 23 body-free `Bash` calls |
| attribution | 34/34, third perfect run |
| `subagent` column | written, all null — **correct**, no subagents spawned |
| **subagent path** | unexercised in THIS run; **closed on 2026-09-05** by `01a07498` — see the top of this file. |
- **ZeroClaw v0.8.5** merged into the fork and deployed. No regressions.
- **Prod was off the tailnet for a day.** Tailscale node-key expiry on
gw-01/02/04 — staggered by enrolment date, which is the tell. Re-authed,
key expiry disabled on all five Hetzner nodes, `<node>-pub` aliases in
`~/.ssh/config` on the public IPs, vault corrected, runbook written
(`Valhalla/20 Infrastructure/30 Runbooks/tailscale-key-expiry-2026-09.md`).
- **Fleet re-enrolled**: tank + architect online. morpheus reappeared.
- `CLAWMATES_API_ORIGIN` set explicitly; `worker_glm`/`worker_glm5`/
`worker_kimi` removed from the prod runtime template (byte-identical to
`worker`, names that promised providers they never used); map routes
`researcher`/`analyst` to `worker` directly.
## Open, in the order I would take them
1. **Confirm the last-run cards in the prod UI.** They read HISTORICAL data, so
unlike everything else this pass they should work right now against the two
2026-08-25 missions. If blank, suspect the roster join: `agent_last_run`
reaches agents via `team_members → mission_teams → missions`, so an agent no
longer on the mission's team will not resolve.
2. **Three fork items, all behind one runtime image rebuild.** Branch
`port/config-write-lock` on the zeroclaw fork carries upstream `841f28c7f1`
(cherry-picked CLEANLY despite being 331 behind; `handle_prop_put` — the
endpoint ClawMates writes through — now takes the lock). Not merged, not
deployed. Still to take: credential-fragment redaction (`eadaee0b62`) and
`/api/pair` lockout hardening (`47adb9863e`). Tank is offline, so the rebuild
has to happen on gw-04 and push to the web-01 registry.
3. **Mission egress remediation** — written up in `MISSION-EGRESS.md`,
deliberately NOT applied on the operator's call. A mission container reaches
the entire tailnet and host SSH.
4. **The 331-commit upstream merge.** 18 overlapping files; `claude_cli.rs`
cannot conflict (zero upstream files).
5. **Two silent-discard defects found by sweep, not yet fixed.**
`container_tool_hooks::install`'s outcome is discarded at both call sites and
recorded nowhere, so "did this mission run gated?" is unanswerable once the
container is reaped — and `PreToolUse` is now a security control, not just
telemetry. `vm_tool_gate::INERT_FILE` has no reader anywhere (low severity:
node v22 is in the image, so it cannot currently trigger).
1. **The judge runs to its check cap almost every time.** Compaction made
that cheaper; it did not ask why a research verdict needs 12 commands.
Watch `requests` per verdict on the next few missions. If it stays at the
cap, the lever is the judge prompt, not the budget.
2. **microVM tier is unexercised since v0.8.5.** Fleet is online; every run
this pass was container-tier. Run one microVM mission before assuming the
upgrade left that path alone.
3. **`files` is n=3.** 7 of 9 is a signal. `structured-paper-summary` went
unread in 2 of the last 3 runs; the skills section sits 8790% into the
prompt. Position is the untested lever.
4. **Compliance checks exist for 6 of 53 skills.** The rest score
`not_applicable` on that axis forever.
5. **The persistent `clawmates-runtime` container's own config** still names
`worker_glm`/`worker_kimi` (3 mentions). Not on the mission path; editing
it restarts the paired runtime.
6. **Prod holds our 7 test missions** with 90-day retention. They are the
evidence for everything above; wipe them when they stop being.
## State you should know about
- **Local DB has test state**: `workspace-repo-commit-protocol.always_inject =
true`, set by hand for the validation run. **Production has none set** —
`always_inject` is deployed, defaulting off, changing nothing until someone
marks a skill.
- **No production mission since 2026-08-25.** Everything above is deployed and,
apart from the last-run cards, unexercised in prod.
- **Fleet offline 17 days** — `architect`, `morpheus`, `tank`; `nodes` table is
empty. microVM tier stays blocked.
- **Postgres is named differently on each stack.** Locally it is
`clawmates-postgres-1` (dashes); on gw-04 it is `clawmates_postgres_1`
(underscores). Same for `server`/`frontend`. Using the wrong one gives
"No such container", which reads like a down stack rather than a typo.
- **`target/` is a symlink to `/Volumes/NVMeRAID`.** If it detaches mid-run,
cargo dies with SIGKILL and then `Not a directory`. It is the drive, not a
flaky test.
- **Prod: 7 missions, 6 completed** (the 7th was the quota casualty). All
research_only, all the same task — that sameness is what made the arm
comparison mean anything.
- **Judge quota** resets weekly (last: 2026-09-11 10:01 UTC). With backoff
and accounting in place a blocked phase can no longer empty it alone; a
week of missions still can. Check the query above before a batch.
- **Postgres is named differently on each stack.** Locally
`clawmates-postgres-1` (dashes); on gw-04 `clawmates_postgres_1`
(underscores). Same for `server`/`frontend`.
- **`target/` is a symlink to `/Volumes/NVMeRAID`**, and that volume went
away entirely on 2026-09-14 (SIGBUS mid-compile, then "failed to create
directory target"). Build with `CARGO_TARGET_DIR=$HOME/cargo-target-clawmates`
until it is back. It is the drive, not the code.
- **The Mac kills background processes under memory pressure** — four
watchers and Tailscale this pass. Long polls belong on gw-04 (`nohup`), not
here.
- **gw-04 is reachable two ways**: `ssh gw-04` (Tailscale) and `ssh gw-04-pub`
(public IP, `204.168.133.187`). It is NOT on the Hetzner private net; the
web-01 back door cannot reach it.
## Deliberately not done
- The mission executor swap — blockers are structural (see prior handoffs).
- A tap for the direct-session tier — dormant, `CLAWMATES_MISSION_EXECUTOR`
unset in production.
- `cm-brain` offline tests — 6 of 9 need live `clawbrainhub.com`.
- Routing any agent role to GLM. The z.ai plan is the judge's, and the judge
is the one consumer whose spend is now measured. The design for a real
`claude_cli.glm` route is in `deploy/clawmates-runtime/agent.config.example.toml`,
commented out, with the reason it does not work as a TOML sub-table.
- Marking more skills `always_inject`. See above.
- The mission executor swap; a tap for the direct-session tier; `cm-brain`
offline tests — unchanged from prior handoffs.