Skip to content

Commit e861a97

Browse files
malhotra5openhands-agentsimonrosenbergxingyaoww
authored
Add ask_agent method to conversation classes for simple LLM completions (#1227)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com> Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
1 parent a527525 commit e861a97

File tree

16 files changed

+1496
-40
lines changed

16 files changed

+1496
-40
lines changed

.github/workflows/check-documented-examples.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,24 @@ jobs:
2525
with:
2626
fetch-depth: 0
2727

28-
- name: Checkout docs repository
28+
- name: Checkout docs repository (try feature branch)
2929
uses: actions/checkout@v5
30+
continue-on-error: true
31+
id: checkout-feature
3032
with:
3133
repository: OpenHands/docs
3234
path: docs
3335
fetch-depth: 0
36+
ref: ${{ github.head_ref || github.ref_name }}
37+
38+
- name: Checkout docs repository (fallback to main)
39+
if: steps.checkout-feature.outcome == 'failure'
40+
uses: actions/checkout@v5
41+
with:
42+
repository: OpenHands/docs
43+
path: docs
44+
fetch-depth: 0
45+
ref: main
3446

3547
- name: Set up Python
3648
uses: actions/setup-python@v6
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Example demonstrating the ask_agent functionality for getting sidebar replies
3+
from the agent for a running conversation.
4+
5+
This example shows how to use ask_agent() to get quick responses from the agent
6+
about the current conversation state without interrupting the main execution flow.
7+
"""
8+
9+
import os
10+
import threading
11+
import time
12+
from datetime import datetime
13+
14+
from pydantic import SecretStr
15+
16+
from openhands.sdk import (
17+
LLM,
18+
Agent,
19+
Conversation,
20+
)
21+
from openhands.sdk.conversation import ConversationVisualizerBase
22+
from openhands.sdk.event import Event
23+
from openhands.sdk.tool import Tool
24+
from openhands.tools.file_editor import FileEditorTool
25+
from openhands.tools.task_tracker import TaskTrackerTool
26+
from openhands.tools.terminal import TerminalTool
27+
28+
29+
# Configure LLM
30+
api_key = os.getenv("LLM_API_KEY")
31+
assert api_key is not None, "LLM_API_KEY environment variable is not set."
32+
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
33+
base_url = os.getenv("LLM_BASE_URL")
34+
llm = LLM(
35+
usage_id="agent",
36+
model=model,
37+
base_url=base_url,
38+
api_key=SecretStr(api_key),
39+
)
40+
41+
# Tools
42+
cwd = os.getcwd()
43+
tools = [
44+
Tool(name=TerminalTool.name),
45+
Tool(name=FileEditorTool.name),
46+
Tool(name=TaskTrackerTool.name),
47+
]
48+
49+
50+
class MinimalVisualizer(ConversationVisualizerBase):
51+
"""A minimal visualizer that print the raw events as they occur."""
52+
53+
count = 0
54+
55+
def on_event(self, event: Event) -> None:
56+
"""Handle events for minimal progress visualization."""
57+
print(f"\n\n[EVENT {self.count}] {type(event).__name__}")
58+
self.count += 1
59+
60+
61+
# Agent
62+
agent = Agent(llm=llm, tools=tools)
63+
conversation = Conversation(
64+
agent=agent, workspace=cwd, visualizer=MinimalVisualizer, max_iteration_per_run=5
65+
)
66+
67+
68+
def timestamp() -> str:
69+
return datetime.now().strftime("%H:%M:%S")
70+
71+
72+
print("=== Ask Agent Example ===")
73+
print("This example demonstrates asking questions during conversation execution")
74+
75+
# Step 1: Build conversation context
76+
print(f"\n[{timestamp()}] Building conversation context...")
77+
conversation.send_message("Explore the current directory and describe the architecture")
78+
79+
# Step 2: Start conversation in background thread
80+
print(f"[{timestamp()}] Starting conversation in background thread...")
81+
thread = threading.Thread(target=conversation.run)
82+
thread.start()
83+
84+
# Give the agent time to start processing
85+
time.sleep(2)
86+
87+
# Step 3: Use ask_agent while conversation is running
88+
print(f"\n[{timestamp()}] Using ask_agent while conversation is processing...")
89+
90+
# Ask context-aware questions
91+
questions_and_responses = []
92+
93+
question_1 = "Summarize the activity so far in 1 sentence."
94+
print(f"\n[{timestamp()}] Asking: {question_1}")
95+
response1 = conversation.ask_agent(question_1)
96+
questions_and_responses.append((question_1, response1))
97+
print(f"Response: {response1}")
98+
99+
time.sleep(1)
100+
101+
question_2 = "How's the progress?"
102+
print(f"\n[{timestamp()}] Asking: {question_2}")
103+
response2 = conversation.ask_agent(question_2)
104+
questions_and_responses.append((question_2, response2))
105+
print(f"Response: {response2}")
106+
107+
time.sleep(1)
108+
109+
question_3 = "Have you finished running?"
110+
print(f"\n[{timestamp()}] {question_3}")
111+
response3 = conversation.ask_agent(question_3)
112+
questions_and_responses.append((question_3, response3))
113+
print(f"Response: {response3}")
114+
115+
# Step 4: Wait for conversation to complete
116+
print(f"\n[{timestamp()}] Waiting for conversation to complete...")
117+
thread.join()
118+
119+
# Step 5: Verify conversation state wasn't affected
120+
final_event_count = len(conversation.state.events)
121+
# Step 6: Ask a final question after conversation completion
122+
print(f"\n[{timestamp()}] Asking final question after completion...")
123+
final_response = conversation.ask_agent(
124+
"Can you summarize what you accomplished in this conversation?"
125+
)
126+
print(f"Final response: {final_response}")
127+
128+
# Step 7: Summary
129+
print("\n" + "=" * 60)
130+
print("SUMMARY OF ASK_AGENT DEMONSTRATION")
131+
print("=" * 60)
132+
133+
print("\nQuestions and Responses:")
134+
for i, (question, response) in enumerate(questions_and_responses, 1):
135+
print(f"\n{i}. Q: {question}")
136+
print(f" A: {response[:100]}{'...' if len(response) > 100 else ''}")
137+
138+
final_truncated = final_response[:100] + ("..." if len(final_response) > 100 else "")
139+
print(f"\nFinal Question Response: {final_truncated}")
140+
141+
# Report cost
142+
cost = llm.metrics.accumulated_cost
143+
print(f"EXAMPLE_COST: {cost:.4f}")

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from openhands.agent_server.conversation_service import ConversationService
1010
from openhands.agent_server.dependencies import get_conversation_service
1111
from openhands.agent_server.models import (
12+
AskAgentRequest,
13+
AskAgentResponse,
1214
ConversationInfo,
1315
ConversationPage,
1416
ConversationSortOrder,
@@ -289,3 +291,19 @@ async def generate_conversation_title(
289291
if title is None:
290292
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
291293
return GenerateTitleResponse(title=title)
294+
295+
296+
@conversation_router.post(
297+
"/{conversation_id}/ask_agent",
298+
responses={404: {"description": "Item not found"}},
299+
)
300+
async def ask_agent(
301+
conversation_id: UUID,
302+
request: AskAgentRequest,
303+
conversation_service: ConversationService = Depends(get_conversation_service),
304+
) -> AskAgentResponse:
305+
"""Ask the agent a simple question without affecting conversation state."""
306+
response = await conversation_service.ask_agent(conversation_id, request.question)
307+
if response is None:
308+
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
309+
return AskAgentResponse(response=response)

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,18 @@ async def generate_conversation_title(
320320
title = await event_service.generate_title(llm=llm, max_length=max_length)
321321
return title
322322

323+
async def ask_agent(self, conversation_id: UUID, question: str) -> str | None:
324+
"""Ask the agent a simple question without affecting conversation state."""
325+
if self._event_services is None:
326+
raise ValueError("inactive_service")
327+
event_service = self._event_services.get(conversation_id)
328+
if event_service is None:
329+
return None
330+
331+
# Delegate to EventService to avoid accessing private conversation internals
332+
response = await event_service.ask_agent(question)
333+
return response
334+
323335
async def __aenter__(self):
324336
self.conversations_dir.mkdir(parents=True, exist_ok=True)
325337
self._event_services = {}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,17 @@ async def generate_title(
463463
None, self._conversation.generate_title, resolved_llm, max_length
464464
)
465465

466+
async def ask_agent(self, question: str) -> str:
467+
"""Ask the agent a simple question without affecting conversation state.
468+
469+
Delegates to LocalConversation in an executor to avoid blocking the event loop.
470+
"""
471+
if not self._conversation:
472+
raise ValueError("inactive_service")
473+
474+
loop = asyncio.get_running_loop()
475+
return await loop.run_in_executor(None, self._conversation.ask_agent, question)
476+
466477
async def get_state(self) -> ConversationState:
467478
if not self._conversation:
468479
raise ValueError("inactive_service")

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ class GenerateTitleResponse(BaseModel):
199199
title: str = Field(description="The generated title for the conversation")
200200

201201

202+
class AskAgentRequest(BaseModel):
203+
"""Payload to ask the agent a simple question."""
204+
205+
question: str = Field(description="The question to ask the agent")
206+
207+
208+
class AskAgentResponse(BaseModel):
209+
"""Response containing the agent's answer."""
210+
211+
response: str = Field(description="The agent's response to the question")
212+
213+
202214
class BashEventBase(DiscriminatedUnionMixin, ABC):
203215
"""Base class for all bash event types"""
204216

openhands-sdk/openhands/sdk/agent/agent.py

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
import openhands.sdk.security.analyzer as analyzer
66
import openhands.sdk.security.risk as risk
77
from openhands.sdk.agent.base import AgentBase
8-
from openhands.sdk.agent.utils import fix_malformed_tool_arguments
9-
from openhands.sdk.context.view import View
8+
from openhands.sdk.agent.utils import (
9+
fix_malformed_tool_arguments,
10+
make_llm_completion,
11+
prepare_llm_messages,
12+
)
1013
from openhands.sdk.conversation import (
1114
ConversationCallbackType,
1215
ConversationState,
@@ -145,49 +148,27 @@ def step(
145148
self._execute_actions(conversation, pending_actions, on_event)
146149
return
147150

148-
# If a condenser is registered with the agent, we need to give it an
149-
# opportunity to transform the events. This will either produce a list
150-
# of events, exactly as expected, or a new condensation that needs to be
151-
# processed before the agent can sample another action.
152-
if self.condenser is not None:
153-
view = View.from_events(state.events)
154-
condensation_result = self.condenser.condense(view)
151+
# Prepare LLM messages using the utility function
152+
_messages_or_condensation = prepare_llm_messages(
153+
state.events, condenser=self.condenser
154+
)
155155

156-
match condensation_result:
157-
case View():
158-
llm_convertible_events = condensation_result.events
156+
# Process condensation event before agent sampels another action
157+
if isinstance(_messages_or_condensation, Condensation):
158+
on_event(_messages_or_condensation)
159+
return
159160

160-
case Condensation():
161-
on_event(condensation_result)
162-
return None
161+
_messages = _messages_or_condensation
163162

164-
else:
165-
llm_convertible_events = [
166-
e for e in state.events if isinstance(e, LLMConvertibleEvent)
167-
]
168-
169-
# Get LLM Response (Action)
170-
_messages = LLMConvertibleEvent.events_to_messages(llm_convertible_events)
171163
logger.debug(
172164
"Sending messages to LLM: "
173165
f"{json.dumps([m.model_dump() for m in _messages[1:]], indent=2)}"
174166
)
175167

176168
try:
177-
if self.llm.uses_responses_api():
178-
llm_response = self.llm.responses(
179-
messages=_messages,
180-
tools=list(self.tools_map.values()),
181-
include=None,
182-
store=False,
183-
add_security_risk_prediction=True,
184-
)
185-
else:
186-
llm_response = self.llm.completion(
187-
messages=_messages,
188-
tools=list(self.tools_map.values()),
189-
add_security_risk_prediction=True,
190-
)
169+
llm_response = make_llm_completion(
170+
self.llm, _messages, tools=list(self.tools_map.values())
171+
)
191172
except FunctionCallValidationError as e:
192173
logger.warning(f"LLM generated malformed function call: {e}")
193174
error_message = MessageEvent(

openhands-sdk/openhands/sdk/agent/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from openhands.sdk.conversation import ConversationState, LocalConversation
2727
from openhands.sdk.conversation.types import ConversationCallbackType
2828

29+
2930
logger = get_logger(__name__)
3031

3132

0 commit comments

Comments
 (0)