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