Skip to content

Commit d020315

Browse files
xingyaowwopenhands-agentenyst
authored
feat: Expose agent final response via REST API endpoint (#2690)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
1 parent 1825e67 commit d020315

File tree

6 files changed

+363
-0
lines changed

6 files changed

+363
-0
lines changed

openhands-agent-server/AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ This package lives in the monorepo root. Typical commands (run from repo root):
1414
When adding non-Python files (JS, templates, etc.) loaded at runtime, add them to `openhands-agent-server/openhands/agent_server/agent-server.spec` using `collect_data_files`.
1515

1616

17+
## Live server integration tests
18+
19+
Small endpoint additions or changes to server behaviour should be covered by a
20+
test in `tests/cross/test_remote_conversation_live_server.py`. These tests spin
21+
up a real FastAPI server with a patched LLM and exercise the full HTTP / WebSocket
22+
stack end-to-end. Add or extend a test there whenever the change is localised
23+
enough that a single new test function (or a few assertions added to an existing
24+
test) captures the expected behaviour.
25+
26+
1727
## Concurrency / async safety
1828

1929
- `ConversationState` uses a synchronous `FIFOLock`. In async agent-server code, never do `with conversation._state` directly on the event loop when the conversation may be running.

openhands-agent-server/openhands/agent_server/conversation_router.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from openhands.agent_server.dependencies import get_conversation_service
1414
from openhands.agent_server.models import (
15+
AgentResponseResult,
1516
AskAgentRequest,
1617
AskAgentResponse,
1718
ConversationInfo,
@@ -113,6 +114,27 @@ async def get_conversation(
113114
return conversation
114115

115116

117+
@conversation_router.get(
118+
"/{conversation_id}/agent_final_response",
119+
responses={404: {"description": "Conversation not found"}},
120+
)
121+
async def get_conversation_agent_final_response(
122+
conversation_id: UUID,
123+
conversation_service: ConversationService = Depends(get_conversation_service),
124+
) -> AgentResponseResult:
125+
"""Get the agent's final response for a conversation.
126+
127+
Returns the text of the last agent finish message (FinishAction) or
128+
the last agent text response (MessageEvent). Returns an empty string
129+
if the agent has not produced a final response yet.
130+
"""
131+
event_service = await conversation_service.get_event_service(conversation_id)
132+
if event_service is None:
133+
raise HTTPException(status.HTTP_404_NOT_FOUND)
134+
response = await event_service.get_agent_final_response()
135+
return AgentResponseResult(response=response)
136+
137+
116138
@conversation_router.get("")
117139
async def batch_get_conversations(
118140
ids: Annotated[list[UUID], Query()],

openhands-agent-server/openhands/agent_server/event_service.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from openhands.agent_server.pub_sub import PubSub, Subscriber
1414
from openhands.sdk import LLM, AgentBase, Event, Message, get_logger
1515
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
16+
from openhands.sdk.conversation.response_utils import get_agent_final_response
1617
from openhands.sdk.conversation.secret_registry import SecretValue
1718
from openhands.sdk.conversation.state import (
1819
ConversationExecutionStatus,
@@ -705,6 +706,28 @@ async def condense(self) -> None:
705706
loop = asyncio.get_running_loop()
706707
return await loop.run_in_executor(None, self._conversation.condense)
707708

709+
def _get_agent_final_response_sync(self) -> str:
710+
"""Extract the agent's final response from the conversation events.
711+
712+
Reads directly from the EventLog without acquiring the state lock.
713+
EventLog reads are safe without the FIFOLock because events are
714+
append-only and immutable once written.
715+
"""
716+
if not self._conversation:
717+
raise ValueError("inactive_service")
718+
return get_agent_final_response(self._conversation._state.events)
719+
720+
async def get_agent_final_response(self) -> str:
721+
"""Extract the agent's final response from the conversation events.
722+
723+
Returns the text from the last FinishAction or agent MessageEvent,
724+
or empty string if no final response is found.
725+
"""
726+
if not self._conversation:
727+
raise ValueError("inactive_service")
728+
loop = asyncio.get_running_loop()
729+
return await loop.run_in_executor(None, self._get_agent_final_response_sync)
730+
708731
async def get_state(self) -> ConversationState:
709732
if not self._conversation:
710733
raise ValueError("inactive_service")

openhands-agent-server/openhands/agent_server/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,22 @@ class AskAgentResponse(BaseModel):
459459
response: str = Field(description="The agent's response to the question")
460460

461461

462+
class AgentResponseResult(BaseModel):
463+
"""The agent's final response for a conversation.
464+
465+
Contains the text of the last agent finish message or text response.
466+
Empty string if the agent has not produced a final response yet.
467+
"""
468+
469+
response: str = Field(
470+
description=(
471+
"The agent's final response text. Extracted from either a "
472+
"FinishAction message or the last agent MessageEvent. "
473+
"Empty string if no final response is available."
474+
)
475+
)
476+
477+
462478
class BashEventBase(DiscriminatedUnionMixin, ABC):
463479
"""Base class for all bash event types"""
464480

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Tests for the GET /conversations/{id}/agent_final_response endpoint."""
2+
3+
from pathlib import Path
4+
from unittest.mock import AsyncMock, MagicMock
5+
from uuid import uuid4
6+
7+
import pytest
8+
from fastapi import FastAPI
9+
from fastapi.testclient import TestClient
10+
11+
from openhands.agent_server.conversation_router import conversation_router
12+
from openhands.agent_server.conversation_service import ConversationService
13+
from openhands.agent_server.dependencies import get_conversation_service
14+
from openhands.agent_server.event_service import EventService
15+
from openhands.sdk import Message
16+
from openhands.sdk.event import ActionEvent, MessageEvent
17+
from openhands.sdk.llm import MessageToolCall, TextContent
18+
from openhands.sdk.tool.builtins.finish import FinishAction
19+
20+
21+
@pytest.fixture
22+
def client():
23+
app = FastAPI()
24+
app.include_router(conversation_router, prefix="/api")
25+
return TestClient(app)
26+
27+
28+
@pytest.fixture
29+
def sample_conversation_id():
30+
return uuid4()
31+
32+
33+
@pytest.fixture
34+
def mock_conversation_service():
35+
return AsyncMock(spec=ConversationService)
36+
37+
38+
@pytest.fixture
39+
def mock_event_service():
40+
return AsyncMock(spec=EventService)
41+
42+
43+
def test_get_response_with_finish_action(
44+
client, mock_conversation_service, mock_event_service, sample_conversation_id
45+
):
46+
"""Endpoint returns FinishAction message text."""
47+
mock_conversation_service.get_event_service.return_value = mock_event_service
48+
mock_event_service.get_agent_final_response.return_value = (
49+
"Task completed successfully!"
50+
)
51+
52+
client.app.dependency_overrides[get_conversation_service] = (
53+
lambda: mock_conversation_service
54+
)
55+
56+
try:
57+
response = client.get(
58+
f"/api/conversations/{sample_conversation_id}/agent_final_response"
59+
)
60+
61+
assert response.status_code == 200
62+
data = response.json()
63+
assert data["response"] == "Task completed successfully!"
64+
mock_conversation_service.get_event_service.assert_called_once_with(
65+
sample_conversation_id
66+
)
67+
mock_event_service.get_agent_final_response.assert_called_once()
68+
finally:
69+
client.app.dependency_overrides.clear()
70+
71+
72+
def test_get_response_empty_when_no_agent_events(
73+
client, mock_conversation_service, mock_event_service, sample_conversation_id
74+
):
75+
"""Endpoint returns empty string when no agent response exists."""
76+
mock_conversation_service.get_event_service.return_value = mock_event_service
77+
mock_event_service.get_agent_final_response.return_value = ""
78+
79+
client.app.dependency_overrides[get_conversation_service] = (
80+
lambda: mock_conversation_service
81+
)
82+
83+
try:
84+
response = client.get(
85+
f"/api/conversations/{sample_conversation_id}/agent_final_response"
86+
)
87+
88+
assert response.status_code == 200
89+
data = response.json()
90+
assert data["response"] == ""
91+
finally:
92+
client.app.dependency_overrides.clear()
93+
94+
95+
def test_get_response_conversation_not_found(
96+
client, mock_conversation_service, sample_conversation_id
97+
):
98+
"""Endpoint returns 404 when conversation does not exist."""
99+
mock_conversation_service.get_event_service.return_value = None
100+
101+
client.app.dependency_overrides[get_conversation_service] = (
102+
lambda: mock_conversation_service
103+
)
104+
105+
try:
106+
response = client.get(
107+
f"/api/conversations/{sample_conversation_id}/agent_final_response"
108+
)
109+
assert response.status_code == 404
110+
finally:
111+
client.app.dependency_overrides.clear()
112+
113+
114+
def test_event_service_get_agent_final_response_with_finish():
115+
"""EventService delegates to get_agent_final_response from SDK."""
116+
event_service = EventService(stored=MagicMock(), conversations_dir=Path("test_dir"))
117+
118+
finish_action = FinishAction(message="Done!")
119+
tool_call = MessageToolCall(
120+
id="tc1", name="finish", arguments="{}", origin="completion"
121+
)
122+
action_event = ActionEvent(
123+
source="agent",
124+
thought=[TextContent(text="Finishing")],
125+
action=finish_action,
126+
tool_name="finish",
127+
tool_call_id="tc1",
128+
tool_call=tool_call,
129+
llm_response_id="resp1",
130+
)
131+
132+
conversation = MagicMock()
133+
state = MagicMock()
134+
state.events = [action_event]
135+
conversation._state = state
136+
event_service._conversation = conversation
137+
138+
result = event_service._get_agent_final_response_sync()
139+
assert result == "Done!"
140+
141+
142+
def test_event_service_get_agent_final_response_with_message():
143+
"""EventService returns MessageEvent text when no FinishAction."""
144+
event_service = EventService(stored=MagicMock(), conversations_dir=Path("test_dir"))
145+
146+
message_event = MessageEvent(
147+
source="agent",
148+
llm_message=Message(
149+
role="assistant",
150+
content=[TextContent(text="Here is my answer")],
151+
),
152+
)
153+
154+
conversation = MagicMock()
155+
state = MagicMock()
156+
state.events = [message_event]
157+
conversation._state = state
158+
event_service._conversation = conversation
159+
160+
result = event_service._get_agent_final_response_sync()
161+
assert result == "Here is my answer"
162+
163+
164+
def test_event_service_get_agent_final_response_empty():
165+
"""EventService returns empty string with no agent events."""
166+
event_service = EventService(stored=MagicMock(), conversations_dir=Path("test_dir"))
167+
168+
conversation = MagicMock()
169+
state = MagicMock()
170+
state.events = []
171+
conversation._state = state
172+
event_service._conversation = conversation
173+
174+
result = event_service._get_agent_final_response_sync()
175+
assert result == ""
176+
177+
178+
def test_event_service_get_agent_final_response_inactive():
179+
"""EventService raises ValueError when service is inactive."""
180+
event_service = EventService(stored=MagicMock(), conversations_dir=Path("test_dir"))
181+
182+
with pytest.raises(ValueError, match="inactive_service"):
183+
event_service._get_agent_final_response_sync()

0 commit comments

Comments
 (0)