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/__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)"
|