Skip to content
7 changes: 7 additions & 0 deletions openhands-sdk/openhands/sdk/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,13 @@ def step(
# Emit VLLM token ids if enabled
self._maybe_emit_vllm_tokens(llm_response, on_event)

# If the model produced user-facing content (no tool calls), yield control
# back to the user. Setting IDLE avoids continuing the loop and hitting
# stuck detection due to repeated assistant messages.
if has_content:
state.execution_status = ConversationExecutionStatus.IDLE
return

def _requires_user_confirmation(
self, state: ConversationState, action_events: list[ActionEvent]
) -> bool:
Expand Down
59 changes: 44 additions & 15 deletions openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ def send_message(self, message: str | Message, sender: str | None = None) -> Non
self._state.execution_status = (
ConversationExecutionStatus.IDLE
) # now we have a new message
# Signal that a new user message arrived during or between steps
self._state._new_user_message = True

# TODO: We should add test cases for all these scenarios
activated_skill_names: list[str] = []
Expand Down Expand Up @@ -275,27 +277,53 @@ def run(self) -> None:
"""

with self._state:
if self._state.execution_status in [
promote = self._state.execution_status in [
ConversationExecutionStatus.IDLE,
ConversationExecutionStatus.PAUSED,
ConversationExecutionStatus.ERROR,
]:
]
# Do not promote IDLE->RUNNING when no new user message is present;
# allow the run loop to proceed and exit via the normal lifecycle.
if (
self._state.execution_status == ConversationExecutionStatus.IDLE
and not self._state._new_user_message
):
promote = False
if promote:
self._state.execution_status = ConversationExecutionStatus.RUNNING

iteration = 0
try:
while True:
logger.debug(f"Conversation run iteration {iteration}")
with self._state:
# Pause attempts to acquire the state lock
# Before value can be modified step can be taken
# Ensure step conditions are checked when lock is already acquired
if self._state.execution_status in [
ConversationExecutionStatus.FINISHED,
# Early status handling before deciding to step again
status = self._state.execution_status
# Hard terminal statuses
if status in [
ConversationExecutionStatus.PAUSED,
ConversationExecutionStatus.STUCK,
]:
break
# If FINISHED or IDLE, continue only if a new user message arrived
if status in [
ConversationExecutionStatus.FINISHED,
ConversationExecutionStatus.IDLE,
]:
if self._state._new_user_message:
# Allow one more iteration to process the new message
self._state.execution_status = (
ConversationExecutionStatus.RUNNING
)
else:
break

# Clear the new-user-message flag just before stepping;
# any message arriving during this step will set it again.
self._state._new_user_message = False
# Pause attempts to acquire the state lock
# Before value can be modified step can be taken
# Ensure step conditions are checked when lock is already acquired

# Check for stuck patterns if enabled
if self._stuck_detector:
Expand All @@ -322,20 +350,21 @@ def run(self) -> None:
)
iteration += 1

# Check for non-finished terminal conditions
# Note: We intentionally do NOT check for FINISHED status here.
# This allows concurrent user messages to be processed:
# 1. Agent finishes and sets status to FINISHED
# 2. User sends message concurrently via send_message()
# 3. send_message() waits for FIFO lock, then sets status to IDLE
# 4. Run loop continues to next iteration and processes the message
# 5. Without this design, concurrent messages would be lost
# Check for terminal conditions. We still do NOT break on FINISHED
# to allow a concurrent user message to be processed on next loop.
# For IDLE (content-only reply), break only if no new user message
# arrived during this iteration.
if (
self.state.execution_status
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
or iteration >= self.max_iteration_per_run
):
break
if (
self.state.execution_status == ConversationExecutionStatus.IDLE
and not self._state._new_user_message
):
break
except Exception as e:
self._state.execution_status = ConversationExecutionStatus.ERROR

Expand Down
1 change: 1 addition & 0 deletions openhands-sdk/openhands/sdk/conversation/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class ConversationState(OpenHandsModel):
_lock: FIFOLock = PrivateAttr(
default_factory=FIFOLock
) # FIFO lock for thread safety
_new_user_message: bool = PrivateAttr(default=False)

# ===== Public "events" facade (Sequence[Event]) =====
@property
Expand Down
4 changes: 2 additions & 2 deletions tests/sdk/agent/test_reasoning_only_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ def test_agent_finishes_after_content_only_response():
conversation.send_message("Analyze this")
conversation.run()

# Verify agent was called once - content responses finish immediately
# Verify agent was called once - content-only responses yield to user
assert llm._call_count == 1
assert conversation.state.execution_status == ConversationExecutionStatus.FINISHED
assert conversation.state.execution_status == ConversationExecutionStatus.IDLE

# Verify the content message was emitted
msg_events = [
Expand Down
Loading
Loading