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