plato-sdk-v2 2.1.11__py3-none-any.whl → 2.2.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/worlds/base.py CHANGED
@@ -11,8 +11,14 @@ from pydantic import BaseModel, Field
11
11
  from plato.worlds.config import RunConfig
12
12
 
13
13
  if TYPE_CHECKING:
14
+ from plato.v2.async_.environment import Environment
14
15
  from plato.v2.async_.session import Session
15
16
 
17
+ from plato.agents.logging import init_logging as _init_chronos_logging
18
+ from plato.agents.logging import log_event as _log_event
19
+ from plato.agents.logging import reset_logging as _reset_chronos_logging
20
+ from plato.agents.logging import span as _span
21
+
16
22
  logger = logging.getLogger(__name__)
17
23
 
18
24
  # Global registry of worlds
@@ -100,6 +106,7 @@ class BaseWorld(ABC, Generic[ConfigT]):
100
106
  self.logger = logging.getLogger(f"plato.worlds.{self.name}")
101
107
  self._step_count: int = 0
102
108
  self.plato_session = None
109
+ self._current_step_id: str | None = None
103
110
 
104
111
  @classmethod
105
112
  def get_config_class(cls) -> type[RunConfig]:
@@ -138,6 +145,7 @@ class BaseWorld(ABC, Generic[ConfigT]):
138
145
  "required": schema.get("required", []),
139
146
  "agents": schema.get("agents", []),
140
147
  "secrets": schema.get("secrets", []),
148
+ "envs": schema.get("envs", []),
141
149
  }
142
150
 
143
151
  @abstractmethod
@@ -166,6 +174,9 @@ class BaseWorld(ABC, Generic[ConfigT]):
166
174
  This is called automatically during run() to restore the session
167
175
  and start sending heartbeats while the world runs.
168
176
  """
177
+ if not self.config.plato_session:
178
+ return
179
+
169
180
  try:
170
181
  from plato.v2.async_.session import Session
171
182
 
@@ -184,6 +195,163 @@ class BaseWorld(ABC, Generic[ConfigT]):
184
195
  except Exception as e:
185
196
  self.logger.warning(f"Error stopping Plato heartbeat: {e}")
186
197
 
198
+ def get_env(self, alias: str) -> Environment | None:
199
+ """Get an environment by alias.
200
+
201
+ Use this to access environments defined in the world config (e.g., gitea, localstack).
202
+
203
+ Args:
204
+ alias: The environment alias (e.g., "gitea", "localstack", "runtime")
205
+
206
+ Returns:
207
+ The Environment object or None if not found or session not connected.
208
+
209
+ Example:
210
+ gitea = self.get_env("gitea")
211
+ if gitea:
212
+ result = await gitea.execute("git status")
213
+ """
214
+ if not self.plato_session:
215
+ self.logger.warning("Cannot get env: Plato session not connected")
216
+ return None
217
+ return self.plato_session.get_env(alias)
218
+
219
+ @property
220
+ def envs(self) -> list[Environment]:
221
+ """Get all environments in the Plato session.
222
+
223
+ Returns:
224
+ List of Environment objects. Empty list if session not connected.
225
+ """
226
+ if not self.plato_session:
227
+ return []
228
+ return self.plato_session.envs
229
+
230
+ def get_sim_env_vars(self) -> dict[str, str]:
231
+ """Get environment variables from all configured sims.
232
+
233
+ Automatically discovers and loads env vars from sims like localstack, gitea, etc.
234
+ based on the environments configured in the world.
235
+
236
+ Returns:
237
+ Dict of environment variable name -> value
238
+
239
+ Example:
240
+ env_vars = self.get_sim_env_vars()
241
+ # Returns: {"AWS_ENDPOINT_URL": "https://...", "GITEA_URL": "https://...", ...}
242
+ """
243
+ env_vars: dict[str, str] = {}
244
+
245
+ # Known sim packages and their env aliases
246
+ sim_packages = [
247
+ ("localstack", "localstack"),
248
+ ("gitea", "gitea"),
249
+ ]
250
+
251
+ for package_name, env_alias in sim_packages:
252
+ env = self.get_env(env_alias)
253
+ if not env:
254
+ continue
255
+
256
+ try:
257
+ # Dynamically import the sim package
258
+ sim_module = __import__(f"plato.sims.{package_name}", fromlist=[package_name])
259
+
260
+ # Get service URLs and env vars
261
+ service_urls = sim_module.get_service_urls(env.job_id)
262
+ sim_vars = sim_module.get_env_vars(service_urls)
263
+ env_vars.update(sim_vars)
264
+ self.logger.info(f"{package_name} env vars: {list(sim_vars.keys())}")
265
+ except ImportError:
266
+ self.logger.debug(f"{package_name} sim package not installed, skipping")
267
+ except Exception as e:
268
+ self.logger.warning(f"Failed to get {package_name} env vars: {e}")
269
+
270
+ return env_vars
271
+
272
+ def get_sim_instructions(self) -> str:
273
+ """Get usage instructions from all configured sims.
274
+
275
+ Returns markdown-formatted instructions for using LocalStack, Gitea, etc.
276
+ based on the environments configured in the world.
277
+
278
+ Returns:
279
+ Markdown string with instructions, or empty string if no sims configured.
280
+
281
+ Example:
282
+ instructions = self.get_sim_instructions()
283
+ # Returns markdown with LocalStack/Gitea setup instructions
284
+ """
285
+ instructions_parts: list[str] = []
286
+
287
+ # Known sim packages and their env aliases
288
+ sim_packages = [
289
+ ("localstack", "localstack"),
290
+ ("gitea", "gitea"),
291
+ ]
292
+
293
+ for package_name, env_alias in sim_packages:
294
+ env = self.get_env(env_alias)
295
+ if not env:
296
+ continue
297
+
298
+ try:
299
+ # Dynamically import the sim package
300
+ sim_module = __import__(f"plato.sims.{package_name}", fromlist=[package_name])
301
+
302
+ # Get instructions using the job_id
303
+ if hasattr(sim_module, "get_instructions_from_job"):
304
+ instructions = sim_module.get_instructions_from_job(env.job_id)
305
+ if instructions:
306
+ instructions_parts.append(instructions)
307
+ self.logger.info(f"Added {package_name} instructions to prompt")
308
+ except ImportError:
309
+ self.logger.debug(f"{package_name} sim package not installed, skipping instructions")
310
+ except Exception as e:
311
+ self.logger.warning(f"Failed to get {package_name} instructions: {e}")
312
+
313
+ if instructions_parts:
314
+ return "\n\n---\n\n".join(instructions_parts)
315
+ return ""
316
+
317
+ def format_instruction_with_sims(self, instruction: str) -> str:
318
+ """Format an instruction with sim context prepended.
319
+
320
+ Automatically adds available service instructions (LocalStack, Gitea, etc.)
321
+ before the main instruction.
322
+
323
+ Args:
324
+ instruction: The base instruction/task
325
+
326
+ Returns:
327
+ Formatted instruction with sim context, or original instruction if no sims.
328
+
329
+ Example:
330
+ formatted = self.format_instruction_with_sims("Fix the bug in main.py")
331
+ # Returns:
332
+ # ## Available Services
333
+ # The following services are available...
334
+ # [LocalStack instructions]
335
+ # ---
336
+ # ## Task
337
+ # Fix the bug in main.py
338
+ """
339
+ sim_instructions = self.get_sim_instructions()
340
+
341
+ if sim_instructions:
342
+ return f"""## Available Services
343
+
344
+ The following services are available for your use:
345
+
346
+ {sim_instructions}
347
+
348
+ ---
349
+
350
+ ## Task
351
+
352
+ {instruction}"""
353
+ return instruction
354
+
187
355
  async def run(self, config: ConfigT) -> None:
188
356
  """Run the world: reset -> step until done -> close.
189
357
 
@@ -195,16 +363,56 @@ class BaseWorld(ABC, Generic[ConfigT]):
195
363
 
196
364
  self.logger.info(f"Starting world '{self.name}'")
197
365
 
366
+ # Initialize the logging singleton for agents to use
367
+ if config.callback_url and config.session_id:
368
+ _init_chronos_logging(
369
+ callback_url=config.callback_url,
370
+ session_id=config.session_id,
371
+ )
372
+
198
373
  # Connect to Plato session if configured (for heartbeats)
199
374
  await self._connect_plato_session()
200
375
 
376
+ # Log session start
377
+ await _log_event(
378
+ span_type="session_start",
379
+ content=f"World '{self.name}' started",
380
+ source="world",
381
+ extra={"world_name": self.name, "world_version": self.get_version()},
382
+ )
383
+
201
384
  try:
202
- obs = await self.reset()
385
+ # Execute reset with automatic span tracking
386
+ async with _span("reset", span_type="reset", source="world") as reset_span:
387
+ reset_span.log(f"Resetting world '{self.name}'")
388
+ obs = await self.reset()
389
+ reset_span.set_extra({"observation": obs.model_dump() if hasattr(obs, "model_dump") else str(obs)})
203
390
  self.logger.info(f"World reset complete: {obs}")
204
391
 
205
392
  while True:
206
393
  self._step_count += 1
207
- result = await self.step()
394
+
395
+ # Execute step with automatic span tracking
396
+ # The span automatically sets itself as the current parent,
397
+ # so agent trajectories will nest under this step
398
+ async with _span(
399
+ f"step_{self._step_count}",
400
+ span_type="step",
401
+ source="world",
402
+ ) as step_span:
403
+ self._current_step_id = step_span.event_id
404
+ step_span.log(f"Step {self._step_count} started")
405
+ result = await self.step()
406
+ step_span.set_extra(
407
+ {
408
+ "done": result.done,
409
+ "observation": result.observation.model_dump()
410
+ if hasattr(result.observation, "model_dump")
411
+ else str(result.observation),
412
+ "info": result.info,
413
+ }
414
+ )
415
+
208
416
  self.logger.info(f"Step {self._step_count}: done={result.done}")
209
417
 
210
418
  if result.done:
@@ -213,4 +421,16 @@ class BaseWorld(ABC, Generic[ConfigT]):
213
421
  finally:
214
422
  await self.close()
215
423
  await self._disconnect_plato_session()
424
+
425
+ # Log session end
426
+ await _log_event(
427
+ span_type="session_end",
428
+ content=f"World '{self.name}' completed after {self._step_count} steps",
429
+ source="world",
430
+ extra={"total_steps": self._step_count},
431
+ )
432
+
433
+ # Reset the logging singleton
434
+ _reset_chronos_logging()
435
+
216
436
  self.logger.info(f"World '{self.name}' completed after {self._step_count} steps")
plato/worlds/config.py CHANGED
@@ -7,8 +7,16 @@ from typing import Any
7
7
 
8
8
  from pydantic import BaseModel, Field
9
9
 
10
+ from plato._generated.models import (
11
+ EnvFromArtifact,
12
+ EnvFromResource,
13
+ EnvFromSimulator,
14
+ )
10
15
  from plato.v2.async_.session import SerializedSession
11
16
 
17
+ # Union type for environment configurations
18
+ EnvConfig = EnvFromArtifact | EnvFromSimulator | EnvFromResource
19
+
12
20
 
13
21
  class AgentConfig(BaseModel):
14
22
  """Configuration for an agent.
@@ -46,10 +54,28 @@ class Secret:
46
54
  self.required = required
47
55
 
48
56
 
57
+ class Env:
58
+ """Annotation marker for environment fields.
59
+
60
+ Environments are VMs that run alongside the world's runtime.
61
+ They can be specified by artifact ID, simulator name, or resource config.
62
+
63
+ Usage:
64
+ gitea: Annotated[EnvConfig, Env(description="Git server")] = EnvFromArtifact(
65
+ artifact_id="abc123",
66
+ alias="gitea",
67
+ )
68
+ """
69
+
70
+ def __init__(self, description: str = "", required: bool = True):
71
+ self.description = description
72
+ self.required = required
73
+
74
+
49
75
  class RunConfig(BaseModel):
50
76
  """Base configuration for running a world.
51
77
 
52
- Subclass this with your world-specific fields, agents, and secrets:
78
+ Subclass this with your world-specific fields, agents, secrets, and envs:
53
79
 
54
80
  class CodeWorldConfig(RunConfig):
55
81
  # World-specific fields
@@ -62,6 +88,12 @@ class RunConfig(BaseModel):
62
88
  # Secrets (typed)
63
89
  git_token: Annotated[str | None, Secret(description="GitHub token")] = None
64
90
 
91
+ # Environments (typed)
92
+ gitea: Annotated[EnvConfig, Env(description="Git server")] = EnvFromArtifact(
93
+ artifact_id="abc123",
94
+ alias="gitea",
95
+ )
96
+
65
97
  Attributes:
66
98
  session_id: Unique Chronos session identifier
67
99
  callback_url: Callback URL for status updates
@@ -74,21 +106,21 @@ class RunConfig(BaseModel):
74
106
 
75
107
  # Serialized Plato session for connecting to VM and sending heartbeats
76
108
  # This is the output of Session.dump() - used to restore session with Session.load()
77
- plato_session: SerializedSession
109
+ plato_session: SerializedSession | None = None
78
110
 
79
111
  model_config = {"extra": "allow"}
80
112
 
81
113
  @classmethod
82
- def get_field_annotations(cls) -> dict[str, Agent | Secret | None]:
83
- """Get Agent/Secret annotations for each field."""
84
- result: dict[str, Agent | Secret | None] = {}
114
+ def get_field_annotations(cls) -> dict[str, Agent | Secret | Env | None]:
115
+ """Get Agent/Secret/Env annotations for each field."""
116
+ result: dict[str, Agent | Secret | Env | None] = {}
85
117
 
86
118
  for field_name, field_info in cls.model_fields.items():
87
119
  marker = None
88
120
 
89
121
  # Pydantic stores Annotated metadata in field_info.metadata
90
122
  for meta in field_info.metadata:
91
- if isinstance(meta, (Agent, Secret)):
123
+ if isinstance(meta, (Agent, Secret, Env)):
92
124
  marker = meta
93
125
  break
94
126
 
@@ -98,7 +130,7 @@ class RunConfig(BaseModel):
98
130
 
99
131
  @classmethod
100
132
  def get_json_schema(cls) -> dict:
101
- """Get JSON schema with agents and secrets separated."""
133
+ """Get JSON schema with agents, secrets, and envs separated."""
102
134
  # Get full Pydantic schema
103
135
  full_schema = cls.model_json_schema()
104
136
  full_schema.pop("title", None)
@@ -110,6 +142,7 @@ class RunConfig(BaseModel):
110
142
  world_properties = {}
111
143
  agents = []
112
144
  secrets = []
145
+ envs = []
113
146
 
114
147
  # Skip runtime fields
115
148
  runtime_fields = {"session_id", "callback_url", "all_secrets", "plato_session"}
@@ -136,6 +169,26 @@ class RunConfig(BaseModel):
136
169
  "required": marker.required,
137
170
  }
138
171
  )
172
+ elif isinstance(marker, Env):
173
+ # Get default value for this env field
174
+ field_info = cls.model_fields.get(field_name)
175
+ default_value = None
176
+ if field_info and field_info.default is not None:
177
+ # Serialize the default EnvConfig to dict
178
+ default_env = field_info.default
179
+ if hasattr(default_env, "model_dump"):
180
+ default_value = default_env.model_dump()
181
+ elif isinstance(default_env, dict):
182
+ default_value = default_env
183
+
184
+ envs.append(
185
+ {
186
+ "name": field_name,
187
+ "description": marker.description,
188
+ "required": marker.required,
189
+ "default": default_value,
190
+ }
191
+ )
139
192
  else:
140
193
  world_properties[field_name] = prop_schema
141
194
 
@@ -149,8 +202,26 @@ class RunConfig(BaseModel):
149
202
  "required": required,
150
203
  "agents": agents,
151
204
  "secrets": secrets,
205
+ "envs": envs,
152
206
  }
153
207
 
208
+ def get_envs(self) -> list[EnvConfig]:
209
+ """Get all environment configurations from this config.
210
+
211
+ Returns:
212
+ List of EnvConfig objects (EnvFromArtifact, EnvFromSimulator, or EnvFromResource)
213
+ """
214
+ annotations = self.get_field_annotations()
215
+ envs: list[EnvConfig] = []
216
+
217
+ for field_name, marker in annotations.items():
218
+ if isinstance(marker, Env):
219
+ value = getattr(self, field_name, None)
220
+ if value is not None:
221
+ envs.append(value)
222
+
223
+ return envs
224
+
154
225
  @classmethod
155
226
  def from_file(cls, path: str | Path) -> RunConfig:
156
227
  """Load config from a JSON file."""
@@ -177,6 +248,9 @@ class RunConfig(BaseModel):
177
248
  # Handle secrets dict -> individual secret fields
178
249
  secrets_dict = data.pop("secrets", {})
179
250
 
251
+ # Handle envs dict -> individual env fields
252
+ envs_dict = data.pop("envs", {})
253
+
180
254
  # Merge world_config into top-level
181
255
  parsed.update(world_config)
182
256
  parsed.update(data)
@@ -191,8 +265,24 @@ class RunConfig(BaseModel):
191
265
  parsed[field_name] = AgentConfig(image=str(agent_data))
192
266
  elif isinstance(marker, Secret) and field_name in secrets_dict:
193
267
  parsed[field_name] = secrets_dict[field_name]
268
+ elif isinstance(marker, Env) and field_name in envs_dict:
269
+ env_data = envs_dict[field_name]
270
+ if isinstance(env_data, dict):
271
+ parsed[field_name] = _parse_env_config(env_data)
272
+ else:
273
+ parsed[field_name] = env_data
194
274
 
195
275
  # Store all secrets for agent use
196
276
  parsed["all_secrets"] = secrets_dict
197
277
 
198
278
  return cls(**parsed)
279
+
280
+
281
+ def _parse_env_config(data: dict) -> EnvConfig:
282
+ """Parse an env config dict into the appropriate type."""
283
+ if "artifact_id" in data:
284
+ return EnvFromArtifact(**data)
285
+ elif "sim_config" in data:
286
+ return EnvFromResource(**data)
287
+ else:
288
+ return EnvFromSimulator(**data)