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.
- plato/__init__.py +0 -9
- plato/_sims_generator/__init__.py +19 -4
- plato/_sims_generator/instruction.py +203 -0
- plato/_sims_generator/templates/instruction/helpers.py.jinja +161 -0
- plato/_sims_generator/templates/instruction/init.py.jinja +43 -0
- plato/agents/__init__.py +99 -430
- plato/agents/base.py +145 -0
- plato/agents/build.py +61 -0
- plato/agents/config.py +160 -0
- plato/agents/logging.py +515 -0
- plato/agents/runner.py +191 -0
- plato/agents/trajectory.py +266 -0
- plato/chronos/models/__init__.py +1 -1
- plato/sims/cli.py +299 -123
- plato/sims/registry.py +77 -4
- plato/v1/cli/agent.py +88 -84
- plato/v1/cli/pm.py +84 -44
- plato/v1/cli/sandbox.py +241 -61
- plato/v1/cli/ssh.py +16 -4
- plato/v1/cli/verify.py +685 -0
- plato/v1/cli/world.py +3 -0
- plato/v1/flow_executor.py +21 -17
- plato/v1/models/env.py +11 -11
- plato/v1/sdk.py +2 -2
- plato/v1/sync_env.py +11 -11
- plato/v1/sync_flow_executor.py +21 -17
- plato/v1/sync_sdk.py +4 -2
- plato/v2/__init__.py +2 -0
- plato/v2/async_/environment.py +31 -0
- plato/v2/async_/session.py +72 -4
- plato/v2/sync/environment.py +31 -0
- plato/v2/sync/session.py +72 -4
- plato/worlds/README.md +71 -56
- plato/worlds/__init__.py +56 -18
- plato/worlds/base.py +578 -93
- plato/worlds/config.py +276 -74
- plato/worlds/runner.py +475 -80
- {plato_sdk_v2-2.0.64.dist-info → plato_sdk_v2-2.3.4.dist-info}/METADATA +3 -3
- {plato_sdk_v2-2.0.64.dist-info → plato_sdk_v2-2.3.4.dist-info}/RECORD +41 -36
- {plato_sdk_v2-2.0.64.dist-info → plato_sdk_v2-2.3.4.dist-info}/entry_points.txt +1 -0
- plato/agents/callback.py +0 -246
- plato/world/__init__.py +0 -44
- plato/world/base.py +0 -267
- plato/world/config.py +0 -139
- plato/world/types.py +0 -47
- {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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
79
|
+
class BaseWorld(ABC, Generic[ConfigT]):
|
|
80
|
+
"""Base class for Plato worlds.
|
|
82
81
|
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
+
description = "Run coding agents"
|
|
94
94
|
|
|
95
|
-
async def reset(self
|
|
96
|
-
#
|
|
97
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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":
|
|
160
|
-
"required":
|
|
161
|
-
"agents": [
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
156
|
+
async def reset(self) -> Observation:
|
|
181
157
|
"""Setup the world and return initial observation.
|
|
182
158
|
|
|
183
|
-
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
211
|
-
|
|
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
|
-
|
|
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")
|