zwarm 2.3.5__py3-none-any.whl → 3.6.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.
- zwarm/cli/interactive.py +1065 -0
- zwarm/cli/main.py +525 -934
- zwarm/cli/pilot.py +1240 -0
- zwarm/core/__init__.py +20 -0
- zwarm/core/checkpoints.py +216 -0
- zwarm/core/config.py +26 -9
- zwarm/core/costs.py +71 -0
- zwarm/core/registry.py +329 -0
- zwarm/core/test_config.py +2 -3
- zwarm/orchestrator.py +17 -43
- zwarm/prompts/__init__.py +3 -0
- zwarm/prompts/orchestrator.py +36 -29
- zwarm/prompts/pilot.py +147 -0
- zwarm/sessions/__init__.py +48 -9
- zwarm/sessions/base.py +501 -0
- zwarm/sessions/claude.py +481 -0
- zwarm/sessions/manager.py +233 -486
- zwarm/tools/delegation.py +150 -187
- zwarm-3.6.0.dist-info/METADATA +445 -0
- zwarm-3.6.0.dist-info/RECORD +39 -0
- zwarm/adapters/__init__.py +0 -21
- zwarm/adapters/base.py +0 -109
- zwarm/adapters/claude_code.py +0 -357
- zwarm/adapters/codex_mcp.py +0 -1262
- zwarm/adapters/registry.py +0 -69
- zwarm/adapters/test_codex_mcp.py +0 -274
- zwarm/adapters/test_registry.py +0 -68
- zwarm-2.3.5.dist-info/METADATA +0 -309
- zwarm-2.3.5.dist-info/RECORD +0 -38
- {zwarm-2.3.5.dist-info → zwarm-3.6.0.dist-info}/WHEEL +0 -0
- {zwarm-2.3.5.dist-info → zwarm-3.6.0.dist-info}/entry_points.txt +0 -0
zwarm/sessions/base.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Session Manager - Abstract interface for executor adapters.
|
|
3
|
+
|
|
4
|
+
This module defines the shared interface and data structures that all
|
|
5
|
+
session managers (Codex, Claude, etc.) must implement.
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
- Each session runs an executor CLI in a background subprocess
|
|
9
|
+
- Output is streamed to .zwarm/sessions/<session_id>/turns/turn_N.jsonl
|
|
10
|
+
- Session metadata stored in meta.json
|
|
11
|
+
- Adapters implement CLI-specific command building and output parsing
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import signal
|
|
19
|
+
import time
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
from uuid import uuid4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionStatus(str, Enum):
|
|
30
|
+
"""Status of a session."""
|
|
31
|
+
|
|
32
|
+
PENDING = "pending" # Created but not started
|
|
33
|
+
RUNNING = "running" # Process is running
|
|
34
|
+
COMPLETED = "completed" # Process exited successfully
|
|
35
|
+
FAILED = "failed" # Process exited with error
|
|
36
|
+
KILLED = "killed" # Manually killed
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SessionMessage:
|
|
41
|
+
"""A message in a session's history."""
|
|
42
|
+
|
|
43
|
+
role: str # "user", "assistant", "system", "tool"
|
|
44
|
+
content: str
|
|
45
|
+
timestamp: str = ""
|
|
46
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"role": self.role,
|
|
51
|
+
"content": self.content,
|
|
52
|
+
"timestamp": self.timestamp,
|
|
53
|
+
"metadata": self.metadata,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict) -> "SessionMessage":
|
|
58
|
+
return cls(
|
|
59
|
+
role=data.get("role", "unknown"),
|
|
60
|
+
content=data.get("content", ""),
|
|
61
|
+
timestamp=data.get("timestamp", ""),
|
|
62
|
+
metadata=data.get("metadata", {}),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Session:
|
|
68
|
+
"""A managed executor session."""
|
|
69
|
+
|
|
70
|
+
id: str
|
|
71
|
+
task: str
|
|
72
|
+
status: SessionStatus
|
|
73
|
+
working_dir: Path
|
|
74
|
+
created_at: str
|
|
75
|
+
updated_at: str
|
|
76
|
+
pid: int | None = None
|
|
77
|
+
exit_code: int | None = None
|
|
78
|
+
model: str = ""
|
|
79
|
+
turn: int = 1
|
|
80
|
+
messages: list[SessionMessage] = field(default_factory=list)
|
|
81
|
+
token_usage: dict[str, int] = field(default_factory=dict)
|
|
82
|
+
error: str | None = None
|
|
83
|
+
# Source tracking: "user" for direct spawns, "orchestrator:<instance_id>" for delegated
|
|
84
|
+
source: str = "user"
|
|
85
|
+
# Adapter used: "codex", "claude", etc.
|
|
86
|
+
adapter: str = "codex"
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict:
|
|
89
|
+
return {
|
|
90
|
+
"id": self.id,
|
|
91
|
+
"task": self.task,
|
|
92
|
+
"status": self.status.value,
|
|
93
|
+
"working_dir": str(self.working_dir),
|
|
94
|
+
"created_at": self.created_at,
|
|
95
|
+
"updated_at": self.updated_at,
|
|
96
|
+
"pid": self.pid,
|
|
97
|
+
"exit_code": self.exit_code,
|
|
98
|
+
"model": self.model,
|
|
99
|
+
"turn": self.turn,
|
|
100
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
101
|
+
"token_usage": self.token_usage,
|
|
102
|
+
"error": self.error,
|
|
103
|
+
"source": self.source,
|
|
104
|
+
"adapter": self.adapter,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dict(cls, data: dict) -> "Session":
|
|
109
|
+
return cls(
|
|
110
|
+
id=data["id"],
|
|
111
|
+
task=data["task"],
|
|
112
|
+
status=SessionStatus(data["status"]),
|
|
113
|
+
working_dir=Path(data["working_dir"]),
|
|
114
|
+
created_at=data["created_at"],
|
|
115
|
+
updated_at=data["updated_at"],
|
|
116
|
+
pid=data.get("pid"),
|
|
117
|
+
exit_code=data.get("exit_code"),
|
|
118
|
+
model=data.get("model", ""),
|
|
119
|
+
turn=data.get("turn", 1),
|
|
120
|
+
messages=[SessionMessage.from_dict(m) for m in data.get("messages", [])],
|
|
121
|
+
token_usage=data.get("token_usage", {}),
|
|
122
|
+
error=data.get("error"),
|
|
123
|
+
source=data.get("source", "user"),
|
|
124
|
+
adapter=data.get("adapter", "codex"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_running(self) -> bool:
|
|
129
|
+
"""Check if the session process is still running."""
|
|
130
|
+
if self.pid is None:
|
|
131
|
+
return False
|
|
132
|
+
try:
|
|
133
|
+
os.kill(self.pid, 0) # Signal 0 just checks if process exists
|
|
134
|
+
return True
|
|
135
|
+
except OSError:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def short_id(self) -> str:
|
|
140
|
+
"""Get first 8 chars of ID for display."""
|
|
141
|
+
return self.id[:8]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def runtime(self) -> str:
|
|
145
|
+
"""Get human-readable runtime."""
|
|
146
|
+
created = datetime.fromisoformat(self.created_at)
|
|
147
|
+
now = datetime.now()
|
|
148
|
+
delta = now - created
|
|
149
|
+
|
|
150
|
+
if delta.total_seconds() < 60:
|
|
151
|
+
return f"{int(delta.total_seconds())}s"
|
|
152
|
+
elif delta.total_seconds() < 3600:
|
|
153
|
+
return f"{int(delta.total_seconds() / 60)}m"
|
|
154
|
+
else:
|
|
155
|
+
return f"{delta.total_seconds() / 3600:.1f}h"
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def source_display(self) -> str:
|
|
159
|
+
"""Get short display string for source."""
|
|
160
|
+
if self.source == "user":
|
|
161
|
+
return "you"
|
|
162
|
+
elif self.source.startswith("orchestrator:"):
|
|
163
|
+
instance_id = self.source.split(":", 1)[1]
|
|
164
|
+
return f"orch:{instance_id[:4]}"
|
|
165
|
+
else:
|
|
166
|
+
return self.source[:8]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Type alias for backwards compatibility
|
|
170
|
+
CodexSession = Session
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class BaseSessionManager(ABC):
|
|
174
|
+
"""
|
|
175
|
+
Abstract base class for session managers.
|
|
176
|
+
|
|
177
|
+
Manages background executor sessions with:
|
|
178
|
+
- Session lifecycle (start, inject, kill, delete)
|
|
179
|
+
- State persistence (.zwarm/sessions/<id>/)
|
|
180
|
+
- Output parsing (JSONL → messages, trajectory)
|
|
181
|
+
|
|
182
|
+
Subclasses implement adapter-specific logic:
|
|
183
|
+
- Command building (CLI flags, config handling)
|
|
184
|
+
- Output parsing (different JSONL formats)
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
# Adapter identifier (override in subclasses)
|
|
188
|
+
adapter_name: str = "base"
|
|
189
|
+
|
|
190
|
+
# Default model (override in subclasses)
|
|
191
|
+
default_model: str = ""
|
|
192
|
+
|
|
193
|
+
def __init__(self, state_dir: Path | str = ".zwarm"):
|
|
194
|
+
self.state_dir = Path(state_dir)
|
|
195
|
+
self.sessions_dir = self.state_dir / "sessions"
|
|
196
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
# =========================================================================
|
|
199
|
+
# Path helpers (shared)
|
|
200
|
+
# =========================================================================
|
|
201
|
+
|
|
202
|
+
def _session_dir(self, session_id: str) -> Path:
|
|
203
|
+
"""Get the directory for a session."""
|
|
204
|
+
return self.sessions_dir / session_id
|
|
205
|
+
|
|
206
|
+
def _meta_path(self, session_id: str) -> Path:
|
|
207
|
+
"""Get the metadata file path for a session."""
|
|
208
|
+
return self._session_dir(session_id) / "meta.json"
|
|
209
|
+
|
|
210
|
+
def _output_path(self, session_id: str, turn: int = 1) -> Path:
|
|
211
|
+
"""Get the output file path for a session turn."""
|
|
212
|
+
session_dir = self._session_dir(session_id)
|
|
213
|
+
turns_dir = session_dir / "turns"
|
|
214
|
+
turns_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
return turns_dir / f"turn_{turn}.jsonl"
|
|
216
|
+
|
|
217
|
+
# =========================================================================
|
|
218
|
+
# State persistence (shared)
|
|
219
|
+
# =========================================================================
|
|
220
|
+
|
|
221
|
+
def _save_session(self, session: Session) -> None:
|
|
222
|
+
"""Save session metadata."""
|
|
223
|
+
session.updated_at = datetime.now().isoformat()
|
|
224
|
+
meta_path = self._meta_path(session.id)
|
|
225
|
+
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
meta_path.write_text(json.dumps(session.to_dict(), indent=2))
|
|
227
|
+
|
|
228
|
+
def _load_session(self, session_id: str) -> Session | None:
|
|
229
|
+
"""Load session from disk."""
|
|
230
|
+
meta_path = self._meta_path(session_id)
|
|
231
|
+
if not meta_path.exists():
|
|
232
|
+
return None
|
|
233
|
+
try:
|
|
234
|
+
data = json.loads(meta_path.read_text())
|
|
235
|
+
return Session.from_dict(data)
|
|
236
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
237
|
+
print(f"Error loading session {session_id}: {e}")
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
# =========================================================================
|
|
241
|
+
# Session retrieval (shared with adapter hooks)
|
|
242
|
+
# =========================================================================
|
|
243
|
+
|
|
244
|
+
def get_session(self, session_id: str) -> Session | None:
|
|
245
|
+
"""Get a session by ID (supports partial ID matching)."""
|
|
246
|
+
# Try exact match first
|
|
247
|
+
session = self._load_session(session_id)
|
|
248
|
+
if session:
|
|
249
|
+
self._maybe_update_status(session)
|
|
250
|
+
return session
|
|
251
|
+
|
|
252
|
+
# Try partial match
|
|
253
|
+
for session_dir in self.sessions_dir.iterdir():
|
|
254
|
+
if session_dir.name.startswith(session_id):
|
|
255
|
+
session = self._load_session(session_dir.name)
|
|
256
|
+
if session:
|
|
257
|
+
self._maybe_update_status(session)
|
|
258
|
+
return session
|
|
259
|
+
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
def list_sessions(self, status: SessionStatus | None = None) -> list[Session]:
|
|
263
|
+
"""List all sessions, optionally filtered by status."""
|
|
264
|
+
sessions = []
|
|
265
|
+
if not self.sessions_dir.exists():
|
|
266
|
+
return sessions
|
|
267
|
+
|
|
268
|
+
for session_dir in self.sessions_dir.iterdir():
|
|
269
|
+
if not session_dir.is_dir():
|
|
270
|
+
continue
|
|
271
|
+
session = self._load_session(session_dir.name)
|
|
272
|
+
if session:
|
|
273
|
+
self._maybe_update_status(session)
|
|
274
|
+
if status is None or session.status == status:
|
|
275
|
+
sessions.append(session)
|
|
276
|
+
|
|
277
|
+
# Sort by created_at descending (newest first)
|
|
278
|
+
sessions.sort(key=lambda s: s.created_at, reverse=True)
|
|
279
|
+
return sessions
|
|
280
|
+
|
|
281
|
+
def _maybe_update_status(self, session: Session) -> None:
|
|
282
|
+
"""Update session status if process completed."""
|
|
283
|
+
if session.status == SessionStatus.RUNNING:
|
|
284
|
+
if self._is_output_complete(session.id, session.turn) or not session.is_running:
|
|
285
|
+
self._update_session_status(session)
|
|
286
|
+
|
|
287
|
+
def _update_session_status(self, session: Session) -> None:
|
|
288
|
+
"""Update session status after process completion."""
|
|
289
|
+
output_path = self._output_path(session.id, session.turn)
|
|
290
|
+
if output_path.exists():
|
|
291
|
+
messages, usage, error = self._parse_output(output_path)
|
|
292
|
+
session.messages = messages
|
|
293
|
+
session.token_usage = usage
|
|
294
|
+
|
|
295
|
+
has_response = any(m.role == "assistant" for m in messages)
|
|
296
|
+
|
|
297
|
+
if error and not has_response:
|
|
298
|
+
session.status = SessionStatus.FAILED
|
|
299
|
+
session.error = error
|
|
300
|
+
elif error and has_response:
|
|
301
|
+
session.status = SessionStatus.COMPLETED
|
|
302
|
+
session.error = f"Completed with error: {error}"
|
|
303
|
+
else:
|
|
304
|
+
session.status = SessionStatus.COMPLETED
|
|
305
|
+
else:
|
|
306
|
+
session.status = SessionStatus.FAILED
|
|
307
|
+
session.error = "No output file found"
|
|
308
|
+
|
|
309
|
+
self._save_session(session)
|
|
310
|
+
|
|
311
|
+
# =========================================================================
|
|
312
|
+
# Process management (shared)
|
|
313
|
+
# =========================================================================
|
|
314
|
+
|
|
315
|
+
def kill_session(self, session_id: str, delete: bool = False) -> bool:
|
|
316
|
+
"""Kill a running session."""
|
|
317
|
+
session = self.get_session(session_id)
|
|
318
|
+
if not session:
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
if session.pid and session.is_running:
|
|
322
|
+
try:
|
|
323
|
+
os.killpg(os.getpgid(session.pid), signal.SIGTERM)
|
|
324
|
+
time.sleep(0.5)
|
|
325
|
+
if session.is_running:
|
|
326
|
+
os.killpg(os.getpgid(session.pid), signal.SIGKILL)
|
|
327
|
+
except (OSError, ProcessLookupError):
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
if delete:
|
|
331
|
+
return self.delete_session(session.id)
|
|
332
|
+
|
|
333
|
+
session.status = SessionStatus.KILLED
|
|
334
|
+
session.error = "Manually killed"
|
|
335
|
+
self._save_session(session)
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
def delete_session(self, session_id: str) -> bool:
|
|
339
|
+
"""Delete a session entirely (removes from disk)."""
|
|
340
|
+
import shutil
|
|
341
|
+
|
|
342
|
+
session = self.get_session(session_id)
|
|
343
|
+
if not session:
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
if session.pid and session.is_running:
|
|
347
|
+
try:
|
|
348
|
+
os.killpg(os.getpgid(session.pid), signal.SIGTERM)
|
|
349
|
+
time.sleep(0.3)
|
|
350
|
+
if session.is_running:
|
|
351
|
+
os.killpg(os.getpgid(session.pid), signal.SIGKILL)
|
|
352
|
+
except (OSError, ProcessLookupError):
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
session_dir = self._session_dir(session.id)
|
|
356
|
+
if session_dir.exists():
|
|
357
|
+
shutil.rmtree(session_dir)
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
def get_output(self, session_id: str, turn: int | None = None) -> str:
|
|
363
|
+
"""Get raw JSONL output for a session."""
|
|
364
|
+
session = self.get_session(session_id)
|
|
365
|
+
if not session:
|
|
366
|
+
return ""
|
|
367
|
+
|
|
368
|
+
if turn is None:
|
|
369
|
+
turn = session.turn
|
|
370
|
+
|
|
371
|
+
output_path = self._output_path(session.id, turn)
|
|
372
|
+
if not output_path.exists():
|
|
373
|
+
return ""
|
|
374
|
+
|
|
375
|
+
return output_path.read_text()
|
|
376
|
+
|
|
377
|
+
def get_messages(self, session_id: str) -> list[SessionMessage]:
|
|
378
|
+
"""Get parsed messages for a session across all turns."""
|
|
379
|
+
session = self.get_session(session_id)
|
|
380
|
+
if not session:
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
all_messages = []
|
|
384
|
+
for turn in range(1, session.turn + 1):
|
|
385
|
+
output_path = self._output_path(session.id, turn)
|
|
386
|
+
if output_path.exists():
|
|
387
|
+
messages, _, _ = self._parse_output(output_path)
|
|
388
|
+
all_messages.extend(messages)
|
|
389
|
+
|
|
390
|
+
return all_messages
|
|
391
|
+
|
|
392
|
+
def cleanup_completed(self, keep_days: int = 7) -> int:
|
|
393
|
+
"""Remove old completed/failed/killed sessions."""
|
|
394
|
+
import shutil
|
|
395
|
+
from datetime import timedelta
|
|
396
|
+
|
|
397
|
+
cutoff = datetime.now() - timedelta(days=keep_days)
|
|
398
|
+
cleaned = 0
|
|
399
|
+
|
|
400
|
+
for session in self.list_sessions():
|
|
401
|
+
if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
|
|
402
|
+
created = datetime.fromisoformat(session.created_at)
|
|
403
|
+
if created < cutoff:
|
|
404
|
+
session_dir = self._session_dir(session.id)
|
|
405
|
+
if session_dir.exists():
|
|
406
|
+
shutil.rmtree(session_dir)
|
|
407
|
+
cleaned += 1
|
|
408
|
+
|
|
409
|
+
return cleaned
|
|
410
|
+
|
|
411
|
+
# =========================================================================
|
|
412
|
+
# Abstract methods (adapter-specific)
|
|
413
|
+
# =========================================================================
|
|
414
|
+
|
|
415
|
+
@abstractmethod
|
|
416
|
+
def start_session(
|
|
417
|
+
self,
|
|
418
|
+
task: str,
|
|
419
|
+
working_dir: Path | None = None,
|
|
420
|
+
model: str | None = None,
|
|
421
|
+
sandbox: str = "workspace-write",
|
|
422
|
+
source: str = "user",
|
|
423
|
+
) -> Session:
|
|
424
|
+
"""
|
|
425
|
+
Start a new session in the background.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
task: The task description
|
|
429
|
+
working_dir: Working directory for the executor
|
|
430
|
+
model: Model override
|
|
431
|
+
sandbox: Sandbox mode
|
|
432
|
+
source: Who spawned this session
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
The created session
|
|
436
|
+
"""
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
@abstractmethod
|
|
440
|
+
def inject_message(
|
|
441
|
+
self,
|
|
442
|
+
session_id: str,
|
|
443
|
+
message: str,
|
|
444
|
+
) -> Session | None:
|
|
445
|
+
"""
|
|
446
|
+
Inject a follow-up message into a completed session.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
session_id: Session to continue
|
|
450
|
+
message: The follow-up message
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Updated session or None if not found/not ready
|
|
454
|
+
"""
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
@abstractmethod
|
|
458
|
+
def _is_output_complete(self, session_id: str, turn: int) -> bool:
|
|
459
|
+
"""
|
|
460
|
+
Check if output file indicates the task completed.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
session_id: Session ID
|
|
464
|
+
turn: Turn number
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
True if output indicates completion
|
|
468
|
+
"""
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
@abstractmethod
|
|
472
|
+
def _parse_output(
|
|
473
|
+
self, output_path: Path
|
|
474
|
+
) -> tuple[list[SessionMessage], dict[str, int], str | None]:
|
|
475
|
+
"""
|
|
476
|
+
Parse JSONL output from the executor.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
output_path: Path to the JSONL file
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
(messages, token_usage, error)
|
|
483
|
+
"""
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
@abstractmethod
|
|
487
|
+
def get_trajectory(
|
|
488
|
+
self, session_id: str, full: bool = False, max_output_len: int = 200
|
|
489
|
+
) -> list[dict]:
|
|
490
|
+
"""
|
|
491
|
+
Get the full trajectory of a session.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
session_id: Session to get trajectory for
|
|
495
|
+
full: If True, include full untruncated content
|
|
496
|
+
max_output_len: Max length for outputs when full=False
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
List of step dicts with type, summary, and details
|
|
500
|
+
"""
|
|
501
|
+
pass
|