feat(context): bounded, policy-driven delivery — budget, priority order, always-on by delivery_policy (ADR 0108 D6) - #3247
Conversation
|
Warning Review limit reachedNext included review available in 11 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (23)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
…er, always-on by delivery_policy (ADR 0108 D6) The projected context has a char budget (`context.budget_pct` of the model window, chars//4; default 8%, 0 = unbounded, unbounded when no window is known). Fill priority: working state → always-on memory → skill index → prior-session digest → RAG hits. Over budget the lowest-priority parts shed first — RAG hits one whole hit at a time from the lowest-ranked end, then the digest as a unit, then skill descriptions down to the identity floor (names never drop); working state and always-on memory are never shed (a warning names the sizes when they alone exceed the budget). Deterministic; unbounded delivery is byte-identical to the pre-D6 composer (the D8 golden still holds). Always-on is now selected by `delivery_policy="always"`, not `domain="hot"` (every hot write is stamped always since D4, so legacy rows still inject, and a row on any domain can be pinned): the store's hot readers, the `memory.hot_written` event, the console's hot list/edit (edit keeps the row's own domain, pins the policy), and the snapshot seed (always-on never travels) all key on the policy. `deliverable=True` on list_chunks/search (plain FTS + LIKE, hybrid both rankings, layered both tiers) excludes rejected and expired rows from delivery; the projection's RAG search passes it (with a post-filter for backends that predate the kwarg) and `memory_recall` does not. One wiring: graph/agent.py builds KnowledgeMiddleware with `options=ProjectionOptions.from_config(config)` — the same reader the external runtime uses. `ProjectedContext` carries `budget_chars`, `used_chars` and an `overflow` list; `as_legacy_dict()` adds a `budget` summary when a budget is in force and keeps the two-key shape otherwise; shed sections are marked `truncated`; the injection log records what was delivered. Refs #3187, #3184. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
…, preview carries the budget, lifecycle-preserving hot edit - Skill shed walks by ROWS from the summaries read once per compose (skills_top_k = full_rows-1, -2, …, then the identity floor): monotone in the budget, ≤ skills_top_k renders, no extra index reads — the char-cap walk could overshoot by a few chars for 16 iterations and fall to the floor. - The derived budget is floored at 16k chars (≈ always-on cap + digest cap) so ≤32k-window models keep their standing context whole and shed only RAG hits / skill descriptions beyond it; the never-shed warning fires once per distinct (working_state, always_on) size; from_config logs once when budget_pct > 0 but no window is known (the knob is inert). - GET /api/prompts/preview carries `budget` (null when unbounded) and per-section `truncated` flags through _sections(). - The hot EDIT route carries memory_kind/subject/expires_at/namespace/epoch into the revision; a rejected pin stays rejected, else confirmed; the hot list intentionally shows rejected/expired pins with injecting=false. - ProjectionOptions(budget_chars<=0) reads as None; _finish keeps overflow when everything was shed; deliverable_hit parses ISO (Z, naive→UTC) with a string fallback; context.budget_pct coercion warns on blank/non-numeric and clamps negatives to 0; _deliverable_clauses documents its +00:00 shape assumption; snapshot seed keeps both the commons-tier and always-on guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
… catalog scanner reads emit("…") as a bus topic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
…s always-on (ADR 0108 D6 + D7) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
f6745a5 to
46eab6e
Compare
There was a problem hiding this comment.
QA panel review — WARN
code-review-structural · head f6745a5ad2a2 · formal
⚠️ PR advanced 8 commit(s) during this round (f6745a5ad2a2→46eab6efbd2e); 1 finding(s) in the delta were demoted to possibly addressed.
Overall risk is elevated but unconfirmed: the panel's single blocker (a syntactically truncated return in graph/snapshot_op.py) and one minor (a missing budget floor in graph/projection.py) both sit in files that 404 at the PR head SHA, so neither can be confirmed or refuted. The verifier refuted the third finding (fabricated evidence in memory_routes.py), which is a good sign the panel's cross-file lane was overreaching. Fix-first: re-pin the PR head or resolve the force-push so the two uncertain findings can be re-verified; the blocker is the one that would break import-time if real. The panel did not disagree on severity — the disagreement was on whether the evidence was real (Finding 3) versus unreadable (Findings 1–2). Verification changed the outcome by eliminating Finding 3 entirely and downgrading the panel's confidence on the remaining two from "asserted" to "unverifiable." Gap: the structural pass was skipped because both target files are unreadable at the pinned SHA; no structural invariants could be checked.
Findings
| Severity | Location | Finding | Verified | |
|---|---|---|---|---|
| 🔴 | blocker | graph/snapshot_op.py:539 |
The build_snapshot function ends with a truncated return statement ("return Snapsho") that is syntactically incomplete and will raise a SyntaxError at import t… | |
| 🟡 | minor | graph/projection.py:132 |
The PR description promises a 16k-char floor on budget_chars (max(window × pct/100 × 4, 16000)) so that always-on memory and the prior-session digest are 'neve… | ⏳ possibly addressed |
findings JSON (machine-readable)
[
{
"file": "graph/snapshot_op.py",
"line": 539,
"severity": "blocker",
"category": "correctness",
"claim": "The build_snapshot function ends with a truncated return statement (\"return Snapsho\") that is syntactically incomplete and will raise a SyntaxError at import time, breaking the entire snapshot feature.",
"evidence": "return Snapsho",
"source": "protopatch",
"verdict": "uncertain",
"note": "gap: unverified \u2014 file 404s at PR head (46eab6efbd2e); default-branch read shows the file exists pre-PR but the PR-head version is unreadable, so the claimed truncated return cannot be confirmed or refuted."
},
{
"file": "graph/projection.py",
"line": 132,
"severity": "minor",
"category": "correctness",
"claim": "The PR description promises a 16k-char floor on budget_chars (max(window \u00d7 pct/100 \u00d7 4, 16000)) so that always-on memory and the prior-session digest are 'never fought over, whatever the window', but the code has no such floor; on a small-window model the derived budget can fall below the always-on + digest size and _fit_to_budget sheds the digest (its step 2), contradicting the documented guarantee.",
"evidence": "budget_chars=int(window * budget_pct / 100 * 4) if (window and budget_pct > 0) else None,",
"verdict": "possibly addressed",
"note": "gap: unverified \u2014 file 404s at PR head (46eab6efbd2e); default-branch read shows the pre-PR ProjectionOptions (no budget_chars field, as expected), but the PR-head version is unreadable, so the claimed missing floor cannot be confirmed or refuted. \u2014 the PR head advanced while this round ran and the new commits touch this finding's region \u2014 verified against the superseded head, so it may already be addressed"
}
]There was a problem hiding this comment.
QA panel review — WARN
code-review-structural · head 46eab6efbd2e · formal
The PR head SHA (46eab6e) is unreachable (404), so neither prior-round issue could be independently verified at the actual head. The synthesizer's "confirmed addressed" claim rests on inference from the default branch, which was itself truncated before reaching the relevant functions. The one thing to fix before merge: confirm the truncated return in snapshot_op.py is actually resolved — if it persists, it is a hard SyntaxError at import time. The panel disagreed on whether the two prior issues were closed: the synthesizer said yes, the verifier could not confirm. Gaps: the structural pass was skipped entirely (verifier noted the 404), and the diff was truncated at 12k chars, hiding the code changes to projection.py.
Prior requests
| Prior finding | Disposition | Why | |
|---|---|---|---|
| 🔴 | graph/snapshot_op.py:539 |
open | PR head SHA 46eab6e returned 404; verifier fell back to default branch where build_snapshot was not visible (file truncated at 20k chars). Cannot confirm the … |
Findings
| Severity | Location | Finding | Verified | |
|---|---|---|---|---|
| 🔴 | blocker | graph/snapshot_op.py:539 |
build_snapshot ends with a truncated return statement ("return Snapsho") that is syntactically incomplete and will raise a SyntaxError at import time, breaking… | |
| 🟡 | minor | graph/projection.py:132 |
The PR description promises a 16k-char floor on budget_chars (max(window × pct/100 × 4, 16000)) so that always-on memory and the prior-session digest are 'neve… |
findings JSON (machine-readable)
[
{
"file": "graph/snapshot_op.py",
"line": 539,
"severity": "blocker",
"category": "",
"claim": "build_snapshot ends with a truncated return statement (\"return Snapsho\") that is syntactically incomplete and will raise a SyntaxError at import time, breaking the entire snapshot feature. Flagged in round 1; the synthesizer claimed it was addressed at this head, but the PR head SHA 404'd and the verifier could not independently confirm the fix.",
"evidence": "return Snapsho",
"verdict": "uncertain",
"note": "PR head SHA 46eab6efbd2e0ff5c1a4f8dfaee10ebbc7c25b76 returned 404 on both file reads. Verifier fell back to default branch; build_snapshot was not visible in the truncated output (cut at 20 000 chars). Cannot confirm resolution."
},
{
"file": "graph/projection.py",
"line": 132,
"severity": "minor",
"category": "",
"claim": "The PR description promises a 16k-char floor on budget_chars (max(window \u00d7 pct/100 \u00d7 4, 16000)) so that always-on memory and the prior-session digest are 'never fought over, whatever the window', but no such floor is visible in the code. Flagged in round 1; the synthesizer claimed it was addressed, but the verifier could not confirm (PR head 404'd, diff truncated before reaching projection.py code changes).",
"evidence": "budget_chars",
"verdict": "uncertain",
"note": "PR head SHA 404'd. On the default branch, ProjectionOptions and compose_projected_context are present but no 16k floor is visible. The PR diff was truncated at 12 000 chars, hiding the actual code changes to projection.py."
}
]1 panel step(s) hit their time budget and were skipped this round: find_removed_behavior. The verdict stands on the remaining angles; a finding only that step would have caught could be missed — the next push re-runs the full panel.
Unaccounted prior finding(s). An earlier round of this panel confirmed the following, and this round neither reports them, nor says they were fixed, nor refutes them:
graph/snapshot_op.py:539(blocker) — The build_snapshot function ends with a truncated return statement ("return Snapsho") that is syntactically incomplete and will raise a SyntaxError at import time, breaking the entire snapshot feature.
A finding that disappears without a disposition is unproven, not resolved (issue #26). Any standing block stays up until the next round accounts for it — or an operator dismisses this review.
|
QA panel WARN — both findings verified at the actual head The panel's own notes say its reads 404'd / truncated after the force-push, so both findings were judged on cut-off text:
Dismissal trail for the panel's issue #26 protocol: both prior findings are hereby accounted for as refuted at head with the evidence above. 🤖 Generated with Claude Code |
…pt inspector (ADR 0108 D6) (#3257) * feat(console): show the delivery budget and shed sections in the prompt inspector The backend has returned the ADR 0108 D6 delivery budget since #3247 — the ceiling, chars used, and an `overflow` list naming what was shed to fit, plus `truncated` on every section the budget cut. Nothing consumed any of it: `api.promptPreview()` had zero callers, so the console never called the preview route at all, and the fields were absent from the TypeScript types. Two latent bugs had to be fixed before the preview could be wired up at all: - `promptText()` read only `stable + context`, but post-#3234 captures and the /preview synthesis carry an EMPTY `system.context` and put the whole projection in `projected_context` — so the dialog rendered a bare stable prefix for exactly the calls it is now most used to inspect. `splitLine()` reported a 0-char tail for the same reason. - A `"projected"`-scope section fell through to the stable tint, painting the per-turn projection as if it were the cacheable prefix — the one split the breakdown exists to show. The delivery budget renders as its own strip (ceiling / used / spare, with the shed list beneath) and is deliberately NOT merged into the existing section-size bars: those are a share-of-prompt breakdown, this is a ceiling, and labeling them alike would imply the sections were measured against it. The next-call preview is a tab that loads on selection, not with the dialog — it re-runs the dynamic layer including retrieval, so it is the one tab that costs something to open — and it is badged speculative because no model call was made and nothing was written to the injection log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby * chore(changelog): fragment for #3257 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby * fix(console): the preview tab self-cancelled its own fetch `previewState` was both SET inside the preview effect and listed in its dep array. setPreviewState("loading") re-ran the effect, the cleanup flipped `alive = false` so the in-flight response was discarded, and the re-run's own guard returned early because the state was no longer "idle" — nothing ever set it back, so the tab sat on "Composing the next call…" forever. Dropping `previewState` from the deps is enough: React rebuilds the closure every render, so a run triggered by `active`/`preview`/`sessionId` still reads the current state and the guard still prevents a re-fetch. `preview` stays a dep — it is set on the SUCCESS path after the work completes, so its re-run is benign, which is exactly the distinction that made `previewState` a bug. Every test on this branch targeted the pure formatters in promptView.ts, so nothing exercised the component and this shipped green. Adds four component tests (jsdom + react-dom/client, the NewAgentPanel precedent) driving the real effect. They use a hand-resolved deferred rather than mockResolvedValue: an immediately-resolved mock settles before React re-runs the effect, so the self-cancel never happens and the tests pass against the broken code — with the deferred, all four fail when the dep array is re-armed. Also fixes the changelog fragment to the documented shape: changelog.d/README.md puts the (#NNNN) and its period INSIDE the bold lead-in, which is what the collation extracts as the release-note title. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Implements ADR 0108 D6 — delivery is bounded and policy-driven. Refs #3187, #3184. Builds on D4 (#3242,
delivery_policy) and D8 (#3243,graph/projection.py).What
Budget. New config
context.budget_pct(context_budget_pct, default8,0= unbounded; blank/non-numeric → default with a warning, negative → 0): the projected context — everything injected per turn on top of the stable prompt — may use at most that share of the model's context window (chars//4).ProjectionOptions.from_configderivesbudget_chars = max(window × pct/100 × 4, 16 000)— the 16k-char floor (≈ always-on cap 6k + digest cap ~8k + headroom) means always-on memory and the digest are never fought over, whatever the window. No window reported by the gateway → unbounded, logged once (the knob is inert).ProjectionOptions(budget_chars<=0)reads asNoneso direct construction agrees withfrom_config. Surfaced in Settings → Knowledge → Recall, the example YAML, and the config reference. Nodelivery_orderknob — the order is fixed by the ADR (documented).Priority + shed order (
_fit_to_budgetingraph/projection.py). Fill priority: working state → always-on memory → skill index → prior-session digest → RAG hits. Over budget, shed lowest-priority first, re-measuring after each step so separators and the envelope are accounted for and nothing is cut mid-line:skills_top_k = full_rows-1, -2, …(the last description row becomes a name-only row each step) until the block fits, then the identity floor (name-only rows, skills index: fresh instances hide most skills — MRU-ordered top-5 of 18 made the Cowork archetype's headline skill invisible #2867). Monotone in the budget, at mostskills_top_krenders, never below the floor. (The first cut walked a char cap by overshoot, which could stall for 16 iterations on a 1–20-char overshoot and drop to the floor — the adversarial probe caught it; a sweep test now pins monotonicity.)WARNINGnames the sizes — once per distinct (working-state, always-on) size per process,DEBUGafter that.Deterministic. One
INFOline when anything was shed (never per-item spam).Always-on =
delivery_policy="always", notdomain="hot".get_hot_memory_entries()/get_hot_memory()(names kept for custom backends and the console) select by policy;_publish_hot_writefires on the STORED policy (a superset — D4 forcesalwayson every hot write, so legacy rows still inject and fire);operator_api/memory_routes.pylists the hot inspector by policy — deliberately without the reader'sdeliverablefilter, so a rejected/expired pin still lists (withinjecting: false) for the operator to see and un-reject — and the hot EDIT route is lifecycle-preserving: it keeps the row's own domain, pinsdelivery_policy="always", carriesmemory_kind/subject/expires_at/namespace/epochinto the revision, and keeps a rejected pin rejected (any other edit is the operator's own write →confirmed); a backend predating the kwargs falls back to the old domain pin (its own reader keys on the domain, so that is the same pin, not a demotion).graph/snapshot_op.collect_knowledge_seeddropsalwaysrows on any domain (memory never travels, ADR 0091) alongside #3245's commons-tier guard and keepsMEMORY_DOMAINS.deliverable=Trueonlist_chunksandsearch— plain (FTS + LIKE), hybrid (both rankings, so a rejected row can't surface as a vector-only hit), layered (both tiers): excludesreview_state='rejected'and rows pastexpires_at. The always-on reader and the projection's RAG search pass it (search_scopedretries without the kwarg for a backend that predates it, then post-filters withdeliverable_hit— the rule holds either way);memory_recall/memory_listdo not, so excluded rows stay reachable on demand.One wiring.
KnowledgeMiddleware(..., options=ProjectionOptions.from_config(config))ingraph/agent.pyreplaces the hand-wired kwargs (the individual kwargs stay for tests and derive fromoptionswhen given). The D8 drift test is replaced bytest_middleware_is_wired_from_one_options_object, which also asserts agent.py has no hand-wiredskills_index_chars=int(left.Shape.
ProjectedContextgainsbudget_chars,used_chars,overflow({"label", "dropped_items", "dropped_chars"}in shed order — kept even when everything was shed and the text is empty); shed sections carry"truncated": true;as_legacy_dict()adds"budget": {"chars", "used", "overflow"}only when a budget is in force — unbounded delivery keeps the two-key shape byte-for-byte. The prompt preview API (GET /api/prompts/preview) carries the budget summary (call.budget,nullwhen unbounded) and the per-sectiontruncatedflags through_sections(); the console inspector renders them in a follow-up (listed below). The injection log records the ids that were delivered, after shedding.deliverable_hitparsesexpires_atas ISO-8601 (Zand naive → UTC) with a string-compare fallback; the SQL predicate's+00:00ISO-shape assumption is documented on_deliverable_clauses(D7 normalizes at the write funnel).Defaults and what changes where
8%of a 128k window =int(128_000 × 0.08 × 4)= 40 960 chars ≈ 10k tokens. The worst-realistic 128k turn is ~40.4k chars — 6 000 chars of always-on memory + a ~2 000-token digest (~8 000 chars) + ten 1 000-char hits + a 2%-of-window skill index (10 240) + envelope/working state — i.e. ~564 chars of headroom: nothing is shed at the default on 128k unless every part is at its cap at once, and then only the last hit. The D8 pre-refactor golden (tests/fixtures/projection_native_golden.json) passes unchanged.On ≤32k-window models the default budget is the 16k-char floor (8% of 32k = 10 240 < floor; 8% of 8k would not even hold the always-on envelope), so RAG hits and skill descriptions beyond the floor are shed where they were unbounded before — always-on memory and the digest are never touched. LiteLLM reports 8k/32k for many local/legacy models, so this is the one behavior change an operator may notice; the settings copy, config comment, example YAML and docs all say so.
Rejected/expired rows were never written before D4/D7, so
deliverablechanges nothing for existing stores. Lowercontext.budget_pctto make the budget bite.Consequences of the always-on switch
delivery_policy="always"now injects every turn and shows in the console's Hot memory list — that is the intended D4→D6 payoff.hot+ non-alwaysrows cannot exist (D4 forces the policy), so no row is stranded by the switch; NULL already reads asretrieved.MEMORY_DOMAINSstill drops thehotdomain from snapshots; the policy filter extends that to pinned rows elsewhere.Gates
Run in the worktree at
46eab6ef, rebased ontobd5108f8(origin/main = D7 #3246; before that #3244/#3245). Rebase conflicts resolved:graph/snapshot_op.pyseed loop keeps BOTH guards (commons-tier from #3245, always-on from this PR); ADR 0108 Phase 5 keeps BOTH "Shipped" bullets (D7's + D6's). Post-rebase, the B4 route test was added: analwaysrow on a non-hot domain edited viaPUT /api/knowledge/chunks/{id}keeps its policy and stays inGET /api/memory/hot(D7's typed-field carry-over + this PR's policy-keyed hot list,tests/test_knowledge_routes.py::test_chunk_update_keeps_an_always_on_row_always_on).uv run ruff check .→ All checks passed!uv run lint-imports→ Contracts: 3 kept, 0 broken.uv run python -m pytest tests/ -q -p no:cacheprovider→ 6987 passed, 16 skipped, 156 warnings in 326.02s — EXIT=0 (scratchpad/d6-pytest-r4.log; r3 pre-rebase was 6960 passed EXIT=0; the r2 run's 2 failures were the plugin-events catalog reading the dedupe callable namedemit(as a bus topic — renamed,tests/test_plugin_api_reference.pygreen since)Remaining on #3187
Not in this PR: per-kind retrieval quotas; the session-frozen operator-profile snapshot; a separate confirmation/write gate for standing constraints; inspector "why was this selected" explanations beyond the budget summary; a mid-session "your memory update becomes active next turn" notice; console rendering of the budget summary +
truncatedflags the preview API now carries; an injection-logshedcolumn; per-chat model overrides don't re-derive the budget (pre-existing — same as the skill-index cap, both derive from the configured model's window).Pending in this PR (after #3246 / D7 merges): a route test asserting an
alwaysrow edited from the Knowledge view (PUT /api/knowledge/chunks/{id}) stays always-on and in the hot list — D7 carries the typed-field carry-over fix for that route; this PR does not touch it.🤖 Generated with Claude Code
https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby