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.
@@ -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
+ ]