Skip to content

Commit a125194

Browse files
authored
Merge pull request #286 from tbrandenburg/feature/ob1-agent-cli-integration
2 parents 782ce1b + e850af9 commit a125194

File tree

6 files changed

+1015
-2
lines changed

6 files changed

+1015
-2
lines changed

packages/frontend/src/pages/SettingsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import "../styles/page.css";
66

77
type SettingsMap = Record<string, unknown>;
88

9-
const agentCliOptions = ["opencode", "opencode-legacy", "kiro", "copilot", "codex"];
9+
const agentCliOptions = ["opencode", "opencode-legacy", "kiro", "copilot", "codex", "ob1"];
1010

1111
export const SettingsPage: React.FC = () => {
1212
const [settings, setSettings] = useState<SettingsMap>({});

packages/pybackend/agent_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from copilot_agent_cli import CopilotAgentCLI
1111
from kiro_agent_cli import KiroAgentCLI
1212
from codex_agent_cli import CodexAgentCLI
13+
from ob1_agent_cli import OB1AgentCLI
1314
from config import ensure_directory, get_made_directory, get_workspace_home
1415
from settings_service import read_settings
1516

@@ -35,6 +36,8 @@ def get_agent_cli():
3536
return CopilotAgentCLI()
3637
elif agent_cli_setting == "codex":
3738
return CodexAgentCLI()
39+
elif agent_cli_setting == "ob1":
40+
return OB1AgentCLI()
3841
elif agent_cli_setting == "opencode":
3942
return OpenCodeDatabaseAgentCLI()
4043
elif agent_cli_setting == "opencode-legacy":
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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+
)

packages/pybackend/settings_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def read_settings():
1515
settings_path = get_settings_path()
1616
if not settings_path.exists():
1717
defaults = {
18-
# Supported values: "opencode", "opencode-legacy", "kiro", "copilot", "codex"
18+
# Supported values: "opencode", "opencode-legacy", "kiro", "copilot", "codex", "ob1"
1919
"agentCli": "opencode",
2020
}
2121
settings_path.write_text(json.dumps(defaults, indent=2), encoding="utf-8")

0 commit comments

Comments
 (0)