zwarm 1.3.10__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,154 @@
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
+
21
+
22
+ class OrchestratorEnv(Environment):
23
+ """
24
+ Lean environment for the orchestrator agent.
25
+
26
+ Provides:
27
+ - Task context
28
+ - Working directory info
29
+ - Active session visibility
30
+ - Step progress tracking
31
+ - Budget/resource monitoring
32
+ - Output handler for messages
33
+ """
34
+
35
+ task: str = ""
36
+ working_dir: Path = Path(".")
37
+ output_handler: Callable[[str], None] = lambda x: print(x)
38
+
39
+ # Session tracking (set by orchestrator)
40
+ _sessions: dict[str, "ConversationSession"] | None = PrivateAttr(default=None)
41
+
42
+ # Progress tracking (updated by orchestrator each step)
43
+ _step_count: int = PrivateAttr(default=0)
44
+ _max_steps: int = PrivateAttr(default=50)
45
+ _total_tokens: int = PrivateAttr(default=0)
46
+ _executor_tokens: int = PrivateAttr(default=0) # Executor token usage
47
+
48
+ # Budget config (set from config)
49
+ _budget_max_sessions: int | None = PrivateAttr(default=None)
50
+
51
+ def set_sessions(self, sessions: dict[str, "ConversationSession"]) -> None:
52
+ """Set the sessions dict for observe() visibility."""
53
+ self._sessions = sessions
54
+
55
+ def update_progress(
56
+ self,
57
+ step_count: int,
58
+ max_steps: int,
59
+ total_tokens: int = 0,
60
+ executor_tokens: int = 0,
61
+ ) -> None:
62
+ """Update progress tracking (called by orchestrator each step)."""
63
+ self._step_count = step_count
64
+ self._max_steps = max_steps
65
+ self._total_tokens = total_tokens
66
+ self._executor_tokens = executor_tokens
67
+
68
+ def set_budget(self, max_sessions: int | None = None) -> None:
69
+ """Set budget limits from config."""
70
+ self._budget_max_sessions = max_sessions
71
+
72
+ def observe(self) -> str:
73
+ """
74
+ Return observable state for the orchestrator.
75
+
76
+ Shows:
77
+ - Progress (steps, tokens)
78
+ - Session summary
79
+ - Active sessions with their status
80
+ - Working directory
81
+
82
+ Note: Task is NOT included here as it's already in the user message.
83
+ """
84
+ parts = []
85
+
86
+ # Progress bar and stats
87
+ progress_pct = (
88
+ (self._step_count / self._max_steps * 100) if self._max_steps > 0 else 0
89
+ )
90
+ bar_len = 20
91
+ filled = (
92
+ int(bar_len * self._step_count / self._max_steps)
93
+ if self._max_steps > 0
94
+ else 0
95
+ )
96
+ bar = "█" * filled + "░" * (bar_len - filled)
97
+
98
+ progress_lines = [
99
+ f"Steps: [{bar}] {self._step_count}/{self._max_steps} ({progress_pct:.0f}%)",
100
+ ]
101
+ if self._total_tokens > 0 or self._executor_tokens > 0:
102
+ token_parts = []
103
+ if self._total_tokens > 0:
104
+ token_parts.append(f"orchestrator: ~{self._total_tokens:,}")
105
+ if self._executor_tokens > 0:
106
+ token_parts.append(f"executors: ~{self._executor_tokens:,}")
107
+ progress_lines.append(f"Tokens: {', '.join(token_parts)}")
108
+
109
+ parts.append("## Progress\n" + "\n".join(progress_lines))
110
+
111
+ # Session summary
112
+ if self._sessions is not None:
113
+ active = sum(
114
+ 1 for s in self._sessions.values() if s.status.value == "active"
115
+ )
116
+ completed = sum(
117
+ 1 for s in self._sessions.values() if s.status.value == "completed"
118
+ )
119
+ failed = sum(
120
+ 1 for s in self._sessions.values() if s.status.value == "failed"
121
+ )
122
+ total = len(self._sessions)
123
+
124
+ summary = f"Sessions: {active} active, {completed} done, {failed} failed ({total} total)"
125
+ if self._budget_max_sessions:
126
+ summary += f" [limit: {self._budget_max_sessions}]"
127
+
128
+ parts.append(f"## Resources\n{summary}")
129
+
130
+ # Active sessions detail
131
+ active_sessions = [
132
+ (sid, s)
133
+ for sid, s in self._sessions.items()
134
+ if s.status.value == "active"
135
+ ]
136
+ if active_sessions:
137
+ session_lines = []
138
+ for sid, session in active_sessions:
139
+ mode_tag = "sync" if session.mode.value == "sync" else "async"
140
+ turns = len([m for m in session.messages if m.role == "user"])
141
+ task_preview = (
142
+ session.task_description[:50] + "..."
143
+ if len(session.task_description) > 50
144
+ else session.task_description
145
+ )
146
+ session_lines.append(
147
+ f"\n • {sid[:8]} ({session.adapter}, {mode_tag}, {turns} turns): {task_preview}"
148
+ )
149
+ parts.append("## Active Sessions\n" + "\n".join(session_lines))
150
+
151
+ # Working directory (less prominent)
152
+ parts.append(f"## Context\nWorking dir: {self.working_dir.absolute()}")
153
+
154
+ 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
+ )