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/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
+ )