zwarm 2.3.5__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 +21 -0
- zwarm/adapters/base.py +109 -0
- zwarm/adapters/claude_code.py +357 -0
- zwarm/adapters/codex_mcp.py +1262 -0
- zwarm/adapters/registry.py +69 -0
- zwarm/adapters/test_codex_mcp.py +274 -0
- zwarm/adapters/test_registry.py +68 -0
- zwarm/cli/__init__.py +0 -0
- zwarm/cli/main.py +2503 -0
- zwarm/core/__init__.py +0 -0
- zwarm/core/compact.py +329 -0
- zwarm/core/config.py +344 -0
- zwarm/core/environment.py +173 -0
- zwarm/core/models.py +315 -0
- zwarm/core/state.py +355 -0
- zwarm/core/test_compact.py +312 -0
- zwarm/core/test_config.py +160 -0
- zwarm/core/test_models.py +265 -0
- zwarm/orchestrator.py +683 -0
- zwarm/prompts/__init__.py +10 -0
- zwarm/prompts/orchestrator.py +230 -0
- zwarm/sessions/__init__.py +26 -0
- zwarm/sessions/manager.py +792 -0
- zwarm/test_orchestrator_watchers.py +23 -0
- zwarm/tools/__init__.py +17 -0
- zwarm/tools/delegation.py +784 -0
- zwarm/watchers/__init__.py +31 -0
- zwarm/watchers/base.py +131 -0
- zwarm/watchers/builtin.py +518 -0
- zwarm/watchers/llm_watcher.py +319 -0
- zwarm/watchers/manager.py +181 -0
- zwarm/watchers/registry.py +57 -0
- zwarm/watchers/test_watchers.py +237 -0
- zwarm-2.3.5.dist-info/METADATA +309 -0
- zwarm-2.3.5.dist-info/RECORD +38 -0
- zwarm-2.3.5.dist-info/WHEEL +4 -0
- zwarm-2.3.5.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
|
+
]
|
|
@@ -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
|
+
]
|
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,357 @@
|
|
|
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)"
|