plato-sdk-v2 2.3.4__py3-none-any.whl → 2.3.5__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/__init__.py CHANGED
@@ -20,8 +20,9 @@ Trajectory (ATIF):
20
20
  - Trajectory: ATIF trajectory model
21
21
  - Step, Agent, ToolCall, etc.: ATIF components
22
22
 
23
- Callback:
24
- - ChronosCallback: Utility for Chronos communication
23
+ OTel Tracing:
24
+ - instrument: Initialize OTel tracing from environment
25
+ - get_tracer: Get a tracer for creating spans
25
26
 
26
27
  Example (direct execution):
27
28
  from plato.agents import BaseAgent, AgentConfig, Secret, register_agent
@@ -80,16 +81,25 @@ __all__ = [
80
81
  "Metrics",
81
82
  "FinalMetrics",
82
83
  "SCHEMA_VERSION",
83
- # Logging
84
- "init_logging",
85
- "span",
86
- "log_event",
84
+ # Artifacts
85
+ "zip_directory",
87
86
  "upload_artifacts",
88
87
  "upload_artifact",
89
- "upload_checkpoint",
90
- "reset_logging",
88
+ "upload_to_s3",
89
+ # OTel tracing
90
+ "init_tracing",
91
+ "instrument",
92
+ "shutdown_tracing",
93
+ "get_tracer",
94
+ "is_initialized",
91
95
  ]
92
96
 
97
+ from plato.agents.artifacts import (
98
+ upload_artifact,
99
+ upload_artifacts,
100
+ upload_to_s3,
101
+ zip_directory,
102
+ )
93
103
  from plato.agents.base import (
94
104
  BaseAgent,
95
105
  ConfigT,
@@ -99,14 +109,12 @@ from plato.agents.base import (
99
109
  )
100
110
  from plato.agents.build import BuildConfig, load_build_config
101
111
  from plato.agents.config import AgentConfig, Secret
102
- from plato.agents.logging import (
103
- init_logging,
104
- log_event,
105
- reset_logging,
106
- span,
107
- upload_artifact,
108
- upload_artifacts,
109
- upload_checkpoint,
112
+ from plato.agents.otel import (
113
+ get_tracer,
114
+ init_tracing,
115
+ instrument,
116
+ is_initialized,
117
+ shutdown_tracing,
110
118
  )
111
119
  from plato.agents.runner import run_agent
112
120
  from plato.agents.trajectory import (
@@ -0,0 +1,108 @@
1
+ """Artifact upload utilities for Plato agents and worlds.
2
+
3
+ These functions upload artifacts directly to S3 using presigned URLs.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import io
9
+ import logging
10
+ import zipfile
11
+ from pathlib import Path
12
+
13
+ import httpx
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def zip_directory(dir_path: str) -> bytes:
19
+ """Zip an entire directory.
20
+
21
+ Args:
22
+ dir_path: Path to the directory
23
+
24
+ Returns:
25
+ Zip file contents as bytes.
26
+ """
27
+ path = Path(dir_path)
28
+ buffer = io.BytesIO()
29
+
30
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
31
+ for file_path in path.rglob("*"):
32
+ if file_path.is_file():
33
+ arcname = file_path.relative_to(path)
34
+ zf.write(file_path, arcname)
35
+
36
+ buffer.seek(0)
37
+ return buffer.read()
38
+
39
+
40
+ async def upload_to_s3(upload_url: str, data: bytes, content_type: str = "application/octet-stream") -> bool:
41
+ """Upload data directly to S3 using a presigned URL.
42
+
43
+ Args:
44
+ upload_url: Presigned S3 PUT URL
45
+ data: Raw bytes to upload
46
+ content_type: MIME type of the content
47
+
48
+ Returns:
49
+ True if successful, False otherwise
50
+ """
51
+ if not upload_url:
52
+ logger.warning("No upload URL provided")
53
+ return False
54
+
55
+ try:
56
+ async with httpx.AsyncClient(timeout=120.0) as client:
57
+ response = await client.put(
58
+ upload_url,
59
+ content=data,
60
+ headers={"Content-Type": content_type},
61
+ )
62
+ if response.status_code in (200, 201, 204):
63
+ logger.info(f"Uploaded {len(data)} bytes to S3")
64
+ return True
65
+ else:
66
+ logger.warning(f"S3 upload failed: {response.status_code} {response.text}")
67
+ return False
68
+ except Exception as e:
69
+ logger.warning(f"Failed to upload to S3: {e}")
70
+ return False
71
+
72
+
73
+ async def upload_artifacts(upload_url: str, dir_path: str) -> bool:
74
+ """Upload a directory as a zip directly to S3.
75
+
76
+ Args:
77
+ upload_url: Presigned S3 PUT URL
78
+ dir_path: Path to the directory to upload
79
+
80
+ Returns:
81
+ True if successful, False otherwise
82
+ """
83
+ try:
84
+ zip_data = zip_directory(dir_path)
85
+ logger.info(f"Zipped directory: {len(zip_data)} bytes")
86
+ except Exception as e:
87
+ logger.warning(f"Failed to zip directory: {e}")
88
+ return False
89
+
90
+ return await upload_to_s3(upload_url, zip_data, "application/zip")
91
+
92
+
93
+ async def upload_artifact(
94
+ upload_url: str,
95
+ data: bytes,
96
+ content_type: str = "application/octet-stream",
97
+ ) -> bool:
98
+ """Upload an artifact directly to S3.
99
+
100
+ Args:
101
+ upload_url: Presigned S3 PUT URL
102
+ data: Raw bytes of the artifact
103
+ content_type: MIME type of the content
104
+
105
+ Returns:
106
+ True if successful, False otherwise
107
+ """
108
+ return await upload_to_s3(upload_url, data, content_type)
plato/agents/config.py CHANGED
@@ -21,7 +21,6 @@ import json
21
21
  from pathlib import Path
22
22
  from typing import Any
23
23
 
24
- from pydantic import Field
25
24
  from pydantic_settings import BaseSettings, SettingsConfigDict
26
25
 
27
26
 
@@ -57,8 +56,6 @@ class AgentConfig(BaseSettings):
57
56
 
58
57
  Attributes:
59
58
  logs_dir: Directory for agent logs and trajectory output.
60
- checkpoint_paths: Directories to watch for checkpoint triggers (for workspace tracking).
61
- checkpoint_debounce_ms: Debounce interval for checkpoints.
62
59
  """
63
60
 
64
61
  model_config = SettingsConfigDict(
@@ -68,8 +65,6 @@ class AgentConfig(BaseSettings):
68
65
  )
69
66
 
70
67
  logs_dir: str = "/logs"
71
- checkpoint_paths: list[str] = Field(default_factory=list)
72
- checkpoint_debounce_ms: int = 500
73
68
 
74
69
  @classmethod
75
70
  def get_field_secrets(cls) -> dict[str, Secret]:
@@ -97,7 +92,7 @@ class AgentConfig(BaseSettings):
97
92
  secrets = []
98
93
 
99
94
  # Skip internal fields
100
- internal_fields = {"logs_dir", "checkpoint_paths", "checkpoint_debounce_ms"}
95
+ internal_fields = {"logs_dir"}
101
96
 
102
97
  for field_name, prop_schema in properties.items():
103
98
  if field_name in internal_fields:
@@ -140,7 +135,7 @@ class AgentConfig(BaseSettings):
140
135
  def get_config_dict(self) -> dict[str, Any]:
141
136
  """Extract non-secret config values as a dict."""
142
137
  secrets_map = self.get_field_secrets()
143
- internal_fields = {"logs_dir", "checkpoint_paths", "checkpoint_debounce_ms"}
138
+ internal_fields = {"logs_dir"}
144
139
 
145
140
  result: dict[str, Any] = {}
146
141
  for field_name in self.model_fields:
plato/agents/otel.py ADDED
@@ -0,0 +1,258 @@
1
+ """OpenTelemetry integration for Plato agents and worlds.
2
+
3
+ Provides tracing and logging utilities using OpenTelemetry SDK. Traces and logs
4
+ are sent directly to the Chronos OTLP endpoint.
5
+
6
+ Usage:
7
+ from plato.agents.otel import init_tracing, get_tracer, shutdown_tracing
8
+
9
+ # Initialize tracing (sends to Chronos OTLP endpoint)
10
+ init_tracing(
11
+ service_name="my-world",
12
+ session_id="session-123",
13
+ otlp_endpoint="http://chronos/api/otel",
14
+ )
15
+
16
+ # Create spans
17
+ tracer = get_tracer()
18
+ with tracer.start_as_current_span("my-operation") as span:
19
+ span.set_attribute("key", "value")
20
+ # ... do work ...
21
+
22
+ # All Python logging is automatically sent to Chronos
23
+ import logging
24
+ logger = logging.getLogger(__name__)
25
+ logger.info("This will appear in the trajectory viewer!")
26
+
27
+ # Cleanup
28
+ shutdown_tracing()
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+
35
+ from opentelemetry import trace
36
+ from opentelemetry.trace import Tracer
37
+
38
+ _module_logger = logging.getLogger(__name__)
39
+
40
+ # Global state
41
+ _tracer_provider = None
42
+ _logging_handler = None
43
+ _initialized = False
44
+
45
+
46
+ class OTelLoggingHandler(logging.Handler):
47
+ """Logging handler that emits OTel spans for log messages.
48
+
49
+ Each log message becomes a span with:
50
+ - span.type: "log"
51
+ - log.level: DEBUG/INFO/WARNING/ERROR/CRITICAL
52
+ - content: the log message
53
+ - source: the logger name
54
+ """
55
+
56
+ def __init__(self, tracer_name: str = "plato.logging"):
57
+ super().__init__()
58
+ self._tracer_name = tracer_name
59
+ # Filter out noisy loggers
60
+ self._ignored_loggers = {
61
+ "httpx",
62
+ "httpcore",
63
+ "urllib3",
64
+ "asyncio",
65
+ "opentelemetry",
66
+ "plato.agents.otel", # Avoid recursion
67
+ }
68
+
69
+ def emit(self, record: logging.LogRecord) -> None:
70
+ """Emit a log record as an OTel span."""
71
+ # Skip ignored loggers
72
+ logger_name = record.name
73
+ for ignored in self._ignored_loggers:
74
+ if logger_name.startswith(ignored):
75
+ return
76
+
77
+ try:
78
+ tracer = trace.get_tracer(self._tracer_name)
79
+
80
+ # Format the message
81
+ try:
82
+ msg = self.format(record)
83
+ except Exception:
84
+ msg = record.getMessage()
85
+
86
+ # Create a span for the log message
87
+ with tracer.start_as_current_span(
88
+ f"log.{record.levelname.lower()}",
89
+ end_on_exit=True,
90
+ ) as span:
91
+ span.set_attribute("span.type", "log")
92
+ span.set_attribute("log.level", record.levelname)
93
+ span.set_attribute("content", msg)
94
+ span.set_attribute("source", logger_name)
95
+
96
+ # Add extra context if available
97
+ if record.funcName:
98
+ span.set_attribute("log.function", record.funcName)
99
+ if record.pathname:
100
+ span.set_attribute("log.file", record.pathname)
101
+ if record.lineno:
102
+ span.set_attribute("log.line", record.lineno)
103
+
104
+ # If there's an exception, record it
105
+ if record.exc_info and record.exc_info[1]:
106
+ span.record_exception(record.exc_info[1])
107
+
108
+ except Exception:
109
+ # Don't let logging failures break the application
110
+ pass
111
+
112
+
113
+ def init_tracing(
114
+ service_name: str,
115
+ session_id: str,
116
+ otlp_endpoint: str,
117
+ capture_logging: bool = True,
118
+ log_level: int = logging.INFO,
119
+ ) -> None:
120
+ """Initialize OpenTelemetry tracing and optionally capture Python logging.
121
+
122
+ Args:
123
+ service_name: Name of the service (e.g., world name or agent name)
124
+ session_id: Chronos session ID (added as resource attribute)
125
+ otlp_endpoint: Chronos OTLP endpoint (e.g., http://chronos/api/otel)
126
+ capture_logging: If True, install handler to capture Python logs as OTel spans
127
+ log_level: Minimum log level to capture (default: INFO)
128
+ """
129
+ global _tracer_provider, _logging_handler, _initialized
130
+
131
+ if _initialized:
132
+ _module_logger.debug("Tracing already initialized")
133
+ return
134
+
135
+ try:
136
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
137
+ OTLPSpanExporter,
138
+ )
139
+ from opentelemetry.sdk.resources import Resource
140
+ from opentelemetry.sdk.trace import TracerProvider
141
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
142
+
143
+ # Create resource with session ID
144
+ resource = Resource.create(
145
+ {
146
+ "service.name": service_name,
147
+ "session.id": session_id,
148
+ }
149
+ )
150
+
151
+ # Create tracer provider
152
+ _tracer_provider = TracerProvider(resource=resource)
153
+
154
+ # Add OTLP exporter pointing to Chronos
155
+ otlp_exporter = OTLPSpanExporter(endpoint=f"{otlp_endpoint.rstrip('/')}/v1/traces")
156
+ _tracer_provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
157
+
158
+ # Set as global tracer provider
159
+ trace.set_tracer_provider(_tracer_provider)
160
+
161
+ _initialized = True
162
+
163
+ # Install logging handler to capture Python logs
164
+ if capture_logging:
165
+ _logging_handler = OTelLoggingHandler()
166
+ _logging_handler.setLevel(log_level)
167
+ # Add to root logger to capture all logs
168
+ logging.getLogger().addHandler(_logging_handler)
169
+
170
+ # Use print to ensure this shows regardless of logging config
171
+ print(f"[OTel] Tracing initialized: service={service_name}, session={session_id}, endpoint={otlp_endpoint}")
172
+ _module_logger.info(
173
+ f"OTel tracing initialized: service={service_name}, "
174
+ f"session={session_id}, endpoint={otlp_endpoint}, "
175
+ f"capture_logging={capture_logging}"
176
+ )
177
+
178
+ except ImportError as e:
179
+ print(f"[OTel] OpenTelemetry SDK not installed: {e}")
180
+ _module_logger.warning(f"OpenTelemetry SDK not installed: {e}")
181
+ except Exception as e:
182
+ print(f"[OTel] Failed to initialize tracing: {e}")
183
+ _module_logger.error(f"Failed to initialize tracing: {e}")
184
+
185
+
186
+ def shutdown_tracing() -> None:
187
+ """Shutdown the tracer provider, flush spans, and remove logging handler."""
188
+ global _tracer_provider, _logging_handler, _initialized
189
+
190
+ # Remove logging handler first
191
+ if _logging_handler:
192
+ try:
193
+ logging.getLogger().removeHandler(_logging_handler)
194
+ except Exception:
195
+ pass
196
+ _logging_handler = None
197
+
198
+ if _tracer_provider:
199
+ try:
200
+ _tracer_provider.shutdown()
201
+ _module_logger.info("OTel tracing shutdown complete")
202
+ except Exception as e:
203
+ _module_logger.warning(f"Error shutting down tracer: {e}")
204
+
205
+ _tracer_provider = None
206
+ _initialized = False
207
+
208
+
209
+ def get_tracer(name: str = "plato") -> Tracer:
210
+ """Get a tracer instance.
211
+
212
+ Args:
213
+ name: Tracer name (default: "plato")
214
+
215
+ Returns:
216
+ OpenTelemetry Tracer
217
+ """
218
+ return trace.get_tracer(name)
219
+
220
+
221
+ def is_initialized() -> bool:
222
+ """Check if OTel tracing is initialized."""
223
+ return _initialized
224
+
225
+
226
+ def instrument(service_name: str = "plato-agent") -> Tracer:
227
+ """Initialize OTel tracing from environment variables.
228
+
229
+ Reads the following env vars:
230
+ - OTEL_EXPORTER_OTLP_ENDPOINT: Chronos OTLP endpoint (required for tracing)
231
+ - SESSION_ID: Chronos session ID (default: "local")
232
+
233
+ If OTEL_EXPORTER_OTLP_ENDPOINT is not set, returns a no-op tracer.
234
+
235
+ Args:
236
+ service_name: Name of the service for traces
237
+
238
+ Returns:
239
+ OpenTelemetry Tracer
240
+ """
241
+ import os
242
+
243
+ otel_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
244
+ session_id = os.environ.get("SESSION_ID", "local")
245
+
246
+ if not otel_endpoint:
247
+ # Return default tracer (no-op if no provider configured)
248
+ return trace.get_tracer(service_name)
249
+
250
+ # Initialize tracing
251
+ init_tracing(
252
+ service_name=service_name,
253
+ session_id=session_id,
254
+ otlp_endpoint=otel_endpoint,
255
+ capture_logging=True,
256
+ )
257
+
258
+ return trace.get_tracer(service_name)