zwarm 1.3.11__py3-none-any.whl → 2.0.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/codex_mcp.py +475 -227
- zwarm/cli/main.py +483 -143
- zwarm/core/config.py +2 -0
- zwarm/orchestrator.py +41 -2
- zwarm/prompts/orchestrator.py +29 -13
- zwarm/sessions/__init__.py +2 -0
- zwarm/sessions/manager.py +87 -8
- zwarm/tools/delegation.py +356 -324
- zwarm-2.0.0.dist-info/METADATA +309 -0
- {zwarm-1.3.11.dist-info → zwarm-2.0.0.dist-info}/RECORD +12 -12
- zwarm-1.3.11.dist-info/METADATA +0 -525
- {zwarm-1.3.11.dist-info → zwarm-2.0.0.dist-info}/WHEEL +0 -0
- {zwarm-1.3.11.dist-info → zwarm-2.0.0.dist-info}/entry_points.txt +0 -0
zwarm/tools/delegation.py
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Delegation tools for the orchestrator.
|
|
3
3
|
|
|
4
|
-
These are the core tools that orchestrators use to delegate work to executors
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
12
16
|
"""
|
|
13
17
|
|
|
14
18
|
from __future__ import annotations
|
|
15
19
|
|
|
16
|
-
import
|
|
17
|
-
from datetime import datetime
|
|
20
|
+
import time
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
from typing import TYPE_CHECKING, Any, Literal
|
|
20
23
|
|
|
@@ -25,103 +28,44 @@ if TYPE_CHECKING:
|
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
def _get_session_manager(orchestrator: "Orchestrator"):
|
|
28
|
-
"""
|
|
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
|
+
"""
|
|
29
37
|
if not hasattr(orchestrator, "_session_manager"):
|
|
30
38
|
from zwarm.sessions import CodexSessionManager
|
|
31
39
|
orchestrator._session_manager = CodexSessionManager(orchestrator.working_dir / ".zwarm")
|
|
32
40
|
return orchestrator._session_manager
|
|
33
41
|
|
|
34
42
|
|
|
35
|
-
def
|
|
36
|
-
orchestrator: "Orchestrator",
|
|
37
|
-
session_id: str,
|
|
38
|
-
task: str,
|
|
39
|
-
adapter: str,
|
|
40
|
-
model: str,
|
|
41
|
-
working_dir: Path,
|
|
42
|
-
status: str = "running",
|
|
43
|
-
pid: int | None = None,
|
|
44
|
-
):
|
|
45
|
-
"""
|
|
46
|
-
Register an orchestrator session with CodexSessionManager for visibility.
|
|
47
|
-
|
|
48
|
-
This allows `zwarm interactive` to show orchestrator-delegated sessions
|
|
49
|
-
in its unified dashboard.
|
|
43
|
+
def _wait_for_completion(manager, session_id: str, timeout: float = 300.0, poll_interval: float = 1.0) -> bool:
|
|
50
44
|
"""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
manager = _get_session_manager(orchestrator)
|
|
54
|
-
now = datetime.now().isoformat()
|
|
55
|
-
|
|
56
|
-
# Map status
|
|
57
|
-
status_map = {
|
|
58
|
-
"running": SessionStatus.RUNNING,
|
|
59
|
-
"active": SessionStatus.RUNNING,
|
|
60
|
-
"completed": SessionStatus.COMPLETED,
|
|
61
|
-
"failed": SessionStatus.FAILED,
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
session = CodexSession(
|
|
65
|
-
id=session_id,
|
|
66
|
-
task=task,
|
|
67
|
-
status=status_map.get(status, SessionStatus.RUNNING),
|
|
68
|
-
working_dir=working_dir,
|
|
69
|
-
created_at=now,
|
|
70
|
-
updated_at=now,
|
|
71
|
-
model=model or "unknown",
|
|
72
|
-
pid=pid,
|
|
73
|
-
source=f"orchestrator:{orchestrator.instance_id or 'unknown'}",
|
|
74
|
-
adapter=adapter,
|
|
75
|
-
messages=[SessionMessage(role="user", content=task, timestamp=now)],
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
# Save to disk
|
|
79
|
-
manager._save_session(session)
|
|
80
|
-
return session
|
|
81
|
-
|
|
45
|
+
Wait for a session to complete.
|
|
82
46
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
token_usage: dict | None = None,
|
|
89
|
-
error: str | None = None,
|
|
90
|
-
):
|
|
91
|
-
"""Update a session's visibility record."""
|
|
92
|
-
manager = _get_session_manager(orchestrator)
|
|
93
|
-
session = manager._load_session(session_id)
|
|
94
|
-
|
|
95
|
-
if not session:
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
from zwarm.sessions import SessionStatus, SessionMessage
|
|
99
|
-
|
|
100
|
-
if status:
|
|
101
|
-
status_map = {
|
|
102
|
-
"running": SessionStatus.RUNNING,
|
|
103
|
-
"active": SessionStatus.RUNNING,
|
|
104
|
-
"completed": SessionStatus.COMPLETED,
|
|
105
|
-
"failed": SessionStatus.FAILED,
|
|
106
|
-
}
|
|
107
|
-
session.status = status_map.get(status, session.status)
|
|
47
|
+
Args:
|
|
48
|
+
manager: CodexSessionManager
|
|
49
|
+
session_id: Session to wait for
|
|
50
|
+
timeout: Max seconds to wait
|
|
51
|
+
poll_interval: Seconds between polls
|
|
108
52
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
53
|
+
Returns:
|
|
54
|
+
True if completed, False if timed out
|
|
55
|
+
"""
|
|
56
|
+
start = time.time()
|
|
57
|
+
while time.time() - start < timeout:
|
|
58
|
+
session = manager.get_session(session_id)
|
|
59
|
+
if not session:
|
|
60
|
+
return False
|
|
117
61
|
|
|
118
|
-
|
|
119
|
-
session.
|
|
62
|
+
# Check if process is still running
|
|
63
|
+
if not session.is_running:
|
|
64
|
+
return True
|
|
120
65
|
|
|
121
|
-
|
|
122
|
-
session.error = error
|
|
66
|
+
time.sleep(poll_interval)
|
|
123
67
|
|
|
124
|
-
|
|
68
|
+
return False
|
|
125
69
|
|
|
126
70
|
|
|
127
71
|
def _truncate(text: str, max_len: int = 200) -> str:
|
|
@@ -131,9 +75,9 @@ def _truncate(text: str, max_len: int = 200) -> str:
|
|
|
131
75
|
return text[:max_len - 3] + "..."
|
|
132
76
|
|
|
133
77
|
|
|
134
|
-
def _format_session_header(
|
|
78
|
+
def _format_session_header(session) -> str:
|
|
135
79
|
"""Format a nice session header."""
|
|
136
|
-
return f"[{
|
|
80
|
+
return f"[{session.short_id}] codex ({session.status.value})"
|
|
137
81
|
|
|
138
82
|
|
|
139
83
|
def _validate_working_dir(
|
|
@@ -200,30 +144,27 @@ def delegate(
|
|
|
200
144
|
self: "Orchestrator",
|
|
201
145
|
task: str,
|
|
202
146
|
mode: Literal["sync", "async"] = "sync",
|
|
203
|
-
adapter: str | None = None,
|
|
204
147
|
model: str | None = None,
|
|
205
148
|
working_dir: str | None = None,
|
|
206
149
|
) -> dict[str, Any]:
|
|
207
150
|
"""
|
|
208
|
-
Delegate work to
|
|
151
|
+
Delegate work to a Codex agent.
|
|
209
152
|
|
|
210
|
-
|
|
153
|
+
This spawns a codex session - the exact same way `zwarm interactive` does.
|
|
154
|
+
Two modes available:
|
|
211
155
|
|
|
212
|
-
**sync** (default):
|
|
213
|
-
|
|
214
|
-
Best for: ambiguous tasks, complex requirements, tasks needing guidance.
|
|
156
|
+
**sync** (default): Wait for codex to complete, then return the response.
|
|
157
|
+
Best for: most tasks - you get the full response immediately.
|
|
215
158
|
|
|
216
159
|
**async**: Fire-and-forget execution.
|
|
217
160
|
Check progress later with check_session().
|
|
218
|
-
Best for:
|
|
161
|
+
Best for: long-running tasks, parallel work.
|
|
219
162
|
|
|
220
163
|
Args:
|
|
221
164
|
task: Clear description of what to do. Be specific about requirements.
|
|
222
|
-
mode: "sync" for
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
working_dir: Directory for the executor to work in (default: orchestrator's dir).
|
|
226
|
-
NOTE: May be restricted by orchestrator.allowed_dirs config.
|
|
165
|
+
mode: "sync" to wait for completion, "async" for fire-and-forget.
|
|
166
|
+
model: Model override (default: gpt-5.1-codex-mini).
|
|
167
|
+
working_dir: Directory for codex to work in (default: orchestrator's dir).
|
|
227
168
|
|
|
228
169
|
Returns:
|
|
229
170
|
{session_id, status, response (if sync)}
|
|
@@ -232,7 +173,7 @@ def delegate(
|
|
|
232
173
|
delegate(task="Add a logout button to the navbar", mode="sync")
|
|
233
174
|
# Then use converse() to refine: "Also add a confirmation dialog"
|
|
234
175
|
"""
|
|
235
|
-
# Validate working directory
|
|
176
|
+
# Validate working directory
|
|
236
177
|
effective_dir, dir_error = _validate_working_dir(
|
|
237
178
|
working_dir,
|
|
238
179
|
self.working_dir,
|
|
@@ -246,92 +187,69 @@ def delegate(
|
|
|
246
187
|
"hint": "Use the default working directory or ask user to update allowed_dirs config",
|
|
247
188
|
}
|
|
248
189
|
|
|
249
|
-
# Get
|
|
250
|
-
|
|
251
|
-
executor = self._get_adapter(adapter_name)
|
|
252
|
-
|
|
253
|
-
# Run async start_session
|
|
254
|
-
session = asyncio.run(
|
|
255
|
-
executor.start_session(
|
|
256
|
-
task=task,
|
|
257
|
-
working_dir=effective_dir,
|
|
258
|
-
mode=mode,
|
|
259
|
-
model=model or self.config.executor.model,
|
|
260
|
-
sandbox=self.config.executor.sandbox,
|
|
261
|
-
)
|
|
262
|
-
)
|
|
190
|
+
# Get the session manager (same one zwarm interactive uses)
|
|
191
|
+
manager = _get_session_manager(self)
|
|
263
192
|
|
|
264
|
-
#
|
|
265
|
-
self.
|
|
266
|
-
self.state.add_session(session)
|
|
193
|
+
# Determine model
|
|
194
|
+
effective_model = model or self.config.executor.model or "gpt-5.1-codex-mini"
|
|
267
195
|
|
|
268
|
-
#
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
196
|
+
# Determine sandbox mode
|
|
197
|
+
sandbox = self.config.executor.sandbox or "workspace-write"
|
|
198
|
+
|
|
199
|
+
# Start the session using CodexSessionManager
|
|
200
|
+
# This is the SAME method that `zwarm interactive` uses
|
|
201
|
+
session = manager.start_session(
|
|
272
202
|
task=task,
|
|
273
|
-
adapter=adapter_name,
|
|
274
|
-
model=model or self.config.executor.model,
|
|
275
203
|
working_dir=effective_dir,
|
|
276
|
-
|
|
277
|
-
|
|
204
|
+
model=effective_model,
|
|
205
|
+
sandbox=sandbox,
|
|
206
|
+
source=f"orchestrator:{self.instance_id or 'default'}",
|
|
207
|
+
adapter="codex",
|
|
278
208
|
)
|
|
279
209
|
|
|
280
|
-
#
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if mode == "sync" and session.messages:
|
|
288
|
-
response_text = session.messages[-1].content
|
|
289
|
-
# Log the assistant response too
|
|
290
|
-
self.state.log_event(event_message_sent(
|
|
291
|
-
session,
|
|
292
|
-
Message(role="assistant", content=response_text)
|
|
293
|
-
))
|
|
294
|
-
|
|
295
|
-
# Log delegation result for debugging
|
|
296
|
-
from zwarm.core.models import Event
|
|
297
|
-
self.state.log_event(Event(
|
|
298
|
-
kind="delegation_result",
|
|
299
|
-
payload={
|
|
300
|
-
"session_id": session.id,
|
|
301
|
-
"mode": mode,
|
|
302
|
-
"adapter": adapter_name,
|
|
303
|
-
"response_length": len(response_text),
|
|
304
|
-
"response_preview": response_text[:500] if response_text else "(empty)",
|
|
305
|
-
"message_count": len(session.messages),
|
|
306
|
-
},
|
|
307
|
-
))
|
|
210
|
+
# For sync mode, wait for completion
|
|
211
|
+
if mode == "sync":
|
|
212
|
+
completed = _wait_for_completion(
|
|
213
|
+
manager,
|
|
214
|
+
session.id,
|
|
215
|
+
timeout=self.config.executor.timeout or 300.0,
|
|
216
|
+
)
|
|
308
217
|
|
|
309
|
-
|
|
310
|
-
|
|
218
|
+
# Refresh session to get updated status and messages
|
|
219
|
+
session = manager.get_session(session.id)
|
|
220
|
+
|
|
221
|
+
if not completed:
|
|
222
|
+
return {
|
|
223
|
+
"success": False,
|
|
224
|
+
"session_id": session.id,
|
|
225
|
+
"status": "timeout",
|
|
226
|
+
"error": "Session timed out waiting for codex to complete",
|
|
227
|
+
"hint": "Use check_session() to monitor progress, or use async mode for long tasks",
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Get the response from messages
|
|
231
|
+
response_text = ""
|
|
232
|
+
messages = manager.get_messages(session.id)
|
|
233
|
+
for msg in messages:
|
|
234
|
+
if msg.role == "assistant":
|
|
235
|
+
response_text = msg.content
|
|
236
|
+
break # Take first assistant message
|
|
311
237
|
|
|
312
|
-
|
|
313
|
-
result = {
|
|
238
|
+
return {
|
|
314
239
|
"success": True,
|
|
315
|
-
"session":
|
|
240
|
+
"session": _format_session_header(session),
|
|
316
241
|
"session_id": session.id,
|
|
317
|
-
"status":
|
|
242
|
+
"status": session.status.value,
|
|
318
243
|
"task": _truncate(task, 100),
|
|
319
|
-
"response": response_text,
|
|
244
|
+
"response": response_text or "(no response captured)",
|
|
320
245
|
"tokens": session.token_usage.get("total_tokens", 0),
|
|
321
|
-
"hint": "Use converse(session_id, message) to
|
|
246
|
+
"hint": "Use converse(session_id, message) to send follow-up messages",
|
|
322
247
|
}
|
|
323
|
-
# Warn if no conversation ID - converse() won't work
|
|
324
|
-
if not session.conversation_id:
|
|
325
|
-
result["warning"] = "no_conversation_id"
|
|
326
|
-
result["hint"] = (
|
|
327
|
-
"WARNING: MCP didn't return a conversation ID. "
|
|
328
|
-
"You cannot use converse() - send all instructions upfront or use async mode."
|
|
329
|
-
)
|
|
330
|
-
return result
|
|
331
248
|
else:
|
|
249
|
+
# Async mode - return immediately
|
|
332
250
|
return {
|
|
333
251
|
"success": True,
|
|
334
|
-
"session":
|
|
252
|
+
"session": _format_session_header(session),
|
|
335
253
|
"session_id": session.id,
|
|
336
254
|
"status": "running",
|
|
337
255
|
"task": _truncate(task, 100),
|
|
@@ -344,28 +262,41 @@ def converse(
|
|
|
344
262
|
self: "Orchestrator",
|
|
345
263
|
session_id: str,
|
|
346
264
|
message: str,
|
|
265
|
+
wait: bool = True,
|
|
347
266
|
) -> dict[str, Any]:
|
|
348
267
|
"""
|
|
349
|
-
Continue a
|
|
268
|
+
Continue a conversation with a codex session.
|
|
269
|
+
|
|
270
|
+
This injects a follow-up message into the session, providing the
|
|
271
|
+
conversation history as context. Like chatting with a developer.
|
|
350
272
|
|
|
351
|
-
|
|
352
|
-
|
|
273
|
+
Two modes:
|
|
274
|
+
- **wait=True** (default): Wait for codex to respond before returning.
|
|
275
|
+
- **wait=False**: Fire-and-forget. Message sent, codex runs in background.
|
|
276
|
+
Use check_session() later to see the response.
|
|
353
277
|
|
|
354
278
|
Args:
|
|
355
279
|
session_id: The session to continue (from delegate() result).
|
|
356
|
-
message: Your next message to
|
|
280
|
+
message: Your next message to codex.
|
|
281
|
+
wait: If True, wait for response. If False, return immediately.
|
|
357
282
|
|
|
358
283
|
Returns:
|
|
359
|
-
{session_id, response, turn}
|
|
284
|
+
{session_id, response (if wait=True), turn}
|
|
360
285
|
|
|
361
|
-
Example:
|
|
286
|
+
Example (sync):
|
|
362
287
|
result = delegate(task="Add user authentication")
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
288
|
+
converse(session_id=result["session_id"], message="Use JWT")
|
|
289
|
+
# Returns with response
|
|
290
|
+
|
|
291
|
+
Example (async - managing multiple sessions):
|
|
292
|
+
converse(session_id="abc123", message="Add tests", wait=False)
|
|
293
|
+
converse(session_id="def456", message="Fix bug", wait=False)
|
|
294
|
+
# Both running in parallel, check later with check_session()
|
|
367
295
|
"""
|
|
368
|
-
|
|
296
|
+
manager = _get_session_manager(self)
|
|
297
|
+
|
|
298
|
+
# Get current session
|
|
299
|
+
session = manager.get_session(session_id)
|
|
369
300
|
if not session:
|
|
370
301
|
return {
|
|
371
302
|
"success": False,
|
|
@@ -373,91 +304,84 @@ def converse(
|
|
|
373
304
|
"hint": "Use list_sessions() to see available sessions",
|
|
374
305
|
}
|
|
375
306
|
|
|
376
|
-
if session
|
|
307
|
+
# Check if session is in a conversable state
|
|
308
|
+
from zwarm.sessions import SessionStatus
|
|
309
|
+
if session.status == SessionStatus.RUNNING:
|
|
377
310
|
return {
|
|
378
311
|
"success": False,
|
|
379
|
-
"error": "
|
|
380
|
-
"hint": "
|
|
312
|
+
"error": "Session is still running",
|
|
313
|
+
"hint": "Wait for the current task to complete, or use check_session() to monitor",
|
|
381
314
|
}
|
|
382
315
|
|
|
383
|
-
if session.status
|
|
316
|
+
if session.status == SessionStatus.KILLED:
|
|
384
317
|
return {
|
|
385
318
|
"success": False,
|
|
386
|
-
"error":
|
|
319
|
+
"error": "Session was killed",
|
|
387
320
|
"hint": "Start a new session with delegate()",
|
|
388
321
|
}
|
|
389
322
|
|
|
390
|
-
#
|
|
391
|
-
|
|
323
|
+
# Inject the follow-up message
|
|
324
|
+
# This uses CodexSessionManager.inject_message() which:
|
|
325
|
+
# 1. Builds context from previous messages
|
|
326
|
+
# 2. Starts a new turn with the context + new message (background process)
|
|
327
|
+
updated_session = manager.inject_message(session_id, message)
|
|
328
|
+
|
|
329
|
+
if not updated_session:
|
|
392
330
|
return {
|
|
393
331
|
"success": False,
|
|
394
|
-
"error": "
|
|
395
|
-
"hint": (
|
|
396
|
-
"This session's conversation was lost (MCP server restarted). "
|
|
397
|
-
"Use end_session() to close it, then delegate() a new task."
|
|
398
|
-
),
|
|
332
|
+
"error": "Failed to inject message",
|
|
399
333
|
"session_id": session_id,
|
|
400
334
|
}
|
|
401
335
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
try:
|
|
405
|
-
response = asyncio.run(
|
|
406
|
-
executor.send_message(session, message)
|
|
407
|
-
)
|
|
408
|
-
except Exception as e:
|
|
336
|
+
if not wait:
|
|
337
|
+
# Async mode - return immediately
|
|
409
338
|
return {
|
|
410
|
-
"success":
|
|
411
|
-
"
|
|
339
|
+
"success": True,
|
|
340
|
+
"session": _format_session_header(updated_session),
|
|
412
341
|
"session_id": session_id,
|
|
342
|
+
"turn": updated_session.turn,
|
|
343
|
+
"status": "running",
|
|
344
|
+
"you_said": _truncate(message, 100),
|
|
345
|
+
"hint": "Use check_session(session_id) to see the response when ready",
|
|
413
346
|
}
|
|
414
347
|
|
|
415
|
-
#
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
_update_session_visibility(
|
|
421
|
-
orchestrator=self,
|
|
422
|
-
session_id=session_id,
|
|
423
|
-
messages=[Message(role="user", content=message), Message(role="assistant", content=response)],
|
|
424
|
-
token_usage=session.token_usage,
|
|
348
|
+
# Sync mode - wait for completion
|
|
349
|
+
completed = _wait_for_completion(
|
|
350
|
+
manager,
|
|
351
|
+
session_id,
|
|
352
|
+
timeout=self.config.executor.timeout or 300.0,
|
|
425
353
|
)
|
|
426
354
|
|
|
427
|
-
#
|
|
428
|
-
|
|
429
|
-
self.state.log_event(event_message_sent(session, Message(role="user", content=message)))
|
|
430
|
-
self.state.log_event(event_message_sent(session, Message(role="assistant", content=response)))
|
|
355
|
+
# Refresh session
|
|
356
|
+
session = manager.get_session(session_id)
|
|
431
357
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
358
|
+
if not completed:
|
|
359
|
+
return {
|
|
360
|
+
"success": False,
|
|
361
|
+
"session_id": session_id,
|
|
362
|
+
"status": "timeout",
|
|
363
|
+
"error": "Session timed out waiting for response",
|
|
364
|
+
"hint": "Use check_session() to monitor progress",
|
|
365
|
+
}
|
|
435
366
|
|
|
436
|
-
#
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
367
|
+
# Get the response (last assistant message)
|
|
368
|
+
response_text = ""
|
|
369
|
+
messages = manager.get_messages(session_id)
|
|
370
|
+
for msg in reversed(messages):
|
|
371
|
+
if msg.role == "assistant":
|
|
372
|
+
response_text = msg.content
|
|
373
|
+
break
|
|
441
374
|
|
|
442
|
-
|
|
375
|
+
return {
|
|
443
376
|
"success": True,
|
|
444
|
-
"session":
|
|
377
|
+
"session": _format_session_header(session),
|
|
445
378
|
"session_id": session_id,
|
|
446
|
-
"turn": turn,
|
|
379
|
+
"turn": session.turn,
|
|
447
380
|
"you_said": _truncate(message, 100),
|
|
448
|
-
"response": response,
|
|
381
|
+
"response": response_text or "(no response captured)",
|
|
449
382
|
"tokens": session.token_usage.get("total_tokens", 0),
|
|
450
383
|
}
|
|
451
384
|
|
|
452
|
-
if conversation_lost:
|
|
453
|
-
result["warning"] = "conversation_lost"
|
|
454
|
-
result["hint"] = (
|
|
455
|
-
"The MCP server lost this conversation. You should end_session() "
|
|
456
|
-
"and delegate() a new task with the full context."
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
return result
|
|
460
|
-
|
|
461
385
|
|
|
462
386
|
@weaveTool
|
|
463
387
|
def check_session(
|
|
@@ -467,16 +391,20 @@ def check_session(
|
|
|
467
391
|
"""
|
|
468
392
|
Check the status of a session.
|
|
469
393
|
|
|
470
|
-
|
|
471
|
-
|
|
394
|
+
Use this to:
|
|
395
|
+
- Check if an async session has finished
|
|
396
|
+
- Get current status and message count
|
|
397
|
+
- View the latest response
|
|
472
398
|
|
|
473
399
|
Args:
|
|
474
400
|
session_id: The session to check.
|
|
475
401
|
|
|
476
402
|
Returns:
|
|
477
|
-
{session_id, status,
|
|
403
|
+
{session_id, status, messages, response}
|
|
478
404
|
"""
|
|
479
|
-
|
|
405
|
+
manager = _get_session_manager(self)
|
|
406
|
+
|
|
407
|
+
session = manager.get_session(session_id)
|
|
480
408
|
if not session:
|
|
481
409
|
return {
|
|
482
410
|
"success": False,
|
|
@@ -484,33 +412,65 @@ def check_session(
|
|
|
484
412
|
"hint": "Use list_sessions() to see available sessions",
|
|
485
413
|
}
|
|
486
414
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
)
|
|
415
|
+
# Get latest response
|
|
416
|
+
response_text = ""
|
|
417
|
+
messages = manager.get_messages(session_id)
|
|
418
|
+
for msg in reversed(messages):
|
|
419
|
+
if msg.role == "assistant":
|
|
420
|
+
response_text = msg.content
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
"success": True,
|
|
425
|
+
"session": _format_session_header(session),
|
|
426
|
+
"session_id": session_id,
|
|
427
|
+
"status": session.status.value,
|
|
428
|
+
"is_running": session.is_running,
|
|
429
|
+
"turn": session.turn,
|
|
430
|
+
"message_count": len(messages),
|
|
431
|
+
"task": _truncate(session.task, 80),
|
|
432
|
+
"response": _truncate(response_text, 500) if response_text else "(no response yet)",
|
|
433
|
+
"tokens": session.token_usage.get("total_tokens", 0),
|
|
434
|
+
"runtime": session.runtime,
|
|
435
|
+
}
|
|
491
436
|
|
|
492
|
-
# Update state if status changed
|
|
493
|
-
self.state.update_session(session)
|
|
494
437
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
438
|
+
@weaveTool
|
|
439
|
+
def peek_session(
|
|
440
|
+
self: "Orchestrator",
|
|
441
|
+
session_id: str,
|
|
442
|
+
) -> dict[str, Any]:
|
|
443
|
+
"""
|
|
444
|
+
Quick peek at a session - minimal info for fast polling.
|
|
445
|
+
|
|
446
|
+
Returns just status and latest message. Use check_session() for full details.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
session_id: The session to peek at.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
{session_id, status, latest_message}
|
|
453
|
+
"""
|
|
454
|
+
manager = _get_session_manager(self)
|
|
455
|
+
|
|
456
|
+
session = manager.get_session(session_id)
|
|
457
|
+
if not session:
|
|
458
|
+
return {"success": False, "error": f"Unknown session: {session_id}"}
|
|
502
459
|
|
|
503
|
-
|
|
460
|
+
# Get latest assistant message only
|
|
461
|
+
latest = ""
|
|
462
|
+
messages = manager.get_messages(session_id)
|
|
463
|
+
for msg in reversed(messages):
|
|
464
|
+
if msg.role == "assistant":
|
|
465
|
+
latest = msg.content.replace("\n", " ")
|
|
466
|
+
break
|
|
504
467
|
|
|
505
468
|
return {
|
|
506
469
|
"success": True,
|
|
507
|
-
"
|
|
508
|
-
"session_id": session_id,
|
|
509
|
-
"mode": session.mode.value,
|
|
470
|
+
"session_id": session.short_id,
|
|
510
471
|
"status": session.status.value,
|
|
511
|
-
"
|
|
512
|
-
"
|
|
513
|
-
**status,
|
|
472
|
+
"is_running": session.status.value == "running",
|
|
473
|
+
"latest_message": _truncate(latest, 150) if latest else None,
|
|
514
474
|
}
|
|
515
475
|
|
|
516
476
|
|
|
@@ -518,72 +478,65 @@ def check_session(
|
|
|
518
478
|
def end_session(
|
|
519
479
|
self: "Orchestrator",
|
|
520
480
|
session_id: str,
|
|
521
|
-
|
|
522
|
-
|
|
481
|
+
reason: str | None = None,
|
|
482
|
+
delete: bool = False,
|
|
523
483
|
) -> dict[str, Any]:
|
|
524
484
|
"""
|
|
525
|
-
End a session
|
|
485
|
+
End/kill a session.
|
|
526
486
|
|
|
527
487
|
Call this when:
|
|
528
|
-
-
|
|
529
|
-
-
|
|
530
|
-
-
|
|
488
|
+
- You want to stop a running session
|
|
489
|
+
- Clean up a completed session
|
|
490
|
+
- Cancel a task
|
|
531
491
|
|
|
532
492
|
Args:
|
|
533
493
|
session_id: The session to end.
|
|
534
|
-
|
|
535
|
-
|
|
494
|
+
reason: Optional reason for ending.
|
|
495
|
+
delete: If True, delete session entirely (removes from list_sessions).
|
|
536
496
|
|
|
537
497
|
Returns:
|
|
538
|
-
{session_id, status
|
|
498
|
+
{session_id, status}
|
|
539
499
|
"""
|
|
540
|
-
|
|
500
|
+
manager = _get_session_manager(self)
|
|
501
|
+
|
|
502
|
+
session = manager.get_session(session_id)
|
|
541
503
|
if not session:
|
|
542
504
|
return {
|
|
543
505
|
"success": False,
|
|
544
506
|
"error": f"Unknown session: {session_id}",
|
|
545
507
|
}
|
|
546
508
|
|
|
547
|
-
#
|
|
548
|
-
if
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
else:
|
|
557
|
-
session.fail(f"Cancelled: {summary}" if summary else "Cancelled")
|
|
558
|
-
|
|
559
|
-
# Update state
|
|
560
|
-
self.state.update_session(session)
|
|
561
|
-
|
|
562
|
-
# Update visibility record
|
|
563
|
-
_update_session_visibility(
|
|
564
|
-
orchestrator=self,
|
|
565
|
-
session_id=session_id,
|
|
566
|
-
status=verdict,
|
|
567
|
-
token_usage=session.token_usage,
|
|
568
|
-
error=summary if verdict == "failed" else None,
|
|
569
|
-
)
|
|
509
|
+
# If delete requested, remove entirely
|
|
510
|
+
if delete:
|
|
511
|
+
deleted = manager.delete_session(session_id)
|
|
512
|
+
return {
|
|
513
|
+
"success": deleted,
|
|
514
|
+
"session_id": session_id,
|
|
515
|
+
"status": "deleted",
|
|
516
|
+
"reason": reason or "deleted by orchestrator",
|
|
517
|
+
}
|
|
570
518
|
|
|
571
|
-
#
|
|
572
|
-
|
|
573
|
-
|
|
519
|
+
# Kill if still running
|
|
520
|
+
if session.is_running:
|
|
521
|
+
killed = manager.kill_session(session_id)
|
|
522
|
+
if not killed:
|
|
523
|
+
return {
|
|
524
|
+
"success": False,
|
|
525
|
+
"error": "Failed to kill session",
|
|
526
|
+
"session_id": session_id,
|
|
527
|
+
}
|
|
574
528
|
|
|
575
|
-
|
|
576
|
-
|
|
529
|
+
# Refresh
|
|
530
|
+
session = manager.get_session(session_id)
|
|
577
531
|
|
|
578
532
|
return {
|
|
579
533
|
"success": True,
|
|
580
|
-
"session":
|
|
534
|
+
"session": _format_session_header(session),
|
|
581
535
|
"session_id": session_id,
|
|
582
|
-
"
|
|
583
|
-
"
|
|
584
|
-
"
|
|
585
|
-
"
|
|
586
|
-
"token_usage": session.token_usage,
|
|
536
|
+
"status": session.status.value,
|
|
537
|
+
"reason": reason or "ended by orchestrator",
|
|
538
|
+
"turn": session.turn,
|
|
539
|
+
"tokens": session.token_usage.get("total_tokens", 0),
|
|
587
540
|
}
|
|
588
541
|
|
|
589
542
|
|
|
@@ -595,36 +548,115 @@ def list_sessions(
|
|
|
595
548
|
"""
|
|
596
549
|
List all sessions, optionally filtered by status.
|
|
597
550
|
|
|
551
|
+
Returns rich information about each session including:
|
|
552
|
+
- Status (running/completed/failed)
|
|
553
|
+
- Last update time (for detecting changes)
|
|
554
|
+
- Last message preview (quick peek at response)
|
|
555
|
+
- Whether it's recently updated (needs_attention flag)
|
|
556
|
+
|
|
557
|
+
Use this to monitor multiple parallel sessions and see which
|
|
558
|
+
ones have new responses.
|
|
559
|
+
|
|
598
560
|
Args:
|
|
599
|
-
status: Filter by status ("
|
|
561
|
+
status: Filter by status ("running", "completed", "failed", "killed").
|
|
600
562
|
|
|
601
563
|
Returns:
|
|
602
|
-
{sessions: [...], count}
|
|
564
|
+
{sessions: [...], count, running, completed, needs_attention}
|
|
603
565
|
"""
|
|
604
|
-
|
|
566
|
+
from datetime import datetime
|
|
567
|
+
|
|
568
|
+
manager = _get_session_manager(self)
|
|
569
|
+
|
|
570
|
+
# Map string status to enum
|
|
571
|
+
from zwarm.sessions import SessionStatus
|
|
572
|
+
status_filter = None
|
|
573
|
+
if status:
|
|
574
|
+
status_map = {
|
|
575
|
+
"running": SessionStatus.RUNNING,
|
|
576
|
+
"completed": SessionStatus.COMPLETED,
|
|
577
|
+
"failed": SessionStatus.FAILED,
|
|
578
|
+
"killed": SessionStatus.KILLED,
|
|
579
|
+
"pending": SessionStatus.PENDING,
|
|
580
|
+
}
|
|
581
|
+
status_filter = status_map.get(status.lower())
|
|
582
|
+
|
|
583
|
+
sessions = manager.list_sessions(status=status_filter)
|
|
584
|
+
|
|
585
|
+
def time_ago(iso_str: str) -> tuple[str, float]:
|
|
586
|
+
"""Convert ISO timestamp to ('Xm ago', seconds)."""
|
|
587
|
+
try:
|
|
588
|
+
dt = datetime.fromisoformat(iso_str)
|
|
589
|
+
delta = datetime.now() - dt
|
|
590
|
+
secs = delta.total_seconds()
|
|
591
|
+
if secs < 60:
|
|
592
|
+
return f"{int(secs)}s ago", secs
|
|
593
|
+
elif secs < 3600:
|
|
594
|
+
return f"{int(secs/60)}m ago", secs
|
|
595
|
+
elif secs < 86400:
|
|
596
|
+
return f"{secs/3600:.1f}h ago", secs
|
|
597
|
+
else:
|
|
598
|
+
return f"{secs/86400:.1f}d ago", secs
|
|
599
|
+
except:
|
|
600
|
+
return "?", 999999
|
|
605
601
|
|
|
606
602
|
session_list = []
|
|
603
|
+
needs_attention_count = 0
|
|
604
|
+
|
|
607
605
|
for s in sessions:
|
|
608
606
|
status_icon = {
|
|
609
|
-
"
|
|
607
|
+
"running": "●",
|
|
610
608
|
"completed": "✓",
|
|
611
609
|
"failed": "✗",
|
|
610
|
+
"killed": "○",
|
|
611
|
+
"pending": "◌",
|
|
612
612
|
}.get(s.status.value, "?")
|
|
613
613
|
|
|
614
|
+
updated_str, updated_secs = time_ago(s.updated_at)
|
|
615
|
+
|
|
616
|
+
# Get last assistant message
|
|
617
|
+
messages = manager.get_messages(s.id)
|
|
618
|
+
last_message = ""
|
|
619
|
+
for msg in reversed(messages):
|
|
620
|
+
if msg.role == "assistant":
|
|
621
|
+
last_message = msg.content.replace("\n", " ")
|
|
622
|
+
break
|
|
623
|
+
|
|
624
|
+
# Flag sessions that need attention:
|
|
625
|
+
# - Recently completed (< 60s)
|
|
626
|
+
# - Failed
|
|
627
|
+
is_recent = updated_secs < 60
|
|
628
|
+
needs_attention = (
|
|
629
|
+
(s.status == SessionStatus.COMPLETED and is_recent) or
|
|
630
|
+
s.status == SessionStatus.FAILED
|
|
631
|
+
)
|
|
632
|
+
if needs_attention:
|
|
633
|
+
needs_attention_count += 1
|
|
634
|
+
|
|
614
635
|
session_list.append({
|
|
615
|
-
"id": s.
|
|
636
|
+
"id": s.short_id,
|
|
616
637
|
"full_id": s.id,
|
|
617
638
|
"status": f"{status_icon} {s.status.value}",
|
|
618
|
-
"
|
|
619
|
-
"
|
|
620
|
-
"
|
|
621
|
-
"
|
|
639
|
+
"is_running": s.status == SessionStatus.RUNNING,
|
|
640
|
+
"task": _truncate(s.task, 50),
|
|
641
|
+
"turn": s.turn,
|
|
642
|
+
"updated": updated_str,
|
|
643
|
+
"updated_secs": int(updated_secs),
|
|
644
|
+
"last_message": _truncate(last_message, 100) if last_message else "(no response yet)",
|
|
645
|
+
"needs_attention": needs_attention,
|
|
622
646
|
"tokens": s.token_usage.get("total_tokens", 0),
|
|
623
647
|
})
|
|
624
648
|
|
|
649
|
+
# Summary counts
|
|
650
|
+
running_count = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
|
|
651
|
+
completed_count = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
|
|
652
|
+
|
|
625
653
|
return {
|
|
626
654
|
"success": True,
|
|
627
655
|
"sessions": session_list,
|
|
628
656
|
"count": len(sessions),
|
|
657
|
+
"running": running_count,
|
|
658
|
+
"completed": completed_count,
|
|
659
|
+
"needs_attention": needs_attention_count,
|
|
629
660
|
"filter": status or "all",
|
|
661
|
+
"hint": "Sessions with needs_attention=True have new responses to review" if needs_attention_count else None,
|
|
630
662
|
}
|