zwarm 1.3.10__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 +968 -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 +2052 -0
- zwarm/core/__init__.py +0 -0
- zwarm/core/compact.py +329 -0
- zwarm/core/config.py +342 -0
- zwarm/core/environment.py +154 -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 +623 -0
- zwarm/prompts/__init__.py +10 -0
- zwarm/prompts/orchestrator.py +214 -0
- zwarm/sessions/__init__.py +24 -0
- zwarm/sessions/manager.py +589 -0
- zwarm/test_orchestrator_watchers.py +23 -0
- zwarm/tools/__init__.py +17 -0
- zwarm/tools/delegation.py +630 -0
- zwarm/watchers/__init__.py +26 -0
- zwarm/watchers/base.py +131 -0
- zwarm/watchers/builtin.py +424 -0
- zwarm/watchers/manager.py +181 -0
- zwarm/watchers/registry.py +57 -0
- zwarm/watchers/test_watchers.py +237 -0
- zwarm-1.3.10.dist-info/METADATA +525 -0
- zwarm-1.3.10.dist-info/RECORD +37 -0
- zwarm-1.3.10.dist-info/WHEEL +4 -0
- zwarm-1.3.10.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delegation tools for the orchestrator.
|
|
3
|
+
|
|
4
|
+
These are the core tools that orchestrators use to delegate work to executors:
|
|
5
|
+
- delegate: Start a new session with an executor
|
|
6
|
+
- converse: Continue a sync conversation
|
|
7
|
+
- check_session: Check status of an async session
|
|
8
|
+
- end_session: End a session
|
|
9
|
+
|
|
10
|
+
Sessions are also registered with CodexSessionManager for unified visibility
|
|
11
|
+
in `zwarm interactive`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
20
|
+
|
|
21
|
+
from wbal.helper import weaveTool
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from zwarm.orchestrator import Orchestrator
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_session_manager(orchestrator: "Orchestrator"):
|
|
28
|
+
"""Get or create the CodexSessionManager for unified session tracking."""
|
|
29
|
+
if not hasattr(orchestrator, "_session_manager"):
|
|
30
|
+
from zwarm.sessions import CodexSessionManager
|
|
31
|
+
orchestrator._session_manager = CodexSessionManager(orchestrator.working_dir / ".zwarm")
|
|
32
|
+
return orchestrator._session_manager
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _register_session_for_visibility(
|
|
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.
|
|
50
|
+
"""
|
|
51
|
+
from zwarm.sessions import CodexSession, SessionStatus, SessionMessage
|
|
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
|
+
|
|
82
|
+
|
|
83
|
+
def _update_session_visibility(
|
|
84
|
+
orchestrator: "Orchestrator",
|
|
85
|
+
session_id: str,
|
|
86
|
+
status: str | None = None,
|
|
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)
|
|
108
|
+
|
|
109
|
+
if messages:
|
|
110
|
+
for msg in messages:
|
|
111
|
+
if hasattr(msg, "role") and hasattr(msg, "content"):
|
|
112
|
+
session.messages.append(SessionMessage(
|
|
113
|
+
role=msg.role,
|
|
114
|
+
content=msg.content,
|
|
115
|
+
timestamp=datetime.now().isoformat(),
|
|
116
|
+
))
|
|
117
|
+
|
|
118
|
+
if token_usage:
|
|
119
|
+
session.token_usage = token_usage
|
|
120
|
+
|
|
121
|
+
if error:
|
|
122
|
+
session.error = error
|
|
123
|
+
|
|
124
|
+
manager._save_session(session)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _truncate(text: str, max_len: int = 200) -> str:
|
|
128
|
+
"""Truncate text with ellipsis."""
|
|
129
|
+
if len(text) <= max_len:
|
|
130
|
+
return text
|
|
131
|
+
return text[:max_len - 3] + "..."
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_session_header(session_id: str, adapter: str, mode: str) -> str:
|
|
135
|
+
"""Format a nice session header."""
|
|
136
|
+
return f"[{session_id[:8]}] {adapter} ({mode})"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _validate_working_dir(
|
|
140
|
+
requested_dir: Path | str | None,
|
|
141
|
+
default_dir: Path,
|
|
142
|
+
allowed_dirs: list[str] | None,
|
|
143
|
+
) -> tuple[Path, str | None]:
|
|
144
|
+
"""
|
|
145
|
+
Validate requested working directory against allowed_dirs config.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
requested_dir: Directory requested by the agent (or None for default)
|
|
149
|
+
default_dir: The orchestrator's working directory
|
|
150
|
+
allowed_dirs: Config setting - None means only default allowed,
|
|
151
|
+
["*"] means any, or list of allowed paths
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
(validated_path, error_message) - error is None if valid
|
|
155
|
+
"""
|
|
156
|
+
if requested_dir is None:
|
|
157
|
+
return default_dir, None
|
|
158
|
+
|
|
159
|
+
requested = Path(requested_dir).resolve()
|
|
160
|
+
|
|
161
|
+
# Check if directory exists
|
|
162
|
+
if not requested.exists():
|
|
163
|
+
return default_dir, f"Directory does not exist: {requested}"
|
|
164
|
+
|
|
165
|
+
if not requested.is_dir():
|
|
166
|
+
return default_dir, f"Not a directory: {requested}"
|
|
167
|
+
|
|
168
|
+
# If allowed_dirs is None, only default is allowed
|
|
169
|
+
if allowed_dirs is None:
|
|
170
|
+
if requested == default_dir.resolve():
|
|
171
|
+
return requested, None
|
|
172
|
+
return default_dir, (
|
|
173
|
+
f"Directory not allowed: {requested}. "
|
|
174
|
+
f"Agent can only delegate to working directory ({default_dir}). "
|
|
175
|
+
"Set orchestrator.allowed_dirs in config to allow other directories."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# If ["*"], any directory is allowed
|
|
179
|
+
if allowed_dirs == ["*"]:
|
|
180
|
+
return requested, None
|
|
181
|
+
|
|
182
|
+
# Check against allowed list
|
|
183
|
+
for allowed in allowed_dirs:
|
|
184
|
+
allowed_path = Path(allowed).resolve()
|
|
185
|
+
# Allow if requested is the allowed path or a subdirectory of it
|
|
186
|
+
try:
|
|
187
|
+
requested.relative_to(allowed_path)
|
|
188
|
+
return requested, None
|
|
189
|
+
except ValueError:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
return default_dir, (
|
|
193
|
+
f"Directory not allowed: {requested}. "
|
|
194
|
+
f"Allowed directories: {allowed_dirs}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@weaveTool
|
|
199
|
+
def delegate(
|
|
200
|
+
self: "Orchestrator",
|
|
201
|
+
task: str,
|
|
202
|
+
mode: Literal["sync", "async"] = "sync",
|
|
203
|
+
adapter: str | None = None,
|
|
204
|
+
model: str | None = None,
|
|
205
|
+
working_dir: str | None = None,
|
|
206
|
+
) -> dict[str, Any]:
|
|
207
|
+
"""
|
|
208
|
+
Delegate work to an executor agent.
|
|
209
|
+
|
|
210
|
+
Use this to assign coding tasks to an executor. Two modes available:
|
|
211
|
+
|
|
212
|
+
**sync** (default): Start a conversation with the executor.
|
|
213
|
+
You can iteratively refine requirements using converse().
|
|
214
|
+
Best for: ambiguous tasks, complex requirements, tasks needing guidance.
|
|
215
|
+
|
|
216
|
+
**async**: Fire-and-forget execution.
|
|
217
|
+
Check progress later with check_session().
|
|
218
|
+
Best for: clear self-contained tasks, parallel work.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
task: Clear description of what to do. Be specific about requirements.
|
|
222
|
+
mode: "sync" for conversational, "async" for fire-and-forget.
|
|
223
|
+
adapter: Which executor adapter to use (default: config setting).
|
|
224
|
+
model: Model override for the executor.
|
|
225
|
+
working_dir: Directory for the executor to work in (default: orchestrator's dir).
|
|
226
|
+
NOTE: May be restricted by orchestrator.allowed_dirs config.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
{session_id, status, response (if sync)}
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
delegate(task="Add a logout button to the navbar", mode="sync")
|
|
233
|
+
# Then use converse() to refine: "Also add a confirmation dialog"
|
|
234
|
+
"""
|
|
235
|
+
# Validate working directory against allowed_dirs config
|
|
236
|
+
effective_dir, dir_error = _validate_working_dir(
|
|
237
|
+
working_dir,
|
|
238
|
+
self.working_dir,
|
|
239
|
+
self.config.orchestrator.allowed_dirs,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if dir_error:
|
|
243
|
+
return {
|
|
244
|
+
"success": False,
|
|
245
|
+
"error": dir_error,
|
|
246
|
+
"hint": "Use the default working directory or ask user to update allowed_dirs config",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Get adapter (use default from config if not specified)
|
|
250
|
+
adapter_name = adapter or self.config.executor.adapter
|
|
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
|
+
)
|
|
263
|
+
|
|
264
|
+
# Track session
|
|
265
|
+
self._sessions[session.id] = session
|
|
266
|
+
self.state.add_session(session)
|
|
267
|
+
|
|
268
|
+
# Register for unified visibility in zwarm interactive
|
|
269
|
+
_register_session_for_visibility(
|
|
270
|
+
orchestrator=self,
|
|
271
|
+
session_id=session.id,
|
|
272
|
+
task=task,
|
|
273
|
+
adapter=adapter_name,
|
|
274
|
+
model=model or self.config.executor.model,
|
|
275
|
+
working_dir=effective_dir,
|
|
276
|
+
status="running" if mode == "async" else "active",
|
|
277
|
+
pid=getattr(session, "process", None) and session.process.pid,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Log events
|
|
281
|
+
from zwarm.core.models import event_session_started, event_message_sent, Message
|
|
282
|
+
self.state.log_event(event_session_started(session))
|
|
283
|
+
self.state.log_event(event_message_sent(session, Message(role="user", content=task)))
|
|
284
|
+
|
|
285
|
+
# Get response for sync mode
|
|
286
|
+
response_text = ""
|
|
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
|
+
))
|
|
308
|
+
|
|
309
|
+
# Build nice result
|
|
310
|
+
header = _format_session_header(session.id, adapter_name, mode)
|
|
311
|
+
|
|
312
|
+
if mode == "sync":
|
|
313
|
+
result = {
|
|
314
|
+
"success": True,
|
|
315
|
+
"session": header,
|
|
316
|
+
"session_id": session.id,
|
|
317
|
+
"status": "active",
|
|
318
|
+
"task": _truncate(task, 100),
|
|
319
|
+
"response": response_text,
|
|
320
|
+
"tokens": session.token_usage.get("total_tokens", 0),
|
|
321
|
+
"hint": "Use converse(session_id, message) to continue this conversation",
|
|
322
|
+
}
|
|
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
|
+
else:
|
|
332
|
+
return {
|
|
333
|
+
"success": True,
|
|
334
|
+
"session": header,
|
|
335
|
+
"session_id": session.id,
|
|
336
|
+
"status": "running",
|
|
337
|
+
"task": _truncate(task, 100),
|
|
338
|
+
"hint": "Use check_session(session_id) to monitor progress",
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@weaveTool
|
|
343
|
+
def converse(
|
|
344
|
+
self: "Orchestrator",
|
|
345
|
+
session_id: str,
|
|
346
|
+
message: str,
|
|
347
|
+
) -> dict[str, Any]:
|
|
348
|
+
"""
|
|
349
|
+
Continue a sync conversation with an executor.
|
|
350
|
+
|
|
351
|
+
Use this to iteratively refine requirements, ask for changes,
|
|
352
|
+
or guide the executor step-by-step. Like chatting with a developer.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
session_id: The session to continue (from delegate() result).
|
|
356
|
+
message: Your next message to the executor.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
{session_id, response, turn}
|
|
360
|
+
|
|
361
|
+
Example:
|
|
362
|
+
result = delegate(task="Add user authentication")
|
|
363
|
+
# Executor responds with initial plan
|
|
364
|
+
converse(session_id=result["session_id"], message="Use JWT, not sessions")
|
|
365
|
+
# Executor adjusts approach
|
|
366
|
+
converse(session_id=result["session_id"], message="Now add tests")
|
|
367
|
+
"""
|
|
368
|
+
session = self._sessions.get(session_id)
|
|
369
|
+
if not session:
|
|
370
|
+
return {
|
|
371
|
+
"success": False,
|
|
372
|
+
"error": f"Unknown session: {session_id}",
|
|
373
|
+
"hint": "Use list_sessions() to see available sessions",
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if session.mode.value != "sync":
|
|
377
|
+
return {
|
|
378
|
+
"success": False,
|
|
379
|
+
"error": "Cannot converse with async session",
|
|
380
|
+
"hint": "Use check_session() for async sessions instead",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if session.status.value != "active":
|
|
384
|
+
return {
|
|
385
|
+
"success": False,
|
|
386
|
+
"error": f"Session is {session.status.value}, not active",
|
|
387
|
+
"hint": "Start a new session with delegate()",
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# Check for stale/missing conversation_id (common after resume)
|
|
391
|
+
if not session.conversation_id:
|
|
392
|
+
return {
|
|
393
|
+
"success": False,
|
|
394
|
+
"error": "Session has no conversation ID (likely stale after resume)",
|
|
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
|
+
),
|
|
399
|
+
"session_id": session_id,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
# Get adapter and send message
|
|
403
|
+
executor = self._get_adapter(session.adapter)
|
|
404
|
+
try:
|
|
405
|
+
response = asyncio.run(
|
|
406
|
+
executor.send_message(session, message)
|
|
407
|
+
)
|
|
408
|
+
except Exception as e:
|
|
409
|
+
return {
|
|
410
|
+
"success": False,
|
|
411
|
+
"error": str(e),
|
|
412
|
+
"session_id": session_id,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# Update state
|
|
416
|
+
self.state.update_session(session)
|
|
417
|
+
|
|
418
|
+
# Update visibility record
|
|
419
|
+
from zwarm.core.models import Message
|
|
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,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Log both messages
|
|
428
|
+
from zwarm.core.models import event_message_sent
|
|
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)))
|
|
431
|
+
|
|
432
|
+
# Calculate turn number
|
|
433
|
+
turn = len([m for m in session.messages if m.role == "user"])
|
|
434
|
+
header = _format_session_header(session.id, session.adapter, session.mode.value)
|
|
435
|
+
|
|
436
|
+
# Check for conversation loss (indicated by error in response)
|
|
437
|
+
conversation_lost = (
|
|
438
|
+
"[ERROR] Conversation lost" in response
|
|
439
|
+
or session.conversation_id is None
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
result = {
|
|
443
|
+
"success": True,
|
|
444
|
+
"session": header,
|
|
445
|
+
"session_id": session_id,
|
|
446
|
+
"turn": turn,
|
|
447
|
+
"you_said": _truncate(message, 100),
|
|
448
|
+
"response": response,
|
|
449
|
+
"tokens": session.token_usage.get("total_tokens", 0),
|
|
450
|
+
}
|
|
451
|
+
|
|
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
|
+
|
|
462
|
+
@weaveTool
|
|
463
|
+
def check_session(
|
|
464
|
+
self: "Orchestrator",
|
|
465
|
+
session_id: str,
|
|
466
|
+
) -> dict[str, Any]:
|
|
467
|
+
"""
|
|
468
|
+
Check the status of a session.
|
|
469
|
+
|
|
470
|
+
For async sessions: Check if the executor has finished.
|
|
471
|
+
For sync sessions: Get current status and message count.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
session_id: The session to check.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
{session_id, status, ...}
|
|
478
|
+
"""
|
|
479
|
+
session = self._sessions.get(session_id)
|
|
480
|
+
if not session:
|
|
481
|
+
return {
|
|
482
|
+
"success": False,
|
|
483
|
+
"error": f"Unknown session: {session_id}",
|
|
484
|
+
"hint": "Use list_sessions() to see available sessions",
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
executor = self._get_adapter(session.adapter)
|
|
488
|
+
status = asyncio.run(
|
|
489
|
+
executor.check_status(session)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# Update state if status changed
|
|
493
|
+
self.state.update_session(session)
|
|
494
|
+
|
|
495
|
+
# Sync visibility record
|
|
496
|
+
_update_session_visibility(
|
|
497
|
+
orchestrator=self,
|
|
498
|
+
session_id=session_id,
|
|
499
|
+
status=session.status.value,
|
|
500
|
+
token_usage=session.token_usage,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
header = _format_session_header(session.id, session.adapter, session.mode.value)
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
"success": True,
|
|
507
|
+
"session": header,
|
|
508
|
+
"session_id": session_id,
|
|
509
|
+
"mode": session.mode.value,
|
|
510
|
+
"status": session.status.value,
|
|
511
|
+
"messages": len(session.messages),
|
|
512
|
+
"task": _truncate(session.task_description, 80),
|
|
513
|
+
**status,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@weaveTool
|
|
518
|
+
def end_session(
|
|
519
|
+
self: "Orchestrator",
|
|
520
|
+
session_id: str,
|
|
521
|
+
verdict: Literal["completed", "failed", "cancelled"] = "completed",
|
|
522
|
+
summary: str | None = None,
|
|
523
|
+
) -> dict[str, Any]:
|
|
524
|
+
"""
|
|
525
|
+
End a session with a verdict.
|
|
526
|
+
|
|
527
|
+
Call this when:
|
|
528
|
+
- Task is done (verdict="completed")
|
|
529
|
+
- Task failed and you're giving up (verdict="failed")
|
|
530
|
+
- You want to stop early (verdict="cancelled")
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
session_id: The session to end.
|
|
534
|
+
verdict: How the session ended.
|
|
535
|
+
summary: Optional summary of what was accomplished.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
{session_id, status, summary}
|
|
539
|
+
"""
|
|
540
|
+
session = self._sessions.get(session_id)
|
|
541
|
+
if not session:
|
|
542
|
+
return {
|
|
543
|
+
"success": False,
|
|
544
|
+
"error": f"Unknown session: {session_id}",
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
# Stop the session if still running
|
|
548
|
+
if session.status.value == "active":
|
|
549
|
+
executor = self._get_adapter(session.adapter)
|
|
550
|
+
if verdict == "completed":
|
|
551
|
+
session.complete(summary)
|
|
552
|
+
else:
|
|
553
|
+
asyncio.run(executor.stop(session))
|
|
554
|
+
if verdict == "failed":
|
|
555
|
+
session.fail(summary)
|
|
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
|
+
)
|
|
570
|
+
|
|
571
|
+
# Log event
|
|
572
|
+
from zwarm.core.models import event_session_completed
|
|
573
|
+
self.state.log_event(event_session_completed(session))
|
|
574
|
+
|
|
575
|
+
header = _format_session_header(session.id, session.adapter, session.mode.value)
|
|
576
|
+
verdict_icon = {"completed": "✓", "failed": "✗", "cancelled": "○"}.get(verdict, "?")
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
"success": True,
|
|
580
|
+
"session": header,
|
|
581
|
+
"session_id": session_id,
|
|
582
|
+
"verdict": f"{verdict_icon} {verdict}",
|
|
583
|
+
"summary": session.exit_message or "(no summary)",
|
|
584
|
+
"total_turns": len([m for m in session.messages if m.role == "user"]),
|
|
585
|
+
"total_tokens": session.token_usage.get("total_tokens", 0),
|
|
586
|
+
"token_usage": session.token_usage,
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@weaveTool
|
|
591
|
+
def list_sessions(
|
|
592
|
+
self: "Orchestrator",
|
|
593
|
+
status: str | None = None,
|
|
594
|
+
) -> dict[str, Any]:
|
|
595
|
+
"""
|
|
596
|
+
List all sessions, optionally filtered by status.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
status: Filter by status ("active", "completed", "failed").
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
{sessions: [...], count}
|
|
603
|
+
"""
|
|
604
|
+
sessions = self.state.list_sessions(status=status)
|
|
605
|
+
|
|
606
|
+
session_list = []
|
|
607
|
+
for s in sessions:
|
|
608
|
+
status_icon = {
|
|
609
|
+
"active": "●",
|
|
610
|
+
"completed": "✓",
|
|
611
|
+
"failed": "✗",
|
|
612
|
+
}.get(s.status.value, "?")
|
|
613
|
+
|
|
614
|
+
session_list.append({
|
|
615
|
+
"id": s.id[:8] + "...",
|
|
616
|
+
"full_id": s.id,
|
|
617
|
+
"status": f"{status_icon} {s.status.value}",
|
|
618
|
+
"adapter": s.adapter,
|
|
619
|
+
"mode": s.mode.value,
|
|
620
|
+
"task": _truncate(s.task_description, 60),
|
|
621
|
+
"turns": len([m for m in s.messages if m.role == "user"]),
|
|
622
|
+
"tokens": s.token_usage.get("total_tokens", 0),
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
"success": True,
|
|
627
|
+
"sessions": session_list,
|
|
628
|
+
"count": len(sessions),
|
|
629
|
+
"filter": status or "all",
|
|
630
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Watchers: Trajectory aligners for agent behavior.
|
|
3
|
+
|
|
4
|
+
Watchers observe agent activity and can intervene to correct course.
|
|
5
|
+
They are composable and can be layered.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from zwarm.watchers.base import Watcher, WatcherContext, WatcherResult, WatcherAction
|
|
9
|
+
from zwarm.watchers.registry import register_watcher, get_watcher, list_watchers
|
|
10
|
+
from zwarm.watchers.manager import WatcherManager, WatcherConfig, build_watcher_manager
|
|
11
|
+
|
|
12
|
+
# Import built-in watchers to register them
|
|
13
|
+
from zwarm.watchers import builtin as _builtin # noqa: F401
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Watcher",
|
|
17
|
+
"WatcherContext",
|
|
18
|
+
"WatcherResult",
|
|
19
|
+
"WatcherAction",
|
|
20
|
+
"WatcherConfig",
|
|
21
|
+
"WatcherManager",
|
|
22
|
+
"register_watcher",
|
|
23
|
+
"get_watcher",
|
|
24
|
+
"list_watchers",
|
|
25
|
+
"build_watcher_manager",
|
|
26
|
+
]
|