Skip to content

bedrock backend reads only the first Converse content block, so reasoning models look like truncation #2287

Description

@zhiyanliu

Summary

The bedrock backend reads only the first content block of a Converse response. Reasoning-capable models place a reasoningContent block before the text block, so graphify receives no text at all — every call parses to zero nodes, gets classified as a hollow/truncated response, and the chunk is bisected until the recursion cap. The run reports partial results or produced no nodes while the model was in fact answering correctly and being billed for it.

Root cause

Converse returns output.message.content as a list of content blocks, and the specification does not guarantee that a text block is first — reasoningContent, toolUse, and other block types may precede it. Both bedrock call sites index position 0:

text = resp.get("output", {}).get("message", {}).get("content", [{}])[0].get("text", "{}")
  • _call_bedrock — primary semantic extraction.
  • the backend == "bedrock" branch of _call_llm — secondary dispatch.

When block 0 is a reasoning block the .get("text", …) default is returned, so _parse_llm_json sees {} → zero nodes → _response_is_hollow is true → finish_reason is rewritten to length → the adaptive retry bisects. Splitting cannot help, because the position assumption fails identically at every chunk size.

Reproduction

Probing Converse directly with global.anthropic.claude-opus-5:

stopReason: end_turn
num content blocks: 2
  block[0] keys=['reasoningContent']
  block[1] keys=['text']
      text[:80]='{"nodes":[{"id":"a","label":"A"}],"edges":[],"hyperedges":[]}'

what content[0].get("text","{}") yields:
'{}'

stopReason is end_turn — the model completed normally. The truncation diagnosis is an artifact of reading the wrong block.

Extraction log for the same corpus (48 documents), before:

[graphify] bedrock returned a hollow response; treating as truncation so adaptive retry can bisect the chunk.
[graphify] chunk of 29 truncated at depth 0, splitting into halves of 14 and 15
[graphify] chunk of 4 still truncated at recursion depth 3 (max 3) — partial result kept (not cached as complete)
[graphify] WARNING: 17/48 dispatched file(s) produced no nodes and are absent from the graph

A distinguishing detail: raising GRAPHIFY_MAX_OUTPUT_TOKENS changes nothing, because output length was never the constraint.

Suggested fix

Select the first content block that actually carries non-empty text rather than assuming its position, at both call sites. This keys on the block's shape, not on a model name, so it holds for any model and for block types added to Converse later. A response whose first block is already text — every non-reasoning model today — is unaffected.

Result after the change

Same corpus and command, only the block selection changed:

before after
hollow response lines on essentially every call none
bisection to recursion depth 3 (max 3) repeatedly none
files reported as producing no nodes 17 of 48 none
output tokens 217,538 53,274
estimated cost $4.09 $1.44
graph 2,080 nodes / 4,516 edges 2,126 nodes / 4,580 edges

The cost and output-token drop is the wasted bisection disappearing. Document-type nodes rose from 43 to 66 and concept nodes from 98 to 130, so the additional coverage is real rather than a re-labelling.

Not a duplicate

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions