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 ADDED
@@ -0,0 +1,38 @@
1
+ """
2
+ zwarm: Multi-Agent CLI Orchestration Research Platform
3
+
4
+ A framework for orchestrating multiple CLI coding agents (codex, claude-code, gemini)
5
+ with support for sync (conversational) and async (fire-and-forget) delegation.
6
+ """
7
+
8
+ from zwarm.core.config import ZwarmConfig, load_config
9
+ from zwarm.core.models import (
10
+ ConversationSession,
11
+ Event,
12
+ Message,
13
+ SessionMode,
14
+ SessionStatus,
15
+ Task,
16
+ TaskStatus,
17
+ )
18
+ from zwarm.core.state import StateManager
19
+ from zwarm.orchestrator import Orchestrator, build_orchestrator
20
+
21
+ __all__ = [
22
+ # Config
23
+ "ZwarmConfig",
24
+ "load_config",
25
+ # Models
26
+ "ConversationSession",
27
+ "Event",
28
+ "Message",
29
+ "SessionMode",
30
+ "SessionStatus",
31
+ "Task",
32
+ "TaskStatus",
33
+ # State
34
+ "StateManager",
35
+ # Orchestrator
36
+ "Orchestrator",
37
+ "build_orchestrator",
38
+ ]
File without changes
zwarm/adapters/base.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ Base adapter protocol for executor agents.
3
+
4
+ All CLI coding agent adapters (codex, claude-code, gemini) implement this protocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from zwarm.core.models import ConversationSession, SessionMode
14
+
15
+
16
+ class ExecutorAdapter(ABC):
17
+ """
18
+ Abstract base class for CLI coding agent adapters.
19
+
20
+ Adapters handle the mechanics of:
21
+ - Starting sessions (sync or async)
22
+ - Sending messages in sync mode
23
+ - Checking status in async mode
24
+ - Stopping sessions
25
+ """
26
+
27
+ name: str = "base"
28
+
29
+ @abstractmethod
30
+ async def start_session(
31
+ self,
32
+ task: str,
33
+ working_dir: Path,
34
+ mode: Literal["sync", "async"] = "sync",
35
+ model: str | None = None,
36
+ **kwargs,
37
+ ) -> ConversationSession:
38
+ """
39
+ Start a new session with the executor.
40
+
41
+ Args:
42
+ task: The task description/prompt
43
+ working_dir: Directory to work in
44
+ mode: "sync" for conversational, "async" for fire-and-forget
45
+ model: Optional model override
46
+ **kwargs: Adapter-specific options
47
+
48
+ Returns:
49
+ A ConversationSession with initial response (if sync)
50
+ """
51
+ ...
52
+
53
+ @abstractmethod
54
+ async def send_message(
55
+ self,
56
+ session: ConversationSession,
57
+ message: str,
58
+ ) -> str:
59
+ """
60
+ Send a message to a sync session and get response.
61
+
62
+ Args:
63
+ session: The active session
64
+ message: Message to send
65
+
66
+ Returns:
67
+ The agent's response
68
+
69
+ Raises:
70
+ ValueError: If session is not in sync mode or not active
71
+ """
72
+ ...
73
+
74
+ @abstractmethod
75
+ async def check_status(
76
+ self,
77
+ session: ConversationSession,
78
+ ) -> dict:
79
+ """
80
+ Check the status of an async session.
81
+
82
+ Args:
83
+ session: The session to check
84
+
85
+ Returns:
86
+ Status dict with at least {"status": "running"|"completed"|"failed"}
87
+ """
88
+ ...
89
+
90
+ @abstractmethod
91
+ async def stop(
92
+ self,
93
+ session: ConversationSession,
94
+ ) -> None:
95
+ """
96
+ Stop/kill a session.
97
+
98
+ Args:
99
+ session: The session to stop
100
+ """
101
+ ...
102
+
103
+ async def cleanup(self) -> None:
104
+ """
105
+ Clean up adapter resources (e.g., MCP server).
106
+
107
+ Called when the orchestrator shuts down.
108
+ """
109
+ pass
@@ -0,0 +1,303 @@
1
+ """
2
+ Claude Code adapter for sync/async execution.
3
+
4
+ Uses the claude CLI for conversations:
5
+ - claude -p --output-format json for non-interactive mode
6
+ - claude -r <session_id> to continue conversations
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import os
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+
18
+ import weave
19
+
20
+ from zwarm.adapters.base import ExecutorAdapter
21
+ from zwarm.core.models import (
22
+ ConversationSession,
23
+ SessionMode,
24
+ SessionStatus,
25
+ )
26
+
27
+
28
+ class ClaudeCodeAdapter(ExecutorAdapter):
29
+ """
30
+ Claude Code adapter using the claude CLI.
31
+
32
+ Supports both sync (conversational) and async (fire-and-forget) modes.
33
+ """
34
+
35
+ name = "claude_code"
36
+
37
+ def __init__(self, model: str | None = None):
38
+ self._model = model
39
+ self._sessions: dict[str, str] = {} # session_id -> claude session_id
40
+
41
+ @weave.op()
42
+ async def _call_claude(
43
+ self,
44
+ task: str,
45
+ cwd: str,
46
+ model: str | None = None,
47
+ permission_mode: str = "bypassPermissions",
48
+ ) -> dict[str, Any]:
49
+ """
50
+ Call claude CLI - traced by Weave.
51
+
52
+ This wraps the actual claude call so it appears in Weave traces
53
+ with full input/output visibility.
54
+ """
55
+ cmd = ["claude", "-p", "--output-format", "json"]
56
+
57
+ if permission_mode:
58
+ cmd.extend(["--permission-mode", permission_mode])
59
+ if model:
60
+ cmd.extend(["--model", model])
61
+
62
+ cmd.extend(["--", task])
63
+
64
+ loop = asyncio.get_event_loop()
65
+ result = await loop.run_in_executor(
66
+ None,
67
+ lambda: subprocess.run(
68
+ cmd,
69
+ cwd=cwd,
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=300,
73
+ )
74
+ )
75
+
76
+ response_text = self._extract_response(result.stdout, result.stderr)
77
+
78
+ # Try to get session ID from JSON output
79
+ session_id = None
80
+ try:
81
+ output = json.loads(result.stdout)
82
+ session_id = output.get("session_id")
83
+ except (json.JSONDecodeError, TypeError):
84
+ pass
85
+
86
+ return {
87
+ "response": response_text,
88
+ "session_id": session_id,
89
+ "exit_code": result.returncode,
90
+ }
91
+
92
+ @weave.op()
93
+ async def _call_claude_continue(
94
+ self,
95
+ message: str,
96
+ cwd: str,
97
+ session_id: str | None = None,
98
+ ) -> dict[str, Any]:
99
+ """
100
+ Continue a claude conversation - traced by Weave.
101
+
102
+ This wraps the continuation call so it appears in Weave traces
103
+ with full input/output visibility.
104
+ """
105
+ cmd = ["claude", "-p", "--output-format", "json"]
106
+
107
+ if session_id:
108
+ cmd.extend(["--resume", session_id])
109
+ else:
110
+ cmd.extend(["--continue"])
111
+
112
+ cmd.extend(["--permission-mode", "bypassPermissions"])
113
+ cmd.extend(["--", message])
114
+
115
+ loop = asyncio.get_event_loop()
116
+ result = await loop.run_in_executor(
117
+ None,
118
+ lambda: subprocess.run(
119
+ cmd,
120
+ cwd=cwd,
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=300,
124
+ )
125
+ )
126
+
127
+ response_text = self._extract_response(result.stdout, result.stderr)
128
+
129
+ # Try to get session ID from JSON output
130
+ new_session_id = None
131
+ try:
132
+ output = json.loads(result.stdout)
133
+ new_session_id = output.get("session_id")
134
+ except (json.JSONDecodeError, TypeError):
135
+ pass
136
+
137
+ return {
138
+ "response": response_text,
139
+ "session_id": new_session_id or session_id,
140
+ "exit_code": result.returncode,
141
+ }
142
+
143
+ async def start_session(
144
+ self,
145
+ task: str,
146
+ working_dir: Path,
147
+ mode: Literal["sync", "async"] = "sync",
148
+ model: str | None = None,
149
+ permission_mode: str = "bypassPermissions",
150
+ **kwargs,
151
+ ) -> ConversationSession:
152
+ """Start a Claude Code session."""
153
+ session = ConversationSession(
154
+ adapter=self.name,
155
+ mode=SessionMode(mode),
156
+ working_dir=working_dir,
157
+ task_description=task,
158
+ model=model or self._model,
159
+ )
160
+
161
+ if mode == "sync":
162
+ # Use traced claude call
163
+ result = await self._call_claude(
164
+ task=task,
165
+ cwd=str(working_dir),
166
+ model=model or self._model,
167
+ permission_mode=permission_mode,
168
+ )
169
+
170
+ # Extract session ID and response
171
+ if result["session_id"]:
172
+ session.conversation_id = result["session_id"]
173
+ self._sessions[session.id] = session.conversation_id
174
+
175
+ session.add_message("user", task)
176
+ session.add_message("assistant", result["response"])
177
+
178
+ else:
179
+ # Async mode: run in background
180
+ cmd = ["claude", "-p", "--output-format", "json"]
181
+ if permission_mode:
182
+ cmd.extend(["--permission-mode", permission_mode])
183
+ if model or self._model:
184
+ cmd.extend(["--model", model or self._model])
185
+ cmd.extend(["--", task])
186
+
187
+ proc = subprocess.Popen(
188
+ cmd,
189
+ cwd=working_dir,
190
+ stdout=subprocess.PIPE,
191
+ stderr=subprocess.PIPE,
192
+ text=True,
193
+ )
194
+ session.process = proc
195
+ session.add_message("user", task)
196
+
197
+ return session
198
+
199
+ async def send_message(
200
+ self,
201
+ session: ConversationSession,
202
+ message: str,
203
+ ) -> str:
204
+ """Send a message to continue a sync conversation."""
205
+ if session.mode != SessionMode.SYNC:
206
+ raise ValueError("Cannot send message to async session")
207
+ if session.status != SessionStatus.ACTIVE:
208
+ raise ValueError(f"Session is not active: {session.status}")
209
+
210
+ # Use traced continuation call
211
+ result = await self._call_claude_continue(
212
+ message=message,
213
+ cwd=str(session.working_dir),
214
+ session_id=session.conversation_id,
215
+ )
216
+
217
+ # Update session ID if we didn't have one
218
+ if not session.conversation_id and result["session_id"]:
219
+ session.conversation_id = result["session_id"]
220
+ self._sessions[session.id] = session.conversation_id
221
+
222
+ response_text = result["response"]
223
+ session.add_message("user", message)
224
+ session.add_message("assistant", response_text)
225
+
226
+ return response_text
227
+
228
+ async def check_status(
229
+ self,
230
+ session: ConversationSession,
231
+ ) -> dict:
232
+ """Check status of an async session."""
233
+ if session.mode != SessionMode.ASYNC:
234
+ return {"status": session.status.value}
235
+
236
+ if session.process is None:
237
+ return {"status": "unknown", "error": "No process handle"}
238
+
239
+ # Check if process is still running
240
+ poll = session.process.poll()
241
+ if poll is None:
242
+ return {"status": "running"}
243
+
244
+ # Process finished
245
+ stdout, stderr = session.process.communicate()
246
+ if poll == 0:
247
+ response = self._extract_response(stdout, stderr)
248
+ session.complete(response[:1000] if response else "Completed")
249
+ return {"status": "completed", "output": response}
250
+ else:
251
+ session.fail(stderr[:1000] if stderr else f"Exit code: {poll}")
252
+ return {"status": "failed", "error": stderr, "exit_code": poll}
253
+
254
+ async def stop(
255
+ self,
256
+ session: ConversationSession,
257
+ ) -> None:
258
+ """Stop a session."""
259
+ if session.process and session.process.poll() is None:
260
+ session.process.terminate()
261
+ try:
262
+ session.process.wait(timeout=5)
263
+ except subprocess.TimeoutExpired:
264
+ session.process.kill()
265
+
266
+ session.fail("Stopped by user")
267
+
268
+ # Remove from tracking
269
+ if session.id in self._sessions:
270
+ del self._sessions[session.id]
271
+
272
+ def _extract_response(self, stdout: str, stderr: str) -> str:
273
+ """Extract response text from CLI output."""
274
+ # Try to parse as JSON
275
+ try:
276
+ output = json.loads(stdout)
277
+
278
+ # Check for result/response fields
279
+ if "result" in output:
280
+ return output["result"]
281
+ if "response" in output:
282
+ return output["response"]
283
+ if "content" in output:
284
+ return output["content"]
285
+ if "text" in output:
286
+ return output["text"]
287
+
288
+ # Handle messages array
289
+ if "messages" in output and isinstance(output["messages"], list):
290
+ for msg in reversed(output["messages"]):
291
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
292
+ return msg.get("content", "")
293
+
294
+ # Fallback: stringify the output
295
+ return json.dumps(output, indent=2)
296
+
297
+ except json.JSONDecodeError:
298
+ # Not JSON, return raw output
299
+ if stdout.strip():
300
+ return stdout.strip()
301
+ if stderr.strip():
302
+ return f"Error: {stderr.strip()}"
303
+ return "(no output)"