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
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delegation tools for the orchestrator.
|
|
3
|
+
|
|
4
|
+
These are the core tools that orchestrators use to delegate work to executors.
|
|
5
|
+
They use the SAME CodexSessionManager that `zwarm interactive` uses - no special
|
|
6
|
+
MCP integration, no separate code path.
|
|
7
|
+
|
|
8
|
+
The orchestrator LLM has access to the exact same tools a human would use.
|
|
9
|
+
|
|
10
|
+
Tools:
|
|
11
|
+
- delegate: Start a new codex session
|
|
12
|
+
- converse: Continue a conversation (inject follow-up message)
|
|
13
|
+
- check_session: Check status of a session
|
|
14
|
+
- end_session: End/kill a session
|
|
15
|
+
- list_sessions: List all sessions
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
23
|
+
|
|
24
|
+
from wbal.helper import weaveTool
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from zwarm.orchestrator import Orchestrator
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_session_manager(orchestrator: "Orchestrator"):
|
|
31
|
+
"""
|
|
32
|
+
Get the CodexSessionManager - the SINGLE source of truth for sessions.
|
|
33
|
+
|
|
34
|
+
Both `zwarm interactive` and `zwarm orchestrate` use the same session manager.
|
|
35
|
+
The orchestrator is just another user that happens to be an LLM.
|
|
36
|
+
|
|
37
|
+
The session manager is created eagerly in Orchestrator.model_post_init()
|
|
38
|
+
and shared with the environment for observe() visibility.
|
|
39
|
+
"""
|
|
40
|
+
# Should already exist from model_post_init, but create if not
|
|
41
|
+
if not hasattr(orchestrator, "_session_manager") or orchestrator._session_manager is None:
|
|
42
|
+
from zwarm.sessions import CodexSessionManager
|
|
43
|
+
orchestrator._session_manager = CodexSessionManager(orchestrator.working_dir / ".zwarm")
|
|
44
|
+
return orchestrator._session_manager
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _wait_for_completion(manager, session_id: str, timeout: float = 300.0, poll_interval: float = 1.0) -> bool:
|
|
48
|
+
"""
|
|
49
|
+
Wait for a session to complete.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
manager: CodexSessionManager
|
|
53
|
+
session_id: Session to wait for
|
|
54
|
+
timeout: Max seconds to wait
|
|
55
|
+
poll_interval: Seconds between polls
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if completed, False if timed out
|
|
59
|
+
"""
|
|
60
|
+
from zwarm.sessions import SessionStatus
|
|
61
|
+
|
|
62
|
+
start = time.time()
|
|
63
|
+
while time.time() - start < timeout:
|
|
64
|
+
# get_session() auto-updates status based on output completion markers
|
|
65
|
+
session = manager.get_session(session_id)
|
|
66
|
+
if not session:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# Check status (not is_running - PID check is unreliable due to reuse)
|
|
70
|
+
if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
time.sleep(poll_interval)
|
|
74
|
+
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _truncate(text: str, max_len: int = 200) -> str:
|
|
79
|
+
"""Truncate text with ellipsis."""
|
|
80
|
+
if len(text) <= max_len:
|
|
81
|
+
return text
|
|
82
|
+
return text[:max_len - 3] + "..."
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format_session_header(session) -> str:
|
|
86
|
+
"""Format a nice session header."""
|
|
87
|
+
return f"[{session.short_id}] codex ({session.status.value})"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _get_total_tokens(session) -> int:
|
|
91
|
+
"""Get total tokens, computing from input+output if not present."""
|
|
92
|
+
usage = session.token_usage
|
|
93
|
+
if "total_tokens" in usage:
|
|
94
|
+
return usage["total_tokens"]
|
|
95
|
+
return usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate_working_dir(
|
|
99
|
+
requested_dir: Path | str | None,
|
|
100
|
+
default_dir: Path,
|
|
101
|
+
allowed_dirs: list[str] | None,
|
|
102
|
+
) -> tuple[Path, str | None]:
|
|
103
|
+
"""
|
|
104
|
+
Validate requested working directory against allowed_dirs config.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
requested_dir: Directory requested by the agent (or None for default)
|
|
108
|
+
default_dir: The orchestrator's working directory
|
|
109
|
+
allowed_dirs: Config setting - None means only default allowed,
|
|
110
|
+
["*"] means any, or list of allowed paths
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
(validated_path, error_message) - error is None if valid
|
|
114
|
+
"""
|
|
115
|
+
if requested_dir is None:
|
|
116
|
+
return default_dir, None
|
|
117
|
+
|
|
118
|
+
requested = Path(requested_dir).resolve()
|
|
119
|
+
|
|
120
|
+
# Check if directory exists
|
|
121
|
+
if not requested.exists():
|
|
122
|
+
return default_dir, f"Directory does not exist: {requested}"
|
|
123
|
+
|
|
124
|
+
if not requested.is_dir():
|
|
125
|
+
return default_dir, f"Not a directory: {requested}"
|
|
126
|
+
|
|
127
|
+
# If allowed_dirs is None, only default is allowed
|
|
128
|
+
if allowed_dirs is None:
|
|
129
|
+
if requested == default_dir.resolve():
|
|
130
|
+
return requested, None
|
|
131
|
+
return default_dir, (
|
|
132
|
+
f"Directory not allowed: {requested}. "
|
|
133
|
+
f"Agent can only delegate to working directory ({default_dir}). "
|
|
134
|
+
"Set orchestrator.allowed_dirs in config to allow other directories."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# If ["*"], any directory is allowed
|
|
138
|
+
if allowed_dirs == ["*"]:
|
|
139
|
+
return requested, None
|
|
140
|
+
|
|
141
|
+
# Check against allowed list
|
|
142
|
+
for allowed in allowed_dirs:
|
|
143
|
+
allowed_path = Path(allowed).resolve()
|
|
144
|
+
# Allow if requested is the allowed path or a subdirectory of it
|
|
145
|
+
try:
|
|
146
|
+
requested.relative_to(allowed_path)
|
|
147
|
+
return requested, None
|
|
148
|
+
except ValueError:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
return default_dir, (
|
|
152
|
+
f"Directory not allowed: {requested}. "
|
|
153
|
+
f"Allowed directories: {allowed_dirs}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@weaveTool
|
|
158
|
+
def delegate(
|
|
159
|
+
self: "Orchestrator",
|
|
160
|
+
task: str,
|
|
161
|
+
mode: Literal["sync", "async"] = "sync",
|
|
162
|
+
model: str | None = None,
|
|
163
|
+
working_dir: str | None = None,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""
|
|
166
|
+
Delegate work to a Codex agent.
|
|
167
|
+
|
|
168
|
+
This spawns a codex session - the exact same way `zwarm interactive` does.
|
|
169
|
+
Two modes available:
|
|
170
|
+
|
|
171
|
+
**sync** (default): Wait for codex to complete, then return the response.
|
|
172
|
+
Best for: most tasks - you get the full response immediately.
|
|
173
|
+
|
|
174
|
+
**async**: Fire-and-forget execution.
|
|
175
|
+
Check progress later with check_session().
|
|
176
|
+
Best for: long-running tasks, parallel work.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
task: Clear description of what to do. Be specific about requirements.
|
|
180
|
+
mode: "sync" to wait for completion, "async" for fire-and-forget.
|
|
181
|
+
model: Model override (default: gpt-5.1-codex-mini).
|
|
182
|
+
working_dir: Directory for codex to work in (default: orchestrator's dir).
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
{session_id, status, response (if sync)}
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
delegate(task="Add a logout button to the navbar", mode="sync")
|
|
189
|
+
# Then use converse() to refine: "Also add a confirmation dialog"
|
|
190
|
+
"""
|
|
191
|
+
# Validate working directory
|
|
192
|
+
effective_dir, dir_error = _validate_working_dir(
|
|
193
|
+
working_dir,
|
|
194
|
+
self.working_dir,
|
|
195
|
+
self.config.orchestrator.allowed_dirs,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if dir_error:
|
|
199
|
+
return {
|
|
200
|
+
"success": False,
|
|
201
|
+
"error": dir_error,
|
|
202
|
+
"hint": "Use the default working directory or ask user to update allowed_dirs config",
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Get the session manager (same one zwarm interactive uses)
|
|
206
|
+
manager = _get_session_manager(self)
|
|
207
|
+
|
|
208
|
+
# Determine model
|
|
209
|
+
effective_model = model or self.config.executor.model or "gpt-5.1-codex-mini"
|
|
210
|
+
|
|
211
|
+
# Determine sandbox mode
|
|
212
|
+
sandbox = self.config.executor.sandbox or "workspace-write"
|
|
213
|
+
|
|
214
|
+
# Start the session using CodexSessionManager
|
|
215
|
+
# This is the SAME method that `zwarm interactive` uses
|
|
216
|
+
session = manager.start_session(
|
|
217
|
+
task=task,
|
|
218
|
+
working_dir=effective_dir,
|
|
219
|
+
model=effective_model,
|
|
220
|
+
sandbox=sandbox,
|
|
221
|
+
source=f"orchestrator:{self.instance_id or 'default'}",
|
|
222
|
+
adapter="codex",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# For sync mode, wait for completion
|
|
226
|
+
if mode == "sync":
|
|
227
|
+
completed = _wait_for_completion(
|
|
228
|
+
manager,
|
|
229
|
+
session.id,
|
|
230
|
+
timeout=self.config.executor.timeout or 300.0,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Refresh session to get updated status and messages
|
|
234
|
+
session = manager.get_session(session.id)
|
|
235
|
+
|
|
236
|
+
if not completed:
|
|
237
|
+
return {
|
|
238
|
+
"success": False,
|
|
239
|
+
"session_id": session.id,
|
|
240
|
+
"status": "timeout",
|
|
241
|
+
"error": "Session timed out waiting for codex to complete",
|
|
242
|
+
"hint": "Use check_session() to monitor progress, or use async mode for long tasks",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Get the response from messages
|
|
246
|
+
response_text = ""
|
|
247
|
+
messages = manager.get_messages(session.id)
|
|
248
|
+
for msg in messages:
|
|
249
|
+
if msg.role == "assistant":
|
|
250
|
+
response_text = msg.content
|
|
251
|
+
break # Take first assistant message
|
|
252
|
+
|
|
253
|
+
# Build log path for debugging
|
|
254
|
+
log_path = str(manager._output_path(session.id, session.turn))
|
|
255
|
+
|
|
256
|
+
# Check if session failed
|
|
257
|
+
from zwarm.sessions import SessionStatus
|
|
258
|
+
if session.status == SessionStatus.FAILED:
|
|
259
|
+
return {
|
|
260
|
+
"success": False,
|
|
261
|
+
"session": _format_session_header(session),
|
|
262
|
+
"session_id": session.id,
|
|
263
|
+
"status": "failed",
|
|
264
|
+
"task": _truncate(task, 100),
|
|
265
|
+
"error": session.error or "Unknown error",
|
|
266
|
+
"response": response_text or "(no response captured)",
|
|
267
|
+
"tokens": _get_total_tokens(session),
|
|
268
|
+
"log_file": log_path,
|
|
269
|
+
"hint": "Check log_file for raw codex output. Use bash('cat <log_file>') to inspect.",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
"success": True,
|
|
274
|
+
"session": _format_session_header(session),
|
|
275
|
+
"session_id": session.id,
|
|
276
|
+
"status": session.status.value,
|
|
277
|
+
"task": _truncate(task, 100),
|
|
278
|
+
"response": response_text or "(no response captured)",
|
|
279
|
+
"tokens": _get_total_tokens(session),
|
|
280
|
+
"log_file": log_path,
|
|
281
|
+
"hint": "Use converse(session_id, message) to send follow-up messages",
|
|
282
|
+
}
|
|
283
|
+
else:
|
|
284
|
+
# Async mode - return immediately
|
|
285
|
+
return {
|
|
286
|
+
"success": True,
|
|
287
|
+
"session": _format_session_header(session),
|
|
288
|
+
"session_id": session.id,
|
|
289
|
+
"status": "running",
|
|
290
|
+
"task": _truncate(task, 100),
|
|
291
|
+
"hint": "Use check_session(session_id) to monitor progress",
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@weaveTool
|
|
296
|
+
def converse(
|
|
297
|
+
self: "Orchestrator",
|
|
298
|
+
session_id: str,
|
|
299
|
+
message: str,
|
|
300
|
+
wait: bool = True,
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
"""
|
|
303
|
+
Continue a conversation with a codex session.
|
|
304
|
+
|
|
305
|
+
This injects a follow-up message into the session, providing the
|
|
306
|
+
conversation history as context. Like chatting with a developer.
|
|
307
|
+
|
|
308
|
+
Two modes:
|
|
309
|
+
- **wait=True** (default): Wait for codex to respond before returning.
|
|
310
|
+
- **wait=False**: Fire-and-forget. Message sent, codex runs in background.
|
|
311
|
+
Use check_session() later to see the response.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
session_id: The session to continue (from delegate() result).
|
|
315
|
+
message: Your next message to codex.
|
|
316
|
+
wait: If True, wait for response. If False, return immediately.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
{session_id, response (if wait=True), turn}
|
|
320
|
+
|
|
321
|
+
Example (sync):
|
|
322
|
+
result = delegate(task="Add user authentication")
|
|
323
|
+
converse(session_id=result["session_id"], message="Use JWT")
|
|
324
|
+
# Returns with response
|
|
325
|
+
|
|
326
|
+
Example (async - managing multiple sessions):
|
|
327
|
+
converse(session_id="abc123", message="Add tests", wait=False)
|
|
328
|
+
converse(session_id="def456", message="Fix bug", wait=False)
|
|
329
|
+
# Both running in parallel, check later with check_session()
|
|
330
|
+
"""
|
|
331
|
+
manager = _get_session_manager(self)
|
|
332
|
+
|
|
333
|
+
# Get current session
|
|
334
|
+
session = manager.get_session(session_id)
|
|
335
|
+
if not session:
|
|
336
|
+
return {
|
|
337
|
+
"success": False,
|
|
338
|
+
"error": f"Unknown session: {session_id}",
|
|
339
|
+
"hint": "Use list_sessions() to see available sessions",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
# Check if session is in a conversable state
|
|
343
|
+
from zwarm.sessions import SessionStatus
|
|
344
|
+
if session.status == SessionStatus.RUNNING:
|
|
345
|
+
return {
|
|
346
|
+
"success": False,
|
|
347
|
+
"error": "Session is still running",
|
|
348
|
+
"hint": "Wait for the current task to complete, or use check_session() to monitor",
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if session.status == SessionStatus.KILLED:
|
|
352
|
+
return {
|
|
353
|
+
"success": False,
|
|
354
|
+
"error": "Session was killed",
|
|
355
|
+
"hint": "Start a new session with delegate()",
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# Inject the follow-up message
|
|
359
|
+
# This uses CodexSessionManager.inject_message() which:
|
|
360
|
+
# 1. Builds context from previous messages
|
|
361
|
+
# 2. Starts a new turn with the context + new message (background process)
|
|
362
|
+
updated_session = manager.inject_message(session_id, message)
|
|
363
|
+
|
|
364
|
+
if not updated_session:
|
|
365
|
+
return {
|
|
366
|
+
"success": False,
|
|
367
|
+
"error": "Failed to inject message",
|
|
368
|
+
"session_id": session_id,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if not wait:
|
|
372
|
+
# Async mode - return immediately
|
|
373
|
+
return {
|
|
374
|
+
"success": True,
|
|
375
|
+
"session": _format_session_header(updated_session),
|
|
376
|
+
"session_id": session_id,
|
|
377
|
+
"turn": updated_session.turn,
|
|
378
|
+
"status": "running",
|
|
379
|
+
"you_said": _truncate(message, 100),
|
|
380
|
+
"hint": "Use check_session(session_id) to see the response when ready",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
# Sync mode - wait for completion
|
|
384
|
+
completed = _wait_for_completion(
|
|
385
|
+
manager,
|
|
386
|
+
session_id,
|
|
387
|
+
timeout=self.config.executor.timeout or 300.0,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Refresh session
|
|
391
|
+
session = manager.get_session(session_id)
|
|
392
|
+
|
|
393
|
+
if not completed:
|
|
394
|
+
return {
|
|
395
|
+
"success": False,
|
|
396
|
+
"session_id": session_id,
|
|
397
|
+
"status": "timeout",
|
|
398
|
+
"error": "Session timed out waiting for response",
|
|
399
|
+
"hint": "Use check_session() to monitor progress",
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
# Get the response (last assistant message)
|
|
403
|
+
response_text = ""
|
|
404
|
+
messages = manager.get_messages(session_id)
|
|
405
|
+
for msg in reversed(messages):
|
|
406
|
+
if msg.role == "assistant":
|
|
407
|
+
response_text = msg.content
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
"success": True,
|
|
412
|
+
"session": _format_session_header(session),
|
|
413
|
+
"session_id": session_id,
|
|
414
|
+
"turn": session.turn,
|
|
415
|
+
"you_said": _truncate(message, 100),
|
|
416
|
+
"response": response_text or "(no response captured)",
|
|
417
|
+
"tokens": _get_total_tokens(session),
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@weaveTool
|
|
422
|
+
def check_session(
|
|
423
|
+
self: "Orchestrator",
|
|
424
|
+
session_id: str,
|
|
425
|
+
) -> dict[str, Any]:
|
|
426
|
+
"""
|
|
427
|
+
Check the status of a session.
|
|
428
|
+
|
|
429
|
+
Use this to:
|
|
430
|
+
- Check if an async session has finished
|
|
431
|
+
- Get current status and message count
|
|
432
|
+
- View the latest response
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
session_id: The session to check.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
{session_id, status, messages, response}
|
|
439
|
+
"""
|
|
440
|
+
manager = _get_session_manager(self)
|
|
441
|
+
|
|
442
|
+
session = manager.get_session(session_id)
|
|
443
|
+
if not session:
|
|
444
|
+
return {
|
|
445
|
+
"success": False,
|
|
446
|
+
"error": f"Unknown session: {session_id}",
|
|
447
|
+
"hint": "Use list_sessions() to see available sessions",
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# Get latest response
|
|
451
|
+
response_text = ""
|
|
452
|
+
messages = manager.get_messages(session_id)
|
|
453
|
+
for msg in reversed(messages):
|
|
454
|
+
if msg.role == "assistant":
|
|
455
|
+
response_text = msg.content
|
|
456
|
+
break
|
|
457
|
+
|
|
458
|
+
# Build log path
|
|
459
|
+
log_path = str(manager._output_path(session.id, session.turn))
|
|
460
|
+
|
|
461
|
+
result = {
|
|
462
|
+
"success": True,
|
|
463
|
+
"session": _format_session_header(session),
|
|
464
|
+
"session_id": session_id,
|
|
465
|
+
"status": session.status.value,
|
|
466
|
+
"is_running": session.is_running,
|
|
467
|
+
"turn": session.turn,
|
|
468
|
+
"message_count": len(messages),
|
|
469
|
+
"task": _truncate(session.task, 80),
|
|
470
|
+
"response": _truncate(response_text, 500) if response_text else "(no response yet)",
|
|
471
|
+
"tokens": _get_total_tokens(session),
|
|
472
|
+
"runtime": session.runtime,
|
|
473
|
+
"log_file": log_path,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
# Add error info if failed
|
|
477
|
+
from zwarm.sessions import SessionStatus
|
|
478
|
+
if session.status == SessionStatus.FAILED:
|
|
479
|
+
result["success"] = False
|
|
480
|
+
result["error"] = session.error or "Unknown error"
|
|
481
|
+
|
|
482
|
+
return result
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@weaveTool
|
|
486
|
+
def peek_session(
|
|
487
|
+
self: "Orchestrator",
|
|
488
|
+
session_id: str,
|
|
489
|
+
) -> dict[str, Any]:
|
|
490
|
+
"""
|
|
491
|
+
Quick peek at a session - minimal info for fast polling.
|
|
492
|
+
|
|
493
|
+
Returns just status and latest message. Use check_session() for full details.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
session_id: The session to peek at.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
{session_id, status, latest_message}
|
|
500
|
+
"""
|
|
501
|
+
manager = _get_session_manager(self)
|
|
502
|
+
|
|
503
|
+
session = manager.get_session(session_id)
|
|
504
|
+
if not session:
|
|
505
|
+
return {"success": False, "error": f"Unknown session: {session_id}"}
|
|
506
|
+
|
|
507
|
+
# Get latest assistant message only
|
|
508
|
+
latest = ""
|
|
509
|
+
messages = manager.get_messages(session_id)
|
|
510
|
+
for msg in reversed(messages):
|
|
511
|
+
if msg.role == "assistant":
|
|
512
|
+
latest = msg.content.replace("\n", " ")
|
|
513
|
+
break
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
"success": True,
|
|
517
|
+
"session_id": session.short_id,
|
|
518
|
+
"status": session.status.value,
|
|
519
|
+
"is_running": session.status.value == "running",
|
|
520
|
+
"latest_message": _truncate(latest, 150) if latest else None,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@weaveTool
|
|
525
|
+
def get_trajectory(
|
|
526
|
+
self: "Orchestrator",
|
|
527
|
+
session_id: str,
|
|
528
|
+
full: bool = False,
|
|
529
|
+
) -> dict[str, Any]:
|
|
530
|
+
"""
|
|
531
|
+
Get the full trajectory of a session - all steps the agent took.
|
|
532
|
+
|
|
533
|
+
Shows reasoning, commands, tool calls, and responses in order.
|
|
534
|
+
Useful for understanding HOW the agent completed a task, not just
|
|
535
|
+
the final result.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
session_id: The session to get trajectory for.
|
|
539
|
+
full: If True, include full untruncated content (default: False for summary view).
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
{steps: [...], step_count}
|
|
543
|
+
"""
|
|
544
|
+
manager = _get_session_manager(self)
|
|
545
|
+
|
|
546
|
+
session = manager.get_session(session_id)
|
|
547
|
+
if not session:
|
|
548
|
+
return {"success": False, "error": f"Unknown session: {session_id}"}
|
|
549
|
+
|
|
550
|
+
trajectory = manager.get_trajectory(session_id, full=full)
|
|
551
|
+
|
|
552
|
+
# Format steps for easy reading
|
|
553
|
+
formatted_steps = []
|
|
554
|
+
for step in trajectory:
|
|
555
|
+
step_type = step.get("type", "unknown")
|
|
556
|
+
|
|
557
|
+
if step_type == "reasoning":
|
|
558
|
+
text = step.get("full_text") if full else step.get("summary", "")
|
|
559
|
+
formatted_steps.append(f"[thinking] {text}")
|
|
560
|
+
elif step_type == "command":
|
|
561
|
+
cmd = step.get("command", "")
|
|
562
|
+
output = step.get("output", "")
|
|
563
|
+
exit_code = step.get("exit_code")
|
|
564
|
+
step_str = f"[command] $ {cmd}"
|
|
565
|
+
if output:
|
|
566
|
+
if full:
|
|
567
|
+
step_str += f"\n → {output}"
|
|
568
|
+
else:
|
|
569
|
+
step_str += f"\n → {output[:100]}{'...' if len(output) > 100 else ''}"
|
|
570
|
+
if exit_code and exit_code != 0:
|
|
571
|
+
step_str += f" (exit: {exit_code})"
|
|
572
|
+
formatted_steps.append(step_str)
|
|
573
|
+
elif step_type == "tool_call":
|
|
574
|
+
if full and step.get("full_args"):
|
|
575
|
+
import json
|
|
576
|
+
args_str = json.dumps(step["full_args"], indent=2)
|
|
577
|
+
formatted_steps.append(f"[tool] {step.get('tool', 'unknown')}\n {args_str}")
|
|
578
|
+
else:
|
|
579
|
+
formatted_steps.append(f"[tool] {step.get('tool', 'unknown')}({step.get('args_preview', '')})")
|
|
580
|
+
elif step_type == "tool_output":
|
|
581
|
+
output = step.get("output", "")
|
|
582
|
+
if not full:
|
|
583
|
+
output = output[:100]
|
|
584
|
+
formatted_steps.append(f"[result] {output}")
|
|
585
|
+
elif step_type == "message":
|
|
586
|
+
text = step.get("full_text") if full else step.get("summary", "")
|
|
587
|
+
formatted_steps.append(f"[response] {text}")
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
"success": True,
|
|
591
|
+
"session_id": session.short_id,
|
|
592
|
+
"task": _truncate(session.task, 80),
|
|
593
|
+
"step_count": len(trajectory),
|
|
594
|
+
"steps": formatted_steps,
|
|
595
|
+
"mode": "full" if full else "summary",
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@weaveTool
|
|
600
|
+
def end_session(
|
|
601
|
+
self: "Orchestrator",
|
|
602
|
+
session_id: str,
|
|
603
|
+
reason: str | None = None,
|
|
604
|
+
delete: bool = False,
|
|
605
|
+
) -> dict[str, Any]:
|
|
606
|
+
"""
|
|
607
|
+
End/kill a session.
|
|
608
|
+
|
|
609
|
+
Call this when:
|
|
610
|
+
- You want to stop a running session
|
|
611
|
+
- Clean up a completed session
|
|
612
|
+
- Cancel a task
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
session_id: The session to end.
|
|
616
|
+
reason: Optional reason for ending.
|
|
617
|
+
delete: If True, delete session entirely (removes from list_sessions).
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
{session_id, status}
|
|
621
|
+
"""
|
|
622
|
+
manager = _get_session_manager(self)
|
|
623
|
+
|
|
624
|
+
session = manager.get_session(session_id)
|
|
625
|
+
if not session:
|
|
626
|
+
return {
|
|
627
|
+
"success": False,
|
|
628
|
+
"error": f"Unknown session: {session_id}",
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
# If delete requested, remove entirely
|
|
632
|
+
if delete:
|
|
633
|
+
deleted = manager.delete_session(session_id)
|
|
634
|
+
return {
|
|
635
|
+
"success": deleted,
|
|
636
|
+
"session_id": session_id,
|
|
637
|
+
"status": "deleted",
|
|
638
|
+
"reason": reason or "deleted by orchestrator",
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
# Kill if still running
|
|
642
|
+
if session.is_running:
|
|
643
|
+
killed = manager.kill_session(session_id)
|
|
644
|
+
if not killed:
|
|
645
|
+
return {
|
|
646
|
+
"success": False,
|
|
647
|
+
"error": "Failed to kill session",
|
|
648
|
+
"session_id": session_id,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
# Refresh
|
|
652
|
+
session = manager.get_session(session_id)
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
"success": True,
|
|
656
|
+
"session": _format_session_header(session),
|
|
657
|
+
"session_id": session_id,
|
|
658
|
+
"status": session.status.value,
|
|
659
|
+
"reason": reason or "ended by orchestrator",
|
|
660
|
+
"turn": session.turn,
|
|
661
|
+
"tokens": _get_total_tokens(session),
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@weaveTool
|
|
666
|
+
def list_sessions(
|
|
667
|
+
self: "Orchestrator",
|
|
668
|
+
status: str | None = None,
|
|
669
|
+
) -> dict[str, Any]:
|
|
670
|
+
"""
|
|
671
|
+
List all sessions, optionally filtered by status.
|
|
672
|
+
|
|
673
|
+
Returns rich information about each session including:
|
|
674
|
+
- Status (running/completed/failed)
|
|
675
|
+
- Last update time (for detecting changes)
|
|
676
|
+
- Last message preview (quick peek at response)
|
|
677
|
+
- Whether it's recently updated (needs_attention flag)
|
|
678
|
+
|
|
679
|
+
Use this to monitor multiple parallel sessions and see which
|
|
680
|
+
ones have new responses.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
status: Filter by status ("running", "completed", "failed", "killed").
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
{sessions: [...], count, running, completed, needs_attention}
|
|
687
|
+
"""
|
|
688
|
+
from datetime import datetime
|
|
689
|
+
|
|
690
|
+
manager = _get_session_manager(self)
|
|
691
|
+
|
|
692
|
+
# Map string status to enum
|
|
693
|
+
from zwarm.sessions import SessionStatus
|
|
694
|
+
status_filter = None
|
|
695
|
+
if status:
|
|
696
|
+
status_map = {
|
|
697
|
+
"running": SessionStatus.RUNNING,
|
|
698
|
+
"completed": SessionStatus.COMPLETED,
|
|
699
|
+
"failed": SessionStatus.FAILED,
|
|
700
|
+
"killed": SessionStatus.KILLED,
|
|
701
|
+
"pending": SessionStatus.PENDING,
|
|
702
|
+
}
|
|
703
|
+
status_filter = status_map.get(status.lower())
|
|
704
|
+
|
|
705
|
+
sessions = manager.list_sessions(status=status_filter)
|
|
706
|
+
|
|
707
|
+
def time_ago(iso_str: str) -> tuple[str, float]:
|
|
708
|
+
"""Convert ISO timestamp to ('Xm ago', seconds)."""
|
|
709
|
+
try:
|
|
710
|
+
dt = datetime.fromisoformat(iso_str)
|
|
711
|
+
delta = datetime.now() - dt
|
|
712
|
+
secs = delta.total_seconds()
|
|
713
|
+
if secs < 60:
|
|
714
|
+
return f"{int(secs)}s ago", secs
|
|
715
|
+
elif secs < 3600:
|
|
716
|
+
return f"{int(secs/60)}m ago", secs
|
|
717
|
+
elif secs < 86400:
|
|
718
|
+
return f"{secs/3600:.1f}h ago", secs
|
|
719
|
+
else:
|
|
720
|
+
return f"{secs/86400:.1f}d ago", secs
|
|
721
|
+
except:
|
|
722
|
+
return "?", 999999
|
|
723
|
+
|
|
724
|
+
session_list = []
|
|
725
|
+
needs_attention_count = 0
|
|
726
|
+
|
|
727
|
+
for s in sessions:
|
|
728
|
+
status_icon = {
|
|
729
|
+
"running": "●",
|
|
730
|
+
"completed": "✓",
|
|
731
|
+
"failed": "✗",
|
|
732
|
+
"killed": "○",
|
|
733
|
+
"pending": "◌",
|
|
734
|
+
}.get(s.status.value, "?")
|
|
735
|
+
|
|
736
|
+
updated_str, updated_secs = time_ago(s.updated_at)
|
|
737
|
+
|
|
738
|
+
# Get last assistant message
|
|
739
|
+
messages = manager.get_messages(s.id)
|
|
740
|
+
last_message = ""
|
|
741
|
+
for msg in reversed(messages):
|
|
742
|
+
if msg.role == "assistant":
|
|
743
|
+
last_message = msg.content.replace("\n", " ")
|
|
744
|
+
break
|
|
745
|
+
|
|
746
|
+
# Flag sessions that need attention:
|
|
747
|
+
# - Recently completed (< 60s)
|
|
748
|
+
# - Failed
|
|
749
|
+
is_recent = updated_secs < 60
|
|
750
|
+
needs_attention = (
|
|
751
|
+
(s.status == SessionStatus.COMPLETED and is_recent) or
|
|
752
|
+
s.status == SessionStatus.FAILED
|
|
753
|
+
)
|
|
754
|
+
if needs_attention:
|
|
755
|
+
needs_attention_count += 1
|
|
756
|
+
|
|
757
|
+
session_list.append({
|
|
758
|
+
"id": s.short_id,
|
|
759
|
+
"full_id": s.id,
|
|
760
|
+
"status": f"{status_icon} {s.status.value}",
|
|
761
|
+
"is_running": s.status == SessionStatus.RUNNING,
|
|
762
|
+
"task": _truncate(s.task, 50),
|
|
763
|
+
"turn": s.turn,
|
|
764
|
+
"updated": updated_str,
|
|
765
|
+
"updated_secs": int(updated_secs),
|
|
766
|
+
"last_message": _truncate(last_message, 100) if last_message else "(no response yet)",
|
|
767
|
+
"needs_attention": needs_attention,
|
|
768
|
+
"tokens": _get_total_tokens(s),
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
# Summary counts
|
|
772
|
+
running_count = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
|
|
773
|
+
completed_count = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
"success": True,
|
|
777
|
+
"sessions": session_list,
|
|
778
|
+
"count": len(sessions),
|
|
779
|
+
"running": running_count,
|
|
780
|
+
"completed": completed_count,
|
|
781
|
+
"needs_attention": needs_attention_count,
|
|
782
|
+
"filter": status or "all",
|
|
783
|
+
"hint": "Sessions with needs_attention=True have new responses to review" if needs_attention_count else None,
|
|
784
|
+
}
|