plato-sdk-v2 2.3.10__py3-none-any.whl → 2.4.0__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/agents/config.py CHANGED
@@ -18,7 +18,6 @@ Example:
18
18
  from __future__ import annotations
19
19
 
20
20
  import json
21
- from pathlib import Path
22
21
  from typing import Any
23
22
 
24
23
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -147,9 +146,18 @@ class AgentConfig(BaseSettings):
147
146
  return result
148
147
 
149
148
  @classmethod
150
- def from_file(cls, path: str | Path) -> AgentConfig:
151
- """Load config from a JSON file."""
152
- path = Path(path)
153
- with open(path) as f:
154
- data = json.load(f)
149
+ def from_env(cls) -> AgentConfig:
150
+ """Load config from AGENT_CONFIG_B64 environment variable.
151
+
152
+ The runner passes config as base64-encoded JSON in the
153
+ AGENT_CONFIG_B64 environment variable.
154
+ """
155
+ import base64
156
+ import os
157
+
158
+ config_b64 = os.environ.get("AGENT_CONFIG_B64")
159
+ if not config_b64:
160
+ raise ValueError("AGENT_CONFIG_B64 environment variable not set")
161
+ config_json = base64.b64decode(config_b64).decode()
162
+ data = json.loads(config_json)
155
163
  return cls(**data)
plato/agents/otel.py CHANGED
@@ -164,8 +164,12 @@ def init_tracing(
164
164
  _module_logger.error(f"Failed to initialize tracing: {e}")
165
165
 
166
166
 
167
- def shutdown_tracing() -> None:
168
- """Shutdown the tracer provider and flush spans."""
167
+ def shutdown_tracing(timeout_millis: int = 30000) -> None:
168
+ """Shutdown the tracer provider and flush spans.
169
+
170
+ Args:
171
+ timeout_millis: Timeout in milliseconds to wait for flush (default 30s)
172
+ """
169
173
  global _tracer_provider, _initialized, _log_handler
170
174
 
171
175
  # Remove log handler
@@ -179,9 +183,18 @@ def shutdown_tracing() -> None:
179
183
 
180
184
  if _tracer_provider:
181
185
  try:
186
+ # Force flush all pending spans before shutdown
187
+ print(f"[OTel] Flushing spans (timeout={timeout_millis}ms)...")
188
+ flush_success = _tracer_provider.force_flush(timeout_millis=timeout_millis)
189
+ if flush_success:
190
+ print("[OTel] Span flush completed successfully")
191
+ else:
192
+ print("[OTel] Span flush timed out or failed")
193
+
182
194
  _tracer_provider.shutdown()
183
- _module_logger.info("OTel tracing shutdown complete")
195
+ print("[OTel] Tracing shutdown complete")
184
196
  except Exception as e:
197
+ print(f"[OTel] Error shutting down tracer: {e}")
185
198
  _module_logger.warning(f"Error shutting down tracer: {e}")
186
199
 
187
200
  _tracer_provider = None
plato/agents/runner.py CHANGED
@@ -10,16 +10,15 @@ Agents emit their own OTel spans for trajectory events. This runner:
10
10
  from __future__ import annotations
11
11
 
12
12
  import asyncio
13
+ import base64
13
14
  import json
14
15
  import logging
15
16
  import os
16
17
  import platform
17
- import tempfile
18
+ import uuid
18
19
 
19
20
  from opentelemetry import trace
20
21
 
21
- from plato.agents.artifacts import upload_artifacts
22
-
23
22
  logger = logging.getLogger(__name__)
24
23
 
25
24
 
@@ -28,10 +27,10 @@ async def run_agent(
28
27
  config: dict,
29
28
  secrets: dict[str, str],
30
29
  instruction: str,
31
- workspace: str,
30
+ workspace: str | None = None,
32
31
  logs_dir: str | None = None,
33
32
  pull: bool = True,
34
- ) -> None:
33
+ ) -> str:
35
34
  """Run an agent in a Docker container.
36
35
 
37
36
  Args:
@@ -39,15 +38,19 @@ async def run_agent(
39
38
  config: Agent configuration dict
40
39
  secrets: Secret values (API keys, etc.)
41
40
  instruction: Task instruction for the agent
42
- workspace: Host directory to mount as /workspace
43
- logs_dir: Host directory for logs (temp dir if None)
41
+ workspace: Docker volume name for workspace (created if None)
42
+ logs_dir: Ignored (kept for backwards compatibility)
44
43
  pull: Whether to pull the image first
45
44
 
45
+ Returns:
46
+ The container name that was created (for cleanup purposes)
47
+
46
48
  Note: Agents handle their own OTel tracing. This runner only passes
47
49
  the trace context (TRACEPARENT) so agent spans link to the parent step.
48
- """
49
- logs_dir = logs_dir or tempfile.mkdtemp(prefix="agent_logs_")
50
50
 
51
+ Note: This uses Docker volumes (not bind mounts) for DIND compatibility.
52
+ The workspace parameter should be a Docker volume name.
53
+ """
51
54
  # Get session info from environment variables
52
55
  session_id = os.environ.get("SESSION_ID")
53
56
  otel_url = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
@@ -64,15 +67,41 @@ async def run_agent(
64
67
  )
65
68
  await pull_proc.wait()
66
69
 
67
- # Setup
68
- os.makedirs(os.path.join(logs_dir, "agent"), exist_ok=True)
69
- config_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
70
- json.dump(config, config_file)
71
- config_file.close()
70
+ # Encode config as base64 to pass via environment variable
71
+ # This avoids file mount issues in Docker-in-Docker scenarios
72
+ config_json = json.dumps(config)
73
+ config_b64 = base64.b64encode(config_json.encode()).decode()
74
+
75
+ # Generate a unique container name for inspection
76
+ container_name = f"agent-{uuid.uuid4().hex[:8]}"
77
+
78
+ # Use WORKSPACE_VOLUME env var if set (for DIND compatibility)
79
+ # Otherwise create a new volume
80
+ workspace_volume = os.environ.get("WORKSPACE_VOLUME") or workspace or f"workspace-{uuid.uuid4().hex[:8]}"
81
+ if not os.environ.get("WORKSPACE_VOLUME") and not workspace:
82
+ await asyncio.create_subprocess_exec(
83
+ "docker",
84
+ "volume",
85
+ "create",
86
+ workspace_volume,
87
+ stdout=asyncio.subprocess.DEVNULL,
88
+ stderr=asyncio.subprocess.DEVNULL,
89
+ )
90
+
91
+ # Create logs volume
92
+ logs_volume = f"logs-{uuid.uuid4().hex[:8]}"
93
+ await asyncio.create_subprocess_exec(
94
+ "docker",
95
+ "volume",
96
+ "create",
97
+ logs_volume,
98
+ stdout=asyncio.subprocess.DEVNULL,
99
+ stderr=asyncio.subprocess.DEVNULL,
100
+ )
72
101
 
73
102
  try:
74
103
  # Build docker command
75
- docker_cmd = ["docker", "run", "--rm", "--privileged"]
104
+ docker_cmd = ["docker", "run", "--rm", "--privileged", "--name", container_name]
76
105
 
77
106
  # Determine if we need host networking
78
107
  use_host_network = False
@@ -97,18 +126,19 @@ async def run_agent(
97
126
  if use_host_network:
98
127
  docker_cmd.extend(["--network=host", "--add-host=localhost:127.0.0.1"])
99
128
 
129
+ # Use Docker volumes instead of bind mounts for DIND compatibility
100
130
  docker_cmd.extend(
101
131
  [
102
132
  "-v",
103
- f"{workspace}:/workspace",
104
- "-v",
105
- f"{logs_dir}:/logs",
133
+ f"{workspace_volume}:/workspace",
106
134
  "-v",
107
- f"{config_file.name}:/config.json:ro",
135
+ f"{logs_volume}:/logs",
108
136
  "-v",
109
137
  "/var/run/docker.sock:/var/run/docker.sock",
110
138
  "-w",
111
139
  "/workspace",
140
+ "-e",
141
+ f"AGENT_CONFIG_B64={config_b64}",
112
142
  ]
113
143
  )
114
144
 
@@ -151,6 +181,8 @@ async def run_agent(
151
181
  # Pass instruction via CLI arg
152
182
  docker_cmd.extend(["--instruction", instruction])
153
183
 
184
+ logger.info(f"Starting container: {container_name}")
185
+
154
186
  # Run container - agents emit their own OTel spans
155
187
  # Use large limit to handle agents that output long lines (e.g., JSON with file contents)
156
188
  process = await asyncio.create_subprocess_exec(
@@ -160,7 +192,33 @@ async def run_agent(
160
192
  limit=100 * 1024 * 1024, # 100MB buffer limit
161
193
  )
162
194
 
163
- # Capture output for error reporting using chunked reads to handle large lines
195
+ # Get and print container IP in background
196
+ async def print_container_ip():
197
+ await asyncio.sleep(3) # Wait for container to start
198
+ try:
199
+ inspect_proc = await asyncio.create_subprocess_exec(
200
+ "docker",
201
+ "inspect",
202
+ "-f",
203
+ "{{.NetworkSettings.IPAddress}}",
204
+ container_name,
205
+ stdout=asyncio.subprocess.PIPE,
206
+ stderr=asyncio.subprocess.PIPE,
207
+ )
208
+ stdout, _ = await inspect_proc.communicate()
209
+ container_ip = stdout.decode().strip()
210
+ if container_ip:
211
+ logger.info("=" * 50)
212
+ logger.info(f"Container: {container_name}")
213
+ logger.info(f"Container IP: {container_ip}")
214
+ logger.info(f"noVNC: http://{container_ip}:6080")
215
+ logger.info("=" * 50)
216
+ except Exception:
217
+ pass
218
+
219
+ asyncio.create_task(print_container_ip())
220
+
221
+ # Stream and capture output for error reporting using chunked reads to handle large lines
164
222
  output_lines: list[str] = []
165
223
  assert process.stdout is not None
166
224
  buffer = ""
@@ -176,10 +234,13 @@ async def run_agent(
176
234
  while "\n" in buffer:
177
235
  line, buffer = buffer.split("\n", 1)
178
236
  output_lines.append(line)
237
+ # Print agent output in real-time
238
+ print(f"[agent] {line}")
179
239
 
180
240
  # Handle any remaining content in buffer
181
241
  if buffer.strip():
182
242
  output_lines.append(buffer)
243
+ print(f"[agent] {buffer}")
183
244
 
184
245
  await process.wait()
185
246
 
@@ -189,8 +250,16 @@ async def run_agent(
189
250
  raise RuntimeError(f"Agent failed with exit code {exit_code}\n\nAgent output:\n{error_context}")
190
251
 
191
252
  finally:
192
- os.unlink(config_file.name)
253
+ # Clean up volumes
254
+ await asyncio.create_subprocess_exec(
255
+ "docker",
256
+ "volume",
257
+ "rm",
258
+ "-f",
259
+ logs_volume,
260
+ stdout=asyncio.subprocess.DEVNULL,
261
+ stderr=asyncio.subprocess.DEVNULL,
262
+ )
263
+ # Note: workspace_volume is not cleaned up as it may be shared
193
264
 
194
- # Upload artifacts if we have upload URL configured
195
- if upload_url:
196
- await upload_artifacts(upload_url, logs_dir)
265
+ return container_name