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/orchestrator.py ADDED
@@ -0,0 +1,623 @@
1
+ """
2
+ Orchestrator: The agent that coordinates multiple executor agents.
3
+
4
+ The orchestrator:
5
+ - Plans and breaks down complex tasks
6
+ - Delegates work to executor agents (codex, claude-code, etc.)
7
+ - Supervises progress and provides clarification
8
+ - Verifies work before marking complete
9
+
10
+ It does NOT write code directly - that's the executor's job.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+
19
+ import weave
20
+ from pydantic import Field, PrivateAttr
21
+ from wbal.agents.yaml_agent import YamlAgent
22
+ from wbal.helper import TOOL_CALL_TYPE, format_openai_tool_response
23
+
24
+ from zwarm.adapters import ExecutorAdapter, get_adapter
25
+ from zwarm.core.compact import compact_messages, should_compact
26
+ from zwarm.core.config import ZwarmConfig, load_config
27
+ from zwarm.core.environment import OrchestratorEnv
28
+ from zwarm.core.models import ConversationSession
29
+ from zwarm.core.state import StateManager
30
+ from zwarm.prompts import get_orchestrator_prompt
31
+ from zwarm.watchers import (
32
+ WatcherManager,
33
+ WatcherContext,
34
+ WatcherAction,
35
+ build_watcher_manager,
36
+ )
37
+
38
+
39
+ class Orchestrator(YamlAgent):
40
+ """
41
+ Multi-agent orchestrator built on WBAL's YamlAgent.
42
+
43
+ Extends YamlAgent with:
44
+ - Delegation tools (delegate, converse, check_session, end_session)
45
+ - Session tracking
46
+ - State persistence
47
+ - Watcher integration
48
+ - Weave integration
49
+ """
50
+
51
+ # Configuration
52
+ config: ZwarmConfig = Field(default_factory=ZwarmConfig)
53
+ working_dir: Path = Field(default_factory=Path.cwd)
54
+
55
+ # Instance identification (for multi-orchestrator isolation)
56
+ instance_id: str | None = Field(default=None)
57
+ instance_name: str | None = Field(default=None)
58
+
59
+ # Load tools from modules (delegation + bash for verification)
60
+ agent_tool_modules: list[str] = Field(
61
+ default=[
62
+ "zwarm.tools.delegation",
63
+ "wbal.tools.bash",
64
+ ]
65
+ )
66
+
67
+ # State management
68
+ _state: StateManager = PrivateAttr()
69
+ _sessions: dict[str, ConversationSession] = PrivateAttr(default_factory=dict)
70
+ _adapters: dict[str, ExecutorAdapter] = PrivateAttr(default_factory=dict)
71
+ _watcher_manager: WatcherManager | None = PrivateAttr(default=None)
72
+ _resumed: bool = PrivateAttr(default=False)
73
+ _total_tokens: int = PrivateAttr(default=0) # Cumulative orchestrator tokens
74
+ _executor_usage: dict[str, int] = PrivateAttr(default_factory=lambda: {
75
+ "input_tokens": 0,
76
+ "output_tokens": 0,
77
+ "total_tokens": 0,
78
+ })
79
+
80
+ def model_post_init(self, __context: Any) -> None:
81
+ """Initialize state and adapters after model creation."""
82
+ super().model_post_init(__context)
83
+
84
+ # Initialize state manager with instance isolation
85
+ base_state_dir = self.working_dir / self.config.state_dir
86
+ self._state = StateManager(
87
+ state_dir=base_state_dir,
88
+ instance_id=self.instance_id,
89
+ )
90
+ self._state.init()
91
+ self._state.load()
92
+
93
+ # Register instance if using instance isolation
94
+ if self.instance_id:
95
+ from zwarm.core.state import register_instance
96
+ register_instance(
97
+ instance_id=self.instance_id,
98
+ name=self.instance_name,
99
+ task=None, # Will be updated when task is set
100
+ base_dir=base_state_dir,
101
+ )
102
+
103
+ # Load existing sessions
104
+ for session in self._state.list_sessions():
105
+ self._sessions[session.id] = session
106
+
107
+ # Initialize Weave if configured
108
+ if self.config.weave.enabled and self.config.weave.project:
109
+ weave.init(self.config.weave.project)
110
+
111
+ # Initialize watchers if configured
112
+ if self.config.watchers.enabled:
113
+ self._watcher_manager = build_watcher_manager(
114
+ {
115
+ "watchers": [
116
+ {"name": w.name, "enabled": w.enabled, "config": w.config}
117
+ for w in self.config.watchers.watchers
118
+ ]
119
+ }
120
+ )
121
+
122
+ # Link sessions to environment for observe()
123
+ if hasattr(self.env, "set_sessions"):
124
+ self.env.set_sessions(self._sessions)
125
+
126
+ # Set budget limits in environment
127
+ if hasattr(self.env, "set_budget"):
128
+ # Extract budget from watcher config if available
129
+ max_sessions = None
130
+ for w in self.config.watchers.watchers:
131
+ if w.name == "budget" and w.config:
132
+ max_sessions = w.config.get("max_sessions")
133
+ break
134
+ self.env.set_budget(max_sessions=max_sessions)
135
+
136
+ @property
137
+ def state(self) -> StateManager:
138
+ """Access state manager."""
139
+ return self._state
140
+
141
+ def _get_adapter(self, name: str) -> ExecutorAdapter:
142
+ """Get or create an adapter by name using the adapter registry."""
143
+ if name not in self._adapters:
144
+ # Get model from config (adapters have their own defaults if None)
145
+ model = self.config.executor.model
146
+ self._adapters[name] = get_adapter(name, model=model)
147
+ return self._adapters[name]
148
+
149
+ def get_executor_usage(self) -> dict[str, int]:
150
+ """Get aggregated token usage across all executors."""
151
+ total = {
152
+ "input_tokens": 0,
153
+ "output_tokens": 0,
154
+ "total_tokens": 0,
155
+ }
156
+ for adapter in self._adapters.values():
157
+ if hasattr(adapter, "total_usage"):
158
+ usage = adapter.total_usage
159
+ for key in total:
160
+ total[key] += usage.get(key, 0)
161
+ return total
162
+
163
+ @property
164
+ def executor_usage(self) -> dict[str, int]:
165
+ """Aggregated executor token usage (for Weave tracking)."""
166
+ return self.get_executor_usage()
167
+
168
+ def save_state(self) -> None:
169
+ """Save orchestrator state for resume."""
170
+ self._state.save_orchestrator_messages(self.messages)
171
+
172
+ def load_state(self) -> None:
173
+ """Load orchestrator state for resume.
174
+
175
+ Only marks as resumed if we actually loaded non-empty messages.
176
+ This prevents the resume message from being injected before the
177
+ system prompt when there's no saved state to resume from.
178
+ """
179
+ loaded_messages = self._state.load_orchestrator_messages()
180
+ if loaded_messages:
181
+ self.messages = loaded_messages
182
+ self._resumed = True
183
+ # If no messages were saved, don't set _resumed - start fresh
184
+
185
+ def _maybe_compact(self) -> bool:
186
+ """
187
+ Check if compaction is needed and compact if so.
188
+
189
+ Returns True if compaction was performed.
190
+ """
191
+ compact_config = self.config.orchestrator.compaction
192
+ if not compact_config.enabled:
193
+ return False
194
+
195
+ # Check if we should compact
196
+ if not should_compact(
197
+ self.messages,
198
+ max_tokens=compact_config.max_tokens,
199
+ threshold_pct=compact_config.threshold_pct,
200
+ ):
201
+ return False
202
+
203
+ # Perform compaction
204
+ result = compact_messages(
205
+ self.messages,
206
+ keep_first_n=compact_config.keep_first_n,
207
+ keep_last_n=compact_config.keep_last_n,
208
+ max_tokens=compact_config.max_tokens,
209
+ target_token_pct=compact_config.target_pct,
210
+ )
211
+
212
+ if result.was_compacted:
213
+ self.messages = result.messages
214
+
215
+ # Log compaction event
216
+ from zwarm.core.models import Event
217
+ self._state.log_event(Event(
218
+ kind="context_compacted",
219
+ payload={
220
+ "step": self._step_count,
221
+ "original_count": result.original_count,
222
+ "new_count": len(result.messages),
223
+ "removed_count": result.removed_count,
224
+ },
225
+ ))
226
+
227
+ return True
228
+
229
+ return False
230
+
231
+ def _inject_resume_message(self) -> None:
232
+ """Inject a system message about resumed state."""
233
+ if not self._resumed:
234
+ return
235
+
236
+ # Build list of old sessions and INVALIDATE their conversation IDs
237
+ # The MCP server was restarted, so all conversation IDs are now stale
238
+ old_sessions = []
239
+ invalidated_count = 0
240
+ for sid, session in self._sessions.items():
241
+ old_sessions.append(
242
+ f" - {sid[:8]}... ({session.adapter}, {session.status.value})"
243
+ )
244
+ # Clear stale conversation_id to prevent converse() from trying to use it
245
+ if session.conversation_id:
246
+ session.conversation_id = None
247
+ invalidated_count += 1
248
+
249
+ session_info = "\n".join(old_sessions) if old_sessions else " (none)"
250
+
251
+ resume_msg = {
252
+ "role": "user",
253
+ "content": f"""[SYSTEM NOTICE] You have been resumed from a previous session.
254
+
255
+ CRITICAL: Your previous executor sessions are NO LONGER USABLE. The MCP server was restarted, so all conversation state was lost. {invalidated_count} conversation ID(s) have been invalidated.
256
+
257
+ Previous sessions (conversation IDs cleared):
258
+ {session_info}
259
+
260
+ You MUST start NEW sessions with delegate() to continue any work. The converse() tool will fail on these old sessions because they have no active conversation.
261
+
262
+ Review what was accomplished in the previous session and delegate new tasks as needed.""",
263
+ }
264
+
265
+ self.messages.append(resume_msg)
266
+ self._resumed = False # Only inject once
267
+
268
+ def perceive(self) -> None:
269
+ """
270
+ Override perceive to refresh environment observation each step.
271
+
272
+ The base YamlAgent only adds env.observe() on step 0. We need to
273
+ update it each step to show current progress, sessions, etc.
274
+ """
275
+ # Let base class do initial setup
276
+ super().perceive()
277
+
278
+ # Update environment observation
279
+ env_obs = (self.env.observe() or "").strip()
280
+ if not env_obs:
281
+ return
282
+
283
+ # Find and update existing env observation, or append new one
284
+ # Look for a system message containing our markers
285
+ env_marker = "## Progress" # Our env observation has this
286
+
287
+ for i, msg in enumerate(self.messages):
288
+ if msg.get("role") == "system" and env_marker in msg.get("content", ""):
289
+ # Update in place
290
+ self.messages[i]["content"] = env_obs
291
+ return
292
+
293
+ # Not found - append as new system message (shouldn't happen after step 0)
294
+ self.messages.append({"role": "system", "content": env_obs})
295
+
296
+ @weave.op()
297
+ def _run_watchers(self) -> WatcherAction:
298
+ """Run watchers and return the action to take."""
299
+ if not self._watcher_manager:
300
+ return WatcherAction.CONTINUE
301
+
302
+ # Build watcher context
303
+ task = getattr(self.env, "task", "") if self.env else ""
304
+ events = [e.to_dict() for e in self.state.get_events(limit=200)]
305
+ ctx = WatcherContext(
306
+ task=task,
307
+ step=self._step_count,
308
+ max_steps=self.maxSteps,
309
+ messages=self.messages,
310
+ sessions=[s.to_dict() for s in self._sessions.values()],
311
+ events=events,
312
+ working_dir=str(self.working_dir.absolute()) if self.working_dir else None,
313
+ metadata={
314
+ "config": self.config.to_dict()
315
+ if hasattr(self.config, "to_dict")
316
+ else {},
317
+ },
318
+ )
319
+
320
+ # Run watchers synchronously (they're async internally)
321
+ import asyncio
322
+
323
+ try:
324
+ loop = asyncio.get_running_loop()
325
+ except RuntimeError:
326
+ loop = None
327
+
328
+ if loop and loop.is_running():
329
+ # We're in an async context, create a task
330
+ import concurrent.futures
331
+
332
+ with concurrent.futures.ThreadPoolExecutor() as pool:
333
+ result = pool.submit(
334
+ asyncio.run, self._watcher_manager.observe(ctx)
335
+ ).result()
336
+ else:
337
+ result = asyncio.run(self._watcher_manager.observe(ctx))
338
+
339
+ # Log watcher execution to events
340
+ from zwarm.core.models import Event
341
+ watcher_names = [w.name for w in self.config.watchers.watchers if w.enabled]
342
+ self.state.log_event(Event(
343
+ kind="watchers_run",
344
+ payload={
345
+ "step": self._step_count,
346
+ "watchers": watcher_names,
347
+ "action": result.action.value,
348
+ "triggered_by": result.metadata.get("triggered_by"),
349
+ "reason": result.metadata.get("reason"),
350
+ },
351
+ ))
352
+
353
+ # Handle watcher result
354
+ if result.action == WatcherAction.NUDGE and result.guidance:
355
+ # Inject guidance as a message with configurable role
356
+ message_role = self.config.watchers.message_role
357
+ # Validate role (default to user if invalid)
358
+ if message_role not in ("user", "assistant", "system"):
359
+ message_role = "user"
360
+
361
+ self.messages.append(
362
+ {
363
+ "role": message_role,
364
+ "content": f"[WATCHER: {result.metadata.get('triggered_by', 'unknown')}] {result.guidance}",
365
+ }
366
+ )
367
+
368
+ return result.action
369
+
370
+ def do(self) -> list[tuple[dict[str, Any], Any]]:
371
+ """
372
+ Execute tool calls from the LLM response.
373
+
374
+ Overrides base do() to capture and return tool calls with results
375
+ for Weave tracing visibility.
376
+
377
+ Returns:
378
+ List of (tool_call_info, result) tuples
379
+ """
380
+ if self._last_response is None:
381
+ return []
382
+
383
+ output = getattr(self._last_response, "output", None)
384
+ if output is None:
385
+ return []
386
+
387
+ # Extract tool calls
388
+ tool_calls = [
389
+ item for item in output if getattr(item, "type", None) == TOOL_CALL_TYPE
390
+ ]
391
+
392
+ # If no tool calls, handle text output
393
+ if not tool_calls:
394
+ output_text = getattr(self._last_response, "output_text", "")
395
+ if output_text and hasattr(self.env, "output_handler"):
396
+ self.env.output_handler(output_text)
397
+ return []
398
+
399
+ # Execute each tool call and collect results
400
+ tool_results: list[tuple[dict[str, Any], Any]] = []
401
+
402
+ for tc in tool_calls:
403
+ tc_name = getattr(tc, "name", "")
404
+ tc_args_raw = getattr(tc, "arguments", "{}")
405
+ tc_id = getattr(tc, "call_id", "")
406
+
407
+ # Parse arguments
408
+ if isinstance(tc_args_raw, str):
409
+ try:
410
+ tc_args = json.loads(tc_args_raw)
411
+ except json.JSONDecodeError:
412
+ tc_args = {}
413
+ else:
414
+ tc_args = tc_args_raw or {}
415
+
416
+ # Execute tool
417
+ if tc_name in self._tool_callables:
418
+ try:
419
+ tc_output = self._tool_callables[tc_name](**tc_args)
420
+ except Exception as e:
421
+ tc_output = f"Error executing {tc_name}: {e}"
422
+ else:
423
+ tc_output = f"Unknown tool: {tc_name}"
424
+
425
+ # Collect tool call info and result
426
+ tool_call_info = {
427
+ "name": tc_name,
428
+ "args": tc_args,
429
+ "call_id": tc_id,
430
+ }
431
+ tool_results.append((tool_call_info, tc_output))
432
+
433
+ # Format and append result to messages
434
+ result = format_openai_tool_response(tc_output, tc_id)
435
+ self.messages.append(result)
436
+
437
+ return tool_results
438
+
439
+ @weave.op()
440
+ def step(self) -> list[tuple[dict[str, Any], Any]]:
441
+ """
442
+ Execute one perceive-invoke-do cycle.
443
+
444
+ Overrides base step() to return tool calls with results
445
+ for Weave tracing visibility.
446
+
447
+ Returns:
448
+ List of (tool_call_info, result) tuples from this step.
449
+ Each tuple contains:
450
+ - tool_call_info: {"name": str, "args": dict, "call_id": str}
451
+ - result: The tool output (any type)
452
+ """
453
+ # Check for context compaction before perceive
454
+ # This prevents context overflow on long-running tasks
455
+ self._maybe_compact()
456
+
457
+ # Update environment with current progress before perceive
458
+ if hasattr(self.env, "update_progress"):
459
+ executor_usage = self.get_executor_usage()
460
+ self.env.update_progress(
461
+ step_count=self._step_count,
462
+ max_steps=self.maxSteps,
463
+ total_tokens=self._total_tokens,
464
+ executor_tokens=executor_usage.get("total_tokens", 0),
465
+ )
466
+
467
+ self.perceive()
468
+ self.invoke()
469
+
470
+ # Track cumulative token usage from the API response
471
+ if self._last_response and hasattr(self._last_response, "usage"):
472
+ usage = self._last_response.usage
473
+ if usage:
474
+ self._total_tokens += getattr(usage, "total_tokens", 0)
475
+
476
+ tool_results = self.do()
477
+ self._step_count += 1
478
+ return tool_results
479
+
480
+ @weave.op()
481
+ def run(
482
+ self, task: str | None = None, max_steps: int | None = None
483
+ ) -> dict[str, Any]:
484
+ """
485
+ Run the orchestrator until stop condition is met.
486
+
487
+ Overrides base run() to integrate watchers.
488
+
489
+ Args:
490
+ task: The task string. If not provided, uses env.task
491
+ max_steps: Override maxSteps for this run.
492
+
493
+ Returns:
494
+ Dict with run results
495
+ """
496
+ # Set task from argument or environment
497
+ if task is not None:
498
+ self.env.task = task
499
+
500
+ # Override max_steps if provided
501
+ if max_steps is not None:
502
+ self.maxSteps = max_steps
503
+
504
+ # Reset counters
505
+ self._step_count = 0
506
+ self._total_tokens = 0
507
+
508
+ # Inject resume message if we were resumed
509
+ self._inject_resume_message()
510
+
511
+ for _ in range(self.maxSteps):
512
+ # Run watchers before each step
513
+ watcher_action = self._run_watchers()
514
+
515
+ if watcher_action == WatcherAction.ABORT:
516
+ return {
517
+ "steps": self._step_count,
518
+ "task": self.env.task,
519
+ "stopped_by": "watcher_abort",
520
+ }
521
+ elif watcher_action == WatcherAction.PAUSE:
522
+ # For now, treat pause as stop (could add human-in-loop later)
523
+ return {
524
+ "steps": self._step_count,
525
+ "task": self.env.task,
526
+ "stopped_by": "watcher_pause",
527
+ }
528
+ # NUDGE and CONTINUE just continue
529
+
530
+ self.step()
531
+
532
+ if self.stopCondition:
533
+ break
534
+
535
+ return {
536
+ "steps": self._step_count,
537
+ "task": self.env.task,
538
+ }
539
+
540
+ async def cleanup(self) -> None:
541
+ """Clean up resources."""
542
+ for adapter in self._adapters.values():
543
+ await adapter.cleanup()
544
+
545
+
546
+ def build_orchestrator(
547
+ config_path: Path | None = None,
548
+ task: str | None = None,
549
+ working_dir: Path | None = None,
550
+ overrides: list[str] | None = None,
551
+ resume: bool = False,
552
+ output_handler: Callable[[str], None] | None = None,
553
+ instance_id: str | None = None,
554
+ instance_name: str | None = None,
555
+ ) -> Orchestrator:
556
+ """
557
+ Build an orchestrator from configuration.
558
+
559
+ Args:
560
+ config_path: Path to YAML config file
561
+ task: The task to accomplish
562
+ working_dir: Working directory (default: cwd)
563
+ overrides: CLI overrides (--set key=value)
564
+ resume: Whether to resume from previous state
565
+ output_handler: Function to handle orchestrator output
566
+ instance_id: Unique ID for this instance (enables multi-orchestrator isolation)
567
+ instance_name: Human-readable name for this instance
568
+
569
+ Returns:
570
+ Configured Orchestrator instance
571
+ """
572
+ from uuid import uuid4
573
+
574
+ # Load configuration
575
+ config = load_config(
576
+ config_path=config_path,
577
+ overrides=overrides,
578
+ )
579
+
580
+ # Resolve working directory
581
+ working_dir = working_dir or Path.cwd()
582
+
583
+ # Generate instance ID if not provided (enables isolation by default for new runs)
584
+ # For resume, instance_id should be provided explicitly
585
+ if instance_id is None and not resume:
586
+ instance_id = str(uuid4())
587
+
588
+ # Build system prompt
589
+ system_prompt = _build_system_prompt(config, working_dir)
590
+
591
+ # Create lean orchestrator environment
592
+ env = OrchestratorEnv(
593
+ task=task or "",
594
+ working_dir=working_dir,
595
+ )
596
+
597
+ # Set up output handler
598
+ if output_handler:
599
+ env.output_handler = output_handler
600
+
601
+ # Create orchestrator
602
+ orchestrator = Orchestrator(
603
+ config=config,
604
+ working_dir=working_dir,
605
+ system_prompt=system_prompt,
606
+ maxSteps=config.orchestrator.max_steps,
607
+ env=env,
608
+ instance_id=instance_id,
609
+ instance_name=instance_name,
610
+ )
611
+
612
+ # Resume if requested
613
+ if resume:
614
+ orchestrator.load_state()
615
+
616
+ return orchestrator
617
+
618
+
619
+ def _build_system_prompt(config: ZwarmConfig, working_dir: Path | None = None) -> str:
620
+ """Build the orchestrator system prompt."""
621
+ return get_orchestrator_prompt(
622
+ working_dir=str(working_dir) if working_dir else None
623
+ )
@@ -0,0 +1,10 @@
1
+ """
2
+ System prompts for zwarm agents.
3
+ """
4
+
5
+ from zwarm.prompts.orchestrator import ORCHESTRATOR_SYSTEM_PROMPT, get_orchestrator_prompt
6
+
7
+ __all__ = [
8
+ "ORCHESTRATOR_SYSTEM_PROMPT",
9
+ "get_orchestrator_prompt",
10
+ ]