zwarm 3.2.1__py3-none-any.whl → 3.4.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/adapters/base.py DELETED
@@ -1,109 +0,0 @@
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
@@ -1,357 +0,0 @@
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.adapters.registry import register_adapter
22
- from zwarm.core.models import (
23
- ConversationSession,
24
- SessionMode,
25
- SessionStatus,
26
- )
27
-
28
-
29
- @register_adapter("claude_code")
30
- class ClaudeCodeAdapter(ExecutorAdapter):
31
- """
32
- Claude Code adapter using the claude CLI.
33
-
34
- Supports both sync (conversational) and async (fire-and-forget) modes.
35
- """
36
- DEFAULT_MODEL = "claude-sonnet-4-5-20250514" # Best balance of speed and capability
37
-
38
- def __init__(self, model: str | None = None):
39
- self._model = model or self.DEFAULT_MODEL
40
- self._sessions: dict[str, str] = {} # session_id -> claude session_id
41
- # Cumulative token usage for cost tracking
42
- self._total_usage: dict[str, int] = {
43
- "input_tokens": 0,
44
- "output_tokens": 0,
45
- "cache_creation_input_tokens": 0,
46
- "cache_read_input_tokens": 0,
47
- "total_tokens": 0,
48
- }
49
-
50
- def _accumulate_usage(self, usage: dict[str, Any]) -> None:
51
- """Add usage to cumulative totals."""
52
- if not usage:
53
- return
54
- for key in self._total_usage:
55
- self._total_usage[key] += usage.get(key, 0)
56
-
57
- def _extract_usage(self, output: dict) -> dict[str, int]:
58
- """Extract token usage from claude CLI JSON output."""
59
- usage = {}
60
- # Claude CLI may include usage in various formats
61
- if "usage" in output:
62
- usage = output["usage"]
63
- elif "cost_usd" in output:
64
- # Alternative: estimate from cost if available
65
- usage["cost_usd"] = output["cost_usd"]
66
- # Also check for token counts in the output
67
- for key in ["input_tokens", "output_tokens", "total_tokens"]:
68
- if key in output:
69
- usage[key] = output[key]
70
- return usage
71
-
72
- @property
73
- def total_usage(self) -> dict[str, int]:
74
- """Get cumulative token usage across all calls."""
75
- return self._total_usage.copy()
76
-
77
- @weave.op()
78
- async def _call_claude(
79
- self,
80
- task: str,
81
- cwd: str,
82
- model: str | None = None,
83
- permission_mode: str = "bypassPermissions",
84
- ) -> dict[str, Any]:
85
- """
86
- Call claude CLI - traced by Weave.
87
-
88
- This wraps the actual claude call so it appears in Weave traces
89
- with full input/output visibility.
90
- """
91
- cmd = ["claude", "-p", "--output-format", "json"]
92
-
93
- if permission_mode:
94
- cmd.extend(["--permission-mode", permission_mode])
95
- if model:
96
- cmd.extend(["--model", model])
97
-
98
- cmd.extend(["--", task])
99
-
100
- loop = asyncio.get_event_loop()
101
- result = await loop.run_in_executor(
102
- None,
103
- lambda: subprocess.run(
104
- cmd,
105
- cwd=cwd,
106
- capture_output=True,
107
- text=True,
108
- timeout=300,
109
- )
110
- )
111
-
112
- response_text = self._extract_response(result.stdout, result.stderr)
113
-
114
- # Try to get session ID and usage from JSON output
115
- session_id = None
116
- usage = {}
117
- try:
118
- output = json.loads(result.stdout)
119
- session_id = output.get("session_id")
120
- usage = self._extract_usage(output)
121
- self._accumulate_usage(usage)
122
- except (json.JSONDecodeError, TypeError):
123
- pass
124
-
125
- return {
126
- "response": response_text,
127
- "session_id": session_id,
128
- "exit_code": result.returncode,
129
- "usage": usage,
130
- "total_usage": self.total_usage,
131
- }
132
-
133
- @weave.op()
134
- async def _call_claude_continue(
135
- self,
136
- message: str,
137
- cwd: str,
138
- session_id: str | None = None,
139
- ) -> dict[str, Any]:
140
- """
141
- Continue a claude conversation - traced by Weave.
142
-
143
- This wraps the continuation call so it appears in Weave traces
144
- with full input/output visibility.
145
- """
146
- cmd = ["claude", "-p", "--output-format", "json"]
147
-
148
- if session_id:
149
- cmd.extend(["--resume", session_id])
150
- else:
151
- cmd.extend(["--continue"])
152
-
153
- cmd.extend(["--permission-mode", "bypassPermissions"])
154
- cmd.extend(["--", message])
155
-
156
- loop = asyncio.get_event_loop()
157
- result = await loop.run_in_executor(
158
- None,
159
- lambda: subprocess.run(
160
- cmd,
161
- cwd=cwd,
162
- capture_output=True,
163
- text=True,
164
- timeout=300,
165
- )
166
- )
167
-
168
- response_text = self._extract_response(result.stdout, result.stderr)
169
-
170
- # Try to get session ID and usage from JSON output
171
- new_session_id = None
172
- usage = {}
173
- try:
174
- output = json.loads(result.stdout)
175
- new_session_id = output.get("session_id")
176
- usage = self._extract_usage(output)
177
- self._accumulate_usage(usage)
178
- except (json.JSONDecodeError, TypeError):
179
- pass
180
-
181
- return {
182
- "response": response_text,
183
- "usage": usage,
184
- "total_usage": self.total_usage,
185
- "session_id": new_session_id or session_id,
186
- "exit_code": result.returncode,
187
- }
188
-
189
- @weave.op()
190
- async def start_session(
191
- self,
192
- task: str,
193
- working_dir: Path,
194
- mode: Literal["sync", "async"] = "sync",
195
- model: str | None = None,
196
- permission_mode: str = "bypassPermissions",
197
- **kwargs,
198
- ) -> ConversationSession:
199
- """Start a Claude Code session (sync or async mode)."""
200
- session = ConversationSession(
201
- adapter=self.name,
202
- mode=SessionMode(mode),
203
- working_dir=working_dir,
204
- task_description=task,
205
- model=model or self._model,
206
- )
207
-
208
- if mode == "sync":
209
- # Use traced claude call
210
- result = await self._call_claude(
211
- task=task,
212
- cwd=str(working_dir),
213
- model=model or self._model,
214
- permission_mode=permission_mode,
215
- )
216
-
217
- # Extract session ID and response
218
- if result["session_id"]:
219
- session.conversation_id = result["session_id"]
220
- self._sessions[session.id] = session.conversation_id
221
-
222
- session.add_message("user", task)
223
- session.add_message("assistant", result["response"])
224
-
225
- # Track token usage on the session
226
- session.add_usage(result.get("usage", {}))
227
-
228
- else:
229
- # Async mode: run in background
230
- cmd = ["claude", "-p", "--output-format", "json"]
231
- if permission_mode:
232
- cmd.extend(["--permission-mode", permission_mode])
233
- if model or self._model:
234
- cmd.extend(["--model", model or self._model])
235
- cmd.extend(["--", task])
236
-
237
- proc = subprocess.Popen(
238
- cmd,
239
- cwd=working_dir,
240
- stdout=subprocess.PIPE,
241
- stderr=subprocess.PIPE,
242
- text=True,
243
- )
244
- session.process = proc
245
- session.add_message("user", task)
246
-
247
- return session
248
-
249
- async def send_message(
250
- self,
251
- session: ConversationSession,
252
- message: str,
253
- ) -> str:
254
- """Send a message to continue a sync conversation."""
255
- if session.mode != SessionMode.SYNC:
256
- raise ValueError("Cannot send message to async session")
257
- if session.status != SessionStatus.ACTIVE:
258
- raise ValueError(f"Session is not active: {session.status}")
259
-
260
- # Use traced continuation call
261
- result = await self._call_claude_continue(
262
- message=message,
263
- cwd=str(session.working_dir),
264
- session_id=session.conversation_id,
265
- )
266
-
267
- # Update session ID if we didn't have one
268
- if not session.conversation_id and result["session_id"]:
269
- session.conversation_id = result["session_id"]
270
- self._sessions[session.id] = session.conversation_id
271
-
272
- response_text = result["response"]
273
- session.add_message("user", message)
274
- session.add_message("assistant", response_text)
275
-
276
- # Track token usage on the session
277
- session.add_usage(result.get("usage", {}))
278
-
279
- return response_text
280
-
281
- @weave.op()
282
- async def check_status(
283
- self,
284
- session: ConversationSession,
285
- ) -> dict:
286
- """Check status of an async session."""
287
- if session.mode != SessionMode.ASYNC:
288
- return {"status": session.status.value}
289
-
290
- if session.process is None:
291
- return {"status": "unknown", "error": "No process handle"}
292
-
293
- # Check if process is still running
294
- poll = session.process.poll()
295
- if poll is None:
296
- return {"status": "running"}
297
-
298
- # Process finished
299
- stdout, stderr = session.process.communicate()
300
- if poll == 0:
301
- response = self._extract_response(stdout, stderr)
302
- session.complete(response[:1000] if response else "Completed")
303
- return {"status": "completed", "output": response}
304
- else:
305
- session.fail(stderr[:1000] if stderr else f"Exit code: {poll}")
306
- return {"status": "failed", "error": stderr, "exit_code": poll}
307
-
308
- async def stop(
309
- self,
310
- session: ConversationSession,
311
- ) -> None:
312
- """Stop a session."""
313
- if session.process and session.process.poll() is None:
314
- session.process.terminate()
315
- try:
316
- session.process.wait(timeout=5)
317
- except subprocess.TimeoutExpired:
318
- session.process.kill()
319
-
320
- session.fail("Stopped by user")
321
-
322
- # Remove from tracking
323
- if session.id in self._sessions:
324
- del self._sessions[session.id]
325
-
326
- def _extract_response(self, stdout: str, stderr: str) -> str:
327
- """Extract response text from CLI output."""
328
- # Try to parse as JSON
329
- try:
330
- output = json.loads(stdout)
331
-
332
- # Check for result/response fields
333
- if "result" in output:
334
- return output["result"]
335
- if "response" in output:
336
- return output["response"]
337
- if "content" in output:
338
- return output["content"]
339
- if "text" in output:
340
- return output["text"]
341
-
342
- # Handle messages array
343
- if "messages" in output and isinstance(output["messages"], list):
344
- for msg in reversed(output["messages"]):
345
- if isinstance(msg, dict) and msg.get("role") == "assistant":
346
- return msg.get("content", "")
347
-
348
- # Fallback: stringify the output
349
- return json.dumps(output, indent=2)
350
-
351
- except json.JSONDecodeError:
352
- # Not JSON, return raw output
353
- if stdout.strip():
354
- return stdout.strip()
355
- if stderr.strip():
356
- return f"Error: {stderr.strip()}"
357
- return "(no output)"