zwarm 3.2.1__py3-none-any.whl → 3.4.0__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/cli/interactive.py +3 -3
- zwarm/cli/main.py +95 -77
- zwarm/cli/pilot.py +57 -6
- zwarm/core/config.py +26 -9
- zwarm/core/test_config.py +2 -3
- zwarm/orchestrator.py +17 -43
- zwarm/sessions/manager.py +210 -90
- zwarm/tools/delegation.py +6 -1
- {zwarm-3.2.1.dist-info → zwarm-3.4.0.dist-info}/METADATA +6 -3
- {zwarm-3.2.1.dist-info → zwarm-3.4.0.dist-info}/RECORD +12 -19
- zwarm/adapters/__init__.py +0 -21
- zwarm/adapters/base.py +0 -109
- zwarm/adapters/claude_code.py +0 -357
- zwarm/adapters/codex_mcp.py +0 -1262
- zwarm/adapters/registry.py +0 -69
- zwarm/adapters/test_codex_mcp.py +0 -274
- zwarm/adapters/test_registry.py +0 -68
- {zwarm-3.2.1.dist-info → zwarm-3.4.0.dist-info}/WHEEL +0 -0
- {zwarm-3.2.1.dist-info → zwarm-3.4.0.dist-info}/entry_points.txt +0 -0
zwarm/cli/interactive.py
CHANGED
|
@@ -157,7 +157,7 @@ def cmd_help():
|
|
|
157
157
|
table.add_column("Description")
|
|
158
158
|
|
|
159
159
|
table.add_row("[bold]Session Lifecycle[/]", "")
|
|
160
|
-
table.add_row('spawn "task" [--dir PATH]', "Start new session")
|
|
160
|
+
table.add_row('spawn "task" [--dir PATH] [--model M]', "Start new session")
|
|
161
161
|
table.add_row('c ID "message"', "Continue conversation")
|
|
162
162
|
table.add_row("kill ID | all", "Stop session(s)")
|
|
163
163
|
table.add_row("rm ID | all", "Delete session(s)")
|
|
@@ -307,7 +307,7 @@ def cmd_show(manager, session_id: str):
|
|
|
307
307
|
icon = STATUS_ICONS.get(session.status.value, "?")
|
|
308
308
|
console.print(f"\n{icon} [bold cyan]{session.short_id}[/] - {session.status.value}")
|
|
309
309
|
console.print(f" [dim]Task:[/] {session.task}")
|
|
310
|
-
console.print(f" [dim]Turn:[/] {session.turn} | [dim]Runtime:[/] {session.runtime
|
|
310
|
+
console.print(f" [dim]Turn:[/] {session.turn} | [dim]Runtime:[/] {session.runtime}")
|
|
311
311
|
|
|
312
312
|
# Token usage with cost estimate
|
|
313
313
|
usage = session.token_usage
|
|
@@ -694,7 +694,7 @@ def run_interactive(
|
|
|
694
694
|
|
|
695
695
|
elif cmd == "spawn":
|
|
696
696
|
if not args:
|
|
697
|
-
console.print(" [red]Usage:[/] spawn \"task\" [--dir PATH]")
|
|
697
|
+
console.print(" [red]Usage:[/] spawn \"task\" [--dir PATH] [--search]")
|
|
698
698
|
else:
|
|
699
699
|
# Parse spawn args
|
|
700
700
|
task_parts = []
|
zwarm/cli/main.py
CHANGED
|
@@ -122,16 +122,6 @@ Manage zwarm configurations.
|
|
|
122
122
|
app.add_typer(configs_app, name="configs")
|
|
123
123
|
|
|
124
124
|
|
|
125
|
-
class AdapterType(str, Enum):
|
|
126
|
-
codex_mcp = "codex_mcp"
|
|
127
|
-
claude_code = "claude_code"
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
class ModeType(str, Enum):
|
|
131
|
-
sync = "sync"
|
|
132
|
-
async_ = "async"
|
|
133
|
-
|
|
134
|
-
|
|
135
125
|
@app.command()
|
|
136
126
|
def orchestrate(
|
|
137
127
|
task: Annotated[Optional[str], typer.Option("--task", "-t", help="The task to accomplish")] = None,
|
|
@@ -228,6 +218,26 @@ def orchestrate(
|
|
|
228
218
|
if orchestrator.instance_id and not instance:
|
|
229
219
|
console.print(f" [dim]Instance: {orchestrator.instance_id[:8]}[/]")
|
|
230
220
|
|
|
221
|
+
# Set up step callback for live progress display
|
|
222
|
+
def step_callback(step_num: int, tool_results: list) -> None:
|
|
223
|
+
"""Print tool calls and results as they happen."""
|
|
224
|
+
if not tool_results:
|
|
225
|
+
return
|
|
226
|
+
for tool_info, result in tool_results:
|
|
227
|
+
name = tool_info.get("name", "?")
|
|
228
|
+
# Truncate args for display
|
|
229
|
+
args_str = str(tool_info.get("args", {}))
|
|
230
|
+
if len(args_str) > 80:
|
|
231
|
+
args_str = args_str[:77] + "..."
|
|
232
|
+
# Truncate result for display
|
|
233
|
+
result_str = str(result)
|
|
234
|
+
if len(result_str) > 100:
|
|
235
|
+
result_str = result_str[:97] + "..."
|
|
236
|
+
console.print(f"[dim]step {step_num}[/] → [cyan]{name}[/]({args_str})")
|
|
237
|
+
console.print(f" └ {result_str}")
|
|
238
|
+
|
|
239
|
+
orchestrator._step_callback = step_callback
|
|
240
|
+
|
|
231
241
|
# Run the orchestrator loop
|
|
232
242
|
console.print("[bold]--- Orchestrator running ---[/]\n")
|
|
233
243
|
result = orchestrator.run(task=task)
|
|
@@ -384,78 +394,68 @@ def pilot(
|
|
|
384
394
|
@app.command()
|
|
385
395
|
def exec(
|
|
386
396
|
task: Annotated[str, typer.Option("--task", "-t", help="Task to execute")],
|
|
387
|
-
adapter: Annotated[AdapterType, typer.Option("--adapter", "-a", help="Executor adapter")] = AdapterType.codex_mcp,
|
|
388
|
-
mode: Annotated[ModeType, typer.Option("--mode", "-m", help="Execution mode")] = ModeType.sync,
|
|
389
397
|
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
390
398
|
model: Annotated[Optional[str], typer.Option("--model", help="Model override")] = None,
|
|
399
|
+
wait: Annotated[bool, typer.Option("--wait", help="Wait for completion and show result")] = False,
|
|
391
400
|
):
|
|
392
401
|
"""
|
|
393
|
-
Run a single
|
|
402
|
+
Run a single Codex session directly (for testing).
|
|
394
403
|
|
|
395
|
-
|
|
404
|
+
Spawns a session using CodexSessionManager - same as interactive/pilot.
|
|
405
|
+
Web search is always enabled via .codex/config.toml (set up by `zwarm init`).
|
|
396
406
|
|
|
397
407
|
[bold]Examples:[/]
|
|
398
|
-
[dim]#
|
|
399
|
-
$ zwarm exec --task "What is 2+2?"
|
|
408
|
+
[dim]# Quick test[/]
|
|
409
|
+
$ zwarm exec --task "What is 2+2?" --wait
|
|
400
410
|
|
|
401
|
-
[dim]#
|
|
402
|
-
$ zwarm exec
|
|
411
|
+
[dim]# Run in background[/]
|
|
412
|
+
$ zwarm exec --task "Build feature"
|
|
403
413
|
|
|
404
|
-
[dim]#
|
|
405
|
-
$ zwarm exec --task "
|
|
414
|
+
[dim]# Web search is always available[/]
|
|
415
|
+
$ zwarm exec --task "Find latest FastAPI docs" --wait
|
|
406
416
|
"""
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
console.print(f"[bold]Running executor directly...[/]")
|
|
410
|
-
console.print(f" Adapter: [cyan]{adapter.value}[/]")
|
|
411
|
-
console.print(f" Mode: {mode.value}")
|
|
412
|
-
console.print(f" Task: {task}")
|
|
413
|
-
|
|
414
|
-
# Use isolated codex config if available
|
|
415
|
-
config_path = working_dir / ".zwarm" / "codex.toml"
|
|
416
|
-
if not config_path.exists():
|
|
417
|
-
config_path = None
|
|
418
|
-
|
|
419
|
-
try:
|
|
420
|
-
executor = get_adapter(adapter.value, model=model, config_path=config_path)
|
|
421
|
-
except ValueError as e:
|
|
422
|
-
console.print(f"[red]Error:[/] {e}")
|
|
423
|
-
sys.exit(1)
|
|
424
|
-
|
|
425
|
-
async def run():
|
|
426
|
-
try:
|
|
427
|
-
session = await executor.start_session(
|
|
428
|
-
task=task,
|
|
429
|
-
working_dir=working_dir.absolute(),
|
|
430
|
-
mode=mode.value,
|
|
431
|
-
model=model,
|
|
432
|
-
)
|
|
433
|
-
|
|
434
|
-
console.print(f"\n[green]Session started:[/] {session.id[:8]}")
|
|
435
|
-
|
|
436
|
-
if mode == ModeType.sync:
|
|
437
|
-
response = session.messages[-1].content if session.messages else "(no response)"
|
|
438
|
-
console.print(f"\n[bold]Response:[/]\n{response}")
|
|
417
|
+
import time
|
|
418
|
+
from zwarm.sessions import CodexSessionManager, SessionStatus
|
|
439
419
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if user_input.lower() == "exit" or not user_input:
|
|
445
|
-
break
|
|
420
|
+
console.print(f"[bold]Running Codex session...[/]")
|
|
421
|
+
console.print(f" Task: {task[:60]}{'...' if len(task) > 60 else ''}")
|
|
422
|
+
if model:
|
|
423
|
+
console.print(f" Model: {model}")
|
|
446
424
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
except KeyboardInterrupt:
|
|
450
|
-
break
|
|
451
|
-
else:
|
|
452
|
-
console.print("[dim]Async mode - session running in background.[/]")
|
|
453
|
-
console.print("Use 'zwarm status' to check progress.")
|
|
425
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
426
|
+
effective_model = model or "gpt-5.1-codex-mini"
|
|
454
427
|
|
|
455
|
-
|
|
456
|
-
|
|
428
|
+
session = manager.start_session(
|
|
429
|
+
task=task,
|
|
430
|
+
working_dir=working_dir.absolute(),
|
|
431
|
+
model=effective_model,
|
|
432
|
+
)
|
|
457
433
|
|
|
458
|
-
|
|
434
|
+
console.print(f"\n[green]Session started:[/] {session.short_id}")
|
|
435
|
+
|
|
436
|
+
if wait:
|
|
437
|
+
console.print("[dim]Waiting for completion...[/]")
|
|
438
|
+
while True:
|
|
439
|
+
time.sleep(2)
|
|
440
|
+
session = manager.get_session(session.id)
|
|
441
|
+
if session.status != SessionStatus.RUNNING:
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
if session.status == SessionStatus.COMPLETED:
|
|
445
|
+
console.print(f"\n[green]✓ Completed[/]")
|
|
446
|
+
# Show last assistant message
|
|
447
|
+
for msg in reversed(session.messages):
|
|
448
|
+
if msg.role == "assistant":
|
|
449
|
+
console.print(f"\n[bold]Response:[/]\n{msg.content}")
|
|
450
|
+
break
|
|
451
|
+
else:
|
|
452
|
+
console.print(f"\n[red]Status:[/] {session.status.value}")
|
|
453
|
+
if session.error:
|
|
454
|
+
console.print(f"[red]Error:[/] {session.error}")
|
|
455
|
+
else:
|
|
456
|
+
console.print("[dim]Running in background. Check with:[/]")
|
|
457
|
+
console.print(f" zwarm sessions")
|
|
458
|
+
console.print(f" zwarm session show {session.short_id}")
|
|
459
459
|
|
|
460
460
|
|
|
461
461
|
@app.command()
|
|
@@ -740,12 +740,12 @@ def init(
|
|
|
740
740
|
[bold]Creates:[/]
|
|
741
741
|
[cyan].zwarm/[/] State directory for sessions and events
|
|
742
742
|
[cyan].zwarm/config.toml[/] Runtime settings (weave, adapter, watchers)
|
|
743
|
-
[cyan].zwarm/codex.toml[/] Codex CLI settings (model,
|
|
743
|
+
[cyan].zwarm/codex.toml[/] Codex CLI settings (model, web search, etc.)
|
|
744
744
|
[cyan]zwarm.yaml[/] Project config (optional, with --with-project)
|
|
745
745
|
|
|
746
746
|
[bold]Configuration relationship:[/]
|
|
747
747
|
config.toml → Controls zwarm itself (tracing, which watchers run)
|
|
748
|
-
codex.toml →
|
|
748
|
+
codex.toml → Codex settings, parsed by zwarm and passed via -c overrides
|
|
749
749
|
zwarm.yaml → Project-specific context injected into orchestrator
|
|
750
750
|
|
|
751
751
|
[bold]Examples:[/]
|
|
@@ -939,7 +939,7 @@ def init(
|
|
|
939
939
|
# Explain config files
|
|
940
940
|
console.print("[bold]Configuration files:[/]")
|
|
941
941
|
console.print(" [cyan].zwarm/config.toml[/] - Runtime settings (Weave tracing, watchers)")
|
|
942
|
-
console.print(" [cyan].zwarm/codex.toml[/] - Codex CLI settings (model,
|
|
942
|
+
console.print(" [cyan].zwarm/codex.toml[/] - Codex CLI settings (model, web search, sandbox)")
|
|
943
943
|
if create_project_config:
|
|
944
944
|
console.print(" [cyan]zwarm.yaml[/] - Project context and constraints")
|
|
945
945
|
console.print()
|
|
@@ -982,6 +982,7 @@ def _generate_config_toml(
|
|
|
982
982
|
"[executor]",
|
|
983
983
|
f'adapter = "{adapter}"',
|
|
984
984
|
"# model = \"\" # Optional model override",
|
|
985
|
+
"# web_search = false # Enable web search for delegated sessions",
|
|
985
986
|
"",
|
|
986
987
|
"[watchers]",
|
|
987
988
|
f"enabled = {watchers}",
|
|
@@ -1006,22 +1007,35 @@ def _generate_codex_toml(
|
|
|
1006
1007
|
"""
|
|
1007
1008
|
Generate codex.toml for isolated codex configuration.
|
|
1008
1009
|
|
|
1009
|
-
This file is
|
|
1010
|
-
|
|
1010
|
+
This file is parsed by zwarm and settings are passed to codex via -c overrides.
|
|
1011
|
+
Each .zwarm directory has its own codex config, independent of ~/.codex/config.toml.
|
|
1011
1012
|
"""
|
|
1012
1013
|
lines = [
|
|
1013
1014
|
"# Codex configuration for zwarm",
|
|
1014
|
-
"#
|
|
1015
|
+
"# zwarm parses this file and passes settings to codex via -c overrides",
|
|
1016
|
+
"# Each .zwarm dir has its own config, independent of ~/.codex/config.toml",
|
|
1015
1017
|
"# Generated by 'zwarm init'",
|
|
1016
1018
|
"",
|
|
1017
1019
|
"# Model settings",
|
|
1018
1020
|
f'model = "{model}"',
|
|
1019
1021
|
f'model_reasoning_effort = "{reasoning_effort}" # low | medium | high',
|
|
1020
1022
|
"",
|
|
1021
|
-
"#
|
|
1022
|
-
"#
|
|
1023
|
+
"# DANGER MODE - bypasses all safety controls",
|
|
1024
|
+
"# Set to true to use --dangerously-bypass-approvals-and-sandbox",
|
|
1025
|
+
"full_danger = true",
|
|
1026
|
+
"",
|
|
1027
|
+
"# Web search - enables web_search tool for agents",
|
|
1028
|
+
"[features]",
|
|
1029
|
+
"web_search_request = true",
|
|
1023
1030
|
"",
|
|
1024
|
-
"#
|
|
1031
|
+
"# Sandbox settings - network access required for web search",
|
|
1032
|
+
"[sandbox_workspace_write]",
|
|
1033
|
+
"network_access = true",
|
|
1034
|
+
"",
|
|
1035
|
+
"# Approval policy - 'never' means no human approval needed",
|
|
1036
|
+
"# approval_policy = \"never\"",
|
|
1037
|
+
"",
|
|
1038
|
+
"# You can add any codex config key here",
|
|
1025
1039
|
"# See: https://github.com/openai/codex#configuration",
|
|
1026
1040
|
"",
|
|
1027
1041
|
]
|
|
@@ -1534,6 +1548,7 @@ def session_start(
|
|
|
1534
1548
|
Start a new Codex session in the background.
|
|
1535
1549
|
|
|
1536
1550
|
The session runs independently and you can check on it later.
|
|
1551
|
+
Web search is always enabled via .codex/config.toml (set up by `zwarm init`).
|
|
1537
1552
|
|
|
1538
1553
|
[bold]Examples:[/]
|
|
1539
1554
|
[dim]# Simple task[/]
|
|
@@ -1541,6 +1556,9 @@ def session_start(
|
|
|
1541
1556
|
|
|
1542
1557
|
[dim]# With specific model[/]
|
|
1543
1558
|
$ zwarm session start "Refactor the API" --model gpt-5.1-codex-max
|
|
1559
|
+
|
|
1560
|
+
[dim]# Web search is always available[/]
|
|
1561
|
+
$ zwarm session start "Research latest OAuth2 best practices"
|
|
1544
1562
|
"""
|
|
1545
1563
|
from zwarm.sessions import CodexSessionManager
|
|
1546
1564
|
|
zwarm/cli/pilot.py
CHANGED
|
@@ -186,10 +186,12 @@ def build_pilot_orchestrator(
|
|
|
186
186
|
lm_class = lm_map.get(lm_choice, GPT5LargeVerbose)
|
|
187
187
|
lm = lm_class()
|
|
188
188
|
|
|
189
|
-
# Load configuration
|
|
189
|
+
# Load configuration from working_dir (not cwd!)
|
|
190
|
+
# This ensures config.toml and .env are loaded from the project being worked on
|
|
190
191
|
config = load_config(
|
|
191
192
|
config_path=config_path,
|
|
192
193
|
overrides=overrides,
|
|
194
|
+
working_dir=working_dir,
|
|
193
195
|
)
|
|
194
196
|
|
|
195
197
|
# Resolve working directory
|
|
@@ -592,6 +594,18 @@ def execute_step_with_events(
|
|
|
592
594
|
"""
|
|
593
595
|
had_message = False
|
|
594
596
|
|
|
597
|
+
# Update environment with current progress before perceive
|
|
598
|
+
# This ensures the observation has fresh step/token counts
|
|
599
|
+
if hasattr(orchestrator, "env") and hasattr(orchestrator.env, "update_progress"):
|
|
600
|
+
total_tokens = getattr(orchestrator, "_total_tokens", 0)
|
|
601
|
+
executor_usage = orchestrator.get_executor_usage() if hasattr(orchestrator, "get_executor_usage") else {}
|
|
602
|
+
orchestrator.env.update_progress(
|
|
603
|
+
step_count=getattr(orchestrator, "_step_count", 0),
|
|
604
|
+
max_steps=getattr(orchestrator, "maxSteps", 50),
|
|
605
|
+
total_tokens=total_tokens,
|
|
606
|
+
executor_tokens=executor_usage.get("total_tokens", 0),
|
|
607
|
+
)
|
|
608
|
+
|
|
595
609
|
# Execute perceive (updates environment observation)
|
|
596
610
|
orchestrator.perceive()
|
|
597
611
|
|
|
@@ -647,7 +661,7 @@ def execute_step_with_events(
|
|
|
647
661
|
def run_until_response(
|
|
648
662
|
orchestrator: Any,
|
|
649
663
|
renderer: EventRenderer,
|
|
650
|
-
max_steps: int =
|
|
664
|
+
max_steps: int = 60,
|
|
651
665
|
) -> List[tuple]:
|
|
652
666
|
"""
|
|
653
667
|
Run the orchestrator until it produces a message response.
|
|
@@ -655,7 +669,7 @@ def run_until_response(
|
|
|
655
669
|
Keeps stepping while the agent only produces tool calls.
|
|
656
670
|
Stops when:
|
|
657
671
|
- Agent produces a text message (returns to user)
|
|
658
|
-
- Max steps reached
|
|
672
|
+
- Max steps reached (configurable via orchestrator.max_steps_per_turn)
|
|
659
673
|
- Stop condition triggered
|
|
660
674
|
|
|
661
675
|
This is wrapped as a weave.op to group all child calls per turn.
|
|
@@ -663,7 +677,7 @@ def run_until_response(
|
|
|
663
677
|
Args:
|
|
664
678
|
orchestrator: The orchestrator instance
|
|
665
679
|
renderer: Event renderer for output
|
|
666
|
-
max_steps: Safety limit on steps per turn
|
|
680
|
+
max_steps: Safety limit on steps per turn (default: 60)
|
|
667
681
|
|
|
668
682
|
Returns:
|
|
669
683
|
All tool results from the turn
|
|
@@ -701,6 +715,9 @@ def run_until_response(
|
|
|
701
715
|
if not results:
|
|
702
716
|
break
|
|
703
717
|
|
|
718
|
+
# Show session status at end of turn (if there are any sessions)
|
|
719
|
+
render_session_status(orchestrator, renderer)
|
|
720
|
+
|
|
704
721
|
return all_results
|
|
705
722
|
|
|
706
723
|
return _run_turn()
|
|
@@ -756,6 +773,38 @@ def get_sessions_snapshot(orchestrator: Any) -> Dict[str, Any]:
|
|
|
756
773
|
return {"sessions": []}
|
|
757
774
|
|
|
758
775
|
|
|
776
|
+
def render_session_status(orchestrator: Any, renderer: EventRenderer) -> None:
|
|
777
|
+
"""
|
|
778
|
+
Render a compact session status line if there are active sessions.
|
|
779
|
+
|
|
780
|
+
Shows: "Sessions: 2 running, 1 done, 0 failed"
|
|
781
|
+
Only displays if there are any sessions.
|
|
782
|
+
"""
|
|
783
|
+
if not hasattr(orchestrator, "_session_manager"):
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
sessions = orchestrator._session_manager.list_sessions()
|
|
787
|
+
if not sessions:
|
|
788
|
+
return
|
|
789
|
+
|
|
790
|
+
running = sum(1 for s in sessions if s.status.value == "running")
|
|
791
|
+
completed = sum(1 for s in sessions if s.status.value == "completed")
|
|
792
|
+
failed = sum(1 for s in sessions if s.status.value == "failed")
|
|
793
|
+
|
|
794
|
+
# Build status line with colors
|
|
795
|
+
parts = []
|
|
796
|
+
if running > 0:
|
|
797
|
+
parts.append(f"[cyan]{running} running[/]")
|
|
798
|
+
if completed > 0:
|
|
799
|
+
parts.append(f"[green]{completed} done[/]")
|
|
800
|
+
if failed > 0:
|
|
801
|
+
parts.append(f"[red]{failed} failed[/]")
|
|
802
|
+
|
|
803
|
+
if parts:
|
|
804
|
+
status_line = ", ".join(parts)
|
|
805
|
+
console.print(f"[dim]Sessions:[/] {status_line}")
|
|
806
|
+
|
|
807
|
+
|
|
759
808
|
def run_pilot(
|
|
760
809
|
orchestrator: Any,
|
|
761
810
|
*,
|
|
@@ -812,7 +861,8 @@ def _run_pilot_repl(
|
|
|
812
861
|
})
|
|
813
862
|
|
|
814
863
|
renderer.reset_turn()
|
|
815
|
-
|
|
864
|
+
max_steps = getattr(orchestrator.config.orchestrator, "max_steps_per_turn", 60)
|
|
865
|
+
results = run_until_response(orchestrator, renderer, max_steps=max_steps)
|
|
816
866
|
|
|
817
867
|
# Record checkpoint
|
|
818
868
|
state.record(
|
|
@@ -1101,8 +1151,9 @@ def _run_pilot_repl(
|
|
|
1101
1151
|
|
|
1102
1152
|
# Execute steps until agent responds with a message
|
|
1103
1153
|
renderer.reset_turn()
|
|
1154
|
+
max_steps = getattr(orchestrator.config.orchestrator, "max_steps_per_turn", 60)
|
|
1104
1155
|
try:
|
|
1105
|
-
results = run_until_response(orchestrator, renderer)
|
|
1156
|
+
results = run_until_response(orchestrator, renderer, max_steps=max_steps)
|
|
1106
1157
|
except Exception as e:
|
|
1107
1158
|
renderer.error(f"Step failed: {e}")
|
|
1108
1159
|
# Remove the user message on failure
|
zwarm/core/config.py
CHANGED
|
@@ -37,6 +37,7 @@ class ExecutorConfig:
|
|
|
37
37
|
sandbox: str = "workspace-write" # read-only | workspace-write | danger-full-access
|
|
38
38
|
timeout: int = 3600
|
|
39
39
|
reasoning_effort: str | None = "high" # low | medium | high (default to high for compatibility)
|
|
40
|
+
# Note: web_search is always enabled via .codex/config.toml (set up by `zwarm init`)
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
@dataclass
|
|
@@ -59,8 +60,8 @@ class OrchestratorConfig:
|
|
|
59
60
|
prompt: str | None = None # path to prompt yaml
|
|
60
61
|
tools: list[str] = field(default_factory=lambda: ["delegate", "converse", "check_session", "end_session", "bash"])
|
|
61
62
|
max_steps: int = 50
|
|
63
|
+
max_steps_per_turn: int = 60 # Max tool-call steps before returning to user (pilot mode)
|
|
62
64
|
parallel_delegations: int = 4
|
|
63
|
-
sync_first: bool = True # prefer sync mode by default
|
|
64
65
|
compaction: CompactionConfig = field(default_factory=CompactionConfig)
|
|
65
66
|
|
|
66
67
|
# Directory restrictions for agent delegations
|
|
@@ -172,8 +173,8 @@ class ZwarmConfig:
|
|
|
172
173
|
"prompt": self.orchestrator.prompt,
|
|
173
174
|
"tools": self.orchestrator.tools,
|
|
174
175
|
"max_steps": self.orchestrator.max_steps,
|
|
176
|
+
"max_steps_per_turn": self.orchestrator.max_steps_per_turn,
|
|
175
177
|
"parallel_delegations": self.orchestrator.parallel_delegations,
|
|
176
|
-
"sync_first": self.orchestrator.sync_first,
|
|
177
178
|
"compaction": {
|
|
178
179
|
"enabled": self.orchestrator.compaction.enabled,
|
|
179
180
|
"max_tokens": self.orchestrator.compaction.max_tokens,
|
|
@@ -195,15 +196,16 @@ class ZwarmConfig:
|
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
|
|
198
|
-
def load_env(path: Path | None = None) -> None:
|
|
199
|
+
def load_env(path: Path | None = None, base_dir: Path | None = None) -> None:
|
|
199
200
|
"""Load .env file if it exists."""
|
|
200
201
|
if path is None:
|
|
201
|
-
|
|
202
|
+
base = base_dir or Path.cwd()
|
|
203
|
+
path = base / ".env"
|
|
202
204
|
if path.exists():
|
|
203
205
|
load_dotenv(path)
|
|
204
206
|
|
|
205
207
|
|
|
206
|
-
def load_toml_config(path: Path | None = None) -> dict[str, Any]:
|
|
208
|
+
def load_toml_config(path: Path | None = None, base_dir: Path | None = None) -> dict[str, Any]:
|
|
207
209
|
"""
|
|
208
210
|
Load config.toml file.
|
|
209
211
|
|
|
@@ -211,11 +213,16 @@ def load_toml_config(path: Path | None = None) -> dict[str, Any]:
|
|
|
211
213
|
1. Explicit path (if provided)
|
|
212
214
|
2. .zwarm/config.toml (new standard location)
|
|
213
215
|
3. config.toml (legacy location for backwards compat)
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
path: Explicit path to config.toml
|
|
219
|
+
base_dir: Base directory to search in (defaults to cwd)
|
|
214
220
|
"""
|
|
215
221
|
if path is None:
|
|
222
|
+
base = base_dir or Path.cwd()
|
|
216
223
|
# Try new location first
|
|
217
|
-
new_path =
|
|
218
|
-
legacy_path =
|
|
224
|
+
new_path = base / ".zwarm" / "config.toml"
|
|
225
|
+
legacy_path = base / "config.toml"
|
|
219
226
|
if new_path.exists():
|
|
220
227
|
path = new_path
|
|
221
228
|
elif legacy_path.exists():
|
|
@@ -306,6 +313,7 @@ def load_config(
|
|
|
306
313
|
toml_path: Path | None = None,
|
|
307
314
|
env_path: Path | None = None,
|
|
308
315
|
overrides: list[str] | None = None,
|
|
316
|
+
working_dir: Path | None = None,
|
|
309
317
|
) -> ZwarmConfig:
|
|
310
318
|
"""
|
|
311
319
|
Load configuration with full precedence chain:
|
|
@@ -314,15 +322,24 @@ def load_config(
|
|
|
314
322
|
3. YAML config file (if provided)
|
|
315
323
|
4. CLI overrides (--set key=value)
|
|
316
324
|
5. Environment variables (for secrets)
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
config_path: Path to YAML config file
|
|
328
|
+
toml_path: Explicit path to config.toml
|
|
329
|
+
env_path: Explicit path to .env file
|
|
330
|
+
overrides: CLI overrides (--set key=value)
|
|
331
|
+
working_dir: Working directory to search for config files (defaults to cwd).
|
|
332
|
+
This is important when using --working-dir flag to ensure
|
|
333
|
+
config is loaded from the project directory, not invoke directory.
|
|
317
334
|
"""
|
|
318
335
|
# Load .env first (for secrets)
|
|
319
|
-
load_env(env_path)
|
|
336
|
+
load_env(env_path, base_dir=working_dir)
|
|
320
337
|
|
|
321
338
|
# Start with defaults
|
|
322
339
|
config_dict: dict[str, Any] = {}
|
|
323
340
|
|
|
324
341
|
# Layer in config.toml
|
|
325
|
-
toml_config = load_toml_config(toml_path)
|
|
342
|
+
toml_config = load_toml_config(toml_path, base_dir=working_dir)
|
|
326
343
|
if toml_config:
|
|
327
344
|
config_dict = deep_merge(config_dict, toml_config)
|
|
328
345
|
|
zwarm/core/test_config.py
CHANGED
|
@@ -20,7 +20,6 @@ def test_default_config():
|
|
|
20
20
|
assert config.executor.adapter == "codex_mcp"
|
|
21
21
|
assert config.executor.sandbox == "workspace-write"
|
|
22
22
|
assert config.orchestrator.lm == "gpt-5-mini"
|
|
23
|
-
assert config.orchestrator.sync_first is True
|
|
24
23
|
assert config.state_dir == ".zwarm"
|
|
25
24
|
|
|
26
25
|
|
|
@@ -68,8 +67,8 @@ def test_apply_overrides():
|
|
|
68
67
|
assert result["executor"]["adapter"] == "claude_code"
|
|
69
68
|
|
|
70
69
|
# Override with boolean
|
|
71
|
-
result = apply_overrides(config, ["
|
|
72
|
-
assert result["
|
|
70
|
+
result = apply_overrides(config, ["executor.web_search=true"])
|
|
71
|
+
assert result["executor"]["web_search"] is True
|
|
73
72
|
|
|
74
73
|
# Create new nested path
|
|
75
74
|
result = apply_overrides(config, ["weave.project=my-project"])
|
zwarm/orchestrator.py
CHANGED
|
@@ -23,7 +23,6 @@ from wbal.helper import TOOL_CALL_TYPE, format_openai_tool_response
|
|
|
23
23
|
from wbal.lm import LM as wbalLMGeneric
|
|
24
24
|
from wbal.lm import GPT5LargeVerbose
|
|
25
25
|
|
|
26
|
-
from zwarm.adapters import ExecutorAdapter, get_adapter
|
|
27
26
|
from zwarm.core.compact import compact_messages, should_compact
|
|
28
27
|
from zwarm.core.config import ZwarmConfig, load_config
|
|
29
28
|
from zwarm.core.environment import OrchestratorEnv
|
|
@@ -72,7 +71,6 @@ class Orchestrator(YamlAgent):
|
|
|
72
71
|
# State management
|
|
73
72
|
_state: StateManager = PrivateAttr()
|
|
74
73
|
_sessions: dict[str, ConversationSession] = PrivateAttr(default_factory=dict)
|
|
75
|
-
_adapters: dict[str, ExecutorAdapter] = PrivateAttr(default_factory=dict)
|
|
76
74
|
_watcher_manager: WatcherManager | None = PrivateAttr(default=None)
|
|
77
75
|
_resumed: bool = PrivateAttr(default=False)
|
|
78
76
|
_total_tokens: int = PrivateAttr(default=0) # Cumulative orchestrator tokens
|
|
@@ -83,9 +81,11 @@ class Orchestrator(YamlAgent):
|
|
|
83
81
|
"total_tokens": 0,
|
|
84
82
|
}
|
|
85
83
|
)
|
|
84
|
+
# Callback for step progress (used by CLI to print tool calls)
|
|
85
|
+
_step_callback: Callable[[int, list[tuple[dict[str, Any], Any]]], None] | None = PrivateAttr(default=None)
|
|
86
86
|
|
|
87
87
|
def model_post_init(self, __context: Any) -> None:
|
|
88
|
-
"""Initialize state
|
|
88
|
+
"""Initialize state after model creation."""
|
|
89
89
|
super().model_post_init(__context)
|
|
90
90
|
|
|
91
91
|
# Initialize state manager with instance isolation
|
|
@@ -151,40 +151,9 @@ class Orchestrator(YamlAgent):
|
|
|
151
151
|
"""Access state manager."""
|
|
152
152
|
return self._state
|
|
153
153
|
|
|
154
|
-
def _get_adapter(self, name: str) -> ExecutorAdapter:
|
|
155
|
-
"""Get or create an adapter by name using the adapter registry."""
|
|
156
|
-
if name not in self._adapters:
|
|
157
|
-
# Get model from config (adapters have their own defaults if None)
|
|
158
|
-
model = self.config.executor.model
|
|
159
|
-
|
|
160
|
-
# Use isolated codex config if available
|
|
161
|
-
config_path = self.working_dir / self.config.state_dir / "codex.toml"
|
|
162
|
-
if not config_path.exists():
|
|
163
|
-
config_path = None # Fallback to adapter defaults
|
|
164
|
-
|
|
165
|
-
self._adapters[name] = get_adapter(
|
|
166
|
-
name, model=model, config_path=config_path
|
|
167
|
-
)
|
|
168
|
-
return self._adapters[name]
|
|
169
|
-
|
|
170
154
|
def get_executor_usage(self) -> dict[str, int]:
|
|
171
|
-
"""Get aggregated token usage
|
|
172
|
-
|
|
173
|
-
"input_tokens": 0,
|
|
174
|
-
"output_tokens": 0,
|
|
175
|
-
"total_tokens": 0,
|
|
176
|
-
}
|
|
177
|
-
for adapter in self._adapters.values():
|
|
178
|
-
if hasattr(adapter, "total_usage"):
|
|
179
|
-
usage = adapter.total_usage
|
|
180
|
-
for key in total:
|
|
181
|
-
total[key] += usage.get(key, 0)
|
|
182
|
-
return total
|
|
183
|
-
|
|
184
|
-
@property
|
|
185
|
-
def executor_usage(self) -> dict[str, int]:
|
|
186
|
-
"""Aggregated executor token usage (for Weave tracking)."""
|
|
187
|
-
return self.get_executor_usage()
|
|
155
|
+
"""Get aggregated token usage from executor sessions."""
|
|
156
|
+
return self._executor_usage
|
|
188
157
|
|
|
189
158
|
def save_state(self) -> None:
|
|
190
159
|
"""Save orchestrator state for resume."""
|
|
@@ -587,7 +556,11 @@ Review what was accomplished in the previous session and delegate new tasks as n
|
|
|
587
556
|
}
|
|
588
557
|
# NUDGE and CONTINUE just continue
|
|
589
558
|
|
|
590
|
-
self.step()
|
|
559
|
+
tool_results = self.step()
|
|
560
|
+
|
|
561
|
+
# Call step callback if registered (for CLI progress display)
|
|
562
|
+
if self._step_callback:
|
|
563
|
+
self._step_callback(self._step_count, tool_results)
|
|
591
564
|
|
|
592
565
|
if self.stopCondition:
|
|
593
566
|
break
|
|
@@ -599,8 +572,7 @@ Review what was accomplished in the previous session and delegate new tasks as n
|
|
|
599
572
|
|
|
600
573
|
async def cleanup(self) -> None:
|
|
601
574
|
"""Clean up resources."""
|
|
602
|
-
|
|
603
|
-
await adapter.cleanup()
|
|
575
|
+
pass # Session cleanup handled by CodexSessionManager
|
|
604
576
|
|
|
605
577
|
|
|
606
578
|
def build_orchestrator(
|
|
@@ -631,15 +603,17 @@ def build_orchestrator(
|
|
|
631
603
|
"""
|
|
632
604
|
from uuid import uuid4
|
|
633
605
|
|
|
634
|
-
#
|
|
606
|
+
# Resolve working directory first (needed for config loading)
|
|
607
|
+
working_dir = working_dir or Path.cwd()
|
|
608
|
+
|
|
609
|
+
# Load configuration from working_dir (not cwd!)
|
|
610
|
+
# This ensures config.toml and .env are loaded from the project being worked on
|
|
635
611
|
config = load_config(
|
|
636
612
|
config_path=config_path,
|
|
637
613
|
overrides=overrides,
|
|
614
|
+
working_dir=working_dir,
|
|
638
615
|
)
|
|
639
616
|
|
|
640
|
-
# Resolve working directory
|
|
641
|
-
working_dir = working_dir or Path.cwd()
|
|
642
|
-
|
|
643
617
|
# Generate instance ID if not provided (enables isolation by default for new runs)
|
|
644
618
|
# For resume, instance_id should be provided explicitly
|
|
645
619
|
if instance_id is None and not resume:
|