plato-sdk-v2 2.0.64__py3-none-any.whl → 2.3.4__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.
Files changed (46) hide show
  1. plato/__init__.py +0 -9
  2. plato/_sims_generator/__init__.py +19 -4
  3. plato/_sims_generator/instruction.py +203 -0
  4. plato/_sims_generator/templates/instruction/helpers.py.jinja +161 -0
  5. plato/_sims_generator/templates/instruction/init.py.jinja +43 -0
  6. plato/agents/__init__.py +99 -430
  7. plato/agents/base.py +145 -0
  8. plato/agents/build.py +61 -0
  9. plato/agents/config.py +160 -0
  10. plato/agents/logging.py +515 -0
  11. plato/agents/runner.py +191 -0
  12. plato/agents/trajectory.py +266 -0
  13. plato/chronos/models/__init__.py +1 -1
  14. plato/sims/cli.py +299 -123
  15. plato/sims/registry.py +77 -4
  16. plato/v1/cli/agent.py +88 -84
  17. plato/v1/cli/pm.py +84 -44
  18. plato/v1/cli/sandbox.py +241 -61
  19. plato/v1/cli/ssh.py +16 -4
  20. plato/v1/cli/verify.py +685 -0
  21. plato/v1/cli/world.py +3 -0
  22. plato/v1/flow_executor.py +21 -17
  23. plato/v1/models/env.py +11 -11
  24. plato/v1/sdk.py +2 -2
  25. plato/v1/sync_env.py +11 -11
  26. plato/v1/sync_flow_executor.py +21 -17
  27. plato/v1/sync_sdk.py +4 -2
  28. plato/v2/__init__.py +2 -0
  29. plato/v2/async_/environment.py +31 -0
  30. plato/v2/async_/session.py +72 -4
  31. plato/v2/sync/environment.py +31 -0
  32. plato/v2/sync/session.py +72 -4
  33. plato/worlds/README.md +71 -56
  34. plato/worlds/__init__.py +56 -18
  35. plato/worlds/base.py +578 -93
  36. plato/worlds/config.py +276 -74
  37. plato/worlds/runner.py +475 -80
  38. {plato_sdk_v2-2.0.64.dist-info → plato_sdk_v2-2.3.4.dist-info}/METADATA +3 -3
  39. {plato_sdk_v2-2.0.64.dist-info → plato_sdk_v2-2.3.4.dist-info}/RECORD +41 -36
  40. {plato_sdk_v2-2.0.64.dist-info → plato_sdk_v2-2.3.4.dist-info}/entry_points.txt +1 -0
  41. plato/agents/callback.py +0 -246
  42. plato/world/__init__.py +0 -44
  43. plato/world/base.py +0 -267
  44. plato/world/config.py +0 -139
  45. plato/world/types.py +0 -47
  46. {plato_sdk_v2-2.0.64.dist-info → plato_sdk_v2-2.3.4.dist-info}/WHEEL +0 -0
plato/worlds/base.py CHANGED
@@ -1,28 +1,43 @@
1
- """Base world class for Plato worlds - OpenAI Gym-like interface."""
1
+ """Base world class for Plato worlds."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import subprocess
6
7
  from abc import ABC, abstractmethod
7
- from typing import TYPE_CHECKING, Any, ClassVar
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, get_args, get_origin
8
10
 
9
11
  from pydantic import BaseModel, Field
10
12
 
13
+ from plato.worlds.config import RunConfig
14
+
11
15
  if TYPE_CHECKING:
12
- from plato.worlds.config import RunConfig, WorldConfig
16
+ from plato.v2.async_.environment import Environment
17
+ from plato.v2.async_.session import Session
18
+
19
+ from plato.agents.logging import init_logging as _init_chronos_logging
20
+ from plato.agents.logging import log_event as _log_event
21
+ from plato.agents.logging import reset_logging as _reset_chronos_logging
22
+ from plato.agents.logging import span as _span
23
+ from plato.agents.logging import upload_artifact as _upload_artifact
24
+ from plato.agents.logging import upload_checkpoint as _upload_checkpoint
13
25
 
14
26
  logger = logging.getLogger(__name__)
15
27
 
16
28
  # Global registry of worlds
17
29
  _WORLD_REGISTRY: dict[str, type[BaseWorld]] = {}
18
30
 
31
+ # Type variable for config
32
+ ConfigT = TypeVar("ConfigT", bound=RunConfig)
33
+
19
34
 
20
35
  def register_world(name: str | None = None):
21
36
  """Decorator to register a world class.
22
37
 
23
38
  Usage:
24
39
  @register_world("code")
25
- class CodeWorld(BaseWorld):
40
+ class CodeWorld(BaseWorld[CodeWorldConfig]):
26
41
  ...
27
42
  """
28
43
 
@@ -45,24 +60,8 @@ def get_world(name: str) -> type[BaseWorld] | None:
45
60
  return _WORLD_REGISTRY.get(name)
46
61
 
47
62
 
48
- class AgentSlot(BaseModel):
49
- """Definition of an agent slot in a world."""
50
-
51
- name: str
52
- description: str = ""
53
- required: bool = True
54
-
55
-
56
- class SecretSlot(BaseModel):
57
- """Definition of a secret slot in a world."""
58
-
59
- name: str
60
- description: str = ""
61
- required: bool = False
62
-
63
-
64
63
  class Observation(BaseModel):
65
- """Observation returned from reset/step - what the agent sees."""
64
+ """Observation returned from reset/step."""
66
65
 
67
66
  data: dict[str, Any] = Field(default_factory=dict)
68
67
 
@@ -70,48 +69,60 @@ class Observation(BaseModel):
70
69
 
71
70
 
72
71
  class StepResult(BaseModel):
73
- """Result of a step - OpenAI Gym style."""
72
+ """Result of a step."""
74
73
 
75
74
  observation: Observation
76
75
  done: bool = False
77
76
  info: dict[str, Any] = Field(default_factory=dict)
78
77
 
79
78
 
80
- class BaseWorld(ABC):
81
- """Base class for Plato worlds - OpenAI Gym-like interface.
79
+ class BaseWorld(ABC, Generic[ConfigT]):
80
+ """Base class for Plato worlds.
82
81
 
83
- Subclasses implement:
84
- - reset(): Setup the world, return initial observation
85
- - step(): Execute one step, return StepResult
82
+ Subclass with a config type parameter for fully typed config access:
86
83
 
87
- The base run() method handles the loop: reset -> step until done.
84
+ class CodeWorldConfig(RunConfig):
85
+ repository_url: str
86
+ prompt: str
87
+ coder: Annotated[AgentConfig, Agent(description="Coding agent")]
88
+ git_token: Annotated[str | None, Secret(description="GitHub token")] = None
88
89
 
89
- Example:
90
90
  @register_world("code")
91
- class CodeWorld(BaseWorld):
91
+ class CodeWorld(BaseWorld[CodeWorldConfig]):
92
92
  name = "code"
93
- agents = ["coder"]
93
+ description = "Run coding agents"
94
94
 
95
- async def reset(self, config: RunConfig) -> Observation:
96
- # Clone repo, setup workspace
97
- return Observation(data={"workspace": "/workspace"})
98
-
99
- async def step(self) -> StepResult:
100
- # Run agent, check if done
101
- return StepResult(observation=obs, done=True)
95
+ async def reset(self) -> Observation:
96
+ url = self.config.repository_url # typed as str
97
+ agent = self.config.coder # typed as AgentConfig
98
+ token = self.config.git_token # typed as str | None
102
99
  """
103
100
 
104
101
  # Class attributes
105
102
  name: ClassVar[str] = "base"
106
103
  description: ClassVar[str] = ""
107
- agents: ClassVar[list[AgentSlot | str]] = []
108
- secrets: ClassVar[list[SecretSlot | str]] = []
109
- config_class: ClassVar[type[WorldConfig] | None] = None # Override with typed config
104
+
105
+ # Instance attributes
106
+ config: ConfigT # Typed via generic parameter
107
+ plato_session: Session | None = None # Connected Plato session (if running on managed VM)
110
108
 
111
109
  def __init__(self) -> None:
112
110
  self.logger = logging.getLogger(f"plato.worlds.{self.name}")
113
- self.config: RunConfig | None = None
114
111
  self._step_count: int = 0
112
+ self.plato_session = None
113
+ self._current_step_id: str | None = None
114
+
115
+ @classmethod
116
+ def get_config_class(cls) -> type[RunConfig]:
117
+ """Get the config class from the generic parameter."""
118
+ # Walk up the class hierarchy to find Generic base
119
+ for base in getattr(cls, "__orig_bases__", []):
120
+ origin = get_origin(base)
121
+ if origin is BaseWorld:
122
+ args = get_args(base)
123
+ if args and isinstance(args[0], type) and issubclass(args[0], RunConfig):
124
+ return args[0]
125
+ return RunConfig
115
126
 
116
127
  @classmethod
117
128
  def get_version(cls) -> str:
@@ -125,62 +136,27 @@ class BaseWorld(ABC):
125
136
  continue
126
137
  return "0.0.0"
127
138
 
128
- @classmethod
129
- def get_agent_slots(cls) -> list[AgentSlot]:
130
- """Get the list of agent slots."""
131
- return [AgentSlot(name=a) if isinstance(a, str) else a for a in cls.agents]
132
-
133
- @classmethod
134
- def get_secret_slots(cls) -> list[SecretSlot]:
135
- """Get the list of secret slots."""
136
- return [SecretSlot(name=s) if isinstance(s, str) else s for s in cls.secrets]
137
-
138
- @classmethod
139
- def get_config_schema(cls) -> dict:
140
- """Get JSON schema for world_config.
141
-
142
- If config_class is set, auto-generates schema from the Pydantic model.
143
- Override this method to provide a custom schema.
144
- """
145
- if cls.config_class is not None:
146
- return cls.config_class.get_json_schema()
147
- return {"type": "object", "properties": {}, "required": []}
148
-
149
139
  @classmethod
150
140
  def get_schema(cls) -> dict:
151
- """Get full schema including world_config, agents, and secrets."""
152
- config_schema = cls.get_config_schema()
153
- agent_slots = cls.get_agent_slots()
154
- secret_slots = cls.get_secret_slots()
141
+ """Get full schema including world config, agents, and secrets."""
142
+ config_class = cls.get_config_class()
143
+ schema = config_class.get_json_schema()
155
144
 
156
145
  return {
157
146
  "$schema": "https://json-schema.org/draft/2020-12/schema",
158
147
  "type": "object",
159
- "properties": config_schema.get("properties", {}),
160
- "required": config_schema.get("required", []),
161
- "agents": [
162
- {
163
- "name": slot.name,
164
- "description": slot.description,
165
- "required": slot.required,
166
- }
167
- for slot in agent_slots
168
- ],
169
- "secrets": [
170
- {
171
- "name": slot.name,
172
- "description": slot.description,
173
- "required": slot.required,
174
- }
175
- for slot in secret_slots
176
- ],
148
+ "properties": schema.get("properties", {}),
149
+ "required": schema.get("required", []),
150
+ "agents": schema.get("agents", []),
151
+ "secrets": schema.get("secrets", []),
152
+ "envs": schema.get("envs", []),
177
153
  }
178
154
 
179
155
  @abstractmethod
180
- async def reset(self, config: RunConfig) -> Observation:
156
+ async def reset(self) -> Observation:
181
157
  """Setup the world and return initial observation.
182
158
 
183
- Called once at the start. Setup workspace, clone repos, etc.
159
+ Access configuration via self.config (fully typed).
184
160
  """
185
161
  pass
186
162
 
@@ -196,30 +172,539 @@ class BaseWorld(ABC):
196
172
  """Cleanup resources. Called after run completes."""
197
173
  pass
198
174
 
199
- async def run(self, config: RunConfig) -> None:
175
+ async def _connect_plato_session(self) -> None:
176
+ """Connect to Plato session from config.
177
+
178
+ This is called automatically during run() to restore the session
179
+ and start sending heartbeats while the world runs.
180
+ """
181
+ if not self.config.plato_session:
182
+ return
183
+
184
+ try:
185
+ from plato.v2.async_.session import Session
186
+
187
+ self.logger.info("Restoring Plato session from serialized data")
188
+ self.plato_session = await Session.load(self.config.plato_session, start_heartbeat=True)
189
+ self.logger.info(f"Plato session {self.plato_session.session_id} restored, heartbeat started")
190
+ except Exception as e:
191
+ self.logger.warning(f"Failed to restore Plato session: {e}")
192
+
193
+ async def _disconnect_plato_session(self) -> None:
194
+ """Stop heartbeat for the Plato session (does not close the session)."""
195
+ if self.plato_session:
196
+ try:
197
+ await self.plato_session.stop_heartbeat()
198
+ self.logger.info("Plato session heartbeat stopped")
199
+ except Exception as e:
200
+ self.logger.warning(f"Error stopping Plato heartbeat: {e}")
201
+
202
+ async def _create_checkpoint(self) -> dict[str, str] | None:
203
+ """Create a checkpoint snapshot of all environments (excluding configured envs).
204
+
205
+ Uses snapshot_store for efficient chunk-based deduplication.
206
+
207
+ Returns:
208
+ Dict mapping environment alias to artifact_id, or None if no session connected.
209
+ """
210
+ if not self.plato_session:
211
+ self.logger.warning("Cannot create checkpoint: Plato session not connected")
212
+ return None
213
+
214
+ exclude_envs = set(self.config.checkpoint.exclude_envs)
215
+ envs_to_snapshot = [env for env in self.plato_session.envs if env.alias not in exclude_envs]
216
+
217
+ if not envs_to_snapshot:
218
+ self.logger.info("No environments to checkpoint (all excluded)")
219
+ return {}
220
+
221
+ self.logger.info(
222
+ f"Creating checkpoint for {len(envs_to_snapshot)} environment(s): {[e.alias for e in envs_to_snapshot]}"
223
+ )
224
+
225
+ results: dict[str, str] = {}
226
+ for env in envs_to_snapshot:
227
+ try:
228
+ result = await env.snapshot_store()
229
+ artifact_id = result.artifact_id
230
+ results[env.alias] = artifact_id
231
+
232
+ # Check for success/error fields (available after SDK regeneration)
233
+ success = getattr(result, "success", True)
234
+ error = getattr(result, "error", None)
235
+
236
+ if not success or error:
237
+ self.logger.error(
238
+ f"Checkpoint failed for '{env.alias}': {error or 'unknown error'} (job_id={env.job_id})"
239
+ )
240
+ elif artifact_id:
241
+ self.logger.info(f"Checkpoint created for '{env.alias}': {artifact_id}")
242
+ else:
243
+ self.logger.warning(
244
+ f"Checkpoint for '{env.alias}' returned empty artifact_id (job_id={env.job_id})"
245
+ )
246
+ except Exception as e:
247
+ self.logger.error(f"Failed to checkpoint '{env.alias}': {e}")
248
+
249
+ return results
250
+
251
+ def _init_state_directory(self) -> None:
252
+ """Initialize the state directory as a git repository.
253
+
254
+ Creates the state directory if it doesn't exist and initializes it
255
+ as a git repository with an initial commit.
256
+ """
257
+ if not self.config.state.enabled:
258
+ return
259
+
260
+ state_path = Path(self.config.state.path)
261
+
262
+ # Create directory if it doesn't exist
263
+ if not state_path.exists():
264
+ state_path.mkdir(parents=True)
265
+ self.logger.info(f"Created state directory: {state_path}")
266
+
267
+ # Check if already a git repo
268
+ git_dir = state_path / ".git"
269
+ if git_dir.exists():
270
+ self.logger.info(f"State directory already initialized: {state_path}")
271
+ return
272
+
273
+ # Initialize git repo
274
+ try:
275
+ subprocess.run(
276
+ ["git", "init"],
277
+ cwd=state_path,
278
+ capture_output=True,
279
+ check=True,
280
+ )
281
+ # Create initial commit (even if empty)
282
+ subprocess.run(
283
+ ["git", "config", "user.email", "plato@plato.so"],
284
+ cwd=state_path,
285
+ capture_output=True,
286
+ check=True,
287
+ )
288
+ subprocess.run(
289
+ ["git", "config", "user.name", "Plato"],
290
+ cwd=state_path,
291
+ capture_output=True,
292
+ check=True,
293
+ )
294
+ # Add all files and create initial commit
295
+ subprocess.run(
296
+ ["git", "add", "-A"],
297
+ cwd=state_path,
298
+ capture_output=True,
299
+ check=True,
300
+ )
301
+ subprocess.run(
302
+ ["git", "commit", "--allow-empty", "-m", "Initial state"],
303
+ cwd=state_path,
304
+ capture_output=True,
305
+ check=True,
306
+ )
307
+ self.logger.info(f"Initialized git repo in state directory: {state_path}")
308
+ except subprocess.CalledProcessError as e:
309
+ self.logger.warning(f"Failed to initialize state git repo: {e.stderr}")
310
+
311
+ def _commit_state(self, message: str) -> bool:
312
+ """Commit current state directory changes.
313
+
314
+ Args:
315
+ message: Commit message
316
+
317
+ Returns:
318
+ True if commit was created (or no changes), False on error.
319
+ """
320
+ if not self.config.state.enabled:
321
+ return True
322
+
323
+ state_path = Path(self.config.state.path)
324
+ if not state_path.exists():
325
+ return True
326
+
327
+ try:
328
+ # Add all changes
329
+ subprocess.run(
330
+ ["git", "add", "-A"],
331
+ cwd=state_path,
332
+ capture_output=True,
333
+ check=True,
334
+ )
335
+ # Check if there are changes to commit
336
+ result = subprocess.run(
337
+ ["git", "status", "--porcelain"],
338
+ cwd=state_path,
339
+ capture_output=True,
340
+ text=True,
341
+ check=True,
342
+ )
343
+ if not result.stdout.strip():
344
+ self.logger.debug("No state changes to commit")
345
+ return True
346
+
347
+ # Commit changes
348
+ subprocess.run(
349
+ ["git", "commit", "-m", message],
350
+ cwd=state_path,
351
+ capture_output=True,
352
+ check=True,
353
+ )
354
+ self.logger.info(f"Committed state changes: {message}")
355
+ return True
356
+ except subprocess.CalledProcessError as e:
357
+ self.logger.warning(f"Failed to commit state: {e.stderr}")
358
+ return False
359
+
360
+ def _create_state_bundle(self) -> bytes | None:
361
+ """Create a git bundle of the state directory.
362
+
363
+ Returns:
364
+ Bundle bytes if successful, None otherwise.
365
+ """
366
+ if not self.config.state.enabled:
367
+ return None
368
+
369
+ state_path = Path(self.config.state.path)
370
+ if not state_path.exists():
371
+ return None
372
+
373
+ git_dir = state_path / ".git"
374
+ if not git_dir.exists():
375
+ self.logger.warning("State directory is not a git repository")
376
+ return None
377
+
378
+ try:
379
+ # Create bundle to stdout
380
+ result = subprocess.run(
381
+ ["git", "bundle", "create", "-", "--all"],
382
+ cwd=state_path,
383
+ capture_output=True,
384
+ check=True,
385
+ )
386
+ bundle_data = result.stdout
387
+ self.logger.info(f"Created state bundle: {len(bundle_data)} bytes")
388
+ return bundle_data
389
+ except subprocess.CalledProcessError as e:
390
+ self.logger.warning(f"Failed to create state bundle: {e.stderr}")
391
+ return None
392
+
393
+ async def _create_and_upload_checkpoint(self) -> dict[str, Any] | None:
394
+ """Create a full checkpoint including env snapshots and state bundle.
395
+
396
+ This method:
397
+ 1. Commits any pending state changes
398
+ 2. Creates env snapshots using snapshot_store
399
+ 3. Creates and uploads state bundle as an artifact
400
+ 4. Calls the checkpoint endpoint with all data
401
+
402
+ Returns:
403
+ Checkpoint result dict if successful, None otherwise.
404
+ """
405
+ # Commit state changes first
406
+ self._commit_state(f"Checkpoint at step {self._step_count}")
407
+
408
+ # Create env snapshots
409
+ env_snapshots = await self._create_checkpoint()
410
+ if env_snapshots is None:
411
+ env_snapshots = {}
412
+
413
+ # Create and upload state bundle
414
+ state_artifact_id: str | None = None
415
+ if self.config.state.enabled:
416
+ bundle_data = self._create_state_bundle()
417
+ if bundle_data:
418
+ result = await _upload_artifact(
419
+ data=bundle_data,
420
+ artifact_type="state",
421
+ filename=f"state_step_{self._step_count}.bundle",
422
+ extra={
423
+ "step_number": self._step_count,
424
+ "state_path": self.config.state.path,
425
+ },
426
+ )
427
+ if result:
428
+ state_artifact_id = result.get("artifact_id")
429
+ self.logger.info(f"Uploaded state artifact: {state_artifact_id}")
430
+
431
+ # Upload checkpoint with all data
432
+ checkpoint_result = await _upload_checkpoint(
433
+ step_number=self._step_count,
434
+ env_snapshots=env_snapshots,
435
+ state_artifact_id=state_artifact_id,
436
+ extra={
437
+ "world_name": self.name,
438
+ "world_version": self.get_version(),
439
+ },
440
+ )
441
+
442
+ return checkpoint_result
443
+
444
+ def get_env(self, alias: str) -> Environment | None:
445
+ """Get an environment by alias.
446
+
447
+ Use this to access environments defined in the world config (e.g., gitea, localstack).
448
+
449
+ Args:
450
+ alias: The environment alias (e.g., "gitea", "localstack", "runtime")
451
+
452
+ Returns:
453
+ The Environment object or None if not found or session not connected.
454
+
455
+ Example:
456
+ gitea = self.get_env("gitea")
457
+ if gitea:
458
+ result = await gitea.execute("git status")
459
+ """
460
+ if not self.plato_session:
461
+ self.logger.warning("Cannot get env: Plato session not connected")
462
+ return None
463
+ return self.plato_session.get_env(alias)
464
+
465
+ @property
466
+ def envs(self) -> list[Environment]:
467
+ """Get all environments in the Plato session.
468
+
469
+ Returns:
470
+ List of Environment objects. Empty list if session not connected.
471
+ """
472
+ if not self.plato_session:
473
+ return []
474
+ return self.plato_session.envs
475
+
476
+ def get_sim_env_vars(self) -> dict[str, str]:
477
+ """Get environment variables from all configured sims.
478
+
479
+ Automatically discovers and loads env vars from sims like localstack, gitea, etc.
480
+ based on the environments configured in the world.
481
+
482
+ Returns:
483
+ Dict of environment variable name -> value
484
+
485
+ Raises:
486
+ ImportError: If a sim environment is configured but package is not installed.
487
+
488
+ Example:
489
+ env_vars = self.get_sim_env_vars()
490
+ # Returns: {"AWS_ENDPOINT_URL": "https://...", "GITEA_URL": "https://...", ...}
491
+ """
492
+ env_vars: dict[str, str] = {}
493
+
494
+ # Known sim packages and their env aliases
495
+ sim_packages = [
496
+ ("localstack", "localstack"),
497
+ ("gitea", "gitea"),
498
+ ]
499
+
500
+ for package_name, env_alias in sim_packages:
501
+ env = self.get_env(env_alias)
502
+ if not env:
503
+ continue
504
+
505
+ try:
506
+ # Dynamically import the sim package
507
+ sim_module = __import__(f"plato.sims.{package_name}", fromlist=[package_name])
508
+
509
+ # Get service URLs and env vars
510
+ service_urls = sim_module.get_service_urls(env.job_id)
511
+ sim_vars = sim_module.get_env_vars(service_urls)
512
+ env_vars.update(sim_vars)
513
+ self.logger.info(f"{package_name} env vars: {list(sim_vars.keys())}")
514
+ except ImportError:
515
+ raise ImportError(
516
+ f"Environment '{env_alias}' is configured but 'plato.sims.{package_name}' "
517
+ f"package is not installed.\n\n"
518
+ f"Install sims packages:\n"
519
+ f' export INDEX_URL="https://__token__:${{PLATO_API_KEY}}@plato.so/api/v2/pypi/sims/simple/"\n'
520
+ f" uv pip install '.[sims]' --extra-index-url $INDEX_URL"
521
+ ) from None
522
+ except Exception as e:
523
+ self.logger.warning(f"Failed to get {package_name} env vars: {e}")
524
+
525
+ return env_vars
526
+
527
+ def get_sim_instructions(self) -> str:
528
+ """Get usage instructions from all configured sims.
529
+
530
+ Returns markdown-formatted instructions for using LocalStack, Gitea, etc.
531
+ based on the environments configured in the world.
532
+
533
+ Returns:
534
+ Markdown string with instructions, or empty string if no sims configured.
535
+
536
+ Raises:
537
+ ImportError: If a sim environment is configured but package is not installed.
538
+
539
+ Example:
540
+ instructions = self.get_sim_instructions()
541
+ # Returns markdown with LocalStack/Gitea setup instructions
542
+ """
543
+ instructions_parts: list[str] = []
544
+
545
+ # Known sim packages and their env aliases
546
+ sim_packages = [
547
+ ("localstack", "localstack"),
548
+ ("gitea", "gitea"),
549
+ ]
550
+
551
+ for package_name, env_alias in sim_packages:
552
+ env = self.get_env(env_alias)
553
+ if not env:
554
+ continue
555
+
556
+ try:
557
+ # Dynamically import the sim package
558
+ sim_module = __import__(f"plato.sims.{package_name}", fromlist=[package_name])
559
+
560
+ # Get instructions using the job_id
561
+ if hasattr(sim_module, "get_instructions_from_job"):
562
+ instructions = sim_module.get_instructions_from_job(env.job_id)
563
+ if instructions:
564
+ instructions_parts.append(instructions)
565
+ self.logger.info(f"Added {package_name} instructions to prompt")
566
+ except ImportError:
567
+ raise ImportError(
568
+ f"Environment '{env_alias}' is configured but 'plato.sims.{package_name}' "
569
+ f"package is not installed.\n\n"
570
+ f"Install sims packages:\n"
571
+ f' export INDEX_URL="https://__token__:${{PLATO_API_KEY}}@plato.so/api/v2/pypi/sims/simple/"\n'
572
+ f" uv pip install '.[sims]' --extra-index-url $INDEX_URL"
573
+ ) from None
574
+ except Exception as e:
575
+ self.logger.warning(f"Failed to get {package_name} instructions: {e}")
576
+
577
+ if instructions_parts:
578
+ return "\n\n---\n\n".join(instructions_parts)
579
+ return ""
580
+
581
+ def format_instruction_with_sims(self, instruction: str) -> str:
582
+ """Format an instruction with sim context prepended.
583
+
584
+ Automatically adds available service instructions (LocalStack, Gitea, etc.)
585
+ before the main instruction.
586
+
587
+ Args:
588
+ instruction: The base instruction/task
589
+
590
+ Returns:
591
+ Formatted instruction with sim context, or original instruction if no sims.
592
+
593
+ Example:
594
+ formatted = self.format_instruction_with_sims("Fix the bug in main.py")
595
+ # Returns:
596
+ # ## Available Services
597
+ # The following services are available...
598
+ # [LocalStack instructions]
599
+ # ---
600
+ # ## Task
601
+ # Fix the bug in main.py
602
+ """
603
+ sim_instructions = self.get_sim_instructions()
604
+
605
+ if sim_instructions:
606
+ return f"""## Available Services
607
+
608
+ The following services are available for your use:
609
+
610
+ {sim_instructions}
611
+
612
+ ---
613
+
614
+ ## Task
615
+
616
+ {instruction}"""
617
+ return instruction
618
+
619
+ async def run(self, config: ConfigT) -> None:
200
620
  """Run the world: reset -> step until done -> close.
201
621
 
202
- This is the main entry point. Don't override this.
622
+ This is the main entry point. If plato_session_id is provided in config,
623
+ automatically connects to the Plato session to send heartbeats.
203
624
  """
204
625
  self.config = config
205
626
  self._step_count = 0
206
627
 
207
628
  self.logger.info(f"Starting world '{self.name}'")
208
629
 
630
+ # Initialize state directory (creates git repo if needed)
631
+ self._init_state_directory()
632
+
633
+ # Initialize the logging singleton for agents to use
634
+ if config.callback_url and config.session_id:
635
+ _init_chronos_logging(
636
+ callback_url=config.callback_url,
637
+ session_id=config.session_id,
638
+ )
639
+
640
+ # Connect to Plato session if configured (for heartbeats)
641
+ await self._connect_plato_session()
642
+
643
+ # Log session start
644
+ await _log_event(
645
+ span_type="session_start",
646
+ content=f"World '{self.name}' started",
647
+ source="world",
648
+ extra={"world_name": self.name, "world_version": self.get_version()},
649
+ )
650
+
209
651
  try:
210
- # Reset (setup)
211
- obs = await self.reset(config)
652
+ # Execute reset with automatic span tracking
653
+ async with _span("reset", span_type="reset", source="world") as reset_span:
654
+ reset_span.log(f"Resetting world '{self.name}'")
655
+ obs = await self.reset()
656
+ reset_span.set_extra({"observation": obs.model_dump() if hasattr(obs, "model_dump") else str(obs)})
212
657
  self.logger.info(f"World reset complete: {obs}")
213
658
 
214
- # Step loop
215
659
  while True:
216
660
  self._step_count += 1
217
- result = await self.step()
661
+
662
+ # Execute step with automatic span tracking
663
+ # The span automatically sets itself as the current parent,
664
+ # so agent trajectories will nest under this step
665
+ async with _span(
666
+ f"step_{self._step_count}",
667
+ span_type="step",
668
+ source="world",
669
+ ) as step_span:
670
+ self._current_step_id = step_span.event_id
671
+ step_span.log(f"Step {self._step_count} started")
672
+ result = await self.step()
673
+ step_span.set_extra(
674
+ {
675
+ "done": result.done,
676
+ "observation": result.observation.model_dump()
677
+ if hasattr(result.observation, "model_dump")
678
+ else str(result.observation),
679
+ "info": result.info,
680
+ }
681
+ )
682
+
218
683
  self.logger.info(f"Step {self._step_count}: done={result.done}")
219
684
 
685
+ # Create checkpoint if enabled and interval matches
686
+ # Note: The checkpoint event is created by the callback endpoint,
687
+ # so we don't need a span wrapper here (would create duplicates)
688
+ if self.config.checkpoint.enabled and self._step_count % self.config.checkpoint.interval == 0:
689
+ self.logger.info(f"Creating checkpoint after step {self._step_count}")
690
+ await self._create_and_upload_checkpoint()
691
+
220
692
  if result.done:
221
693
  break
222
694
 
223
695
  finally:
224
696
  await self.close()
697
+ await self._disconnect_plato_session()
698
+
699
+ # Log session end
700
+ await _log_event(
701
+ span_type="session_end",
702
+ content=f"World '{self.name}' completed after {self._step_count} steps",
703
+ source="world",
704
+ extra={"total_steps": self._step_count},
705
+ )
706
+
707
+ # Reset the logging singleton
708
+ _reset_chronos_logging()
709
+
225
710
  self.logger.info(f"World '{self.name}' completed after {self._step_count} steps")