zwarm 1.2.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.
@@ -549,20 +549,33 @@ class CodexMCPAdapter(ExecutorAdapter):
549
549
  """
550
550
  client = self._ensure_client()
551
551
 
552
+ logger.debug(f"Calling codex-reply with conversation_id={conversation_id}")
553
+
552
554
  result = client.call_tool("codex-reply", {
553
555
  "conversationId": conversation_id,
554
556
  "prompt": message,
555
557
  })
556
558
 
559
+ # Check for conversation loss - MCP returns empty result when session not found
560
+ if not result.get("messages") and not result.get("output"):
561
+ logger.error(
562
+ f"codex-reply returned empty result for conversation_id={conversation_id}. "
563
+ f"The MCP server may have lost the conversation state. Result: {result}"
564
+ )
565
+
557
566
  # Track usage
558
567
  usage = result.get("usage", {})
559
568
  self._accumulate_usage(usage)
560
569
 
570
+ response = self._extract_response(result)
571
+ logger.debug(f"codex-reply response length: {len(response)} chars")
572
+
561
573
  return {
562
- "response": self._extract_response(result),
574
+ "response": response,
563
575
  "raw_messages": result.get("messages", []),
564
576
  "usage": usage,
565
577
  "total_usage": self.total_usage,
578
+ "conversation_lost": not result.get("messages") and not result.get("output"),
566
579
  }
567
580
 
568
581
  @weave.op()
@@ -598,6 +611,13 @@ class CodexMCPAdapter(ExecutorAdapter):
598
611
  session.conversation_id = result["conversation_id"]
599
612
  if session.conversation_id:
600
613
  self._sessions[session.id] = session.conversation_id
614
+ logger.debug(f"Session {session.id[:8]} mapped to conversation {session.conversation_id}")
615
+ else:
616
+ # This is bad - we won't be able to continue this conversation
617
+ logger.warning(
618
+ f"Session {session.id[:8]} started but MCP didn't return a conversation ID. "
619
+ "Further converse() calls will fail."
620
+ )
601
621
 
602
622
  session.add_message("user", task)
603
623
  session.add_message("assistant", result["response"])
@@ -652,6 +672,16 @@ class CodexMCPAdapter(ExecutorAdapter):
652
672
  )
653
673
 
654
674
  response_text = result["response"]
675
+
676
+ # Check if conversation was lost
677
+ if result.get("conversation_lost"):
678
+ logger.warning(
679
+ f"Conversation {session.conversation_id} was lost. "
680
+ f"Session {session.id} will be marked as needing re-delegation."
681
+ )
682
+ # Mark the session as having a lost conversation so orchestrator can handle it
683
+ session.conversation_id = None # Clear the stale ID
684
+
655
685
  session.add_message("user", message)
656
686
  session.add_message("assistant", response_text)
657
687
 
@@ -797,6 +827,15 @@ class CodexMCPAdapter(ExecutorAdapter):
797
827
 
798
828
  def _extract_response(self, result: dict) -> str:
799
829
  """Extract response text from MCP result."""
830
+ # Check for error indicators - empty result suggests lost conversation
831
+ if (
832
+ result.get("conversationId") is None
833
+ and not result.get("messages")
834
+ and not result.get("output")
835
+ ):
836
+ logger.warning(f"MCP returned empty result - conversation may be lost: {result}")
837
+ return "[ERROR] Conversation lost - the MCP server no longer has this session. Please re-delegate the task."
838
+
800
839
  # First check for our collected output
801
840
  if result.get("output"):
802
841
  return result["output"]
@@ -823,5 +862,6 @@ class CodexMCPAdapter(ExecutorAdapter):
823
862
  if "text" in result:
824
863
  return result["text"]
825
864
 
826
- # Fallback: stringify the result
865
+ # Fallback: stringify the result (but log it as unexpected)
866
+ logger.warning(f"Unexpected MCP result format, returning raw: {list(result.keys())}")
827
867
  return json.dumps(result, indent=2)
zwarm/cli/main.py CHANGED
@@ -141,6 +141,8 @@ def orchestrate(
141
141
  resume: Annotated[bool, typer.Option("--resume", help="Resume from previous state")] = False,
142
142
  max_steps: Annotated[Optional[int], typer.Option("--max-steps", help="Maximum orchestrator steps")] = None,
143
143
  verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show detailed output")] = False,
144
+ instance: Annotated[Optional[str], typer.Option("--instance", "-i", help="Instance ID (for isolation/resume)")] = None,
145
+ instance_name: Annotated[Optional[str], typer.Option("--name", "-n", help="Human-readable instance name")] = None,
144
146
  ):
145
147
  """
146
148
  Start an orchestrator session.
@@ -149,6 +151,9 @@ def orchestrate(
149
151
  (Codex, Claude Code). It can have sync conversations or fire-and-forget
150
152
  async delegations.
151
153
 
154
+ Each run creates an isolated instance to prevent conflicts when running
155
+ multiple orchestrators in the same directory.
156
+
152
157
  [bold]Examples:[/]
153
158
  [dim]# Simple task[/]
154
159
  $ zwarm orchestrate --task "Add a logout button to the navbar"
@@ -166,8 +171,14 @@ def orchestrate(
166
171
  [dim]# Override settings[/]
167
172
  $ zwarm orchestrate --task "Fix bug" --set executor.adapter=claude_code
168
173
 
169
- [dim]# Resume interrupted session[/]
170
- $ zwarm orchestrate --task "Continue work" --resume
174
+ [dim]# Named instance (easier to track)[/]
175
+ $ zwarm orchestrate --task "Add tests" --name test-work
176
+
177
+ [dim]# Resume a specific instance[/]
178
+ $ zwarm orchestrate --resume --instance abc123
179
+
180
+ [dim]# List all instances[/]
181
+ $ zwarm instances
171
182
  """
172
183
  from zwarm.orchestrator import build_orchestrator
173
184
 
@@ -187,6 +198,8 @@ def orchestrate(
187
198
  console.print(f"[bold]Starting orchestrator...[/]")
188
199
  console.print(f" Task: {task}")
189
200
  console.print(f" Working dir: {working_dir.absolute()}")
201
+ if instance:
202
+ console.print(f" Instance: {instance}" + (f" ({instance_name})" if instance_name else ""))
190
203
  console.print()
191
204
 
192
205
  # Output handler to show orchestrator messages
@@ -203,11 +216,17 @@ def orchestrate(
203
216
  overrides=override_list,
204
217
  resume=resume,
205
218
  output_handler=output_handler,
219
+ instance_id=instance,
220
+ instance_name=instance_name,
206
221
  )
207
222
 
208
223
  if resume:
209
224
  console.print(" [dim]Resuming from previous state...[/]")
210
225
 
226
+ # Show instance ID if auto-generated
227
+ if orchestrator.instance_id and not instance:
228
+ console.print(f" [dim]Instance: {orchestrator.instance_id[:8]}[/]")
229
+
211
230
  # Run the orchestrator loop
212
231
  console.print("[bold]--- Orchestrator running ---[/]\n")
213
232
  result = orchestrator.run(task=task)
@@ -223,16 +242,35 @@ def orchestrate(
223
242
  # Save state for potential resume
224
243
  orchestrator.save_state()
225
244
 
245
+ # Update instance status
246
+ if orchestrator.instance_id:
247
+ from zwarm.core.state import update_instance_status
248
+ update_instance_status(
249
+ orchestrator.instance_id,
250
+ "completed",
251
+ working_dir / ".zwarm",
252
+ )
253
+ console.print(f" [dim]Instance {orchestrator.instance_id[:8]} marked completed[/]")
254
+
226
255
  except KeyboardInterrupt:
227
256
  console.print("\n\n[yellow]Interrupted.[/]")
228
257
  if orchestrator:
229
258
  orchestrator.save_state()
230
259
  console.print("[dim]State saved. Use --resume to continue.[/]")
260
+ # Keep instance as "active" so it can be resumed
231
261
  sys.exit(1)
232
262
  except Exception as e:
233
263
  console.print(f"\n[red]Error:[/] {e}")
234
264
  if verbose:
235
265
  console.print_exception()
266
+ # Update instance status to failed
267
+ if orchestrator and orchestrator.instance_id:
268
+ from zwarm.core.state import update_instance_status
269
+ update_instance_status(
270
+ orchestrator.instance_id,
271
+ "failed",
272
+ working_dir / ".zwarm",
273
+ )
236
274
  sys.exit(1)
237
275
 
238
276
 
@@ -384,6 +422,63 @@ def status(
384
422
  console.print(" [dim](none)[/]")
385
423
 
386
424
 
425
+ @app.command()
426
+ def instances(
427
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
428
+ all_instances: Annotated[bool, typer.Option("--all", "-a", help="Show all instances (including completed)")] = False,
429
+ ):
430
+ """
431
+ List all orchestrator instances.
432
+
433
+ Shows instances that have been run in this directory. Use --all to include
434
+ completed instances.
435
+
436
+ [bold]Examples:[/]
437
+ [dim]# List active instances[/]
438
+ $ zwarm instances
439
+
440
+ [dim]# List all instances[/]
441
+ $ zwarm instances --all
442
+ """
443
+ from zwarm.core.state import list_instances as get_instances
444
+
445
+ state_dir = working_dir / ".zwarm"
446
+ all_inst = get_instances(state_dir)
447
+
448
+ if not all_inst:
449
+ console.print("[dim]No instances found.[/]")
450
+ console.print("[dim]Run 'zwarm orchestrate' to start a new instance.[/]")
451
+ return
452
+
453
+ # Filter if not showing all
454
+ if not all_instances:
455
+ all_inst = [i for i in all_inst if i.get("status") == "active"]
456
+
457
+ if not all_inst:
458
+ console.print("[dim]No active instances. Use --all to see completed ones.[/]")
459
+ return
460
+
461
+ console.print(f"[bold]Instances[/] ({len(all_inst)} total)\n")
462
+
463
+ for inst in all_inst:
464
+ status = inst.get("status", "unknown")
465
+ status_icon = {"active": "[green]●[/]", "completed": "[dim]✓[/]", "failed": "[red]✗[/]"}.get(status, "[dim]?[/]")
466
+
467
+ inst_id = inst.get("id", "unknown")[:8]
468
+ name = inst.get("name", "")
469
+ task = (inst.get("task") or "")[:60]
470
+ updated = inst.get("updated_at", "")[:19] if inst.get("updated_at") else ""
471
+
472
+ console.print(f" {status_icon} [bold]{inst_id}[/]" + (f" ({name})" if name and name != inst_id else ""))
473
+ if task:
474
+ console.print(f" [dim]{task}[/]")
475
+ if updated:
476
+ console.print(f" [dim]Updated: {updated}[/]")
477
+ console.print()
478
+
479
+ console.print("[dim]Use --instance <id> with 'orchestrate --resume' to resume an instance.[/]")
480
+
481
+
387
482
  @app.command()
388
483
  def history(
389
484
  working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
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
- ├── state.json # Current state (sessions, tasks)
7
- ├── events.jsonl # Append-only event log
8
- ├── sessions/
9
- │ └── <session-id>/
10
- ├── messages.json # Full conversation history
11
- │ └── output.log # Agent stdout/stderr
12
- └── orchestrator/
13
- └── messages.json # Orchestrator's message history (for resume)
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 (default: .zwarm/).
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__(self, state_dir: Path | str = ".zwarm"):
53
- self.state_dir = Path(state_dir)
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
@@ -52,6 +52,10 @@ class Orchestrator(YamlAgent):
52
52
  config: ZwarmConfig = Field(default_factory=ZwarmConfig)
53
53
  working_dir: Path = Field(default_factory=Path.cwd)
54
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
+
55
59
  # Load tools from modules (delegation + bash for verification)
56
60
  agent_tool_modules: list[str] = Field(
57
61
  default=[
@@ -77,11 +81,25 @@ class Orchestrator(YamlAgent):
77
81
  """Initialize state and adapters after model creation."""
78
82
  super().model_post_init(__context)
79
83
 
80
- # Initialize state manager
81
- self._state = StateManager(self.working_dir / self.config.state_dir)
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
+ )
82
90
  self._state.init()
83
91
  self._state.load()
84
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
+
85
103
  # Load existing sessions
86
104
  for session in self._state.list_sessions():
87
105
  self._sessions[session.id] = session
@@ -215,12 +233,18 @@ class Orchestrator(YamlAgent):
215
233
  if not self._resumed:
216
234
  return
217
235
 
218
- # 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
219
238
  old_sessions = []
239
+ invalidated_count = 0
220
240
  for sid, session in self._sessions.items():
221
241
  old_sessions.append(
222
242
  f" - {sid[:8]}... ({session.adapter}, {session.status.value})"
223
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
224
248
 
225
249
  session_info = "\n".join(old_sessions) if old_sessions else " (none)"
226
250
 
@@ -228,14 +252,14 @@ class Orchestrator(YamlAgent):
228
252
  "role": "user",
229
253
  "content": f"""[SYSTEM NOTICE] You have been resumed from a previous session.
230
254
 
231
- IMPORTANT: Your previous executor sessions are NO LONGER ACTIVE. The MCP connections and subprocess handles were lost when the previous session ended.
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.
232
256
 
233
- Previous sessions (now stale):
257
+ Previous sessions (conversation IDs cleared):
234
258
  {session_info}
235
259
 
236
- You must start NEW sessions with delegate() if you need to continue work. Do NOT try to use converse() or check_session() with the old session IDs - they will fail.
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.
237
261
 
238
- Continue with your task from where you left off.""",
262
+ Review what was accomplished in the previous session and delegate new tasks as needed.""",
239
263
  }
240
264
 
241
265
  self.messages.append(resume_msg)
@@ -521,6 +545,8 @@ def build_orchestrator(
521
545
  overrides: list[str] | None = None,
522
546
  resume: bool = False,
523
547
  output_handler: Callable[[str], None] | None = None,
548
+ instance_id: str | None = None,
549
+ instance_name: str | None = None,
524
550
  ) -> Orchestrator:
525
551
  """
526
552
  Build an orchestrator from configuration.
@@ -532,10 +558,14 @@ def build_orchestrator(
532
558
  overrides: CLI overrides (--set key=value)
533
559
  resume: Whether to resume from previous state
534
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
535
563
 
536
564
  Returns:
537
565
  Configured Orchestrator instance
538
566
  """
567
+ from uuid import uuid4
568
+
539
569
  # Load configuration
540
570
  config = load_config(
541
571
  config_path=config_path,
@@ -545,6 +575,11 @@ def build_orchestrator(
545
575
  # Resolve working directory
546
576
  working_dir = working_dir or Path.cwd()
547
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
+
548
583
  # Build system prompt
549
584
  system_prompt = _build_system_prompt(config, working_dir)
550
585
 
@@ -565,6 +600,8 @@ def build_orchestrator(
565
600
  system_prompt=system_prompt,
566
601
  maxSteps=config.orchestrator.max_steps,
567
602
  env=env,
603
+ instance_id=instance_id,
604
+ instance_name=instance_name,
568
605
  )
569
606
 
570
607
  # Resume if requested
zwarm/tools/delegation.py CHANGED
@@ -194,7 +194,7 @@ def delegate(
194
194
  header = _format_session_header(session.id, adapter_name, mode)
195
195
 
196
196
  if mode == "sync":
197
- return {
197
+ result = {
198
198
  "success": True,
199
199
  "session": header,
200
200
  "session_id": session.id,
@@ -204,6 +204,14 @@ def delegate(
204
204
  "tokens": session.token_usage.get("total_tokens", 0),
205
205
  "hint": "Use converse(session_id, message) to continue this conversation",
206
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
207
215
  else:
208
216
  return {
209
217
  "success": True,
@@ -263,6 +271,18 @@ def converse(
263
271
  "hint": "Start a new session with delegate()",
264
272
  }
265
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
+
266
286
  # Get adapter and send message
267
287
  executor = self._get_adapter(session.adapter)
268
288
  try:
@@ -288,7 +308,13 @@ def converse(
288
308
  turn = len([m for m in session.messages if m.role == "user"])
289
309
  header = _format_session_header(session.id, session.adapter, session.mode.value)
290
310
 
291
- return {
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 = {
292
318
  "success": True,
293
319
  "session": header,
294
320
  "session_id": session_id,
@@ -298,6 +324,15 @@ def converse(
298
324
  "tokens": session.token_usage.get("total_tokens", 0),
299
325
  }
300
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
+
301
336
 
302
337
  @weaveTool
303
338
  def check_session(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zwarm
3
- Version: 1.2.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
@@ -1,35 +1,35 @@
1
1
  zwarm/__init__.py,sha256=3i3LMjHwIzE-LFIS2aUrwv3EZmpkvVMe-xj1h97rcSM,837
2
- zwarm/orchestrator.py,sha256=3Ix07GCnt7GHoBg2TwFmDaH9TAk6dT5BhuCaLiQQFCw,19430
2
+ zwarm/orchestrator.py,sha256=wCvK4RVBDC6T_vrJSJBhPfaDpTorwLAXpOUYHDjowrs,21112
3
3
  zwarm/test_orchestrator_watchers.py,sha256=QpoaehPU7ekT4XshbTOWnJ2H0wRveV3QOZjxbgyJJLY,807
4
4
  zwarm/adapters/__init__.py,sha256=O0b-SfZpb6txeNqFkXZ2aaf34yLFYreznyrAV25jF_Q,656
5
5
  zwarm/adapters/base.py,sha256=fZlQviTgVvOcwnxduTla6WuM6FzQJ_yoHMW5SxwVgQg,2527
6
6
  zwarm/adapters/claude_code.py,sha256=vAjsjD-_JjARmC4_FBSILQZmQCBrk_oNHo18a9ubuqk,11481
7
- zwarm/adapters/codex_mcp.py,sha256=f6ScDWWryA6AVWxBNMuvw3FO0a3WAJbeTq4Ij0AR50k,30694
7
+ zwarm/adapters/codex_mcp.py,sha256=olgR8mfgjn0cbrD8Gc11DX4yVzWXqE64fQHsMAq_xAQ,32771
8
8
  zwarm/adapters/registry.py,sha256=EdyHECaNA5Kv1od64pYFBJyA_r_6I1r_eJTNP1XYLr4,1781
9
9
  zwarm/adapters/test_codex_mcp.py,sha256=0qhVzxn_KF-XUS30gXSJKwMdR3kWGsDY9iPk1Ihqn3w,10698
10
10
  zwarm/adapters/test_registry.py,sha256=otxcVDONwFCMisyANToF3iy7Y8dSbCL8bTmZNhxNuF4,2383
11
11
  zwarm/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- zwarm/cli/main.py,sha256=TE4qIWu11sEKqt2Npy3bNmB0F7IgsSylpupkrP_OoRI,55291
12
+ zwarm/cli/main.py,sha256=9kHUw897580fQtBii1BqoJrUBYMaKmYjC6CM4AuOsdQ,59013
13
13
  zwarm/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  zwarm/core/compact.py,sha256=Y8C7Gs-5-WOU43WRvQ863Qzd5xtuEqR6Aw3r2p8_-i8,10907
15
15
  zwarm/core/config.py,sha256=J-TAwIQFLY4x_G9qdvP_16DAYndqg_zhakCW5TLEMN4,11059
16
16
  zwarm/core/environment.py,sha256=HVDpDZEpDSfyh9-wHZMzMKVUPKvioBkPVWeiME2JmFo,5435
17
17
  zwarm/core/models.py,sha256=PrC3okRBVJxISUa1Fax4KkagqLT6Xub-kTxC9drN0sY,10083
18
- zwarm/core/state.py,sha256=wMryIvXP-VDLh2b76A5taeL_9dm5k4jk4HnvHWgLqGE,7658
18
+ zwarm/core/state.py,sha256=MzrvODKEiJovI7YI1jajW4uukineZ3ezmW5oQinMgjg,11563
19
19
  zwarm/core/test_compact.py,sha256=WSdjCB5t4YMcknsrkmJIUsVOPY28s4y9GnDmu3Z4BFw,11878
20
20
  zwarm/core/test_config.py,sha256=26ozyiFOdjFF2c9Q-HDfFM6GOLfgw_5FZ55nTDMNYA8,4888
21
21
  zwarm/core/test_models.py,sha256=sWTIhMZvuLP5AooGR6y8OR2EyWydqVfhmGrE7NPBBnk,8450
22
22
  zwarm/prompts/__init__.py,sha256=FiaIOniLrIyfD3_osxT6I7FfyKjtctbf8jNs5QTPs_s,213
23
23
  zwarm/prompts/orchestrator.py,sha256=R-ym3nlspYNspf085Qwwfi96Yh3K6-Csb6vfBg29K2c,14228
24
24
  zwarm/tools/__init__.py,sha256=FpqxwXJA6-fQ7C-oLj30jjK_0qqcE7MbI0dQuaB56kU,290
25
- zwarm/tools/delegation.py,sha256=eoImpOcTpPcI8wZTP57PinQ5FzvnYM-qWBEc_xyuUU4,14006
25
+ zwarm/tools/delegation.py,sha256=PeB0W7x2TcGCZ9rGBYDlIIFr7AT1TOdc1SLe7BKCUoM,15332
26
26
  zwarm/watchers/__init__.py,sha256=yYGTbhuImQLESUdtfrYbHYBJNvCNX3B-Ei-vY5BizX8,760
27
27
  zwarm/watchers/base.py,sha256=r1GoPlj06nOT2xp4fghfSjxbRyFFFQUB6HpZbEyO2OY,3834
28
28
  zwarm/watchers/builtin.py,sha256=52hyRREYYDsSuG-YKElXViSTyMmGySZaFreHc0pz-A4,12482
29
29
  zwarm/watchers/manager.py,sha256=XZjBVeHjgCUlkTUeHqdvBvHoBC862U1ik0fG6nlRGog,5587
30
30
  zwarm/watchers/registry.py,sha256=A9iBIVIFNtO7KPX0kLpUaP8dAK7ozqWLA44ocJGnOw4,1219
31
31
  zwarm/watchers/test_watchers.py,sha256=zOsxumBqKfR5ZVGxrNlxz6KcWjkcdp0QhW9WB0_20zM,7855
32
- zwarm-1.2.1.dist-info/METADATA,sha256=G8rZQVHQOPXt7_bOUdL9wEtp6xyXqpzYoCQLx82ay24,15174
33
- zwarm-1.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
34
- zwarm-1.2.1.dist-info/entry_points.txt,sha256=u0OXq4q8d3yJ3EkUXwZfkS-Y8Lcy0F8cWrcQfoRxM6Q,46
35
- zwarm-1.2.1.dist-info/RECORD,,
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