openhands 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands-1.3.0.dist-info/METADATA +56 -0
- openhands-1.3.0.dist-info/RECORD +43 -0
- openhands-1.3.0.dist-info/WHEEL +4 -0
- openhands-1.3.0.dist-info/entry_points.txt +3 -0
- openhands-1.3.0.dist-info/licenses/LICENSE +21 -0
- openhands_cli/__init__.py +9 -0
- openhands_cli/acp_impl/README.md +68 -0
- openhands_cli/acp_impl/__init__.py +1 -0
- openhands_cli/acp_impl/agent.py +483 -0
- openhands_cli/acp_impl/event.py +512 -0
- openhands_cli/acp_impl/main.py +21 -0
- openhands_cli/acp_impl/test_utils.py +174 -0
- openhands_cli/acp_impl/utils/__init__.py +14 -0
- openhands_cli/acp_impl/utils/convert.py +103 -0
- openhands_cli/acp_impl/utils/mcp.py +66 -0
- openhands_cli/acp_impl/utils/resources.py +189 -0
- openhands_cli/agent_chat.py +236 -0
- openhands_cli/argparsers/main_parser.py +78 -0
- openhands_cli/argparsers/serve_parser.py +31 -0
- openhands_cli/gui_launcher.py +224 -0
- openhands_cli/listeners/__init__.py +4 -0
- openhands_cli/listeners/pause_listener.py +83 -0
- openhands_cli/locations.py +14 -0
- openhands_cli/pt_style.py +33 -0
- openhands_cli/runner.py +190 -0
- openhands_cli/setup.py +136 -0
- openhands_cli/simple_main.py +71 -0
- openhands_cli/tui/__init__.py +6 -0
- openhands_cli/tui/settings/mcp_screen.py +225 -0
- openhands_cli/tui/settings/settings_screen.py +226 -0
- openhands_cli/tui/settings/store.py +132 -0
- openhands_cli/tui/status.py +110 -0
- openhands_cli/tui/tui.py +120 -0
- openhands_cli/tui/utils.py +14 -0
- openhands_cli/tui/visualizer.py +22 -0
- openhands_cli/user_actions/__init__.py +18 -0
- openhands_cli/user_actions/agent_action.py +82 -0
- openhands_cli/user_actions/exit_session.py +18 -0
- openhands_cli/user_actions/settings_action.py +176 -0
- openhands_cli/user_actions/types.py +17 -0
- openhands_cli/user_actions/utils.py +199 -0
- openhands_cli/utils.py +122 -0
- openhands_cli/version_check.py +83 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Utility functions for ACP implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from acp import SessionNotification
|
|
6
|
+
from acp.schema import (
|
|
7
|
+
AgentMessageChunk,
|
|
8
|
+
AgentPlanUpdate,
|
|
9
|
+
AgentThoughtChunk,
|
|
10
|
+
ContentToolCallContent,
|
|
11
|
+
FileEditToolCallContent,
|
|
12
|
+
PlanEntry,
|
|
13
|
+
PlanEntryStatus,
|
|
14
|
+
TerminalToolCallContent,
|
|
15
|
+
TextContentBlock,
|
|
16
|
+
ToolCallLocation,
|
|
17
|
+
ToolCallProgress,
|
|
18
|
+
ToolCallStart,
|
|
19
|
+
ToolCallStatus,
|
|
20
|
+
ToolKind,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from openhands.sdk import Action
|
|
24
|
+
from openhands.sdk.event import (
|
|
25
|
+
ActionEvent,
|
|
26
|
+
AgentErrorEvent,
|
|
27
|
+
Condensation,
|
|
28
|
+
CondensationRequest,
|
|
29
|
+
ConversationStateUpdateEvent,
|
|
30
|
+
Event,
|
|
31
|
+
MessageEvent,
|
|
32
|
+
ObservationBaseEvent,
|
|
33
|
+
ObservationEvent,
|
|
34
|
+
PauseEvent,
|
|
35
|
+
SystemPromptEvent,
|
|
36
|
+
UserRejectObservation,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from acp import AgentSideConnection
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
from openhands.sdk import get_logger
|
|
45
|
+
from openhands.sdk.tool.builtins.finish import FinishAction, FinishObservation
|
|
46
|
+
from openhands.sdk.tool.builtins.think import ThinkAction, ThinkObservation
|
|
47
|
+
from openhands.tools.file_editor.definition import (
|
|
48
|
+
FileEditorAction,
|
|
49
|
+
)
|
|
50
|
+
from openhands.tools.task_tracker.definition import (
|
|
51
|
+
TaskTrackerAction,
|
|
52
|
+
TaskTrackerObservation,
|
|
53
|
+
TaskTrackerStatusType,
|
|
54
|
+
)
|
|
55
|
+
from openhands.tools.terminal.definition import TerminalAction
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
logger = get_logger(__name__)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_action_locations(action: Action) -> list[ToolCallLocation] | None:
|
|
62
|
+
"""Extract file locations from an action if available.
|
|
63
|
+
|
|
64
|
+
Returns a list of ToolCallLocation objects if the action contains location
|
|
65
|
+
information (e.g., file paths, directories), otherwise returns None.
|
|
66
|
+
|
|
67
|
+
Supports:
|
|
68
|
+
- file_editor: path, view_range, insert_line
|
|
69
|
+
- Other tools with 'path' or 'directory' attributes
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
action: Action to extract locations from
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of ToolCallLocation objects or None
|
|
76
|
+
"""
|
|
77
|
+
locations = []
|
|
78
|
+
if isinstance(action, FileEditorAction):
|
|
79
|
+
# Handle FileEditorAction specifically
|
|
80
|
+
if action.path:
|
|
81
|
+
location = ToolCallLocation(path=action.path)
|
|
82
|
+
if action.view_range and len(action.view_range) > 0:
|
|
83
|
+
location.line = action.view_range[0]
|
|
84
|
+
elif action.insert_line is not None:
|
|
85
|
+
location.line = action.insert_line
|
|
86
|
+
locations.append(location)
|
|
87
|
+
return locations if locations else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _event_visualize_to_plain(event: Event) -> str:
|
|
91
|
+
"""Convert Rich Text object to plain string.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
text: Rich Text object or string
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Plain text string
|
|
98
|
+
"""
|
|
99
|
+
text = event.visualize
|
|
100
|
+
text = text.plain
|
|
101
|
+
return str(text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class EventSubscriber:
|
|
105
|
+
"""Subscriber for handling OpenHands events and converting them to ACP
|
|
106
|
+
notifications.
|
|
107
|
+
|
|
108
|
+
This class subscribes to events from an OpenHands conversation and converts
|
|
109
|
+
them to ACP session update notifications that are streamed back to the client.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, session_id: str, conn: "AgentSideConnection"):
|
|
113
|
+
"""Initialize the event subscriber.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
session_id: The ACP session ID
|
|
117
|
+
conn: The ACP connection for sending notifications
|
|
118
|
+
"""
|
|
119
|
+
self.session_id = session_id
|
|
120
|
+
self.conn = conn
|
|
121
|
+
|
|
122
|
+
async def __call__(self, event: Event):
|
|
123
|
+
"""Handle incoming events and convert them to ACP notifications.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
event: Event to process (ActionEvent, ObservationEvent, etc.)
|
|
127
|
+
"""
|
|
128
|
+
# Skip ConversationStateUpdateEvent (internal state management)
|
|
129
|
+
if isinstance(event, ConversationStateUpdateEvent):
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Handle different event types
|
|
133
|
+
if isinstance(event, ActionEvent):
|
|
134
|
+
await self._handle_action_event(event)
|
|
135
|
+
elif isinstance(
|
|
136
|
+
event, ObservationEvent | UserRejectObservation | AgentErrorEvent
|
|
137
|
+
):
|
|
138
|
+
await self._handle_observation_event(event)
|
|
139
|
+
elif isinstance(event, MessageEvent):
|
|
140
|
+
await self._handle_message_event(event)
|
|
141
|
+
elif isinstance(event, SystemPromptEvent):
|
|
142
|
+
await self._handle_system_prompt_event(event)
|
|
143
|
+
elif isinstance(event, PauseEvent):
|
|
144
|
+
await self._handle_pause_event(event)
|
|
145
|
+
elif isinstance(event, Condensation):
|
|
146
|
+
await self._handle_condensation_event(event)
|
|
147
|
+
elif isinstance(event, CondensationRequest):
|
|
148
|
+
await self._handle_condensation_request_event(event)
|
|
149
|
+
|
|
150
|
+
async def _handle_action_event(self, event: ActionEvent):
|
|
151
|
+
"""Handle ActionEvent: send thought as agent_message_chunk, then tool_call.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
event: ActionEvent to process
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
# First, send thoughts/reasoning as agent_message_chunk if available
|
|
158
|
+
thought_text = " ".join([t.text for t in event.thought])
|
|
159
|
+
|
|
160
|
+
if event.reasoning_content and event.reasoning_content.strip():
|
|
161
|
+
await self.conn.sessionUpdate(
|
|
162
|
+
SessionNotification(
|
|
163
|
+
sessionId=self.session_id,
|
|
164
|
+
update=AgentThoughtChunk(
|
|
165
|
+
sessionUpdate="agent_thought_chunk",
|
|
166
|
+
content=TextContentBlock(
|
|
167
|
+
type="text",
|
|
168
|
+
text="**Reasoning**:\n"
|
|
169
|
+
+ event.reasoning_content.strip()
|
|
170
|
+
+ "\n",
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if thought_text.strip():
|
|
177
|
+
await self.conn.sessionUpdate(
|
|
178
|
+
SessionNotification(
|
|
179
|
+
sessionId=self.session_id,
|
|
180
|
+
update=AgentThoughtChunk(
|
|
181
|
+
sessionUpdate="agent_thought_chunk",
|
|
182
|
+
content=TextContentBlock(
|
|
183
|
+
type="text",
|
|
184
|
+
text="\n**Thought**:\n" + thought_text.strip() + "\n",
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Generate content for the tool call
|
|
191
|
+
content: (
|
|
192
|
+
list[
|
|
193
|
+
ContentToolCallContent
|
|
194
|
+
| FileEditToolCallContent
|
|
195
|
+
| TerminalToolCallContent
|
|
196
|
+
]
|
|
197
|
+
| None
|
|
198
|
+
) = None
|
|
199
|
+
tool_kind_mapping: dict[str, ToolKind] = {
|
|
200
|
+
"terminal": "execute",
|
|
201
|
+
"browser_use": "fetch",
|
|
202
|
+
"browser": "fetch",
|
|
203
|
+
}
|
|
204
|
+
tool_kind = tool_kind_mapping.get(event.tool_name, "other")
|
|
205
|
+
title = event.tool_name
|
|
206
|
+
if event.action:
|
|
207
|
+
action_viz = _event_visualize_to_plain(event)
|
|
208
|
+
if action_viz.strip():
|
|
209
|
+
content = [
|
|
210
|
+
ContentToolCallContent(
|
|
211
|
+
type="content",
|
|
212
|
+
content=TextContentBlock(
|
|
213
|
+
type="text",
|
|
214
|
+
text=action_viz,
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
if isinstance(event.action, FileEditorAction):
|
|
220
|
+
if event.action.command == "view":
|
|
221
|
+
tool_kind = "read"
|
|
222
|
+
title = f"Reading {event.action.path}"
|
|
223
|
+
else:
|
|
224
|
+
tool_kind = "edit"
|
|
225
|
+
title = f"Editing {event.action.path}"
|
|
226
|
+
elif isinstance(event.action, TerminalAction):
|
|
227
|
+
title = f"{event.action.command}"
|
|
228
|
+
elif isinstance(event.action, TaskTrackerAction):
|
|
229
|
+
title = "Plan updated"
|
|
230
|
+
elif isinstance(event.action, ThinkAction):
|
|
231
|
+
await self.conn.sessionUpdate(
|
|
232
|
+
SessionNotification(
|
|
233
|
+
sessionId=self.session_id,
|
|
234
|
+
update=AgentThoughtChunk(
|
|
235
|
+
sessionUpdate="agent_thought_chunk",
|
|
236
|
+
content=TextContentBlock(
|
|
237
|
+
type="text",
|
|
238
|
+
text=action_viz,
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
elif isinstance(event.action, FinishAction):
|
|
245
|
+
await self.conn.sessionUpdate(
|
|
246
|
+
SessionNotification(
|
|
247
|
+
sessionId=self.session_id,
|
|
248
|
+
update=AgentMessageChunk(
|
|
249
|
+
sessionUpdate="agent_message_chunk",
|
|
250
|
+
content=TextContentBlock(
|
|
251
|
+
type="text",
|
|
252
|
+
text=action_viz,
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
await self.conn.sessionUpdate(
|
|
260
|
+
SessionNotification(
|
|
261
|
+
sessionId=self.session_id,
|
|
262
|
+
update=ToolCallStart(
|
|
263
|
+
sessionUpdate="tool_call",
|
|
264
|
+
toolCallId=event.tool_call_id,
|
|
265
|
+
title=title,
|
|
266
|
+
kind=tool_kind,
|
|
267
|
+
status="in_progress",
|
|
268
|
+
content=content,
|
|
269
|
+
locations=extract_action_locations(event.action)
|
|
270
|
+
if event.action
|
|
271
|
+
else None,
|
|
272
|
+
rawInput=event.action.model_dump() if event.action else None,
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.debug(f"Error processing ActionEvent: {e}", exc_info=True)
|
|
278
|
+
|
|
279
|
+
async def _handle_observation_event(self, event: ObservationBaseEvent):
|
|
280
|
+
"""Handle observation events by sending tool_call_update notification.
|
|
281
|
+
|
|
282
|
+
Handles special observation types (FileEditor, TaskTracker) with custom logic,
|
|
283
|
+
and generic observations with visualization text.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
event: ObservationEvent, UserRejectObservation, or AgentErrorEvent
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
content: ContentToolCallContent | None = None
|
|
290
|
+
status: ToolCallStatus = "completed"
|
|
291
|
+
if isinstance(event, ObservationEvent):
|
|
292
|
+
if isinstance(event.observation, ThinkObservation | FinishObservation):
|
|
293
|
+
# Think and Finish observations are handled in action event
|
|
294
|
+
return
|
|
295
|
+
# Special handling for TaskTrackerObservation
|
|
296
|
+
elif isinstance(event.observation, TaskTrackerObservation):
|
|
297
|
+
observation = event.observation
|
|
298
|
+
# Convert TaskItems to PlanEntries
|
|
299
|
+
entries: list[PlanEntry] = []
|
|
300
|
+
for task in observation.task_list:
|
|
301
|
+
# Map status: todoāpending, in_progressāin_progress,
|
|
302
|
+
# doneācompleted
|
|
303
|
+
status_map: dict[TaskTrackerStatusType, PlanEntryStatus] = {
|
|
304
|
+
"todo": "pending",
|
|
305
|
+
"in_progress": "in_progress",
|
|
306
|
+
"done": "completed",
|
|
307
|
+
}
|
|
308
|
+
task_status = status_map.get(task.status, "pending")
|
|
309
|
+
task_content = task.title
|
|
310
|
+
# NOTE: we ignore notes for now to keep it concise
|
|
311
|
+
# if task.notes:
|
|
312
|
+
# task_content += f"\n{task.notes}"
|
|
313
|
+
entries.append(
|
|
314
|
+
PlanEntry(
|
|
315
|
+
content=task_content,
|
|
316
|
+
status=task_status,
|
|
317
|
+
priority="medium", # TaskItem doesn't have priority
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Send AgentPlanUpdate
|
|
322
|
+
await self.conn.sessionUpdate(
|
|
323
|
+
SessionNotification(
|
|
324
|
+
sessionId=self.session_id,
|
|
325
|
+
update=AgentPlanUpdate(
|
|
326
|
+
sessionUpdate="plan",
|
|
327
|
+
entries=entries,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
observation = event.observation
|
|
333
|
+
# Use ContentToolCallContent for view commands and other operations
|
|
334
|
+
viz_text = _event_visualize_to_plain(event)
|
|
335
|
+
if viz_text.strip():
|
|
336
|
+
content = ContentToolCallContent(
|
|
337
|
+
type="content",
|
|
338
|
+
content=TextContentBlock(
|
|
339
|
+
type="text",
|
|
340
|
+
text=viz_text,
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
# For UserRejectObservation or AgentErrorEvent
|
|
345
|
+
status = "failed"
|
|
346
|
+
viz_text = _event_visualize_to_plain(event)
|
|
347
|
+
if viz_text.strip():
|
|
348
|
+
content = ContentToolCallContent(
|
|
349
|
+
type="content",
|
|
350
|
+
content=TextContentBlock(
|
|
351
|
+
type="text",
|
|
352
|
+
text=viz_text,
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
# Send tool_call_update for all observation types
|
|
356
|
+
await self.conn.sessionUpdate(
|
|
357
|
+
SessionNotification(
|
|
358
|
+
sessionId=self.session_id,
|
|
359
|
+
update=ToolCallProgress(
|
|
360
|
+
sessionUpdate="tool_call_update",
|
|
361
|
+
toolCallId=event.tool_call_id,
|
|
362
|
+
status=status,
|
|
363
|
+
content=[content] if content else None,
|
|
364
|
+
rawOutput=event.model_dump(),
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.debug(f"Error processing observation event: {e}", exc_info=True)
|
|
370
|
+
|
|
371
|
+
async def _handle_message_event(self, event: MessageEvent):
|
|
372
|
+
"""Handle MessageEvent by sending AgentMessageChunk or UserMessageChunk.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
event: MessageEvent from agent or user
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
# Get visualization text
|
|
379
|
+
viz_text = _event_visualize_to_plain(event)
|
|
380
|
+
if not viz_text.strip():
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# Determine which type of message chunk to send based on role
|
|
384
|
+
if event.llm_message.role == "user":
|
|
385
|
+
# NOTE: Zed UI will render user messages when it is sent
|
|
386
|
+
# if we update it again, they will be duplicated
|
|
387
|
+
pass
|
|
388
|
+
else: # assistant or other roles
|
|
389
|
+
await self.conn.sessionUpdate(
|
|
390
|
+
SessionNotification(
|
|
391
|
+
sessionId=self.session_id,
|
|
392
|
+
update=AgentMessageChunk(
|
|
393
|
+
sessionUpdate="agent_message_chunk",
|
|
394
|
+
content=TextContentBlock(
|
|
395
|
+
type="text",
|
|
396
|
+
text=viz_text,
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.debug(f"Error processing MessageEvent: {e}", exc_info=True)
|
|
403
|
+
|
|
404
|
+
async def _handle_system_prompt_event(self, event: SystemPromptEvent):
|
|
405
|
+
"""Handle SystemPromptEvent by sending as AgentThoughtChunk.
|
|
406
|
+
|
|
407
|
+
System prompts are internal setup, so we send them as thought chunks
|
|
408
|
+
to indicate they're part of the agent's internal state.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
event: SystemPromptEvent
|
|
412
|
+
"""
|
|
413
|
+
try:
|
|
414
|
+
viz_text = _event_visualize_to_plain(event)
|
|
415
|
+
if not viz_text.strip():
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
await self.conn.sessionUpdate(
|
|
419
|
+
SessionNotification(
|
|
420
|
+
sessionId=self.session_id,
|
|
421
|
+
update=AgentThoughtChunk(
|
|
422
|
+
sessionUpdate="agent_thought_chunk",
|
|
423
|
+
content=TextContentBlock(
|
|
424
|
+
type="text",
|
|
425
|
+
text=viz_text,
|
|
426
|
+
),
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
except Exception as e:
|
|
431
|
+
logger.debug(f"Error processing SystemPromptEvent: {e}", exc_info=True)
|
|
432
|
+
|
|
433
|
+
async def _handle_pause_event(self, event: PauseEvent):
|
|
434
|
+
"""Handle PauseEvent by sending as AgentThoughtChunk.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
event: PauseEvent
|
|
438
|
+
"""
|
|
439
|
+
try:
|
|
440
|
+
viz_text = _event_visualize_to_plain(event)
|
|
441
|
+
if not viz_text.strip():
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
await self.conn.sessionUpdate(
|
|
445
|
+
SessionNotification(
|
|
446
|
+
sessionId=self.session_id,
|
|
447
|
+
update=AgentThoughtChunk(
|
|
448
|
+
sessionUpdate="agent_thought_chunk",
|
|
449
|
+
content=TextContentBlock(
|
|
450
|
+
type="text",
|
|
451
|
+
text=viz_text,
|
|
452
|
+
),
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
except Exception as e:
|
|
457
|
+
logger.debug(f"Error processing PauseEvent: {e}", exc_info=True)
|
|
458
|
+
|
|
459
|
+
async def _handle_condensation_event(self, event: Condensation):
|
|
460
|
+
"""Handle Condensation by sending as AgentThoughtChunk.
|
|
461
|
+
|
|
462
|
+
Condensation events indicate memory management is happening, which is
|
|
463
|
+
useful for the user to know but doesn't require special UI treatment.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
event: Condensation event
|
|
467
|
+
"""
|
|
468
|
+
try:
|
|
469
|
+
viz_text = _event_visualize_to_plain(event)
|
|
470
|
+
if not viz_text.strip():
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
await self.conn.sessionUpdate(
|
|
474
|
+
SessionNotification(
|
|
475
|
+
sessionId=self.session_id,
|
|
476
|
+
update=AgentThoughtChunk(
|
|
477
|
+
sessionUpdate="agent_thought_chunk",
|
|
478
|
+
content=TextContentBlock(
|
|
479
|
+
type="text",
|
|
480
|
+
text=viz_text,
|
|
481
|
+
),
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
except Exception as e:
|
|
486
|
+
logger.debug(f"Error processing Condensation: {e}", exc_info=True)
|
|
487
|
+
|
|
488
|
+
async def _handle_condensation_request_event(self, event: CondensationRequest):
|
|
489
|
+
"""Handle CondensationRequest by sending as AgentThoughtChunk.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
event: CondensationRequest event
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
viz_text = _event_visualize_to_plain(event)
|
|
496
|
+
if not viz_text.strip():
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
await self.conn.sessionUpdate(
|
|
500
|
+
SessionNotification(
|
|
501
|
+
sessionId=self.session_id,
|
|
502
|
+
update=AgentThoughtChunk(
|
|
503
|
+
sessionUpdate="agent_thought_chunk",
|
|
504
|
+
content=TextContentBlock(
|
|
505
|
+
type="text",
|
|
506
|
+
text=viz_text,
|
|
507
|
+
),
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
except Exception as e:
|
|
512
|
+
logger.debug(f"Error processing CondensationRequest: {e}", exc_info=True)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""OpenHands ACP Main Entry Point."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .agent import run_acp_server
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Configure logging
|
|
11
|
+
logging.basicConfig(
|
|
12
|
+
level=logging.INFO,
|
|
13
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
14
|
+
handlers=[logging.StreamHandler(sys.stderr)],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
asyncio.run(run_acp_server())
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for testing JSON-RPC servers (ACP testing).
|
|
3
|
+
|
|
4
|
+
This module provides reusable functions for testing JSON-RPC servers,
|
|
5
|
+
specifically designed for testing the Agent Client Protocol (ACP) implementation.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from openhands_cli.acp_impl.test_utils import test_jsonrpc_messages
|
|
9
|
+
|
|
10
|
+
success, responses = test_jsonrpc_messages(
|
|
11
|
+
"./dist/openhands",
|
|
12
|
+
["acp"],
|
|
13
|
+
messages,
|
|
14
|
+
timeout_per_message=5.0,
|
|
15
|
+
verbose=True,
|
|
16
|
+
)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import select
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def send_jsonrpc_and_wait(
|
|
27
|
+
proc: subprocess.Popen,
|
|
28
|
+
message: dict[str, Any],
|
|
29
|
+
timeout: float = 5.0,
|
|
30
|
+
) -> tuple[bool, dict[str, Any] | None, str]:
|
|
31
|
+
"""
|
|
32
|
+
Send a JSON-RPC message and wait for response.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
proc: The subprocess to communicate with
|
|
36
|
+
message: JSON-RPC message dict
|
|
37
|
+
timeout: Timeout in seconds
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
tuple of (success: bool, response: dict | None, error_message: str)
|
|
41
|
+
"""
|
|
42
|
+
if not proc.stdin or not proc.stdout:
|
|
43
|
+
return False, None, "stdin or stdout not available"
|
|
44
|
+
|
|
45
|
+
# Send message
|
|
46
|
+
try:
|
|
47
|
+
msg_line = json.dumps(message) + "\n"
|
|
48
|
+
proc.stdin.write(msg_line)
|
|
49
|
+
proc.stdin.flush()
|
|
50
|
+
except Exception as e:
|
|
51
|
+
return False, None, f"Failed to send message: {e}"
|
|
52
|
+
|
|
53
|
+
# Wait for response
|
|
54
|
+
deadline = time.time() + timeout
|
|
55
|
+
while time.time() < deadline:
|
|
56
|
+
if proc.poll() is not None:
|
|
57
|
+
return False, None, "Process terminated unexpectedly"
|
|
58
|
+
|
|
59
|
+
rlist, _, _ = select.select([proc.stdout], [], [], 0.5)
|
|
60
|
+
if rlist:
|
|
61
|
+
line = proc.stdout.readline()
|
|
62
|
+
if line:
|
|
63
|
+
try:
|
|
64
|
+
response = json.loads(line)
|
|
65
|
+
return True, response, ""
|
|
66
|
+
except json.JSONDecodeError as e:
|
|
67
|
+
return (
|
|
68
|
+
False,
|
|
69
|
+
None,
|
|
70
|
+
f"Failed to parse JSON: {e}\nRaw: {line.strip()}",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return False, None, "Response timeout"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_jsonrpc_response(response: dict[str, Any]) -> tuple[bool, str]:
|
|
77
|
+
"""
|
|
78
|
+
Validate a JSON-RPC response for errors.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
response: The JSON-RPC response dict
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
tuple of (is_valid: bool, error_message: str)
|
|
85
|
+
"""
|
|
86
|
+
if "error" in response:
|
|
87
|
+
error = response["error"]
|
|
88
|
+
code = error.get("code", "unknown")
|
|
89
|
+
message = error.get("message", "unknown")
|
|
90
|
+
return False, f"JSON-RPC Error {code}: {message}"
|
|
91
|
+
|
|
92
|
+
if "result" not in response:
|
|
93
|
+
return False, "Response missing 'result' field"
|
|
94
|
+
|
|
95
|
+
return True, ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_jsonrpc_messages(
|
|
99
|
+
executable_path: str,
|
|
100
|
+
args: list[str],
|
|
101
|
+
messages: list[dict[str, Any]],
|
|
102
|
+
timeout_per_message: float = 5.0,
|
|
103
|
+
verbose: bool = True,
|
|
104
|
+
) -> tuple[bool, list[dict[str, Any]]]:
|
|
105
|
+
"""
|
|
106
|
+
Test a JSON-RPC server by sending messages and validating responses.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
executable_path: Path to the executable
|
|
110
|
+
args: Command-line arguments for the executable
|
|
111
|
+
messages: List of JSON-RPC messages to send
|
|
112
|
+
timeout_per_message: Timeout in seconds for each message
|
|
113
|
+
verbose: Print detailed output
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
tuple of (success: bool, responses: list[dict])
|
|
117
|
+
"""
|
|
118
|
+
if verbose:
|
|
119
|
+
print(f"š Starting: {executable_path} {' '.join(args)}")
|
|
120
|
+
|
|
121
|
+
proc = subprocess.Popen(
|
|
122
|
+
[executable_path] + args,
|
|
123
|
+
stdin=subprocess.PIPE,
|
|
124
|
+
stdout=subprocess.PIPE,
|
|
125
|
+
stderr=subprocess.DEVNULL, # Don't pipe stderr to avoid buffer blocking
|
|
126
|
+
text=True,
|
|
127
|
+
bufsize=1,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
all_responses = []
|
|
131
|
+
all_passed = True
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
for i, msg in enumerate(messages, 1):
|
|
135
|
+
if verbose:
|
|
136
|
+
print(
|
|
137
|
+
f"\nš¤ Message {i}/{len(messages)}: {msg.get('method', 'unknown')}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
success, response, error = send_jsonrpc_and_wait(
|
|
141
|
+
proc, msg, timeout_per_message
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not success:
|
|
145
|
+
if verbose:
|
|
146
|
+
print(f"ā {error}")
|
|
147
|
+
all_passed = False
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
if response:
|
|
151
|
+
all_responses.append(response)
|
|
152
|
+
|
|
153
|
+
if verbose:
|
|
154
|
+
print(f"š„ Response: {json.dumps(response)}")
|
|
155
|
+
|
|
156
|
+
is_valid, error_msg = validate_jsonrpc_response(response)
|
|
157
|
+
if not is_valid:
|
|
158
|
+
if verbose:
|
|
159
|
+
print(f"ā {error_msg}")
|
|
160
|
+
all_passed = False
|
|
161
|
+
elif verbose:
|
|
162
|
+
print("ā
Success")
|
|
163
|
+
|
|
164
|
+
return all_passed, all_responses
|
|
165
|
+
|
|
166
|
+
finally:
|
|
167
|
+
if verbose:
|
|
168
|
+
print("\nš Terminating process...")
|
|
169
|
+
proc.terminate()
|
|
170
|
+
try:
|
|
171
|
+
proc.wait(timeout=5)
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
proc.kill()
|
|
174
|
+
proc.wait()
|