evolvingmachines-evolve 0.0.55.dev1355__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.
evolve/agent.py ADDED
@@ -0,0 +1,896 @@
1
+ """Main Evolve class for Python SDK."""
2
+
3
+ import asyncio
4
+ import json
5
+ from dataclasses import asdict, is_dataclass
6
+ from typing import Any, Callable, Dict, List, Literal, Optional, Type, Union
7
+
8
+ from .bridge import BridgeManager, SandboxNotFoundError
9
+ from .config import AgentConfig, AgentPluginConfig, BrowserConfig, BrowserCredentialsConfig, IntegrationsSetup, ManagedSecretRef, SandboxCreateOptions, SandboxProvider, SchemaOptions, StorageConfig, WorkspaceMode
10
+ from .results import AgentResponse, CheckpointInfo, ExecuteResult, OutputResult, RunCost, SessionCost, SessionStatus
11
+ from .storage_client import StorageClient
12
+ from . import integrations as integrations_helpers
13
+ from .schema import is_pydantic_model, is_dataclass, to_json_schema, validate_and_parse
14
+ from .utils import _encode_files_for_transport, _decode_files_from_transport, _filter_none, _parse_checkpoint, _require_checkpoint
15
+
16
+
17
+ class Evolve:
18
+ """Evolve agent orchestrator.
19
+
20
+ Provides a Pythonic interface to the TypeScript Evolve SDK via JSON-RPC bridge.
21
+
22
+ Example:
23
+ >>> from evolve import Evolve
24
+ >>>
25
+ >>> # Minimal usage - uses EVOLVE_API_KEY and E2B_API_KEY env vars
26
+ >>> async with Evolve() as evolve:
27
+ ... result = await evolve.run(prompt='Analyze data.csv')
28
+ ... output = await evolve.get_output_files()
29
+ ... for name, content in output.files.items():
30
+ ... print(f'{name}: {len(content)} bytes')
31
+ >>>
32
+ >>> # Or with explicit config (E2B)
33
+ >>> from evolve import AgentConfig, E2BProvider
34
+ >>> evolve = Evolve(
35
+ ... config=AgentConfig(type='codex', api_key='sk-...'),
36
+ ... sandbox=E2BProvider(api_key='...')
37
+ ... )
38
+ >>>
39
+ >>> # Or with Daytona
40
+ >>> from evolve import DaytonaProvider
41
+ >>> evolve = Evolve(sandbox=DaytonaProvider(api_key='...'))
42
+ >>> # Or with Modal
43
+ >>> from evolve import ModalProvider
44
+ >>> evolve = Evolve(sandbox=ModalProvider())
45
+ """
46
+
47
+ # Static helpers for Integrations pre-auth flows (no instance required)
48
+ integrations = integrations_helpers
49
+
50
+ def __init__(
51
+ self,
52
+ config: Optional[AgentConfig] = None,
53
+ sandbox: Optional[SandboxProvider] = None,
54
+ working_directory: str = '/home/user/workspace',
55
+ workspace_mode: WorkspaceMode = 'knowledge',
56
+ system_prompt: Optional[str] = None,
57
+ context: Optional[Dict[str, Union[str, bytes]]] = None,
58
+ files: Optional[Dict[str, Union[str, bytes]]] = None,
59
+ mcp_servers: Optional[Dict[str, Any]] = None,
60
+ skills: Optional[List[str]] = None,
61
+ secrets: Optional[Dict[str, str]] = None,
62
+ managed_secrets: Optional[List[Union[ManagedSecretRef, Dict[str, Any]]]] = None,
63
+ sandbox_id: Optional[str] = None,
64
+ session_tag_prefix: Optional[str] = None,
65
+ schema: Optional[Union[Type, Dict[str, Any]]] = None,
66
+ schema_options: Optional[SchemaOptions] = None,
67
+ integrations: Optional[IntegrationsSetup] = None,
68
+ storage: Optional[StorageConfig] = None,
69
+ browser: Optional[BrowserConfig] = None,
70
+ browser_credentials: Optional[BrowserCredentialsConfig] = None,
71
+ plugins: Optional[Union[AgentPluginConfig, List[AgentPluginConfig]]] = None,
72
+ sandbox_create_options: Optional[SandboxCreateOptions] = None,
73
+ ):
74
+ """Initialize Evolve.
75
+
76
+ Args:
77
+ config: Agent configuration (optional - defaults to EVOLVE_API_KEY env var with 'claude' type)
78
+ sandbox: Sandbox provider (optional - auto-resolves from env vars:
79
+ E2B_API_KEY → E2B direct, DAYTONA_API_KEY → Daytona direct,
80
+ MODAL_TOKEN_ID+MODAL_TOKEN_SECRET → Modal direct,
81
+ EVOLVE_API_KEY → E2B via gateway. User sandbox keys take priority.)
82
+ working_directory: Working directory in sandbox (default: /home/user/workspace)
83
+ workspace_mode: 'knowledge', 'swe', or 'task'. Task mode leaves the
84
+ task-owned working directory untouched.
85
+ system_prompt: Custom system prompt (appended to default in 'knowledge' mode, sole prompt in 'swe' mode)
86
+ context: Files to upload to context/ folder on first run - { "filename.txt": "content" }
87
+ files: Files to upload to working directory on first run - { "scripts/run.sh": "content" }
88
+ mcp_servers: MCP server configurations
89
+ skills: Skill references to mount into the sandbox — real references,
90
+ no built-in catalog: 'skills.sh/<owner>/<repo>[/<skill>]',
91
+ 'org/repo[@ref]', an https git URL, or a local folder path
92
+ containing SKILL.md. Resolved pinned + content-cached by the
93
+ SDK's one resolver and mounted where the harness discovers them.
94
+ secrets: Environment variables for sandbox
95
+ managed_secrets: Dashboard-stored managed secrets to attach
96
+ ({'name', 'label'?, 'as'?} or ManagedSecretRef). Brokered
97
+ secrets ride as opaque placeholder env vars behind the
98
+ egress proxy; direct secrets land as raw env values
99
+ sandbox_id: Existing sandbox ID to reconnect to
100
+ session_tag_prefix: Optional semantic label for observability log files (e.g., 'experiment-7')
101
+ schema: Schema for structured output - Pydantic model, dataclass, or JSON Schema dict
102
+ schema_options: Validation options (mode: 'strict' or 'loose', default: 'loose')
103
+ integrations: managed app integrations setup
104
+ storage: Storage configuration for checkpoint persistence (BYOK S3 or gateway mode)
105
+ browser: Browser automation provider. Use {'provider': 'agent-browser', 'remote': True}
106
+ for managed remote browser automation.
107
+ browser_credentials: Saved browser login MCP setup. Requires managed remote agent-browser.
108
+ plugins: Agent plugins/extensions to install in the sandbox user profile before first run.
109
+ sandbox_create_options: Provider-neutral image, env, metadata, timeout,
110
+ working-directory and outbound-network options for fresh sandbox creation.
111
+ """
112
+ self.config = config
113
+ self.sandbox = sandbox
114
+ self.sandbox_create_options = sandbox_create_options
115
+ self.working_directory = working_directory
116
+ self.workspace_mode = workspace_mode
117
+ self.system_prompt = system_prompt
118
+ self.context = context
119
+ self.files = files
120
+ self.mcp_servers = mcp_servers
121
+ self.browser = self._normalize_browser(browser)
122
+ self.browser_credentials = browser_credentials
123
+ self.skills = skills
124
+ self.secrets = secrets
125
+ self.managed_secrets = self._normalize_managed_secrets(managed_secrets)
126
+ self.sandbox_id = sandbox_id
127
+ self.session_tag_prefix = session_tag_prefix
128
+ self.schema_options = schema_options or SchemaOptions()
129
+ self._integrations = integrations
130
+ self._storage_config = storage
131
+ self.plugins = self._normalize_plugins(plugins)
132
+
133
+ # Schema handling: store original + convert to JSON Schema
134
+ self._schema = schema
135
+ self._schema_json = to_json_schema(schema)
136
+
137
+ self.bridge = BridgeManager()
138
+ self._initialized = False
139
+ self._init_lock = asyncio.Lock()
140
+
141
+ async def _ensure_initialized(self):
142
+ """Ensure bridge is started and agent is initialized."""
143
+ async with self._init_lock:
144
+ if self._initialized:
145
+ return
146
+
147
+ await self.bridge.start()
148
+
149
+ # Build params with _filter_none to exclude None values
150
+ # TS SDK resolves defaults from env vars when not provided
151
+ params = _filter_none({
152
+ # Agent config (optional - TS SDK resolves from env vars)
153
+ 'agent_type': self.config.type if self.config else None,
154
+ 'api_key': self.config.api_key if self.config else None,
155
+ 'provider_api_key': self.config.provider_api_key if self.config else None,
156
+ 'oauth_token': self.config.oauth_token if self.config else None,
157
+ 'provider_base_url': self.config.provider_base_url if self.config else None,
158
+ 'model': self.config.model if self.config else None,
159
+ 'reasoning_effort': self.config.reasoning_effort if self.config else None,
160
+ 'max_context_size': self.config.max_context_size if self.config else None,
161
+ 'agent_config': self.config.config if self.config else None,
162
+ 'agent_preset': self.config.preset if self.config else None,
163
+ # Sandbox (optional - TS SDK auto-resolves from EVOLVE_API_KEY/E2B_API_KEY/DAYTONA_API_KEY)
164
+ 'sandbox_provider': {'type': self.sandbox.type, 'config': self.sandbox.config} if self.sandbox else None,
165
+ 'sandbox_create_options': self.sandbox_create_options,
166
+ # Other settings
167
+ 'working_directory': self.working_directory,
168
+ 'workspace_mode': self.workspace_mode,
169
+ 'system_prompt': self.system_prompt,
170
+ 'context': _encode_files_for_transport(self.context) if self.context else None,
171
+ 'files': _encode_files_for_transport(self.files) if self.files else None,
172
+ 'mcp_servers': self.mcp_servers,
173
+ 'browser': self.browser,
174
+ 'browser_credentials': self.browser_credentials.to_dict() if self.browser_credentials else None,
175
+ 'plugins': self.plugins,
176
+ 'skills': self.skills,
177
+ 'secrets': self.secrets,
178
+ 'managed_secrets': self.managed_secrets,
179
+ 'sandbox_id': self.sandbox_id,
180
+ 'session_tag_prefix': self.session_tag_prefix,
181
+ 'schema': self._schema_json,
182
+ 'schema_options': {'mode': self.schema_options.mode} if self._schema_json else None,
183
+ # Managed integrations
184
+ 'integrations': self._integrations.to_dict() if self._integrations else None,
185
+ # Storage / Checkpointing
186
+ 'storage': self._storage_config.to_dict() if self._storage_config else None,
187
+ # Always forward events
188
+ 'forward_stdout': True,
189
+ 'forward_stderr': True,
190
+ 'forward_content': True,
191
+ 'forward_lifecycle': True,
192
+ })
193
+
194
+ await self.bridge.call('initialize', params, timeout_s=self._get_rpc_timeout_s(None))
195
+ self._initialized = True
196
+
197
+ @staticmethod
198
+ def _normalize_browser(browser: Optional[BrowserConfig]) -> Optional[BrowserConfig]:
199
+ """Normalize browser automation shorthand for bridge transport."""
200
+ if browser is None:
201
+ return None
202
+ if browser in ('browser-use', 'actionbook', 'agent-browser'):
203
+ return browser
204
+ if isinstance(browser, dict):
205
+ provider = browser.get('provider', 'agent-browser')
206
+ if provider not in ('actionbook', 'agent-browser'):
207
+ raise ValueError("browser provider must be 'actionbook' or 'agent-browser'")
208
+ normalized = dict(browser)
209
+ normalized['provider'] = provider
210
+ if 'profile' in normalized and 'remote' not in normalized and browser.get('provider') is None:
211
+ normalized['remote'] = True
212
+ if 'profile' in normalized and normalized.get('remote') is not True:
213
+ raise ValueError("browser profile requires managed remote browser mode")
214
+ return normalized
215
+ raise ValueError("browser must be 'browser-use', 'actionbook', 'agent-browser', a managed browser config dict, or None")
216
+
217
+ @staticmethod
218
+ def _normalize_managed_secrets(
219
+ managed_secrets: Optional[List[Union[ManagedSecretRef, Dict[str, Any]]]]
220
+ ) -> Optional[List[Dict[str, Any]]]:
221
+ if managed_secrets is None:
222
+ return None
223
+ if not managed_secrets:
224
+ raise ValueError('managed_secrets requires at least one secret')
225
+ normalized: List[Dict[str, Any]] = []
226
+ for secret in managed_secrets:
227
+ if isinstance(secret, ManagedSecretRef):
228
+ normalized.append(secret.to_dict())
229
+ elif isinstance(secret, dict):
230
+ item = dict(secret)
231
+ if 'as_name' in item:
232
+ item['as'] = item.pop('as_name')
233
+ normalized.append(item)
234
+ else:
235
+ raise ValueError('managed_secrets entries must be ManagedSecretRef or dict')
236
+ return normalized
237
+
238
+ @staticmethod
239
+ def _normalize_plugins(
240
+ plugins: Optional[Union[AgentPluginConfig, List[AgentPluginConfig]]]
241
+ ) -> Optional[List[Dict[str, Any]]]:
242
+ """Normalize plugin config and convert Python snake_case flags for TS transport."""
243
+ if plugins is None:
244
+ return None
245
+
246
+ if isinstance(plugins, dict) or is_dataclass(plugins):
247
+ raw_plugins: List[Any] = [plugins]
248
+ elif isinstance(plugins, list):
249
+ raw_plugins = plugins
250
+ else:
251
+ raise ValueError('plugins must be a plugin dict, list of plugin dicts, or None')
252
+
253
+ normalized: List[Dict[str, Any]] = []
254
+ for plugin in raw_plugins:
255
+ if is_dataclass(plugin):
256
+ item = asdict(plugin)
257
+ elif isinstance(plugin, dict):
258
+ item = dict(plugin)
259
+ else:
260
+ raise ValueError('each plugin must be a dict-like object')
261
+
262
+ for python_key, ts_key in (
263
+ ('auto_update', 'autoUpdate'),
264
+ ('pre_release', 'preRelease'),
265
+ ('skip_settings', 'skipSettings'),
266
+ ):
267
+ if python_key in item:
268
+ item[ts_key] = item.pop(python_key)
269
+
270
+ normalized.append(item)
271
+
272
+ return normalized
273
+
274
+ def on(
275
+ self,
276
+ event_type: Literal['stdout', 'stderr', 'content', 'lifecycle'],
277
+ callback: Callable[[Any], None]
278
+ ):
279
+ """Register event callback.
280
+
281
+ Args:
282
+ event_type: Event type ('stdout' | 'stderr' | 'content' | 'lifecycle')
283
+ callback: Callback function invoked with the event payload
284
+ (str for stdout/stderr, dict for content/lifecycle)
285
+
286
+ Example:
287
+ >>> evolve.on('stdout', lambda data: print(data, end=''))
288
+ >>> evolve.on('stderr', lambda data: print(f'[ERR] {data}', end=''))
289
+ >>> evolve.on('content', lambda event: print(event['update']['sessionUpdate']))
290
+ >>> evolve.on('lifecycle', lambda event: print(event['reason']))
291
+ """
292
+ self.bridge.on(event_type, callback)
293
+
294
+ def _get_rpc_timeout_s(self, timeout_ms: Optional[int]) -> float:
295
+ """Compute an RPC timeout aligned with sandbox execution timeout."""
296
+ if timeout_ms is None:
297
+ timeout_ms = getattr(self.sandbox, "timeout_ms", 3600000) if self.sandbox else 3600000
298
+ # Add small grace to allow bridge/agent cleanup after sandbox timeout.
299
+ return timeout_ms / 1000.0 + 30.0
300
+
301
+ async def run(
302
+ self,
303
+ prompt: str,
304
+ timeout_ms: Optional[int] = None,
305
+ background: bool = False,
306
+ from_checkpoint: Optional[str] = None,
307
+ checkpoint_comment: Optional[str] = None,
308
+ resume: Optional[bool] = None,
309
+ ) -> AgentResponse:
310
+ """Run AI-assisted task (agent decides and acts).
311
+
312
+ Args:
313
+ prompt: Task description
314
+ timeout_ms: Optional timeout in milliseconds (default: 1 hour)
315
+ background: Run in background (default: False). If True, returns
316
+ immediately with a handshake response (`exit_code=0`).
317
+ Final completion is delivered asynchronously via
318
+ lifecycle events or status polling.
319
+ from_checkpoint: Restore from checkpoint ID before running (requires storage).
320
+ Use 'latest' to restore the most recent checkpoint.
321
+ checkpoint_comment: Optional label for the auto-checkpoint after this run
322
+ resume: Whether this run continues the agent's previous conversation
323
+ in this sandbox. Omitted, the first run in a sandbox is fresh
324
+ and every run after it resumes. Pass False to force a FRESH
325
+ conversation in a sandbox the agent has already run in — the
326
+ shape a sequence of independent tasks against one shared
327
+ sandbox needs, where the environment persists but the context
328
+ should not.
329
+
330
+ Returns:
331
+ AgentResponse with sandbox_id, exit_code, stdout, stderr, checkpoint
332
+
333
+ Example:
334
+ >>> result = await evolve.run(prompt='Analyze data and create report', timeout_ms=600000)
335
+ >>> # Background execution
336
+ >>> result = await evolve.run(prompt='Long task', background=True)
337
+ >>> # Restore from checkpoint
338
+ >>> result = await evolve.run(prompt='Continue work', from_checkpoint='latest')
339
+ >>> # Independent tasks in one sandbox: keep the box, drop the context
340
+ >>> result = await evolve.run(prompt='Second, unrelated task', resume=False)
341
+ """
342
+ await self._ensure_initialized()
343
+
344
+ params: Dict[str, Any] = {
345
+ 'prompt': prompt,
346
+ }
347
+ if timeout_ms is not None:
348
+ params['timeout_ms'] = timeout_ms
349
+ if background:
350
+ params['background'] = background
351
+ if from_checkpoint is not None:
352
+ params['from'] = from_checkpoint
353
+ if checkpoint_comment is not None:
354
+ params['checkpoint_comment'] = checkpoint_comment
355
+ if resume is not None:
356
+ params['resume'] = resume
357
+
358
+ response = await self.bridge.call(
359
+ 'run',
360
+ params,
361
+ timeout_s=self._get_rpc_timeout_s(timeout_ms),
362
+ )
363
+
364
+ return AgentResponse(
365
+ sandbox_id=response['sandbox_id'],
366
+ exit_code=response['exit_code'],
367
+ stdout=response['stdout'],
368
+ stderr=response['stderr'],
369
+ session_id=response.get('session_id'),
370
+ browser=response.get('browser'),
371
+ run_id=response.get('run_id'),
372
+ checkpoint=_parse_checkpoint(response.get('checkpoint')),
373
+ )
374
+
375
+ async def execute_command(
376
+ self,
377
+ command: str,
378
+ timeout_ms: Optional[int] = None,
379
+ background: bool = False,
380
+ ) -> AgentResponse:
381
+ """Execute direct shell command.
382
+
383
+ Args:
384
+ command: Shell command to execute
385
+ timeout_ms: Optional timeout in milliseconds (default: 1 hour)
386
+ background: Run in background (default: False). If True, returns
387
+ immediately with a handshake response (`exit_code=0`).
388
+ Final completion is delivered via lifecycle events.
389
+
390
+ Returns:
391
+ AgentResponse with sandbox_id, exit_code, stdout, stderr
392
+
393
+ Example:
394
+ >>> result = await evolve.execute_command(command='python script.py')
395
+ """
396
+ await self._ensure_initialized()
397
+
398
+ response = await self.bridge.call(
399
+ 'execute_command',
400
+ {
401
+ 'command': command,
402
+ 'timeout_ms': timeout_ms,
403
+ 'background': background,
404
+ },
405
+ timeout_s=self._get_rpc_timeout_s(timeout_ms),
406
+ )
407
+
408
+ return AgentResponse(
409
+ sandbox_id=response['sandbox_id'],
410
+ exit_code=response['exit_code'],
411
+ stdout=response['stdout'],
412
+ stderr=response['stderr'],
413
+ session_id=response.get('session_id'),
414
+ browser=response.get('browser'),
415
+ )
416
+
417
+ async def seal_credentials(self) -> None:
418
+ """Irreversibly revoke Evolve-managed model credentials for this sandbox."""
419
+ await self._ensure_initialized()
420
+ await self.bridge.call('seal_credentials', {}, timeout_s=30)
421
+
422
+ async def collect_artifacts(
423
+ self,
424
+ paths: List[str],
425
+ ) -> Dict[str, Union[str, bytes]]:
426
+ """Collect declared task files after :meth:`seal_credentials`."""
427
+ await self._ensure_initialized()
428
+ response = await self.bridge.call(
429
+ 'collect_artifacts',
430
+ {'paths': paths},
431
+ timeout_s=self._get_rpc_timeout_s(None),
432
+ )
433
+ return _decode_files_from_transport(response.get('files', {}))
434
+
435
+ async def upload_context(
436
+ self,
437
+ files: Dict[str, Union[str, bytes]],
438
+ ):
439
+ """Upload files to context/ folder (runtime - immediate upload).
440
+
441
+ Args:
442
+ files: Dict mapping filename to content - { "filename.txt": "content", "data.json": jsonStr }
443
+
444
+ Example:
445
+ >>> await evolve.upload_context({
446
+ ... 'spec.json': json.dumps(spec),
447
+ ... 'readme.txt': 'Project documentation...',
448
+ ... })
449
+ """
450
+ await self._ensure_initialized()
451
+ await self.bridge.call('upload_context', {
452
+ 'files': _encode_files_for_transport(files),
453
+ }, timeout_s=self._get_rpc_timeout_s(None))
454
+
455
+ async def upload_files(
456
+ self,
457
+ files: Dict[str, Union[str, bytes]],
458
+ ):
459
+ """Upload files to working directory (runtime - immediate upload).
460
+
461
+ Args:
462
+ files: Dict mapping path to content - { "scripts/run.sh": "#!/bin/bash...", "data/input.csv": csvData }
463
+
464
+ Example:
465
+ >>> await evolve.upload_files({
466
+ ... 'scripts/setup.sh': '#!/bin/bash\\necho hello',
467
+ ... 'temp/cache.json': json.dumps(cache),
468
+ ... })
469
+ """
470
+ await self._ensure_initialized()
471
+ await self.bridge.call('upload_files', {
472
+ 'files': _encode_files_for_transport(files),
473
+ }, timeout_s=self._get_rpc_timeout_s(None))
474
+
475
+ async def upload_file_from_path(
476
+ self,
477
+ sandbox_path: str,
478
+ local_path: str,
479
+ ):
480
+ """Upload one LOCAL file into the sandbox by path, without buffering it.
481
+
482
+ The memory-bounded counterpart to ``upload_files()``: that one takes the
483
+ bytes as a value, so a large artifact costs one full-size copy per
484
+ concurrent upload. This takes the path, and the provider streams it off
485
+ disk — peak memory is a chunk rather than the file.
486
+
487
+ Args:
488
+ sandbox_path: Destination in the sandbox. Absolute paths are used
489
+ as-is; relative paths resolve under the working directory.
490
+ local_path: Path on THIS machine to upload.
491
+
492
+ Example:
493
+ >>> await evolve.upload_file_from_path('/tmp/dataset.tgz', './dataset.tgz')
494
+ """
495
+ await self._ensure_initialized()
496
+ await self.bridge.call('upload_file_from_path', {
497
+ 'sandbox_path': sandbox_path,
498
+ 'local_path': local_path,
499
+ }, timeout_s=self._get_rpc_timeout_s(None))
500
+
501
+ async def get_output_files(self, recursive: bool = False) -> OutputResult:
502
+ """Get output files with optional schema validation result.
503
+
504
+ Returns files modified after the last run() call, along with schema
505
+ validation results if a schema was configured.
506
+
507
+ Matches TypeScript SDK's getOutputFiles() for exact parity.
508
+ Evidence: sdk-ts/src/types.ts OutputResult<T> interface
509
+
510
+ Args:
511
+ recursive: Include files in subdirectories (default: False)
512
+
513
+ Returns:
514
+ OutputResult containing:
515
+ - files: Dict mapping filename/path to content (str for text, bytes for binary)
516
+ - data: Parsed and validated result.json data (None if no schema or validation failed)
517
+ - error: Validation or parse error message, if any
518
+ - raw_data: Raw result.json string when parse or validation failed
519
+
520
+ Example:
521
+ >>> output = await evolve.get_output_files()
522
+ >>> for name, content in output.files.items():
523
+ ... with open(f'./downloads/{name}', 'wb') as f:
524
+ ... f.write(content if isinstance(content, bytes) else content.encode())
525
+ >>> if output.data:
526
+ ... print(f"Validated data: {output.data}")
527
+ >>> if output.error:
528
+ ... print(f"Validation error: {output.error}")
529
+ """
530
+ await self._ensure_initialized()
531
+
532
+ response = await self.bridge.call('get_output_files', {'recursive': recursive}, timeout_s=self._get_rpc_timeout_s(None))
533
+
534
+ files = _decode_files_from_transport(response.get('files', {}))
535
+
536
+ data = None
537
+ error = None
538
+ raw_data = None
539
+
540
+ # CASE 1: Pydantic model or dataclass → Native Python validation
541
+ if is_pydantic_model(self._schema) or is_dataclass(self._schema):
542
+ raw_json = files.get('result.json')
543
+ if raw_json is None:
544
+ error = "Schema provided but agent did not create output/result.json"
545
+ else:
546
+ if isinstance(raw_json, bytes):
547
+ raw_json = raw_json.decode('utf-8')
548
+
549
+ try:
550
+ strict = self.schema_options.mode == 'strict'
551
+ data = validate_and_parse(raw_json, self._schema, strict=strict)
552
+ except Exception as e:
553
+ error = f"Schema validation failed: {e}"
554
+ raw_data = raw_json
555
+
556
+ # CASE 2: JSON Schema dict → Use TS validation (backward compatible)
557
+ elif self._schema_json is not None:
558
+ data = response.get('data')
559
+ error = response.get('error')
560
+ raw_data = response.get('raw_data')
561
+
562
+ # CASE 3: No schema → Just return files (data stays None)
563
+
564
+ return OutputResult(
565
+ files=files,
566
+ data=data,
567
+ error=error,
568
+ raw_data=raw_data,
569
+ )
570
+
571
+ # =========================================================================
572
+ # STORAGE / CHECKPOINTING
573
+ # =========================================================================
574
+
575
+ async def checkpoint(self, comment: Optional[str] = None) -> CheckpointInfo:
576
+ """Create an explicit checkpoint of the current sandbox state.
577
+
578
+ Requires a prior run() call (needs an active sandbox to snapshot).
579
+
580
+ Args:
581
+ comment: Optional label for this checkpoint
582
+
583
+ Returns:
584
+ CheckpointInfo with id, hash, tag, timestamp, etc.
585
+
586
+ Example:
587
+ >>> info = await evolve.checkpoint(comment='before refactor')
588
+ >>> print(f'Checkpoint: {info.id}')
589
+ """
590
+ await self._ensure_initialized()
591
+ params: Dict[str, Any] = {}
592
+ if comment is not None:
593
+ params['comment'] = comment
594
+ response = await self.bridge.call('checkpoint', params, timeout_s=self._get_rpc_timeout_s(None))
595
+ return _require_checkpoint(response)
596
+
597
+ async def list_checkpoints(
598
+ self,
599
+ limit: Optional[int] = None,
600
+ tag: Optional[str] = None,
601
+ ) -> List[CheckpointInfo]:
602
+ """List checkpoints (requires storage configuration).
603
+
604
+ Does not require a running sandbox — only storage configuration.
605
+
606
+ Args:
607
+ limit: Maximum number of checkpoints to return
608
+ tag: Filter by session tag
609
+
610
+ Returns:
611
+ List of CheckpointInfo sorted by newest first
612
+
613
+ Example:
614
+ >>> checkpoints = await evolve.list_checkpoints(limit=5)
615
+ >>> for cp in checkpoints:
616
+ ... print(f'{cp.id} ({cp.comment})')
617
+ """
618
+ await self._ensure_initialized()
619
+ params: Dict[str, Any] = {}
620
+ if limit is not None:
621
+ params['limit'] = limit
622
+ if tag is not None:
623
+ params['tag'] = tag
624
+ response = await self.bridge.call('list_checkpoints', params, timeout_s=self._get_rpc_timeout_s(None))
625
+ return [_require_checkpoint(cp) for cp in response]
626
+
627
+ def storage(self) -> StorageClient:
628
+ """Get a StorageClient bound to this instance's storage configuration.
629
+
630
+ Same API surface as the standalone ``storage()`` factory, but uses
631
+ the Evolve instance's bridge and gateway credentials.
632
+
633
+ Returns:
634
+ StorageClient with list_checkpoints, get_checkpoint,
635
+ download_checkpoint, download_files methods
636
+
637
+ Raises:
638
+ RuntimeError: If storage is not configured
639
+
640
+ Example:
641
+ >>> store = evolve.storage()
642
+ >>> checkpoints = await store.list_checkpoints(limit=5)
643
+ >>> files = await store.download_files(checkpoints[0].id)
644
+ """
645
+ if self._storage_config is None:
646
+ raise RuntimeError("Storage not configured. Pass storage=StorageConfig() to Evolve().")
647
+ return StorageClient(self.bridge, storage_config=None, _init_fn=self._ensure_initialized)
648
+
649
+ async def get_session(self) -> Optional[str]:
650
+ """Get sandbox ID for reuse.
651
+
652
+ Returns:
653
+ Sandbox ID or None if not initialized
654
+
655
+ Example:
656
+ >>> sandbox_id = await evolve.get_session()
657
+ >>> print(f'Sandbox ID: {sandbox_id}')
658
+ """
659
+ await self._ensure_initialized()
660
+ return await self.bridge.call('get_session')
661
+
662
+ async def prepare_sandbox(self) -> str:
663
+ """Create and initialize the sandbox without starting the agent."""
664
+ await self._ensure_initialized()
665
+ sandbox_id = await self.bridge.call('prepare_sandbox')
666
+ if not isinstance(sandbox_id, str) or not sandbox_id:
667
+ raise RuntimeError('Sandbox preparation did not return a sandbox ID')
668
+ return sandbox_id
669
+
670
+ async def set_session(self, session_id: str):
671
+ """Change sandbox session.
672
+
673
+ Args:
674
+ session_id: New sandbox ID to connect to
675
+
676
+ Example:
677
+ >>> await evolve.set_session('existing-sandbox-id')
678
+ """
679
+ await self._ensure_initialized()
680
+ await self.bridge.call('set_session', {
681
+ 'session_id': session_id,
682
+ })
683
+
684
+ async def pause(self):
685
+ """Pause sandbox to save costs while preserving state.
686
+
687
+ Example:
688
+ >>> await evolve.pause()
689
+ """
690
+ await self._ensure_initialized()
691
+ await self.bridge.call('pause')
692
+
693
+ async def interrupt(self) -> bool:
694
+ """Interrupt active process without killing the sandbox.
695
+
696
+ Returns:
697
+ True if an active process was interrupted; False otherwise.
698
+ """
699
+ await self._ensure_initialized()
700
+ interrupted = await self.bridge.call('interrupt')
701
+ return bool(interrupted)
702
+
703
+ async def status(self) -> SessionStatus:
704
+ """Get runtime status snapshot for sandbox and agent."""
705
+ await self._ensure_initialized()
706
+ response = await self.bridge.call('status')
707
+ return SessionStatus(
708
+ sandbox_id=response.get('sandbox_id'),
709
+ sandbox=response.get('sandbox', 'stopped'),
710
+ agent=response.get('agent', 'idle'),
711
+ active_process_id=response.get('active_process_id'),
712
+ has_run=bool(response.get('has_run', False)),
713
+ timestamp=response.get('timestamp', ''),
714
+ browser=response.get('browser'),
715
+ )
716
+
717
+ async def resume(self):
718
+ """Resume paused sandbox.
719
+
720
+ Example:
721
+ >>> await evolve.resume()
722
+ """
723
+ await self._ensure_initialized()
724
+ await self.bridge.call('resume')
725
+
726
+ async def kill(self):
727
+ """Terminate sandbox and release all resources.
728
+
729
+ Example:
730
+ >>> await evolve.kill()
731
+ """
732
+ await self._ensure_initialized()
733
+ try:
734
+ await self.bridge.call('kill')
735
+ finally:
736
+ # Always stop bridge even if RPC fails (e.g., sandbox already gone)
737
+ await self.bridge.stop()
738
+ self._initialized = False
739
+
740
+ async def get_host(self, port: int) -> str:
741
+ """Get public URL for sandbox port.
742
+
743
+ Args:
744
+ port: Port number to expose
745
+
746
+ Returns:
747
+ Public URL for the port
748
+
749
+ Example:
750
+ >>> url = await evolve.get_host(8000)
751
+ >>> print(f'Server available at: {url}')
752
+ """
753
+ await self._ensure_initialized()
754
+ response = await self.bridge.call('get_host', {
755
+ 'port': port,
756
+ })
757
+ return response['url']
758
+
759
+ async def get_session_tag(self) -> Optional[str]:
760
+ """Get the observability session tag.
761
+
762
+ Returns the generated tag (e.g., 'my-prefix-a3f8b2c1') used for the
763
+ log file in ~/.evolve-sdk/observability/sessions/
764
+
765
+ Returns:
766
+ Session tag or None if not initialized
767
+
768
+ Example:
769
+ >>> tag = await evolve.get_session_tag()
770
+ >>> print(f'Log file tag: {tag}')
771
+ 'experiment-7-a3f8b2c1'
772
+ """
773
+ await self._ensure_initialized()
774
+ return await self.bridge.call('get_session_tag')
775
+
776
+ async def get_session_timestamp(self) -> Optional[str]:
777
+ """Get the session start timestamp (ISO format).
778
+
779
+ Returns:
780
+ ISO timestamp when session was created or None if not initialized
781
+
782
+ Example:
783
+ >>> timestamp = await evolve.get_session_timestamp()
784
+ >>> print(f'Session started: {timestamp}')
785
+ '2025-01-15T10:30:45.123Z'
786
+ """
787
+ await self._ensure_initialized()
788
+ return await self.bridge.call('get_session_timestamp')
789
+
790
+ # =========================================================================
791
+ # COST
792
+ # =========================================================================
793
+
794
+ async def get_session_cost(self) -> SessionCost:
795
+ """Get cost breakdown for the current session (all runs).
796
+
797
+ Cost data can lag live usage by about a minute.
798
+ Note: after kill(), the bridge process is stopped. To query costs for a
799
+ completed session, create a new Evolve instance — the TS bridge handles
800
+ previousSessionTag fallback internally.
801
+
802
+ Requires gateway mode (EVOLVE_API_KEY).
803
+
804
+ Returns:
805
+ SessionCost with total cost, token counts, and per-run breakdown
806
+
807
+ Example:
808
+ >>> cost = await evolve.get_session_cost()
809
+ >>> print(f'Total: ${cost.total_cost:.4f}')
810
+ >>> for run in cost.runs:
811
+ ... print(f' Run {run.index}: ${run.cost:.4f} ({run.model})')
812
+ """
813
+ await self._ensure_initialized()
814
+ response = await self.bridge.call('get_session_cost')
815
+ return self._parse_session_cost(response)
816
+
817
+ async def get_run_cost(
818
+ self,
819
+ *,
820
+ run_id: Optional[str] = None,
821
+ index: Optional[int] = None,
822
+ ) -> RunCost:
823
+ """Get cost for a specific run by ID or index.
824
+
825
+ Args:
826
+ run_id: Run ID from AgentResponse.run_id
827
+ index: 1-based chronological position (negative = from end, e.g. -1 = last run)
828
+
829
+ Returns:
830
+ RunCost with cost, tokens, model, and completion status
831
+
832
+ Example:
833
+ >>> # By run ID
834
+ >>> result = await evolve.run('Analyze data')
835
+ >>> cost = await evolve.get_run_cost(run_id=result.run_id)
836
+ >>> # By index (last run)
837
+ >>> cost = await evolve.get_run_cost(index=-1)
838
+ """
839
+ if run_id is not None and index is not None:
840
+ raise ValueError('Specify run_id or index, not both')
841
+ if run_id is None and index is None:
842
+ raise ValueError('Specify either run_id or index')
843
+ await self._ensure_initialized()
844
+ params: Dict[str, Any] = {}
845
+ if run_id is not None:
846
+ params['run_id'] = run_id
847
+ if index is not None:
848
+ params['index'] = index
849
+ response = await self.bridge.call('get_run_cost', params)
850
+ return self._parse_run_cost(response)
851
+
852
+ @staticmethod
853
+ def _parse_run_cost(data: Dict[str, Any]) -> RunCost:
854
+ """Parse run cost dict from bridge response into RunCost."""
855
+ return RunCost(
856
+ run_id=data['run_id'],
857
+ index=data['index'],
858
+ cost=data['cost'],
859
+ tokens=data['tokens'],
860
+ model=data['model'],
861
+ requests=data['requests'],
862
+ as_of=data['as_of'],
863
+ is_complete=data['is_complete'],
864
+ truncated=data['truncated'],
865
+ )
866
+
867
+ @classmethod
868
+ def _parse_session_cost(cls, data: Dict[str, Any]) -> SessionCost:
869
+ """Parse session cost dict from bridge response into SessionCost."""
870
+ return SessionCost(
871
+ session_tag=data['session_tag'],
872
+ total_cost=data['total_cost'],
873
+ total_tokens=data['total_tokens'],
874
+ runs=[cls._parse_run_cost(r) for r in data.get('runs', [])],
875
+ as_of=data['as_of'],
876
+ is_complete=data['is_complete'],
877
+ truncated=data['truncated'],
878
+ )
879
+
880
+ async def __aenter__(self):
881
+ """Context manager entry."""
882
+ try:
883
+ await self._ensure_initialized()
884
+ return self
885
+ except Exception:
886
+ # Cleanup bridge process if initialization fails
887
+ await self.bridge.stop()
888
+ raise
889
+
890
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
891
+ """Context manager exit - cleanup resources."""
892
+ try:
893
+ await self.kill()
894
+ except Exception as e:
895
+ import warnings
896
+ warnings.warn(f"Error during cleanup: {e}", RuntimeWarning)