plato-sdk-v2 2.3.3__py3-none-any.whl → 2.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,788 @@
1
+ """Plato Chronos CLI - Launch and manage Chronos jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from plato.v1.cli.utils import console
17
+
18
+ chronos_app = typer.Typer(help="Chronos job management commands.")
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @chronos_app.command()
23
+ def launch(
24
+ config: Path = typer.Argument(
25
+ ...,
26
+ help="Path to job config JSON file",
27
+ exists=True,
28
+ readable=True,
29
+ ),
30
+ chronos_url: str = typer.Option(
31
+ None,
32
+ "--url",
33
+ "-u",
34
+ envvar="CHRONOS_URL",
35
+ help="Chronos API URL (default: https://chronos.plato.so)",
36
+ ),
37
+ api_key: str = typer.Option(
38
+ None,
39
+ "--api-key",
40
+ "-k",
41
+ envvar="PLATO_API_KEY",
42
+ help="Plato API key for authentication",
43
+ ),
44
+ wait: bool = typer.Option(
45
+ False,
46
+ "--wait",
47
+ "-w",
48
+ help="Wait for job completion and stream logs",
49
+ ),
50
+ ):
51
+ """
52
+ Launch a Chronos job from a config file.
53
+
54
+ The config file should be a JSON file with the following structure:
55
+
56
+ \b
57
+ {
58
+ "world_package": "plato-world-structured-execution",
59
+ "world_version": "0.1.13", // optional, uses latest if not specified
60
+ "world_config": { ... },
61
+ "agent_configs": {
62
+ "skill_runner": {
63
+ "agent_id": "claude-code:2.1.5",
64
+ "config": { ... }
65
+ }
66
+ },
67
+ "secret_ids": [1, 2, 3] // IDs of secrets from Chronos
68
+ }
69
+
70
+ Examples:
71
+ plato chronos launch config.json
72
+ plato chronos launch config.json --wait
73
+ plato chronos launch config.json --url https://chronos.example.com
74
+
75
+ Config format (simplified - agents/secrets embedded in world_config):
76
+ {
77
+ "world_package": "plato-world-structured-execution",
78
+ "world_version": "latest", // optional
79
+ "world_config": {
80
+ "skill_runner": {
81
+ "image": "claude-code:2.1.5",
82
+ "config": { ... }
83
+ },
84
+ "plato_api_key": "pk_xxx", // secrets embedded
85
+ ...
86
+ },
87
+ "runtime_artifact_id": "..." // optional
88
+ }
89
+ """
90
+ import httpx
91
+
92
+ # Set defaults
93
+ if not chronos_url:
94
+ chronos_url = "https://chronos.plato.so"
95
+
96
+ if not api_key:
97
+ console.print("[red]❌ No API key provided[/red]")
98
+ console.print("Set PLATO_API_KEY environment variable or use --api-key")
99
+ raise typer.Exit(1)
100
+
101
+ # Load config
102
+ try:
103
+ with open(config) as f:
104
+ job_config = json.load(f)
105
+ except json.JSONDecodeError as e:
106
+ console.print(f"[red]❌ Invalid JSON in config file: {e}[/red]")
107
+ raise typer.Exit(1)
108
+
109
+ # Validate required fields
110
+ if "world_package" not in job_config:
111
+ console.print("[red]❌ Missing required field: world_package[/red]")
112
+ raise typer.Exit(1)
113
+
114
+ # Build request - simplified format (agents/secrets in world_config)
115
+ request_body = {
116
+ "world_package": job_config["world_package"],
117
+ "world_version": job_config.get("world_version"),
118
+ "world_config": job_config.get("world_config", {}),
119
+ "runtime_artifact_id": job_config.get("runtime_artifact_id"),
120
+ }
121
+
122
+ console.print("[blue]🚀 Launching job...[/blue]")
123
+ console.print(f" World: {request_body['world_package']}")
124
+ if request_body.get("world_version"):
125
+ console.print(f" Version: {request_body['world_version']}")
126
+
127
+ try:
128
+ with httpx.Client(timeout=60) as client:
129
+ response = client.post(
130
+ f"{chronos_url.rstrip('/')}/api/jobs/launch",
131
+ json=request_body,
132
+ headers={"X-API-Key": api_key},
133
+ )
134
+ response.raise_for_status()
135
+ result = response.json()
136
+
137
+ console.print("\n[green]✅ Job launched successfully![/green]")
138
+ console.print(f" Session ID: {result['session_id']}")
139
+ console.print(f" Plato Session: {result.get('plato_session_id', 'N/A')}")
140
+ console.print(f" Status: {result['status']}")
141
+ console.print(f"\n[dim]View at: {chronos_url}/sessions/{result['session_id']}[/dim]")
142
+
143
+ if wait:
144
+ console.print("\n[yellow]--wait not yet implemented[/yellow]")
145
+
146
+ except Exception as e:
147
+ console.print(f"[red]❌ Failed to launch job: {e}[/red]")
148
+ raise typer.Exit(1)
149
+
150
+
151
+ @chronos_app.command()
152
+ def example(
153
+ world: str = typer.Argument(
154
+ "structured-execution",
155
+ help="World to generate example config for",
156
+ ),
157
+ output: Path = typer.Option(
158
+ None,
159
+ "--output",
160
+ "-o",
161
+ help="Output file path (prints to stdout if not specified)",
162
+ ),
163
+ ):
164
+ """
165
+ Generate an example job config file.
166
+
167
+ Examples:
168
+ plato chronos example
169
+ plato chronos example structured-execution -o config.json
170
+ """
171
+ examples = {
172
+ "structured-execution": {
173
+ "world_package": "plato-world-structured-execution",
174
+ "world_version": "latest",
175
+ "world_config": {
176
+ "sim_name": "my-sim",
177
+ "github_url": "https://github.com/example/repo",
178
+ "max_attempts": 3,
179
+ "use_backtrack": True,
180
+ "skill_runner": {
181
+ "image": "claude-code:2.1.5",
182
+ "config": {"model_name": "anthropic/claude-sonnet-4-20250514", "max_turns": 100},
183
+ },
184
+ "plato_api_key": "pk_xxx",
185
+ "anthropic_api_key": "sk-ant-xxx",
186
+ },
187
+ "_comment": "Agents and secrets are embedded directly in world_config",
188
+ },
189
+ "code-world": {
190
+ "world_package": "plato-world-code",
191
+ "world_config": {
192
+ "task": "Fix the bug in src/main.py",
193
+ "repo_url": "https://github.com/example/repo",
194
+ "coder": {
195
+ "image": "claude-code:latest",
196
+ "config": {"model_name": "anthropic/claude-sonnet-4-20250514"},
197
+ },
198
+ },
199
+ "_comment": "world_version is optional - uses latest if not specified",
200
+ },
201
+ }
202
+
203
+ if world not in examples:
204
+ console.print(f"[red]❌ Unknown world: {world}[/red]")
205
+ console.print(f"Available examples: {list(examples.keys())}")
206
+ raise typer.Exit(1)
207
+
208
+ example_config = examples[world]
209
+ json_output = json.dumps(example_config, indent=2)
210
+
211
+ if output:
212
+ with open(output, "w") as f:
213
+ f.write(json_output)
214
+ console.print(f"[green]✅ Example config written to {output}[/green]")
215
+ else:
216
+ console.print(json_output)
217
+
218
+
219
+ def _get_world_runner_dockerfile() -> Path:
220
+ """Get the path to the world runner Dockerfile template."""
221
+ return Path(__file__).parent / "templates" / "world-runner.Dockerfile"
222
+
223
+
224
+ def _build_world_runner_image(platform_override: str | None = None) -> str:
225
+ """Build the world runner Docker image if needed."""
226
+ image_tag = "plato-world-runner:latest"
227
+ dockerfile_path = _get_world_runner_dockerfile()
228
+
229
+ if not dockerfile_path.exists():
230
+ raise FileNotFoundError(f"World runner Dockerfile not found: {dockerfile_path}")
231
+
232
+ docker_platform = _get_docker_platform(platform_override)
233
+
234
+ # Check if image exists
235
+ result = subprocess.run(
236
+ ["docker", "images", "-q", image_tag],
237
+ capture_output=True,
238
+ text=True,
239
+ )
240
+
241
+ if result.stdout.strip():
242
+ # Image exists
243
+ return image_tag
244
+
245
+ console.print("[blue]Building world runner image...[/blue]")
246
+
247
+ cmd = [
248
+ "docker",
249
+ "build",
250
+ "--platform",
251
+ docker_platform,
252
+ "-t",
253
+ image_tag,
254
+ "-f",
255
+ str(dockerfile_path),
256
+ str(dockerfile_path.parent),
257
+ ]
258
+
259
+ result = subprocess.run(cmd)
260
+ if result.returncode != 0:
261
+ raise RuntimeError("Failed to build world runner image")
262
+
263
+ console.print(f"[green]✅ Built {image_tag}[/green]")
264
+ return image_tag
265
+
266
+
267
+ def _get_docker_platform(override: str | None = None) -> str:
268
+ """Get the appropriate Docker platform for the current system."""
269
+ if override:
270
+ return override
271
+
272
+ import platform as plat
273
+
274
+ system = plat.system()
275
+ machine = plat.machine().lower()
276
+
277
+ if system == "Darwin" and machine in ("arm64", "aarch64"):
278
+ return "linux/arm64"
279
+ elif system == "Linux" and machine in ("arm64", "aarch64"):
280
+ return "linux/arm64"
281
+ else:
282
+ return "linux/amd64"
283
+
284
+
285
+ def _get_docker_host_ip() -> str:
286
+ """Get the Docker host IP address accessible from containers."""
287
+ try:
288
+ result = subprocess.run(
289
+ ["docker", "network", "inspect", "bridge", "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}"],
290
+ capture_output=True,
291
+ text=True,
292
+ )
293
+ if result.returncode == 0 and result.stdout.strip():
294
+ return result.stdout.strip()
295
+ except Exception:
296
+ pass
297
+ # Fallback to common Docker gateway IP
298
+ return "172.17.0.1"
299
+
300
+
301
+ def _build_agent_image(
302
+ agent_name: str,
303
+ agents_dir: Path,
304
+ platform_override: str | None = None,
305
+ ) -> bool:
306
+ """Build a local agent Docker image."""
307
+ agents_dir = agents_dir.expanduser().resolve()
308
+ agent_path = agents_dir / agent_name
309
+ dockerfile_path = agent_path / "Dockerfile"
310
+
311
+ if not dockerfile_path.exists():
312
+ logger.warning(f"No Dockerfile found for agent '{agent_name}' at {dockerfile_path}")
313
+ return False
314
+
315
+ image_tag = f"{agent_name}:latest"
316
+ docker_platform = _get_docker_platform(platform_override)
317
+
318
+ # Determine build context - check if we're in plato-client structure
319
+ plato_client_root = agents_dir.parent if agents_dir.name == "agents" else None
320
+
321
+ if plato_client_root and (plato_client_root / "python-sdk").exists():
322
+ build_context = str(plato_client_root)
323
+ target = "dev"
324
+ console.print(f"[blue]Building {image_tag} (dev mode from {build_context})...[/blue]")
325
+ else:
326
+ build_context = str(agent_path)
327
+ target = "prod"
328
+ console.print(f"[blue]Building {image_tag} (prod mode from {build_context})...[/blue]")
329
+
330
+ console.print(f"[dim]Platform: {docker_platform}[/dim]")
331
+
332
+ cmd = [
333
+ "docker",
334
+ "build",
335
+ "--platform",
336
+ docker_platform,
337
+ "--build-arg",
338
+ f"PLATFORM={docker_platform}",
339
+ "--target",
340
+ target,
341
+ "-t",
342
+ image_tag,
343
+ "-f",
344
+ str(dockerfile_path),
345
+ build_context,
346
+ ]
347
+
348
+ result = subprocess.run(cmd)
349
+ if result.returncode != 0:
350
+ console.print(f"[red]❌ Failed to build {image_tag}[/red]")
351
+ return False
352
+
353
+ console.print(f"[green]✅ Built {image_tag}[/green]")
354
+ return True
355
+
356
+
357
+ def _extract_agent_images_from_config(config_data: dict) -> list[str]:
358
+ """Extract local agent image names from config data."""
359
+ images = []
360
+
361
+ # Check agents section
362
+ agents = config_data.get("agents", {})
363
+ for agent_config in agents.values():
364
+ if isinstance(agent_config, dict):
365
+ image = agent_config.get("image", "")
366
+ # Only include local images (no registry prefix)
367
+ if image and "/" not in image.split(":")[0]:
368
+ name = image.split(":")[0]
369
+ if name not in images:
370
+ images.append(name)
371
+
372
+ # Also check direct coder/verifier fields
373
+ for field in ["coder", "verifier", "skill_runner"]:
374
+ agent_config = config_data.get(field, {})
375
+ if isinstance(agent_config, dict):
376
+ image = agent_config.get("image", "")
377
+ if image and "/" not in image.split(":")[0]:
378
+ name = image.split(":")[0]
379
+ if name not in images:
380
+ images.append(name)
381
+
382
+ return images
383
+
384
+
385
+ async def _create_chronos_session(
386
+ chronos_url: str,
387
+ api_key: str,
388
+ world_name: str,
389
+ world_config: dict,
390
+ plato_session_id: str | None = None,
391
+ ) -> dict:
392
+ """Create a session in Chronos."""
393
+ import httpx
394
+
395
+ url = f"{chronos_url.rstrip('/')}/api/sessions"
396
+
397
+ async with httpx.AsyncClient(timeout=30.0) as client:
398
+ response = await client.post(
399
+ url,
400
+ json={
401
+ "world_name": world_name,
402
+ "world_config": world_config,
403
+ "plato_session_id": plato_session_id,
404
+ },
405
+ headers={"x-api-key": api_key},
406
+ )
407
+ response.raise_for_status()
408
+ return response.json()
409
+
410
+
411
+ async def _close_chronos_session(
412
+ chronos_url: str,
413
+ api_key: str,
414
+ session_id: str,
415
+ ) -> None:
416
+ """Close a Chronos session."""
417
+ import httpx
418
+
419
+ url = f"{chronos_url.rstrip('/')}/api/sessions/{session_id}/close"
420
+
421
+ try:
422
+ async with httpx.AsyncClient(timeout=30.0) as client:
423
+ response = await client.post(url, headers={"x-api-key": api_key})
424
+ response.raise_for_status()
425
+ logger.info(f"Closed Chronos session: {session_id}")
426
+ except Exception as e:
427
+ logger.warning(f"Failed to close Chronos session: {e}")
428
+
429
+
430
+ async def _complete_chronos_session(
431
+ chronos_url: str,
432
+ api_key: str,
433
+ session_id: str,
434
+ status: str,
435
+ exit_code: int | None = None,
436
+ error_message: str | None = None,
437
+ ) -> None:
438
+ """Complete a Chronos session with final status."""
439
+ import httpx
440
+
441
+ url = f"{chronos_url.rstrip('/')}/api/sessions/{session_id}/complete"
442
+
443
+ payload = {"status": status}
444
+ if exit_code is not None:
445
+ payload["exit_code"] = exit_code
446
+ if error_message:
447
+ payload["error_message"] = error_message
448
+
449
+ try:
450
+ async with httpx.AsyncClient(timeout=30.0) as client:
451
+ response = await client.post(url, headers={"x-api-key": api_key}, json=payload)
452
+ response.raise_for_status()
453
+ logger.info(f"Completed Chronos session: {session_id} with status: {status}")
454
+ except Exception as e:
455
+ logger.warning(f"Failed to complete Chronos session: {e}")
456
+
457
+
458
+ async def _run_dev_impl(
459
+ world_dir: Path,
460
+ config_path: Path,
461
+ agents_dir: Path | None = None,
462
+ platform_override: str | None = None,
463
+ env_timeout: int = 7200,
464
+ ) -> None:
465
+ """Run a world locally in a Docker container.
466
+
467
+ This:
468
+ 1. Builds local agent images if --agents-dir is provided
469
+ 2. Creates Plato environments
470
+ 3. Creates Chronos session for OTel traces
471
+ 4. Runs the world in a Docker container with docker.sock mounted
472
+ """
473
+ from plato._generated.models import Envs
474
+ from plato.v2 import AsyncPlato
475
+ from plato.worlds.config import EnvConfig
476
+
477
+ # Get required env vars
478
+ chronos_url = os.environ.get("CHRONOS_URL", "https://chronos.plato.so")
479
+ api_key = os.environ.get("PLATO_API_KEY")
480
+
481
+ if not api_key:
482
+ raise ValueError("PLATO_API_KEY environment variable is required")
483
+
484
+ # Resolve paths
485
+ world_dir = world_dir.expanduser().resolve()
486
+ config_path = config_path.expanduser().resolve()
487
+
488
+ # Load config
489
+ with open(config_path) as f:
490
+ raw_config = json.load(f)
491
+
492
+ # Handle Chronos-style config format
493
+ if "world_config" in raw_config:
494
+ config_data = raw_config["world_config"].copy()
495
+ top_level_secrets = raw_config.get("secrets", {})
496
+ if top_level_secrets:
497
+ config_data.setdefault("secrets", {})
498
+ config_data["secrets"].update(top_level_secrets)
499
+ else:
500
+ config_data = raw_config.copy()
501
+
502
+ # Determine world name from config
503
+ world_package = raw_config.get("world_package", "")
504
+ if world_package.startswith("plato-world-"):
505
+ world_name = world_package[len("plato-world-") :]
506
+ else:
507
+ world_name = world_package or "unknown"
508
+
509
+ # Build local agent images if agents_dir is provided
510
+ if agents_dir:
511
+ agents_dir = agents_dir.expanduser().resolve()
512
+ agent_images = _extract_agent_images_from_config(config_data)
513
+ if agent_images:
514
+ console.print(f"[blue]Building agent images: {agent_images}[/blue]")
515
+ for agent_name in agent_images:
516
+ success = _build_agent_image(agent_name, agents_dir, platform_override)
517
+ if not success:
518
+ raise RuntimeError(f"Failed to build agent image: {agent_name}")
519
+
520
+ # Import world module to get config class for environment detection
521
+ # We need to dynamically load the world from world_dir
522
+ import sys
523
+
524
+ sys.path.insert(0, str(world_dir / "src"))
525
+
526
+ try:
527
+ # Try to import the world module
528
+
529
+ world_module_path = list((world_dir / "src").glob("*_world/*.py"))
530
+ if not world_module_path:
531
+ world_module_path = list((world_dir / "src").glob("*/__init__.py"))
532
+
533
+ env_configs: list[EnvConfig] = []
534
+
535
+ # Try to extract env configs from world config
536
+ if "envs" in config_data:
537
+ for env_cfg in config_data["envs"]:
538
+ env_configs.append(Envs.model_validate(env_cfg).root)
539
+ finally:
540
+ if str(world_dir / "src") in sys.path:
541
+ sys.path.remove(str(world_dir / "src"))
542
+
543
+ # Create Plato client and session
544
+ plato = AsyncPlato()
545
+ session = None
546
+ plato_session_id: str | None = None
547
+ chronos_session_id: str | None = None
548
+
549
+ try:
550
+ if env_configs:
551
+ console.print(f"[blue]Creating {len(env_configs)} Plato environments...[/blue]")
552
+ session = await plato.sessions.create(envs=env_configs, timeout=env_timeout)
553
+ plato_session_id = session.session_id
554
+ console.print(f"[green]✅ Created Plato session: {plato_session_id}[/green]")
555
+
556
+ # Add session to config
557
+ config_data["plato_session"] = session.dump()
558
+
559
+ # Create Chronos session
560
+ console.print("[blue]Creating Chronos session...[/blue]")
561
+ chronos_session = await _create_chronos_session(
562
+ chronos_url=chronos_url,
563
+ api_key=api_key,
564
+ world_name=world_name,
565
+ world_config=config_data,
566
+ plato_session_id=plato_session_id,
567
+ )
568
+ chronos_session_id = chronos_session["public_id"]
569
+ console.print(f"[green]✅ Created Chronos session: {chronos_session_id}[/green]")
570
+ console.print(f"[dim]View at: {chronos_url}/sessions/{chronos_session_id}[/dim]")
571
+
572
+ # Add session info to config
573
+ config_data["session_id"] = chronos_session_id
574
+ # Use otel_url from backend response (uses tunnel if available), or construct it
575
+ otel_url = chronos_session.get("otel_url") or f"{chronos_url.rstrip('/')}/api/otel"
576
+ # For Docker containers, replace localhost with Docker gateway IP
577
+ if "localhost" in otel_url or "127.0.0.1" in otel_url:
578
+ docker_host_ip = _get_docker_host_ip()
579
+ otel_url = otel_url.replace("localhost", docker_host_ip).replace("127.0.0.1", docker_host_ip)
580
+ config_data["otel_url"] = otel_url
581
+ config_data["upload_url"] = chronos_session.get("upload_url", "")
582
+
583
+ # Write updated config to temp file
584
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
585
+ # Write in direct format (not Chronos format) for the world runner
586
+ json.dump(config_data, f)
587
+ container_config_path = f.name
588
+
589
+ # Create shared workspace volume for DIND compatibility
590
+ import uuid as uuid_mod
591
+
592
+ workspace_volume = f"plato-workspace-{uuid_mod.uuid4().hex[:8]}"
593
+ subprocess.run(
594
+ ["docker", "volume", "create", workspace_volume],
595
+ capture_output=True,
596
+ )
597
+ console.print(f"[blue]Created workspace volume: {workspace_volume}[/blue]")
598
+
599
+ try:
600
+ # Run world in Docker container
601
+ console.print("[blue]Starting world in Docker container...[/blue]")
602
+
603
+ docker_platform = _get_docker_platform(platform_override)
604
+
605
+ # Build world runner image if needed
606
+ world_runner_image = _build_world_runner_image(platform_override)
607
+
608
+ # Find python-sdk relative to world_dir (assumes plato-client structure)
609
+ # world_dir: plato-client/worlds/structured-execution
610
+ # python_sdk: plato-client/python-sdk
611
+ python_sdk_dir = world_dir.parent.parent / "python-sdk"
612
+
613
+ # For Docker containers, replace localhost with Docker gateway IP
614
+ docker_chronos_url = chronos_url
615
+ if "localhost" in docker_chronos_url or "127.0.0.1" in docker_chronos_url:
616
+ docker_host_ip = _get_docker_host_ip()
617
+ docker_chronos_url = docker_chronos_url.replace("localhost", docker_host_ip).replace(
618
+ "127.0.0.1", docker_host_ip
619
+ )
620
+
621
+ docker_cmd = [
622
+ "docker",
623
+ "run",
624
+ "--rm",
625
+ "--platform",
626
+ docker_platform,
627
+ "--privileged",
628
+ "-v",
629
+ "/var/run/docker.sock:/var/run/docker.sock",
630
+ "-v",
631
+ f"{world_dir}:/world:ro",
632
+ "-v",
633
+ f"{python_sdk_dir}:/python-sdk:ro", # Mount local SDK for dev
634
+ "-v",
635
+ f"{container_config_path}:/config.json:ro",
636
+ "-v",
637
+ f"{workspace_volume}:/tmp/workspace", # Shared workspace volume
638
+ "-e",
639
+ f"WORLD_NAME={world_name}",
640
+ "-e",
641
+ f"WORKSPACE_VOLUME={workspace_volume}", # Pass volume name for run_agent
642
+ "-e",
643
+ f"CHRONOS_URL={docker_chronos_url}",
644
+ "-e",
645
+ f"PLATO_API_KEY={api_key}",
646
+ "-e",
647
+ f"SESSION_ID={chronos_session_id}",
648
+ "-e",
649
+ f"OTEL_EXPORTER_OTLP_ENDPOINT={otel_url}",
650
+ "-e",
651
+ f"UPLOAD_URL={chronos_session.get('upload_url', '')}",
652
+ ]
653
+
654
+ # Add secrets as env vars
655
+ for key, value in config_data.get("secrets", {}).items():
656
+ docker_cmd.extend(["-e", f"{key.upper()}={value}"])
657
+
658
+ # Use world runner image
659
+ docker_cmd.append(world_runner_image)
660
+
661
+ console.print(f"[dim]Running: docker run ... {world_runner_image}[/dim]")
662
+
663
+ # Run and stream output
664
+ process = subprocess.Popen(
665
+ docker_cmd,
666
+ stdout=subprocess.PIPE,
667
+ stderr=subprocess.STDOUT,
668
+ text=True,
669
+ )
670
+
671
+ if process.stdout:
672
+ for line in process.stdout:
673
+ print(line, end="")
674
+
675
+ process.wait()
676
+ world_exit_code = process.returncode
677
+
678
+ if world_exit_code != 0:
679
+ raise RuntimeError(f"World execution failed with exit code {world_exit_code}")
680
+
681
+ finally:
682
+ os.unlink(container_config_path)
683
+ # Clean up workspace volume
684
+ subprocess.run(
685
+ ["docker", "volume", "rm", "-f", workspace_volume],
686
+ capture_output=True,
687
+ )
688
+ console.print(f"[dim]Cleaned up workspace volume: {workspace_volume}[/dim]")
689
+
690
+ except Exception as e:
691
+ # Complete session as failed
692
+ if chronos_session_id:
693
+ await _complete_chronos_session(
694
+ chronos_url,
695
+ api_key,
696
+ chronos_session_id,
697
+ status="failed",
698
+ exit_code=getattr(e, "exit_code", 1),
699
+ error_message=str(e)[:500],
700
+ )
701
+ raise
702
+ else:
703
+ # Complete session as successful
704
+ if chronos_session_id:
705
+ await _complete_chronos_session(
706
+ chronos_url,
707
+ api_key,
708
+ chronos_session_id,
709
+ status="completed",
710
+ exit_code=0,
711
+ )
712
+ finally:
713
+ if session:
714
+ console.print("[blue]Closing Plato session...[/blue]")
715
+ await session.close()
716
+ await plato.close()
717
+
718
+
719
+ @chronos_app.command()
720
+ def dev(
721
+ config: Annotated[
722
+ Path,
723
+ typer.Argument(help="Path to config JSON file", exists=True, readable=True),
724
+ ],
725
+ world_dir: Annotated[
726
+ Path,
727
+ typer.Option("--world-dir", "-w", help="Directory containing world source code"),
728
+ ],
729
+ agents_dir: Annotated[
730
+ Path | None,
731
+ typer.Option("--agents-dir", "-a", help="Directory containing agent source code"),
732
+ ] = None,
733
+ platform: Annotated[
734
+ str | None,
735
+ typer.Option("--platform", "-p", help="Docker platform (e.g., linux/amd64)"),
736
+ ] = None,
737
+ env_timeout: Annotated[
738
+ int,
739
+ typer.Option("--env-timeout", help="Timeout for environment creation (seconds)"),
740
+ ] = 7200,
741
+ ):
742
+ """
743
+ Run a world locally for development/debugging.
744
+
745
+ This runs the world in a Docker container with docker.sock mounted,
746
+ allowing the world to spawn agent containers.
747
+
748
+ \b
749
+ Config format (same as Chronos launch):
750
+ {
751
+ "world_package": "plato-world-structured-execution",
752
+ "world_config": {
753
+ "sim_name": "my-task",
754
+ "steps": [...],
755
+ "agents": {
756
+ "skill_runner": {
757
+ "image": "computer-use:latest",
758
+ "config": {"model_name": "..."}
759
+ }
760
+ }
761
+ },
762
+ "secrets": {
763
+ "gemini_api_key": "...",
764
+ "plato_api_key": "..."
765
+ }
766
+ }
767
+
768
+ Examples:
769
+ plato chronos dev config.json --world-dir ~/worlds/my-world
770
+ plato chronos dev config.json -w ~/worlds/my-world -a ~/agents
771
+ plato chronos dev config.json -w ~/worlds/my-world --platform linux/amd64
772
+ """
773
+ logging.basicConfig(
774
+ level=logging.INFO,
775
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
776
+ datefmt="%H:%M:%S",
777
+ )
778
+
779
+ if not os.environ.get("PLATO_API_KEY"):
780
+ console.print("[red]❌ PLATO_API_KEY environment variable required[/red]")
781
+ raise typer.Exit(1)
782
+
783
+ try:
784
+ asyncio.run(_run_dev_impl(world_dir, config, agents_dir, platform, env_timeout))
785
+ except Exception as e:
786
+ console.print(f"[red]❌ Failed: {e}[/red]")
787
+ logger.exception("World execution failed")
788
+ raise typer.Exit(1)