Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions examples/repro_reject_anthropic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import os
from pydantic import SecretStr

from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool


def main():
api_key = os.environ.get("LITELLM_API_KEY")
if not api_key:
raise SystemExit("LITELLM_API_KEY not set")

llm = LLM(
model="litellm_proxy/anthropic/claude-sonnet-4-5-20250929",
base_url="https://llm-proxy.eval.all-hands.dev",
api_key=SecretStr(api_key),
usage_id="repro-reject-anthropic",
)

agent = Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
)

conv = Conversation(agent=agent, workspace=os.getcwd())
conv.set_confirmation_policy(AlwaysConfirm())

# Intentionally trigger a file edit to get a tool_use
conv.send_message("Create a file TEST_REPRO.txt with content 'hello world'")
try:
conv.run()
except Exception as e:
print("First run error:", e)
raise

# Reject pending actions
conv.reject_pending_actions("Please explain first")

# Run again to send the tool_result (rejection) immediately after tool_use
try:
conv.run()
print("Second run completed without Anthropic tool_result error.")
except Exception as e:
print("Second run error:", e)
raise


if __name__ == "__main__":
main()
52 changes: 52 additions & 0 deletions tests/sdk/event/test_user_reject_tool_result_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from openhands.sdk.event import ActionEvent, MessageEvent
from openhands.sdk.event.base import LLMConvertibleEvent
from openhands.sdk.event.llm_convertible import UserRejectObservation
from openhands.sdk.llm import Message, MessageToolCall, TextContent


def test_reject_emits_tool_result_immediately_after_tool_use():
# 1) Simulate a user message that led to a tool call
user_msg = MessageEvent(
source="user",
llm_message=Message(role="user", content=[TextContent(text="do something")]),
)

# 2) Simulate assistant tool_use (ActionEvent)
tool_call = MessageToolCall(
id="toolu_01ABC",
name="str_replace_editor",
arguments="{}",
origin="completion",
)
action = ActionEvent(
source="agent",
thought=[TextContent(text="Let me do that")],
tool_name="str_replace_editor",
tool_call_id=tool_call.id,
tool_call=tool_call,
llm_response_id="resp_1",
action=None, # not executed due to confirmation mode
)

# 3) Simulate a user rejection of that action
reject = UserRejectObservation(
source="environment",
action_id=action.id,
tool_name=action.tool_name,
tool_call_id=action.tool_call_id,
rejection_reason="Please explain first",
)

# Convert events to LLM messages using the core utility
messages = LLMConvertibleEvent.events_to_messages([user_msg, action, reject])

# Assert shape: assistant tool_use followed immediately by tool_result
# messages[0] is user
assert messages[1].role == "assistant"
assert messages[1].tool_calls and len(messages[1].tool_calls) == 1
assert messages[1].tool_calls[0].id == tool_call.id

assert messages[2].role == "tool"
assert messages[2].tool_call_id == tool_call.id
# Ensure serializer is forced to string to satisfy strict providers
assert getattr(messages[2], "force_string_serializer", False) is True
Loading