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.
Files changed (43) hide show
  1. openhands-1.3.0.dist-info/METADATA +56 -0
  2. openhands-1.3.0.dist-info/RECORD +43 -0
  3. openhands-1.3.0.dist-info/WHEEL +4 -0
  4. openhands-1.3.0.dist-info/entry_points.txt +3 -0
  5. openhands-1.3.0.dist-info/licenses/LICENSE +21 -0
  6. openhands_cli/__init__.py +9 -0
  7. openhands_cli/acp_impl/README.md +68 -0
  8. openhands_cli/acp_impl/__init__.py +1 -0
  9. openhands_cli/acp_impl/agent.py +483 -0
  10. openhands_cli/acp_impl/event.py +512 -0
  11. openhands_cli/acp_impl/main.py +21 -0
  12. openhands_cli/acp_impl/test_utils.py +174 -0
  13. openhands_cli/acp_impl/utils/__init__.py +14 -0
  14. openhands_cli/acp_impl/utils/convert.py +103 -0
  15. openhands_cli/acp_impl/utils/mcp.py +66 -0
  16. openhands_cli/acp_impl/utils/resources.py +189 -0
  17. openhands_cli/agent_chat.py +236 -0
  18. openhands_cli/argparsers/main_parser.py +78 -0
  19. openhands_cli/argparsers/serve_parser.py +31 -0
  20. openhands_cli/gui_launcher.py +224 -0
  21. openhands_cli/listeners/__init__.py +4 -0
  22. openhands_cli/listeners/pause_listener.py +83 -0
  23. openhands_cli/locations.py +14 -0
  24. openhands_cli/pt_style.py +33 -0
  25. openhands_cli/runner.py +190 -0
  26. openhands_cli/setup.py +136 -0
  27. openhands_cli/simple_main.py +71 -0
  28. openhands_cli/tui/__init__.py +6 -0
  29. openhands_cli/tui/settings/mcp_screen.py +225 -0
  30. openhands_cli/tui/settings/settings_screen.py +226 -0
  31. openhands_cli/tui/settings/store.py +132 -0
  32. openhands_cli/tui/status.py +110 -0
  33. openhands_cli/tui/tui.py +120 -0
  34. openhands_cli/tui/utils.py +14 -0
  35. openhands_cli/tui/visualizer.py +22 -0
  36. openhands_cli/user_actions/__init__.py +18 -0
  37. openhands_cli/user_actions/agent_action.py +82 -0
  38. openhands_cli/user_actions/exit_session.py +18 -0
  39. openhands_cli/user_actions/settings_action.py +176 -0
  40. openhands_cli/user_actions/types.py +17 -0
  41. openhands_cli/user_actions/utils.py +199 -0
  42. openhands_cli/utils.py +122 -0
  43. openhands_cli/version_check.py +83 -0
@@ -0,0 +1,483 @@
1
+ """OpenHands Agent Client Protocol (ACP) server implementation."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+ from acp import (
11
+ Agent as ACPAgent,
12
+ AgentSideConnection,
13
+ InitializeRequest,
14
+ InitializeResponse,
15
+ NewSessionRequest,
16
+ NewSessionResponse,
17
+ PromptRequest,
18
+ PromptResponse,
19
+ RequestError,
20
+ SessionNotification,
21
+ stdio_streams,
22
+ )
23
+ from acp.schema import (
24
+ AgentCapabilities,
25
+ AgentMessageChunk,
26
+ AuthenticateRequest,
27
+ AuthenticateResponse,
28
+ CancelNotification,
29
+ Implementation,
30
+ LoadSessionRequest,
31
+ LoadSessionResponse,
32
+ McpCapabilities,
33
+ PromptCapabilities,
34
+ SetSessionModelRequest,
35
+ SetSessionModelResponse,
36
+ SetSessionModeRequest,
37
+ SetSessionModeResponse,
38
+ TextContentBlock,
39
+ )
40
+
41
+ from openhands.sdk import (
42
+ BaseConversation,
43
+ Conversation,
44
+ Message,
45
+ Workspace,
46
+ )
47
+ from openhands.sdk.event import Event
48
+ from openhands_cli import __version__
49
+ from openhands_cli.acp_impl.event import EventSubscriber
50
+ from openhands_cli.acp_impl.utils import (
51
+ RESOURCE_SKILL,
52
+ convert_acp_mcp_servers_to_agent_format,
53
+ convert_acp_prompt_to_message_content,
54
+ )
55
+ from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
56
+ from openhands_cli.setup import MissingAgentSpec, load_agent_specs
57
+
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ class OpenHandsACPAgent(ACPAgent):
63
+ """OpenHands Agent Client Protocol implementation."""
64
+
65
+ def __init__(self, conn: AgentSideConnection):
66
+ """Initialize the OpenHands ACP agent.
67
+
68
+ Args:
69
+ conn: ACP connection for sending notifications
70
+ """
71
+ self._conn = conn
72
+ # Cache of active conversations to preserve state (pause, confirmation, etc.)
73
+ # across multiple operations on the same session
74
+ self._active_sessions: dict[str, BaseConversation] = {}
75
+ # Track running tasks for each session to ensure proper cleanup on cancel
76
+ self._running_tasks: dict[str, asyncio.Task] = {}
77
+
78
+ logger.info("OpenHands ACP Agent initialized")
79
+
80
+ def _get_or_create_conversation(
81
+ self,
82
+ session_id: str,
83
+ working_dir: str | None = None,
84
+ mcp_servers: dict[str, dict[str, Any]] | None = None,
85
+ ) -> BaseConversation:
86
+ """Get an active conversation from cache or create/load it.
87
+
88
+ This maintains conversation state (pause, confirmation, etc.) across
89
+ multiple operations on the same session.
90
+
91
+ Args:
92
+ session_id: Session/conversation ID (UUID string)
93
+ working_dir: Working directory for workspace (only for new sessions)
94
+ mcp_servers: MCP servers config (only for new sessions)
95
+
96
+ Returns:
97
+ Cached or newly created/loaded conversation
98
+ """
99
+ # Check if we already have this conversation active
100
+ if session_id in self._active_sessions:
101
+ logger.debug(f"Using cached conversation for session {session_id}")
102
+ return self._active_sessions[session_id]
103
+
104
+ # Create/load new conversation
105
+ logger.debug(f"Creating new conversation for session {session_id}")
106
+ conversation = self._setup_acp_conversation(
107
+ session_id=session_id,
108
+ working_dir=working_dir,
109
+ mcp_servers=mcp_servers,
110
+ )
111
+
112
+ # Cache it for future operations
113
+ self._active_sessions[session_id] = conversation
114
+ return conversation
115
+
116
+ def _setup_acp_conversation(
117
+ self,
118
+ session_id: str,
119
+ working_dir: str | None = None,
120
+ mcp_servers: dict[str, dict[str, Any]] | None = None,
121
+ ) -> BaseConversation:
122
+ """Set up a conversation for ACP with event streaming support.
123
+
124
+ This function reuses the resume logic from
125
+ openhands_cli.setup.setup_conversation but adapts it for ACP by using
126
+ EventSubscriber instead of CLIVisualizer.
127
+
128
+ The SDK's Conversation class automatically:
129
+ - Loads from disk if conversation_id exists in persistence_dir
130
+ - Creates a new conversation if it doesn't exist
131
+
132
+ Args:
133
+ session_id: Session/conversation ID (UUID string)
134
+ working_dir: Working directory for the workspace. Defaults to WORK_DIR.
135
+ mcp_servers: Optional MCP servers configuration
136
+
137
+ Returns:
138
+ Configured conversation that's either loaded from disk or newly created
139
+
140
+ Raises:
141
+ MissingAgentSpec: If agent configuration is missing
142
+ """
143
+ # Load agent specs (same as setup_conversation)
144
+ agent = load_agent_specs(
145
+ conversation_id=session_id,
146
+ mcp_servers=mcp_servers,
147
+ skills=[RESOURCE_SKILL],
148
+ )
149
+
150
+ # Validate and setup workspace
151
+ if working_dir is None:
152
+ working_dir = WORK_DIR
153
+ working_path = Path(working_dir)
154
+
155
+ if not working_path.exists():
156
+ logger.warning(
157
+ f"Working directory {working_dir} doesn't exist, creating it"
158
+ )
159
+ working_path.mkdir(parents=True, exist_ok=True)
160
+
161
+ if not working_path.is_dir():
162
+ raise RequestError.invalid_params(
163
+ {"reason": f"Working directory path is not a directory: {working_dir}"}
164
+ )
165
+
166
+ workspace = Workspace(working_dir=str(working_path))
167
+
168
+ # Create event subscriber for streaming updates (ACP-specific)
169
+ subscriber = EventSubscriber(session_id, self._conn)
170
+
171
+ # Get the current event loop for the callback
172
+ loop = asyncio.get_event_loop()
173
+
174
+ def sync_callback(event: Event) -> None:
175
+ """Synchronous wrapper that schedules async event handling."""
176
+ asyncio.run_coroutine_threadsafe(subscriber(event), loop)
177
+
178
+ # Create conversation with persistence support
179
+ # The SDK automatically loads from disk if conversation_id exists
180
+ conversation = Conversation(
181
+ agent=agent,
182
+ workspace=workspace,
183
+ persistence_dir=CONVERSATIONS_DIR,
184
+ conversation_id=UUID(session_id),
185
+ callbacks=[sync_callback],
186
+ visualizer=None, # No visualizer needed for ACP
187
+ )
188
+
189
+ # # Set up security analyzer (same as setup_conversation with confirmation mode)
190
+ # conversation.set_security_analyzer(LLMSecurityAnalyzer())
191
+ # conversation.set_confirmation_policy(AlwaysConfirm())
192
+ # TODO: implement later
193
+
194
+ return conversation
195
+
196
+ async def initialize(self, params: InitializeRequest) -> InitializeResponse:
197
+ """Initialize the ACP protocol."""
198
+ logger.info(f"Initializing ACP with protocol version: {params.protocolVersion}")
199
+
200
+ # Check if agent is configured
201
+ try:
202
+ load_agent_specs()
203
+ auth_methods = []
204
+ logger.info("Agent configured, no authentication required")
205
+ except MissingAgentSpec:
206
+ # Agent not configured - this shouldn't happen in production
207
+ # but we'll return empty auth methods for now
208
+ auth_methods = []
209
+ logger.warning("Agent not configured - users should run 'openhands' first")
210
+
211
+ return InitializeResponse(
212
+ protocolVersion=params.protocolVersion,
213
+ authMethods=auth_methods,
214
+ agentCapabilities=AgentCapabilities(
215
+ loadSession=True,
216
+ mcpCapabilities=McpCapabilities(http=True, sse=True),
217
+ promptCapabilities=PromptCapabilities(
218
+ audio=False,
219
+ embeddedContext=True,
220
+ image=True,
221
+ ),
222
+ ),
223
+ agentInfo=Implementation(
224
+ name="OpenHands CLI ACP Agent",
225
+ version=__version__,
226
+ ),
227
+ )
228
+
229
+ async def authenticate(
230
+ self, params: AuthenticateRequest
231
+ ) -> AuthenticateResponse | None:
232
+ """Authenticate the client (no-op for now)."""
233
+ logger.info(f"Authentication requested with method: {params.methodId}")
234
+ return AuthenticateResponse()
235
+
236
+ async def newSession(self, params: NewSessionRequest) -> NewSessionResponse:
237
+ """Create a new conversation session."""
238
+ session_id = str(uuid.uuid4())
239
+
240
+ try:
241
+ # Convert ACP MCP servers to Agent format
242
+ mcp_servers_dict = None
243
+ if params.mcpServers:
244
+ mcp_servers_dict = convert_acp_mcp_servers_to_agent_format(
245
+ params.mcpServers
246
+ )
247
+
248
+ # Validate working directory
249
+ working_dir = params.cwd or str(Path.cwd())
250
+ logger.info(f"Using working directory: {working_dir}")
251
+
252
+ # Create conversation and cache it for future operations
253
+ # This reuses the same pattern as openhands --resume
254
+ conversation = self._get_or_create_conversation(
255
+ session_id=session_id,
256
+ working_dir=working_dir,
257
+ mcp_servers=mcp_servers_dict,
258
+ )
259
+
260
+ logger.info(
261
+ f"Created new session {session_id} with model: "
262
+ f"{conversation.agent.llm.model}" # type: ignore[attr-defined]
263
+ )
264
+
265
+ return NewSessionResponse(sessionId=session_id)
266
+
267
+ except MissingAgentSpec as e:
268
+ logger.error(f"Agent not configured: {e}")
269
+ raise RequestError.internal_error(
270
+ {
271
+ "reason": "Agent not configured",
272
+ "details": "Please run 'openhands' to configure the agent first.",
273
+ }
274
+ )
275
+ except RequestError:
276
+ # Re-raise RequestError as-is
277
+ raise
278
+ except Exception as e:
279
+ logger.error(f"Failed to create new session: {e}", exc_info=True)
280
+ raise RequestError.internal_error(
281
+ {"reason": "Failed to create new session", "details": str(e)}
282
+ )
283
+
284
+ async def prompt(self, params: PromptRequest) -> PromptResponse:
285
+ """Handle a prompt request."""
286
+ session_id = params.sessionId
287
+
288
+ try:
289
+ # Get or create conversation (preserves state like pause/confirmation)
290
+ conversation = self._get_or_create_conversation(session_id=session_id)
291
+
292
+ # Convert ACP prompt format to OpenHands message content
293
+ message_content = convert_acp_prompt_to_message_content(params.prompt)
294
+
295
+ if not message_content:
296
+ return PromptResponse(stopReason="end_turn")
297
+
298
+ # Send the message with potentially multiple content types
299
+ # (text + images)
300
+ message = Message(role="user", content=message_content)
301
+ conversation.send_message(message)
302
+
303
+ # Run the conversation asynchronously
304
+ # Callbacks are already set up when conversation was created
305
+ # Track the running task so cancel() can wait for proper cleanup
306
+ run_task = asyncio.create_task(asyncio.to_thread(conversation.run))
307
+ self._running_tasks[session_id] = run_task
308
+ try:
309
+ await run_task
310
+ finally:
311
+ # Clean up task tracking
312
+ self._running_tasks.pop(session_id, None)
313
+
314
+ # Return the final response
315
+ return PromptResponse(stopReason="end_turn")
316
+
317
+ except RequestError:
318
+ # Re-raise RequestError as-is
319
+ raise
320
+ except Exception as e:
321
+ logger.error(f"Error processing prompt: {e}", exc_info=True)
322
+ # Send error notification to client
323
+ await self._conn.sessionUpdate(
324
+ SessionNotification(
325
+ sessionId=session_id,
326
+ update=AgentMessageChunk(
327
+ sessionUpdate="agent_message_chunk",
328
+ content=TextContentBlock(type="text", text=f"Error: {str(e)}"),
329
+ ),
330
+ )
331
+ )
332
+ raise RequestError.internal_error(
333
+ {"reason": "Failed to process prompt", "details": str(e)}
334
+ )
335
+
336
+ async def _wait_for_task_completion(
337
+ self, task: asyncio.Task, session_id: str, timeout: float = 10.0
338
+ ) -> None:
339
+ """Wait for a task to complete and handle cancellation if needed."""
340
+ try:
341
+ await asyncio.wait_for(task, timeout=timeout)
342
+ except TimeoutError:
343
+ logger.warning(
344
+ f"Conversation thread did not stop within timeout for session "
345
+ f"{session_id}"
346
+ )
347
+ task.cancel()
348
+ try:
349
+ await task
350
+ except asyncio.CancelledError:
351
+ pass
352
+ except Exception as e:
353
+ logger.error(f"Error while waiting for conversation to stop: {e}")
354
+ raise RequestError.internal_error(
355
+ {
356
+ "reason": "Error during conversation cancellation",
357
+ "details": str(e),
358
+ }
359
+ )
360
+
361
+ async def cancel(self, params: CancelNotification) -> None:
362
+ """Cancel the current operation."""
363
+ logger.info(f"Cancel requested for session: {params.sessionId}")
364
+
365
+ try:
366
+ conversation = self._get_or_create_conversation(session_id=params.sessionId)
367
+ conversation.pause()
368
+
369
+ running_task = self._running_tasks.get(params.sessionId)
370
+ if not running_task or running_task.done():
371
+ return
372
+
373
+ logger.debug(
374
+ f"Waiting for conversation thread to terminate for session "
375
+ f"{params.sessionId}"
376
+ )
377
+ await self._wait_for_task_completion(running_task, params.sessionId)
378
+
379
+ except RequestError:
380
+ raise
381
+ except Exception as e:
382
+ logger.error(f"Failed to cancel session {params.sessionId}: {e}")
383
+ raise RequestError.internal_error(
384
+ {"reason": "Failed to cancel session", "details": str(e)}
385
+ )
386
+
387
+ async def loadSession(
388
+ self, params: LoadSessionRequest
389
+ ) -> LoadSessionResponse | None:
390
+ """Load an existing session and replay conversation history.
391
+
392
+ This implements the same logic as 'openhands --resume <session_id>':
393
+ - Uses _setup_acp_conversation which calls the SDK's Conversation constructor
394
+ - The SDK automatically loads from persistence_dir if conversation_id exists
395
+ - Streams the loaded history back to the client
396
+
397
+ Per ACP spec (https://agentclientprotocol.com/protocol/session-setup#loading-sessions):
398
+ - Server should load the session state from persistent storage
399
+ - Replay the conversation history to the client via sessionUpdate notifications
400
+ """
401
+ session_id = params.sessionId
402
+ logger.info(f"Loading session: {session_id}")
403
+
404
+ try:
405
+ # Validate session ID format
406
+ try:
407
+ UUID(session_id)
408
+ except ValueError:
409
+ raise RequestError.invalid_params(
410
+ {"reason": "Invalid session ID format", "sessionId": session_id}
411
+ )
412
+
413
+ # Get or create conversation (loads from disk if not in cache)
414
+ # The SDK's Conversation class automatically loads from disk if the
415
+ # conversation_id exists in persistence_dir
416
+ conversation = self._get_or_create_conversation(session_id=session_id)
417
+
418
+ # Check if there's actually any history to load
419
+ if not conversation.state.events:
420
+ logger.warning(
421
+ f"Session {session_id} has no history (new or empty session)"
422
+ )
423
+ return LoadSessionResponse()
424
+
425
+ # Stream conversation history to client by reusing EventSubscriber
426
+ # This ensures consistent event handling with live conversations
427
+ logger.info(
428
+ f"Streaming {len(conversation.state.events)} events from "
429
+ f"conversation history"
430
+ )
431
+ subscriber = EventSubscriber(session_id, self._conn)
432
+ for event in conversation.state.events:
433
+ await subscriber(event)
434
+
435
+ logger.info(f"Successfully loaded session {session_id}")
436
+ return LoadSessionResponse()
437
+
438
+ except RequestError:
439
+ # Re-raise RequestError as-is
440
+ raise
441
+ except Exception as e:
442
+ logger.error(f"Failed to load session {session_id}: {e}", exc_info=True)
443
+ raise RequestError.internal_error(
444
+ {"reason": "Failed to load session", "details": str(e)}
445
+ )
446
+
447
+ async def setSessionMode(
448
+ self, params: SetSessionModeRequest
449
+ ) -> SetSessionModeResponse | None:
450
+ """Set session mode (no-op for now)."""
451
+ logger.info(f"Set session mode requested: {params.sessionId}")
452
+ return SetSessionModeResponse()
453
+
454
+ async def setSessionModel(
455
+ self, params: SetSessionModelRequest
456
+ ) -> SetSessionModelResponse | None:
457
+ """Set session model (no-op for now)."""
458
+ logger.info(f"Set session model requested: {params.sessionId}")
459
+ return SetSessionModelResponse()
460
+
461
+ async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
462
+ """Extension method (not supported)."""
463
+ logger.info(f"Extension method '{method}' requested with params: {params}")
464
+ return {"error": "extMethod not supported"}
465
+
466
+ async def extNotification(self, method: str, params: dict[str, Any]) -> None:
467
+ """Extension notification (no-op for now)."""
468
+ logger.info(f"Extension notification '{method}' received with params: {params}")
469
+
470
+
471
+ async def run_acp_server() -> None:
472
+ """Run the OpenHands ACP server."""
473
+ logger.info("Starting OpenHands ACP server...")
474
+
475
+ reader, writer = await stdio_streams()
476
+
477
+ def create_agent(conn: AgentSideConnection) -> OpenHandsACPAgent:
478
+ return OpenHandsACPAgent(conn)
479
+
480
+ AgentSideConnection(create_agent, writer, reader)
481
+
482
+ # Keep the server running
483
+ await asyncio.Event().wait()