plato-sdk-v2 2.3.3__py3-none-any.whl → 2.4.1__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/logging.py DELETED
@@ -1,515 +0,0 @@
1
- """Chronos logging utilities.
2
-
3
- Simple singleton-based logging for Chronos events and spans.
4
-
5
- Usage:
6
- from plato.agents.logging import init_logging, span, log_event, upload_logs
7
-
8
- # Initialize once (typically in world setup)
9
- init_logging(
10
- callback_url="http://chronos/api/callback",
11
- session_id="session-123",
12
- )
13
-
14
- # Use spans anywhere - parent tracking is automatic
15
- async with span("clone_repo") as s:
16
- s.log("Cloning...")
17
-
18
- async with span("checkout"): # Automatically nested under clone_repo
19
- s.log("Checking out...")
20
-
21
- # Log events - parent is automatically the current span
22
- await log_event(span_type="my_event", content="Something happened")
23
-
24
- # Upload artifacts (zip of directory)
25
- await upload_artifacts(dir_path="/path/to/logs")
26
- """
27
-
28
- from __future__ import annotations
29
-
30
- import base64
31
- import io
32
- import logging
33
- import zipfile
34
- from datetime import datetime, timezone
35
- from pathlib import Path
36
- from typing import Any
37
- from uuid import uuid4
38
-
39
- import httpx
40
-
41
- logger = logging.getLogger(__name__)
42
-
43
-
44
- class _ChronosLogger:
45
- """Internal singleton logger for Chronos events."""
46
-
47
- _instance: _ChronosLogger | None = None
48
-
49
- def __init__(
50
- self,
51
- callback_url: str,
52
- session_id: str,
53
- parent_event_id: str | None = None,
54
- ):
55
- self.callback_url = callback_url.rstrip("/")
56
- self.session_id = session_id
57
- self.parent_event_id = parent_event_id
58
- self._current_span_id: str | None = None
59
- self._enabled = bool(callback_url and session_id)
60
-
61
- @property
62
- def enabled(self) -> bool:
63
- return self._enabled
64
-
65
- @property
66
- def current_parent_id(self) -> str | None:
67
- """Get the current parent ID (current span or root parent)."""
68
- return self._current_span_id or self.parent_event_id
69
-
70
- async def log_event(
71
- self,
72
- span_type: str,
73
- content: str = "",
74
- source: str = "agent",
75
- log_type: str | None = None,
76
- step_number: int | None = None,
77
- extra: dict[str, Any] | None = None,
78
- event_id: str | None = None,
79
- parent_id: str | None = None,
80
- started_at: str | None = None,
81
- ended_at: str | None = None,
82
- ) -> bool:
83
- """Log an event to Chronos."""
84
- if not self._enabled:
85
- return False
86
-
87
- try:
88
- async with httpx.AsyncClient(timeout=30.0) as client:
89
- response = await client.post(
90
- f"{self.callback_url}/event",
91
- json={
92
- "session_id": self.session_id,
93
- "span_type": span_type,
94
- "content": content,
95
- "source": source,
96
- "log_type": log_type,
97
- "step_number": step_number,
98
- "extra": extra,
99
- "event_id": event_id,
100
- "parent_id": parent_id,
101
- "started_at": started_at,
102
- "ended_at": ended_at,
103
- },
104
- )
105
- if response.status_code == 200:
106
- return True
107
- else:
108
- logger.warning(f"Failed to log event: {response.status_code} {response.text}")
109
- return False
110
- except Exception as e:
111
- logger.warning(f"Failed to log event to Chronos: {e}")
112
- return False
113
-
114
-
115
- class SpanContext:
116
- """Context manager for logging spans with automatic timing and nesting."""
117
-
118
- def __init__(
119
- self,
120
- name: str,
121
- span_type: str = "span",
122
- source: str = "agent",
123
- extra: dict[str, Any] | None = None,
124
- ):
125
- self._name = name
126
- self._span_type = span_type
127
- self._source = source
128
- self._extra = extra or {}
129
- self._event_id: str | None = None
130
- self._started_at: datetime | None = None
131
- self._child_logs: list[str] = []
132
- self._previous_span_id: str | None = None
133
-
134
- async def __aenter__(self) -> SpanContext:
135
- """Log span start and set as current span."""
136
- self._started_at = datetime.now(timezone.utc)
137
- self._event_id = str(uuid4())
138
-
139
- chronos = _ChronosLogger._instance
140
- if chronos and chronos.enabled:
141
- # Save previous span and set self as current
142
- self._previous_span_id = chronos._current_span_id
143
- parent_id = chronos.current_parent_id
144
-
145
- await chronos.log_event(
146
- span_type=self._span_type,
147
- content=f"{self._name} started",
148
- source=self._source,
149
- event_id=self._event_id,
150
- parent_id=parent_id,
151
- started_at=self._started_at.isoformat(),
152
- extra=self._extra,
153
- )
154
-
155
- # Set self as the current span for child events
156
- chronos._current_span_id = self._event_id
157
-
158
- return self
159
-
160
- async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
161
- """Log span end and restore previous span."""
162
- ended_at = datetime.now(timezone.utc)
163
- status = "failed" if exc_type else "completed"
164
-
165
- chronos = _ChronosLogger._instance
166
- if chronos and chronos.enabled:
167
- # Restore previous span
168
- chronos._current_span_id = self._previous_span_id
169
- parent_id = self._previous_span_id or chronos.parent_event_id
170
-
171
- # Update extra with any child logs
172
- extra = {**self._extra}
173
- if self._child_logs:
174
- extra["logs"] = self._child_logs
175
- if exc_type:
176
- extra["error"] = str(exc_val)
177
-
178
- await chronos.log_event(
179
- span_type=self._span_type,
180
- content=f"{self._name} {status}",
181
- source=self._source,
182
- event_id=self._event_id,
183
- parent_id=parent_id,
184
- started_at=self._started_at.isoformat() if self._started_at else None,
185
- ended_at=ended_at.isoformat(),
186
- extra=extra,
187
- )
188
-
189
- def log(self, message: str) -> None:
190
- """Add a log message to the span."""
191
- self._child_logs.append(message)
192
- logger.info(f"[{self._name}] {message}")
193
-
194
- def set_extra(self, data: dict[str, Any]) -> None:
195
- """Update the span's extra data."""
196
- self._extra.update(data)
197
-
198
- @property
199
- def event_id(self) -> str | None:
200
- """Get the span's event ID (available after entering context)."""
201
- return self._event_id
202
-
203
-
204
- class _NoOpSpanContext:
205
- """No-op span context when logging is not initialized."""
206
-
207
- def __init__(self, name: str, **kwargs):
208
- self._name = name
209
- self._event_id = str(uuid4())
210
-
211
- async def __aenter__(self) -> _NoOpSpanContext:
212
- return self
213
-
214
- async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
215
- pass
216
-
217
- def log(self, message: str) -> None:
218
- logger.debug(f"[{self._name}] {message}")
219
-
220
- def set_extra(self, data: dict[str, Any]) -> None:
221
- pass
222
-
223
- @property
224
- def event_id(self) -> str | None:
225
- return self._event_id
226
-
227
-
228
- # =============================================================================
229
- # Public API
230
- # =============================================================================
231
-
232
-
233
- def init_logging(
234
- callback_url: str,
235
- session_id: str,
236
- parent_event_id: str | None = None,
237
- ) -> None:
238
- """Initialize Chronos logging.
239
-
240
- Call this once at startup (e.g., in world.run()).
241
-
242
- Args:
243
- callback_url: Chronos callback URL (e.g., http://chronos/api/callback)
244
- session_id: Chronos session ID
245
- parent_event_id: Optional root parent event ID (e.g., step ID)
246
- """
247
- _ChronosLogger._instance = _ChronosLogger(
248
- callback_url=callback_url,
249
- session_id=session_id,
250
- parent_event_id=parent_event_id,
251
- )
252
- logger.info(f"Chronos logging initialized: session={session_id}")
253
-
254
-
255
- def set_parent_event_id(parent_event_id: str | None) -> None:
256
- """Set the root parent event ID.
257
-
258
- Useful for updating the parent when entering a new step.
259
- """
260
- if _ChronosLogger._instance:
261
- _ChronosLogger._instance.parent_event_id = parent_event_id
262
-
263
-
264
- def span(
265
- name: str,
266
- span_type: str = "span",
267
- source: str = "agent",
268
- extra: dict[str, Any] | None = None,
269
- ) -> SpanContext | _NoOpSpanContext:
270
- """Create a span context manager.
271
-
272
- Usage:
273
- async with span("my_operation") as s:
274
- s.log("Starting...")
275
- # nested spans work automatically
276
- async with span("sub_operation"):
277
- ...
278
-
279
- Args:
280
- name: Span name/description
281
- span_type: Event type for the span
282
- source: Event source (agent, world, system)
283
- extra: Additional data
284
-
285
- Returns:
286
- SpanContext (or NoOpSpanContext if logging not initialized)
287
- """
288
- if _ChronosLogger._instance and _ChronosLogger._instance.enabled:
289
- return SpanContext(
290
- name=name,
291
- span_type=span_type,
292
- source=source,
293
- extra=extra,
294
- )
295
- else:
296
- return _NoOpSpanContext(name=name)
297
-
298
-
299
- async def log_event(
300
- span_type: str,
301
- content: str = "",
302
- source: str = "agent",
303
- log_type: str | None = None,
304
- extra: dict[str, Any] | None = None,
305
- parent_id: str | None = None,
306
- ) -> bool:
307
- """Log a single event to Chronos.
308
-
309
- For spans with timing, use span() instead.
310
- """
311
- chronos = _ChronosLogger._instance
312
- if not chronos or not chronos.enabled:
313
- return False
314
-
315
- return await chronos.log_event(
316
- span_type=span_type,
317
- content=content,
318
- source=source,
319
- log_type=log_type,
320
- extra=extra,
321
- parent_id=parent_id or chronos.current_parent_id,
322
- )
323
-
324
-
325
- def is_logging_enabled() -> bool:
326
- """Check if Chronos logging is enabled."""
327
- return _ChronosLogger._instance is not None and _ChronosLogger._instance.enabled
328
-
329
-
330
- def reset_logging() -> None:
331
- """Reset the logger (mainly for testing)."""
332
- _ChronosLogger._instance = None
333
-
334
-
335
- # =============================================================================
336
- # Logs Upload
337
- # =============================================================================
338
-
339
-
340
- def zip_directory(dir_path: str) -> bytes:
341
- """Zip an entire directory.
342
-
343
- Args:
344
- dir_path: Path to the directory
345
-
346
- Returns:
347
- Zip file contents as bytes.
348
- """
349
- path = Path(dir_path)
350
- buffer = io.BytesIO()
351
-
352
- with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
353
- for file_path in path.rglob("*"):
354
- if file_path.is_file():
355
- arcname = file_path.relative_to(path)
356
- zf.write(file_path, arcname)
357
-
358
- buffer.seek(0)
359
- return buffer.read()
360
-
361
-
362
- async def upload_artifacts(dir_path: str) -> str | None:
363
- """Upload a directory as a zip to Chronos.
364
-
365
- Args:
366
- dir_path: Path to the directory to upload
367
-
368
- Returns:
369
- URL if successful, None otherwise.
370
- """
371
- chronos = _ChronosLogger._instance
372
- if not chronos or not chronos.enabled:
373
- return None
374
-
375
- try:
376
- zip_data = zip_directory(dir_path)
377
- zip_base64 = base64.b64encode(zip_data).decode("utf-8")
378
- logger.info(f"Zipped directory: {len(zip_data)} bytes")
379
- except Exception as e:
380
- logger.warning(f"Failed to zip directory: {e}")
381
- return None
382
-
383
- try:
384
- async with httpx.AsyncClient(timeout=60.0) as client:
385
- response = await client.post(
386
- f"{chronos.callback_url}/logs-upload",
387
- json={
388
- "session_id": chronos.session_id,
389
- "logs_base64": zip_base64,
390
- },
391
- )
392
- if response.status_code == 200:
393
- result = response.json()
394
- logger.info(f"Uploaded artifacts: {result}")
395
- return result.get("logs_url")
396
- else:
397
- logger.warning(f"Failed to upload artifacts: {response.status_code} {response.text}")
398
- return None
399
- except Exception as e:
400
- logger.warning(f"Failed to upload artifacts: {e}")
401
- return None
402
-
403
-
404
- # =============================================================================
405
- # Artifact Upload (Generic)
406
- # =============================================================================
407
-
408
-
409
- async def upload_artifact(
410
- data: bytes,
411
- artifact_type: str,
412
- filename: str | None = None,
413
- extra: dict[str, Any] | None = None,
414
- ) -> dict[str, Any] | None:
415
- """Upload an artifact to Chronos.
416
-
417
- Artifacts are stored in S3 and linked to the session in the database.
418
-
419
- Args:
420
- data: Raw bytes of the artifact
421
- artifact_type: Type of artifact (e.g., "state", "logs", "trajectory")
422
- filename: Optional filename for the artifact
423
- extra: Optional extra data to store with the artifact
424
-
425
- Returns:
426
- Dict with artifact_id and s3_url if successful, None otherwise.
427
- """
428
- chronos = _ChronosLogger._instance
429
- if not chronos or not chronos.enabled:
430
- return None
431
-
432
- try:
433
- data_base64 = base64.b64encode(data).decode("utf-8")
434
- logger.info(f"Uploading artifact: type={artifact_type}, size={len(data)} bytes")
435
- except Exception as e:
436
- logger.warning(f"Failed to encode artifact: {e}")
437
- return None
438
-
439
- try:
440
- async with httpx.AsyncClient(timeout=120.0) as client:
441
- response = await client.post(
442
- f"{chronos.callback_url}/artifact",
443
- json={
444
- "session_id": chronos.session_id,
445
- "artifact_type": artifact_type,
446
- "data_base64": data_base64,
447
- "filename": filename,
448
- "extra": extra or {},
449
- },
450
- )
451
- if response.status_code == 200:
452
- result = response.json()
453
- logger.info(f"Uploaded artifact: {result}")
454
- return result
455
- else:
456
- logger.warning(f"Failed to upload artifact: {response.status_code} {response.text}")
457
- return None
458
- except Exception as e:
459
- logger.warning(f"Failed to upload artifact: {e}")
460
- return None
461
-
462
-
463
- # =============================================================================
464
- # Checkpoint Upload
465
- # =============================================================================
466
-
467
-
468
- async def upload_checkpoint(
469
- step_number: int,
470
- env_snapshots: dict[str, str],
471
- state_artifact_id: str | None = None,
472
- extra: dict[str, Any] | None = None,
473
- ) -> dict[str, Any] | None:
474
- """Upload checkpoint data to Chronos.
475
-
476
- A checkpoint includes:
477
- - Environment snapshots (artifact IDs per env alias)
478
- - State artifact (git bundle of /state directory)
479
- - Extra data (step number, timestamp, etc.)
480
-
481
- Args:
482
- step_number: The step number when this checkpoint was created
483
- env_snapshots: Dict mapping env alias to artifact_id
484
- state_artifact_id: Artifact ID of the state bundle (from upload_artifact)
485
- extra: Optional additional data
486
-
487
- Returns:
488
- Dict with checkpoint_id if successful, None otherwise.
489
- """
490
- chronos = _ChronosLogger._instance
491
- if not chronos or not chronos.enabled:
492
- return None
493
-
494
- try:
495
- async with httpx.AsyncClient(timeout=60.0) as client:
496
- response = await client.post(
497
- f"{chronos.callback_url}/checkpoint",
498
- json={
499
- "session_id": chronos.session_id,
500
- "step_number": step_number,
501
- "env_snapshots": env_snapshots,
502
- "state_artifact_id": state_artifact_id,
503
- "extra": extra or {},
504
- },
505
- )
506
- if response.status_code == 200:
507
- result = response.json()
508
- logger.info(f"Uploaded checkpoint: step={step_number}, checkpoint_id={result.get('checkpoint_id')}")
509
- return result
510
- else:
511
- logger.warning(f"Failed to upload checkpoint: {response.status_code} {response.text}")
512
- return None
513
- except Exception as e:
514
- logger.warning(f"Failed to upload checkpoint: {e}")
515
- return None
@@ -1,11 +0,0 @@
1
- """API endpoints."""
2
-
3
- from . import push_agent_logs, update_agent_status, upload_artifacts, upload_logs_zip, upload_trajectory
4
-
5
- __all__ = [
6
- "push_agent_logs",
7
- "update_agent_status",
8
- "upload_trajectory",
9
- "upload_logs_zip",
10
- "upload_artifacts",
11
- ]
@@ -1,61 +0,0 @@
1
- """Push Agent Logs"""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
6
-
7
- import httpx
8
-
9
- from plato.chronos.errors import raise_for_status
10
- from plato.chronos.models import AgentLogsRequest, AgentLogsResponse
11
-
12
-
13
- def _build_request_args(
14
- body: AgentLogsRequest,
15
- ) -> dict[str, Any]:
16
- """Build request arguments."""
17
- url = "/api/callback/logs"
18
-
19
- return {
20
- "method": "POST",
21
- "url": url,
22
- "json": body.model_dump(mode="json", exclude_none=True),
23
- }
24
-
25
-
26
- def sync(
27
- client: httpx.Client,
28
- body: AgentLogsRequest,
29
- ) -> AgentLogsResponse:
30
- """Receive logs from an agent running in a VM.
31
-
32
- This endpoint acknowledges log receipt for real-time streaming.
33
- Logs are NOT stored in the database - they're uploaded to S3 as a zip
34
- at the end of the session via the /artifacts endpoint."""
35
-
36
- request_args = _build_request_args(
37
- body=body,
38
- )
39
-
40
- response = client.request(**request_args)
41
- raise_for_status(response)
42
- return AgentLogsResponse.model_validate(response.json())
43
-
44
-
45
- async def asyncio(
46
- client: httpx.AsyncClient,
47
- body: AgentLogsRequest,
48
- ) -> AgentLogsResponse:
49
- """Receive logs from an agent running in a VM.
50
-
51
- This endpoint acknowledges log receipt for real-time streaming.
52
- Logs are NOT stored in the database - they're uploaded to S3 as a zip
53
- at the end of the session via the /artifacts endpoint."""
54
-
55
- request_args = _build_request_args(
56
- body=body,
57
- )
58
-
59
- response = await client.request(**request_args)
60
- raise_for_status(response)
61
- return AgentLogsResponse.model_validate(response.json())
@@ -1,57 +0,0 @@
1
- """Update Agent Status"""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
6
-
7
- import httpx
8
-
9
- from plato.chronos.errors import raise_for_status
10
- from plato.chronos.models import AgentLogsResponse, AgentStatusRequest
11
-
12
-
13
- def _build_request_args(
14
- body: AgentStatusRequest,
15
- ) -> dict[str, Any]:
16
- """Build request arguments."""
17
- url = "/api/callback/status"
18
-
19
- return {
20
- "method": "POST",
21
- "url": url,
22
- "json": body.model_dump(mode="json", exclude_none=True),
23
- }
24
-
25
-
26
- def sync(
27
- client: httpx.Client,
28
- body: AgentStatusRequest,
29
- ) -> AgentLogsResponse:
30
- """Update the status of a running session.
31
-
32
- Called by agents to report completion or failure."""
33
-
34
- request_args = _build_request_args(
35
- body=body,
36
- )
37
-
38
- response = client.request(**request_args)
39
- raise_for_status(response)
40
- return AgentLogsResponse.model_validate(response.json())
41
-
42
-
43
- async def asyncio(
44
- client: httpx.AsyncClient,
45
- body: AgentStatusRequest,
46
- ) -> AgentLogsResponse:
47
- """Update the status of a running session.
48
-
49
- Called by agents to report completion or failure."""
50
-
51
- request_args = _build_request_args(
52
- body=body,
53
- )
54
-
55
- response = await client.request(**request_args)
56
- raise_for_status(response)
57
- return AgentLogsResponse.model_validate(response.json())
@@ -1,59 +0,0 @@
1
- """Upload Artifacts Endpoint"""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
6
-
7
- import httpx
8
-
9
- from plato.chronos.errors import raise_for_status
10
- from plato.chronos.models import ArtifactsUploadRequest, ArtifactsUploadResponse
11
-
12
-
13
- def _build_request_args(
14
- body: ArtifactsUploadRequest,
15
- ) -> dict[str, Any]:
16
- """Build request arguments."""
17
- url = "/api/callback/artifacts"
18
-
19
- return {
20
- "method": "POST",
21
- "url": url,
22
- "json": body.model_dump(mode="json", exclude_none=True),
23
- }
24
-
25
-
26
- def sync(
27
- client: httpx.Client,
28
- body: ArtifactsUploadRequest,
29
- ) -> ArtifactsUploadResponse:
30
- """Upload trajectory and/or logs for a session.
31
-
32
- Convenience endpoint to upload both artifacts in one request.
33
- Trajectory is stored in DB, logs are uploaded to S3."""
34
-
35
- request_args = _build_request_args(
36
- body=body,
37
- )
38
-
39
- response = client.request(**request_args)
40
- raise_for_status(response)
41
- return ArtifactsUploadResponse.model_validate(response.json())
42
-
43
-
44
- async def asyncio(
45
- client: httpx.AsyncClient,
46
- body: ArtifactsUploadRequest,
47
- ) -> ArtifactsUploadResponse:
48
- """Upload trajectory and/or logs for a session.
49
-
50
- Convenience endpoint to upload both artifacts in one request.
51
- Trajectory is stored in DB, logs are uploaded to S3."""
52
-
53
- request_args = _build_request_args(
54
- body=body,
55
- )
56
-
57
- response = await client.request(**request_args)
58
- raise_for_status(response)
59
- return ArtifactsUploadResponse.model_validate(response.json())