zwarm 1.1.1__py3-none-any.whl → 1.3.2__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/__init__.py +21 -0
- zwarm/adapters/claude_code.py +5 -3
- zwarm/adapters/codex_mcp.py +140 -12
- zwarm/adapters/registry.py +69 -0
- zwarm/adapters/test_codex_mcp.py +50 -0
- zwarm/adapters/test_registry.py +68 -0
- zwarm/cli/main.py +650 -27
- zwarm/core/config.py +23 -2
- zwarm/core/state.py +143 -12
- zwarm/orchestrator.py +47 -17
- zwarm/tools/delegation.py +115 -3
- {zwarm-1.1.1.dist-info → zwarm-1.3.2.dist-info}/METADATA +7 -5
- {zwarm-1.1.1.dist-info → zwarm-1.3.2.dist-info}/RECORD +15 -13
- {zwarm-1.1.1.dist-info → zwarm-1.3.2.dist-info}/WHEEL +0 -0
- {zwarm-1.1.1.dist-info → zwarm-1.3.2.dist-info}/entry_points.txt +0 -0
zwarm/core/config.py
CHANGED
|
@@ -62,6 +62,12 @@ class OrchestratorConfig:
|
|
|
62
62
|
sync_first: bool = True # prefer sync mode by default
|
|
63
63
|
compaction: CompactionConfig = field(default_factory=CompactionConfig)
|
|
64
64
|
|
|
65
|
+
# Directory restrictions for agent delegations
|
|
66
|
+
# None = only working_dir allowed (most restrictive, default)
|
|
67
|
+
# ["*"] = any directory allowed (dangerous)
|
|
68
|
+
# ["/path/a", "/path/b"] = only these directories allowed
|
|
69
|
+
allowed_dirs: list[str] | None = None
|
|
70
|
+
|
|
65
71
|
|
|
66
72
|
@dataclass
|
|
67
73
|
class WatcherConfigItem:
|
|
@@ -188,9 +194,24 @@ def load_env(path: Path | None = None) -> None:
|
|
|
188
194
|
|
|
189
195
|
|
|
190
196
|
def load_toml_config(path: Path | None = None) -> dict[str, Any]:
|
|
191
|
-
"""
|
|
197
|
+
"""
|
|
198
|
+
Load config.toml file.
|
|
199
|
+
|
|
200
|
+
Search order:
|
|
201
|
+
1. Explicit path (if provided)
|
|
202
|
+
2. .zwarm/config.toml (new standard location)
|
|
203
|
+
3. config.toml (legacy location for backwards compat)
|
|
204
|
+
"""
|
|
192
205
|
if path is None:
|
|
193
|
-
|
|
206
|
+
# Try new location first
|
|
207
|
+
new_path = Path.cwd() / ".zwarm" / "config.toml"
|
|
208
|
+
legacy_path = Path.cwd() / "config.toml"
|
|
209
|
+
if new_path.exists():
|
|
210
|
+
path = new_path
|
|
211
|
+
elif legacy_path.exists():
|
|
212
|
+
path = legacy_path
|
|
213
|
+
else:
|
|
214
|
+
return {}
|
|
194
215
|
if not path.exists():
|
|
195
216
|
return {}
|
|
196
217
|
with open(path, "rb") as f:
|
zwarm/core/state.py
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Flat-file state management for zwarm.
|
|
3
3
|
|
|
4
|
-
State structure:
|
|
4
|
+
State structure (with instance isolation):
|
|
5
5
|
.zwarm/
|
|
6
|
-
├──
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
└──
|
|
13
|
-
|
|
6
|
+
├── instances.json # Registry of all instances
|
|
7
|
+
└── instances/
|
|
8
|
+
└── <instance-id>/
|
|
9
|
+
├── state.json # Current state (sessions, tasks)
|
|
10
|
+
├── events.jsonl # Append-only event log
|
|
11
|
+
├── sessions/
|
|
12
|
+
│ └── <session-id>/
|
|
13
|
+
│ ├── messages.json
|
|
14
|
+
│ └── output.log
|
|
15
|
+
└── orchestrator/
|
|
16
|
+
└── messages.json # Orchestrator's message history (for resume)
|
|
17
|
+
|
|
18
|
+
Legacy structure (single instance, for backwards compat):
|
|
19
|
+
.zwarm/
|
|
20
|
+
├── state.json
|
|
21
|
+
├── events.jsonl
|
|
22
|
+
└── ...
|
|
14
23
|
"""
|
|
15
24
|
|
|
16
25
|
from __future__ import annotations
|
|
@@ -19,10 +28,116 @@ import json
|
|
|
19
28
|
from datetime import datetime
|
|
20
29
|
from pathlib import Path
|
|
21
30
|
from typing import Any
|
|
31
|
+
from uuid import uuid4
|
|
22
32
|
|
|
23
33
|
from .models import ConversationSession, Event, Task
|
|
24
34
|
|
|
25
35
|
|
|
36
|
+
# --- Instance Registry ---
|
|
37
|
+
|
|
38
|
+
def get_instances_registry_path(base_dir: Path | str = ".zwarm") -> Path:
|
|
39
|
+
"""Get path to the instances registry file."""
|
|
40
|
+
return Path(base_dir) / "instances.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def list_instances(base_dir: Path | str = ".zwarm") -> list[dict[str, Any]]:
|
|
44
|
+
"""List all registered instances."""
|
|
45
|
+
registry_path = get_instances_registry_path(base_dir)
|
|
46
|
+
if not registry_path.exists():
|
|
47
|
+
return []
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(registry_path.read_text()).get("instances", [])
|
|
50
|
+
except (json.JSONDecodeError, KeyError):
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register_instance(
|
|
55
|
+
instance_id: str,
|
|
56
|
+
name: str | None = None,
|
|
57
|
+
task: str | None = None,
|
|
58
|
+
base_dir: Path | str = ".zwarm",
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Register an instance in the global registry."""
|
|
61
|
+
base = Path(base_dir)
|
|
62
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
registry_path = get_instances_registry_path(base_dir)
|
|
65
|
+
|
|
66
|
+
# Load existing registry
|
|
67
|
+
if registry_path.exists():
|
|
68
|
+
try:
|
|
69
|
+
registry = json.loads(registry_path.read_text())
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
registry = {"instances": []}
|
|
72
|
+
else:
|
|
73
|
+
registry = {"instances": []}
|
|
74
|
+
|
|
75
|
+
# Check if instance already registered
|
|
76
|
+
existing_ids = {inst["id"] for inst in registry["instances"]}
|
|
77
|
+
if instance_id in existing_ids:
|
|
78
|
+
# Update existing entry
|
|
79
|
+
for inst in registry["instances"]:
|
|
80
|
+
if inst["id"] == instance_id:
|
|
81
|
+
inst["updated_at"] = datetime.now().isoformat()
|
|
82
|
+
inst["status"] = "active"
|
|
83
|
+
if name:
|
|
84
|
+
inst["name"] = name
|
|
85
|
+
if task:
|
|
86
|
+
inst["task"] = task[:100] # Truncate
|
|
87
|
+
break
|
|
88
|
+
else:
|
|
89
|
+
# Add new entry
|
|
90
|
+
registry["instances"].append({
|
|
91
|
+
"id": instance_id,
|
|
92
|
+
"name": name or instance_id[:8],
|
|
93
|
+
"task": (task[:100] if task else None),
|
|
94
|
+
"created_at": datetime.now().isoformat(),
|
|
95
|
+
"updated_at": datetime.now().isoformat(),
|
|
96
|
+
"status": "active",
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
registry_path.write_text(json.dumps(registry, indent=2))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def update_instance_status(
|
|
103
|
+
instance_id: str,
|
|
104
|
+
status: str,
|
|
105
|
+
base_dir: Path | str = ".zwarm",
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Update an instance's status in the registry."""
|
|
108
|
+
registry_path = get_instances_registry_path(base_dir)
|
|
109
|
+
if not registry_path.exists():
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
registry = json.loads(registry_path.read_text())
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
for inst in registry.get("instances", []):
|
|
118
|
+
if inst["id"] == instance_id:
|
|
119
|
+
inst["status"] = status
|
|
120
|
+
inst["updated_at"] = datetime.now().isoformat()
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
registry_path.write_text(json.dumps(registry, indent=2))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_instance_state_dir(
|
|
127
|
+
instance_id: str | None = None,
|
|
128
|
+
base_dir: Path | str = ".zwarm",
|
|
129
|
+
) -> Path:
|
|
130
|
+
"""
|
|
131
|
+
Get the state directory for an instance.
|
|
132
|
+
|
|
133
|
+
If instance_id is None, returns the legacy path for backwards compat.
|
|
134
|
+
"""
|
|
135
|
+
base = Path(base_dir)
|
|
136
|
+
if instance_id is None:
|
|
137
|
+
return base # Legacy: .zwarm/
|
|
138
|
+
return base / "instances" / instance_id
|
|
139
|
+
|
|
140
|
+
|
|
26
141
|
def _json_serializer(obj: Any) -> Any:
|
|
27
142
|
"""Custom JSON serializer for non-standard types."""
|
|
28
143
|
# Handle pydantic models
|
|
@@ -42,15 +157,31 @@ class StateManager:
|
|
|
42
157
|
"""
|
|
43
158
|
Manages flat-file state for zwarm.
|
|
44
159
|
|
|
45
|
-
All state is stored as JSON files in a directory
|
|
160
|
+
All state is stored as JSON files in a directory.
|
|
161
|
+
With instance isolation: .zwarm/instances/<instance-id>/
|
|
162
|
+
Legacy (no instance): .zwarm/
|
|
163
|
+
|
|
46
164
|
This enables:
|
|
47
165
|
- Git-backed history
|
|
48
166
|
- Easy debugging (just read the files)
|
|
49
167
|
- Resume from previous state
|
|
168
|
+
- Multiple concurrent orchestrators (with instance isolation)
|
|
50
169
|
"""
|
|
51
170
|
|
|
52
|
-
def __init__(
|
|
53
|
-
self
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
state_dir: Path | str = ".zwarm",
|
|
174
|
+
instance_id: str | None = None,
|
|
175
|
+
):
|
|
176
|
+
self.base_dir = Path(state_dir)
|
|
177
|
+
self.instance_id = instance_id
|
|
178
|
+
|
|
179
|
+
# Resolve actual state directory
|
|
180
|
+
if instance_id:
|
|
181
|
+
self.state_dir = get_instance_state_dir(instance_id, self.base_dir)
|
|
182
|
+
else:
|
|
183
|
+
self.state_dir = self.base_dir
|
|
184
|
+
|
|
54
185
|
self._sessions: dict[str, ConversationSession] = {}
|
|
55
186
|
self._tasks: dict[str, Task] = {}
|
|
56
187
|
self._orchestrator_messages: list[dict[str, Any]] = []
|
zwarm/orchestrator.py
CHANGED
|
@@ -21,9 +21,7 @@ from pydantic import Field, PrivateAttr
|
|
|
21
21
|
from wbal.agents.yaml_agent import YamlAgent
|
|
22
22
|
from wbal.helper import TOOL_CALL_TYPE, format_openai_tool_response
|
|
23
23
|
|
|
24
|
-
from zwarm.adapters
|
|
25
|
-
from zwarm.adapters.claude_code import ClaudeCodeAdapter
|
|
26
|
-
from zwarm.adapters.codex_mcp import CodexMCPAdapter
|
|
24
|
+
from zwarm.adapters import ExecutorAdapter, get_adapter
|
|
27
25
|
from zwarm.core.compact import compact_messages, should_compact
|
|
28
26
|
from zwarm.core.config import ZwarmConfig, load_config
|
|
29
27
|
from zwarm.core.environment import OrchestratorEnv
|
|
@@ -54,6 +52,10 @@ class Orchestrator(YamlAgent):
|
|
|
54
52
|
config: ZwarmConfig = Field(default_factory=ZwarmConfig)
|
|
55
53
|
working_dir: Path = Field(default_factory=Path.cwd)
|
|
56
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
|
+
|
|
57
59
|
# Load tools from modules (delegation + bash for verification)
|
|
58
60
|
agent_tool_modules: list[str] = Field(
|
|
59
61
|
default=[
|
|
@@ -79,11 +81,25 @@ class Orchestrator(YamlAgent):
|
|
|
79
81
|
"""Initialize state and adapters after model creation."""
|
|
80
82
|
super().model_post_init(__context)
|
|
81
83
|
|
|
82
|
-
# Initialize state manager
|
|
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
|
+
)
|
|
84
90
|
self._state.init()
|
|
85
91
|
self._state.load()
|
|
86
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
|
+
|
|
87
103
|
# Load existing sessions
|
|
88
104
|
for session in self._state.list_sessions():
|
|
89
105
|
self._sessions[session.id] = session
|
|
@@ -123,16 +139,11 @@ class Orchestrator(YamlAgent):
|
|
|
123
139
|
return self._state
|
|
124
140
|
|
|
125
141
|
def _get_adapter(self, name: str) -> ExecutorAdapter:
|
|
126
|
-
"""Get or create an adapter by name."""
|
|
142
|
+
"""Get or create an adapter by name using the adapter registry."""
|
|
127
143
|
if name not in self._adapters:
|
|
128
144
|
# Get model from config (adapters have their own defaults if None)
|
|
129
145
|
model = self.config.executor.model
|
|
130
|
-
|
|
131
|
-
self._adapters[name] = CodexMCPAdapter(model=model)
|
|
132
|
-
elif name == "claude_code":
|
|
133
|
-
self._adapters[name] = ClaudeCodeAdapter(model=model)
|
|
134
|
-
else:
|
|
135
|
-
raise ValueError(f"Unknown adapter: {name}")
|
|
146
|
+
self._adapters[name] = get_adapter(name, model=model)
|
|
136
147
|
return self._adapters[name]
|
|
137
148
|
|
|
138
149
|
def get_executor_usage(self) -> dict[str, int]:
|
|
@@ -222,12 +233,18 @@ class Orchestrator(YamlAgent):
|
|
|
222
233
|
if not self._resumed:
|
|
223
234
|
return
|
|
224
235
|
|
|
225
|
-
# Build list of old sessions
|
|
236
|
+
# Build list of old sessions and INVALIDATE their conversation IDs
|
|
237
|
+
# The MCP server was restarted, so all conversation IDs are now stale
|
|
226
238
|
old_sessions = []
|
|
239
|
+
invalidated_count = 0
|
|
227
240
|
for sid, session in self._sessions.items():
|
|
228
241
|
old_sessions.append(
|
|
229
242
|
f" - {sid[:8]}... ({session.adapter}, {session.status.value})"
|
|
230
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
|
|
231
248
|
|
|
232
249
|
session_info = "\n".join(old_sessions) if old_sessions else " (none)"
|
|
233
250
|
|
|
@@ -235,14 +252,14 @@ class Orchestrator(YamlAgent):
|
|
|
235
252
|
"role": "user",
|
|
236
253
|
"content": f"""[SYSTEM NOTICE] You have been resumed from a previous session.
|
|
237
254
|
|
|
238
|
-
|
|
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.
|
|
239
256
|
|
|
240
|
-
Previous sessions (
|
|
257
|
+
Previous sessions (conversation IDs cleared):
|
|
241
258
|
{session_info}
|
|
242
259
|
|
|
243
|
-
You
|
|
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.
|
|
244
261
|
|
|
245
|
-
|
|
262
|
+
Review what was accomplished in the previous session and delegate new tasks as needed.""",
|
|
246
263
|
}
|
|
247
264
|
|
|
248
265
|
self.messages.append(resume_msg)
|
|
@@ -528,6 +545,8 @@ def build_orchestrator(
|
|
|
528
545
|
overrides: list[str] | None = None,
|
|
529
546
|
resume: bool = False,
|
|
530
547
|
output_handler: Callable[[str], None] | None = None,
|
|
548
|
+
instance_id: str | None = None,
|
|
549
|
+
instance_name: str | None = None,
|
|
531
550
|
) -> Orchestrator:
|
|
532
551
|
"""
|
|
533
552
|
Build an orchestrator from configuration.
|
|
@@ -539,10 +558,14 @@ def build_orchestrator(
|
|
|
539
558
|
overrides: CLI overrides (--set key=value)
|
|
540
559
|
resume: Whether to resume from previous state
|
|
541
560
|
output_handler: Function to handle orchestrator output
|
|
561
|
+
instance_id: Unique ID for this instance (enables multi-orchestrator isolation)
|
|
562
|
+
instance_name: Human-readable name for this instance
|
|
542
563
|
|
|
543
564
|
Returns:
|
|
544
565
|
Configured Orchestrator instance
|
|
545
566
|
"""
|
|
567
|
+
from uuid import uuid4
|
|
568
|
+
|
|
546
569
|
# Load configuration
|
|
547
570
|
config = load_config(
|
|
548
571
|
config_path=config_path,
|
|
@@ -552,6 +575,11 @@ def build_orchestrator(
|
|
|
552
575
|
# Resolve working directory
|
|
553
576
|
working_dir = working_dir or Path.cwd()
|
|
554
577
|
|
|
578
|
+
# Generate instance ID if not provided (enables isolation by default for new runs)
|
|
579
|
+
# For resume, instance_id should be provided explicitly
|
|
580
|
+
if instance_id is None and not resume:
|
|
581
|
+
instance_id = str(uuid4())
|
|
582
|
+
|
|
555
583
|
# Build system prompt
|
|
556
584
|
system_prompt = _build_system_prompt(config, working_dir)
|
|
557
585
|
|
|
@@ -572,6 +600,8 @@ def build_orchestrator(
|
|
|
572
600
|
system_prompt=system_prompt,
|
|
573
601
|
maxSteps=config.orchestrator.max_steps,
|
|
574
602
|
env=env,
|
|
603
|
+
instance_id=instance_id,
|
|
604
|
+
instance_name=instance_name,
|
|
575
605
|
)
|
|
576
606
|
|
|
577
607
|
# Resume if requested
|
zwarm/tools/delegation.py
CHANGED
|
@@ -11,6 +11,7 @@ These are the core tools that orchestrators use to delegate work to executors:
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
14
|
+
from pathlib import Path
|
|
14
15
|
from typing import TYPE_CHECKING, Any, Literal
|
|
15
16
|
|
|
16
17
|
from wbal.helper import weaveTool
|
|
@@ -31,6 +32,65 @@ def _format_session_header(session_id: str, adapter: str, mode: str) -> str:
|
|
|
31
32
|
return f"[{session_id[:8]}] {adapter} ({mode})"
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _validate_working_dir(
|
|
36
|
+
requested_dir: Path | str | None,
|
|
37
|
+
default_dir: Path,
|
|
38
|
+
allowed_dirs: list[str] | None,
|
|
39
|
+
) -> tuple[Path, str | None]:
|
|
40
|
+
"""
|
|
41
|
+
Validate requested working directory against allowed_dirs config.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
requested_dir: Directory requested by the agent (or None for default)
|
|
45
|
+
default_dir: The orchestrator's working directory
|
|
46
|
+
allowed_dirs: Config setting - None means only default allowed,
|
|
47
|
+
["*"] means any, or list of allowed paths
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
(validated_path, error_message) - error is None if valid
|
|
51
|
+
"""
|
|
52
|
+
if requested_dir is None:
|
|
53
|
+
return default_dir, None
|
|
54
|
+
|
|
55
|
+
requested = Path(requested_dir).resolve()
|
|
56
|
+
|
|
57
|
+
# Check if directory exists
|
|
58
|
+
if not requested.exists():
|
|
59
|
+
return default_dir, f"Directory does not exist: {requested}"
|
|
60
|
+
|
|
61
|
+
if not requested.is_dir():
|
|
62
|
+
return default_dir, f"Not a directory: {requested}"
|
|
63
|
+
|
|
64
|
+
# If allowed_dirs is None, only default is allowed
|
|
65
|
+
if allowed_dirs is None:
|
|
66
|
+
if requested == default_dir.resolve():
|
|
67
|
+
return requested, None
|
|
68
|
+
return default_dir, (
|
|
69
|
+
f"Directory not allowed: {requested}. "
|
|
70
|
+
f"Agent can only delegate to working directory ({default_dir}). "
|
|
71
|
+
"Set orchestrator.allowed_dirs in config to allow other directories."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# If ["*"], any directory is allowed
|
|
75
|
+
if allowed_dirs == ["*"]:
|
|
76
|
+
return requested, None
|
|
77
|
+
|
|
78
|
+
# Check against allowed list
|
|
79
|
+
for allowed in allowed_dirs:
|
|
80
|
+
allowed_path = Path(allowed).resolve()
|
|
81
|
+
# Allow if requested is the allowed path or a subdirectory of it
|
|
82
|
+
try:
|
|
83
|
+
requested.relative_to(allowed_path)
|
|
84
|
+
return requested, None
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
return default_dir, (
|
|
89
|
+
f"Directory not allowed: {requested}. "
|
|
90
|
+
f"Allowed directories: {allowed_dirs}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
34
94
|
@weaveTool
|
|
35
95
|
def delegate(
|
|
36
96
|
self: "Orchestrator",
|
|
@@ -38,6 +98,7 @@ def delegate(
|
|
|
38
98
|
mode: Literal["sync", "async"] = "sync",
|
|
39
99
|
adapter: str | None = None,
|
|
40
100
|
model: str | None = None,
|
|
101
|
+
working_dir: str | None = None,
|
|
41
102
|
) -> dict[str, Any]:
|
|
42
103
|
"""
|
|
43
104
|
Delegate work to an executor agent.
|
|
@@ -57,6 +118,8 @@ def delegate(
|
|
|
57
118
|
mode: "sync" for conversational, "async" for fire-and-forget.
|
|
58
119
|
adapter: Which executor adapter to use (default: config setting).
|
|
59
120
|
model: Model override for the executor.
|
|
121
|
+
working_dir: Directory for the executor to work in (default: orchestrator's dir).
|
|
122
|
+
NOTE: May be restricted by orchestrator.allowed_dirs config.
|
|
60
123
|
|
|
61
124
|
Returns:
|
|
62
125
|
{session_id, status, response (if sync)}
|
|
@@ -65,6 +128,20 @@ def delegate(
|
|
|
65
128
|
delegate(task="Add a logout button to the navbar", mode="sync")
|
|
66
129
|
# Then use converse() to refine: "Also add a confirmation dialog"
|
|
67
130
|
"""
|
|
131
|
+
# Validate working directory against allowed_dirs config
|
|
132
|
+
effective_dir, dir_error = _validate_working_dir(
|
|
133
|
+
working_dir,
|
|
134
|
+
self.working_dir,
|
|
135
|
+
self.config.orchestrator.allowed_dirs,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if dir_error:
|
|
139
|
+
return {
|
|
140
|
+
"success": False,
|
|
141
|
+
"error": dir_error,
|
|
142
|
+
"hint": "Use the default working directory or ask user to update allowed_dirs config",
|
|
143
|
+
}
|
|
144
|
+
|
|
68
145
|
# Get adapter (use default from config if not specified)
|
|
69
146
|
adapter_name = adapter or self.config.executor.adapter
|
|
70
147
|
executor = self._get_adapter(adapter_name)
|
|
@@ -73,7 +150,7 @@ def delegate(
|
|
|
73
150
|
session = asyncio.run(
|
|
74
151
|
executor.start_session(
|
|
75
152
|
task=task,
|
|
76
|
-
working_dir=
|
|
153
|
+
working_dir=effective_dir,
|
|
77
154
|
mode=mode,
|
|
78
155
|
model=model or self.config.executor.model,
|
|
79
156
|
sandbox=self.config.executor.sandbox,
|
|
@@ -117,7 +194,7 @@ def delegate(
|
|
|
117
194
|
header = _format_session_header(session.id, adapter_name, mode)
|
|
118
195
|
|
|
119
196
|
if mode == "sync":
|
|
120
|
-
|
|
197
|
+
result = {
|
|
121
198
|
"success": True,
|
|
122
199
|
"session": header,
|
|
123
200
|
"session_id": session.id,
|
|
@@ -127,6 +204,14 @@ def delegate(
|
|
|
127
204
|
"tokens": session.token_usage.get("total_tokens", 0),
|
|
128
205
|
"hint": "Use converse(session_id, message) to continue this conversation",
|
|
129
206
|
}
|
|
207
|
+
# Warn if no conversation ID - converse() won't work
|
|
208
|
+
if not session.conversation_id:
|
|
209
|
+
result["warning"] = "no_conversation_id"
|
|
210
|
+
result["hint"] = (
|
|
211
|
+
"WARNING: MCP didn't return a conversation ID. "
|
|
212
|
+
"You cannot use converse() - send all instructions upfront or use async mode."
|
|
213
|
+
)
|
|
214
|
+
return result
|
|
130
215
|
else:
|
|
131
216
|
return {
|
|
132
217
|
"success": True,
|
|
@@ -186,6 +271,18 @@ def converse(
|
|
|
186
271
|
"hint": "Start a new session with delegate()",
|
|
187
272
|
}
|
|
188
273
|
|
|
274
|
+
# Check for stale/missing conversation_id (common after resume)
|
|
275
|
+
if not session.conversation_id:
|
|
276
|
+
return {
|
|
277
|
+
"success": False,
|
|
278
|
+
"error": "Session has no conversation ID (likely stale after resume)",
|
|
279
|
+
"hint": (
|
|
280
|
+
"This session's conversation was lost (MCP server restarted). "
|
|
281
|
+
"Use end_session() to close it, then delegate() a new task."
|
|
282
|
+
),
|
|
283
|
+
"session_id": session_id,
|
|
284
|
+
}
|
|
285
|
+
|
|
189
286
|
# Get adapter and send message
|
|
190
287
|
executor = self._get_adapter(session.adapter)
|
|
191
288
|
try:
|
|
@@ -211,7 +308,13 @@ def converse(
|
|
|
211
308
|
turn = len([m for m in session.messages if m.role == "user"])
|
|
212
309
|
header = _format_session_header(session.id, session.adapter, session.mode.value)
|
|
213
310
|
|
|
214
|
-
|
|
311
|
+
# Check for conversation loss (indicated by error in response)
|
|
312
|
+
conversation_lost = (
|
|
313
|
+
"[ERROR] Conversation lost" in response
|
|
314
|
+
or session.conversation_id is None
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
result = {
|
|
215
318
|
"success": True,
|
|
216
319
|
"session": header,
|
|
217
320
|
"session_id": session_id,
|
|
@@ -221,6 +324,15 @@ def converse(
|
|
|
221
324
|
"tokens": session.token_usage.get("total_tokens", 0),
|
|
222
325
|
}
|
|
223
326
|
|
|
327
|
+
if conversation_lost:
|
|
328
|
+
result["warning"] = "conversation_lost"
|
|
329
|
+
result["hint"] = (
|
|
330
|
+
"The MCP server lost this conversation. You should end_session() "
|
|
331
|
+
"and delegate() a new task with the full context."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return result
|
|
335
|
+
|
|
224
336
|
|
|
225
337
|
@weaveTool
|
|
226
338
|
def check_session(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zwarm
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: Multi-Agent CLI Orchestration Research Platform
|
|
5
5
|
Requires-Python: <3.14,>=3.13
|
|
6
6
|
Requires-Dist: python-dotenv>=1.0.0
|
|
@@ -76,10 +76,11 @@ echo "Fix the bug in auth.py" | zwarm orchestrate
|
|
|
76
76
|
|
|
77
77
|
zwarm looks for configuration in this order:
|
|
78
78
|
1. `--config` flag (YAML file)
|
|
79
|
-
2.
|
|
80
|
-
3.
|
|
79
|
+
2. `.zwarm/config.toml` (created by `zwarm init`)
|
|
80
|
+
3. `config.toml` in working directory (legacy, for backwards compat)
|
|
81
|
+
4. Default settings
|
|
81
82
|
|
|
82
|
-
### Minimal config.toml
|
|
83
|
+
### Minimal .zwarm/config.toml
|
|
83
84
|
|
|
84
85
|
```toml
|
|
85
86
|
[weave]
|
|
@@ -430,10 +431,11 @@ zwarm clean --events
|
|
|
430
431
|
|
|
431
432
|
### State Management
|
|
432
433
|
|
|
433
|
-
All state is stored in flat files under `.zwarm/`:
|
|
434
|
+
All state and config is stored in flat files under `.zwarm/`:
|
|
434
435
|
|
|
435
436
|
```
|
|
436
437
|
.zwarm/
|
|
438
|
+
├── config.toml # Runtime settings (weave, adapter, watchers)
|
|
437
439
|
├── state.json # Current state
|
|
438
440
|
├── events.jsonl # Append-only event log
|
|
439
441
|
├── sessions/
|
|
@@ -1,33 +1,35 @@
|
|
|
1
1
|
zwarm/__init__.py,sha256=3i3LMjHwIzE-LFIS2aUrwv3EZmpkvVMe-xj1h97rcSM,837
|
|
2
|
-
zwarm/orchestrator.py,sha256=
|
|
2
|
+
zwarm/orchestrator.py,sha256=wCvK4RVBDC6T_vrJSJBhPfaDpTorwLAXpOUYHDjowrs,21112
|
|
3
3
|
zwarm/test_orchestrator_watchers.py,sha256=QpoaehPU7ekT4XshbTOWnJ2H0wRveV3QOZjxbgyJJLY,807
|
|
4
|
-
zwarm/adapters/__init__.py,sha256=
|
|
4
|
+
zwarm/adapters/__init__.py,sha256=O0b-SfZpb6txeNqFkXZ2aaf34yLFYreznyrAV25jF_Q,656
|
|
5
5
|
zwarm/adapters/base.py,sha256=fZlQviTgVvOcwnxduTla6WuM6FzQJ_yoHMW5SxwVgQg,2527
|
|
6
|
-
zwarm/adapters/claude_code.py,sha256=
|
|
7
|
-
zwarm/adapters/codex_mcp.py,sha256=
|
|
8
|
-
zwarm/adapters/
|
|
6
|
+
zwarm/adapters/claude_code.py,sha256=vAjsjD-_JjARmC4_FBSILQZmQCBrk_oNHo18a9ubuqk,11481
|
|
7
|
+
zwarm/adapters/codex_mcp.py,sha256=olgR8mfgjn0cbrD8Gc11DX4yVzWXqE64fQHsMAq_xAQ,32771
|
|
8
|
+
zwarm/adapters/registry.py,sha256=EdyHECaNA5Kv1od64pYFBJyA_r_6I1r_eJTNP1XYLr4,1781
|
|
9
|
+
zwarm/adapters/test_codex_mcp.py,sha256=0qhVzxn_KF-XUS30gXSJKwMdR3kWGsDY9iPk1Ihqn3w,10698
|
|
10
|
+
zwarm/adapters/test_registry.py,sha256=otxcVDONwFCMisyANToF3iy7Y8dSbCL8bTmZNhxNuF4,2383
|
|
9
11
|
zwarm/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
zwarm/cli/main.py,sha256=
|
|
12
|
+
zwarm/cli/main.py,sha256=9kHUw897580fQtBii1BqoJrUBYMaKmYjC6CM4AuOsdQ,59013
|
|
11
13
|
zwarm/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
14
|
zwarm/core/compact.py,sha256=Y8C7Gs-5-WOU43WRvQ863Qzd5xtuEqR6Aw3r2p8_-i8,10907
|
|
13
|
-
zwarm/core/config.py,sha256=
|
|
15
|
+
zwarm/core/config.py,sha256=J-TAwIQFLY4x_G9qdvP_16DAYndqg_zhakCW5TLEMN4,11059
|
|
14
16
|
zwarm/core/environment.py,sha256=HVDpDZEpDSfyh9-wHZMzMKVUPKvioBkPVWeiME2JmFo,5435
|
|
15
17
|
zwarm/core/models.py,sha256=PrC3okRBVJxISUa1Fax4KkagqLT6Xub-kTxC9drN0sY,10083
|
|
16
|
-
zwarm/core/state.py,sha256=
|
|
18
|
+
zwarm/core/state.py,sha256=MzrvODKEiJovI7YI1jajW4uukineZ3ezmW5oQinMgjg,11563
|
|
17
19
|
zwarm/core/test_compact.py,sha256=WSdjCB5t4YMcknsrkmJIUsVOPY28s4y9GnDmu3Z4BFw,11878
|
|
18
20
|
zwarm/core/test_config.py,sha256=26ozyiFOdjFF2c9Q-HDfFM6GOLfgw_5FZ55nTDMNYA8,4888
|
|
19
21
|
zwarm/core/test_models.py,sha256=sWTIhMZvuLP5AooGR6y8OR2EyWydqVfhmGrE7NPBBnk,8450
|
|
20
22
|
zwarm/prompts/__init__.py,sha256=FiaIOniLrIyfD3_osxT6I7FfyKjtctbf8jNs5QTPs_s,213
|
|
21
23
|
zwarm/prompts/orchestrator.py,sha256=R-ym3nlspYNspf085Qwwfi96Yh3K6-Csb6vfBg29K2c,14228
|
|
22
24
|
zwarm/tools/__init__.py,sha256=FpqxwXJA6-fQ7C-oLj30jjK_0qqcE7MbI0dQuaB56kU,290
|
|
23
|
-
zwarm/tools/delegation.py,sha256=
|
|
25
|
+
zwarm/tools/delegation.py,sha256=PeB0W7x2TcGCZ9rGBYDlIIFr7AT1TOdc1SLe7BKCUoM,15332
|
|
24
26
|
zwarm/watchers/__init__.py,sha256=yYGTbhuImQLESUdtfrYbHYBJNvCNX3B-Ei-vY5BizX8,760
|
|
25
27
|
zwarm/watchers/base.py,sha256=r1GoPlj06nOT2xp4fghfSjxbRyFFFQUB6HpZbEyO2OY,3834
|
|
26
28
|
zwarm/watchers/builtin.py,sha256=52hyRREYYDsSuG-YKElXViSTyMmGySZaFreHc0pz-A4,12482
|
|
27
29
|
zwarm/watchers/manager.py,sha256=XZjBVeHjgCUlkTUeHqdvBvHoBC862U1ik0fG6nlRGog,5587
|
|
28
30
|
zwarm/watchers/registry.py,sha256=A9iBIVIFNtO7KPX0kLpUaP8dAK7ozqWLA44ocJGnOw4,1219
|
|
29
31
|
zwarm/watchers/test_watchers.py,sha256=zOsxumBqKfR5ZVGxrNlxz6KcWjkcdp0QhW9WB0_20zM,7855
|
|
30
|
-
zwarm-1.
|
|
31
|
-
zwarm-1.
|
|
32
|
-
zwarm-1.
|
|
33
|
-
zwarm-1.
|
|
32
|
+
zwarm-1.3.2.dist-info/METADATA,sha256=Xf0dFQrOuhMhlbVQsRWw1uh65uCIVWMB-Hv99-rZ7Xc,15174
|
|
33
|
+
zwarm-1.3.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
34
|
+
zwarm-1.3.2.dist-info/entry_points.txt,sha256=u0OXq4q8d3yJ3EkUXwZfkS-Y8Lcy0F8cWrcQfoRxM6Q,46
|
|
35
|
+
zwarm-1.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|