zwarm 0.1.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/__init__.py +38 -0
- zwarm/adapters/__init__.py +0 -0
- zwarm/adapters/base.py +109 -0
- zwarm/adapters/claude_code.py +303 -0
- zwarm/adapters/codex_mcp.py +428 -0
- zwarm/adapters/test_codex_mcp.py +224 -0
- zwarm/cli/__init__.py +0 -0
- zwarm/cli/main.py +534 -0
- zwarm/core/__init__.py +0 -0
- zwarm/core/config.py +271 -0
- zwarm/core/environment.py +83 -0
- zwarm/core/models.py +299 -0
- zwarm/core/state.py +224 -0
- zwarm/core/test_config.py +160 -0
- zwarm/core/test_models.py +265 -0
- zwarm/orchestrator.py +405 -0
- zwarm/prompts/__init__.py +10 -0
- zwarm/prompts/orchestrator.py +214 -0
- zwarm/tools/__init__.py +17 -0
- zwarm/tools/delegation.py +357 -0
- zwarm/watchers/__init__.py +26 -0
- zwarm/watchers/base.py +131 -0
- zwarm/watchers/builtin.py +256 -0
- zwarm/watchers/manager.py +143 -0
- zwarm/watchers/registry.py +57 -0
- zwarm/watchers/test_watchers.py +195 -0
- zwarm-0.1.0.dist-info/METADATA +382 -0
- zwarm-0.1.0.dist-info/RECORD +30 -0
- zwarm-0.1.0.dist-info/WHEEL +4 -0
- zwarm-0.1.0.dist-info/entry_points.txt +2 -0
zwarm/core/config.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration system for zwarm.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- config.toml for user settings (weave project, defaults)
|
|
6
|
+
- .env for environment variables
|
|
7
|
+
- Composable YAML configs with inheritance (extends:)
|
|
8
|
+
- CLI overrides via --set key=value
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import tomllib
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class WeaveConfig:
|
|
25
|
+
"""Weave integration settings."""
|
|
26
|
+
|
|
27
|
+
project: str | None = None
|
|
28
|
+
enabled: bool = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ExecutorConfig:
|
|
33
|
+
"""Configuration for an executor (coding agent)."""
|
|
34
|
+
|
|
35
|
+
adapter: str = "codex_mcp" # codex_mcp | codex_exec | claude_code
|
|
36
|
+
model: str | None = None
|
|
37
|
+
sandbox: str = "workspace-write" # read-only | workspace-write | danger-full-access
|
|
38
|
+
timeout: int = 3600
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class OrchestratorConfig:
|
|
43
|
+
"""Configuration for the orchestrator."""
|
|
44
|
+
|
|
45
|
+
lm: str = "gpt-5-mini"
|
|
46
|
+
prompt: str | None = None # path to prompt yaml
|
|
47
|
+
tools: list[str] = field(default_factory=lambda: ["delegate", "converse", "check_session", "end_session", "bash"])
|
|
48
|
+
max_steps: int = 50
|
|
49
|
+
parallel_delegations: int = 4
|
|
50
|
+
sync_first: bool = True # prefer sync mode by default
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class WatcherConfigItem:
|
|
55
|
+
"""Configuration for a single watcher."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
enabled: bool = True
|
|
59
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class WatchersConfig:
|
|
64
|
+
"""Configuration for watchers."""
|
|
65
|
+
|
|
66
|
+
enabled: bool = True
|
|
67
|
+
watchers: list[WatcherConfigItem] = field(default_factory=lambda: [
|
|
68
|
+
WatcherConfigItem(name="progress"),
|
|
69
|
+
WatcherConfigItem(name="budget"),
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ZwarmConfig:
|
|
75
|
+
"""Root configuration for zwarm."""
|
|
76
|
+
|
|
77
|
+
weave: WeaveConfig = field(default_factory=WeaveConfig)
|
|
78
|
+
executor: ExecutorConfig = field(default_factory=ExecutorConfig)
|
|
79
|
+
orchestrator: OrchestratorConfig = field(default_factory=OrchestratorConfig)
|
|
80
|
+
watchers: WatchersConfig = field(default_factory=WatchersConfig)
|
|
81
|
+
state_dir: str = ".zwarm"
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: dict[str, Any]) -> ZwarmConfig:
|
|
85
|
+
"""Create config from dictionary."""
|
|
86
|
+
weave_data = data.get("weave", {})
|
|
87
|
+
executor_data = data.get("executor", {})
|
|
88
|
+
orchestrator_data = data.get("orchestrator", {})
|
|
89
|
+
watchers_data = data.get("watchers", {})
|
|
90
|
+
|
|
91
|
+
# Parse watchers config
|
|
92
|
+
watchers_config = WatchersConfig(
|
|
93
|
+
enabled=watchers_data.get("enabled", True),
|
|
94
|
+
watchers=[
|
|
95
|
+
WatcherConfigItem(**w) if isinstance(w, dict) else w
|
|
96
|
+
for w in watchers_data.get("watchers", [])
|
|
97
|
+
] or WatchersConfig().watchers,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return cls(
|
|
101
|
+
weave=WeaveConfig(**weave_data) if weave_data else WeaveConfig(),
|
|
102
|
+
executor=ExecutorConfig(**executor_data) if executor_data else ExecutorConfig(),
|
|
103
|
+
orchestrator=OrchestratorConfig(**orchestrator_data) if orchestrator_data else OrchestratorConfig(),
|
|
104
|
+
watchers=watchers_config,
|
|
105
|
+
state_dir=data.get("state_dir", ".zwarm"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, Any]:
|
|
109
|
+
"""Convert to dictionary."""
|
|
110
|
+
return {
|
|
111
|
+
"weave": {
|
|
112
|
+
"project": self.weave.project,
|
|
113
|
+
"enabled": self.weave.enabled,
|
|
114
|
+
},
|
|
115
|
+
"executor": {
|
|
116
|
+
"adapter": self.executor.adapter,
|
|
117
|
+
"model": self.executor.model,
|
|
118
|
+
"sandbox": self.executor.sandbox,
|
|
119
|
+
"timeout": self.executor.timeout,
|
|
120
|
+
},
|
|
121
|
+
"orchestrator": {
|
|
122
|
+
"lm": self.orchestrator.lm,
|
|
123
|
+
"prompt": self.orchestrator.prompt,
|
|
124
|
+
"tools": self.orchestrator.tools,
|
|
125
|
+
"max_steps": self.orchestrator.max_steps,
|
|
126
|
+
"parallel_delegations": self.orchestrator.parallel_delegations,
|
|
127
|
+
"sync_first": self.orchestrator.sync_first,
|
|
128
|
+
},
|
|
129
|
+
"watchers": {
|
|
130
|
+
"enabled": self.watchers.enabled,
|
|
131
|
+
"watchers": [
|
|
132
|
+
{"name": w.name, "enabled": w.enabled, "config": w.config}
|
|
133
|
+
for w in self.watchers.watchers
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
"state_dir": self.state_dir,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_env(path: Path | None = None) -> None:
|
|
141
|
+
"""Load .env file if it exists."""
|
|
142
|
+
if path is None:
|
|
143
|
+
path = Path.cwd() / ".env"
|
|
144
|
+
if path.exists():
|
|
145
|
+
load_dotenv(path)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_toml_config(path: Path | None = None) -> dict[str, Any]:
|
|
149
|
+
"""Load config.toml file."""
|
|
150
|
+
if path is None:
|
|
151
|
+
path = Path.cwd() / "config.toml"
|
|
152
|
+
if not path.exists():
|
|
153
|
+
return {}
|
|
154
|
+
with open(path, "rb") as f:
|
|
155
|
+
return tomllib.load(f)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_yaml_config(path: Path) -> dict[str, Any]:
|
|
159
|
+
"""
|
|
160
|
+
Load YAML config with inheritance support.
|
|
161
|
+
|
|
162
|
+
Supports 'extends: path/to/base.yaml' for composition.
|
|
163
|
+
"""
|
|
164
|
+
if not path.exists():
|
|
165
|
+
raise FileNotFoundError(f"Config not found: {path}")
|
|
166
|
+
|
|
167
|
+
with open(path) as f:
|
|
168
|
+
data = yaml.safe_load(f) or {}
|
|
169
|
+
|
|
170
|
+
# Handle inheritance
|
|
171
|
+
extends = data.pop("extends", None)
|
|
172
|
+
if extends:
|
|
173
|
+
base_path = (path.parent / extends).resolve()
|
|
174
|
+
base_data = load_yaml_config(base_path)
|
|
175
|
+
# Deep merge: data overrides base
|
|
176
|
+
data = deep_merge(base_data, data)
|
|
177
|
+
|
|
178
|
+
return data
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def deep_merge(base: dict, override: dict) -> dict:
|
|
182
|
+
"""Deep merge two dicts, with override taking precedence."""
|
|
183
|
+
result = base.copy()
|
|
184
|
+
for key, value in override.items():
|
|
185
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
186
|
+
result[key] = deep_merge(result[key], value)
|
|
187
|
+
else:
|
|
188
|
+
result[key] = value
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def apply_overrides(config: dict[str, Any], overrides: list[str]) -> dict[str, Any]:
|
|
193
|
+
"""
|
|
194
|
+
Apply CLI overrides in format 'key.path=value'.
|
|
195
|
+
|
|
196
|
+
Example: 'orchestrator.lm=claude-sonnet' sets config['orchestrator']['lm'] = 'claude-sonnet'
|
|
197
|
+
"""
|
|
198
|
+
result = config.copy()
|
|
199
|
+
for override in overrides:
|
|
200
|
+
if "=" not in override:
|
|
201
|
+
continue
|
|
202
|
+
key_path, value = override.split("=", 1)
|
|
203
|
+
keys = key_path.split(".")
|
|
204
|
+
|
|
205
|
+
# Parse value (try int, float, bool, then string)
|
|
206
|
+
parsed_value: Any = value
|
|
207
|
+
if value.lower() == "true":
|
|
208
|
+
parsed_value = True
|
|
209
|
+
elif value.lower() == "false":
|
|
210
|
+
parsed_value = False
|
|
211
|
+
else:
|
|
212
|
+
try:
|
|
213
|
+
parsed_value = int(value)
|
|
214
|
+
except ValueError:
|
|
215
|
+
try:
|
|
216
|
+
parsed_value = float(value)
|
|
217
|
+
except ValueError:
|
|
218
|
+
pass # Keep as string
|
|
219
|
+
|
|
220
|
+
# Navigate and set
|
|
221
|
+
target = result
|
|
222
|
+
for key in keys[:-1]:
|
|
223
|
+
if key not in target:
|
|
224
|
+
target[key] = {}
|
|
225
|
+
target = target[key]
|
|
226
|
+
target[keys[-1]] = parsed_value
|
|
227
|
+
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def load_config(
|
|
232
|
+
config_path: Path | None = None,
|
|
233
|
+
toml_path: Path | None = None,
|
|
234
|
+
env_path: Path | None = None,
|
|
235
|
+
overrides: list[str] | None = None,
|
|
236
|
+
) -> ZwarmConfig:
|
|
237
|
+
"""
|
|
238
|
+
Load configuration with full precedence chain:
|
|
239
|
+
1. Defaults (in dataclasses)
|
|
240
|
+
2. config.toml (user settings)
|
|
241
|
+
3. YAML config file (if provided)
|
|
242
|
+
4. CLI overrides (--set key=value)
|
|
243
|
+
5. Environment variables (for secrets)
|
|
244
|
+
"""
|
|
245
|
+
# Load .env first (for secrets)
|
|
246
|
+
load_env(env_path)
|
|
247
|
+
|
|
248
|
+
# Start with defaults
|
|
249
|
+
config_dict: dict[str, Any] = {}
|
|
250
|
+
|
|
251
|
+
# Layer in config.toml
|
|
252
|
+
toml_config = load_toml_config(toml_path)
|
|
253
|
+
if toml_config:
|
|
254
|
+
config_dict = deep_merge(config_dict, toml_config)
|
|
255
|
+
|
|
256
|
+
# Layer in YAML config
|
|
257
|
+
if config_path and config_path.exists():
|
|
258
|
+
yaml_config = load_yaml_config(config_path)
|
|
259
|
+
config_dict = deep_merge(config_dict, yaml_config)
|
|
260
|
+
|
|
261
|
+
# Apply CLI overrides
|
|
262
|
+
if overrides:
|
|
263
|
+
config_dict = apply_overrides(config_dict, overrides)
|
|
264
|
+
|
|
265
|
+
# Apply environment variables for weave
|
|
266
|
+
if os.getenv("WEAVE_PROJECT"):
|
|
267
|
+
if "weave" not in config_dict:
|
|
268
|
+
config_dict["weave"] = {}
|
|
269
|
+
config_dict["weave"]["project"] = os.getenv("WEAVE_PROJECT")
|
|
270
|
+
|
|
271
|
+
return ZwarmConfig.from_dict(config_dict)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OrchestratorEnv: A lean environment for the zwarm orchestrator.
|
|
3
|
+
|
|
4
|
+
Unlike ChatEnv, this environment:
|
|
5
|
+
- Has no notes/observations (we use StateManager instead)
|
|
6
|
+
- Has no chat() tool (orchestrator communicates via output_handler)
|
|
7
|
+
- Shows active sessions in observe() for context
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from wbal.environment import Environment
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from zwarm.core.models import ConversationSession
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OrchestratorEnv(Environment):
|
|
22
|
+
"""
|
|
23
|
+
Lean environment for the orchestrator agent.
|
|
24
|
+
|
|
25
|
+
Provides:
|
|
26
|
+
- Task context
|
|
27
|
+
- Working directory info
|
|
28
|
+
- Active session visibility
|
|
29
|
+
- Output handler for messages
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
task: str = ""
|
|
33
|
+
working_dir: Path = Path(".")
|
|
34
|
+
output_handler: Callable[[str], None] = lambda x: print(x)
|
|
35
|
+
|
|
36
|
+
# Session tracking (set by orchestrator)
|
|
37
|
+
_sessions: dict[str, "ConversationSession"] | None = None
|
|
38
|
+
|
|
39
|
+
def set_sessions(self, sessions: dict[str, "ConversationSession"]) -> None:
|
|
40
|
+
"""Set the sessions dict for observe() visibility."""
|
|
41
|
+
self._sessions = sessions
|
|
42
|
+
|
|
43
|
+
def observe(self) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Return observable state for the orchestrator.
|
|
46
|
+
|
|
47
|
+
Shows:
|
|
48
|
+
- Current task
|
|
49
|
+
- Working directory
|
|
50
|
+
- Active sessions with their status
|
|
51
|
+
"""
|
|
52
|
+
parts = []
|
|
53
|
+
|
|
54
|
+
# Task
|
|
55
|
+
if self.task:
|
|
56
|
+
parts.append(f"## Current Task\n{self.task}")
|
|
57
|
+
|
|
58
|
+
# Working directory
|
|
59
|
+
parts.append(f"## Working Directory\n{self.working_dir.absolute()}")
|
|
60
|
+
|
|
61
|
+
# Active sessions
|
|
62
|
+
if self._sessions:
|
|
63
|
+
session_lines = []
|
|
64
|
+
for sid, session in self._sessions.items():
|
|
65
|
+
status_icon = {
|
|
66
|
+
"active": "[ACTIVE]",
|
|
67
|
+
"completed": "[DONE]",
|
|
68
|
+
"failed": "[FAILED]",
|
|
69
|
+
}.get(session.status.value, "[?]")
|
|
70
|
+
|
|
71
|
+
mode_icon = "sync" if session.mode.value == "sync" else "async"
|
|
72
|
+
task_preview = session.task_description[:60] + "..." if len(session.task_description) > 60 else session.task_description
|
|
73
|
+
|
|
74
|
+
session_lines.append(
|
|
75
|
+
f" - {sid[:8]}... {status_icon} ({mode_icon}, {session.adapter}) {task_preview}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if session_lines:
|
|
79
|
+
parts.append("## Sessions\n" + "\n".join(session_lines))
|
|
80
|
+
else:
|
|
81
|
+
parts.append("## Sessions\n (none)")
|
|
82
|
+
|
|
83
|
+
return "\n\n".join(parts)
|
zwarm/core/models.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core models for zwarm.
|
|
3
|
+
|
|
4
|
+
These are the fundamental data structures:
|
|
5
|
+
- ConversationSession: A session with an executor agent (sync or async)
|
|
6
|
+
- Task: A unit of work that may be delegated
|
|
7
|
+
- Event: An append-only log entry for audit/debugging
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SessionMode(str, Enum):
|
|
22
|
+
"""Execution mode for a session."""
|
|
23
|
+
|
|
24
|
+
SYNC = "sync"
|
|
25
|
+
ASYNC = "async"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionStatus(str, Enum):
|
|
29
|
+
"""Status of a conversation session."""
|
|
30
|
+
|
|
31
|
+
ACTIVE = "active"
|
|
32
|
+
COMPLETED = "completed"
|
|
33
|
+
FAILED = "failed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TaskStatus(str, Enum):
|
|
37
|
+
"""Status of a task."""
|
|
38
|
+
|
|
39
|
+
PENDING = "pending"
|
|
40
|
+
IN_PROGRESS = "in_progress"
|
|
41
|
+
COMPLETED = "completed"
|
|
42
|
+
FAILED = "failed"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Message:
|
|
47
|
+
"""A single message in a conversation."""
|
|
48
|
+
|
|
49
|
+
role: Literal["user", "assistant", "system"]
|
|
50
|
+
content: str
|
|
51
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"role": self.role,
|
|
56
|
+
"content": self.content,
|
|
57
|
+
"timestamp": self.timestamp.isoformat(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, data: dict[str, Any]) -> Message:
|
|
62
|
+
return cls(
|
|
63
|
+
role=data["role"],
|
|
64
|
+
content=data["content"],
|
|
65
|
+
timestamp=datetime.fromisoformat(data["timestamp"]) if "timestamp" in data else datetime.now(),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ConversationSession:
|
|
71
|
+
"""
|
|
72
|
+
A conversational session with an executor agent.
|
|
73
|
+
|
|
74
|
+
Supports both sync (iterative conversation) and async (fire-and-forget) modes.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
78
|
+
adapter: str = "codex_mcp" # codex_mcp | codex_exec | claude_code
|
|
79
|
+
mode: SessionMode = SessionMode.SYNC
|
|
80
|
+
status: SessionStatus = SessionStatus.ACTIVE
|
|
81
|
+
working_dir: Path = field(default_factory=Path.cwd)
|
|
82
|
+
messages: list[Message] = field(default_factory=list)
|
|
83
|
+
started_at: datetime = field(default_factory=datetime.now)
|
|
84
|
+
completed_at: datetime | None = None
|
|
85
|
+
|
|
86
|
+
# Adapter-specific handles (not serialized)
|
|
87
|
+
conversation_id: str | None = None # MCP conversationId for codex
|
|
88
|
+
process: subprocess.Popen | None = field(default=None, repr=False)
|
|
89
|
+
|
|
90
|
+
# Metadata
|
|
91
|
+
task_description: str = ""
|
|
92
|
+
model: str | None = None
|
|
93
|
+
exit_message: str | None = None
|
|
94
|
+
|
|
95
|
+
def add_message(self, role: Literal["user", "assistant", "system"], content: str) -> Message:
|
|
96
|
+
"""Add a message to the conversation."""
|
|
97
|
+
msg = Message(role=role, content=content)
|
|
98
|
+
self.messages.append(msg)
|
|
99
|
+
return msg
|
|
100
|
+
|
|
101
|
+
def complete(self, exit_message: str | None = None) -> None:
|
|
102
|
+
"""Mark session as completed."""
|
|
103
|
+
self.status = SessionStatus.COMPLETED
|
|
104
|
+
self.completed_at = datetime.now()
|
|
105
|
+
self.exit_message = exit_message
|
|
106
|
+
|
|
107
|
+
def fail(self, error: str | None = None) -> None:
|
|
108
|
+
"""Mark session as failed."""
|
|
109
|
+
self.status = SessionStatus.FAILED
|
|
110
|
+
self.completed_at = datetime.now()
|
|
111
|
+
self.exit_message = error
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict[str, Any]:
|
|
114
|
+
"""Serialize to dictionary (for persistence)."""
|
|
115
|
+
return {
|
|
116
|
+
"id": self.id,
|
|
117
|
+
"adapter": self.adapter,
|
|
118
|
+
"mode": self.mode.value,
|
|
119
|
+
"status": self.status.value,
|
|
120
|
+
"working_dir": str(self.working_dir),
|
|
121
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
122
|
+
"started_at": self.started_at.isoformat(),
|
|
123
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
124
|
+
"conversation_id": self.conversation_id,
|
|
125
|
+
"task_description": self.task_description,
|
|
126
|
+
"model": self.model,
|
|
127
|
+
"exit_message": self.exit_message,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_dict(cls, data: dict[str, Any]) -> ConversationSession:
|
|
132
|
+
"""Deserialize from dictionary."""
|
|
133
|
+
return cls(
|
|
134
|
+
id=data["id"],
|
|
135
|
+
adapter=data.get("adapter", "codex_mcp"),
|
|
136
|
+
mode=SessionMode(data["mode"]),
|
|
137
|
+
status=SessionStatus(data["status"]),
|
|
138
|
+
working_dir=Path(data["working_dir"]),
|
|
139
|
+
messages=[Message.from_dict(m) for m in data.get("messages", [])],
|
|
140
|
+
started_at=datetime.fromisoformat(data["started_at"]),
|
|
141
|
+
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
|
|
142
|
+
conversation_id=data.get("conversation_id"),
|
|
143
|
+
task_description=data.get("task_description", ""),
|
|
144
|
+
model=data.get("model"),
|
|
145
|
+
exit_message=data.get("exit_message"),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class Task:
|
|
151
|
+
"""
|
|
152
|
+
A unit of work that may be delegated to an executor.
|
|
153
|
+
|
|
154
|
+
Tasks track what needs to be done and link to the session doing the work.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
158
|
+
description: str = ""
|
|
159
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
160
|
+
session_id: str | None = None
|
|
161
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
162
|
+
completed_at: datetime | None = None
|
|
163
|
+
result: str | None = None
|
|
164
|
+
parent_task_id: str | None = None # For subtasks
|
|
165
|
+
|
|
166
|
+
def start(self, session_id: str) -> None:
|
|
167
|
+
"""Mark task as started with a session."""
|
|
168
|
+
self.status = TaskStatus.IN_PROGRESS
|
|
169
|
+
self.session_id = session_id
|
|
170
|
+
|
|
171
|
+
def complete(self, result: str | None = None) -> None:
|
|
172
|
+
"""Mark task as completed."""
|
|
173
|
+
self.status = TaskStatus.COMPLETED
|
|
174
|
+
self.completed_at = datetime.now()
|
|
175
|
+
self.result = result
|
|
176
|
+
|
|
177
|
+
def fail(self, error: str | None = None) -> None:
|
|
178
|
+
"""Mark task as failed."""
|
|
179
|
+
self.status = TaskStatus.FAILED
|
|
180
|
+
self.completed_at = datetime.now()
|
|
181
|
+
self.result = error
|
|
182
|
+
|
|
183
|
+
def to_dict(self) -> dict[str, Any]:
|
|
184
|
+
return {
|
|
185
|
+
"id": self.id,
|
|
186
|
+
"description": self.description,
|
|
187
|
+
"status": self.status.value,
|
|
188
|
+
"session_id": self.session_id,
|
|
189
|
+
"created_at": self.created_at.isoformat(),
|
|
190
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
191
|
+
"result": self.result,
|
|
192
|
+
"parent_task_id": self.parent_task_id,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_dict(cls, data: dict[str, Any]) -> Task:
|
|
197
|
+
return cls(
|
|
198
|
+
id=data["id"],
|
|
199
|
+
description=data["description"],
|
|
200
|
+
status=TaskStatus(data["status"]),
|
|
201
|
+
session_id=data.get("session_id"),
|
|
202
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
203
|
+
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
|
|
204
|
+
result=data.get("result"),
|
|
205
|
+
parent_task_id=data.get("parent_task_id"),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class Event:
|
|
211
|
+
"""
|
|
212
|
+
An append-only log entry for audit and debugging.
|
|
213
|
+
|
|
214
|
+
Events capture everything that happens in the system.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
218
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
219
|
+
kind: str = "" # session_started, message_sent, task_completed, etc.
|
|
220
|
+
session_id: str | None = None
|
|
221
|
+
task_id: str | None = None
|
|
222
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
223
|
+
|
|
224
|
+
def to_dict(self) -> dict[str, Any]:
|
|
225
|
+
return {
|
|
226
|
+
"id": self.id,
|
|
227
|
+
"timestamp": self.timestamp.isoformat(),
|
|
228
|
+
"kind": self.kind,
|
|
229
|
+
"session_id": self.session_id,
|
|
230
|
+
"task_id": self.task_id,
|
|
231
|
+
"payload": self.payload,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def from_dict(cls, data: dict[str, Any]) -> Event:
|
|
236
|
+
return cls(
|
|
237
|
+
id=data["id"],
|
|
238
|
+
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
239
|
+
kind=data["kind"],
|
|
240
|
+
session_id=data.get("session_id"),
|
|
241
|
+
task_id=data.get("task_id"),
|
|
242
|
+
payload=data.get("payload", {}),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# Event factory functions for common event types
|
|
247
|
+
def event_session_started(session: ConversationSession) -> Event:
|
|
248
|
+
return Event(
|
|
249
|
+
kind="session_started",
|
|
250
|
+
session_id=session.id,
|
|
251
|
+
payload={
|
|
252
|
+
"adapter": session.adapter,
|
|
253
|
+
"mode": session.mode.value,
|
|
254
|
+
"task": session.task_description,
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def event_message_sent(session: ConversationSession, message: Message) -> Event:
|
|
260
|
+
return Event(
|
|
261
|
+
kind="message_sent",
|
|
262
|
+
session_id=session.id,
|
|
263
|
+
payload={
|
|
264
|
+
"role": message.role,
|
|
265
|
+
"content": message.content[:500], # Truncate for log
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def event_session_completed(session: ConversationSession) -> Event:
|
|
271
|
+
return Event(
|
|
272
|
+
kind="session_completed",
|
|
273
|
+
session_id=session.id,
|
|
274
|
+
payload={
|
|
275
|
+
"status": session.status.value,
|
|
276
|
+
"exit_message": session.exit_message,
|
|
277
|
+
"message_count": len(session.messages),
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def event_task_created(task: Task) -> Event:
|
|
283
|
+
return Event(
|
|
284
|
+
kind="task_created",
|
|
285
|
+
task_id=task.id,
|
|
286
|
+
payload={"description": task.description},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def event_task_completed(task: Task) -> Event:
|
|
291
|
+
return Event(
|
|
292
|
+
kind="task_completed",
|
|
293
|
+
task_id=task.id,
|
|
294
|
+
session_id=task.session_id,
|
|
295
|
+
payload={
|
|
296
|
+
"status": task.status.value,
|
|
297
|
+
"result": task.result[:500] if task.result else None,
|
|
298
|
+
},
|
|
299
|
+
)
|