zwarm 2.3.5__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.
@@ -0,0 +1,173 @@
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, step progress, and budget in observe()
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, Callable
14
+
15
+ from pydantic import PrivateAttr
16
+ from wbal.environment import Environment
17
+
18
+ if TYPE_CHECKING:
19
+ from zwarm.core.models import ConversationSession
20
+ from zwarm.sessions import CodexSessionManager
21
+
22
+
23
+ class OrchestratorEnv(Environment):
24
+ """
25
+ Lean environment for the orchestrator agent.
26
+
27
+ Provides:
28
+ - Task context
29
+ - Working directory info
30
+ - Active session visibility
31
+ - Step progress tracking
32
+ - Budget/resource monitoring
33
+ - Output handler for messages
34
+ """
35
+
36
+ task: str = ""
37
+ working_dir: Path = Path(".")
38
+ output_handler: Callable[[str], None] = lambda x: print(x)
39
+
40
+ # Session manager (set by orchestrator) - pulls live data each observe()
41
+ _session_manager: "CodexSessionManager | None" = PrivateAttr(default=None)
42
+
43
+ # Legacy: old sessions dict (deprecated, for backwards compat)
44
+ _sessions: dict[str, "ConversationSession"] | None = PrivateAttr(default=None)
45
+
46
+ # Progress tracking (updated by orchestrator each step)
47
+ _step_count: int = PrivateAttr(default=0)
48
+ _max_steps: int = PrivateAttr(default=50)
49
+ _total_tokens: int = PrivateAttr(default=0)
50
+ _executor_tokens: int = PrivateAttr(default=0) # Executor token usage
51
+
52
+ # Budget config (set from config)
53
+ _budget_max_sessions: int | None = PrivateAttr(default=None)
54
+
55
+ def set_session_manager(self, manager: "CodexSessionManager") -> None:
56
+ """Set the session manager for live session visibility in observe()."""
57
+ self._session_manager = manager
58
+
59
+ def set_sessions(self, sessions: dict[str, "ConversationSession"]) -> None:
60
+ """Legacy: Set the sessions dict for observe() visibility."""
61
+ self._sessions = sessions
62
+
63
+ def update_progress(
64
+ self,
65
+ step_count: int,
66
+ max_steps: int,
67
+ total_tokens: int = 0,
68
+ executor_tokens: int = 0,
69
+ ) -> None:
70
+ """Update progress tracking (called by orchestrator each step)."""
71
+ self._step_count = step_count
72
+ self._max_steps = max_steps
73
+ self._total_tokens = total_tokens
74
+ self._executor_tokens = executor_tokens
75
+
76
+ def set_budget(self, max_sessions: int | None = None) -> None:
77
+ """Set budget limits from config."""
78
+ self._budget_max_sessions = max_sessions
79
+
80
+ def observe(self) -> str:
81
+ """
82
+ Return observable state for the orchestrator.
83
+
84
+ Shows:
85
+ - Progress (steps, tokens)
86
+ - Session summary (pulled LIVE from CodexSessionManager)
87
+ - Active sessions with their status
88
+ - Working directory
89
+
90
+ Note: Task is NOT included here as it's already in the user message.
91
+ """
92
+ parts = []
93
+
94
+ # Progress bar and stats
95
+ progress_pct = (
96
+ (self._step_count / self._max_steps * 100) if self._max_steps > 0 else 0
97
+ )
98
+ bar_len = 20
99
+ filled = (
100
+ int(bar_len * self._step_count / self._max_steps)
101
+ if self._max_steps > 0
102
+ else 0
103
+ )
104
+ bar = "█" * filled + "░" * (bar_len - filled)
105
+
106
+ progress_lines = [
107
+ f"Steps: [{bar}] {self._step_count}/{self._max_steps} ({progress_pct:.0f}%)",
108
+ ]
109
+ if self._total_tokens > 0 or self._executor_tokens > 0:
110
+ token_parts = []
111
+ if self._total_tokens > 0:
112
+ token_parts.append(f"orchestrator: ~{self._total_tokens:,}")
113
+ if self._executor_tokens > 0:
114
+ token_parts.append(f"executors: ~{self._executor_tokens:,}")
115
+ progress_lines.append(f"Tokens: {', '.join(token_parts)}")
116
+
117
+ parts.append("## Progress\n" + "\n".join(progress_lines))
118
+
119
+ # Session summary - pull LIVE from CodexSessionManager
120
+ if self._session_manager is not None:
121
+ sessions = self._session_manager.list_sessions()
122
+
123
+ running = sum(1 for s in sessions if s.status.value == "running")
124
+ completed = sum(1 for s in sessions if s.status.value == "completed")
125
+ failed = sum(1 for s in sessions if s.status.value == "failed")
126
+ total = len(sessions)
127
+
128
+ summary = f"Sessions: {running} running, {completed} done, {failed} failed ({total} total)"
129
+ if self._budget_max_sessions:
130
+ summary += f" [limit: {self._budget_max_sessions}]"
131
+
132
+ parts.append(f"## Resources\n{summary}")
133
+
134
+ # Running sessions detail
135
+ running_sessions = [s for s in sessions if s.status.value == "running"]
136
+ if running_sessions:
137
+ session_lines = []
138
+ for session in running_sessions:
139
+ task_preview = (
140
+ session.task[:50] + "..."
141
+ if len(session.task) > 50
142
+ else session.task
143
+ )
144
+ tokens = session.token_usage.get("total_tokens", 0)
145
+ token_info = f", {tokens:,} tok" if tokens else ""
146
+ session_lines.append(
147
+ f" • {session.short_id} (turn {session.turn}{token_info}): {task_preview}"
148
+ )
149
+ parts.append("## Running Sessions\n" + "\n".join(session_lines))
150
+
151
+ # Recently completed (for visibility)
152
+ recent_completed = [
153
+ s for s in sessions
154
+ if s.status.value == "completed"
155
+ ][:3] # Last 3 completed
156
+ if recent_completed:
157
+ session_lines = []
158
+ for session in recent_completed:
159
+ task_preview = (
160
+ session.task[:40] + "..."
161
+ if len(session.task) > 40
162
+ else session.task
163
+ )
164
+ tokens = session.token_usage.get("total_tokens", 0)
165
+ session_lines.append(
166
+ f" • {session.short_id} ✓ ({tokens:,} tok): {task_preview}"
167
+ )
168
+ parts.append("## Recently Completed\n" + "\n".join(session_lines))
169
+
170
+ # Working directory (less prominent)
171
+ parts.append(f"## Context\nWorking dir: {self.working_dir.absolute()}")
172
+
173
+ return "\n\n".join(parts)
zwarm/core/models.py ADDED
@@ -0,0 +1,315 @@
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
+ # Token usage tracking for cost calculation
96
+ token_usage: dict[str, int] = field(default_factory=lambda: {
97
+ "input_tokens": 0,
98
+ "output_tokens": 0,
99
+ "total_tokens": 0,
100
+ })
101
+
102
+ def add_usage(self, usage: dict[str, int]) -> None:
103
+ """Add token usage from an interaction."""
104
+ if not usage:
105
+ return
106
+ for key in self.token_usage:
107
+ self.token_usage[key] += usage.get(key, 0)
108
+
109
+ def add_message(self, role: Literal["user", "assistant", "system"], content: str) -> Message:
110
+ """Add a message to the conversation."""
111
+ msg = Message(role=role, content=content)
112
+ self.messages.append(msg)
113
+ return msg
114
+
115
+ def complete(self, exit_message: str | None = None) -> None:
116
+ """Mark session as completed."""
117
+ self.status = SessionStatus.COMPLETED
118
+ self.completed_at = datetime.now()
119
+ self.exit_message = exit_message
120
+
121
+ def fail(self, error: str | None = None) -> None:
122
+ """Mark session as failed."""
123
+ self.status = SessionStatus.FAILED
124
+ self.completed_at = datetime.now()
125
+ self.exit_message = error
126
+
127
+ def to_dict(self) -> dict[str, Any]:
128
+ """Serialize to dictionary (for persistence)."""
129
+ return {
130
+ "id": self.id,
131
+ "adapter": self.adapter,
132
+ "mode": self.mode.value,
133
+ "status": self.status.value,
134
+ "working_dir": str(self.working_dir),
135
+ "messages": [m.to_dict() for m in self.messages],
136
+ "started_at": self.started_at.isoformat(),
137
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
138
+ "conversation_id": self.conversation_id,
139
+ "task_description": self.task_description,
140
+ "model": self.model,
141
+ "exit_message": self.exit_message,
142
+ "token_usage": self.token_usage,
143
+ }
144
+
145
+ @classmethod
146
+ def from_dict(cls, data: dict[str, Any]) -> ConversationSession:
147
+ """Deserialize from dictionary."""
148
+ return cls(
149
+ id=data["id"],
150
+ adapter=data.get("adapter", "codex_mcp"),
151
+ mode=SessionMode(data["mode"]),
152
+ status=SessionStatus(data["status"]),
153
+ working_dir=Path(data["working_dir"]),
154
+ messages=[Message.from_dict(m) for m in data.get("messages", [])],
155
+ started_at=datetime.fromisoformat(data["started_at"]),
156
+ completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
157
+ conversation_id=data.get("conversation_id"),
158
+ task_description=data.get("task_description", ""),
159
+ model=data.get("model"),
160
+ exit_message=data.get("exit_message"),
161
+ token_usage=data.get("token_usage", {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}),
162
+ )
163
+
164
+
165
+ @dataclass
166
+ class Task:
167
+ """
168
+ A unit of work that may be delegated to an executor.
169
+
170
+ Tasks track what needs to be done and link to the session doing the work.
171
+ """
172
+
173
+ id: str = field(default_factory=lambda: str(uuid4()))
174
+ description: str = ""
175
+ status: TaskStatus = TaskStatus.PENDING
176
+ session_id: str | None = None
177
+ created_at: datetime = field(default_factory=datetime.now)
178
+ completed_at: datetime | None = None
179
+ result: str | None = None
180
+ parent_task_id: str | None = None # For subtasks
181
+
182
+ def start(self, session_id: str) -> None:
183
+ """Mark task as started with a session."""
184
+ self.status = TaskStatus.IN_PROGRESS
185
+ self.session_id = session_id
186
+
187
+ def complete(self, result: str | None = None) -> None:
188
+ """Mark task as completed."""
189
+ self.status = TaskStatus.COMPLETED
190
+ self.completed_at = datetime.now()
191
+ self.result = result
192
+
193
+ def fail(self, error: str | None = None) -> None:
194
+ """Mark task as failed."""
195
+ self.status = TaskStatus.FAILED
196
+ self.completed_at = datetime.now()
197
+ self.result = error
198
+
199
+ def to_dict(self) -> dict[str, Any]:
200
+ return {
201
+ "id": self.id,
202
+ "description": self.description,
203
+ "status": self.status.value,
204
+ "session_id": self.session_id,
205
+ "created_at": self.created_at.isoformat(),
206
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
207
+ "result": self.result,
208
+ "parent_task_id": self.parent_task_id,
209
+ }
210
+
211
+ @classmethod
212
+ def from_dict(cls, data: dict[str, Any]) -> Task:
213
+ return cls(
214
+ id=data["id"],
215
+ description=data["description"],
216
+ status=TaskStatus(data["status"]),
217
+ session_id=data.get("session_id"),
218
+ created_at=datetime.fromisoformat(data["created_at"]),
219
+ completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
220
+ result=data.get("result"),
221
+ parent_task_id=data.get("parent_task_id"),
222
+ )
223
+
224
+
225
+ @dataclass
226
+ class Event:
227
+ """
228
+ An append-only log entry for audit and debugging.
229
+
230
+ Events capture everything that happens in the system.
231
+ """
232
+
233
+ id: str = field(default_factory=lambda: str(uuid4()))
234
+ timestamp: datetime = field(default_factory=datetime.now)
235
+ kind: str = "" # session_started, message_sent, task_completed, etc.
236
+ session_id: str | None = None
237
+ task_id: str | None = None
238
+ payload: dict[str, Any] = field(default_factory=dict)
239
+
240
+ def to_dict(self) -> dict[str, Any]:
241
+ return {
242
+ "id": self.id,
243
+ "timestamp": self.timestamp.isoformat(),
244
+ "kind": self.kind,
245
+ "session_id": self.session_id,
246
+ "task_id": self.task_id,
247
+ "payload": self.payload,
248
+ }
249
+
250
+ @classmethod
251
+ def from_dict(cls, data: dict[str, Any]) -> Event:
252
+ return cls(
253
+ id=data["id"],
254
+ timestamp=datetime.fromisoformat(data["timestamp"]),
255
+ kind=data["kind"],
256
+ session_id=data.get("session_id"),
257
+ task_id=data.get("task_id"),
258
+ payload=data.get("payload", {}),
259
+ )
260
+
261
+
262
+ # Event factory functions for common event types
263
+ def event_session_started(session: ConversationSession) -> Event:
264
+ return Event(
265
+ kind="session_started",
266
+ session_id=session.id,
267
+ payload={
268
+ "adapter": session.adapter,
269
+ "mode": session.mode.value,
270
+ "task": session.task_description,
271
+ },
272
+ )
273
+
274
+
275
+ def event_message_sent(session: ConversationSession, message: Message) -> Event:
276
+ return Event(
277
+ kind="message_sent",
278
+ session_id=session.id,
279
+ payload={
280
+ "role": message.role,
281
+ "content": message.content[:500], # Truncate for log
282
+ },
283
+ )
284
+
285
+
286
+ def event_session_completed(session: ConversationSession) -> Event:
287
+ return Event(
288
+ kind="session_completed",
289
+ session_id=session.id,
290
+ payload={
291
+ "status": session.status.value,
292
+ "exit_message": session.exit_message,
293
+ "message_count": len(session.messages),
294
+ },
295
+ )
296
+
297
+
298
+ def event_task_created(task: Task) -> Event:
299
+ return Event(
300
+ kind="task_created",
301
+ task_id=task.id,
302
+ payload={"description": task.description},
303
+ )
304
+
305
+
306
+ def event_task_completed(task: Task) -> Event:
307
+ return Event(
308
+ kind="task_completed",
309
+ task_id=task.id,
310
+ session_id=task.session_id,
311
+ payload={
312
+ "status": task.status.value,
313
+ "result": task.result[:500] if task.result else None,
314
+ },
315
+ )