zwarm 3.2.1__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/sessions/manager.py CHANGED
@@ -1,387 +1,178 @@
1
1
  """
2
2
  Codex Session Manager - Background process management for Codex agents.
3
3
 
4
- Architecture:
5
- - Each session runs `codex exec --json` in a background subprocess
6
- - Output is streamed to .zwarm/sessions/<session_id>/output.jsonl
7
- - Session metadata stored in meta.json
8
- - Can inject follow-up messages by starting new turns with context
4
+ This module implements the CodexSessionManager, which handles:
5
+ - Spawning `codex exec --json` subprocesses
6
+ - Parsing Codex's JSONL output format
7
+ - Loading config from .zwarm/codex.toml
8
+
9
+ Inherits shared functionality from BaseSessionManager.
9
10
  """
10
11
 
11
12
  from __future__ import annotations
12
13
 
13
14
  import json
14
- import os
15
- import signal
16
15
  import subprocess
17
- import time
18
- from dataclasses import dataclass, field
16
+ import tomllib
19
17
  from datetime import datetime
20
- from enum import Enum
21
18
  from pathlib import Path
22
19
  from typing import Any
23
20
  from uuid import uuid4
24
21
 
22
+ from .base import (
23
+ BaseSessionManager,
24
+ Session,
25
+ SessionMessage,
26
+ SessionStatus,
27
+ )
25
28
 
26
- class SessionStatus(str, Enum):
27
- """Status of a codex session."""
28
- PENDING = "pending" # Created but not started
29
- RUNNING = "running" # Process is running
30
- COMPLETED = "completed" # Process exited successfully
31
- FAILED = "failed" # Process exited with error
32
- KILLED = "killed" # Manually killed
33
-
34
-
35
- @dataclass
36
- class SessionMessage:
37
- """A message in a session's history."""
38
- role: str # "user", "assistant", "system", "tool"
39
- content: str
40
- timestamp: str = ""
41
- metadata: dict[str, Any] = field(default_factory=dict)
42
-
43
- def to_dict(self) -> dict:
44
- return {
45
- "role": self.role,
46
- "content": self.content,
47
- "timestamp": self.timestamp,
48
- "metadata": self.metadata,
49
- }
50
-
51
- @classmethod
52
- def from_dict(cls, data: dict) -> "SessionMessage":
53
- return cls(
54
- role=data.get("role", "unknown"),
55
- content=data.get("content", ""),
56
- timestamp=data.get("timestamp", ""),
57
- metadata=data.get("metadata", {}),
58
- )
59
-
60
-
61
- @dataclass
62
- class CodexSession:
63
- """A managed Codex session."""
64
- id: str
65
- task: str
66
- status: SessionStatus
67
- working_dir: Path
68
- created_at: str
69
- updated_at: str
70
- pid: int | None = None
71
- exit_code: int | None = None
72
- model: str = "gpt-5.1-codex-mini"
73
- turn: int = 1
74
- messages: list[SessionMessage] = field(default_factory=list)
75
- token_usage: dict[str, int] = field(default_factory=dict)
76
- error: str | None = None
77
- # Source tracking: "user" for direct spawns, "orchestrator:<instance_id>" for delegated
78
- source: str = "user"
79
- # Adapter used: "codex", "claude_code", etc.
80
- adapter: str = "codex"
81
-
82
- def to_dict(self) -> dict:
83
- return {
84
- "id": self.id,
85
- "task": self.task,
86
- "status": self.status.value,
87
- "working_dir": str(self.working_dir),
88
- "created_at": self.created_at,
89
- "updated_at": self.updated_at,
90
- "pid": self.pid,
91
- "exit_code": self.exit_code,
92
- "model": self.model,
93
- "turn": self.turn,
94
- "messages": [m.to_dict() for m in self.messages],
95
- "token_usage": self.token_usage,
96
- "error": self.error,
97
- "source": self.source,
98
- "adapter": self.adapter,
99
- }
100
-
101
- @classmethod
102
- def from_dict(cls, data: dict) -> "CodexSession":
103
- return cls(
104
- id=data["id"],
105
- task=data["task"],
106
- status=SessionStatus(data["status"]),
107
- working_dir=Path(data["working_dir"]),
108
- created_at=data["created_at"],
109
- updated_at=data["updated_at"],
110
- pid=data.get("pid"),
111
- exit_code=data.get("exit_code"),
112
- model=data.get("model", "gpt-5.1-codex-mini"),
113
- turn=data.get("turn", 1),
114
- messages=[SessionMessage.from_dict(m) for m in data.get("messages", [])],
115
- token_usage=data.get("token_usage", {}),
116
- error=data.get("error"),
117
- source=data.get("source", "user"),
118
- adapter=data.get("adapter", "codex"),
119
- )
29
+ # Re-export for backwards compatibility
30
+ CodexSession = Session
120
31
 
121
- @property
122
- def is_running(self) -> bool:
123
- """Check if the session process is still running."""
124
- if self.pid is None:
125
- return False
126
- try:
127
- os.kill(self.pid, 0) # Signal 0 just checks if process exists
128
- return True
129
- except OSError:
130
- return False
131
32
 
132
- @property
133
- def short_id(self) -> str:
134
- """Get first 8 chars of ID for display."""
135
- return self.id[:8]
136
-
137
- @property
138
- def runtime(self) -> str:
139
- """Get human-readable runtime."""
140
- created = datetime.fromisoformat(self.created_at)
141
- now = datetime.now()
142
- delta = now - created
143
-
144
- if delta.total_seconds() < 60:
145
- return f"{int(delta.total_seconds())}s"
146
- elif delta.total_seconds() < 3600:
147
- return f"{int(delta.total_seconds() / 60)}m"
148
- else:
149
- return f"{delta.total_seconds() / 3600:.1f}h"
150
-
151
- @property
152
- def source_display(self) -> str:
153
- """Get short display string for source."""
154
- if self.source == "user":
155
- return "you"
156
- elif self.source.startswith("orchestrator:"):
157
- # Extract instance ID and shorten it
158
- instance_id = self.source.split(":", 1)[1]
159
- return f"orch:{instance_id[:4]}"
160
- else:
161
- return self.source[:8]
162
-
163
-
164
- class CodexSessionManager:
33
+ class CodexSessionManager(BaseSessionManager):
165
34
  """
166
35
  Manages background Codex sessions.
167
36
 
168
37
  Sessions are stored in:
169
38
  .zwarm/sessions/<session_id>/
170
39
  meta.json - Session metadata
171
- output.jsonl - Raw JSONL output from codex exec
172
40
  turns/
173
41
  turn_1.jsonl
174
42
  turn_2.jsonl
175
43
  ...
176
44
  """
177
45
 
178
- def __init__(self, state_dir: Path | str = ".zwarm"):
179
- self.state_dir = Path(state_dir)
180
- self.sessions_dir = self.state_dir / "sessions"
181
- self.sessions_dir.mkdir(parents=True, exist_ok=True)
182
-
183
- def _session_dir(self, session_id: str) -> Path:
184
- """Get the directory for a session."""
185
- return self.sessions_dir / session_id
186
-
187
- def _meta_path(self, session_id: str) -> Path:
188
- """Get the metadata file path for a session."""
189
- return self._session_dir(session_id) / "meta.json"
190
-
191
- def _output_path(self, session_id: str, turn: int = 1) -> Path:
192
- """Get the output file path for a session turn."""
193
- session_dir = self._session_dir(session_id)
194
- turns_dir = session_dir / "turns"
195
- turns_dir.mkdir(parents=True, exist_ok=True)
196
- return turns_dir / f"turn_{turn}.jsonl"
197
-
198
- def _save_session(self, session: CodexSession) -> None:
199
- """Save session metadata."""
200
- session.updated_at = datetime.now().isoformat()
201
- meta_path = self._meta_path(session.id)
202
- meta_path.parent.mkdir(parents=True, exist_ok=True)
203
- meta_path.write_text(json.dumps(session.to_dict(), indent=2))
204
-
205
- def _load_session(self, session_id: str) -> CodexSession | None:
206
- """Load session from disk."""
207
- meta_path = self._meta_path(session_id)
208
- if not meta_path.exists():
209
- return None
210
- try:
211
- data = json.loads(meta_path.read_text())
212
- return CodexSession.from_dict(data)
213
- except (json.JSONDecodeError, KeyError) as e:
214
- print(f"Error loading session {session_id}: {e}")
215
- return None
216
-
217
- def list_sessions(self, status: SessionStatus | None = None) -> list[CodexSession]:
218
- """List all sessions, optionally filtered by status."""
219
- sessions = []
220
- if not self.sessions_dir.exists():
221
- return sessions
46
+ adapter_name = "codex"
47
+ default_model = "gpt-5.1-codex-mini"
222
48
 
223
- for session_dir in self.sessions_dir.iterdir():
224
- if not session_dir.is_dir():
225
- continue
226
- session = self._load_session(session_dir.name)
227
- if session:
228
- # Update status if process died OR output indicates completion
229
- # (output check is more reliable than PID check due to PID reuse)
230
- if session.status == SessionStatus.RUNNING:
231
- if self._is_output_complete(session.id, session.turn) or not session.is_running:
232
- self._update_session_status(session)
233
-
234
- if status is None or session.status == status:
235
- sessions.append(session)
236
-
237
- # Sort by created_at descending (newest first)
238
- sessions.sort(key=lambda s: s.created_at, reverse=True)
239
- return sessions
240
-
241
- def get_session(self, session_id: str) -> CodexSession | None:
242
- """Get a session by ID (supports partial ID matching)."""
243
- # Try exact match first
244
- session = self._load_session(session_id)
245
- if session:
246
- if session.status == SessionStatus.RUNNING:
247
- if self._is_output_complete(session.id, session.turn) or not session.is_running:
248
- self._update_session_status(session)
249
- return session
250
-
251
- # Try partial match
252
- for session_dir in self.sessions_dir.iterdir():
253
- if session_dir.name.startswith(session_id):
254
- session = self._load_session(session_dir.name)
255
- if session:
256
- if session.status == SessionStatus.RUNNING:
257
- if self._is_output_complete(session.id, session.turn) or not session.is_running:
258
- self._update_session_status(session)
259
- return session
260
-
261
- return None
49
+ # =========================================================================
50
+ # Codex-specific config handling
51
+ # =========================================================================
262
52
 
263
- def _is_output_complete(self, session_id: str, turn: int) -> bool:
53
+ def _load_codex_config(self) -> dict[str, Any]:
264
54
  """
265
- Check if output file indicates the task completed.
55
+ Load codex.toml from state_dir.
266
56
 
267
- Looks for completion markers like 'turn.completed' or 'task.completed'
268
- in the JSONL output. This is more reliable than PID checking.
57
+ Returns parsed TOML as dict, or empty dict if not found.
269
58
  """
270
- output_path = self._output_path(session_id, turn)
271
- if not output_path.exists():
272
- return False
273
-
59
+ codex_toml = self.state_dir / "codex.toml"
60
+ if not codex_toml.exists():
61
+ return {}
274
62
  try:
275
- content = output_path.read_text()
276
- for line in content.strip().split("\n"):
277
- if not line.strip():
278
- continue
279
- try:
280
- event = json.loads(line)
281
- event_type = event.get("type", "")
282
- # Check for any completion marker
283
- if event_type in ("turn.completed", "task.completed", "completed", "done"):
284
- return True
285
- # Also check for error as a form of completion
286
- if event_type == "error":
287
- return True
288
- except json.JSONDecodeError:
289
- continue
63
+ with open(codex_toml, "rb") as f:
64
+ return tomllib.load(f)
290
65
  except Exception:
291
- pass
66
+ return {}
292
67
 
293
- return False
68
+ def _build_codex_overrides(self, config: dict[str, Any]) -> list[str]:
69
+ """
70
+ Convert codex.toml config to -c override flags.
294
71
 
295
- def _update_session_status(self, session: CodexSession) -> None:
296
- """Update session status after process completion."""
297
- # Parse output to determine status
298
- output_path = self._output_path(session.id, session.turn)
299
- if output_path.exists():
300
- messages, usage, error = self._parse_output(output_path)
301
- session.messages = messages
302
- session.token_usage = usage
303
-
304
- # Check if we got actual assistant responses
305
- has_response = any(m.role == "assistant" for m in messages)
306
-
307
- if error and not has_response:
308
- # Only mark as failed if we have an error AND no response
309
- session.status = SessionStatus.FAILED
310
- session.error = error
311
- elif error and has_response:
312
- # Got response but also an error (e.g., network disconnect at end)
313
- # Treat as completed but note the error
314
- session.status = SessionStatus.COMPLETED
315
- session.error = f"Completed with error: {error}"
72
+ Handles nested sections like [features] and [sandbox_workspace_write].
73
+
74
+ Returns list of ["-c", "key=value", "-c", "key2=value2", ...]
75
+ """
76
+ overrides = []
77
+
78
+ def add_override(key: str, value: Any):
79
+ """Add a -c override for a key=value pair."""
80
+ if isinstance(value, bool):
81
+ value = "true" if value else "false"
82
+ overrides.extend(["-c", f"{key}={value}"])
83
+
84
+ for key, value in config.items():
85
+ if isinstance(value, dict):
86
+ # Nested section like [features] or [sandbox_workspace_write]
87
+ for subkey, subvalue in value.items():
88
+ add_override(f"{key}.{subkey}", subvalue)
316
89
  else:
317
- session.status = SessionStatus.COMPLETED
318
- else:
319
- session.status = SessionStatus.FAILED
320
- session.error = "No output file found"
90
+ # Top-level key
91
+ add_override(key, value)
321
92
 
322
- self._save_session(session)
93
+ return overrides
94
+
95
+ # =========================================================================
96
+ # Session lifecycle (Codex-specific implementation)
97
+ # =========================================================================
323
98
 
324
99
  def start_session(
325
100
  self,
326
101
  task: str,
327
102
  working_dir: Path | None = None,
328
- model: str = "gpt-5.1-codex-mini",
103
+ model: str | None = None,
329
104
  sandbox: str = "workspace-write",
330
105
  source: str = "user",
331
- adapter: str = "codex",
332
- ) -> CodexSession:
106
+ ) -> Session:
333
107
  """
334
108
  Start a new Codex session in the background.
335
109
 
336
110
  Args:
337
111
  task: The task description
338
112
  working_dir: Working directory for codex (default: cwd)
339
- model: Model to use
340
- sandbox: Sandbox mode
113
+ model: Model override (default: from codex.toml or gpt-5.1-codex-mini)
114
+ sandbox: Sandbox mode (ignored if full_danger=true in codex.toml)
341
115
  source: Who spawned this session ("user" or "orchestrator:<id>")
342
- adapter: Which adapter to use ("codex", "claude_code")
343
116
 
344
117
  Returns:
345
118
  The created session
119
+
120
+ Note:
121
+ Settings are read from .zwarm/codex.toml and passed via -c overrides.
122
+ Run `zwarm init` to set up the config.
346
123
  """
347
124
  session_id = str(uuid4())
348
125
  working_dir = working_dir or Path.cwd()
349
126
  now = datetime.now().isoformat()
350
127
 
351
- session = CodexSession(
128
+ # Load codex config from .zwarm/codex.toml
129
+ codex_config = self._load_codex_config()
130
+
131
+ # Get model from config or use default
132
+ effective_model = model or codex_config.get("model", self.default_model)
133
+
134
+ # Check if full_danger mode is enabled
135
+ full_danger = codex_config.get("full_danger", False)
136
+
137
+ session = Session(
352
138
  id=session_id,
353
139
  task=task,
354
140
  status=SessionStatus.PENDING,
355
141
  working_dir=working_dir,
356
142
  created_at=now,
357
143
  updated_at=now,
358
- model=model,
144
+ model=effective_model,
359
145
  turn=1,
360
146
  messages=[SessionMessage(role="user", content=task, timestamp=now)],
361
147
  source=source,
362
- adapter=adapter,
148
+ adapter=self.adapter_name,
363
149
  )
364
150
 
365
151
  # Create session directory
366
152
  session_dir = self._session_dir(session_id)
367
153
  session_dir.mkdir(parents=True, exist_ok=True)
368
154
 
369
- # Build command
370
- cmd = [
371
- "codex", "exec",
155
+ # Build command with -c overrides from codex.toml
156
+ cmd = ["codex"]
157
+
158
+ # Add -c overrides from codex.toml (excluding special keys we handle separately)
159
+ config_for_overrides = {k: v for k, v in codex_config.items() if k not in ("model", "full_danger")}
160
+ cmd.extend(self._build_codex_overrides(config_for_overrides))
161
+
162
+ # Add exec command and flags
163
+ cmd.extend([
164
+ "exec",
372
165
  "--json",
373
- "--full-auto",
374
166
  "--skip-git-repo-check",
375
- "--model", model,
376
- "-C", str(working_dir.absolute()),
377
- ]
378
-
379
- # Add sandbox mode
380
- if sandbox == "danger-full-access":
167
+ "--model",
168
+ effective_model,
169
+ "-C",
170
+ str(working_dir.absolute()),
171
+ ])
172
+
173
+ # Full danger mode bypasses all safety controls
174
+ if full_danger:
381
175
  cmd.append("--dangerously-bypass-approvals-and-sandbox")
382
- elif sandbox == "workspace-write":
383
- # Default codex behavior
384
- pass
385
176
 
386
177
  cmd.extend(["--", task])
387
178
 
@@ -407,7 +198,7 @@ class CodexSessionManager:
407
198
  self,
408
199
  session_id: str,
409
200
  message: str,
410
- ) -> CodexSession | None:
201
+ ) -> Session | None:
411
202
  """
412
203
  Inject a follow-up message into a completed session.
413
204
 
@@ -425,8 +216,6 @@ class CodexSessionManager:
425
216
  return None
426
217
 
427
218
  if session.status == SessionStatus.RUNNING:
428
- # Can't inject while running - would need to implement
429
- # a more complex IPC mechanism for that
430
219
  return None
431
220
 
432
221
  # Build context from previous messages
@@ -449,18 +238,35 @@ Continue from where you left off, addressing the user's new message."""
449
238
  # Start new turn
450
239
  session.turn += 1
451
240
  now = datetime.now().isoformat()
452
- session.messages.append(SessionMessage(role="user", content=message, timestamp=now))
241
+ session.messages.append(
242
+ SessionMessage(role="user", content=message, timestamp=now)
243
+ )
453
244
 
454
- # Build command
455
- cmd = [
456
- "codex", "exec",
245
+ # Build command with -c overrides from codex.toml
246
+ codex_config = self._load_codex_config()
247
+ full_danger = codex_config.get("full_danger", False)
248
+
249
+ cmd = ["codex"]
250
+
251
+ # Add -c overrides from codex.toml (excluding special keys)
252
+ config_for_overrides = {k: v for k, v in codex_config.items() if k not in ("model", "full_danger")}
253
+ cmd.extend(self._build_codex_overrides(config_for_overrides))
254
+
255
+ cmd.extend([
256
+ "exec",
457
257
  "--json",
458
- "--full-auto",
459
258
  "--skip-git-repo-check",
460
- "--model", session.model,
461
- "-C", str(session.working_dir.absolute()),
462
- "--", augmented_task,
463
- ]
259
+ "--model",
260
+ session.model,
261
+ "-C",
262
+ str(session.working_dir.absolute()),
263
+ ])
264
+
265
+ # Full danger mode bypasses all safety controls
266
+ if full_danger:
267
+ cmd.append("--dangerously-bypass-approvals-and-sandbox")
268
+
269
+ cmd.extend(["--", augmented_task])
464
270
 
465
271
  # Start process
466
272
  output_path = self._output_path(session.id, session.turn)
@@ -480,105 +286,50 @@ Continue from where you left off, addressing the user's new message."""
480
286
 
481
287
  return session
482
288
 
483
- def kill_session(self, session_id: str, delete: bool = False) -> bool:
484
- """
485
- Kill a running session.
486
-
487
- Args:
488
- session_id: Session to kill
489
- delete: If True, also delete session data entirely
490
-
491
- Returns True if killed, False if not found or not running.
492
- """
493
- session = self.get_session(session_id)
494
- if not session:
495
- return False
496
-
497
- if session.pid and session.is_running:
498
- try:
499
- # Kill the entire process group
500
- os.killpg(os.getpgid(session.pid), signal.SIGTERM)
501
- time.sleep(0.5)
502
-
503
- # Force kill if still running
504
- if session.is_running:
505
- os.killpg(os.getpgid(session.pid), signal.SIGKILL)
506
- except (OSError, ProcessLookupError):
507
- pass
289
+ # =========================================================================
290
+ # Output parsing (Codex-specific JSONL format)
291
+ # =========================================================================
508
292
 
509
- if delete:
510
- return self.delete_session(session.id)
511
-
512
- session.status = SessionStatus.KILLED
513
- session.error = "Manually killed"
514
- self._save_session(session)
515
- return True
516
-
517
- def delete_session(self, session_id: str) -> bool:
293
+ def _is_output_complete(self, session_id: str, turn: int) -> bool:
518
294
  """
519
- Delete a session entirely (removes from disk).
520
-
521
- Kills the process first if still running.
295
+ Check if output file indicates the task completed.
522
296
 
523
- Returns True if deleted, False if not found.
297
+ Looks for completion markers like 'turn.completed' or 'task.completed'
298
+ in the JSONL output. This is more reliable than PID checking.
524
299
  """
525
- import shutil
526
-
527
- session = self.get_session(session_id)
528
- if not session:
300
+ output_path = self._output_path(session_id, turn)
301
+ if not output_path.exists():
529
302
  return False
530
303
 
531
- # Kill if running
532
- if session.pid and session.is_running:
533
- try:
534
- os.killpg(os.getpgid(session.pid), signal.SIGTERM)
535
- time.sleep(0.3)
536
- if session.is_running:
537
- os.killpg(os.getpgid(session.pid), signal.SIGKILL)
538
- except (OSError, ProcessLookupError):
539
- pass
540
-
541
- # Remove session directory
542
- session_dir = self._session_dir(session.id)
543
- if session_dir.exists():
544
- shutil.rmtree(session_dir)
545
- return True
304
+ try:
305
+ content = output_path.read_text()
306
+ for line in content.strip().split("\n"):
307
+ if not line.strip():
308
+ continue
309
+ try:
310
+ event = json.loads(line)
311
+ event_type = event.get("type", "")
312
+ # Check for any completion marker
313
+ if event_type in (
314
+ "turn.completed",
315
+ "task.completed",
316
+ "completed",
317
+ "done",
318
+ ):
319
+ return True
320
+ # Also check for error as a form of completion
321
+ if event_type == "error":
322
+ return True
323
+ except json.JSONDecodeError:
324
+ continue
325
+ except Exception:
326
+ pass
546
327
 
547
328
  return False
548
329
 
549
- def get_output(self, session_id: str, turn: int | None = None) -> str:
550
- """Get raw JSONL output for a session."""
551
- session = self.get_session(session_id)
552
- if not session:
553
- return ""
554
-
555
- if turn is None:
556
- turn = session.turn
557
-
558
- output_path = self._output_path(session.id, turn)
559
- if not output_path.exists():
560
- return ""
561
-
562
- return output_path.read_text()
563
-
564
- def get_messages(self, session_id: str) -> list[SessionMessage]:
565
- """Get parsed messages for a session across all turns."""
566
- session = self.get_session(session_id)
567
- if not session:
568
- return []
569
-
570
- all_messages = []
571
-
572
- # Get messages from each turn
573
- for turn in range(1, session.turn + 1):
574
- output_path = self._output_path(session.id, turn)
575
- if output_path.exists():
576
- messages, _, _ = self._parse_output(output_path)
577
- all_messages.extend(messages)
578
-
579
- return all_messages
580
-
581
- def _parse_output(self, output_path: Path) -> tuple[list[SessionMessage], dict[str, int], str | None]:
330
+ def _parse_output(
331
+ self, output_path: Path
332
+ ) -> tuple[list[SessionMessage], dict[str, int], str | None]:
582
333
  """
583
334
  Parse JSONL output from codex exec.
584
335
 
@@ -612,11 +363,13 @@ Continue from where you left off, addressing the user's new message."""
612
363
  if item_type == "agent_message":
613
364
  text = item.get("text", "")
614
365
  if text:
615
- messages.append(SessionMessage(
616
- role="assistant",
617
- content=text,
618
- timestamp=datetime.now().isoformat(),
619
- ))
366
+ messages.append(
367
+ SessionMessage(
368
+ role="assistant",
369
+ content=text,
370
+ timestamp=datetime.now().isoformat(),
371
+ )
372
+ )
620
373
 
621
374
  elif item_type == "reasoning":
622
375
  # Could optionally capture reasoning
@@ -625,19 +378,23 @@ Continue from where you left off, addressing the user's new message."""
625
378
  elif item_type == "function_call":
626
379
  # Track tool calls
627
380
  func_name = item.get("name", "unknown")
628
- messages.append(SessionMessage(
629
- role="tool",
630
- content=f"[Calling: {func_name}]",
631
- metadata={"function": func_name},
632
- ))
381
+ messages.append(
382
+ SessionMessage(
383
+ role="tool",
384
+ content=f"[Calling: {func_name}]",
385
+ metadata={"function": func_name},
386
+ )
387
+ )
633
388
 
634
389
  elif item_type == "function_call_output":
635
390
  output = item.get("output", "")
636
391
  if output and len(output) < 500:
637
- messages.append(SessionMessage(
638
- role="tool",
639
- content=f"[Output]: {output[:500]}",
640
- ))
392
+ messages.append(
393
+ SessionMessage(
394
+ role="tool",
395
+ content=f"[Output]: {output[:500]}",
396
+ )
397
+ )
641
398
 
642
399
  elif event_type == "turn.completed":
643
400
  turn_usage = event.get("usage", {})
@@ -645,14 +402,18 @@ Continue from where you left off, addressing the user's new message."""
645
402
  usage[key] = usage.get(key, 0) + value
646
403
  # Compute total_tokens if not present
647
404
  if "total_tokens" not in usage:
648
- usage["total_tokens"] = usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
405
+ usage["total_tokens"] = usage.get("input_tokens", 0) + usage.get(
406
+ "output_tokens", 0
407
+ )
649
408
 
650
409
  elif event_type == "error":
651
410
  error = event.get("message", str(event))
652
411
 
653
412
  return messages, usage, error
654
413
 
655
- def get_trajectory(self, session_id: str, full: bool = False, max_output_len: int = 200) -> list[dict]:
414
+ def get_trajectory(
415
+ self, session_id: str, full: bool = False, max_output_len: int = 200
416
+ ) -> list[dict]:
656
417
  """
657
418
  Get the full trajectory of a session - all steps in order.
658
419
 
@@ -699,13 +460,16 @@ Continue from where you left off, addressing the user's new message."""
699
460
  if item_type == "reasoning":
700
461
  text = item.get("text", "")
701
462
  summary_len = max_output_len if full else 100
702
- trajectory.append({
703
- "turn": turn,
704
- "step": step_num,
705
- "type": "reasoning",
706
- "summary": text[:summary_len] + ("..." if len(text) > summary_len else ""),
707
- "full_text": text if full else None,
708
- })
463
+ trajectory.append(
464
+ {
465
+ "turn": turn,
466
+ "step": step_num,
467
+ "type": "reasoning",
468
+ "summary": text[:summary_len]
469
+ + ("..." if len(text) > summary_len else ""),
470
+ "full_text": text if full else None,
471
+ }
472
+ )
709
473
 
710
474
  elif item_type == "command_execution":
711
475
  cmd = item.get("command", "")
@@ -715,78 +479,61 @@ Continue from where you left off, addressing the user's new message."""
715
479
  output_preview = output[:max_output_len]
716
480
  if len(output) > max_output_len:
717
481
  output_preview += "..."
718
- trajectory.append({
719
- "turn": turn,
720
- "step": step_num,
721
- "type": "command",
722
- "command": cmd,
723
- "output": output_preview.strip(),
724
- "exit_code": exit_code,
725
- })
482
+ trajectory.append(
483
+ {
484
+ "turn": turn,
485
+ "step": step_num,
486
+ "type": "command",
487
+ "command": cmd,
488
+ "output": output_preview.strip(),
489
+ "exit_code": exit_code,
490
+ }
491
+ )
726
492
 
727
493
  elif item_type == "function_call":
728
494
  func_name = item.get("name", "unknown")
729
495
  args = item.get("arguments", {})
730
496
  args_str = str(args)
731
497
  args_len = max_output_len if full else 100
732
- trajectory.append({
733
- "turn": turn,
734
- "step": step_num,
735
- "type": "tool_call",
736
- "tool": func_name,
737
- "args_preview": args_str[:args_len] + ("..." if len(args_str) > args_len else ""),
738
- "full_args": args if full else None,
739
- })
498
+ trajectory.append(
499
+ {
500
+ "turn": turn,
501
+ "step": step_num,
502
+ "type": "tool_call",
503
+ "tool": func_name,
504
+ "args_preview": args_str[:args_len]
505
+ + ("..." if len(args_str) > args_len else ""),
506
+ "full_args": args if full else None,
507
+ }
508
+ )
740
509
 
741
510
  elif item_type == "function_call_output":
742
511
  output = item.get("output", "")
743
512
  output_preview = output[:max_output_len]
744
513
  if len(output) > max_output_len:
745
514
  output_preview += "..."
746
- trajectory.append({
747
- "turn": turn,
748
- "step": step_num,
749
- "type": "tool_output",
750
- "output": output_preview,
751
- })
515
+ trajectory.append(
516
+ {
517
+ "turn": turn,
518
+ "step": step_num,
519
+ "type": "tool_output",
520
+ "output": output_preview,
521
+ }
522
+ )
752
523
 
753
524
  elif item_type == "agent_message":
754
525
  text = item.get("text", "")
755
526
  summary_len = max_output_len if full else 200
756
- trajectory.append({
757
- "turn": turn,
758
- "step": step_num,
759
- "type": "message",
760
- "summary": text[:summary_len] + ("..." if len(text) > summary_len else ""),
761
- "full_text": text if full else None,
762
- "full_length": len(text),
763
- })
527
+ trajectory.append(
528
+ {
529
+ "turn": turn,
530
+ "step": step_num,
531
+ "type": "message",
532
+ "summary": text[:summary_len]
533
+ + ("..." if len(text) > summary_len else ""),
534
+ "full_text": text if full else None,
535
+ "full_length": len(text),
536
+ }
537
+ )
764
538
 
765
539
  return trajectory
766
-
767
- def cleanup_completed(self, keep_days: int = 7) -> int:
768
- """
769
- Remove old completed/failed/killed sessions.
770
-
771
- Args:
772
- keep_days: Keep sessions newer than this many days
773
-
774
- Returns:
775
- Number of sessions cleaned up
776
- """
777
- import shutil
778
- from datetime import timedelta
779
-
780
- cutoff = datetime.now() - timedelta(days=keep_days)
781
- cleaned = 0
782
-
783
- for session in self.list_sessions():
784
- if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
785
- created = datetime.fromisoformat(session.created_at)
786
- if created < cutoff:
787
- session_dir = self._session_dir(session.id)
788
- if session_dir.exists():
789
- shutil.rmtree(session_dir)
790
- cleaned += 1
791
-
792
- return cleaned