fix(llm): read the first text block of a bedrock Converse response - #2288
fix(llm): read the first text block of a bedrock Converse response#2288zhiyanliu wants to merge 1 commit into
Conversation
Converse returns output.message.content as a list of blocks and does not
promise a text block is first. Reasoning-capable models emit a
reasoningContent block ahead of the answer, and toolUse or future block
types can precede it too, but both bedrock call sites indexed position 0:
content", [{}])[0].get("text", "{}")
For those models the default was returned on every call, so _parse_llm_json
saw an empty object, _response_is_hollow reported a hollow result,
finish_reason was rewritten to "length", and the adaptive retry bisected the
chunk. Splitting could not converge because the position assumption fails
identically at every chunk size, and raising GRAPHIFY_MAX_OUTPUT_TOKENS did
nothing because output length was never the constraint. stopReason on those
responses was end_turn, i.e. the model had answered correctly.
Selection now keys on the block's shape rather than its position, at both
_call_bedrock and the bedrock branch of _call_llm. A response whose first
block is already text -- every non-reasoning model today -- is unaffected.
On a 48-document corpus the hollow warnings and the bisection to the
recursion cap disappear, the 17 files previously reported as producing no
nodes are extracted, and output tokens drop from 217,538 to 53,274 as the
wasted retries stop.
Fixes Graphify-Labs#2287
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This PR adds a helper _bedrock_response_text to graphify/llm.py that scans the Bedrock Converse response's content blocks and returns the first block containing non-empty text, rather than assuming the text is at index 0. It replaces two existing hard-indexed content extractions (in _call_bedrock and _call_llm) with calls to this helper. The tests/test_image_vision.py file gains a new section of unit and end-to-end tests covering the helper's block-selection, fallback, and malformed-input behavior.
No blocking issues surfaced. 1 lower-confidence candidate did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 600 functions depend on the 195 node(s) this change touches.
Health — this change adds coupling hotspots:
- worse:
_call_llm()— 7 callers, 15 callees - worse:
_call_bedrock()— 3 callers, 6 callees
Verification — 600 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 410 function(s) in the blast radius were not formally verified this run
· 2 more finding(s) on lines outside this diff (see the check run).
|
Noted on the coupling advisory — flagging that I read it and am leaving the shape as-is, so a reviewer doesn't have to wonder. The Inlining the loop at both sites would keep the callee counts flat and I'd rather not: that reintroduces exactly the duplication that let the two sites drift in the first place (one already defaulted differently). Happy to inline it if you'd prefer to hold the counts flat, or to move the helper next to the other bedrock helpers if placement is the concern. |
Summary
Both bedrock call sites read only the first content block of a Converse response. Converse returns
output.message.contentas a list and does not promise a text block is first — reasoning-capable models emitreasoningContentahead of the answer, andtoolUseor future block types can precede it too. For those models the hard-coded index returned the.get("text", …)default on every call, so_parse_llm_jsonsaw an empty object,_response_is_hollowreported a hollow result,finish_reasonwas rewritten tolength, and the adaptive retry bisected the chunk.Splitting cannot converge, because the position assumption fails identically at every chunk size.
stopReasonon those responses isend_turn— the model answered correctly and was billed for it.Fixes #2287.
What changed
Selection now keys on the block's shape rather than its position, via one helper used by
_call_bedrock(primary extraction) and thebackend == "bedrock"branch of_call_llm(secondary dispatch). A response whose first block is already text — every non-reasoning model today — is unaffected.The helper is deliberately model-agnostic: it asks whether a block carries non-empty text, never which model produced it, so it also covers block types Converse may add later.
Verification
Probing Converse directly with a reasoning model shows the shape the old code could not read:
Re-running extraction on a 48-document corpus, changing only the block selection:
hollow responselinesrecursion depth 3 (max 3)Document-type nodes rose from 43 to 66 and concept nodes from 98 to 130, so the extra coverage is real rather than a re-labelling. The output-token drop is the wasted bisection disappearing.
Tests
Added coverage for the block-selection contract: a single text block behaves exactly as before; a leading
reasoningContent,toolUse, unknown future block type, or whitespace-only text block is skipped in favour of the real one; an absent text block, empty list, non-list content, and missingoutput/messageall fall back to the caller's default; non-dict blocks and non-stringtextvalues are tolerated without raising. One end-to-end test drives_call_bedrockwith a reasoning-model response and asserts the result is neither empty nor reclassified as truncation — it fails if the call sites are reverted to indexing position 0.pytest tests/shows no new failures againstv8(the pre-existingtest_security/test_ollama_retry_cap/test_labelingfailures in my environment are unrelated — local DNS and network sandboxing — and are identical with and without this change).ruff checkis clean on both touched files.Happy to reshape the helper or move it next to the other bedrock helpers if you'd prefer a different placement.