zwarm 1.0.1__py3-none-any.whl → 1.2.1__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,21 @@
1
+ """
2
+ Adapters: Executor wrappers for CLI coding agents.
3
+
4
+ Adapters provide a unified interface to different coding CLIs (Codex, Claude Code).
5
+ Use the registry to discover and instantiate adapters by name.
6
+ """
7
+
8
+ from zwarm.adapters.base import ExecutorAdapter
9
+ from zwarm.adapters.registry import register_adapter, get_adapter, list_adapters, adapter_exists
10
+
11
+ # Import built-in adapters to register them
12
+ from zwarm.adapters import codex_mcp as _codex_mcp # noqa: F401
13
+ from zwarm.adapters import claude_code as _claude_code # noqa: F401
14
+
15
+ __all__ = [
16
+ "ExecutorAdapter",
17
+ "register_adapter",
18
+ "get_adapter",
19
+ "list_adapters",
20
+ "adapter_exists",
21
+ ]
@@ -18,6 +18,7 @@ from typing import Any, Literal
18
18
  import weave
19
19
 
20
20
  from zwarm.adapters.base import ExecutorAdapter
21
+ from zwarm.adapters.registry import register_adapter
21
22
  from zwarm.core.models import (
22
23
  ConversationSession,
23
24
  SessionMode,
@@ -25,14 +26,13 @@ from zwarm.core.models import (
25
26
  )
26
27
 
27
28
 
29
+ @register_adapter("claude_code")
28
30
  class ClaudeCodeAdapter(ExecutorAdapter):
29
31
  """
30
32
  Claude Code adapter using the claude CLI.
31
33
 
32
34
  Supports both sync (conversational) and async (fire-and-forget) modes.
33
35
  """
34
-
35
- name = "claude_code"
36
36
  DEFAULT_MODEL = "claude-sonnet-4-5-20250514" # Best balance of speed and capability
37
37
 
38
38
  def __init__(self, model: str | None = None):
@@ -186,6 +186,7 @@ class ClaudeCodeAdapter(ExecutorAdapter):
186
186
  "exit_code": result.returncode,
187
187
  }
188
188
 
189
+ @weave.op()
189
190
  async def start_session(
190
191
  self,
191
192
  task: str,
@@ -195,7 +196,7 @@ class ClaudeCodeAdapter(ExecutorAdapter):
195
196
  permission_mode: str = "bypassPermissions",
196
197
  **kwargs,
197
198
  ) -> ConversationSession:
198
- """Start a Claude Code session."""
199
+ """Start a Claude Code session (sync or async mode)."""
199
200
  session = ConversationSession(
200
201
  adapter=self.name,
201
202
  mode=SessionMode(mode),
@@ -277,6 +278,7 @@ class ClaudeCodeAdapter(ExecutorAdapter):
277
278
 
278
279
  return response_text
279
280
 
281
+ @weave.op()
280
282
  async def check_status(
281
283
  self,
282
284
  session: ConversationSession,
@@ -20,6 +20,7 @@ from typing import Any, Literal
20
20
  import weave
21
21
 
22
22
  from zwarm.adapters.base import ExecutorAdapter
23
+ from zwarm.adapters.registry import register_adapter
23
24
  from zwarm.core.models import (
24
25
  ConversationSession,
25
26
  SessionMode,
@@ -450,6 +451,7 @@ class MCPClient:
450
451
  return self._proc is not None and self._proc.poll() is None
451
452
 
452
453
 
454
+ @register_adapter("codex_mcp")
453
455
  class CodexMCPAdapter(ExecutorAdapter):
454
456
  """
455
457
  Codex adapter using MCP server for sync conversations.
@@ -458,8 +460,6 @@ class CodexMCPAdapter(ExecutorAdapter):
458
460
  The MCP client uses subprocess.Popen (not asyncio) so it persists across
459
461
  multiple asyncio.run() calls, preserving conversation state.
460
462
  """
461
-
462
- name = "codex_mcp"
463
463
  DEFAULT_MODEL = "gpt-5.1-codex-mini" # Default codex model
464
464
 
465
465
  def __init__(self, model: str | None = None):
@@ -565,6 +565,7 @@ class CodexMCPAdapter(ExecutorAdapter):
565
565
  "total_usage": self.total_usage,
566
566
  }
567
567
 
568
+ @weave.op()
568
569
  async def start_session(
569
570
  self,
570
571
  task: str,
@@ -574,7 +575,7 @@ class CodexMCPAdapter(ExecutorAdapter):
574
575
  sandbox: str = "workspace-write",
575
576
  **kwargs,
576
577
  ) -> ConversationSession:
577
- """Start a Codex session."""
578
+ """Start a Codex session (sync or async mode)."""
578
579
  effective_model = model or self._model
579
580
  session = ConversationSession(
580
581
  adapter=self.name,
@@ -606,15 +607,18 @@ class CodexMCPAdapter(ExecutorAdapter):
606
607
 
607
608
  else:
608
609
  # Async mode: use codex exec (fire-and-forget)
609
- # This runs in a subprocess without MCP
610
+ # This runs in a subprocess without MCP, outputs JSONL events
610
611
  cmd = [
611
612
  "codex", "exec",
612
613
  "--dangerously-bypass-approvals-and-sandbox",
613
614
  "--skip-git-repo-check",
614
615
  "--json",
615
616
  "--model", effective_model,
617
+ "-C", str(working_dir.absolute()), # Explicit working directory
618
+ "--", task,
616
619
  ]
617
- cmd.extend(["--", task])
620
+
621
+ logger.info(f"Starting async codex: {' '.join(cmd[:8])}...")
618
622
 
619
623
  proc = subprocess.Popen(
620
624
  cmd,
@@ -656,6 +660,54 @@ class CodexMCPAdapter(ExecutorAdapter):
656
660
 
657
661
  return response_text
658
662
 
663
+ @weave.op()
664
+ def _parse_jsonl_output(self, stdout: str) -> dict[str, Any]:
665
+ """
666
+ Parse JSONL output from codex exec --json.
667
+
668
+ Returns dict with:
669
+ - response: The agent's message text
670
+ - usage: Token usage stats
671
+ - thread_id: The conversation thread ID
672
+ - events: All parsed events (for debugging)
673
+ """
674
+ response_parts = []
675
+ usage = {}
676
+ thread_id = None
677
+ events = []
678
+
679
+ for line in stdout.strip().split("\n"):
680
+ if not line.strip():
681
+ continue
682
+ try:
683
+ event = json.loads(line)
684
+ events.append(event)
685
+
686
+ event_type = event.get("type", "")
687
+
688
+ if event_type == "thread.started":
689
+ thread_id = event.get("thread_id")
690
+
691
+ elif event_type == "item.completed":
692
+ item = event.get("item", {})
693
+ if item.get("type") == "agent_message":
694
+ response_parts.append(item.get("text", ""))
695
+
696
+ elif event_type == "turn.completed":
697
+ usage = event.get("usage", {})
698
+
699
+ except json.JSONDecodeError:
700
+ logger.warning(f"Failed to parse JSONL line: {line[:100]}")
701
+ continue
702
+
703
+ return {
704
+ "response": "\n".join(response_parts),
705
+ "usage": usage,
706
+ "thread_id": thread_id,
707
+ "events": events,
708
+ }
709
+
710
+ @weave.op()
659
711
  async def check_status(
660
712
  self,
661
713
  session: ConversationSession,
@@ -672,14 +724,50 @@ class CodexMCPAdapter(ExecutorAdapter):
672
724
  if poll is None:
673
725
  return {"status": "running"}
674
726
 
675
- # Process finished
727
+ # Process finished - parse the JSONL output
676
728
  stdout, stderr = session.process.communicate()
729
+
677
730
  if poll == 0:
678
- session.complete(stdout[:1000] if stdout else "Completed")
679
- return {"status": "completed", "output": stdout}
731
+ # Parse JSONL to extract actual response
732
+ parsed = self._parse_jsonl_output(stdout)
733
+ response_text = parsed["response"] or "(no response captured)"
734
+
735
+ # Add the response as a message
736
+ session.add_message("assistant", response_text)
737
+
738
+ # Track token usage
739
+ if parsed["usage"]:
740
+ session.add_usage({
741
+ "input_tokens": parsed["usage"].get("input_tokens", 0),
742
+ "output_tokens": parsed["usage"].get("output_tokens", 0),
743
+ "total_tokens": (
744
+ parsed["usage"].get("input_tokens", 0) +
745
+ parsed["usage"].get("output_tokens", 0)
746
+ ),
747
+ })
748
+
749
+ session.complete(response_text[:500])
750
+ return {
751
+ "status": "completed",
752
+ "response": response_text,
753
+ "usage": parsed["usage"],
754
+ "thread_id": parsed["thread_id"],
755
+ }
680
756
  else:
681
- session.fail(stderr[:1000] if stderr else f"Exit code: {poll}")
682
- return {"status": "failed", "error": stderr, "exit_code": poll}
757
+ # Try to parse stderr or stdout for error info
758
+ error_msg = stderr.strip() if stderr else f"Exit code: {poll}"
759
+
760
+ # Sometimes errors come through stdout as JSONL too
761
+ if stdout and not stderr:
762
+ try:
763
+ parsed = self._parse_jsonl_output(stdout)
764
+ if not parsed["response"]:
765
+ error_msg = f"Process failed with no response. Exit code: {poll}"
766
+ except Exception:
767
+ error_msg = stdout[:500] if stdout else f"Exit code: {poll}"
768
+
769
+ session.fail(error_msg[:500])
770
+ return {"status": "failed", "error": error_msg, "exit_code": poll}
683
771
 
684
772
  async def stop(
685
773
  self,
@@ -0,0 +1,69 @@
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
@@ -177,6 +177,56 @@ class TestCodexMCPAdapter:
177
177
  response = adapter._extract_response(result)
178
178
  assert "unknown" in response
179
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
+
180
230
 
181
231
  @pytest.mark.integration
182
232
  class TestCodexMCPIntegration:
@@ -0,0 +1,68 @@
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"