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.
Files changed (70) hide show
  1. examples/export_tasks.py +16 -5
  2. examples/export_tasks_filtered.py +245 -0
  3. examples/fetch_tasks.py +230 -0
  4. examples/import_tasks.py +140 -8
  5. examples/iterate_verifiers.py +725 -0
  6. fleet/__init__.py +128 -5
  7. fleet/_async/__init__.py +27 -3
  8. fleet/_async/base.py +24 -9
  9. fleet/_async/client.py +938 -41
  10. fleet/_async/env/client.py +60 -3
  11. fleet/_async/instance/client.py +52 -7
  12. fleet/_async/models.py +15 -0
  13. fleet/_async/resources/api.py +200 -0
  14. fleet/_async/resources/sqlite.py +1801 -46
  15. fleet/_async/tasks.py +122 -25
  16. fleet/_async/verifiers/bundler.py +22 -21
  17. fleet/_async/verifiers/verifier.py +25 -19
  18. fleet/agent/__init__.py +32 -0
  19. fleet/agent/gemini_cua/Dockerfile +45 -0
  20. fleet/agent/gemini_cua/__init__.py +10 -0
  21. fleet/agent/gemini_cua/agent.py +759 -0
  22. fleet/agent/gemini_cua/mcp/main.py +108 -0
  23. fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
  24. fleet/agent/gemini_cua/mcp_server/main.py +105 -0
  25. fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
  26. fleet/agent/gemini_cua/requirements.txt +5 -0
  27. fleet/agent/gemini_cua/start.sh +30 -0
  28. fleet/agent/orchestrator.py +854 -0
  29. fleet/agent/types.py +49 -0
  30. fleet/agent/utils.py +34 -0
  31. fleet/base.py +34 -9
  32. fleet/cli.py +1061 -0
  33. fleet/client.py +1060 -48
  34. fleet/config.py +1 -1
  35. fleet/env/__init__.py +16 -0
  36. fleet/env/client.py +60 -3
  37. fleet/eval/__init__.py +15 -0
  38. fleet/eval/uploader.py +231 -0
  39. fleet/exceptions.py +8 -0
  40. fleet/instance/client.py +53 -8
  41. fleet/instance/models.py +1 -0
  42. fleet/models.py +303 -0
  43. fleet/proxy/__init__.py +25 -0
  44. fleet/proxy/proxy.py +453 -0
  45. fleet/proxy/whitelist.py +244 -0
  46. fleet/resources/api.py +200 -0
  47. fleet/resources/sqlite.py +1845 -46
  48. fleet/tasks.py +113 -20
  49. fleet/utils/__init__.py +7 -0
  50. fleet/utils/http_logging.py +178 -0
  51. fleet/utils/logging.py +13 -0
  52. fleet/utils/playwright.py +440 -0
  53. fleet/verifiers/bundler.py +22 -21
  54. fleet/verifiers/db.py +985 -1
  55. fleet/verifiers/decorator.py +1 -1
  56. fleet/verifiers/verifier.py +25 -19
  57. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
  58. fleet_python-0.2.105.dist-info/RECORD +115 -0
  59. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
  60. fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
  61. tests/test_app_method.py +85 -0
  62. tests/test_expect_exactly.py +4148 -0
  63. tests/test_expect_only.py +2593 -0
  64. tests/test_instance_dispatch.py +607 -0
  65. tests/test_sqlite_resource_dual_mode.py +263 -0
  66. tests/test_sqlite_shared_memory_behavior.py +117 -0
  67. fleet_python-0.2.66b2.dist-info/RECORD +0 -81
  68. tests/test_verifier_security.py +0 -427
  69. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
  70. {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 typing import List, Optional, Dict, Any, TYPE_CHECKING
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
- response = await self.client.request("GET", f"/v1/env/instances/{instance_id}")
288
- instance = AsyncEnv(client=self.client, **response.json())
289
- await instance.instance.load()
290
- return instance
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
- logger.warning(error_msg)
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
- f"Failed to create verifier {tr.verifier.key}: {e}"
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
- f"Embedded verifier code missing for {tr.verifier.key} (NoSuchKey). "
485
- f"Attempting to refetch by id {tr.verifier.verifier_id}"
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
- f"Refetch by verifier id failed for {tr.verifier.key}: {e}. "
493
- "Leaving verifier unset."
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
- f"Verifier loading failed for {task_response.key}: {verifier_result}"
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
- metadata={}, # Default empty metadata
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(prompt=prompt, verifier_code=verifier_code)
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
- f"Sending verifier execute request: key={key}, sha256={bundle_sha[:8]}..., function_name={function_name}"
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
- f"Bundle size: {len(request_data.get('bundle', ''))} chars"
862
- if "bundle" in request_data
863
- else "No bundle"
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)