fleet-python 0.2.66b2__py3-none-any.whl → 0.2.105__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.
- examples/export_tasks.py +16 -5
- examples/export_tasks_filtered.py +245 -0
- examples/fetch_tasks.py +230 -0
- examples/import_tasks.py +140 -8
- examples/iterate_verifiers.py +725 -0
- fleet/__init__.py +128 -5
- fleet/_async/__init__.py +27 -3
- fleet/_async/base.py +24 -9
- fleet/_async/client.py +938 -41
- fleet/_async/env/client.py +60 -3
- fleet/_async/instance/client.py +52 -7
- fleet/_async/models.py +15 -0
- fleet/_async/resources/api.py +200 -0
- fleet/_async/resources/sqlite.py +1801 -46
- fleet/_async/tasks.py +122 -25
- fleet/_async/verifiers/bundler.py +22 -21
- fleet/_async/verifiers/verifier.py +25 -19
- fleet/agent/__init__.py +32 -0
- fleet/agent/gemini_cua/Dockerfile +45 -0
- fleet/agent/gemini_cua/__init__.py +10 -0
- fleet/agent/gemini_cua/agent.py +759 -0
- fleet/agent/gemini_cua/mcp/main.py +108 -0
- fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
- fleet/agent/gemini_cua/mcp_server/main.py +105 -0
- fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
- fleet/agent/gemini_cua/requirements.txt +5 -0
- fleet/agent/gemini_cua/start.sh +30 -0
- fleet/agent/orchestrator.py +854 -0
- fleet/agent/types.py +49 -0
- fleet/agent/utils.py +34 -0
- fleet/base.py +34 -9
- fleet/cli.py +1061 -0
- fleet/client.py +1060 -48
- fleet/config.py +1 -1
- fleet/env/__init__.py +16 -0
- fleet/env/client.py +60 -3
- fleet/eval/__init__.py +15 -0
- fleet/eval/uploader.py +231 -0
- fleet/exceptions.py +8 -0
- fleet/instance/client.py +53 -8
- fleet/instance/models.py +1 -0
- fleet/models.py +303 -0
- fleet/proxy/__init__.py +25 -0
- fleet/proxy/proxy.py +453 -0
- fleet/proxy/whitelist.py +244 -0
- fleet/resources/api.py +200 -0
- fleet/resources/sqlite.py +1845 -46
- fleet/tasks.py +113 -20
- fleet/utils/__init__.py +7 -0
- fleet/utils/http_logging.py +178 -0
- fleet/utils/logging.py +13 -0
- fleet/utils/playwright.py +440 -0
- fleet/verifiers/bundler.py +22 -21
- fleet/verifiers/db.py +985 -1
- fleet/verifiers/decorator.py +1 -1
- fleet/verifiers/verifier.py +25 -19
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
- fleet_python-0.2.105.dist-info/RECORD +115 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
- fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
- tests/test_app_method.py +85 -0
- tests/test_expect_exactly.py +4148 -0
- tests/test_expect_only.py +2593 -0
- tests/test_instance_dispatch.py +607 -0
- tests/test_sqlite_resource_dual_mode.py +263 -0
- tests/test_sqlite_shared_memory_behavior.py +117 -0
- fleet_python-0.2.66b2.dist-info/RECORD +0 -81
- tests/test_verifier_security.py +0 -427
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/top_level.txt +0 -0
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
|
|
24
|
-
from
|
|
25
|
+
from datetime import date, datetime
|
|
26
|
+
from decimal import Decimal
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from pathlib import Path
|
|
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 (
|
|
@@ -35,18 +41,125 @@ from ..models import (
|
|
|
35
41
|
TaskRequest,
|
|
36
42
|
TaskResponse,
|
|
37
43
|
TaskUpdateRequest,
|
|
44
|
+
Run,
|
|
45
|
+
HeartbeatResponse,
|
|
46
|
+
SessionIngestRequest,
|
|
47
|
+
SessionIngestMessage,
|
|
48
|
+
SessionIngestResponse,
|
|
49
|
+
SessionStatus,
|
|
50
|
+
JobSessionsResponse,
|
|
51
|
+
SessionTranscriptResponse,
|
|
38
52
|
)
|
|
39
53
|
from .tasks import Task
|
|
40
54
|
|
|
41
55
|
if TYPE_CHECKING:
|
|
42
56
|
from .verifiers import AsyncVerifierFunction
|
|
43
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
|
+
|
|
44
152
|
from .instance import (
|
|
45
153
|
AsyncInstanceClient,
|
|
46
154
|
ResetRequest,
|
|
47
155
|
ResetResponse,
|
|
48
156
|
ExecuteFunctionResponse,
|
|
49
157
|
)
|
|
158
|
+
from ..instance.models import (
|
|
159
|
+
Resource as ResourceModel,
|
|
160
|
+
ResourceType,
|
|
161
|
+
ResourceMode,
|
|
162
|
+
)
|
|
50
163
|
from ..config import (
|
|
51
164
|
DEFAULT_MAX_RETRIES,
|
|
52
165
|
DEFAULT_TIMEOUT,
|
|
@@ -59,10 +172,171 @@ from .resources.base import Resource
|
|
|
59
172
|
from .resources.sqlite import AsyncSQLiteResource
|
|
60
173
|
from .resources.browser import AsyncBrowserResource
|
|
61
174
|
from .resources.mcp import AsyncMCPResource
|
|
175
|
+
from .resources.api import AsyncAPIResource
|
|
62
176
|
|
|
63
177
|
logger = logging.getLogger(__name__)
|
|
64
178
|
|
|
65
179
|
|
|
180
|
+
class AsyncSession:
|
|
181
|
+
"""A session for logging agent interactions to Fleet.
|
|
182
|
+
|
|
183
|
+
This provides a simple interface for streaming messages during an agent run.
|
|
184
|
+
Messages are sent one-by-one as they happen.
|
|
185
|
+
|
|
186
|
+
Usage:
|
|
187
|
+
session = await fleet.session_async(
|
|
188
|
+
model="anthropic/claude-sonnet-4",
|
|
189
|
+
task_key="my_task",
|
|
190
|
+
instance_id=env.instance_id,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Log messages as they happen
|
|
194
|
+
await session.log(history, response)
|
|
195
|
+
|
|
196
|
+
# Complete when done
|
|
197
|
+
await session.complete() # or session.fail()
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
client: "AsyncFleet",
|
|
203
|
+
session_id: Optional[str] = None,
|
|
204
|
+
job_id: Optional[str] = None,
|
|
205
|
+
config: Optional[Any] = None,
|
|
206
|
+
model: Optional[str] = None,
|
|
207
|
+
task_key: Optional[str] = None,
|
|
208
|
+
instance_id: Optional[str] = None,
|
|
209
|
+
):
|
|
210
|
+
self.session_id = session_id
|
|
211
|
+
self.job_id = job_id
|
|
212
|
+
self.config = config
|
|
213
|
+
self.model = model
|
|
214
|
+
self.task_key = task_key
|
|
215
|
+
self.instance_id = instance_id
|
|
216
|
+
self._client = client
|
|
217
|
+
self._message_count = 0
|
|
218
|
+
self._logged_count = 0 # Track how many messages from history have been logged
|
|
219
|
+
self._config_sent = False # Only send config/model/task_key/instance_id on first log
|
|
220
|
+
|
|
221
|
+
async def log(self, history: List[Any], response: Any) -> SessionIngestResponse:
|
|
222
|
+
"""Log an LLM call to the session.
|
|
223
|
+
|
|
224
|
+
Pass the input history and the model response. The session tracks what's
|
|
225
|
+
already been logged and only sends new messages. Objects are automatically
|
|
226
|
+
serialized to JSON (supports Pydantic, dataclasses, TypedDict, etc.).
|
|
227
|
+
|
|
228
|
+
Example:
|
|
229
|
+
response = model.generate(history)
|
|
230
|
+
await session.log(history, response.content)
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
history: The input messages sent to the model
|
|
234
|
+
response: The model's response (any serializable object)
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
SessionIngestResponse with updated message count
|
|
238
|
+
"""
|
|
239
|
+
# Collect new history messages since last call
|
|
240
|
+
new_history = history[self._logged_count:]
|
|
241
|
+
|
|
242
|
+
# Update tracked count to include the response we're about to send
|
|
243
|
+
# This prevents the response from being sent again as "new history" in the next call
|
|
244
|
+
self._logged_count = len(history) + (1 if response is not None else 0)
|
|
245
|
+
|
|
246
|
+
# Build the payload - serialize history + response to JSON
|
|
247
|
+
payload: Dict[str, Any] = {
|
|
248
|
+
"history": [_to_dict(msg) for msg in new_history],
|
|
249
|
+
"response": _to_dict(response),
|
|
250
|
+
}
|
|
251
|
+
if self.session_id:
|
|
252
|
+
payload["session_id"] = self.session_id
|
|
253
|
+
if self.job_id:
|
|
254
|
+
payload["job_id"] = self.job_id
|
|
255
|
+
# Include config, model, task_key, instance_id on first log only
|
|
256
|
+
if not self._config_sent:
|
|
257
|
+
if self.config is not None:
|
|
258
|
+
payload["config"] = _to_dict(self.config)
|
|
259
|
+
if self.model is not None:
|
|
260
|
+
payload["model"] = self.model
|
|
261
|
+
if self.task_key is not None:
|
|
262
|
+
payload["task_key"] = self.task_key
|
|
263
|
+
if self.instance_id is not None:
|
|
264
|
+
payload["instance_id"] = self.instance_id
|
|
265
|
+
self._config_sent = True
|
|
266
|
+
|
|
267
|
+
if not new_history and response is None:
|
|
268
|
+
return SessionIngestResponse(
|
|
269
|
+
success=True,
|
|
270
|
+
session_id=self.session_id or "",
|
|
271
|
+
message_count=self._message_count,
|
|
272
|
+
created_new_session=False,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
result = await self._client._ingest_raw(payload=payload)
|
|
276
|
+
self._message_count = result.message_count
|
|
277
|
+
# Update session_id if this was the first log (new session created)
|
|
278
|
+
if not self.session_id and result.session_id:
|
|
279
|
+
self.session_id = result.session_id
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
async def complete(
|
|
283
|
+
self,
|
|
284
|
+
verifier_execution_id: Optional[str] = None,
|
|
285
|
+
) -> SessionIngestResponse:
|
|
286
|
+
"""Mark the session as completed successfully.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
verifier_execution_id: Optional ID of the verifier execution record
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
SessionIngestResponse with final state
|
|
293
|
+
"""
|
|
294
|
+
from datetime import datetime
|
|
295
|
+
|
|
296
|
+
payload: Dict[str, Any] = {
|
|
297
|
+
"session_id": self.session_id,
|
|
298
|
+
"status": "completed",
|
|
299
|
+
"ended_at": datetime.now().isoformat(),
|
|
300
|
+
}
|
|
301
|
+
if verifier_execution_id:
|
|
302
|
+
payload["verifier_execution_id"] = verifier_execution_id
|
|
303
|
+
|
|
304
|
+
response = await self._client._ingest_raw(payload)
|
|
305
|
+
self._message_count = response.message_count
|
|
306
|
+
return response
|
|
307
|
+
|
|
308
|
+
async def fail(
|
|
309
|
+
self,
|
|
310
|
+
verifier_execution_id: Optional[str] = None,
|
|
311
|
+
) -> SessionIngestResponse:
|
|
312
|
+
"""Mark the session as failed.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
verifier_execution_id: Optional ID of the verifier execution record
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
SessionIngestResponse with final state
|
|
319
|
+
"""
|
|
320
|
+
from datetime import datetime
|
|
321
|
+
|
|
322
|
+
payload: Dict[str, Any] = {
|
|
323
|
+
"session_id": self.session_id,
|
|
324
|
+
"status": "failed",
|
|
325
|
+
"ended_at": datetime.now().isoformat(),
|
|
326
|
+
}
|
|
327
|
+
if verifier_execution_id:
|
|
328
|
+
payload["verifier_execution_id"] = verifier_execution_id
|
|
329
|
+
|
|
330
|
+
response = await self._client._ingest_raw(payload)
|
|
331
|
+
self._message_count = response.message_count
|
|
332
|
+
return response
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def message_count(self) -> int:
|
|
336
|
+
"""Get the current message count."""
|
|
337
|
+
return self._message_count
|
|
338
|
+
|
|
339
|
+
|
|
66
340
|
class AsyncEnv(EnvironmentBase):
|
|
67
341
|
def __init__(self, client: Optional[AsyncWrapper], **kwargs):
|
|
68
342
|
super().__init__(**kwargs)
|
|
@@ -112,6 +386,29 @@ class AsyncEnv(EnvironmentBase):
|
|
|
112
386
|
def browser(self, name: str = "cdp") -> AsyncBrowserResource:
|
|
113
387
|
return self.instance.browser(name)
|
|
114
388
|
|
|
389
|
+
def api(self, name: str = "api") -> AsyncAPIResource:
|
|
390
|
+
"""Get an API resource for making HTTP requests to the app's API.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
name: Name for the API resource (default: "api")
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
AsyncAPIResource for making HTTP requests
|
|
397
|
+
"""
|
|
398
|
+
# Use urls.api if available, otherwise fall back to urls.root + "/raw"
|
|
399
|
+
if self.urls and self.urls.api:
|
|
400
|
+
base_url = self.urls.api
|
|
401
|
+
elif self.urls and self.urls.root:
|
|
402
|
+
base_url = f"{self.urls.root.rstrip('/')}/raw"
|
|
403
|
+
elif self._manager_url_override and self._manager_url_override != "local://":
|
|
404
|
+
# URL mode: strip /api/v1/env suffix to get root URL
|
|
405
|
+
base_url = self._manager_url_override.rstrip('/')
|
|
406
|
+
if base_url.endswith('/api/v1/env'):
|
|
407
|
+
base_url = base_url[:-len('/api/v1/env')]
|
|
408
|
+
else:
|
|
409
|
+
raise ValueError("No API URL configured for this environment")
|
|
410
|
+
return self.instance.api(name, base_url)
|
|
411
|
+
|
|
115
412
|
@property
|
|
116
413
|
def mcp(self) -> AsyncMCPResource:
|
|
117
414
|
mcp_url = f"{self.urls.root}mcp"
|
|
@@ -126,6 +423,23 @@ class AsyncEnv(EnvironmentBase):
|
|
|
126
423
|
async def close(self) -> InstanceResponse:
|
|
127
424
|
return await _delete_instance(self._load_client, self.instance_id)
|
|
128
425
|
|
|
426
|
+
async def heartbeat(self) -> HeartbeatResponse:
|
|
427
|
+
"""Send heartbeat to keep instance alive (if heartbeat monitoring is enabled).
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
HeartbeatResponse containing heartbeat status and deadline information
|
|
431
|
+
"""
|
|
432
|
+
body = {}
|
|
433
|
+
if self.heartbeat_region:
|
|
434
|
+
body["region"] = self.heartbeat_region
|
|
435
|
+
|
|
436
|
+
response = await self._load_client.request(
|
|
437
|
+
"POST",
|
|
438
|
+
f"/v1/env/instances/{self.instance_id}/heartbeat",
|
|
439
|
+
json=body
|
|
440
|
+
)
|
|
441
|
+
return HeartbeatResponse(**response.json())
|
|
442
|
+
|
|
129
443
|
async def verify(self, validator: ValidatorType) -> ExecuteFunctionResponse:
|
|
130
444
|
return await self.instance.verify(validator)
|
|
131
445
|
|
|
@@ -148,6 +462,7 @@ class AsyncEnv(EnvironmentBase):
|
|
|
148
462
|
kwargs: dict,
|
|
149
463
|
timeout: Optional[int] = 30,
|
|
150
464
|
needs_upload: bool = True,
|
|
465
|
+
verifier_runtime_version: Optional[str] = None,
|
|
151
466
|
) -> VerifiersExecuteResponse:
|
|
152
467
|
return await _execute_verifier_remote(
|
|
153
468
|
self._load_client,
|
|
@@ -160,6 +475,7 @@ class AsyncEnv(EnvironmentBase):
|
|
|
160
475
|
kwargs,
|
|
161
476
|
timeout,
|
|
162
477
|
needs_upload,
|
|
478
|
+
verifier_runtime_version,
|
|
163
479
|
)
|
|
164
480
|
|
|
165
481
|
def __getstate__(self):
|
|
@@ -212,6 +528,8 @@ class AsyncFleet:
|
|
|
212
528
|
env_variables: Optional[Dict[str, Any]] = None,
|
|
213
529
|
image_type: Optional[str] = None,
|
|
214
530
|
ttl_seconds: Optional[int] = None,
|
|
531
|
+
run_id: Optional[str] = None,
|
|
532
|
+
heartbeat_interval: Optional[int] = None,
|
|
215
533
|
) -> AsyncEnv:
|
|
216
534
|
if ":" in env_key:
|
|
217
535
|
env_key_part, env_version = env_key.split(":", 1)
|
|
@@ -247,6 +565,8 @@ class AsyncFleet:
|
|
|
247
565
|
image_type=image_type,
|
|
248
566
|
created_from="sdk",
|
|
249
567
|
ttl_seconds=ttl_seconds,
|
|
568
|
+
run_id=run_id,
|
|
569
|
+
heartbeat_interval=heartbeat_interval,
|
|
250
570
|
)
|
|
251
571
|
|
|
252
572
|
# Only use region-specific base URL if no custom base URL is set
|
|
@@ -269,13 +589,17 @@ class AsyncFleet:
|
|
|
269
589
|
return await self.make(env_key=f"{task.env_id}:{task.version}")
|
|
270
590
|
|
|
271
591
|
async def instances(
|
|
272
|
-
self, status: Optional[str] = None, region: Optional[str] = None
|
|
592
|
+
self, status: Optional[str] = None, region: Optional[str] = None, run_id: Optional[str] = None, profile_id: Optional[str] = None
|
|
273
593
|
) -> List[AsyncEnv]:
|
|
274
594
|
params = {}
|
|
275
595
|
if status:
|
|
276
596
|
params["status"] = status
|
|
277
597
|
if region:
|
|
278
598
|
params["region"] = region
|
|
599
|
+
if run_id:
|
|
600
|
+
params["run_id"] = run_id
|
|
601
|
+
if profile_id:
|
|
602
|
+
params["profile_id"] = profile_id
|
|
279
603
|
|
|
280
604
|
response = await self.client.request("GET", "/v1/env/instances", params=params)
|
|
281
605
|
return [
|
|
@@ -283,11 +607,163 @@ class AsyncFleet:
|
|
|
283
607
|
for instance_data in response.json()
|
|
284
608
|
]
|
|
285
609
|
|
|
286
|
-
async def instance(self, instance_id: str) -> AsyncEnv:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
610
|
+
async def instance(self, instance_id: Union[str, Dict[str, str]]) -> AsyncEnv:
|
|
611
|
+
"""Create or connect to an environment instance.
|
|
612
|
+
|
|
613
|
+
Supports three modes based on input type:
|
|
614
|
+
1. dict: Local filesystem mode - {"current": "./data.db", "seed": "./seed.db"}
|
|
615
|
+
2. str starting with http:// or https://: Localhost/URL mode
|
|
616
|
+
3. str (other): Remote cloud instance mode
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
instance_id: Instance identifier (str), URL (str starting with http://),
|
|
620
|
+
or local db mapping (dict)
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
AsyncEnv: Environment instance
|
|
624
|
+
"""
|
|
625
|
+
# Local filesystem mode - dict of resource names to file paths
|
|
626
|
+
if isinstance(instance_id, dict):
|
|
627
|
+
return self._create_local_instance(instance_id)
|
|
628
|
+
|
|
629
|
+
# Localhost/direct URL mode - string starting with http:// or https://
|
|
630
|
+
elif isinstance(instance_id, str) and instance_id.startswith(("http://", "https://")):
|
|
631
|
+
return self._create_url_instance(instance_id)
|
|
632
|
+
|
|
633
|
+
# Remote mode - existing behavior
|
|
634
|
+
else:
|
|
635
|
+
response = await self.client.request("GET", f"/v1/env/instances/{instance_id}")
|
|
636
|
+
instance = AsyncEnv(client=self.client, **response.json())
|
|
637
|
+
await instance.instance.load()
|
|
638
|
+
return instance
|
|
639
|
+
|
|
640
|
+
def _create_url_instance(self, base_url: str) -> AsyncEnv:
|
|
641
|
+
"""Create instance connected to a direct URL (localhost or custom).
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
base_url: URL of the instance manager API
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
AsyncEnv: Environment instance configured for URL mode
|
|
648
|
+
"""
|
|
649
|
+
instance_client = AsyncInstanceClient(url=base_url, httpx_client=self._httpx_client)
|
|
650
|
+
|
|
651
|
+
# Create a minimal environment for URL mode
|
|
652
|
+
env = AsyncEnv(
|
|
653
|
+
client=self.client,
|
|
654
|
+
instance_id=base_url,
|
|
655
|
+
env_key="localhost",
|
|
656
|
+
version="",
|
|
657
|
+
status="running",
|
|
658
|
+
subdomain="localhost",
|
|
659
|
+
created_at="",
|
|
660
|
+
updated_at="",
|
|
661
|
+
terminated_at=None,
|
|
662
|
+
team_id="",
|
|
663
|
+
region="localhost",
|
|
664
|
+
env_variables=None,
|
|
665
|
+
data_key=None,
|
|
666
|
+
data_version=None,
|
|
667
|
+
urls=None,
|
|
668
|
+
health=None,
|
|
669
|
+
)
|
|
670
|
+
env._instance = instance_client
|
|
671
|
+
return env
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _normalize_db_path(path: str) -> tuple[str, bool]:
|
|
675
|
+
"""Normalize database path and detect if it's in-memory.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
path: Database path - can be:
|
|
679
|
+
- File path: "./data.db"
|
|
680
|
+
- Plain memory: ":memory:"
|
|
681
|
+
- Named memory: ":memory:namespace"
|
|
682
|
+
- URI: "file:name?mode=memory&cache=shared"
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
Tuple of (normalized_path, is_memory)
|
|
686
|
+
"""
|
|
687
|
+
import uuid
|
|
688
|
+
import sqlite3
|
|
689
|
+
|
|
690
|
+
if path == ":memory:":
|
|
691
|
+
# Plain :memory: - create unique namespace
|
|
692
|
+
name = f"mem_{uuid.uuid4().hex[:8]}"
|
|
693
|
+
return f"file:{name}?mode=memory&cache=shared", True
|
|
694
|
+
elif path.startswith(":memory:"):
|
|
695
|
+
# Named memory: :memory:current -> file:current?mode=memory&cache=shared
|
|
696
|
+
namespace = path[8:] # Remove ":memory:" prefix
|
|
697
|
+
return f"file:{namespace}?mode=memory&cache=shared", True
|
|
698
|
+
elif "mode=memory" in path:
|
|
699
|
+
# Already a proper memory URI
|
|
700
|
+
return path, True
|
|
701
|
+
else:
|
|
702
|
+
# Regular file path
|
|
703
|
+
return path, False
|
|
704
|
+
|
|
705
|
+
def _create_local_instance(self, dbs: Dict[str, str]) -> AsyncEnv:
|
|
706
|
+
"""Create instance with local file-based or in-memory SQLite resources.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
dbs: Map of resource names to paths (e.g., {"current": "./data.db"} or
|
|
710
|
+
{"current": ":memory:current"})
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
AsyncEnv: Environment instance configured for local mode
|
|
714
|
+
"""
|
|
715
|
+
import sqlite3
|
|
716
|
+
|
|
717
|
+
instance_client = AsyncInstanceClient(url="local://", httpx_client=None)
|
|
718
|
+
instance_client._resources = [] # Mark as loaded
|
|
719
|
+
instance_client._memory_anchors = {} # Store anchor connections for in-memory DBs
|
|
720
|
+
|
|
721
|
+
# Store creation parameters for local AsyncSQLiteResources
|
|
722
|
+
# This allows db() to create new instances each time (matching HTTP mode behavior)
|
|
723
|
+
for name, path in dbs.items():
|
|
724
|
+
# Normalize path and detect if it's in-memory
|
|
725
|
+
normalized_path, is_memory = self._normalize_db_path(path)
|
|
726
|
+
|
|
727
|
+
# Create anchor connection for in-memory databases
|
|
728
|
+
# This keeps the database alive as long as the env exists
|
|
729
|
+
if is_memory:
|
|
730
|
+
anchor_conn = sqlite3.connect(normalized_path, uri=True)
|
|
731
|
+
instance_client._memory_anchors[name] = anchor_conn
|
|
732
|
+
|
|
733
|
+
resource_model = ResourceModel(
|
|
734
|
+
name=name,
|
|
735
|
+
type=ResourceType.db,
|
|
736
|
+
mode=ResourceMode.rw,
|
|
737
|
+
label=f"Local: {path}",
|
|
738
|
+
)
|
|
739
|
+
instance_client._resources_state[ResourceType.db.value][name] = {
|
|
740
|
+
'type': 'local',
|
|
741
|
+
'resource_model': resource_model,
|
|
742
|
+
'db_path': normalized_path,
|
|
743
|
+
'is_memory': is_memory
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
# Create a minimal environment for local mode
|
|
747
|
+
env = AsyncEnv(
|
|
748
|
+
client=self.client,
|
|
749
|
+
instance_id="local",
|
|
750
|
+
env_key="local",
|
|
751
|
+
version="",
|
|
752
|
+
status="running",
|
|
753
|
+
subdomain="local",
|
|
754
|
+
created_at="",
|
|
755
|
+
updated_at="",
|
|
756
|
+
terminated_at=None,
|
|
757
|
+
team_id="",
|
|
758
|
+
region="local",
|
|
759
|
+
env_variables=None,
|
|
760
|
+
data_key=None,
|
|
761
|
+
data_version=None,
|
|
762
|
+
urls=None,
|
|
763
|
+
health=None,
|
|
764
|
+
)
|
|
765
|
+
env._instance = instance_client
|
|
766
|
+
return env
|
|
291
767
|
|
|
292
768
|
async def check_bundle_exists(self, bundle_hash: str) -> VerifiersCheckResponse:
|
|
293
769
|
return await _check_bundle_exists(self.client, bundle_hash)
|
|
@@ -302,6 +778,65 @@ class AsyncFleet:
|
|
|
302
778
|
async def delete(self, instance_id: str) -> InstanceResponse:
|
|
303
779
|
return await _delete_instance(self.client, instance_id)
|
|
304
780
|
|
|
781
|
+
async def close(self, instance_id: str) -> InstanceResponse:
|
|
782
|
+
"""Close (delete) a specific instance by ID.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
instance_id: The instance ID to close
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
InstanceResponse containing the deleted instance details
|
|
789
|
+
"""
|
|
790
|
+
return await _delete_instance(self.client, instance_id)
|
|
791
|
+
|
|
792
|
+
async def heartbeat(self, instance_id: str, region: Optional[str] = None) -> HeartbeatResponse:
|
|
793
|
+
"""Send heartbeat to keep instance alive (if heartbeat monitoring is enabled).
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
instance_id: The instance ID to send heartbeat for
|
|
797
|
+
region: Optional region override for cross-region heartbeats
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
HeartbeatResponse containing heartbeat status and deadline information
|
|
801
|
+
"""
|
|
802
|
+
return await _send_heartbeat(self.client, instance_id, region)
|
|
803
|
+
|
|
804
|
+
async def close_all(self, run_id: Optional[str] = None, profile_id: Optional[str] = None) -> List[InstanceResponse]:
|
|
805
|
+
"""Close (delete) instances using the batch delete endpoint.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
run_id: Optional run ID to filter instances by
|
|
809
|
+
profile_id: Optional profile ID to filter instances by (use "self" for your own profile)
|
|
810
|
+
|
|
811
|
+
Returns:
|
|
812
|
+
List[InstanceResponse] containing the deleted instances
|
|
813
|
+
|
|
814
|
+
Note:
|
|
815
|
+
At least one of run_id or profile_id must be provided.
|
|
816
|
+
"""
|
|
817
|
+
return await _delete_instances_batch(self.client, run_id=run_id, profile_id=profile_id)
|
|
818
|
+
|
|
819
|
+
async def list_runs(
|
|
820
|
+
self, profile_id: Optional[str] = None, status: Optional[str] = "active"
|
|
821
|
+
) -> List[Run]:
|
|
822
|
+
"""List all runs (groups of instances by run_id) with aggregated statistics.
|
|
823
|
+
|
|
824
|
+
Args:
|
|
825
|
+
profile_id: Optional profile ID to filter runs by (use "self" for your own profile)
|
|
826
|
+
status: Filter by run status - "active" (default), "inactive", or "all"
|
|
827
|
+
|
|
828
|
+
Returns:
|
|
829
|
+
List[Run] containing run information with instance counts and timestamps
|
|
830
|
+
"""
|
|
831
|
+
params = {}
|
|
832
|
+
if profile_id:
|
|
833
|
+
params["profile_id"] = profile_id
|
|
834
|
+
if status:
|
|
835
|
+
params["active"] = status
|
|
836
|
+
|
|
837
|
+
response = await self.client.request("GET", "/v1/env/runs", params=params)
|
|
838
|
+
return [Run(**run_data) for run_data in response.json()]
|
|
839
|
+
|
|
305
840
|
async def load_tasks_from_file(self, filename: str) -> List[Task]:
|
|
306
841
|
with open(filename, "r", encoding="utf-8") as f:
|
|
307
842
|
tasks_data = f.read()
|
|
@@ -368,6 +903,11 @@ class AsyncFleet:
|
|
|
368
903
|
if not verifier_id:
|
|
369
904
|
verifier_id = task_json.get("key", task_json.get("id"))
|
|
370
905
|
|
|
906
|
+
# Extract verifier_runtime_version from metadata if present
|
|
907
|
+
verifier_runtime_version = None
|
|
908
|
+
if "metadata" in task_json and isinstance(task_json["metadata"], dict):
|
|
909
|
+
verifier_runtime_version = task_json["metadata"].get("verifier_runtime_version")
|
|
910
|
+
|
|
371
911
|
try:
|
|
372
912
|
if verifier_id and verifier_code:
|
|
373
913
|
verifier = await self._create_verifier_from_data(
|
|
@@ -375,13 +915,14 @@ class AsyncFleet:
|
|
|
375
915
|
verifier_key=task_json.get("key", task_json.get("id")),
|
|
376
916
|
verifier_code=verifier_code,
|
|
377
917
|
verifier_sha=verifier_sha,
|
|
918
|
+
verifier_runtime_version=verifier_runtime_version,
|
|
378
919
|
)
|
|
379
920
|
except Exception as e:
|
|
380
921
|
error_msg = f"Failed to create verifier {task_json.get('key', task_json.get('id'))}: {e}"
|
|
381
922
|
if raise_on_verifier_error:
|
|
382
923
|
raise ValueError(error_msg) from e
|
|
383
|
-
else:
|
|
384
|
-
|
|
924
|
+
# else:
|
|
925
|
+
# logger.warning(error_msg)
|
|
385
926
|
|
|
386
927
|
task = Task(
|
|
387
928
|
key=task_json.get("key", task_json.get("id")),
|
|
@@ -398,7 +939,10 @@ class AsyncFleet:
|
|
|
398
939
|
verifier=verifier, # Use created verifier or None
|
|
399
940
|
verifier_id=verifier_id, # Set verifier_id so _rebuild_verifier works
|
|
400
941
|
verifier_sha=verifier_sha, # Set verifier_sha
|
|
942
|
+
verifier_runtime_version=verifier_runtime_version, # Set verifier_runtime_version
|
|
401
943
|
metadata=task_json.get("metadata", {}), # Default empty metadata
|
|
944
|
+
writer_metadata=task_json.get("writer_metadata"), # Writer metadata
|
|
945
|
+
qa_metadata=task_json.get("qa_metadata"), # QA metadata
|
|
402
946
|
output_json_schema=task_json.get("output_json_schema"), # JSON schema for output
|
|
403
947
|
)
|
|
404
948
|
return task
|
|
@@ -473,25 +1017,25 @@ class AsyncFleet:
|
|
|
473
1017
|
verifier_sha=tr.verifier.sha256,
|
|
474
1018
|
)
|
|
475
1019
|
except Exception as e:
|
|
476
|
-
logger.warning(
|
|
477
|
-
|
|
478
|
-
)
|
|
1020
|
+
# logger.warning(
|
|
1021
|
+
# f"Failed to create verifier {tr.verifier.key}: {e}"
|
|
1022
|
+
# )
|
|
479
1023
|
return None
|
|
480
1024
|
else:
|
|
481
1025
|
# Fallback: try fetching by ID
|
|
482
1026
|
try:
|
|
483
|
-
logger.warning(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
)
|
|
1027
|
+
# logger.warning(
|
|
1028
|
+
# f"Embedded verifier code missing for {tr.verifier.key} (NoSuchKey). "
|
|
1029
|
+
# f"Attempting to refetch by id {tr.verifier.verifier_id}"
|
|
1030
|
+
# )
|
|
487
1031
|
return await self._load_verifier(
|
|
488
1032
|
tr.verifier.verifier_id
|
|
489
1033
|
)
|
|
490
1034
|
except Exception as e:
|
|
491
|
-
logger.warning(
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
)
|
|
1035
|
+
# logger.warning(
|
|
1036
|
+
# f"Refetch by verifier id failed for {tr.verifier.key}: {e}. "
|
|
1037
|
+
# "Leaving verifier unset."
|
|
1038
|
+
# )
|
|
495
1039
|
return None
|
|
496
1040
|
|
|
497
1041
|
# Add the coroutine for parallel execution
|
|
@@ -530,9 +1074,10 @@ class AsyncFleet:
|
|
|
530
1074
|
if task_response.verifier:
|
|
531
1075
|
# Process verifier result
|
|
532
1076
|
if isinstance(verifier_result, Exception):
|
|
533
|
-
logger.warning(
|
|
534
|
-
|
|
535
|
-
)
|
|
1077
|
+
# logger.warning(
|
|
1078
|
+
# f"Verifier loading failed for {task_response.key}: {verifier_result}"
|
|
1079
|
+
# )
|
|
1080
|
+
pass
|
|
536
1081
|
elif verifier_result is not None:
|
|
537
1082
|
verifier = verifier_result
|
|
538
1083
|
embedded_code = task_response.verifier.code or ""
|
|
@@ -542,6 +1087,21 @@ class AsyncFleet:
|
|
|
542
1087
|
if not is_embedded_error:
|
|
543
1088
|
verifier_func = embedded_code
|
|
544
1089
|
|
|
1090
|
+
# Extract verifier metadata
|
|
1091
|
+
verifier_id = task_response.verifier_id
|
|
1092
|
+
if not verifier_id and task_response.verifier:
|
|
1093
|
+
verifier_id = task_response.verifier.verifier_id
|
|
1094
|
+
|
|
1095
|
+
verifier_sha = None
|
|
1096
|
+
if task_response.verifier:
|
|
1097
|
+
verifier_sha = task_response.verifier.sha256
|
|
1098
|
+
|
|
1099
|
+
# Extract verifier_runtime_version from metadata if present
|
|
1100
|
+
verifier_runtime_version = None
|
|
1101
|
+
metadata = task_response.metadata or {}
|
|
1102
|
+
if isinstance(metadata, dict):
|
|
1103
|
+
verifier_runtime_version = metadata.get("verifier_runtime_version")
|
|
1104
|
+
|
|
545
1105
|
task = Task(
|
|
546
1106
|
key=task_response.key,
|
|
547
1107
|
prompt=task_response.prompt,
|
|
@@ -553,7 +1113,12 @@ class AsyncFleet:
|
|
|
553
1113
|
env_variables=task_response.env_variables or {},
|
|
554
1114
|
verifier_func=verifier_func, # Set verifier code
|
|
555
1115
|
verifier=verifier, # Use created verifier or None
|
|
556
|
-
|
|
1116
|
+
verifier_id=verifier_id, # Set verifier_id
|
|
1117
|
+
verifier_sha=verifier_sha, # Set verifier_sha
|
|
1118
|
+
verifier_runtime_version=verifier_runtime_version, # Set verifier_runtime_version
|
|
1119
|
+
metadata=metadata,
|
|
1120
|
+
writer_metadata=getattr(task_response, "writer_metadata", None), # Writer metadata
|
|
1121
|
+
qa_metadata=getattr(task_response, "qa_metadata", None), # QA metadata
|
|
557
1122
|
output_json_schema=getattr(task_response, "output_json_schema", None), # Get output_json_schema if available
|
|
558
1123
|
)
|
|
559
1124
|
tasks.append(task)
|
|
@@ -606,10 +1171,10 @@ class AsyncFleet:
|
|
|
606
1171
|
with open(filename, "w", encoding="utf-8") as f:
|
|
607
1172
|
json.dump(tasks_data, f, indent=2, default=str)
|
|
608
1173
|
|
|
609
|
-
logger.info(f"Exported {len(tasks)} tasks to {filename}")
|
|
1174
|
+
# logger.info(f"Exported {len(tasks)} tasks to {filename}")
|
|
610
1175
|
return filename
|
|
611
1176
|
else:
|
|
612
|
-
logger.info("No tasks found to export")
|
|
1177
|
+
# logger.info("No tasks found to export")
|
|
613
1178
|
return None
|
|
614
1179
|
|
|
615
1180
|
async def import_single_task(self, task: Task, project_key: Optional[str] = None):
|
|
@@ -638,7 +1203,7 @@ class AsyncFleet:
|
|
|
638
1203
|
)
|
|
639
1204
|
return response
|
|
640
1205
|
except Exception as e:
|
|
641
|
-
logger.error(f"Failed to import task {task.key}: {e}")
|
|
1206
|
+
# logger.error(f"Failed to import task {task.key}: {e}")
|
|
642
1207
|
return None
|
|
643
1208
|
|
|
644
1209
|
async def import_tasks(self, filename: str, project_key: Optional[str] = None):
|
|
@@ -708,6 +1273,9 @@ class AsyncFleet:
|
|
|
708
1273
|
task_key: str,
|
|
709
1274
|
prompt: Optional[str] = None,
|
|
710
1275
|
verifier_code: Optional[str] = None,
|
|
1276
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1277
|
+
writer_metadata: Optional[Dict[str, Any]] = None,
|
|
1278
|
+
qa_metadata: Optional[Dict[str, Any]] = None,
|
|
711
1279
|
) -> TaskResponse:
|
|
712
1280
|
"""Update an existing task.
|
|
713
1281
|
|
|
@@ -715,11 +1283,20 @@ class AsyncFleet:
|
|
|
715
1283
|
task_key: The key of the task to update
|
|
716
1284
|
prompt: New prompt text for the task (optional)
|
|
717
1285
|
verifier_code: Python code for task verification (optional)
|
|
1286
|
+
metadata: Additional metadata for the task (optional)
|
|
1287
|
+
writer_metadata: Metadata filled by task writer (optional)
|
|
1288
|
+
qa_metadata: Metadata filled by QA reviewer (optional)
|
|
718
1289
|
|
|
719
1290
|
Returns:
|
|
720
1291
|
TaskResponse containing the updated task details
|
|
721
1292
|
"""
|
|
722
|
-
payload = TaskUpdateRequest(
|
|
1293
|
+
payload = TaskUpdateRequest(
|
|
1294
|
+
prompt=prompt,
|
|
1295
|
+
verifier_code=verifier_code,
|
|
1296
|
+
metadata=metadata,
|
|
1297
|
+
writer_metadata=writer_metadata,
|
|
1298
|
+
qa_metadata=qa_metadata,
|
|
1299
|
+
)
|
|
723
1300
|
response = await self.client.request(
|
|
724
1301
|
"PUT", f"/v1/tasks/{task_key}", json=payload.model_dump(exclude_none=True)
|
|
725
1302
|
)
|
|
@@ -752,8 +1329,291 @@ class AsyncFleet:
|
|
|
752
1329
|
)
|
|
753
1330
|
return TaskResponse(**response.json())
|
|
754
1331
|
|
|
1332
|
+
# Sessions API methods
|
|
1333
|
+
|
|
1334
|
+
async def list_job_sessions(self, job_id: str) -> JobSessionsResponse:
|
|
1335
|
+
"""List all sessions for a job, grouped by task.
|
|
1336
|
+
|
|
1337
|
+
Args:
|
|
1338
|
+
job_id: The job ID
|
|
1339
|
+
|
|
1340
|
+
Returns:
|
|
1341
|
+
JobSessionsResponse containing sessions grouped by task with statistics
|
|
1342
|
+
"""
|
|
1343
|
+
response = await self.client.request("GET", f"/v1/sessions/job/{job_id}")
|
|
1344
|
+
return JobSessionsResponse(**response.json())
|
|
1345
|
+
|
|
1346
|
+
async def get_session_transcript(self, session_id: str) -> SessionTranscriptResponse:
|
|
1347
|
+
"""Get the transcript for a specific session.
|
|
1348
|
+
|
|
1349
|
+
Args:
|
|
1350
|
+
session_id: The session ID
|
|
1351
|
+
|
|
1352
|
+
Returns:
|
|
1353
|
+
SessionTranscriptResponse containing task, instance, verifier result, and messages
|
|
1354
|
+
"""
|
|
1355
|
+
response = await self.client.request(
|
|
1356
|
+
"GET", f"/v1/sessions/{session_id}/transcript"
|
|
1357
|
+
)
|
|
1358
|
+
return SessionTranscriptResponse(**response.json())
|
|
1359
|
+
|
|
1360
|
+
async def _ingest(
|
|
1361
|
+
self,
|
|
1362
|
+
messages: List[Dict[str, Any]],
|
|
1363
|
+
session_id: Optional[str] = None,
|
|
1364
|
+
model: Optional[str] = None,
|
|
1365
|
+
task_key: Optional[str] = None,
|
|
1366
|
+
job_id: Optional[str] = None,
|
|
1367
|
+
instance_id: Optional[str] = None,
|
|
1368
|
+
status: Optional[str] = None,
|
|
1369
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1370
|
+
started_at: Optional[str] = None,
|
|
1371
|
+
ended_at: Optional[str] = None,
|
|
1372
|
+
verifier_execution_id: Optional[str] = None,
|
|
1373
|
+
) -> SessionIngestResponse:
|
|
1374
|
+
"""Internal method to ingest session data."""
|
|
1375
|
+
message_objects = [SessionIngestMessage(**msg) for msg in messages]
|
|
1376
|
+
request = SessionIngestRequest(
|
|
1377
|
+
messages=message_objects,
|
|
1378
|
+
session_id=session_id,
|
|
1379
|
+
model=model,
|
|
1380
|
+
task_key=task_key,
|
|
1381
|
+
job_id=job_id,
|
|
1382
|
+
instance_id=instance_id,
|
|
1383
|
+
status=SessionStatus(status) if status else None,
|
|
1384
|
+
metadata=metadata,
|
|
1385
|
+
started_at=started_at,
|
|
1386
|
+
ended_at=ended_at,
|
|
1387
|
+
verifier_execution_id=verifier_execution_id,
|
|
1388
|
+
)
|
|
1389
|
+
response = await self.client.request(
|
|
1390
|
+
"POST",
|
|
1391
|
+
"/v1/sessions/ingest",
|
|
1392
|
+
json=request.model_dump(exclude_none=True),
|
|
1393
|
+
)
|
|
1394
|
+
return SessionIngestResponse(**response.json())
|
|
1395
|
+
|
|
1396
|
+
async def _ingest_raw(
|
|
1397
|
+
self,
|
|
1398
|
+
payload: Dict[str, Any],
|
|
1399
|
+
) -> SessionIngestResponse:
|
|
1400
|
+
"""Internal method to ingest raw session data as JSON.
|
|
1401
|
+
|
|
1402
|
+
This sends the history and response as-is to the backend,
|
|
1403
|
+
letting the backend handle format normalization.
|
|
1404
|
+
"""
|
|
1405
|
+
# Pre-serialize with our custom handler to ensure all types are JSON-safe
|
|
1406
|
+
json_str = json.dumps(payload, default=_json_default)
|
|
1407
|
+
clean_payload = json.loads(json_str)
|
|
1408
|
+
|
|
1409
|
+
response = await self.client.request(
|
|
1410
|
+
"POST",
|
|
1411
|
+
"/v1/traces/logs",
|
|
1412
|
+
json=clean_payload,
|
|
1413
|
+
)
|
|
1414
|
+
return SessionIngestResponse(**response.json())
|
|
1415
|
+
|
|
1416
|
+
def start_session(
|
|
1417
|
+
self,
|
|
1418
|
+
session_id: Optional[str] = None,
|
|
1419
|
+
job_id: Optional[str] = None,
|
|
1420
|
+
config: Optional[Any] = None,
|
|
1421
|
+
model: Optional[str] = None,
|
|
1422
|
+
task_key: Optional[str] = None,
|
|
1423
|
+
instance_id: Optional[str] = None,
|
|
1424
|
+
) -> AsyncSession:
|
|
1425
|
+
"""Start a new session for logging agent interactions.
|
|
1426
|
+
|
|
1427
|
+
This returns a Session object. The session is created on the backend
|
|
1428
|
+
when you call log() for the first time.
|
|
1429
|
+
|
|
1430
|
+
Args:
|
|
1431
|
+
session_id: Optional existing session ID to resume
|
|
1432
|
+
job_id: Optional job ID to associate with the session
|
|
1433
|
+
config: Optional config object (e.g., GenerateContentConfig) to log
|
|
1434
|
+
model: Optional model name to log
|
|
1435
|
+
task_key: Optional Fleet task key
|
|
1436
|
+
instance_id: Optional Fleet instance ID
|
|
1437
|
+
|
|
1438
|
+
Returns:
|
|
1439
|
+
AsyncSession object with log(), complete(), and fail() methods
|
|
1440
|
+
|
|
1441
|
+
Example:
|
|
1442
|
+
session = fleet_client.start_session(config=config, model="gpt-4", task_key="task_123")
|
|
1443
|
+
|
|
1444
|
+
# Log LLM calls during agent run
|
|
1445
|
+
await session.log(history, response)
|
|
1446
|
+
|
|
1447
|
+
# Complete when done
|
|
1448
|
+
await session.complete()
|
|
1449
|
+
"""
|
|
1450
|
+
return AsyncSession(
|
|
1451
|
+
client=self,
|
|
1452
|
+
session_id=session_id,
|
|
1453
|
+
job_id=job_id,
|
|
1454
|
+
config=config,
|
|
1455
|
+
model=model,
|
|
1456
|
+
task_key=task_key,
|
|
1457
|
+
instance_id=instance_id,
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
async def trace_job(self, name: Optional[str] = None) -> str:
|
|
1461
|
+
"""Create a new trace job.
|
|
1462
|
+
|
|
1463
|
+
Args:
|
|
1464
|
+
name: Name of the job (generated server-side if not provided)
|
|
1465
|
+
|
|
1466
|
+
Returns:
|
|
1467
|
+
The job_id string
|
|
1468
|
+
"""
|
|
1469
|
+
from fleet.models import TraceJobRequest, TraceJobResponse
|
|
1470
|
+
|
|
1471
|
+
request = TraceJobRequest(name=name)
|
|
1472
|
+
response = await self.client.request(
|
|
1473
|
+
"POST",
|
|
1474
|
+
"/v1/traces/jobs",
|
|
1475
|
+
json=request.model_dump(),
|
|
1476
|
+
)
|
|
1477
|
+
result = TraceJobResponse(**response.json())
|
|
1478
|
+
return result.job_id
|
|
1479
|
+
|
|
1480
|
+
async def create_session(
|
|
1481
|
+
self,
|
|
1482
|
+
model: Optional[str] = None,
|
|
1483
|
+
task_key: Optional[str] = None,
|
|
1484
|
+
job_id: Optional[str] = None,
|
|
1485
|
+
instance_id: Optional[str] = None,
|
|
1486
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1487
|
+
started_at: Optional[str] = None,
|
|
1488
|
+
initial_message: Optional[Dict[str, Any]] = None,
|
|
1489
|
+
) -> SessionIngestResponse:
|
|
1490
|
+
"""Create a new session, optionally with an initial message.
|
|
1491
|
+
|
|
1492
|
+
This is useful for streaming scenarios where you want to create
|
|
1493
|
+
a session first and then append messages one by one.
|
|
1494
|
+
|
|
1495
|
+
Args:
|
|
1496
|
+
model: Model identifier (e.g., "anthropic/claude-sonnet-4")
|
|
1497
|
+
task_key: Task key to associate with the session
|
|
1498
|
+
job_id: Job ID to associate with the session
|
|
1499
|
+
instance_id: Instance ID to associate with the session
|
|
1500
|
+
metadata: Additional metadata for the session
|
|
1501
|
+
started_at: ISO timestamp when session started
|
|
1502
|
+
initial_message: Optional first message dict with 'role' and 'content'
|
|
1503
|
+
|
|
1504
|
+
Returns:
|
|
1505
|
+
SessionIngestResponse containing session_id
|
|
1506
|
+
|
|
1507
|
+
Example:
|
|
1508
|
+
# Create session and get ID
|
|
1509
|
+
session = await fleet.create_session(
|
|
1510
|
+
model="anthropic/claude-sonnet-4",
|
|
1511
|
+
task_key="my_task",
|
|
1512
|
+
started_at=datetime.now().isoformat()
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
# Append messages as they happen
|
|
1516
|
+
await fleet.append_message(session.session_id, {"role": "user", "content": "Hello"})
|
|
1517
|
+
await fleet.append_message(session.session_id, {"role": "assistant", "content": "Hi!"})
|
|
1518
|
+
"""
|
|
1519
|
+
# Use a placeholder message if none provided
|
|
1520
|
+
if initial_message:
|
|
1521
|
+
messages = [initial_message]
|
|
1522
|
+
else:
|
|
1523
|
+
messages = [{"role": "system", "content": "[session created]"}]
|
|
1524
|
+
|
|
1525
|
+
return await self._ingest(
|
|
1526
|
+
messages=messages,
|
|
1527
|
+
model=model,
|
|
1528
|
+
task_key=task_key,
|
|
1529
|
+
job_id=job_id,
|
|
1530
|
+
instance_id=instance_id,
|
|
1531
|
+
status="running",
|
|
1532
|
+
metadata=metadata,
|
|
1533
|
+
started_at=started_at,
|
|
1534
|
+
)
|
|
1535
|
+
|
|
1536
|
+
async def append_message(
|
|
1537
|
+
self,
|
|
1538
|
+
session_id: str,
|
|
1539
|
+
message: Dict[str, Any],
|
|
1540
|
+
status: Optional[str] = None,
|
|
1541
|
+
ended_at: Optional[str] = None,
|
|
1542
|
+
) -> SessionIngestResponse:
|
|
1543
|
+
"""Append a single message to an existing session.
|
|
1544
|
+
|
|
1545
|
+
This is useful for streaming scenarios where you want to send
|
|
1546
|
+
messages one by one as they happen.
|
|
1547
|
+
|
|
1548
|
+
Args:
|
|
1549
|
+
session_id: The session ID to append to
|
|
1550
|
+
message: Message dict with 'role' and 'content' keys.
|
|
1551
|
+
Optional keys: 'tool_calls', 'tool_call_id', 'timestamp', 'tokens', 'metadata'
|
|
1552
|
+
status: Optional status update ("running", "completed", "failed")
|
|
1553
|
+
ended_at: ISO timestamp when session ended (set when completing)
|
|
1554
|
+
|
|
1555
|
+
Returns:
|
|
1556
|
+
SessionIngestResponse with updated message count
|
|
1557
|
+
|
|
1558
|
+
Example:
|
|
1559
|
+
# Append user message
|
|
1560
|
+
await fleet.append_message(session_id, {"role": "user", "content": "What's 2+2?"})
|
|
1561
|
+
|
|
1562
|
+
# Append assistant response
|
|
1563
|
+
await fleet.append_message(session_id, {"role": "assistant", "content": "4"})
|
|
1564
|
+
|
|
1565
|
+
# Complete the session
|
|
1566
|
+
await fleet.append_message(
|
|
1567
|
+
session_id,
|
|
1568
|
+
{"role": "assistant", "content": "Done!"},
|
|
1569
|
+
status="completed",
|
|
1570
|
+
ended_at=datetime.now().isoformat()
|
|
1571
|
+
)
|
|
1572
|
+
"""
|
|
1573
|
+
return await self._ingest(
|
|
1574
|
+
messages=[message],
|
|
1575
|
+
session_id=session_id,
|
|
1576
|
+
status=status,
|
|
1577
|
+
ended_at=ended_at,
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
async def complete_session(
|
|
1581
|
+
self,
|
|
1582
|
+
session_id: str,
|
|
1583
|
+
status: str = "completed",
|
|
1584
|
+
ended_at: Optional[str] = None,
|
|
1585
|
+
final_message: Optional[Dict[str, Any]] = None,
|
|
1586
|
+
) -> SessionIngestResponse:
|
|
1587
|
+
"""Mark a session as complete.
|
|
1588
|
+
|
|
1589
|
+
Args:
|
|
1590
|
+
session_id: The session ID to complete
|
|
1591
|
+
status: Final status ("completed", "failed", "cancelled")
|
|
1592
|
+
ended_at: ISO timestamp when session ended (defaults to now)
|
|
1593
|
+
final_message: Optional final message to append
|
|
1594
|
+
|
|
1595
|
+
Returns:
|
|
1596
|
+
SessionIngestResponse with final state
|
|
1597
|
+
"""
|
|
1598
|
+
from datetime import datetime as dt
|
|
1599
|
+
|
|
1600
|
+
if ended_at is None:
|
|
1601
|
+
ended_at = dt.now().isoformat()
|
|
1602
|
+
|
|
1603
|
+
if final_message:
|
|
1604
|
+
messages = [final_message]
|
|
1605
|
+
else:
|
|
1606
|
+
messages = [{"role": "system", "content": f"[session {status}]"}]
|
|
1607
|
+
|
|
1608
|
+
return await self._ingest(
|
|
1609
|
+
messages=messages,
|
|
1610
|
+
session_id=session_id,
|
|
1611
|
+
status=status,
|
|
1612
|
+
ended_at=ended_at,
|
|
1613
|
+
)
|
|
1614
|
+
|
|
755
1615
|
async def _create_verifier_from_data(
|
|
756
|
-
self, verifier_id: str, verifier_key: str, verifier_code: str, verifier_sha: str
|
|
1616
|
+
self, verifier_id: str, verifier_key: str, verifier_code: str, verifier_sha: str, verifier_runtime_version: Optional[str] = None
|
|
757
1617
|
) -> "AsyncVerifierFunction":
|
|
758
1618
|
"""Create an AsyncVerifierFunction from verifier data.
|
|
759
1619
|
|
|
@@ -774,6 +1634,7 @@ class AsyncFleet:
|
|
|
774
1634
|
verifier_id=verifier_id,
|
|
775
1635
|
verifier_key=verifier_key,
|
|
776
1636
|
sha256=verifier_sha,
|
|
1637
|
+
verifier_runtime_version=verifier_runtime_version or "",
|
|
777
1638
|
)
|
|
778
1639
|
|
|
779
1640
|
# Store the original verifier code for reference
|
|
@@ -809,6 +1670,37 @@ async def _delete_instance(client: AsyncWrapper, instance_id: str) -> InstanceRe
|
|
|
809
1670
|
return InstanceResponse(**response.json())
|
|
810
1671
|
|
|
811
1672
|
|
|
1673
|
+
async def _send_heartbeat(client: AsyncWrapper, instance_id: str, region: Optional[str] = None) -> HeartbeatResponse:
|
|
1674
|
+
"""Send heartbeat to keep instance alive."""
|
|
1675
|
+
body = {}
|
|
1676
|
+
if region:
|
|
1677
|
+
body["region"] = region
|
|
1678
|
+
|
|
1679
|
+
response = await client.request(
|
|
1680
|
+
"POST",
|
|
1681
|
+
f"/v1/env/instances/{instance_id}/heartbeat",
|
|
1682
|
+
json=body
|
|
1683
|
+
)
|
|
1684
|
+
return HeartbeatResponse(**response.json())
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
async def _delete_instances_batch(
|
|
1688
|
+
client: AsyncWrapper, run_id: Optional[str] = None, profile_id: Optional[str] = None
|
|
1689
|
+
) -> List[InstanceResponse]:
|
|
1690
|
+
"""Delete instances using the batch endpoint with flexible filtering."""
|
|
1691
|
+
params = {}
|
|
1692
|
+
if run_id:
|
|
1693
|
+
params["run_id"] = run_id
|
|
1694
|
+
if profile_id:
|
|
1695
|
+
params["profile_id"] = profile_id
|
|
1696
|
+
|
|
1697
|
+
if not params:
|
|
1698
|
+
raise ValueError("At least one of run_id or profile_id must be provided")
|
|
1699
|
+
|
|
1700
|
+
response = await client.request("DELETE", "/v1/env/instances/batch", params=params)
|
|
1701
|
+
return [InstanceResponse(**instance_data) for instance_data in response.json()]
|
|
1702
|
+
|
|
1703
|
+
|
|
812
1704
|
async def _check_bundle_exists(
|
|
813
1705
|
client: AsyncWrapper, bundle_hash: str
|
|
814
1706
|
) -> VerifiersCheckResponse:
|
|
@@ -827,6 +1719,7 @@ async def _execute_verifier_remote(
|
|
|
827
1719
|
kwargs: dict,
|
|
828
1720
|
timeout: Optional[int] = 30,
|
|
829
1721
|
needs_upload: bool = True,
|
|
1722
|
+
verifier_runtime_version: Optional[str] = None,
|
|
830
1723
|
) -> VerifiersExecuteResponse:
|
|
831
1724
|
# Pickle args and kwargs together
|
|
832
1725
|
# The first arg should be None as a placeholder for env
|
|
@@ -850,18 +1743,22 @@ async def _execute_verifier_remote(
|
|
|
850
1743
|
bundle_b64 = base64.b64encode(bundle_data).decode("utf-8")
|
|
851
1744
|
request_data["bundle"] = bundle_b64
|
|
852
1745
|
|
|
1746
|
+
# Add verifier_runtime_version if present
|
|
1747
|
+
if verifier_runtime_version:
|
|
1748
|
+
request_data["verifier_runtime_version"] = verifier_runtime_version
|
|
1749
|
+
|
|
853
1750
|
# Debug logging
|
|
854
|
-
logger.debug(
|
|
855
|
-
|
|
856
|
-
)
|
|
857
|
-
logger.debug(f"Request has bundle: {needs_upload}")
|
|
858
|
-
logger.debug(f"Using client with base_url: {client.base_url}")
|
|
859
|
-
logger.debug(f"Request data keys: {list(request_data.keys())}")
|
|
860
|
-
logger.debug(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
)
|
|
1751
|
+
# logger.debug(
|
|
1752
|
+
# f"Sending verifier execute request: key={key}, sha256={bundle_sha[:8]}..., function_name={function_name}"
|
|
1753
|
+
# )
|
|
1754
|
+
# logger.debug(f"Request has bundle: {needs_upload}")
|
|
1755
|
+
# logger.debug(f"Using client with base_url: {client.base_url}")
|
|
1756
|
+
# logger.debug(f"Request data keys: {list(request_data.keys())}")
|
|
1757
|
+
# logger.debug(
|
|
1758
|
+
# f"Bundle size: {len(request_data.get('bundle', ''))} chars"
|
|
1759
|
+
# if "bundle" in request_data
|
|
1760
|
+
# else "No bundle"
|
|
1761
|
+
# )
|
|
865
1762
|
|
|
866
1763
|
# Note: This should be called on the instance URL, not the orchestrator
|
|
867
1764
|
# The instance has manager URLs for verifier execution
|
|
@@ -869,6 +1766,6 @@ async def _execute_verifier_remote(
|
|
|
869
1766
|
|
|
870
1767
|
# Debug the response
|
|
871
1768
|
response_json = response.json()
|
|
872
|
-
logger.debug(f"Verifier execute response: {response_json}")
|
|
1769
|
+
# logger.debug(f"Verifier execute response: {response_json}")
|
|
873
1770
|
|
|
874
1771
|
return VerifiersExecuteResponse(**response_json)
|