fleet-python 0.2.81__py3-none-any.whl → 0.2.83__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.
fleet/_async/client.py CHANGED
@@ -17,11 +17,17 @@
17
17
  import asyncio
18
18
  import base64
19
19
  import cloudpickle
20
+ import dataclasses
20
21
  import httpx
21
22
  import json
22
23
  import logging
23
24
  import os
25
+ from datetime import date, datetime
26
+ from decimal import Decimal
27
+ from enum import Enum
28
+ from pathlib import Path
24
29
  from typing import List, Optional, Dict, Any, TYPE_CHECKING, Union
30
+ from uuid import UUID
25
31
 
26
32
  from .base import EnvironmentBase, AsyncWrapper
27
33
  from ..models import (
@@ -37,12 +43,112 @@ from ..models import (
37
43
  TaskUpdateRequest,
38
44
  Run,
39
45
  HeartbeatResponse,
46
+ SessionIngestRequest,
47
+ SessionIngestMessage,
48
+ SessionIngestResponse,
49
+ SessionStatus,
50
+ JobSessionsResponse,
51
+ SessionTranscriptResponse,
40
52
  )
41
53
  from .tasks import Task
42
54
 
43
55
  if TYPE_CHECKING:
44
56
  from .verifiers import AsyncVerifierFunction
45
57
 
58
+
59
+ def _json_default(x: Any) -> Any:
60
+ """Default JSON serializer for non-native types."""
61
+ if isinstance(x, (datetime, date)):
62
+ return x.isoformat()
63
+ if isinstance(x, (UUID, Path)):
64
+ return str(x)
65
+ if isinstance(x, Decimal):
66
+ return float(x)
67
+ if isinstance(x, Enum):
68
+ return x.value
69
+ if isinstance(x, bytes):
70
+ return base64.b64encode(x).decode("utf-8")
71
+ if isinstance(x, set):
72
+ return list(x)
73
+ if dataclasses.is_dataclass(x) and not isinstance(x, type):
74
+ return dataclasses.asdict(x)
75
+ # Handle objects with __dict__ (generic objects)
76
+ if hasattr(x, "__dict__"):
77
+ return x.__dict__
78
+ raise TypeError(f"Not JSON serializable: {type(x)}")
79
+
80
+
81
+ def _to_dict(obj: Any) -> Any:
82
+ """Convert any object to a JSON-serializable dict/value.
83
+
84
+ Handles:
85
+ - Pydantic v2 models (model_dump)
86
+ - Pydantic v1 models (.dict())
87
+ - dataclasses (asdict)
88
+ - TypedDict (just dict at runtime)
89
+ - Objects with __dict__
90
+ - Primitives pass through
91
+ """
92
+ if obj is None:
93
+ return None
94
+
95
+ # Pydantic v2
96
+ if hasattr(obj, "model_dump"):
97
+ return obj.model_dump()
98
+
99
+ # Pydantic v1
100
+ if hasattr(obj, "dict") and callable(obj.dict):
101
+ return obj.dict()
102
+
103
+ # dataclass
104
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
105
+ return dataclasses.asdict(obj)
106
+
107
+ # Already a dict or list - recursively convert
108
+ if isinstance(obj, dict):
109
+ return {k: _to_dict(v) for k, v in obj.items()}
110
+ if isinstance(obj, list):
111
+ return [_to_dict(v) for v in obj]
112
+
113
+ # Primitives
114
+ if isinstance(obj, (str, int, float, bool, type(None))):
115
+ return obj
116
+
117
+ # bytes -> base64
118
+ if isinstance(obj, bytes):
119
+ return base64.b64encode(obj).decode("utf-8")
120
+
121
+ # datetime/date
122
+ if isinstance(obj, (datetime, date)):
123
+ return obj.isoformat()
124
+
125
+ # UUID, Path
126
+ if isinstance(obj, (UUID, Path)):
127
+ return str(obj)
128
+
129
+ # Enum
130
+ if isinstance(obj, Enum):
131
+ return obj.value
132
+
133
+ # Decimal
134
+ if isinstance(obj, Decimal):
135
+ return float(obj)
136
+
137
+ # set
138
+ if isinstance(obj, set):
139
+ return list(obj)
140
+
141
+ # Generic object with __dict__
142
+ if hasattr(obj, "__dict__"):
143
+ return {k: _to_dict(v) for k, v in obj.__dict__.items() if not k.startswith("_")}
144
+
145
+ # Fallback - try to convert, or return string representation
146
+ try:
147
+ json.dumps(obj)
148
+ return obj
149
+ except (TypeError, ValueError):
150
+ return str(obj)
151
+
46
152
  from .instance import (
47
153
  AsyncInstanceClient,
48
154
  ResetRequest,
@@ -70,6 +176,166 @@ from .resources.mcp import AsyncMCPResource
70
176
  logger = logging.getLogger(__name__)
71
177
 
72
178
 
179
+ class AsyncSession:
180
+ """A session for logging agent interactions to Fleet.
181
+
182
+ This provides a simple interface for streaming messages during an agent run.
183
+ Messages are sent one-by-one as they happen.
184
+
185
+ Usage:
186
+ session = await fleet.session_async(
187
+ model="anthropic/claude-sonnet-4",
188
+ task_key="my_task",
189
+ instance_id=env.instance_id,
190
+ )
191
+
192
+ # Log messages as they happen
193
+ await session.log(history, response)
194
+
195
+ # Complete when done
196
+ await session.complete() # or session.fail()
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ client: "AsyncFleet",
202
+ session_id: Optional[str] = None,
203
+ job_id: Optional[str] = None,
204
+ config: Optional[Any] = None,
205
+ model: Optional[str] = None,
206
+ task_key: Optional[str] = None,
207
+ instance_id: Optional[str] = None,
208
+ ):
209
+ self.session_id = session_id
210
+ self.job_id = job_id
211
+ self.config = config
212
+ self.model = model
213
+ self.task_key = task_key
214
+ self.instance_id = instance_id
215
+ self._client = client
216
+ self._message_count = 0
217
+ self._logged_count = 0 # Track how many messages from history have been logged
218
+ self._config_sent = False # Only send config/model/task_key/instance_id on first log
219
+
220
+ async def log(self, history: List[Any], response: Any) -> SessionIngestResponse:
221
+ """Log an LLM call to the session.
222
+
223
+ Pass the input history and the model response. The session tracks what's
224
+ already been logged and only sends new messages. Objects are automatically
225
+ serialized to JSON (supports Pydantic, dataclasses, TypedDict, etc.).
226
+
227
+ Example:
228
+ response = model.generate(history)
229
+ await session.log(history, response.content)
230
+
231
+ Args:
232
+ history: The input messages sent to the model
233
+ response: The model's response (any serializable object)
234
+
235
+ Returns:
236
+ SessionIngestResponse with updated message count
237
+ """
238
+ # Collect new history messages since last call
239
+ new_history = history[self._logged_count:]
240
+
241
+ # Update tracked count to include the response we're about to send
242
+ # This prevents the response from being sent again as "new history" in the next call
243
+ self._logged_count = len(history) + (1 if response is not None else 0)
244
+
245
+ # Build the payload - serialize history + response to JSON
246
+ payload: Dict[str, Any] = {
247
+ "history": [_to_dict(msg) for msg in new_history],
248
+ "response": _to_dict(response),
249
+ }
250
+ if self.session_id:
251
+ payload["session_id"] = self.session_id
252
+ if self.job_id:
253
+ payload["job_id"] = self.job_id
254
+ # Include config, model, task_key, instance_id on first log only
255
+ if not self._config_sent:
256
+ if self.config is not None:
257
+ payload["config"] = _to_dict(self.config)
258
+ if self.model is not None:
259
+ payload["model"] = self.model
260
+ if self.task_key is not None:
261
+ payload["task_key"] = self.task_key
262
+ if self.instance_id is not None:
263
+ payload["instance_id"] = self.instance_id
264
+ self._config_sent = True
265
+
266
+ if not new_history and response is None:
267
+ return SessionIngestResponse(
268
+ success=True,
269
+ session_id=self.session_id or "",
270
+ message_count=self._message_count,
271
+ created_new_session=False,
272
+ )
273
+
274
+ result = await self._client._ingest_raw(payload=payload)
275
+ self._message_count = result.message_count
276
+ # Update session_id if this was the first log (new session created)
277
+ if not self.session_id and result.session_id:
278
+ self.session_id = result.session_id
279
+ return result
280
+
281
+ async def complete(
282
+ self,
283
+ verifier_execution_id: Optional[str] = None,
284
+ ) -> SessionIngestResponse:
285
+ """Mark the session as completed successfully.
286
+
287
+ Args:
288
+ verifier_execution_id: Optional ID of the verifier execution record
289
+
290
+ Returns:
291
+ SessionIngestResponse with final state
292
+ """
293
+ from datetime import datetime
294
+
295
+ payload: Dict[str, Any] = {
296
+ "session_id": self.session_id,
297
+ "status": "completed",
298
+ "ended_at": datetime.now().isoformat(),
299
+ }
300
+ if verifier_execution_id:
301
+ payload["verifier_execution_id"] = verifier_execution_id
302
+
303
+ response = await self._client._ingest_raw(payload)
304
+ self._message_count = response.message_count
305
+ return response
306
+
307
+ async def fail(
308
+ self,
309
+ verifier_execution_id: Optional[str] = None,
310
+ ) -> SessionIngestResponse:
311
+ """Mark the session as failed.
312
+
313
+ Args:
314
+ verifier_execution_id: Optional ID of the verifier execution record
315
+
316
+ Returns:
317
+ SessionIngestResponse with final state
318
+ """
319
+ from datetime import datetime
320
+
321
+ payload: Dict[str, Any] = {
322
+ "session_id": self.session_id,
323
+ "status": "failed",
324
+ "ended_at": datetime.now().isoformat(),
325
+ }
326
+ if verifier_execution_id:
327
+ payload["verifier_execution_id"] = verifier_execution_id
328
+
329
+ response = await self._client._ingest_raw(payload)
330
+ self._message_count = response.message_count
331
+ return response
332
+
333
+ @property
334
+ def message_count(self) -> int:
335
+ """Get the current message count."""
336
+ return self._message_count
337
+
338
+
73
339
  class AsyncEnv(EnvironmentBase):
74
340
  def __init__(self, client: Optional[AsyncWrapper], **kwargs):
75
341
  super().__init__(**kwargs)
@@ -1025,6 +1291,289 @@ class AsyncFleet:
1025
1291
  )
1026
1292
  return TaskResponse(**response.json())
1027
1293
 
1294
+ # Sessions API methods
1295
+
1296
+ async def list_job_sessions(self, job_id: str) -> JobSessionsResponse:
1297
+ """List all sessions for a job, grouped by task.
1298
+
1299
+ Args:
1300
+ job_id: The job ID
1301
+
1302
+ Returns:
1303
+ JobSessionsResponse containing sessions grouped by task with statistics
1304
+ """
1305
+ response = await self.client.request("GET", f"/v1/sessions/job/{job_id}")
1306
+ return JobSessionsResponse(**response.json())
1307
+
1308
+ async def get_session_transcript(self, session_id: str) -> SessionTranscriptResponse:
1309
+ """Get the transcript for a specific session.
1310
+
1311
+ Args:
1312
+ session_id: The session ID
1313
+
1314
+ Returns:
1315
+ SessionTranscriptResponse containing task, instance, verifier result, and messages
1316
+ """
1317
+ response = await self.client.request(
1318
+ "GET", f"/v1/sessions/{session_id}/transcript"
1319
+ )
1320
+ return SessionTranscriptResponse(**response.json())
1321
+
1322
+ async def _ingest(
1323
+ self,
1324
+ messages: List[Dict[str, Any]],
1325
+ session_id: Optional[str] = None,
1326
+ model: Optional[str] = None,
1327
+ task_key: Optional[str] = None,
1328
+ job_id: Optional[str] = None,
1329
+ instance_id: Optional[str] = None,
1330
+ status: Optional[str] = None,
1331
+ metadata: Optional[Dict[str, Any]] = None,
1332
+ started_at: Optional[str] = None,
1333
+ ended_at: Optional[str] = None,
1334
+ verifier_execution_id: Optional[str] = None,
1335
+ ) -> SessionIngestResponse:
1336
+ """Internal method to ingest session data."""
1337
+ message_objects = [SessionIngestMessage(**msg) for msg in messages]
1338
+ request = SessionIngestRequest(
1339
+ messages=message_objects,
1340
+ session_id=session_id,
1341
+ model=model,
1342
+ task_key=task_key,
1343
+ job_id=job_id,
1344
+ instance_id=instance_id,
1345
+ status=SessionStatus(status) if status else None,
1346
+ metadata=metadata,
1347
+ started_at=started_at,
1348
+ ended_at=ended_at,
1349
+ verifier_execution_id=verifier_execution_id,
1350
+ )
1351
+ response = await self.client.request(
1352
+ "POST",
1353
+ "/v1/sessions/ingest",
1354
+ json=request.model_dump(exclude_none=True),
1355
+ )
1356
+ return SessionIngestResponse(**response.json())
1357
+
1358
+ async def _ingest_raw(
1359
+ self,
1360
+ payload: Dict[str, Any],
1361
+ ) -> SessionIngestResponse:
1362
+ """Internal method to ingest raw session data as JSON.
1363
+
1364
+ This sends the history and response as-is to the backend,
1365
+ letting the backend handle format normalization.
1366
+ """
1367
+ # Pre-serialize with our custom handler to ensure all types are JSON-safe
1368
+ json_str = json.dumps(payload, default=_json_default)
1369
+ clean_payload = json.loads(json_str)
1370
+
1371
+ response = await self.client.request(
1372
+ "POST",
1373
+ "/v1/traces/logs",
1374
+ json=clean_payload,
1375
+ )
1376
+ return SessionIngestResponse(**response.json())
1377
+
1378
+ def start_session(
1379
+ self,
1380
+ session_id: Optional[str] = None,
1381
+ job_id: Optional[str] = None,
1382
+ config: Optional[Any] = None,
1383
+ model: Optional[str] = None,
1384
+ task_key: Optional[str] = None,
1385
+ instance_id: Optional[str] = None,
1386
+ ) -> AsyncSession:
1387
+ """Start a new session for logging agent interactions.
1388
+
1389
+ This returns a Session object. The session is created on the backend
1390
+ when you call log() for the first time.
1391
+
1392
+ Args:
1393
+ session_id: Optional existing session ID to resume
1394
+ job_id: Optional job ID to associate with the session
1395
+ config: Optional config object (e.g., GenerateContentConfig) to log
1396
+ model: Optional model name to log
1397
+ task_key: Optional Fleet task key
1398
+ instance_id: Optional Fleet instance ID
1399
+
1400
+ Returns:
1401
+ AsyncSession object with log(), complete(), and fail() methods
1402
+
1403
+ Example:
1404
+ session = fleet_client.start_session(config=config, model="gpt-4", task_key="task_123")
1405
+
1406
+ # Log LLM calls during agent run
1407
+ await session.log(history, response)
1408
+
1409
+ # Complete when done
1410
+ await session.complete()
1411
+ """
1412
+ return AsyncSession(
1413
+ client=self,
1414
+ session_id=session_id,
1415
+ job_id=job_id,
1416
+ config=config,
1417
+ model=model,
1418
+ task_key=task_key,
1419
+ instance_id=instance_id,
1420
+ )
1421
+
1422
+ async def trace_job(self, name: str) -> str:
1423
+ """Create a new trace job.
1424
+
1425
+ Args:
1426
+ name: Name of the job
1427
+
1428
+ Returns:
1429
+ The job_id string
1430
+ """
1431
+ from fleet.models import TraceJobRequest, TraceJobResponse
1432
+
1433
+ request = TraceJobRequest(name=name)
1434
+ response = await self.client.request(
1435
+ "POST",
1436
+ "/v1/traces/jobs",
1437
+ json=request.model_dump(),
1438
+ )
1439
+ result = TraceJobResponse(**response.json())
1440
+ return result.job_id
1441
+
1442
+ async def create_session(
1443
+ self,
1444
+ model: Optional[str] = None,
1445
+ task_key: Optional[str] = None,
1446
+ job_id: Optional[str] = None,
1447
+ instance_id: Optional[str] = None,
1448
+ metadata: Optional[Dict[str, Any]] = None,
1449
+ started_at: Optional[str] = None,
1450
+ initial_message: Optional[Dict[str, Any]] = None,
1451
+ ) -> SessionIngestResponse:
1452
+ """Create a new session, optionally with an initial message.
1453
+
1454
+ This is useful for streaming scenarios where you want to create
1455
+ a session first and then append messages one by one.
1456
+
1457
+ Args:
1458
+ model: Model identifier (e.g., "anthropic/claude-sonnet-4")
1459
+ task_key: Task key to associate with the session
1460
+ job_id: Job ID to associate with the session
1461
+ instance_id: Instance ID to associate with the session
1462
+ metadata: Additional metadata for the session
1463
+ started_at: ISO timestamp when session started
1464
+ initial_message: Optional first message dict with 'role' and 'content'
1465
+
1466
+ Returns:
1467
+ SessionIngestResponse containing session_id
1468
+
1469
+ Example:
1470
+ # Create session and get ID
1471
+ session = await fleet.create_session(
1472
+ model="anthropic/claude-sonnet-4",
1473
+ task_key="my_task",
1474
+ started_at=datetime.now().isoformat()
1475
+ )
1476
+
1477
+ # Append messages as they happen
1478
+ await fleet.append_message(session.session_id, {"role": "user", "content": "Hello"})
1479
+ await fleet.append_message(session.session_id, {"role": "assistant", "content": "Hi!"})
1480
+ """
1481
+ # Use a placeholder message if none provided
1482
+ if initial_message:
1483
+ messages = [initial_message]
1484
+ else:
1485
+ messages = [{"role": "system", "content": "[session created]"}]
1486
+
1487
+ return await self._ingest(
1488
+ messages=messages,
1489
+ model=model,
1490
+ task_key=task_key,
1491
+ job_id=job_id,
1492
+ instance_id=instance_id,
1493
+ status="running",
1494
+ metadata=metadata,
1495
+ started_at=started_at,
1496
+ )
1497
+
1498
+ async def append_message(
1499
+ self,
1500
+ session_id: str,
1501
+ message: Dict[str, Any],
1502
+ status: Optional[str] = None,
1503
+ ended_at: Optional[str] = None,
1504
+ ) -> SessionIngestResponse:
1505
+ """Append a single message to an existing session.
1506
+
1507
+ This is useful for streaming scenarios where you want to send
1508
+ messages one by one as they happen.
1509
+
1510
+ Args:
1511
+ session_id: The session ID to append to
1512
+ message: Message dict with 'role' and 'content' keys.
1513
+ Optional keys: 'tool_calls', 'tool_call_id', 'timestamp', 'tokens', 'metadata'
1514
+ status: Optional status update ("running", "completed", "failed")
1515
+ ended_at: ISO timestamp when session ended (set when completing)
1516
+
1517
+ Returns:
1518
+ SessionIngestResponse with updated message count
1519
+
1520
+ Example:
1521
+ # Append user message
1522
+ await fleet.append_message(session_id, {"role": "user", "content": "What's 2+2?"})
1523
+
1524
+ # Append assistant response
1525
+ await fleet.append_message(session_id, {"role": "assistant", "content": "4"})
1526
+
1527
+ # Complete the session
1528
+ await fleet.append_message(
1529
+ session_id,
1530
+ {"role": "assistant", "content": "Done!"},
1531
+ status="completed",
1532
+ ended_at=datetime.now().isoformat()
1533
+ )
1534
+ """
1535
+ return await self._ingest(
1536
+ messages=[message],
1537
+ session_id=session_id,
1538
+ status=status,
1539
+ ended_at=ended_at,
1540
+ )
1541
+
1542
+ async def complete_session(
1543
+ self,
1544
+ session_id: str,
1545
+ status: str = "completed",
1546
+ ended_at: Optional[str] = None,
1547
+ final_message: Optional[Dict[str, Any]] = None,
1548
+ ) -> SessionIngestResponse:
1549
+ """Mark a session as complete.
1550
+
1551
+ Args:
1552
+ session_id: The session ID to complete
1553
+ status: Final status ("completed", "failed", "cancelled")
1554
+ ended_at: ISO timestamp when session ended (defaults to now)
1555
+ final_message: Optional final message to append
1556
+
1557
+ Returns:
1558
+ SessionIngestResponse with final state
1559
+ """
1560
+ from datetime import datetime as dt
1561
+
1562
+ if ended_at is None:
1563
+ ended_at = dt.now().isoformat()
1564
+
1565
+ if final_message:
1566
+ messages = [final_message]
1567
+ else:
1568
+ messages = [{"role": "system", "content": f"[session {status}]"}]
1569
+
1570
+ return await self._ingest(
1571
+ messages=messages,
1572
+ session_id=session_id,
1573
+ status=status,
1574
+ ended_at=ended_at,
1575
+ )
1576
+
1028
1577
  async def _create_verifier_from_data(
1029
1578
  self, verifier_id: str, verifier_key: str, verifier_code: str, verifier_sha: str, verifier_runtime_version: Optional[str] = None
1030
1579
  ) -> "AsyncVerifierFunction":