Skip to content

[Bug]: Reopening a confirmation-paused conversation drops the assistant tool_use message from the next request #4532

Description

@shawnwahi

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:highFor bugs, affecting nearly all users and degrading performance or UX.sdksession

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions