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/client.py CHANGED
@@ -17,11 +17,18 @@
17
17
  import base64
18
18
  import cloudpickle
19
19
  import concurrent.futures
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 urllib.parse import urlparse
31
+ from uuid import UUID
25
32
 
26
33
  from .base import EnvironmentBase, SyncWrapper
27
34
  from .models import (
@@ -35,18 +42,130 @@ from .models import (
35
42
  TaskRequest,
36
43
  TaskResponse,
37
44
  TaskUpdateRequest,
45
+ Run,
46
+ HeartbeatResponse,
47
+ JobCreateRequest,
48
+ JobResponse,
49
+ JobListResponse,
50
+ JobCreateResponse,
51
+ JobSessionsResponse,
52
+ SessionTranscriptResponse,
53
+ SessionIngestRequest,
54
+ SessionIngestMessage,
55
+ SessionIngestResponse,
56
+ SessionStatus,
38
57
  )
39
58
  from .tasks import Task
40
59
 
41
60
  if TYPE_CHECKING:
42
61
  from .verifiers import SyncVerifierFunction
43
62
 
63
+
64
+ def _json_default(x: Any) -> Any:
65
+ """Default JSON serializer for non-native types."""
66
+ if isinstance(x, (datetime, date)):
67
+ return x.isoformat()
68
+ if isinstance(x, (UUID, Path)):
69
+ return str(x)
70
+ if isinstance(x, Decimal):
71
+ return float(x)
72
+ if isinstance(x, Enum):
73
+ return x.value
74
+ if isinstance(x, bytes):
75
+ return base64.b64encode(x).decode("utf-8")
76
+ if isinstance(x, set):
77
+ return list(x)
78
+ if dataclasses.is_dataclass(x) and not isinstance(x, type):
79
+ return dataclasses.asdict(x)
80
+ # Handle objects with __dict__ (generic objects)
81
+ if hasattr(x, "__dict__"):
82
+ return x.__dict__
83
+ raise TypeError(f"Not JSON serializable: {type(x)}")
84
+
85
+
86
+ def _to_dict(obj: Any) -> Any:
87
+ """Convert any object to a JSON-serializable dict/value.
88
+
89
+ Handles:
90
+ - Pydantic v2 models (model_dump)
91
+ - Pydantic v1 models (.dict())
92
+ - dataclasses (asdict)
93
+ - TypedDict (just dict at runtime)
94
+ - Objects with __dict__
95
+ - Primitives pass through
96
+ """
97
+ if obj is None:
98
+ return None
99
+
100
+ # Pydantic v2
101
+ if hasattr(obj, "model_dump"):
102
+ return obj.model_dump()
103
+
104
+ # Pydantic v1
105
+ if hasattr(obj, "dict") and callable(obj.dict):
106
+ return obj.dict()
107
+
108
+ # dataclass
109
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
110
+ return dataclasses.asdict(obj)
111
+
112
+ # Already a dict or list - recursively convert
113
+ if isinstance(obj, dict):
114
+ return {k: _to_dict(v) for k, v in obj.items()}
115
+ if isinstance(obj, list):
116
+ return [_to_dict(v) for v in obj]
117
+
118
+ # Primitives
119
+ if isinstance(obj, (str, int, float, bool, type(None))):
120
+ return obj
121
+
122
+ # bytes -> base64
123
+ if isinstance(obj, bytes):
124
+ return base64.b64encode(obj).decode("utf-8")
125
+
126
+ # datetime/date
127
+ if isinstance(obj, (datetime, date)):
128
+ return obj.isoformat()
129
+
130
+ # UUID, Path
131
+ if isinstance(obj, (UUID, Path)):
132
+ return str(obj)
133
+
134
+ # Enum
135
+ if isinstance(obj, Enum):
136
+ return obj.value
137
+
138
+ # Decimal
139
+ if isinstance(obj, Decimal):
140
+ return float(obj)
141
+
142
+ # set
143
+ if isinstance(obj, set):
144
+ return list(obj)
145
+
146
+ # Generic object with __dict__
147
+ if hasattr(obj, "__dict__"):
148
+ return {k: _to_dict(v) for k, v in obj.__dict__.items() if not k.startswith("_")}
149
+
150
+ # Fallback - try to convert, or return string representation
151
+ try:
152
+ json.dumps(obj)
153
+ return obj
154
+ except (TypeError, ValueError):
155
+ return str(obj)
156
+
157
+
44
158
  from .instance import (
45
159
  InstanceClient,
46
160
  ResetRequest,
47
161
  ResetResponse,
48
162
  ExecuteFunctionResponse,
49
163
  )
164
+ from .instance.models import (
165
+ Resource as ResourceModel,
166
+ ResourceType,
167
+ ResourceMode,
168
+ )
50
169
  from .config import (
51
170
  DEFAULT_MAX_RETRIES,
52
171
  DEFAULT_TIMEOUT,
@@ -59,16 +178,183 @@ from .resources.base import Resource
59
178
  from .resources.sqlite import SQLiteResource
60
179
  from .resources.browser import BrowserResource
61
180
  from .resources.mcp import SyncMCPResource
181
+ from .resources.api import APIResource
62
182
 
63
183
  logger = logging.getLogger(__name__)
64
184
 
65
185
 
186
+ class Session:
187
+ """A session for logging agent interactions to Fleet.
188
+
189
+ This provides a simple interface for streaming messages during an agent run.
190
+ Messages are sent one-by-one as they happen.
191
+
192
+ Usage:
193
+ session = fleet.session(job_id=job_id)
194
+
195
+ # Log LLM calls
196
+ session.log(history, response)
197
+
198
+ # Complete when done
199
+ session.complete() # or session.fail()
200
+ """
201
+
202
+ def __init__(
203
+ self,
204
+ client: "Fleet",
205
+ session_id: Optional[str] = None,
206
+ job_id: Optional[str] = None,
207
+ config: Optional[Any] = None,
208
+ model: Optional[str] = None,
209
+ task_key: Optional[str] = None,
210
+ instance_id: Optional[str] = None,
211
+ ):
212
+ self.session_id = session_id
213
+ self.job_id = job_id
214
+ self.config = config
215
+ self.model = model
216
+ self.task_key = task_key
217
+ self.instance_id = instance_id
218
+ self._client = client
219
+ self._message_count = 0
220
+ self._logged_count = 0 # Track how many messages from history have been logged
221
+ self._config_sent = False # Only send config/model/task_key/instance_id on first log
222
+
223
+ def log(self, history: List[Any], response: Any) -> "SessionIngestResponse":
224
+ """Log an LLM call to the session.
225
+
226
+ Pass the input history and the model response. The session tracks what's
227
+ already been logged and only sends new messages. Objects are automatically
228
+ serialized to JSON (supports Pydantic, dataclasses, TypedDict, etc.).
229
+
230
+ Example:
231
+ response = model.generate(history)
232
+ session.log(history, response.content)
233
+
234
+ Args:
235
+ history: The input messages sent to the model
236
+ response: The model's response (any serializable object)
237
+
238
+ Returns:
239
+ SessionIngestResponse with updated message count
240
+ """
241
+ from .models import SessionIngestResponse
242
+
243
+ # Collect new history messages since last call
244
+ new_history = history[self._logged_count:]
245
+
246
+ # Update tracked count to include the response we're about to send
247
+ # This prevents the response from being sent again as "new history" in the next call
248
+ self._logged_count = len(history) + (1 if response is not None else 0)
249
+
250
+ # Build the payload - serialize history + response to JSON
251
+ payload: Dict[str, Any] = {
252
+ "history": [_to_dict(msg) for msg in new_history],
253
+ "response": _to_dict(response),
254
+ }
255
+ if self.session_id:
256
+ payload["session_id"] = self.session_id
257
+ if self.job_id:
258
+ payload["job_id"] = self.job_id
259
+ # Include config, model, task_key, instance_id on first log only
260
+ if not self._config_sent:
261
+ if self.config is not None:
262
+ payload["config"] = _to_dict(self.config)
263
+ if self.model is not None:
264
+ payload["model"] = self.model
265
+ if self.task_key is not None:
266
+ payload["task_key"] = self.task_key
267
+ if self.instance_id is not None:
268
+ payload["instance_id"] = self.instance_id
269
+ self._config_sent = True
270
+
271
+ if not new_history and response is None:
272
+ return SessionIngestResponse(
273
+ success=True,
274
+ session_id=self.session_id or "",
275
+ message_count=self._message_count,
276
+ created_new_session=False,
277
+ )
278
+
279
+ result = self._client._ingest_raw(payload=payload)
280
+ self._message_count = result.message_count
281
+ # Update session_id if this was the first log (new session created)
282
+ if not self.session_id and result.session_id:
283
+ self.session_id = result.session_id
284
+ return result
285
+
286
+ def complete(
287
+ self,
288
+ verifier_execution_id: Optional[str] = None,
289
+ ) -> "SessionIngestResponse":
290
+ """Mark the session as completed successfully.
291
+
292
+ Args:
293
+ verifier_execution_id: Optional ID of the verifier execution record
294
+
295
+ Returns:
296
+ SessionIngestResponse with final state
297
+ """
298
+ from datetime import datetime
299
+
300
+ payload: Dict[str, Any] = {
301
+ "session_id": self.session_id,
302
+ "status": "completed",
303
+ "ended_at": datetime.now().isoformat(),
304
+ }
305
+ if verifier_execution_id:
306
+ payload["verifier_execution_id"] = verifier_execution_id
307
+
308
+ response = self._client._ingest_raw(payload)
309
+ self._message_count = response.message_count
310
+ return response
311
+
312
+ def fail(
313
+ self,
314
+ verifier_execution_id: Optional[str] = None,
315
+ ) -> "SessionIngestResponse":
316
+ """Mark the session as failed.
317
+
318
+ Args:
319
+ verifier_execution_id: Optional ID of the verifier execution record
320
+
321
+ Returns:
322
+ SessionIngestResponse with final state
323
+ """
324
+ from datetime import datetime
325
+
326
+ payload: Dict[str, Any] = {
327
+ "session_id": self.session_id,
328
+ "status": "failed",
329
+ "ended_at": datetime.now().isoformat(),
330
+ }
331
+ if verifier_execution_id:
332
+ payload["verifier_execution_id"] = verifier_execution_id
333
+
334
+ response = self._client._ingest_raw(payload)
335
+ self._message_count = response.message_count
336
+ return response
337
+
338
+ @property
339
+ def message_count(self) -> int:
340
+ """Get the current message count."""
341
+ return self._message_count
342
+
343
+
66
344
  class SyncEnv(EnvironmentBase):
67
345
  def __init__(self, client: Optional[SyncWrapper], **kwargs):
68
346
  super().__init__(**kwargs)
69
347
  self._client = client
70
348
  self._apps: Dict[str, InstanceClient] = {}
71
349
  self._instance: Optional[InstanceClient] = None
350
+ self._manager_url_override: Optional[str] = None # For URL mode
351
+
352
+ @property
353
+ def manager_url(self) -> str:
354
+ """Override to support URL mode where urls is None."""
355
+ if self._manager_url_override is not None:
356
+ return self._manager_url_override
357
+ return super().manager_url
72
358
 
73
359
  @property
74
360
  def instance(self) -> InstanceClient:
@@ -80,17 +366,17 @@ class SyncEnv(EnvironmentBase):
80
366
 
81
367
  def app(self, name: str) -> InstanceClient:
82
368
  if name not in self._apps:
83
- # Extract base URL by removing the current app path (e.g., /sentry/api/v1/env)
84
- # manager_url looks like: https://xxx.fleetai.com/sentry/api/v1/env
85
- base_url = self.manager_url.split("/api/v1/env")[0]
86
- # Remove the current app name (e.g., /sentry) to get the root
87
- if "/" in base_url:
88
- parts = base_url.rsplit("/", 1)
89
- if len(parts) == 2 and parts[0] != "https:/":
90
- base_url = parts[0]
369
+ # Extract scheme://netloc from manager_url, then construct /{name}/api/v1/env
370
+ # Supports all URL formats:
371
+ # https://host/api/v1/env -> https://host/{name}/api/v1/env
372
+ # https://host/sentry/api/v1/env -> https://host/{name}/api/v1/env
373
+ # http://localhost:8080/api/v1/env -> http://localhost:8080/{name}/api/v1/env
374
+ parsed = urlparse(self.manager_url)
375
+ root = f"{parsed.scheme}://{parsed.netloc}"
376
+ new_url = f"{root}/{name}/api/v1/env"
91
377
 
92
378
  self._apps[name] = InstanceClient(
93
- f"{base_url}/{name}/api/v1/env",
379
+ new_url,
94
380
  self._client.httpx_client if self._client else None,
95
381
  )
96
382
  return self._apps[name]
@@ -112,6 +398,29 @@ class SyncEnv(EnvironmentBase):
112
398
  def browser(self, name: str = "cdp") -> BrowserResource:
113
399
  return self.instance.browser(name)
114
400
 
401
+ def api(self, name: str = "api") -> APIResource:
402
+ """Get an API resource for making HTTP requests to the app's API.
403
+
404
+ Args:
405
+ name: Name for the API resource (default: "api")
406
+
407
+ Returns:
408
+ APIResource for making HTTP requests
409
+ """
410
+ # Use urls.api if available, otherwise fall back to urls.root + "/raw"
411
+ if self.urls and self.urls.api:
412
+ base_url = self.urls.api
413
+ elif self.urls and self.urls.root:
414
+ base_url = f"{self.urls.root.rstrip('/')}/raw"
415
+ elif self._manager_url_override and self._manager_url_override != "local://":
416
+ # URL mode: strip /api/v1/env suffix to get root URL
417
+ base_url = self._manager_url_override.rstrip('/')
418
+ if base_url.endswith('/api/v1/env'):
419
+ base_url = base_url[:-len('/api/v1/env')]
420
+ else:
421
+ raise ValueError("No API URL configured for this environment")
422
+ return self.instance.api(name, base_url)
423
+
115
424
  @property
116
425
  def mcp(self) -> SyncMCPResource:
117
426
  mcp_url = f"{self.urls.root}mcp"
@@ -126,6 +435,23 @@ class SyncEnv(EnvironmentBase):
126
435
  def close(self) -> InstanceResponse:
127
436
  return _delete_instance(self._load_client, self.instance_id)
128
437
 
438
+ def heartbeat(self) -> HeartbeatResponse:
439
+ """Send heartbeat to keep instance alive (if heartbeat monitoring is enabled).
440
+
441
+ Returns:
442
+ HeartbeatResponse containing heartbeat status and deadline information
443
+ """
444
+ body = {}
445
+ if self.heartbeat_region:
446
+ body["region"] = self.heartbeat_region
447
+
448
+ response = self._load_client.request(
449
+ "POST",
450
+ f"/v1/env/instances/{self.instance_id}/heartbeat",
451
+ json=body
452
+ )
453
+ return HeartbeatResponse(**response.json())
454
+
129
455
  def verify(self, validator: ValidatorType) -> ExecuteFunctionResponse:
130
456
  return self.instance.verify(validator)
131
457
 
@@ -148,6 +474,7 @@ class SyncEnv(EnvironmentBase):
148
474
  kwargs: dict,
149
475
  timeout: Optional[int] = 30,
150
476
  needs_upload: bool = True,
477
+ verifier_runtime_version: Optional[str] = None,
151
478
  ) -> VerifiersExecuteResponse:
152
479
  return _execute_verifier_remote(
153
480
  self._load_client,
@@ -160,6 +487,7 @@ class SyncEnv(EnvironmentBase):
160
487
  kwargs,
161
488
  timeout,
162
489
  needs_upload,
490
+ verifier_runtime_version,
163
491
  )
164
492
 
165
493
  def __getstate__(self):
@@ -212,6 +540,8 @@ class Fleet:
212
540
  env_variables: Optional[Dict[str, Any]] = None,
213
541
  image_type: Optional[str] = None,
214
542
  ttl_seconds: Optional[int] = None,
543
+ run_id: Optional[str] = None,
544
+ heartbeat_interval: Optional[int] = None,
215
545
  ) -> SyncEnv:
216
546
  if ":" in env_key:
217
547
  env_key_part, env_version = env_key.split(":", 1)
@@ -247,6 +577,8 @@ class Fleet:
247
577
  image_type=image_type,
248
578
  created_from="sdk",
249
579
  ttl_seconds=ttl_seconds,
580
+ run_id=run_id,
581
+ heartbeat_interval=heartbeat_interval,
250
582
  )
251
583
 
252
584
  # Only use region-specific base URL if no custom base URL is set
@@ -269,13 +601,17 @@ class Fleet:
269
601
  return self.make(env_key=f"{task.env_id}:{task.version}")
270
602
 
271
603
  def instances(
272
- self, status: Optional[str] = None, region: Optional[str] = None
604
+ self, status: Optional[str] = None, region: Optional[str] = None, run_id: Optional[str] = None, profile_id: Optional[str] = None
273
605
  ) -> List[SyncEnv]:
274
606
  params = {}
275
607
  if status:
276
608
  params["status"] = status
277
609
  if region:
278
610
  params["region"] = region
611
+ if run_id:
612
+ params["run_id"] = run_id
613
+ if profile_id:
614
+ params["profile_id"] = profile_id
279
615
 
280
616
  response = self.client.request("GET", "/v1/env/instances", params=params)
281
617
  return [
@@ -283,11 +619,165 @@ class Fleet:
283
619
  for instance_data in response.json()
284
620
  ]
285
621
 
286
- def instance(self, instance_id: str) -> SyncEnv:
287
- response = self.client.request("GET", f"/v1/env/instances/{instance_id}")
288
- instance = SyncEnv(client=self.client, **response.json())
289
- instance.instance.load()
290
- return instance
622
+ def instance(self, instance_id: Union[str, Dict[str, str]]) -> SyncEnv:
623
+ """Create or connect to an environment instance.
624
+
625
+ Supports three modes based on input type:
626
+ 1. dict: Local filesystem mode - {"current": "./data.db", "seed": "./seed.db"}
627
+ 2. str starting with http:// or https://: Localhost/URL mode
628
+ 3. str (other): Remote cloud instance mode
629
+
630
+ Args:
631
+ instance_id: Instance identifier (str), URL (str starting with http://),
632
+ or local db mapping (dict)
633
+
634
+ Returns:
635
+ SyncEnv: Environment instance
636
+ """
637
+ # Local filesystem mode - dict of resource names to file paths
638
+ if isinstance(instance_id, dict):
639
+ return self._create_local_instance(instance_id)
640
+
641
+ # Localhost/direct URL mode - string starting with http:// or https://
642
+ elif isinstance(instance_id, str) and instance_id.startswith(("http://", "https://")):
643
+ return self._create_url_instance(instance_id)
644
+
645
+ # Remote mode - existing behavior
646
+ else:
647
+ response = self.client.request("GET", f"/v1/env/instances/{instance_id}")
648
+ instance = SyncEnv(client=self.client, **response.json())
649
+ instance.instance.load()
650
+ return instance
651
+
652
+ def _create_url_instance(self, base_url: str) -> SyncEnv:
653
+ """Create instance connected to a direct URL (localhost or custom).
654
+
655
+ Args:
656
+ base_url: URL of the instance manager API
657
+
658
+ Returns:
659
+ SyncEnv: Environment instance configured for URL mode
660
+ """
661
+ instance_client = InstanceClient(url=base_url, httpx_client=self._httpx_client)
662
+
663
+ # Create a minimal environment for URL mode
664
+ env = SyncEnv(
665
+ client=self.client,
666
+ instance_id=base_url,
667
+ env_key="localhost",
668
+ version="",
669
+ status="running",
670
+ subdomain="localhost",
671
+ created_at="",
672
+ updated_at="",
673
+ terminated_at=None,
674
+ team_id="",
675
+ region="localhost",
676
+ env_variables=None,
677
+ data_key=None,
678
+ data_version=None,
679
+ urls=None,
680
+ health=None,
681
+ )
682
+ env._instance = instance_client
683
+ env._manager_url_override = base_url # Set manager_url for URL mode
684
+ return env
685
+
686
+ @staticmethod
687
+ def _normalize_db_path(path: str) -> tuple[str, bool]:
688
+ """Normalize database path and detect if it's in-memory.
689
+
690
+ Args:
691
+ path: Database path - can be:
692
+ - File path: "./data.db"
693
+ - Plain memory: ":memory:"
694
+ - Named memory: ":memory:namespace"
695
+ - URI: "file:name?mode=memory&cache=shared"
696
+
697
+ Returns:
698
+ Tuple of (normalized_path, is_memory)
699
+ """
700
+ import uuid
701
+ import sqlite3
702
+
703
+ if path == ":memory:":
704
+ # Plain :memory: - create unique namespace
705
+ name = f"mem_{uuid.uuid4().hex[:8]}"
706
+ return f"file:{name}?mode=memory&cache=shared", True
707
+ elif path.startswith(":memory:"):
708
+ # Named memory: :memory:current -> file:current?mode=memory&cache=shared
709
+ namespace = path[8:] # Remove ":memory:" prefix
710
+ return f"file:{namespace}?mode=memory&cache=shared", True
711
+ elif "mode=memory" in path:
712
+ # Already a proper memory URI
713
+ return path, True
714
+ else:
715
+ # Regular file path
716
+ return path, False
717
+
718
+ def _create_local_instance(self, dbs: Dict[str, str]) -> SyncEnv:
719
+ """Create instance with local file-based or in-memory SQLite resources.
720
+
721
+ Args:
722
+ dbs: Map of resource names to paths (e.g., {"current": "./data.db"} or
723
+ {"current": ":memory:current"})
724
+
725
+ Returns:
726
+ SyncEnv: Environment instance configured for local mode
727
+ """
728
+ import sqlite3
729
+
730
+ instance_client = InstanceClient(url="local://", httpx_client=None)
731
+ instance_client._resources = [] # Mark as loaded
732
+ instance_client._memory_anchors = {} # Store anchor connections for in-memory DBs
733
+
734
+ # Store creation parameters for local SQLiteResources
735
+ # This allows db() to create new instances each time (matching HTTP mode behavior)
736
+ for name, path in dbs.items():
737
+ # Normalize path and detect if it's in-memory
738
+ normalized_path, is_memory = self._normalize_db_path(path)
739
+
740
+ # Create anchor connection for in-memory databases
741
+ # This keeps the database alive as long as the env exists
742
+ if is_memory:
743
+ anchor_conn = sqlite3.connect(normalized_path, uri=True)
744
+ instance_client._memory_anchors[name] = anchor_conn
745
+
746
+ resource_model = ResourceModel(
747
+ name=name,
748
+ type=ResourceType.db,
749
+ mode=ResourceMode.rw,
750
+ label=f"Local: {path}",
751
+ )
752
+ instance_client._resources_state[ResourceType.db.value][name] = {
753
+ 'type': 'local',
754
+ 'resource_model': resource_model,
755
+ 'db_path': normalized_path,
756
+ 'is_memory': is_memory
757
+ }
758
+
759
+ # Create a minimal environment for local mode
760
+ env = SyncEnv(
761
+ client=self.client,
762
+ instance_id="local",
763
+ env_key="local",
764
+ version="",
765
+ status="running",
766
+ subdomain="local",
767
+ created_at="",
768
+ updated_at="",
769
+ terminated_at=None,
770
+ team_id="",
771
+ region="local",
772
+ env_variables=None,
773
+ data_key=None,
774
+ data_version=None,
775
+ urls=None,
776
+ health=None,
777
+ )
778
+ env._instance = instance_client
779
+ env._manager_url_override = "local://" # Set manager_url for local mode
780
+ return env
291
781
 
292
782
  def check_bundle_exists(self, bundle_hash: str) -> VerifiersCheckResponse:
293
783
  return _check_bundle_exists(self.client, bundle_hash)
@@ -300,6 +790,65 @@ class Fleet:
300
790
  def delete(self, instance_id: str) -> InstanceResponse:
301
791
  return _delete_instance(self.client, instance_id)
302
792
 
793
+ def close(self, instance_id: str) -> InstanceResponse:
794
+ """Close (delete) a specific instance by ID.
795
+
796
+ Args:
797
+ instance_id: The instance ID to close
798
+
799
+ Returns:
800
+ InstanceResponse containing the deleted instance details
801
+ """
802
+ return _delete_instance(self.client, instance_id)
803
+
804
+ def heartbeat(self, instance_id: str, region: Optional[str] = None) -> HeartbeatResponse:
805
+ """Send heartbeat to keep instance alive (if heartbeat monitoring is enabled).
806
+
807
+ Args:
808
+ instance_id: The instance ID to send heartbeat for
809
+ region: Optional region override for cross-region heartbeats
810
+
811
+ Returns:
812
+ HeartbeatResponse containing heartbeat status and deadline information
813
+ """
814
+ return _send_heartbeat(self.client, instance_id, region)
815
+
816
+ def close_all(self, run_id: Optional[str] = None, profile_id: Optional[str] = None) -> List[InstanceResponse]:
817
+ """Close (delete) instances using the batch delete endpoint.
818
+
819
+ Args:
820
+ run_id: Optional run ID to filter instances by
821
+ profile_id: Optional profile ID to filter instances by (use "self" for your own profile)
822
+
823
+ Returns:
824
+ List[InstanceResponse] containing the deleted instances
825
+
826
+ Note:
827
+ At least one of run_id or profile_id must be provided.
828
+ """
829
+ return _delete_instances_batch(self.client, run_id=run_id, profile_id=profile_id)
830
+
831
+ def list_runs(
832
+ self, profile_id: Optional[str] = None, status: Optional[str] = "active"
833
+ ) -> List[Run]:
834
+ """List all runs (groups of instances by run_id) with aggregated statistics.
835
+
836
+ Args:
837
+ profile_id: Optional profile ID to filter runs by (use "self" for your own profile)
838
+ status: Filter by run status - "active" (default), "inactive", or "all"
839
+
840
+ Returns:
841
+ List[Run] containing run information with instance counts and timestamps
842
+ """
843
+ params = {}
844
+ if profile_id:
845
+ params["profile_id"] = profile_id
846
+ if status:
847
+ params["active"] = status
848
+
849
+ response = self.client.request("GET", "/v1/env/runs", params=params)
850
+ return [Run(**run_data) for run_data in response.json()]
851
+
303
852
  def load_tasks_from_file(self, filename: str) -> List[Task]:
304
853
  with open(filename, "r", encoding="utf-8") as f:
305
854
  tasks_data = f.read()
@@ -366,6 +915,11 @@ class Fleet:
366
915
  if not verifier_id:
367
916
  verifier_id = task_json.get("key", task_json.get("id"))
368
917
 
918
+ # Extract verifier_runtime_version from metadata if present
919
+ verifier_runtime_version = None
920
+ if "metadata" in task_json and isinstance(task_json["metadata"], dict):
921
+ verifier_runtime_version = task_json["metadata"].get("verifier_runtime_version")
922
+
369
923
  try:
370
924
  if verifier_id and verifier_code:
371
925
  verifier = self._create_verifier_from_data(
@@ -373,13 +927,14 @@ class Fleet:
373
927
  verifier_key=task_json.get("key", task_json.get("id")),
374
928
  verifier_code=verifier_code,
375
929
  verifier_sha=verifier_sha,
930
+ verifier_runtime_version=verifier_runtime_version,
376
931
  )
377
932
  except Exception as e:
378
933
  error_msg = f"Failed to create verifier {task_json.get('key', task_json.get('id'))}: {e}"
379
934
  if raise_on_verifier_error:
380
935
  raise ValueError(error_msg) from e
381
- else:
382
- logger.warning(error_msg)
936
+ # else:
937
+ # logger.warning(error_msg)
383
938
 
384
939
  task = Task(
385
940
  key=task_json.get("key", task_json.get("id")),
@@ -396,7 +951,10 @@ class Fleet:
396
951
  verifier=verifier, # Use created verifier or None
397
952
  verifier_id=verifier_id, # Set verifier_id so _rebuild_verifier works
398
953
  verifier_sha=verifier_sha, # Set verifier_sha
954
+ verifier_runtime_version=verifier_runtime_version, # Set verifier_runtime_version
399
955
  metadata=task_json.get("metadata", {}), # Default empty metadata
956
+ writer_metadata=task_json.get("writer_metadata"), # Writer metadata
957
+ qa_metadata=task_json.get("qa_metadata"), # QA metadata
400
958
  output_json_schema=task_json.get("output_json_schema"), # JSON schema for output
401
959
  )
402
960
  return task
@@ -469,23 +1027,23 @@ class Fleet:
469
1027
  verifier_sha=tr.verifier.sha256,
470
1028
  )
471
1029
  except Exception as e:
472
- logger.warning(
473
- f"Failed to create verifier {tr.verifier.key}: {e}"
474
- )
1030
+ # logger.warning(
1031
+ # f"Failed to create verifier {tr.verifier.key}: {e}"
1032
+ # )
475
1033
  return None
476
1034
  else:
477
1035
  # Fallback: try fetching by ID
478
1036
  try:
479
- logger.warning(
480
- f"Embedded verifier code missing for {tr.verifier.key} (NoSuchKey). "
481
- f"Attempting to refetch by id {tr.verifier.verifier_id}"
482
- )
1037
+ # logger.warning(
1038
+ # f"Embedded verifier code missing for {tr.verifier.key} (NoSuchKey). "
1039
+ # f"Attempting to refetch by id {tr.verifier.verifier_id}"
1040
+ # )
483
1041
  return self._load_verifier(tr.verifier.verifier_id)
484
1042
  except Exception as e:
485
- logger.warning(
486
- f"Refetch by verifier id failed for {tr.verifier.key}: {e}. "
487
- "Leaving verifier unset."
488
- )
1043
+ # logger.warning(
1044
+ # f"Refetch by verifier id failed for {tr.verifier.key}: {e}. "
1045
+ # "Leaving verifier unset."
1046
+ # )
489
1047
  return None
490
1048
 
491
1049
  # Add the task for parallel execution
@@ -525,7 +1083,7 @@ class Fleet:
525
1083
  result = future.result()
526
1084
  verifier_results.append(result)
527
1085
  except Exception as e:
528
- logger.warning(f"Verifier loading failed: {e}")
1086
+ # logger.warning(f"Verifier loading failed: {e}")
529
1087
  verifier_results.append(None)
530
1088
 
531
1089
  # Build tasks with results
@@ -548,6 +1106,21 @@ class Fleet:
548
1106
  if not is_embedded_error:
549
1107
  verifier_func = embedded_code
550
1108
 
1109
+ # Extract verifier metadata
1110
+ verifier_id = task_response.verifier_id
1111
+ if not verifier_id and task_response.verifier:
1112
+ verifier_id = task_response.verifier.verifier_id
1113
+
1114
+ verifier_sha = None
1115
+ if task_response.verifier:
1116
+ verifier_sha = task_response.verifier.sha256
1117
+
1118
+ # Extract verifier_runtime_version from metadata if present
1119
+ verifier_runtime_version = None
1120
+ metadata = task_response.metadata or {}
1121
+ if isinstance(metadata, dict):
1122
+ verifier_runtime_version = metadata.get("verifier_runtime_version")
1123
+
551
1124
  task = Task(
552
1125
  key=task_response.key,
553
1126
  prompt=task_response.prompt,
@@ -559,7 +1132,12 @@ class Fleet:
559
1132
  env_variables=task_response.env_variables or {},
560
1133
  verifier_func=verifier_func, # Set verifier code
561
1134
  verifier=verifier, # Use created verifier or None
562
- metadata={}, # Default empty metadata
1135
+ verifier_id=verifier_id, # Set verifier_id
1136
+ verifier_sha=verifier_sha, # Set verifier_sha
1137
+ verifier_runtime_version=verifier_runtime_version, # Set verifier_runtime_version
1138
+ metadata=metadata,
1139
+ writer_metadata=getattr(task_response, "writer_metadata", None), # Writer metadata
1140
+ qa_metadata=getattr(task_response, "qa_metadata", None), # QA metadata
563
1141
  output_json_schema=getattr(task_response, "output_json_schema", None), # Get output_json_schema if available
564
1142
  )
565
1143
  tasks.append(task)
@@ -612,10 +1190,10 @@ class Fleet:
612
1190
  with open(filename, "w", encoding="utf-8") as f:
613
1191
  json.dump(tasks_data, f, indent=2, default=str)
614
1192
 
615
- logger.info(f"Exported {len(tasks)} tasks to {filename}")
1193
+ # logger.info(f"Exported {len(tasks)} tasks to {filename}")
616
1194
  return filename
617
1195
  else:
618
- logger.info("No tasks found to export")
1196
+ # logger.info("No tasks found to export")
619
1197
  return None
620
1198
 
621
1199
  def import_single_task(self, task: Task, project_key: Optional[str] = None):
@@ -644,7 +1222,7 @@ class Fleet:
644
1222
  )
645
1223
  return response
646
1224
  except Exception as e:
647
- logger.error(f"Failed to import task {task.key}: {e}")
1225
+ # logger.error(f"Failed to import task {task.key}: {e}")
648
1226
  return None
649
1227
 
650
1228
  def import_tasks(self, filename: str, project_key: Optional[str] = None):
@@ -706,6 +1284,9 @@ class Fleet:
706
1284
  task_key: str,
707
1285
  prompt: Optional[str] = None,
708
1286
  verifier_code: Optional[str] = None,
1287
+ metadata: Optional[Dict[str, Any]] = None,
1288
+ writer_metadata: Optional[Dict[str, Any]] = None,
1289
+ qa_metadata: Optional[Dict[str, Any]] = None,
709
1290
  ) -> TaskResponse:
710
1291
  """Update an existing task.
711
1292
 
@@ -713,11 +1294,20 @@ class Fleet:
713
1294
  task_key: The key of the task to update
714
1295
  prompt: New prompt text for the task (optional)
715
1296
  verifier_code: Python code for task verification (optional)
1297
+ metadata: Additional metadata for the task (optional)
1298
+ writer_metadata: Metadata filled by task writer (optional)
1299
+ qa_metadata: Metadata filled by QA reviewer (optional)
716
1300
 
717
1301
  Returns:
718
1302
  TaskResponse containing the updated task details
719
1303
  """
720
- payload = TaskUpdateRequest(prompt=prompt, verifier_code=verifier_code)
1304
+ payload = TaskUpdateRequest(
1305
+ prompt=prompt,
1306
+ verifier_code=verifier_code,
1307
+ metadata=metadata,
1308
+ writer_metadata=writer_metadata,
1309
+ qa_metadata=qa_metadata,
1310
+ )
721
1311
  response = self.client.request(
722
1312
  "PUT", f"/v1/tasks/{task_key}", json=payload.model_dump(exclude_none=True)
723
1313
  )
@@ -750,8 +1340,393 @@ class Fleet:
750
1340
  )
751
1341
  return TaskResponse(**response.json())
752
1342
 
1343
+ # Jobs API methods
1344
+
1345
+ def list_jobs(self, team_id: Optional[str] = None) -> List[JobResponse]:
1346
+ """List all jobs for the authenticated team.
1347
+
1348
+ Args:
1349
+ team_id: Optional team_id to filter by (admin only)
1350
+
1351
+ Returns:
1352
+ List[JobResponse] containing job information
1353
+ """
1354
+ params = {}
1355
+ if team_id is not None:
1356
+ params["team_id"] = team_id
1357
+
1358
+ response = self.client.request("GET", "/v1/jobs", params=params)
1359
+ job_list = JobListResponse(**response.json())
1360
+ return job_list.jobs
1361
+
1362
+ def create_job(
1363
+ self,
1364
+ models: List[str],
1365
+ name: Optional[str] = None,
1366
+ pass_k: int = 1,
1367
+ env_key: Optional[str] = None,
1368
+ project_key: Optional[str] = None,
1369
+ task_keys: Optional[List[str]] = None,
1370
+ excluded_task_keys: Optional[List[str]] = None,
1371
+ max_steps: Optional[int] = None,
1372
+ max_duration_minutes: int = 60,
1373
+ max_concurrent_per_model: int = 30,
1374
+ mode: Optional[str] = None,
1375
+ system_prompt: Optional[str] = None,
1376
+ model_prompts: Optional[Dict[str, str]] = None,
1377
+ byok_keys: Optional[Dict[str, str]] = None,
1378
+ byok_ttl_minutes: Optional[int] = None,
1379
+ harness: Optional[str] = None,
1380
+ ) -> JobCreateResponse:
1381
+ """Create a new job.
1382
+
1383
+ Args:
1384
+ models: List of model identifiers in "provider/model" format
1385
+ name: Optional job name. Supports placeholders: {id} (UUID), {sid} (short UUID), {i} (auto-increment, must be suffix)
1386
+ pass_k: Number of passes (default: 1)
1387
+ env_key: Environment key (mutually exclusive with project_key/task_keys)
1388
+ project_key: Project key (mutually exclusive with env_key/task_keys)
1389
+ task_keys: Specific task keys (mutually exclusive with env_key/project_key)
1390
+ excluded_task_keys: Task keys to exclude
1391
+ max_steps: Maximum agent steps
1392
+ max_duration_minutes: Timeout in minutes (default: 60)
1393
+ max_concurrent_per_model: Max concurrent per model (default: 30)
1394
+ mode: "tool-use" or "computer-use"
1395
+ system_prompt: Custom system prompt
1396
+ model_prompts: Per-model prompts (model -> prompt)
1397
+ byok_keys: Bring Your Own Keys (provider -> API key)
1398
+ byok_ttl_minutes: TTL for BYOK keys in minutes
1399
+ harness: Harness identifier
1400
+
1401
+ Returns:
1402
+ JobCreateResponse containing job_id, workflow_job_id, status, and name
1403
+ """
1404
+ request = JobCreateRequest(
1405
+ name=name,
1406
+ models=models,
1407
+ pass_k=pass_k,
1408
+ env_key=env_key,
1409
+ project_key=project_key,
1410
+ task_keys=task_keys,
1411
+ excluded_task_keys=excluded_task_keys,
1412
+ max_steps=max_steps,
1413
+ max_duration_minutes=max_duration_minutes,
1414
+ max_concurrent_per_model=max_concurrent_per_model,
1415
+ mode=mode,
1416
+ system_prompt=system_prompt,
1417
+ model_prompts=model_prompts,
1418
+ byok_keys=byok_keys,
1419
+ byok_ttl_minutes=byok_ttl_minutes,
1420
+ harness=harness,
1421
+ )
1422
+
1423
+ response = self.client.request(
1424
+ "POST", "/v1/jobs", json=request.model_dump(exclude_none=True)
1425
+ )
1426
+ return JobCreateResponse(**response.json())
1427
+
1428
+ def get_job(self, job_id: str, team_id: Optional[str] = None) -> JobResponse:
1429
+ """Get a specific job by ID.
1430
+
1431
+ Args:
1432
+ job_id: The job ID
1433
+ team_id: Optional team_id to filter by (admin only)
1434
+
1435
+ Returns:
1436
+ JobResponse containing job information
1437
+ """
1438
+ params = {}
1439
+ if team_id is not None:
1440
+ params["team_id"] = team_id
1441
+
1442
+ response = self.client.request("GET", f"/v1/jobs/{job_id}", params=params)
1443
+ return JobResponse(**response.json())
1444
+
1445
+ # Sessions API methods
1446
+
1447
+ def list_job_sessions(self, job_id: str) -> JobSessionsResponse:
1448
+ """List all sessions for a job, grouped by task.
1449
+
1450
+ Args:
1451
+ job_id: The job ID
1452
+
1453
+ Returns:
1454
+ JobSessionsResponse containing sessions grouped by task with statistics
1455
+ """
1456
+ response = self.client.request("GET", f"/v1/sessions/job/{job_id}")
1457
+ return JobSessionsResponse(**response.json())
1458
+
1459
+ def get_session_transcript(self, session_id: str) -> SessionTranscriptResponse:
1460
+ """Get the transcript for a specific session.
1461
+
1462
+ Args:
1463
+ session_id: The session ID
1464
+
1465
+ Returns:
1466
+ SessionTranscriptResponse containing task, instance, verifier result, and messages
1467
+ """
1468
+ response = self.client.request(
1469
+ "GET", f"/v1/sessions/{session_id}/transcript"
1470
+ )
1471
+ return SessionTranscriptResponse(**response.json())
1472
+
1473
+ def _ingest(
1474
+ self,
1475
+ messages: List[Dict[str, Any]],
1476
+ session_id: Optional[str] = None,
1477
+ model: Optional[str] = None,
1478
+ task_key: Optional[str] = None,
1479
+ job_id: Optional[str] = None,
1480
+ instance_id: Optional[str] = None,
1481
+ status: Optional[str] = None,
1482
+ metadata: Optional[Dict[str, Any]] = None,
1483
+ started_at: Optional[str] = None,
1484
+ ended_at: Optional[str] = None,
1485
+ verifier_execution_id: Optional[str] = None,
1486
+ ) -> SessionIngestResponse:
1487
+ """Internal method to ingest session data."""
1488
+ message_objects = [SessionIngestMessage(**msg) for msg in messages]
1489
+ request = SessionIngestRequest(
1490
+ messages=message_objects,
1491
+ session_id=session_id,
1492
+ model=model,
1493
+ task_key=task_key,
1494
+ job_id=job_id,
1495
+ instance_id=instance_id,
1496
+ status=SessionStatus(status) if status else None,
1497
+ metadata=metadata,
1498
+ started_at=started_at,
1499
+ ended_at=ended_at,
1500
+ verifier_execution_id=verifier_execution_id,
1501
+ )
1502
+ response = self.client.request(
1503
+ "POST",
1504
+ "/v1/sessions/ingest",
1505
+ json=request.model_dump(exclude_none=True),
1506
+ )
1507
+ return SessionIngestResponse(**response.json())
1508
+
1509
+ def _ingest_raw(
1510
+ self,
1511
+ payload: Dict[str, Any],
1512
+ ) -> SessionIngestResponse:
1513
+ """Internal method to ingest raw session data as JSON.
1514
+
1515
+ This sends the history and response as-is to the backend,
1516
+ letting the backend handle format normalization.
1517
+ """
1518
+ # Pre-serialize with our custom handler to ensure all types are JSON-safe
1519
+ json_str = json.dumps(payload, default=_json_default)
1520
+ clean_payload = json.loads(json_str)
1521
+
1522
+ response = self.client.request(
1523
+ "POST",
1524
+ "/v1/traces/logs",
1525
+ json=clean_payload,
1526
+ )
1527
+ return SessionIngestResponse(**response.json())
1528
+
1529
+ def start_session(
1530
+ self,
1531
+ session_id: Optional[str] = None,
1532
+ job_id: Optional[str] = None,
1533
+ config: Optional[Any] = None,
1534
+ model: Optional[str] = None,
1535
+ task_key: Optional[str] = None,
1536
+ instance_id: Optional[str] = None,
1537
+ ) -> Session:
1538
+ """Start a new session for logging agent interactions.
1539
+
1540
+ This returns a Session object. The session is created on the backend
1541
+ when you call log() for the first time.
1542
+
1543
+ Args:
1544
+ session_id: Optional existing session ID to resume
1545
+ job_id: Optional job ID to associate with the session
1546
+ config: Optional config object (e.g., GenerateContentConfig) to log
1547
+ model: Optional model name to log
1548
+ task_key: Optional Fleet task key
1549
+ instance_id: Optional Fleet instance ID
1550
+
1551
+ Returns:
1552
+ Session object with log(), complete(), and fail() methods
1553
+
1554
+ Example:
1555
+ session = fleet_client.start_session(config=config, model="gpt-4", task_key="task_123")
1556
+
1557
+ # Log LLM calls during agent run
1558
+ session.log(history, response)
1559
+
1560
+ # Complete when done
1561
+ session.complete()
1562
+ """
1563
+ return Session(
1564
+ client=self,
1565
+ session_id=session_id,
1566
+ job_id=job_id,
1567
+ config=config,
1568
+ model=model,
1569
+ task_key=task_key,
1570
+ instance_id=instance_id,
1571
+ )
1572
+
1573
+ def trace_job(self, name: Optional[str] = None) -> str:
1574
+ """Create a new trace job.
1575
+
1576
+ Args:
1577
+ name: Name of the job (generated server-side if not provided)
1578
+
1579
+ Returns:
1580
+ The job_id string
1581
+ """
1582
+ from .models import TraceJobRequest, TraceJobResponse
1583
+
1584
+ request = TraceJobRequest(name=name)
1585
+ response = self.client.request(
1586
+ "POST",
1587
+ "/v1/traces/jobs",
1588
+ json=request.model_dump(),
1589
+ )
1590
+ result = TraceJobResponse(**response.json())
1591
+ return result.job_id
1592
+
1593
+ def create_session(
1594
+ self,
1595
+ model: Optional[str] = None,
1596
+ task_key: Optional[str] = None,
1597
+ job_id: Optional[str] = None,
1598
+ instance_id: Optional[str] = None,
1599
+ metadata: Optional[Dict[str, Any]] = None,
1600
+ started_at: Optional[str] = None,
1601
+ initial_message: Optional[Dict[str, Any]] = None,
1602
+ ) -> SessionIngestResponse:
1603
+ """Create a new session, optionally with an initial message.
1604
+
1605
+ This is useful for streaming scenarios where you want to create
1606
+ a session first and then append messages one by one.
1607
+
1608
+ Args:
1609
+ model: Model identifier (e.g., "anthropic/claude-sonnet-4")
1610
+ task_key: Task key to associate with the session
1611
+ job_id: Job ID to associate with the session
1612
+ instance_id: Instance ID to associate with the session
1613
+ metadata: Additional metadata for the session
1614
+ started_at: ISO timestamp when session started
1615
+ initial_message: Optional first message dict with 'role' and 'content'
1616
+
1617
+ Returns:
1618
+ SessionIngestResponse containing session_id
1619
+
1620
+ Example:
1621
+ # Create session and get ID
1622
+ session = fleet.create_session(
1623
+ model="anthropic/claude-sonnet-4",
1624
+ task_key="my_task",
1625
+ started_at=datetime.now().isoformat()
1626
+ )
1627
+
1628
+ # Append messages as they happen
1629
+ fleet.append_message(session.session_id, {"role": "user", "content": "Hello"})
1630
+ fleet.append_message(session.session_id, {"role": "assistant", "content": "Hi!"})
1631
+ """
1632
+ # Use a placeholder message if none provided
1633
+ if initial_message:
1634
+ messages = [initial_message]
1635
+ else:
1636
+ messages = [{"role": "system", "content": "[session created]"}]
1637
+
1638
+ return self._ingest(
1639
+ messages=messages,
1640
+ model=model,
1641
+ task_key=task_key,
1642
+ job_id=job_id,
1643
+ instance_id=instance_id,
1644
+ status="running",
1645
+ metadata=metadata,
1646
+ started_at=started_at,
1647
+ )
1648
+
1649
+ def append_message(
1650
+ self,
1651
+ session_id: str,
1652
+ message: Dict[str, Any],
1653
+ status: Optional[str] = None,
1654
+ ended_at: Optional[str] = None,
1655
+ ) -> SessionIngestResponse:
1656
+ """Append a single message to an existing session.
1657
+
1658
+ This is useful for streaming scenarios where you want to send
1659
+ messages one by one as they happen.
1660
+
1661
+ Args:
1662
+ session_id: The session ID to append to
1663
+ message: Message dict with 'role' and 'content' keys.
1664
+ Optional keys: 'tool_calls', 'tool_call_id', 'timestamp', 'tokens', 'metadata'
1665
+ status: Optional status update ("running", "completed", "failed")
1666
+ ended_at: ISO timestamp when session ended (set when completing)
1667
+
1668
+ Returns:
1669
+ SessionIngestResponse with updated message count
1670
+
1671
+ Example:
1672
+ # Append user message
1673
+ fleet.append_message(session_id, {"role": "user", "content": "What's 2+2?"})
1674
+
1675
+ # Append assistant response
1676
+ fleet.append_message(session_id, {"role": "assistant", "content": "4"})
1677
+
1678
+ # Complete the session
1679
+ fleet.append_message(
1680
+ session_id,
1681
+ {"role": "assistant", "content": "Done!"},
1682
+ status="completed",
1683
+ ended_at=datetime.now().isoformat()
1684
+ )
1685
+ """
1686
+ return self._ingest(
1687
+ messages=[message],
1688
+ session_id=session_id,
1689
+ status=status,
1690
+ ended_at=ended_at,
1691
+ )
1692
+
1693
+ def complete_session(
1694
+ self,
1695
+ session_id: str,
1696
+ status: str = "completed",
1697
+ ended_at: Optional[str] = None,
1698
+ final_message: Optional[Dict[str, Any]] = None,
1699
+ ) -> SessionIngestResponse:
1700
+ """Mark a session as complete.
1701
+
1702
+ Args:
1703
+ session_id: The session ID to complete
1704
+ status: Final status ("completed", "failed", "cancelled")
1705
+ ended_at: ISO timestamp when session ended (defaults to now)
1706
+ final_message: Optional final message to append
1707
+
1708
+ Returns:
1709
+ SessionIngestResponse with final state
1710
+ """
1711
+ from datetime import datetime as dt
1712
+
1713
+ if ended_at is None:
1714
+ ended_at = dt.now().isoformat()
1715
+
1716
+ if final_message:
1717
+ messages = [final_message]
1718
+ else:
1719
+ messages = [{"role": "system", "content": f"[session {status}]"}]
1720
+
1721
+ return self._ingest(
1722
+ messages=messages,
1723
+ session_id=session_id,
1724
+ status=status,
1725
+ ended_at=ended_at,
1726
+ )
1727
+
753
1728
  def _create_verifier_from_data(
754
- self, verifier_id: str, verifier_key: str, verifier_code: str, verifier_sha: str
1729
+ self, verifier_id: str, verifier_key: str, verifier_code: str, verifier_sha: str, verifier_runtime_version: Optional[str] = None
755
1730
  ) -> "SyncVerifierFunction":
756
1731
  """Create an AsyncVerifierFunction from verifier data.
757
1732
 
@@ -773,6 +1748,7 @@ class Fleet:
773
1748
  verifier_id=verifier_id,
774
1749
  verifier_key=verifier_key,
775
1750
  sha256=verifier_sha,
1751
+ verifier_runtime_version=verifier_runtime_version or "",
776
1752
  )
777
1753
 
778
1754
  # Store the original verifier code for reference
@@ -808,6 +1784,37 @@ def _delete_instance(client: SyncWrapper, instance_id: str) -> InstanceResponse:
808
1784
  return InstanceResponse(**response.json())
809
1785
 
810
1786
 
1787
+ def _send_heartbeat(client: SyncWrapper, instance_id: str, region: Optional[str] = None) -> HeartbeatResponse:
1788
+ """Send heartbeat to keep instance alive."""
1789
+ body = {}
1790
+ if region:
1791
+ body["region"] = region
1792
+
1793
+ response = client.request(
1794
+ "POST",
1795
+ f"/v1/env/instances/{instance_id}/heartbeat",
1796
+ json=body
1797
+ )
1798
+ return HeartbeatResponse(**response.json())
1799
+
1800
+
1801
+ def _delete_instances_batch(
1802
+ client: SyncWrapper, run_id: Optional[str] = None, profile_id: Optional[str] = None
1803
+ ) -> List[InstanceResponse]:
1804
+ """Delete instances using the batch endpoint with flexible filtering."""
1805
+ params = {}
1806
+ if run_id:
1807
+ params["run_id"] = run_id
1808
+ if profile_id:
1809
+ params["profile_id"] = profile_id
1810
+
1811
+ if not params:
1812
+ raise ValueError("At least one of run_id or profile_id must be provided")
1813
+
1814
+ response = client.request("DELETE", "/v1/env/instances/batch", params=params)
1815
+ return [InstanceResponse(**instance_data) for instance_data in response.json()]
1816
+
1817
+
811
1818
  def _check_bundle_exists(
812
1819
  client: SyncWrapper, bundle_hash: str
813
1820
  ) -> VerifiersCheckResponse:
@@ -826,6 +1833,7 @@ def _execute_verifier_remote(
826
1833
  kwargs: dict,
827
1834
  timeout: Optional[int] = 30,
828
1835
  needs_upload: bool = True,
1836
+ verifier_runtime_version: Optional[str] = None,
829
1837
  ) -> VerifiersExecuteResponse:
830
1838
  # Pickle args and kwargs together
831
1839
  # The first arg should be None as a placeholder for env
@@ -849,18 +1857,22 @@ def _execute_verifier_remote(
849
1857
  bundle_b64 = base64.b64encode(bundle_data).decode("utf-8")
850
1858
  request_data["bundle"] = bundle_b64
851
1859
 
1860
+ # Add verifier_runtime_version if present
1861
+ if verifier_runtime_version:
1862
+ request_data["verifier_runtime_version"] = verifier_runtime_version
1863
+
852
1864
  # Debug logging
853
- logger.debug(
854
- f"Sending verifier execute request: key={key}, sha256={bundle_sha[:8]}..., function_name={function_name}"
855
- )
856
- logger.debug(f"Request has bundle: {needs_upload}")
857
- logger.debug(f"Using client with base_url: {client.base_url}")
858
- logger.debug(f"Request data keys: {list(request_data.keys())}")
859
- logger.debug(
860
- f"Bundle size: {len(request_data.get('bundle', ''))} chars"
861
- if "bundle" in request_data
862
- else "No bundle"
863
- )
1865
+ # logger.debug(
1866
+ # f"Sending verifier execute request: key={key}, sha256={bundle_sha[:8]}..., function_name={function_name}"
1867
+ # )
1868
+ # logger.debug(f"Request has bundle: {needs_upload}")
1869
+ # logger.debug(f"Using client with base_url: {client.base_url}")
1870
+ # logger.debug(f"Request data keys: {list(request_data.keys())}")
1871
+ # logger.debug(
1872
+ # f"Bundle size: {len(request_data.get('bundle', ''))} chars"
1873
+ # if "bundle" in request_data
1874
+ # else "No bundle"
1875
+ # )
864
1876
 
865
1877
  # Note: This should be called on the instance URL, not the orchestrator
866
1878
  # The instance has manager URLs for verifier execution
@@ -868,6 +1880,6 @@ def _execute_verifier_remote(
868
1880
 
869
1881
  # Debug the response
870
1882
  response_json = response.json()
871
- logger.debug(f"Verifier execute response: {response_json}")
1883
+ # logger.debug(f"Verifier execute response: {response_json}")
872
1884
 
873
1885
  return VerifiersExecuteResponse(**response_json)