Summary
If a conversation pauses for confirmation and is then closed and resumed from
persistence before the pending action is approved, the next LLM request omits the
assistant message carrying the tool_use while still including its tool_result.
Providers reject that request. On Bedrock (Claude Sonnet 4.6):
litellm.BadRequestError: BedrockException - {"message":"The number of toolResult blocks
at messages.2.content exceeds the number of toolUse blocks of previous turn."}
Also reproduced against OpenAI (GPT-5.5), so it is not provider-specific.
The persisted event log is correct — one ActionEvent and one ObservationEvent sharing
a tool_call_id. Only the serialised request is wrong.
Why this shape matters
Confirmation exists so a human can decide. In any web application that decision arrives
in a separate HTTP request, so the paused conversation is closed at the end of the
first request and reopened from persistence_dir when the user approves. That is the
flow this bug breaks, and it appears to be the intended use of persisted conversations.
Everything works if the process happens to keep the same Conversation object alive.
Reproduction
openhands-sdk==1.42.1, no provider credentials (a TestLLM stands in and the script
inspects the messages the agent builds).
"""Reopening a confirmation-paused conversation drops the assistant tool_use
message from the next LLM request.
uv run python issue_repro.py # reopen -> malformed request
NO_REOPEN=1 uv run python issue_repro.py # no reopen -> fine
"""
from __future__ import annotations
import os
import tempfile
import uuid
from pathlib import Path
from openhands.sdk import Agent, Conversation
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.llm import Message, MessageToolCall, TextContent
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
from openhands.sdk.testing import TestLLM
from openhands.sdk.tool import (
Action, Observation, Tool, ToolAnnotations, ToolDefinition, ToolExecutor, register_tool,
)
from pydantic import Field
REOPEN = os.environ.get("NO_REOPEN") != "1"
class EchoAction(Action):
text: str = Field(default="hi", description="text to echo")
class EchoObservation(Observation):
@property
def agent_observation(self):
return [TextContent(text="echoed: hi")]
class EchoExecutor(ToolExecutor):
def __call__(self, action, conversation=None):
return EchoObservation()
class EchoTool(ToolDefinition[EchoAction, EchoObservation]):
@classmethod
def create(cls, conv_state=None, **params):
return [cls(
name="echo", description="Echo text back.",
action_type=EchoAction, observation_type=EchoObservation,
executor=EchoExecutor(),
annotations=ToolAnnotations(title="echo", readOnlyHint=True),
)]
SENT: list[list] = []
def main() -> None:
register_tool("echo", EchoTool)
llm = TestLLM.from_messages([
Message(role="assistant", content=[TextContent(text="running it")],
tool_calls=[MessageToolCall(id="call_1", name="echo",
arguments="{}", origin="completion")]),
Message(role="assistant", content=[TextContent(text="done")]),
], usage_id="repro")
original = type(llm).completion
def capture(self, messages, *a, **kw):
SENT.append(list(messages))
return original(self, messages, *a, **kw)
type(llm).completion = capture
def build(tmp, cid):
convo = Conversation(
agent=Agent(llm=llm, tools=[Tool(name="echo")]),
workspace=str(Path(tmp) / "ws"),
persistence_dir=str(Path(tmp) / "state"),
conversation_id=cid, visualizer=None, delete_on_close=False,
)
convo.set_confirmation_policy(AlwaysConfirm())
return convo
with tempfile.TemporaryDirectory() as tmp:
cid = uuid.uuid4()
convo = build(tmp, cid)
convo.send_message("please echo")
convo.run()
assert convo.state.execution_status == ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
if REOPEN:
convo.close()
convo = build(tmp, cid)
assert convo.state.execution_status == ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
convo.run() # approve
print(f"reopened between pause and approval: {REOPEN}")
for i, sent in enumerate(SENT):
shape = [
f"assistant(tool_calls={len(m.tool_calls or [])})" if m.role == "assistant"
else (f"tool[{m.tool_call_id}]" if m.role == "tool" else m.role)
for m in sent
]
print(f" LLM call {i}: {', '.join(shape)}")
last = SENT[-1]
for i, m in enumerate(last):
if m.role == "tool":
prev = last[i - 1] if i else None
if not (getattr(prev, "tool_calls", None) or []):
raise SystemExit(
f"BUG: messages[{i}] is a tool result, but the preceding "
f"'{prev.role if prev else None}' message has no tool_calls."
)
print("OK: every tool result follows a matching assistant tool_calls message.")
convo.close()
if __name__ == "__main__":
main()
Actual
$ uv run python issue_repro.py
reopened between pause and approval: True
LLM call 0: system, user
LLM call 1: system, user, tool[call_1]
BUG: messages[2] is a tool result, but the preceding 'user' message has no tool_calls.
Expected
$ NO_REOPEN=1 uv run python issue_repro.py
reopened between pause and approval: False
LLM call 0: system, user
LLM call 1: system, user, assistant(tool_calls=1), tool[call_1]
OK: every tool result follows a matching assistant tool_calls message.
The only difference between the two runs is close() + reconstruct with the same
conversation_id and persistence_dir while the conversation is parked at
WAITING_FOR_CONFIRMATION.
Where it seems to come from
While the conversation is paused there is an ActionEvent with no matching
ObservationEvent yet, and ToolCallMatchingProperty correctly drops that unmatched
action from the view:
INFO <class 'openhands.sdk.context.view.properties.tool_call_matching.ToolCallMatchingProperty'>
enforced, 1 events dropped.
That is logged on reopen. After approval the observation is created, but the request
built for the follow-up call contains the observation without the action — so the
exclusion appears to outlive the condition that justified it, rather than being
recomputed once the pair is complete.
BatchAtomicityProperty's docstring describes the invariant being violated here ("all
events from the same batch, sharing the same llm_response_id, form an atomic unit"),
which may be the better place to look.
Impact
Any confirmation-gated deployment that spans the approval across processes or requests
gets a correct tool execution followed by a failed turn: the tool result is produced and
stored, but the model can never comment on it. Approval works end-to-end only if the
Conversation object survives in memory between the pause and the approval.
Environment
openhands-sdk==1.42.1 (latest on PyPI at time of filing)
- Python 3.13, macOS (arm64)
- Reproduced with
TestLLM (above), and live against Bedrock claude-sonnet-4-6 and
OpenAI gpt-5.5
Summary
If a conversation pauses for confirmation and is then closed and resumed from
persistence before the pending action is approved, the next LLM request omits the
assistant message carrying the
tool_usewhile still including itstool_result.Providers reject that request. On Bedrock (Claude Sonnet 4.6):
Also reproduced against OpenAI (GPT-5.5), so it is not provider-specific.
The persisted event log is correct — one
ActionEventand oneObservationEventsharinga
tool_call_id. Only the serialised request is wrong.Why this shape matters
Confirmation exists so a human can decide. In any web application that decision arrives
in a separate HTTP request, so the paused conversation is closed at the end of the
first request and reopened from
persistence_dirwhen the user approves. That is theflow this bug breaks, and it appears to be the intended use of persisted conversations.
Everything works if the process happens to keep the same
Conversationobject alive.Reproduction
openhands-sdk==1.42.1, no provider credentials (aTestLLMstands in and the scriptinspects the messages the agent builds).
Actual
Expected
The only difference between the two runs is
close()+ reconstruct with the sameconversation_idandpersistence_dirwhile the conversation is parked atWAITING_FOR_CONFIRMATION.Where it seems to come from
While the conversation is paused there is an
ActionEventwith no matchingObservationEventyet, andToolCallMatchingPropertycorrectly drops that unmatchedaction from the view:
That is logged on reopen. After approval the observation is created, but the request
built for the follow-up call contains the observation without the action — so the
exclusion appears to outlive the condition that justified it, rather than being
recomputed once the pair is complete.
BatchAtomicityProperty's docstring describes the invariant being violated here ("allevents from the same batch, sharing the same
llm_response_id, form an atomic unit"),which may be the better place to look.
Impact
Any confirmation-gated deployment that spans the approval across processes or requests
gets a correct tool execution followed by a failed turn: the tool result is produced and
stored, but the model can never comment on it. Approval works end-to-end only if the
Conversationobject survives in memory between the pause and the approval.Environment
openhands-sdk==1.42.1(latest on PyPI at time of filing)TestLLM(above), and live against Bedrockclaude-sonnet-4-6andOpenAI
gpt-5.5