zwarm 3.2.0__py3-none-any.whl → 3.3.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/cli/interactive.py +2 -2
- zwarm/cli/main.py +75 -77
- zwarm/cli/pilot.py +3 -1
- zwarm/core/config.py +24 -9
- zwarm/core/test_config.py +2 -3
- zwarm/orchestrator.py +8 -44
- zwarm/sessions/manager.py +210 -90
- zwarm/tools/delegation.py +6 -1
- zwarm-3.3.0.dist-info/METADATA +396 -0
- {zwarm-3.2.0.dist-info → zwarm-3.3.0.dist-info}/RECORD +12 -19
- zwarm/adapters/__init__.py +0 -21
- zwarm/adapters/base.py +0 -109
- zwarm/adapters/claude_code.py +0 -357
- zwarm/adapters/codex_mcp.py +0 -1262
- zwarm/adapters/registry.py +0 -69
- zwarm/adapters/test_codex_mcp.py +0 -274
- zwarm/adapters/test_registry.py +0 -68
- zwarm-3.2.0.dist-info/METADATA +0 -310
- {zwarm-3.2.0.dist-info → zwarm-3.3.0.dist-info}/WHEEL +0 -0
- {zwarm-3.2.0.dist-info → zwarm-3.3.0.dist-info}/entry_points.txt +0 -0
zwarm/adapters/registry.py
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Adapter registry for discovering and instantiating executor adapters.
|
|
3
|
-
|
|
4
|
-
This follows the same pattern as the watcher registry, enabling:
|
|
5
|
-
- Easy addition of new adapters without modifying orchestrator code
|
|
6
|
-
- Runtime discovery of available adapters
|
|
7
|
-
- Consistent instantiation across CLI and orchestrator
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
from typing import Any, Type
|
|
13
|
-
|
|
14
|
-
from zwarm.adapters.base import ExecutorAdapter
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# Global adapter registry
|
|
18
|
-
_ADAPTERS: dict[str, Type[ExecutorAdapter]] = {}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def register_adapter(name: str):
|
|
22
|
-
"""
|
|
23
|
-
Decorator to register an adapter class.
|
|
24
|
-
|
|
25
|
-
Example:
|
|
26
|
-
@register_adapter("codex_mcp")
|
|
27
|
-
class CodexMCPAdapter(ExecutorAdapter):
|
|
28
|
-
...
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
def decorator(cls: Type[ExecutorAdapter]) -> Type[ExecutorAdapter]:
|
|
32
|
-
cls.name = name
|
|
33
|
-
_ADAPTERS[name] = cls
|
|
34
|
-
return cls
|
|
35
|
-
|
|
36
|
-
return decorator
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def get_adapter(name: str, model: str | None = None, **kwargs: Any) -> ExecutorAdapter:
|
|
40
|
-
"""
|
|
41
|
-
Get an adapter instance by name.
|
|
42
|
-
|
|
43
|
-
Args:
|
|
44
|
-
name: Registered adapter name (e.g., "codex_mcp", "claude_code")
|
|
45
|
-
model: Optional model override to pass to adapter
|
|
46
|
-
**kwargs: Additional kwargs passed to adapter constructor
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
Instantiated adapter
|
|
50
|
-
|
|
51
|
-
Raises:
|
|
52
|
-
ValueError: If adapter not found
|
|
53
|
-
"""
|
|
54
|
-
if name not in _ADAPTERS:
|
|
55
|
-
available = list(_ADAPTERS.keys())
|
|
56
|
-
raise ValueError(
|
|
57
|
-
f"Unknown adapter: {name}. Available: {available}"
|
|
58
|
-
)
|
|
59
|
-
return _ADAPTERS[name](model=model, **kwargs)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def list_adapters() -> list[str]:
|
|
63
|
-
"""List all registered adapter names."""
|
|
64
|
-
return list(_ADAPTERS.keys())
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def adapter_exists(name: str) -> bool:
|
|
68
|
-
"""Check if an adapter is registered."""
|
|
69
|
-
return name in _ADAPTERS
|
zwarm/adapters/test_codex_mcp.py
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
"""Tests for Codex MCP adapter."""
|
|
2
|
-
|
|
3
|
-
import subprocess
|
|
4
|
-
import tempfile
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from unittest.mock import MagicMock, patch
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
from zwarm.adapters.codex_mcp import CodexMCPAdapter, MCPClient
|
|
11
|
-
from zwarm.core.models import SessionMode, SessionStatus
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class TestMCPClient:
|
|
15
|
-
"""Tests for the MCP client."""
|
|
16
|
-
|
|
17
|
-
def test_next_id_increments(self):
|
|
18
|
-
"""Test that request IDs increment properly."""
|
|
19
|
-
client = MCPClient()
|
|
20
|
-
assert client._next_id() == 1
|
|
21
|
-
assert client._next_id() == 2
|
|
22
|
-
assert client._next_id() == 3
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class TestCodexMCPAdapter:
|
|
26
|
-
"""Tests for the Codex MCP adapter."""
|
|
27
|
-
|
|
28
|
-
@pytest.fixture
|
|
29
|
-
def adapter(self):
|
|
30
|
-
return CodexMCPAdapter()
|
|
31
|
-
|
|
32
|
-
@pytest.mark.asyncio
|
|
33
|
-
async def test_start_session_creates_session(self, adapter):
|
|
34
|
-
"""Test that start_session creates a proper session object."""
|
|
35
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
36
|
-
# Mock the _call_codex method (now synchronous)
|
|
37
|
-
with patch.object(adapter, "_call_codex", return_value={
|
|
38
|
-
"conversation_id": "conv-123",
|
|
39
|
-
"response": "Hello! I'll help you with that.",
|
|
40
|
-
"raw_messages": [],
|
|
41
|
-
"usage": {},
|
|
42
|
-
"total_usage": {},
|
|
43
|
-
}):
|
|
44
|
-
session = await adapter.start_session(
|
|
45
|
-
task="Say hello",
|
|
46
|
-
working_dir=Path(tmpdir),
|
|
47
|
-
mode="sync",
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
assert session.adapter == "codex_mcp"
|
|
51
|
-
assert session.mode == SessionMode.SYNC
|
|
52
|
-
assert session.status == SessionStatus.ACTIVE
|
|
53
|
-
assert session.conversation_id == "conv-123"
|
|
54
|
-
assert len(session.messages) == 2
|
|
55
|
-
assert session.messages[0].role == "user"
|
|
56
|
-
assert session.messages[1].role == "assistant"
|
|
57
|
-
|
|
58
|
-
@pytest.mark.asyncio
|
|
59
|
-
async def test_send_message_continues_conversation(self, adapter):
|
|
60
|
-
"""Test that send_message continues an existing conversation."""
|
|
61
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
62
|
-
# Mock _call_codex for start_session
|
|
63
|
-
with patch.object(adapter, "_call_codex", return_value={
|
|
64
|
-
"conversation_id": "conv-123",
|
|
65
|
-
"response": "Initial response",
|
|
66
|
-
"raw_messages": [],
|
|
67
|
-
"usage": {},
|
|
68
|
-
"total_usage": {},
|
|
69
|
-
}):
|
|
70
|
-
session = await adapter.start_session(
|
|
71
|
-
task="Start task",
|
|
72
|
-
working_dir=Path(tmpdir),
|
|
73
|
-
mode="sync",
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
# Mock _call_codex_reply for send_message
|
|
77
|
-
with patch.object(adapter, "_call_codex_reply", return_value={
|
|
78
|
-
"response": "Follow-up response",
|
|
79
|
-
"raw_messages": [],
|
|
80
|
-
"usage": {},
|
|
81
|
-
"total_usage": {},
|
|
82
|
-
}):
|
|
83
|
-
response = await adapter.send_message(session, "Continue please")
|
|
84
|
-
|
|
85
|
-
assert response == "Follow-up response"
|
|
86
|
-
assert len(session.messages) == 4 # 2 from start + 2 from reply
|
|
87
|
-
|
|
88
|
-
@pytest.mark.asyncio
|
|
89
|
-
async def test_send_message_fails_on_async_session(self, adapter):
|
|
90
|
-
"""Test that send_message raises error for async sessions."""
|
|
91
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
92
|
-
# Create async session (mocked to avoid actually starting codex)
|
|
93
|
-
with patch("subprocess.Popen") as mock_popen:
|
|
94
|
-
mock_popen.return_value = MagicMock()
|
|
95
|
-
session = await adapter.start_session(
|
|
96
|
-
task="Async task",
|
|
97
|
-
working_dir=Path(tmpdir),
|
|
98
|
-
mode="async",
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
with pytest.raises(ValueError, match="Cannot send message to async session"):
|
|
102
|
-
await adapter.send_message(session, "Should fail")
|
|
103
|
-
|
|
104
|
-
@pytest.mark.asyncio
|
|
105
|
-
async def test_check_status_async_running(self, adapter):
|
|
106
|
-
"""Test checking status of a running async session."""
|
|
107
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
108
|
-
with patch("subprocess.Popen") as mock_popen:
|
|
109
|
-
mock_proc = MagicMock()
|
|
110
|
-
mock_proc.poll.return_value = None # Still running
|
|
111
|
-
mock_popen.return_value = mock_proc
|
|
112
|
-
|
|
113
|
-
session = await adapter.start_session(
|
|
114
|
-
task="Async task",
|
|
115
|
-
working_dir=Path(tmpdir),
|
|
116
|
-
mode="async",
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
status = await adapter.check_status(session)
|
|
120
|
-
assert status["status"] == "running"
|
|
121
|
-
|
|
122
|
-
@pytest.mark.asyncio
|
|
123
|
-
async def test_check_status_async_completed(self, adapter):
|
|
124
|
-
"""Test checking status of a completed async session."""
|
|
125
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
126
|
-
with patch("subprocess.Popen") as mock_popen:
|
|
127
|
-
mock_proc = MagicMock()
|
|
128
|
-
mock_proc.poll.return_value = 0 # Completed
|
|
129
|
-
mock_proc.communicate.return_value = ("Output text", "")
|
|
130
|
-
mock_popen.return_value = mock_proc
|
|
131
|
-
|
|
132
|
-
session = await adapter.start_session(
|
|
133
|
-
task="Async task",
|
|
134
|
-
working_dir=Path(tmpdir),
|
|
135
|
-
mode="async",
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
status = await adapter.check_status(session)
|
|
139
|
-
assert status["status"] == "completed"
|
|
140
|
-
assert session.status == SessionStatus.COMPLETED
|
|
141
|
-
|
|
142
|
-
@pytest.mark.asyncio
|
|
143
|
-
async def test_stop_session(self, adapter):
|
|
144
|
-
"""Test stopping a session."""
|
|
145
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
146
|
-
with patch("subprocess.Popen") as mock_popen:
|
|
147
|
-
mock_proc = MagicMock()
|
|
148
|
-
mock_proc.poll.return_value = None # Running
|
|
149
|
-
mock_popen.return_value = mock_proc
|
|
150
|
-
|
|
151
|
-
session = await adapter.start_session(
|
|
152
|
-
task="Async task",
|
|
153
|
-
working_dir=Path(tmpdir),
|
|
154
|
-
mode="async",
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
await adapter.stop(session)
|
|
158
|
-
|
|
159
|
-
mock_proc.terminate.assert_called_once()
|
|
160
|
-
assert session.status == SessionStatus.FAILED
|
|
161
|
-
|
|
162
|
-
def test_extract_response_content_list(self, adapter):
|
|
163
|
-
"""Test response extraction from content list."""
|
|
164
|
-
result = {"content": [{"text": "Line 1"}, {"text": "Line 2"}]}
|
|
165
|
-
response = adapter._extract_response(result)
|
|
166
|
-
assert response == "Line 1\nLine 2"
|
|
167
|
-
|
|
168
|
-
def test_extract_response_output(self, adapter):
|
|
169
|
-
"""Test response extraction from output field."""
|
|
170
|
-
result = {"output": "Direct output"}
|
|
171
|
-
response = adapter._extract_response(result)
|
|
172
|
-
assert response == "Direct output"
|
|
173
|
-
|
|
174
|
-
def test_extract_response_fallback(self, adapter):
|
|
175
|
-
"""Test response extraction fallback to JSON."""
|
|
176
|
-
result = {"unknown": "field"}
|
|
177
|
-
response = adapter._extract_response(result)
|
|
178
|
-
assert "unknown" in response
|
|
179
|
-
|
|
180
|
-
def test_parse_jsonl_output(self, adapter):
|
|
181
|
-
"""Test parsing JSONL output from codex exec --json."""
|
|
182
|
-
jsonl_output = """{"type":"thread.started","thread_id":"abc123"}
|
|
183
|
-
{"type":"turn.started"}
|
|
184
|
-
{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Thinking..."}}
|
|
185
|
-
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"The answer is 4"}}
|
|
186
|
-
{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":10}}"""
|
|
187
|
-
|
|
188
|
-
parsed = adapter._parse_jsonl_output(jsonl_output)
|
|
189
|
-
|
|
190
|
-
assert parsed["response"] == "The answer is 4"
|
|
191
|
-
assert parsed["thread_id"] == "abc123"
|
|
192
|
-
assert parsed["usage"]["input_tokens"] == 100
|
|
193
|
-
assert parsed["usage"]["output_tokens"] == 10
|
|
194
|
-
assert len(parsed["events"]) == 5
|
|
195
|
-
|
|
196
|
-
def test_parse_jsonl_output_multiple_messages(self, adapter):
|
|
197
|
-
"""Test parsing JSONL with multiple agent messages."""
|
|
198
|
-
jsonl_output = """{"type":"thread.started","thread_id":"xyz"}
|
|
199
|
-
{"type":"item.completed","item":{"type":"agent_message","text":"First part"}}
|
|
200
|
-
{"type":"item.completed","item":{"type":"agent_message","text":"Second part"}}
|
|
201
|
-
{"type":"turn.completed","usage":{"input_tokens":50,"output_tokens":20}}"""
|
|
202
|
-
|
|
203
|
-
parsed = adapter._parse_jsonl_output(jsonl_output)
|
|
204
|
-
|
|
205
|
-
assert parsed["response"] == "First part\nSecond part"
|
|
206
|
-
assert parsed["thread_id"] == "xyz"
|
|
207
|
-
|
|
208
|
-
def test_parse_jsonl_output_empty(self, adapter):
|
|
209
|
-
"""Test parsing empty JSONL output."""
|
|
210
|
-
parsed = adapter._parse_jsonl_output("")
|
|
211
|
-
assert parsed["response"] == ""
|
|
212
|
-
assert parsed["usage"] == {}
|
|
213
|
-
assert parsed["thread_id"] is None
|
|
214
|
-
|
|
215
|
-
def test_parse_jsonl_output_malformed_lines(self, adapter):
|
|
216
|
-
"""Test parsing JSONL with some malformed lines."""
|
|
217
|
-
jsonl_output = """{"type":"thread.started","thread_id":"test123"}
|
|
218
|
-
not valid json
|
|
219
|
-
{"type":"item.completed","item":{"type":"agent_message","text":"Valid response"}}
|
|
220
|
-
also not json
|
|
221
|
-
{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}"""
|
|
222
|
-
|
|
223
|
-
parsed = adapter._parse_jsonl_output(jsonl_output)
|
|
224
|
-
|
|
225
|
-
# Should still extract valid data
|
|
226
|
-
assert parsed["response"] == "Valid response"
|
|
227
|
-
assert parsed["thread_id"] == "test123"
|
|
228
|
-
assert len(parsed["events"]) == 3 # Only valid JSON lines
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
@pytest.mark.integration
|
|
232
|
-
class TestCodexMCPIntegration:
|
|
233
|
-
"""
|
|
234
|
-
Integration tests that run against real codex mcp-server.
|
|
235
|
-
|
|
236
|
-
These tests are skipped by default. Run with:
|
|
237
|
-
pytest -m integration
|
|
238
|
-
"""
|
|
239
|
-
|
|
240
|
-
@pytest.fixture
|
|
241
|
-
def adapter(self):
|
|
242
|
-
return CodexMCPAdapter()
|
|
243
|
-
|
|
244
|
-
@pytest.mark.asyncio
|
|
245
|
-
async def test_real_sync_conversation(self, adapter):
|
|
246
|
-
"""Test a real sync conversation with codex."""
|
|
247
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
248
|
-
try:
|
|
249
|
-
# Start a simple session
|
|
250
|
-
session = await adapter.start_session(
|
|
251
|
-
task="What is 2 + 2? Reply with just the number.",
|
|
252
|
-
working_dir=Path(tmpdir),
|
|
253
|
-
mode="sync",
|
|
254
|
-
sandbox="read-only",
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
assert session.conversation_id is not None
|
|
258
|
-
assert len(session.messages) >= 2
|
|
259
|
-
|
|
260
|
-
# Continue conversation
|
|
261
|
-
response = await adapter.send_message(
|
|
262
|
-
session,
|
|
263
|
-
"And what is that number times 3?"
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
assert response is not None
|
|
267
|
-
assert len(session.messages) >= 4
|
|
268
|
-
|
|
269
|
-
finally:
|
|
270
|
-
await adapter.cleanup()
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if __name__ == "__main__":
|
|
274
|
-
pytest.main([__file__, "-v", "-m", "not integration"])
|
zwarm/adapters/test_registry.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""Tests for the adapter registry."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from zwarm.adapters import (
|
|
6
|
-
get_adapter,
|
|
7
|
-
list_adapters,
|
|
8
|
-
adapter_exists,
|
|
9
|
-
ExecutorAdapter,
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class TestAdapterRegistry:
|
|
14
|
-
"""Test adapter registration and retrieval."""
|
|
15
|
-
|
|
16
|
-
def test_list_adapters_includes_builtins(self):
|
|
17
|
-
"""Built-in adapters are registered on import."""
|
|
18
|
-
adapters = list_adapters()
|
|
19
|
-
assert "codex_mcp" in adapters
|
|
20
|
-
assert "claude_code" in adapters
|
|
21
|
-
|
|
22
|
-
def test_get_adapter_codex(self):
|
|
23
|
-
"""Can retrieve codex adapter by name."""
|
|
24
|
-
adapter = get_adapter("codex_mcp")
|
|
25
|
-
assert isinstance(adapter, ExecutorAdapter)
|
|
26
|
-
assert adapter.name == "codex_mcp"
|
|
27
|
-
|
|
28
|
-
def test_get_adapter_claude(self):
|
|
29
|
-
"""Can retrieve claude adapter by name."""
|
|
30
|
-
adapter = get_adapter("claude_code")
|
|
31
|
-
assert isinstance(adapter, ExecutorAdapter)
|
|
32
|
-
assert adapter.name == "claude_code"
|
|
33
|
-
|
|
34
|
-
def test_get_adapter_with_model(self):
|
|
35
|
-
"""Model parameter is passed to adapter."""
|
|
36
|
-
adapter = get_adapter("codex_mcp", model="custom-model")
|
|
37
|
-
# The model should be set (adapters store it as _model)
|
|
38
|
-
assert adapter._model == "custom-model"
|
|
39
|
-
|
|
40
|
-
def test_get_unknown_adapter(self):
|
|
41
|
-
"""Unknown adapter raises ValueError."""
|
|
42
|
-
with pytest.raises(ValueError) as exc_info:
|
|
43
|
-
get_adapter("nonexistent_adapter")
|
|
44
|
-
assert "Unknown adapter" in str(exc_info.value)
|
|
45
|
-
assert "nonexistent_adapter" in str(exc_info.value)
|
|
46
|
-
|
|
47
|
-
def test_adapter_exists(self):
|
|
48
|
-
"""adapter_exists returns correct boolean."""
|
|
49
|
-
assert adapter_exists("codex_mcp") is True
|
|
50
|
-
assert adapter_exists("claude_code") is True
|
|
51
|
-
assert adapter_exists("nonexistent") is False
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class TestAdapterInstances:
|
|
55
|
-
"""Test that retrieved adapters are independent instances."""
|
|
56
|
-
|
|
57
|
-
def test_separate_instances(self):
|
|
58
|
-
"""Each get_adapter call returns a new instance."""
|
|
59
|
-
adapter1 = get_adapter("codex_mcp")
|
|
60
|
-
adapter2 = get_adapter("codex_mcp")
|
|
61
|
-
assert adapter1 is not adapter2
|
|
62
|
-
|
|
63
|
-
def test_different_models(self):
|
|
64
|
-
"""Can create adapters with different models."""
|
|
65
|
-
adapter1 = get_adapter("codex_mcp", model="model-a")
|
|
66
|
-
adapter2 = get_adapter("codex_mcp", model="model-b")
|
|
67
|
-
assert adapter1._model == "model-a"
|
|
68
|
-
assert adapter2._model == "model-b"
|