The repo has an explicit defensive convention — never index tool_calls[0] unguarded,
because forced tool_choice is not honored identically by the four providers the app offers
(langgraph-agent-convention,
model-selection-flow). Three of the four consumer
nodes follow it; fact_check_node — the newest, added by the verifiable-report epic — does
not:
All four unguarded sites read ai_message.tool_calls[0]["id"] off
state["messages"][-1] (fact_check.py:54) to stamp the resolving ToolMessage.
Honest reachability — why P2, not P1
fact_check_node is entered only from chat_node's router, which reaches it exclusively via
ai_message.tool_calls[0]["name"] == "FactCheckReport" and appends that same AIMessage to
state — so on the current graph the last message is structurally guaranteed to carry a tool
call, and . It is graded as convention drift plus a latent
footgun, not as a live break; the closed row was P1 precisely
because its second site sat on a , where a provider declining the
tool is an ordinary occurrence. Note already guards class correctly
at .
What makes it worth a row anyway: the guarantee is a property of chat.py's router, not of
this node, and nothing encodes it. Adding a second inbound edge to fact_check_node, resuming
from a checkpoint whose tail message differs, or copying this node as the template for the next
one (the brain warns to check the code, not the convention, because of exactly this file)
each turn a silent assumption into an IndexError that aborts the whole run. The fix is one
guard and a local variable, and it makes the node self-contained.
Mirror consideration: the TypeScript port has not yet ported fact_check_node at all
(agent-typescript-parity) — fixing Python first
means the port inherits the guarded shape rather than the drifted one.