|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import json |
| 4 | +import logging |
| 5 | +import shlex |
| 6 | +import subprocess |
| 7 | +from pathlib import Path |
| 8 | +from threading import Event |
| 9 | +from typing import Callable, Any |
| 10 | + |
| 11 | +from agent_cli import AgentCLI |
| 12 | +from agent_results import ( |
| 13 | + RunResult, |
| 14 | + ExportResult, |
| 15 | + SessionListResult, |
| 16 | + AgentListResult, |
| 17 | + ResponsePart, |
| 18 | + HistoryMessage, |
| 19 | + SessionInfo, |
| 20 | + AgentInfo, |
| 21 | +) |
| 22 | + |
| 23 | +logger = logging.getLogger(__name__) |
| 24 | + |
| 25 | + |
| 26 | +class OB1AgentCLI(AgentCLI): |
| 27 | + """OB1 AgentCLI implementation.""" |
| 28 | + |
| 29 | + @property |
| 30 | + def cli_name(self) -> str: |
| 31 | + return "ob1" |
| 32 | + |
| 33 | + def run_agent( |
| 34 | + self, |
| 35 | + message: str, |
| 36 | + session_id: str | None, |
| 37 | + agent: str | None, |
| 38 | + model: str | None, |
| 39 | + cwd: Path, |
| 40 | + cancel_event: Event | None = None, |
| 41 | + on_process: Callable[[subprocess.Popen[str]], None] | None = None, |
| 42 | + ) -> RunResult: |
| 43 | + """Run OB1 with message and return structured result.""" |
| 44 | + |
| 45 | + # Build OB1 command with proper argument quoting |
| 46 | + cmd = ["ob1", "--output-format", "json", "--prompt", shlex.quote(message)] |
| 47 | + |
| 48 | + if session_id: |
| 49 | + cmd.extend(["--resume", shlex.quote(session_id)]) |
| 50 | + if model: |
| 51 | + cmd.extend(["--model", shlex.quote(model)]) |
| 52 | + |
| 53 | + logger.info( |
| 54 | + "OB1 CLI operation starting (session: %s, model: %s)", |
| 55 | + session_id or "<new>", |
| 56 | + model or "<default>", |
| 57 | + ) |
| 58 | + |
| 59 | + try: |
| 60 | + process = subprocess.Popen( |
| 61 | + cmd, |
| 62 | + cwd=str(cwd), |
| 63 | + stdout=subprocess.PIPE, |
| 64 | + stderr=subprocess.PIPE, |
| 65 | + text=True, |
| 66 | + ) |
| 67 | + |
| 68 | + if on_process: |
| 69 | + on_process(process) |
| 70 | + |
| 71 | + # Handle cancellation |
| 72 | + if cancel_event and cancel_event.is_set(): |
| 73 | + process.terminate() |
| 74 | + try: |
| 75 | + process.wait(timeout=5) |
| 76 | + except subprocess.TimeoutExpired: |
| 77 | + process.kill() |
| 78 | + process.wait() |
| 79 | + return RunResult( |
| 80 | + success=False, |
| 81 | + session_id=session_id, |
| 82 | + response_parts=[], |
| 83 | + error_message="Agent request cancelled.", |
| 84 | + ) |
| 85 | + |
| 86 | + stdout, stderr = process.communicate() |
| 87 | + |
| 88 | + if process.returncode != 0: |
| 89 | + error_msg = (stderr or "").strip() or "Command failed with no output" |
| 90 | + return RunResult( |
| 91 | + success=False, |
| 92 | + session_id=session_id, |
| 93 | + response_parts=[], |
| 94 | + error_message=f"CLI failed: {error_msg}", |
| 95 | + ) |
| 96 | + |
| 97 | + # Parse OB1 JSON response |
| 98 | + return self._parse_ob1_response(stdout, session_id) |
| 99 | + |
| 100 | + except FileNotFoundError: |
| 101 | + return RunResult( |
| 102 | + success=False, |
| 103 | + session_id=session_id, |
| 104 | + response_parts=[], |
| 105 | + error_message=self.missing_command_error(), |
| 106 | + ) |
| 107 | + except Exception as e: |
| 108 | + return RunResult( |
| 109 | + success=False, |
| 110 | + session_id=session_id, |
| 111 | + response_parts=[], |
| 112 | + error_message=f"Error: {str(e)}", |
| 113 | + ) |
| 114 | + |
| 115 | + def _parse_ob1_response(self, stdout: str, session_id: str | None) -> RunResult: |
| 116 | + """Parse OB1 JSON output into RunResult.""" |
| 117 | + try: |
| 118 | + # OB1 outputs JSON on the last line |
| 119 | + lines = [line for line in stdout.splitlines() if line.strip()] |
| 120 | + if not lines: |
| 121 | + return RunResult( |
| 122 | + success=False, |
| 123 | + session_id=session_id, |
| 124 | + response_parts=[], |
| 125 | + error_message="No output from OB1", |
| 126 | + ) |
| 127 | + |
| 128 | + json_line = lines[-1] |
| 129 | + data = json.loads(json_line) |
| 130 | + |
| 131 | + # Extract response content from OB1 JSON format |
| 132 | + content = data.get("content", "") |
| 133 | + extracted_session_id = data.get("session_id") or session_id |
| 134 | + |
| 135 | + # Create response part |
| 136 | + response_part = ResponsePart( |
| 137 | + text=content, |
| 138 | + timestamp=None, # OB1 doesn't provide timestamp in response |
| 139 | + part_type="final", |
| 140 | + part_id=None, |
| 141 | + call_id=None, |
| 142 | + ) |
| 143 | + |
| 144 | + return RunResult( |
| 145 | + success=True, |
| 146 | + session_id=extracted_session_id, |
| 147 | + response_parts=[response_part], |
| 148 | + error_message=None, |
| 149 | + ) |
| 150 | + |
| 151 | + except json.JSONDecodeError as e: |
| 152 | + return RunResult( |
| 153 | + success=False, |
| 154 | + session_id=session_id, |
| 155 | + response_parts=[], |
| 156 | + error_message=f"Failed to parse OB1 response: {str(e)}", |
| 157 | + ) |
| 158 | + except Exception as e: |
| 159 | + return RunResult( |
| 160 | + success=False, |
| 161 | + session_id=session_id, |
| 162 | + response_parts=[], |
| 163 | + error_message=f"Error parsing OB1 output: {str(e)}", |
| 164 | + ) |
| 165 | + |
| 166 | + def export_session(self, session_id: str, cwd: Path | None) -> ExportResult: |
| 167 | + """Export session history and return structured result.""" |
| 168 | + try: |
| 169 | + # Discover OB1 session files in ~/.ob1/tmp/{project}/chats/ |
| 170 | + session_files = self._find_ob1_session_files(cwd) |
| 171 | + |
| 172 | + for session_file in session_files: |
| 173 | + if session_id in str(session_file): |
| 174 | + with open(session_file, "r") as f: |
| 175 | + session_data = json.load(f) |
| 176 | + |
| 177 | + messages = self._parse_ob1_session_data(session_data) |
| 178 | + |
| 179 | + return ExportResult( |
| 180 | + success=True, |
| 181 | + session_id=session_id, |
| 182 | + messages=messages, |
| 183 | + error_message=None, |
| 184 | + ) |
| 185 | + |
| 186 | + # Session not found |
| 187 | + return ExportResult( |
| 188 | + success=False, |
| 189 | + session_id=session_id, |
| 190 | + messages=[], |
| 191 | + error_message=f"Session '{session_id}' not found", |
| 192 | + ) |
| 193 | + |
| 194 | + except FileNotFoundError: |
| 195 | + return ExportResult( |
| 196 | + success=False, |
| 197 | + session_id=session_id, |
| 198 | + messages=[], |
| 199 | + error_message=self.missing_command_error(), |
| 200 | + ) |
| 201 | + except Exception as e: |
| 202 | + return ExportResult( |
| 203 | + success=False, |
| 204 | + session_id=session_id, |
| 205 | + messages=[], |
| 206 | + error_message=f"Error: {str(e)}", |
| 207 | + ) |
| 208 | + |
| 209 | + def _find_ob1_session_files(self, cwd: Path | None) -> list[Path]: |
| 210 | + """Find OB1 session files in ~/.ob1/tmp/ directory structure.""" |
| 211 | + from os.path import expanduser |
| 212 | + |
| 213 | + ob1_dir = Path(expanduser("~")) / ".ob1" / "tmp" |
| 214 | + session_files = [] |
| 215 | + |
| 216 | + if ob1_dir.exists(): |
| 217 | + # Look for session files in project subdirectories |
| 218 | + for project_dir in ob1_dir.iterdir(): |
| 219 | + if project_dir.is_dir(): |
| 220 | + chats_dir = project_dir / "chats" |
| 221 | + if chats_dir.exists(): |
| 222 | + session_files.extend(chats_dir.glob("session-*.json")) |
| 223 | + |
| 224 | + return session_files |
| 225 | + |
| 226 | + def _parse_ob1_session_data( |
| 227 | + self, session_data: dict[str, Any] |
| 228 | + ) -> list[HistoryMessage]: |
| 229 | + """Parse OB1 session data into HistoryMessage objects.""" |
| 230 | + messages = [] |
| 231 | + |
| 232 | + # OB1 session format (based on POC findings) |
| 233 | + exchanges = session_data.get("exchanges", []) |
| 234 | + |
| 235 | + for exchange in exchanges: |
| 236 | + # User message |
| 237 | + user_msg = exchange.get("user", {}) |
| 238 | + if user_msg: |
| 239 | + messages.append( |
| 240 | + HistoryMessage( |
| 241 | + message_id=None, |
| 242 | + role="user", |
| 243 | + content_type="text", |
| 244 | + content=user_msg.get("content", ""), |
| 245 | + timestamp=user_msg.get("timestamp_ms"), |
| 246 | + ) |
| 247 | + ) |
| 248 | + |
| 249 | + # Assistant message |
| 250 | + assistant_msg = exchange.get("assistant", {}) |
| 251 | + if assistant_msg: |
| 252 | + messages.append( |
| 253 | + HistoryMessage( |
| 254 | + message_id=None, |
| 255 | + role="assistant", |
| 256 | + content_type="text", |
| 257 | + content=assistant_msg.get("content", ""), |
| 258 | + timestamp=assistant_msg.get("timestamp_ms"), |
| 259 | + ) |
| 260 | + ) |
| 261 | + |
| 262 | + return messages |
| 263 | + |
| 264 | + def list_sessions(self, cwd: Path | None) -> SessionListResult: |
| 265 | + """List available sessions and return structured result.""" |
| 266 | + try: |
| 267 | + session_files = self._find_ob1_session_files(cwd) |
| 268 | + sessions = [] |
| 269 | + |
| 270 | + for session_file in session_files: |
| 271 | + # Extract session ID from filename (e.g., "session-abc123.json") |
| 272 | + session_id = session_file.stem.replace("session-", "") |
| 273 | + |
| 274 | + try: |
| 275 | + with open(session_file, "r") as f: |
| 276 | + session_data = json.load(f) |
| 277 | + |
| 278 | + # Extract session info |
| 279 | + created_at = session_data.get("created_at", "Unknown") |
| 280 | + |
| 281 | + sessions.append( |
| 282 | + SessionInfo( |
| 283 | + session_id=session_id, |
| 284 | + title=f"OB1 Session {session_id[:8]}", # Short title |
| 285 | + updated=created_at or "Unknown", |
| 286 | + ) |
| 287 | + ) |
| 288 | + |
| 289 | + except (json.JSONDecodeError, Exception): |
| 290 | + # Skip corrupted session files |
| 291 | + continue |
| 292 | + |
| 293 | + return SessionListResult( |
| 294 | + success=True, |
| 295 | + sessions=sessions, |
| 296 | + error_message=None, |
| 297 | + ) |
| 298 | + |
| 299 | + except FileNotFoundError: |
| 300 | + return SessionListResult( |
| 301 | + success=False, |
| 302 | + sessions=[], |
| 303 | + error_message=self.missing_command_error(), |
| 304 | + ) |
| 305 | + except Exception as e: |
| 306 | + return SessionListResult( |
| 307 | + success=False, |
| 308 | + sessions=[], |
| 309 | + error_message=f"Error: {str(e)}", |
| 310 | + ) |
| 311 | + |
| 312 | + def list_agents(self, cwd: Path | None = None) -> AgentListResult: |
| 313 | + """List available agents and return structured result.""" |
| 314 | + try: |
| 315 | + # OB1 supports multiple models, but we return a single "ob1" agent |
| 316 | + # as the CLI handles model selection internally |
| 317 | + agents = [ |
| 318 | + AgentInfo( |
| 319 | + name="ob1", |
| 320 | + agent_type="Multi-Model", |
| 321 | + details=["300+ models available via --model parameter"], |
| 322 | + ) |
| 323 | + ] |
| 324 | + |
| 325 | + return AgentListResult( |
| 326 | + success=True, |
| 327 | + agents=agents, |
| 328 | + error_message=None, |
| 329 | + ) |
| 330 | + |
| 331 | + except FileNotFoundError: |
| 332 | + return AgentListResult( |
| 333 | + success=False, |
| 334 | + agents=[], |
| 335 | + error_message=self.missing_command_error(), |
| 336 | + ) |
| 337 | + except Exception as e: |
| 338 | + return AgentListResult( |
| 339 | + success=False, |
| 340 | + agents=[], |
| 341 | + error_message=f"Error: {str(e)}", |
| 342 | + ) |
0 commit comments