fix(llm): record Responses chain state only for completed responses

The non-streaming path recorded the response id, reasoning items and the
staged system-head hash before checking the terminal status, so a
response that came back failed could become the id the next call chains
onto and commit a head the stored transcript never received. Chain state
is now recorded after the status checks; a response cut off by
max_output_tokens still records it, since its input was accepted.
This commit is contained in:
Alex committed 2026-09-05 11:07:07 +01:00
1 parent dbee30a048
commit 5837c300df
2 files changed
+57 -2

No files matched your search

+9 -2
View File
@@ -1551,11 +1551,13 @@ class OpenAILLM(BaseLLM):
"OpenAI Responses request completed id=%s",
getattr(response, "id", None),
)
self._record_responses_metadata(response)
content, tool_calls, reasoning_items = self._parse_responses_output(
response
)
self._last_reasoning_items = reasoning_items
# Chain state (response id, head hash, reasoning items) is recorded
# only for a response the provider actually completed — a failed one
# must not become the id the next call chains onto, nor commit a head
# the stored transcript never received.
details = getattr(response, "incomplete_details", None)
incomplete_reason = getattr(details, "reason", None)
if getattr(response, "status", None) == "incomplete":
@@ -1564,6 +1566,9 @@ class OpenAILLM(BaseLLM):
self._responses_status_error(response)
or "Responses API incomplete: unknown reason"
)
# Cut off by the output cap: the input was accepted and stored.
self._record_responses_metadata(response)
self._last_reasoning_items = reasoning_items
self._last_finish_reason = "length"
if tools:
return _RespChoice(
@@ -1574,6 +1579,8 @@ class OpenAILLM(BaseLLM):
status_error = self._responses_status_error(response)
if status_error:
raise RuntimeError(status_error)
self._record_responses_metadata(response)
self._last_reasoning_items = reasoning_items
if tools:
self._remember_reasoning(tool_calls, reasoning_items)
message = _RespMessage(
+48
View File
@@ -354,3 +354,51 @@ def test_failed_chained_request_resends_changed_head_on_retry(monkeypatch):
_accepted(llm, "resp_2")
after, _ = llm._build_responses_input(_messages("sys v2"), "resp_2")
assert _roles(after) == ["user"]
# ── a failed non-streaming response must not become chain state ─────────────
@pytest.mark.unit
def test_failed_response_records_no_chain_state(monkeypatch):
llm = _make_llm(monkeypatch)
# An accepted turn with head "sys v1".
llm._build_responses_input(_messages("sys v1"), None)
_accepted(llm, "resp_ok")
committed = llm._chain_system_hash
failed = types.SimpleNamespace(
id="resp_bad", status="failed", error=types.SimpleNamespace(message="boom"),
incomplete_details=None, output=[], usage=None,
)
llm.client.responses.create = MagicMock(return_value=failed)
with pytest.raises(RuntimeError):
llm._responses_gen(
"gpt-5.6", _messages("sys v2"), tools=None, previous_response_id="resp_ok"
)
# Neither the failed id nor the head it carried became chain state
# (the in-turn id is cleared before every request; the retry chains via
# the caller's previous_response_id)...
assert llm._last_response_id != "resp_bad"
assert llm._chain_system_hash == committed
# ...so the retry chains onto resp_ok and re-sends the changed head.
retry, prev = llm._build_responses_input(_messages("sys v2"), "resp_ok")
assert prev == "resp_ok"
assert _roles(retry) == ["system", "user"]
@pytest.mark.unit
def test_output_capped_response_still_records_chain_state(monkeypatch):
llm = _make_llm(monkeypatch)
capped = types.SimpleNamespace(
id="resp_len", status="incomplete",
incomplete_details=types.SimpleNamespace(reason="max_output_tokens"),
output=[types.SimpleNamespace(type="message", content=[
types.SimpleNamespace(type="output_text", text="partial")])],
usage=None, error=None,
)
llm.client.responses.create = MagicMock(return_value=capped)
assert llm._responses_gen("gpt-5.6", _messages(), tools=None) == "partial"
assert llm._last_response_id == "resp_len"
assert llm._chain_system_hash is not None