Skip to content

fix(1634): a node read by id returns its full widget value, not a survey clip - #1637

Merged
artokun merged 5 commits into
mainfrom
fix/1634-clipped-prompt-read
Aug 16, 2026
Merged

fix(1634): a node read by id returns its full widget value, not a survey clip#1637
artokun merged 5 commits into
mainfrom
fix/1634-clipped-prompt-read

Conversation

@artokun

@artokun artokun commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Fixes #1634

The filed hypothesis is not what happens

The issue proposed that panel_graph_outline sheds 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:

PROMPT length = 300 chars

===== fields:'compact'  (ids:["2"]) =====
#2 CLIPTextEncode "Positive Prompt" · text=masterpiece, best quality, ultra detailed, a lone astronaut…  ← clip:1  → 4
(1 widget value(s) clipped to 60 chars by `fields`:"compact" — read fuller values with `fields`:"detail", …)

-> contains the FULL prompt? NO      -> reply length 301 chars   [budget: 12000]

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 calls ids "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 detail projection 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:

  • Only one id is a pinpoint. Treating any ids list as one cost rows the caller explicitly asked for: 20 ordinary 600-char prompts at the default budget went 20/20 → 18/20.
  • A fit test, not an arithmetic reserve. Every reserve I tried leaked — a per-widget floor still grows without bound across many widgets, so a 24-widget node breached max_chars on default parameters while reporting truncated: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/main across 1316 shape × budget × projection cases (1–300 widgets, 10–9000 chars each, 1–60 nodes, budgets 500 → 60000, with and without ids):

cases=1316  breaches-vs-main=0  lost-rows=0  shorter=0  richer=92

Zero replies breach max_chars where 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_chars bound is already soft, because clipLine bounds 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

  • 43/43 in graph-query.test.ts (32 pre-existing + 11 new); 516 files / 9690 tests green across the full suite; tsc --noEmit clean.
  • Mutation-tested: reverting graph-query.ts to origin/main with the tests in place fails 3. Mutating the fit test's reserve constant fails 1 (the gate found that constant unpinned; it now is).
  • The new tests pin the survey path, the multi-id row count, the max_chars bound and the note's honesty — not just the happy path.

Both engines

src/services/graph-query.ts is 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:

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" in ollama-backend.ts:353. Two reasons I left it:

  1. There is no test that can fail-on-revert — "the model stopped calling a tool" is not assertable, and a description-string assertion would be the tested-but-unreachable class rather than evidence.
  2. The reporter is on Gemini (ACP), so the ollama-backend.ts line 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:

round finding outcome
1 the protected first row breached max_chars (740/500 where main returned 465) fixed
1 the note pointed at fields:"detail" where detail applies the same cap — a dead retry fixed
2 P0 the per-widget floor leaked: 24 widgets breached on default params reporting truncated:false replaced arithmetic with the fit test
2 any ids list treated as pinpoint — multi-id reads lost rows main returned pinpoint is now a single id
2 the note named a cap in force for no widget cap is uniform per row again
3 SHIP — plus two non-blocking notes (unpinned reserve constant, overstated source comment) both addressed in the last commit

…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.
Copilot AI balanced review requested due to automatic review settings August 16, 2026 01:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…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
artokun marked this pull request as ready for review August 16, 2026 18:41
@artokun
artokun merged commit eee675c into main Aug 16, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent returns a truncated prompt from a node, and does graph lookups for questions that have nothing to do with the canvas

2 participants