fix(1634): a node read by id returns its full widget value, not a survey clip - #1637
Merged
Conversation
…vey clip
Reported via Discord: asking the agent for a positive prompt kept returning a
cut-off version. The filed hypothesis was panel_graph_outline's ladder shedding
widget values on an over-budget graph. Measured, that is not it: it reproduces on
a FOUR-node graph, where no ladder degrades and nothing is near a budget.
The compact projection clips widget values at a fixed 60 chars. That cap is a
SURVEY cap -- it keeps a 200-node listing of nodes you have not identified yet
small. It was also applied when the caller named the nodes explicitly via 'ids',
which is the opposite case: the nodes are already identified, so the clip is
starving the one value that was asked for. panel_query_graph's own description
calls 'ids' "THE way to read ONE node's exact detail", so the default did not
match the documented intent, and the shortfall is silent -- a clipped prompt ends
in an ellipsis a real prompt could plausibly contain, so it gets quoted back to
the user as the node's content.
Measured on a 4-node graph, {ids:["2"]} returned the prompt cut at 60 chars in a
301-char reply against a 12000-char budget.
An explicit-ids read now uses the same per-value cap the 'detail' projection uses.
Deliberately unchanged: the compact SHAPE (still one line per node), the survey
path (no ids -> still 60), and max_chars, which still governs via the existing
row-drop path.
The cap is tightened to max_chars the way capWidgets() already does for detail,
and floored at the survey clip. Without that reserve the FIRST row -- which #609
protects from the budget and never drops -- breached the bound outright: 740 chars
against max_chars=500, where main returned 465. Swept the whole clamp window for
bound compliance.
The footer note names the cap actually in force: at the fixed 2048 cap it stops
pointing at fields:'detail' (which applies the same cap -- the dead retry #809
exists to remove), and when max_chars was the cutter it names max_chars instead.
…where it helps Both found by the review gate on the first version of this fix. P1: a PER-VALUE cap does not bound the ROW. N widgets at 2048 sum to N x 2048, and the #609-protected first row can never be dropped to recover, so a multi-widget pinpoint node breached max_chars on DEFAULT parameters -- measured 12169/12000 with six long widgets, and 2700/2500 with just two (an ordinary positive+negative pair). The raised cap is now ONE budget spent across the row's widgets, not a per-widget allowance. Re-measured: 11191/12000, 1458/2000, 1831/2500. The 300-widget shape still exceeds by 172, but that is PRE-EXISTING (main: 12184, this branch: 12172) -- the documented protected-row overflow, not something this branch introduces. P1: dropping the fields:'detail' pointer is only honest AT the fixed cap, where detail applies the same 2048. Below it detail is strictly better -- capWidgets() reserves 256 where this row reserves 1024, so at max_chars=1084 compact carries 59 value chars against detail's 747. Suppressing the pointer there would hand back the very 60-char clipped prompt this issue is about, with the one lever that works at that budget omitted. The pointer is restored below the fixed cap. And a third, caught by this branch's own bound test: when the budget degrades the pinpoint cap all the way back to the survey clip, emit main's note VERBATIM. The longer note is not free -- it rides inside max_chars, and at the 500 floor it alone pushed a reply that fitted (465) past the bound (545).
Round-2 gate findings. My arithmetic reserves were leaking. P0: a per-widget floor of 60 still grows without bound across many widgets, so the 'one row budget' did not bound the row: a 24-widget node breached max_chars on DEFAULT parameters (12118/12000) while reporting truncated:false, and the added test only exercised 1-6 widgets so it stayed green. Replaced the arithmetic with a FIT TEST -- render the row generously, and use it only if it demonstrably fits. A fit test cannot leak. P1: pinpoint is now a SINGLE id, not any ids list. Treating a list as a pinpoint cost rows the caller explicitly asked for and main returned in full: 20 ordinary 600-char prompts at the default budget went 20/20 -> 18/20, 30 -> 18/30, 60 -> 12. One id also means at most one row, so there is no budget to divide. P1: the cap is uniform across the row again, so the clip note has exactly two honest forms. The budget-derived per-widget cap named a number that was in force for NO widget (values cut at 2048/736/60 all reported as 2048) and called a cut unraisable that raising max_chars demonstrably lifted -- the #809 wrong-lever defect verbatim. Measured against origin/main across 1316 shape x budget x projection cases: 0 breaches of max_chars that main did not have, 0 lost rows, 0 shorter replies, 92 richer. Every gate shape from both rounds is now within bound. 516 files / 9690 tests pass.
…ing the bound Two non-blocking notes from the SHIP gate. The reserve constant was unpinned: mutating maxChars-1024 to maxChars survived all 103 tests. Pinned by asserting the raise is DECLINED where the widgets alone would eat the budget, and GRANTED for the same node once the budget clears the reserve. And the source comment claimed this is 'never worse than main on any shape', which the gate's fuzz contradicts: the max_chars bound is already soft -- clipLine bounds the LINE while the header and clip note ride outside it, so main overshoots by ~173 chars on 1893 of 4000 hostile shapes and this branch does so on 80 by the same magnitude. It does not make the bound softer, and it does not fix it either. The comment now says that instead of reassuring.
…eplaced The reserve-arithmetic block survived the rewrite to a fit test and now described code that no longer exists -- including a measurement (740/500) taken against a version that was thrown away. A stale comment is worse than no comment.
artokun
marked this pull request as ready for review
August 16, 2026 18:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1634
The filed hypothesis is not what happens
The issue proposed that
panel_graph_outlinesheds widget values on an over-budget graph, so an agent quoting a prompt out of the outline hands back a value cut off by design — with a falsifiable prediction attached: "If the prompt is truncated on a small graph, this analysis is wrong and the bug is elsewhere."I measured it. It reproduces on a four-node graph, where no ladder rung degrades and nothing is near a budget:
301 characters against a 12000-character budget. The clip was not protecting anything — it was starving the one value that was asked for. So the prediction lands on the "this analysis is wrong" side, and the truncation is not graph-size dependent. That also explains the reporter's "always": a fixed clip fires every time, where a ladder rung would not.
What actually happens
The compact projection clips widget values at a fixed 60 chars. That is a survey cap — it keeps a 200-node listing of nodes you have not identified yet small.
It was also applied when the caller named the node explicitly by
ids, which is the opposite case: the node is already identified, so the clip only starves the value that was asked for.panel_query_graph's own description callsids"THE way to read ONE node's exact slot/widget detail", so the default did not match the documented intent.And the shortfall is silent in the way the issue describes: a clipped prompt ends in an ellipsis that a real prompt could plausibly contain, so it gets quoted back to the user as the node's content. That is why it reads as a bug in the prompt node rather than in the retrieval.
The fix
A single-id read renders its widget values at the same per-value cap the
detailprojection uses — but only when the generous row demonstrably fits the budget. If it does not fit, we render exactly what main renders.Two deliberate restrictions, both of which I got wrong first and the gate caught:
idslist as one cost rows the caller explicitly asked for: 20 ordinary 600-char prompts at the default budget went 20/20 → 18/20.max_charson default parameters while reportingtruncated:false. A fit test cannot leak.Deliberately unchanged: the compact shape (still one line per node), the survey path,
max_chars, and the outline ladder, which the issue flagged as carrying expensive #809/#1184/#1203 invariants. This change does not touch it.Never worse than main — measured, not argued
Swept this branch against
origin/mainacross 1316 shape × budget × projection cases (1–300 widgets, 10–9000 chars each, 1–60 nodes, budgets 500 → 60000, with and withoutids):Zero replies breach
max_charswhere main did not, zero lose a row main returned, zero come back shorter. 92 come back richer — the pinpoint reads this issue is about.The gate's own independent fuzz (4000 hostile single-id shapes) agrees on the direction and adds a caveat I've since written into the source: the
max_charsbound is already soft, becauseclipLinebounds the LINE while the header and clip note ride outside it. Main overshoots by ~173 chars on 1893 of those 4000; this branch does so on 80, by the same magnitude. It does not make that softness worse, and it does not fix it.Verification
graph-query.test.ts(32 pre-existing + 11 new); 516 files / 9690 tests green across the full suite;tsc --noEmitclean.graph-query.tstoorigin/mainwith the tests in place fails 3. Mutating the fit test's reserve constant fails 1 (the gate found that constant unpinned; it now is).max_charsbound and the note's honesty — not just the happy path.Both engines
src/services/graph-query.tsis the semantic spec for the panel's hand-mirrored twin, and its own header mandates the port. The orchestrator change alone would not have fixed the reporter, who reads the live canvas:queryApiGraph, reached in production byget_workflowaction:"query"(src/tools/workflow-library.ts:690)What this PR does NOT do — symptom B
The issue also reports unrelated questions triggering reflexive graph lookups. I have not fixed that, and I don't want to imply coverage I don't have.
It is model-steering, not a code path: the candidate sites are the unconditional "read it FIRST to get oriented" in the outline's tool description and "ALWAYS start with
panel_graph_outline" inollama-backend.ts:353. Two reasons I left it:ollama-backend.tsline was never in their path. Changing it could not have produced their symptom and would not demonstrably fix it.The tool descriptions are also byte-pinned by the tool-surface ratchet, so conditioning that guidance is a deliberate product edit, not a drive-by. Left for a separate decision.
Gate
codex was unavailable (account exhausted until 2026-08-19); gated by an independent adversarial review instead (
claude-gate.mjs), which spawns a fresh reviewer that has never seen my reasoning. Disclosing the substitution is the condition it was accepted under.It returned NO-SHIP twice before SHIP, and both rejections were right:
max_chars(740/500 where main returned 465)fields:"detail" where detail applies the same cap — a dead retrytruncated:falseidslist treated as pinpoint — multi-id reads lost rows main returned