langfun 0.1.2.dev202510230805__py3-none-any.whl → 0.1.2.dev202511160804__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.

Potentially problematic release.


This version of langfun might be problematic. Click here for more details.

Files changed (146) hide show
  1. langfun/core/__init__.py +1 -0
  2. langfun/core/agentic/action.py +107 -12
  3. langfun/core/agentic/action_eval.py +9 -2
  4. langfun/core/agentic/action_test.py +25 -0
  5. langfun/core/async_support.py +32 -3
  6. langfun/core/coding/python/correction.py +19 -9
  7. langfun/core/coding/python/execution.py +14 -12
  8. langfun/core/coding/python/generation.py +21 -16
  9. langfun/core/coding/python/sandboxing.py +23 -3
  10. langfun/core/component.py +42 -3
  11. langfun/core/concurrent.py +70 -6
  12. langfun/core/concurrent_test.py +1 -0
  13. langfun/core/console.py +1 -1
  14. langfun/core/data/conversion/anthropic.py +12 -3
  15. langfun/core/data/conversion/anthropic_test.py +8 -6
  16. langfun/core/data/conversion/gemini.py +9 -2
  17. langfun/core/data/conversion/gemini_test.py +12 -9
  18. langfun/core/data/conversion/openai.py +145 -31
  19. langfun/core/data/conversion/openai_test.py +161 -17
  20. langfun/core/eval/base.py +47 -43
  21. langfun/core/eval/base_test.py +4 -4
  22. langfun/core/eval/matching.py +5 -2
  23. langfun/core/eval/patching.py +3 -3
  24. langfun/core/eval/scoring.py +4 -3
  25. langfun/core/eval/v2/__init__.py +1 -0
  26. langfun/core/eval/v2/checkpointing.py +39 -5
  27. langfun/core/eval/v2/checkpointing_test.py +1 -1
  28. langfun/core/eval/v2/eval_test_helper.py +96 -0
  29. langfun/core/eval/v2/evaluation.py +87 -15
  30. langfun/core/eval/v2/evaluation_test.py +9 -3
  31. langfun/core/eval/v2/example.py +45 -39
  32. langfun/core/eval/v2/example_test.py +3 -3
  33. langfun/core/eval/v2/experiment.py +51 -8
  34. langfun/core/eval/v2/metric_values.py +31 -3
  35. langfun/core/eval/v2/metric_values_test.py +32 -0
  36. langfun/core/eval/v2/metrics.py +157 -44
  37. langfun/core/eval/v2/metrics_test.py +39 -18
  38. langfun/core/eval/v2/progress.py +30 -1
  39. langfun/core/eval/v2/progress_test.py +27 -0
  40. langfun/core/eval/v2/progress_tracking_test.py +3 -0
  41. langfun/core/eval/v2/reporting.py +90 -71
  42. langfun/core/eval/v2/reporting_test.py +20 -6
  43. langfun/core/eval/v2/runners/__init__.py +26 -0
  44. langfun/core/eval/v2/{runners.py → runners/base.py} +22 -124
  45. langfun/core/eval/v2/runners/debug.py +40 -0
  46. langfun/core/eval/v2/runners/debug_test.py +79 -0
  47. langfun/core/eval/v2/runners/parallel.py +100 -0
  48. langfun/core/eval/v2/runners/parallel_test.py +98 -0
  49. langfun/core/eval/v2/runners/sequential.py +47 -0
  50. langfun/core/eval/v2/runners/sequential_test.py +175 -0
  51. langfun/core/langfunc.py +45 -130
  52. langfun/core/langfunc_test.py +6 -4
  53. langfun/core/language_model.py +103 -16
  54. langfun/core/language_model_test.py +9 -3
  55. langfun/core/llms/__init__.py +7 -1
  56. langfun/core/llms/anthropic.py +157 -2
  57. langfun/core/llms/azure_openai.py +29 -17
  58. langfun/core/llms/cache/base.py +25 -3
  59. langfun/core/llms/cache/in_memory.py +48 -7
  60. langfun/core/llms/cache/in_memory_test.py +14 -4
  61. langfun/core/llms/compositional.py +25 -1
  62. langfun/core/llms/deepseek.py +30 -2
  63. langfun/core/llms/fake.py +32 -1
  64. langfun/core/llms/gemini.py +14 -9
  65. langfun/core/llms/google_genai.py +29 -1
  66. langfun/core/llms/groq.py +28 -3
  67. langfun/core/llms/llama_cpp.py +23 -4
  68. langfun/core/llms/openai.py +36 -3
  69. langfun/core/llms/openai_compatible.py +148 -27
  70. langfun/core/llms/openai_compatible_test.py +207 -20
  71. langfun/core/llms/openai_test.py +0 -2
  72. langfun/core/llms/rest.py +12 -1
  73. langfun/core/llms/vertexai.py +51 -8
  74. langfun/core/logging.py +1 -1
  75. langfun/core/mcp/client.py +77 -22
  76. langfun/core/mcp/client_test.py +8 -35
  77. langfun/core/mcp/session.py +94 -29
  78. langfun/core/mcp/session_test.py +54 -0
  79. langfun/core/mcp/tool.py +151 -22
  80. langfun/core/mcp/tool_test.py +197 -0
  81. langfun/core/memory.py +1 -0
  82. langfun/core/message.py +160 -55
  83. langfun/core/message_test.py +65 -81
  84. langfun/core/modalities/__init__.py +8 -0
  85. langfun/core/modalities/audio.py +21 -1
  86. langfun/core/modalities/image.py +19 -1
  87. langfun/core/modalities/mime.py +62 -3
  88. langfun/core/modalities/pdf.py +19 -1
  89. langfun/core/modalities/video.py +21 -1
  90. langfun/core/modality.py +167 -29
  91. langfun/core/modality_test.py +42 -12
  92. langfun/core/natural_language.py +1 -1
  93. langfun/core/sampling.py +4 -4
  94. langfun/core/sampling_test.py +20 -4
  95. langfun/core/structured/__init__.py +2 -24
  96. langfun/core/structured/completion.py +34 -44
  97. langfun/core/structured/completion_test.py +23 -43
  98. langfun/core/structured/description.py +54 -50
  99. langfun/core/structured/function_generation.py +29 -12
  100. langfun/core/structured/mapping.py +81 -37
  101. langfun/core/structured/parsing.py +95 -79
  102. langfun/core/structured/parsing_test.py +0 -3
  103. langfun/core/structured/querying.py +215 -142
  104. langfun/core/structured/querying_test.py +65 -29
  105. langfun/core/structured/schema/__init__.py +48 -0
  106. langfun/core/structured/schema/base.py +664 -0
  107. langfun/core/structured/schema/base_test.py +531 -0
  108. langfun/core/structured/schema/json.py +174 -0
  109. langfun/core/structured/schema/json_test.py +121 -0
  110. langfun/core/structured/schema/python.py +316 -0
  111. langfun/core/structured/schema/python_test.py +410 -0
  112. langfun/core/structured/schema_generation.py +33 -14
  113. langfun/core/structured/scoring.py +47 -36
  114. langfun/core/structured/tokenization.py +26 -11
  115. langfun/core/subscription.py +2 -2
  116. langfun/core/template.py +174 -49
  117. langfun/core/template_test.py +123 -17
  118. langfun/env/__init__.py +8 -2
  119. langfun/env/base_environment.py +320 -128
  120. langfun/env/base_environment_test.py +473 -0
  121. langfun/env/base_feature.py +92 -15
  122. langfun/env/base_feature_test.py +228 -0
  123. langfun/env/base_sandbox.py +84 -361
  124. langfun/env/base_sandbox_test.py +1235 -0
  125. langfun/env/event_handlers/__init__.py +1 -1
  126. langfun/env/event_handlers/chain.py +233 -0
  127. langfun/env/event_handlers/chain_test.py +253 -0
  128. langfun/env/event_handlers/event_logger.py +95 -98
  129. langfun/env/event_handlers/event_logger_test.py +21 -21
  130. langfun/env/event_handlers/metric_writer.py +225 -140
  131. langfun/env/event_handlers/metric_writer_test.py +23 -6
  132. langfun/env/interface.py +854 -40
  133. langfun/env/interface_test.py +112 -2
  134. langfun/env/load_balancers_test.py +23 -2
  135. langfun/env/test_utils.py +126 -84
  136. {langfun-0.1.2.dev202510230805.dist-info → langfun-0.1.2.dev202511160804.dist-info}/METADATA +1 -1
  137. langfun-0.1.2.dev202511160804.dist-info/RECORD +211 -0
  138. langfun/core/eval/v2/runners_test.py +0 -343
  139. langfun/core/structured/schema.py +0 -987
  140. langfun/core/structured/schema_test.py +0 -982
  141. langfun/env/base_test.py +0 -1481
  142. langfun/env/event_handlers/base.py +0 -350
  143. langfun-0.1.2.dev202510230805.dist-info/RECORD +0 -195
  144. {langfun-0.1.2.dev202510230805.dist-info → langfun-0.1.2.dev202511160804.dist-info}/WHEEL +0 -0
  145. {langfun-0.1.2.dev202510230805.dist-info → langfun-0.1.2.dev202511160804.dist-info}/licenses/LICENSE +0 -0
  146. {langfun-0.1.2.dev202510230805.dist-info → langfun-0.1.2.dev202511160804.dist-info}/top_level.txt +0 -0
langfun/env/interface.py CHANGED
@@ -17,8 +17,9 @@ import abc
17
17
  import contextlib
18
18
  import dataclasses
19
19
  import enum
20
+ import functools
20
21
  import os
21
- from typing import Annotated, Any, ContextManager, ClassVar, Iterator, Optional
22
+ from typing import Annotated, Any, Callable, ContextManager, ClassVar, Iterator, Optional, Sequence, Type
22
23
 
23
24
  import pyglove as pg
24
25
 
@@ -168,7 +169,179 @@ class SessionTeardownError(SandboxError):
168
169
 
169
170
 
170
171
  class Environment(pg.Object):
171
- """Base class for an environment."""
172
+ """Base class for an environment.
173
+
174
+ An **Environment** is the central component for managing sandboxes and
175
+ **Features**. It acts as an abstraction layer, hiding the implementation
176
+ details of the underlying container/sandboxing system
177
+ (e.g., Docker, virtual machines).
178
+
179
+ The core goal is to enable the development of features that are **agnostic**
180
+ to how or where they are executed (sandboxed or not).
181
+
182
+ -----------------------------------------------------------------------------
183
+
184
+ ## Core Concepts
185
+
186
+ 1. **Sandbox-Based Features:** Features that require isolated execution
187
+ contexts (sandboxes). They become available as properties of the sandbox
188
+ object (e.g., `sandbox.feature1`). Applicability is determined by an image
189
+ ID regex.
190
+ 2. **Non-Sandbox-Based Features:** Features that run directly within the host
191
+ process or manage their own execution context outside of the Environment's
192
+ managed sandboxes. They are accessed directly via the Environment (e.g.,
193
+ `env.feature3()`).
194
+
195
+ How to Use:
196
+
197
+ The primary usage patterns are creating a **sandbox session** or directly
198
+ accessing a specific **Feature**, which transparently handles sandbox creation
199
+ if needed.
200
+
201
+ ```python
202
+ env = MyEnvironment(
203
+ image_ids=['image1', 'image2'],
204
+ features={
205
+ 'feature1': Feature1(applicable_images=['image1.*']),
206
+ 'feature2': Feature2(applicable_images=['image2.*']),
207
+ 'feature3': Feature3(is_sandbox_based=False)
208
+ })
209
+
210
+ # Context manager for the Environment lifetime.
211
+ with env:
212
+
213
+ # 1. Access a specific sandbox directly.
214
+ # Upon exiting the context, the sandbox will be shutdown or returned to
215
+ # the pool for reuse.
216
+ with env.sandbox(image_id='image1') as sandbox:
217
+ # Execute a shell command inside the sandbox.
218
+ sandbox.shell('echo "hello world"')
219
+
220
+ # Access a sandbox-based feature (feature1 is applicable to image1).
221
+ sandbox.feature1.feature_method()
222
+
223
+ # Attempts to access inapplicable features will raise AttributeError:
224
+ # sandbox.feature2 # Not applicable to image1.
225
+ # sandbox.feature3 # Not sandbox-based.
226
+
227
+ # 2. Access a sandbox-based feature and let the Environment manage the
228
+ # sandbox. A suitable sandbox (e.g., one built from an image matching
229
+ # 'image1.*') will be provisioned, and the feature instance will be yielded.
230
+ with env.feature1() as feature1:
231
+ feature1.feature_method()
232
+
233
+ # 3. Access a non-sandbox-based feature.
234
+ with env.feature3() as feature3:
235
+ feature3.feature_method()
236
+ ```
237
+
238
+ -----------------------------------------------------------------------------
239
+
240
+ ## Multi-tenancy and Pooling
241
+
242
+ The Environment supports multi-tenancy (working with multiple image types) and
243
+ pooling (reusing sandboxes) to amortize setup costs across different user
244
+ requests. Pooling is configured via the pool_size parameter.
245
+
246
+ | pool_size Value | Behavior |
247
+ |---------------------------|------------------------------------------------|
248
+ | 0 or (0, 0) | No pooling. Sandboxes are created and shut down|
249
+ | | on demand (useful for local development). |
250
+ |---------------------------|------------------------------------------------|
251
+ | (MIN, MAX) tuple | Global Pool: Applies the same minimum and |
252
+ | | maximum pool size to sandboxes created from all|
253
+ | | specified images. |
254
+ |---------------------------|------------------------------------------------|
255
+ | {image_regex: (MIN, MAX)} | Per-Image Pool: Allows customizing pool |
256
+ | | settings based on image ID regular expressions.|
257
+ | | (e.g., 'image1.*': (64, 256), '.*': (0, 256)). |
258
+
259
+
260
+ **Example 1: No Pooling (pool_size=0)**
261
+ Sandboxes are created and shutdown immediately upon session end.
262
+
263
+ ```python
264
+ env = MyEnvironment(image_ids=['image1', 'image2'], pool_size=0)
265
+
266
+ # Sandbox created and shutdown on demand.
267
+ with env.sandbox(image_id='image1') as sandbox1:
268
+ ...
269
+ ```
270
+
271
+ **Exaxmple 2: Global Pooling (pool_size=(0, 256))**
272
+ Up to 256 sandboxes will be created and pooled across both images as needed.
273
+ None are created initially.
274
+
275
+ ```python
276
+ env = MyEnvironment(
277
+ image_ids=['image1', 'image2'],
278
+ pool_size=(0, 256)
279
+ )
280
+ ```
281
+
282
+ **Example 3: Per-Image Custom Pooling**:
283
+ For images matching 'image1.*': 64 sandboxes are pre-created (MIN=64) and
284
+ pooled, up to a MAX of 256.
285
+ For all other images ('.*'): Sandboxes are created and pooled on demand
286
+ (MIN=0), up to a MAX of 256.
287
+
288
+ ```python
289
+ env = MyEnvironment(
290
+ image_ids=['image1', 'image2'],
291
+ pool_size={
292
+ 'image1.*': (64, 256),
293
+ '.*': (0, 256),
294
+ }
295
+ )
296
+ ```
297
+
298
+ ## Handling Sandbox Failures
299
+
300
+ Sandboxes often run in distributed, ephemeral environments and must be treated
301
+ as fault-tolerant. Langfun provides a protocol for handling unexpected sandbox
302
+ state issues.
303
+
304
+ ### Communicating Errors
305
+ If a feature encounters an unexpected state in its sandbox (e.g., a process
306
+ died), it should raise `lf.env.SandboxStateError`.
307
+
308
+ The sandbox will be automatically shut down when its context manager exits.
309
+ The Environment handles replacement and ensures future requests are routed to
310
+ healthy sandboxes.
311
+
312
+ For example:
313
+ ```
314
+ with env:
315
+ with env.sandbox() as sb:
316
+ # If SandboxStateError is raised within this block, the sandbox
317
+ # will be forcefully shut down upon block exit.
318
+ sb.shell('echo hi')
319
+ ```
320
+
321
+ ### Robust User Code
322
+ A simple strategy for robust user code is to wrap critical operations in a
323
+ retry loop:
324
+ ```
325
+ while True:
326
+ try:
327
+ result = do_something_that_involves_sandbox()
328
+ break # Success!
329
+ except lf.env.SandboxStateError:
330
+ # The sandbox failed; a new, healthy one will be provisioned on the next
331
+ # iteration.
332
+ # Wait briefly to avoid resource thrashing.
333
+ time.sleep(1)
334
+ except lf.env.EnvironmentOutageError:
335
+ # If the Environment is down for too long
336
+ # (past BaseEnvironment.outage_grace_period)
337
+ # and cannot provision a healthy replacement, this error is raised.
338
+ # The retry loop should be broken or an outer failure reported.
339
+ raise
340
+ ```
341
+ """
342
+
343
+ # Disable symbolic comparison and hashing for environment objects.
344
+ use_symbolic_comparison = False
172
345
 
173
346
  @dataclasses.dataclass(frozen=True)
174
347
  class Id:
@@ -220,6 +393,31 @@ class Environment(pg.Object):
220
393
  def id(self) -> Id:
221
394
  """Returns the identifier for the environment."""
222
395
 
396
+ @property
397
+ @abc.abstractmethod
398
+ def image_ids(self) -> list[str]:
399
+ """Returns the non-dynamic image IDs served by the environment."""
400
+
401
+ def image_id_for(self, feature: 'Feature') -> str:
402
+ """Returns the default image ID for the environment."""
403
+ for image_id in self.image_ids:
404
+ if feature.is_applicable(image_id):
405
+ return image_id
406
+ raise ValueError(
407
+ f'No image ID found for feature {feature.name} in {self.image_ids}.'
408
+ )
409
+
410
+ def non_sandbox_based_features(self) -> Iterator['Feature']:
411
+ """Returns non-sandbox-based features."""
412
+ for feature in self.features.values():
413
+ if not feature.is_sandbox_based:
414
+ yield feature
415
+
416
+ @property
417
+ @abc.abstractmethod
418
+ def event_handler(self) -> 'EventHandler':
419
+ """Returns the event handler for the environment."""
420
+
223
421
  @property
224
422
  @abc.abstractmethod
225
423
  def status(self) -> Status:
@@ -245,9 +443,16 @@ class Environment(pg.Object):
245
443
  """
246
444
 
247
445
  @abc.abstractmethod
248
- def acquire(self) -> 'Sandbox':
446
+ def acquire(
447
+ self,
448
+ image_id: str | None = None,
449
+ ) -> 'Sandbox':
249
450
  """Acquires a free sandbox from the environment.
250
451
 
452
+ Args:
453
+ image_id: The image ID to use for the sandbox. If None, it will be
454
+ automatically determined by the environment.
455
+
251
456
  Returns:
252
457
  A free sandbox from the environment.
253
458
 
@@ -257,7 +462,7 @@ class Environment(pg.Object):
257
462
  """
258
463
 
259
464
  @abc.abstractmethod
260
- def new_session_id(self) -> str:
465
+ def new_session_id(self, feature_hint: str | None = None) -> str:
261
466
  """Generates a new session ID."""
262
467
 
263
468
  #
@@ -281,10 +486,6 @@ class Environment(pg.Object):
281
486
  Environment._ENV_STACK.pop()
282
487
  self.shutdown()
283
488
 
284
- def __del__(self):
285
- """Deletes the environment."""
286
- self.shutdown()
287
-
288
489
  @classmethod
289
490
  def current(cls) -> Optional['Environment']:
290
491
  """Returns the current environment."""
@@ -299,47 +500,100 @@ class Environment(pg.Object):
299
500
  def sandbox(
300
501
  self,
301
502
  session_id: str | None = None,
503
+ image_id: str | None = None,
302
504
  ) -> ContextManager['Sandbox']:
303
505
  """Gets a sandbox from the environment and starts a new user session."""
304
- return self.acquire().new_session(session_id)
506
+ return self.acquire(image_id=image_id).new_session(
507
+ session_id or self.new_session_id()
508
+ )
305
509
 
306
510
  def __getattr__(self, name: str) -> Any:
307
- """Gets a feature from a free sandbox from the environment.
511
+ """Gets a feature session from a free sandbox from the environment.
308
512
 
309
513
  Example:
310
514
  ```
311
515
  with XboxEnvironment(
312
516
  features={'selenium': SeleniumFeature()}
313
517
  ) as env:
314
- driver = env.selenium.get_driver()
518
+ with env.selenium() as selenium:
519
+ driver = selenium.get_driver()
315
520
  ```
316
521
 
317
522
  Args:
318
523
  name: The name of the feature.
319
524
 
320
525
  Returns:
321
- A feature from a free sandbox from the environment.
526
+ A callable `(image_id, *, session_id) -> ContextManager[Feature]` that
527
+ creates a context manager for the requested feature under a new client
528
+ session.
322
529
  """
323
530
  if name in self.features:
324
- return self.acquire().features[name]
531
+ return _feature_session_creator(self, self.features[name])
325
532
  raise AttributeError(name)
326
533
 
327
534
 
535
+ @contextlib.contextmanager
536
+ def _sandbox_session_for_feature(
537
+ environment: Environment,
538
+ feature: 'Feature',
539
+ image_id: str | None = None,
540
+ session_id: str | None = None,
541
+ ) -> Iterator['Feature']:
542
+ """Returns a context manager for a session for a feature."""
543
+ assert feature.is_sandbox_based
544
+ if image_id is None:
545
+ image_id = environment.image_id_for(feature)
546
+ elif not feature.is_applicable(image_id):
547
+ raise ValueError(
548
+ f'Feature {feature.name!r} is not applicable to image {image_id!r}.'
549
+ )
550
+ sandbox = environment.acquire(image_id=image_id)
551
+ with sandbox.new_session(
552
+ session_id=session_id or environment.new_session_id(feature.name)
553
+ ):
554
+ yield sandbox.features[feature.name]
555
+
556
+
557
+ def _feature_session_creator(environment: Environment, feature: 'Feature'):
558
+ """Returns a callable that returns a context manager for a feature session."""
559
+ def fn(session_id: str | None = None, image_id: str | None = None):
560
+ if feature.is_sandbox_based:
561
+ return _sandbox_session_for_feature(
562
+ environment, feature, image_id, session_id
563
+ )
564
+ assert image_id is None, (
565
+ 'Non-sandbox based feature does not support image ID.'
566
+ )
567
+ return feature.new_session(
568
+ session_id or environment.new_session_id(feature.name)
569
+ )
570
+ return fn
571
+
572
+
328
573
  # Enable automatic conversion from str to Environment.Id.
329
574
  pg.typing.register_converter(str, Environment.Id, Environment.Id)
330
575
 
331
576
 
332
577
  class Sandbox(pg.Object):
333
- """Interface for sandboxes."""
578
+ """Interface for sandboxes.
579
+
580
+ A sandbox is a container that runs a single image with a set of features.
581
+ It will be brought up by the environment, setup the features, fullfill user
582
+ requests, and then tear down features and finally the sandbox itself.
583
+ """
584
+
585
+ # Disable symbolic comparison and hashing for sandbox objects.
586
+ use_symbolic_comparison = False
334
587
 
335
588
  @dataclasses.dataclass(frozen=True, slots=True)
336
589
  class Id:
337
590
  """Identifier for a sandbox."""
338
591
  environment_id: Environment.Id
592
+ image_id: str
339
593
  sandbox_id: str
340
594
 
341
595
  def __str__(self) -> str:
342
- return f'{self.environment_id}/{self.sandbox_id}'
596
+ return f'{self.environment_id}/{self.image_id}:{self.sandbox_id}'
343
597
 
344
598
  def working_dir(self, root_dir: str | None) -> str | None:
345
599
  """Returns the download directory for the sandbox."""
@@ -347,6 +601,7 @@ class Sandbox(pg.Object):
347
601
  return None
348
602
  return os.path.join(
349
603
  self.environment_id.working_dir(root_dir),
604
+ _make_path_compatible(self.image_id),
350
605
  _make_path_compatible(self.sandbox_id)
351
606
  )
352
607
 
@@ -438,6 +693,11 @@ class Sandbox(pg.Object):
438
693
  def id(self) -> Id:
439
694
  """Returns the identifier for the sandbox."""
440
695
 
696
+ @property
697
+ @abc.abstractmethod
698
+ def image_id(self) -> str:
699
+ """Returns the image ID used for bootstrapping the sandbox."""
700
+
441
701
  @property
442
702
  @abc.abstractmethod
443
703
  def environment(self) -> Environment:
@@ -458,10 +718,17 @@ class Sandbox(pg.Object):
458
718
  """Returns True if the sandbox is online."""
459
719
  return self.status.is_online
460
720
 
461
- @property
462
721
  @abc.abstractmethod
463
- def state_errors(self) -> list[SandboxStateError]:
464
- """Returns state errors encountered during sandbox's lifecycle."""
722
+ def report_state_error(self, error: SandboxStateError) -> None:
723
+ """Reports state error the sandbox.
724
+
725
+ If state errors are reported, the sandbox will be forcefully shutdown when
726
+ `Sandbox.end_session()` is called, even if the sandbox is set to be
727
+ reusable.
728
+
729
+ Args:
730
+ error: SandboxStateError to report.
731
+ """
465
732
 
466
733
  @abc.abstractmethod
467
734
  def start(self) -> None:
@@ -592,6 +859,9 @@ class Sandbox(pg.Object):
592
859
  `end_session` should always be called for each `start_session` call, even
593
860
  when the session fails to start, to ensure proper cleanup.
594
861
 
862
+ When `end_session` is called with state errors reported, the sandbox will be
863
+ forcefully shutdown even if the sandbox is set to be reusable.
864
+
595
865
  `end_session` may fail with two sources of errors:
596
866
 
597
867
  1. SandboxStateError: If the sandbox is in a bad state or session teardown
@@ -615,6 +885,22 @@ class Sandbox(pg.Object):
615
885
  def session_id(self) -> str | None:
616
886
  """Returns the current user session identifier."""
617
887
 
888
+ @abc.abstractmethod
889
+ def track_activity(
890
+ self,
891
+ name: str,
892
+ **kwargs: Any
893
+ ) -> ContextManager[None]:
894
+ """Context manager that tracks a sandbox activity.
895
+
896
+ Args:
897
+ name: The name of the activity.
898
+ **kwargs: Additional keyword arguments to pass to the activity handler.
899
+
900
+ Returns:
901
+ A context manager that tracks the activity, including duration and error.
902
+ """
903
+
618
904
  #
619
905
  # API related to a user session.
620
906
  # A sandbox could be reused across different user sessions.
@@ -623,10 +909,7 @@ class Sandbox(pg.Object):
623
909
  #
624
910
 
625
911
  @contextlib.contextmanager
626
- def new_session(
627
- self,
628
- session_id: str | None = None,
629
- ) -> Iterator['Sandbox']:
912
+ def new_session(self, session_id: str) -> Iterator['Sandbox']:
630
913
  """Context manager for obtaining a sandbox for a user session.
631
914
 
632
915
  State transitions:
@@ -634,8 +917,7 @@ class Sandbox(pg.Object):
634
917
  ACQUIRED -> IN_SESSINO -> OFFLINE: When session setup or teardown fails.
635
918
 
636
919
  Args:
637
- session_id: The identifier for the user session. If not provided, a random
638
- ID will be generated.
920
+ session_id: The identifier for the user session.
639
921
 
640
922
  Yields:
641
923
  The sandbox for the user session.
@@ -645,11 +927,12 @@ class Sandbox(pg.Object):
645
927
  BaseException: If session setup or teardown failed with user-defined
646
928
  errors.
647
929
  """
648
- if session_id is None:
649
- session_id = self.environment.new_session_id()
650
930
  self.start_session(session_id)
651
931
  try:
652
932
  yield self
933
+ except SandboxStateError as e:
934
+ self.report_state_error(e)
935
+ raise
653
936
  finally:
654
937
  self.end_session()
655
938
 
@@ -675,34 +958,115 @@ class Sandbox(pg.Object):
675
958
  return self.features[name]
676
959
  raise AttributeError(name)
677
960
 
678
- def __del__(self):
679
- """Deletes the sandbox."""
680
- self.shutdown()
681
-
682
961
 
683
962
  class Feature(pg.Object):
684
- """Interface for sandbox features."""
963
+ """Interface for features that run in a Langfun environment.
964
+
965
+ There are two type of features: sandbox-based and non-sandbox-based.
966
+ Sandbox-based features run in a sandbox, which is emulated in a separate
967
+ process. Non-sandbox-based features do not run in a sandbox.
968
+
969
+ Features can be directly accessed through the environment, for example:
970
+
971
+ ```python
972
+ env = MyEnvironment(
973
+ feature={
974
+ 'feature1': SandboxBasedFeature(),
975
+ 'feature2': NonSandboxBasedFeature(),
976
+ }
977
+ )
978
+ # Start the environment.
979
+ with env:
980
+ # Access feature1, which involves acquiring a sandbox and return the feature
981
+ # associated with the sandbox.
982
+ with env.feature1() as f1:
983
+ f1.feature_method()
984
+
985
+ # Access feature2, which does not involve acquiring a sandbox.
986
+ with env.feature2() as f2:
987
+ f2.feature_method()
988
+ ```
989
+
990
+ Sandbox-based features can also be accessed through the sandbox, for example:
991
+
992
+ ```python
993
+ with env.sandbox('session1') as sb:
994
+ # Access feature1 within the sandbox.
995
+ sb.feature1.feature_method()
996
+
997
+ # Attribute error.
998
+ sb.feature2
999
+ ```
1000
+ """
1001
+
1002
+ @dataclasses.dataclass
1003
+ class Id:
1004
+ container_id: Environment.Id | Sandbox.Id
1005
+ feature_name: str
1006
+
1007
+ def __str__(self) -> str:
1008
+ return f'{self.container_id}/{self.feature_name}'
1009
+
1010
+ def working_dir(self, root_dir: str | None) -> str | None:
1011
+ """Returns the working directory for the feature."""
1012
+ if root_dir is None:
1013
+ return None
1014
+ return os.path.join(
1015
+ self.container_id.working_dir(root_dir),
1016
+ _make_path_compatible(self.feature_name)
1017
+ )
1018
+
1019
+ # Disable symbolic comparison and hashing for sandbox objects.
1020
+ allow_symbolic_comparison = False
685
1021
 
686
1022
  @property
687
1023
  @abc.abstractmethod
688
1024
  def name(self) -> str:
689
1025
  """Name of the feature, which will be used as key to access the feature."""
690
1026
 
1027
+ @functools.cached_property
1028
+ def id(self) -> Id:
1029
+ """Returns the identifier of the feature."""
1030
+ if self.is_sandbox_based:
1031
+ return Feature.Id(self.sandbox.id, self.name)
1032
+ return Feature.Id(self.environment.id, self.name)
1033
+
1034
+ @property
1035
+ @abc.abstractmethod
1036
+ def environment(self) -> Environment:
1037
+ """Returns the environment that the feature is running in."""
1038
+
1039
+ @property
1040
+ @abc.abstractmethod
1041
+ def is_sandbox_based(self) -> bool:
1042
+ """Returns True if the feature is sandbox-based."""
1043
+
691
1044
  @property
692
1045
  @abc.abstractmethod
693
- def sandbox(self) -> Sandbox:
1046
+ def sandbox(self) -> Sandbox | None:
694
1047
  """Returns the sandbox that the feature is running in.
695
1048
 
696
1049
  Returns:
697
- The sandbox that the feature is running in.
698
-
699
- Raises:
700
- AssertError: If the feature is not set up with a sandbox yet.
1050
+ The sandbox that the feature is running in. None if the feature is not
1051
+ sandbox-based or not yet bound with a sandbox.
701
1052
  """
702
1053
 
703
1054
  @abc.abstractmethod
704
- def setup(self, sandbox: Sandbox) -> None:
705
- """Sets up the feature, which is called once when the sandbox is up.
1055
+ def is_applicable(self, image_id: str) -> bool:
1056
+ """Returns True if the feature is applicable to the given image."""
1057
+
1058
+ @abc.abstractmethod
1059
+ def setup(self, sandbox: Sandbox | None = None) -> None:
1060
+ """Sets up the feature.
1061
+
1062
+ For sandbox-based features, the setup will be called when a sandbox is
1063
+ started for the first time.
1064
+
1065
+ For non-sandbox-based features, the setup will be called when the
1066
+ environment starts.
1067
+
1068
+ When a feature's `setup` is called, its `teardown` is guaranteed to be
1069
+ called.
706
1070
 
707
1071
  State transitions:
708
1072
  SETTING_UP -> READY: When setup succeeds.
@@ -805,11 +1169,54 @@ class Feature(pg.Object):
805
1169
  will not be housekeeping.
806
1170
  """
807
1171
 
1172
+ @abc.abstractmethod
1173
+ def track_activity(
1174
+ self,
1175
+ name: str,
1176
+ **kwargs: Any
1177
+ ) -> ContextManager[None]:
1178
+ """Context manager that tracks a feature activity.
1179
+
1180
+ Args:
1181
+ name: The name of the activity.
1182
+ **kwargs: Additional keyword arguments to pass to the activity handler.
1183
+
1184
+ Returns:
1185
+ A context manager that tracks the activity, including duration and error.
1186
+ """
1187
+
808
1188
  @property
809
1189
  def session_id(self) -> str | None:
810
1190
  """Returns the current user session identifier."""
811
- assert self.sandbox is not None
812
- return self.sandbox.session_id
1191
+ if self.is_sandbox_based:
1192
+ return self.sandbox.session_id
1193
+ return self._non_sandbox_based_session_id
1194
+
1195
+ @contextlib.contextmanager
1196
+ def new_session(self, session_id: str) -> Iterator['Feature']:
1197
+ """Context manager for obtaining a non-sandbox-based feature session."""
1198
+ assert not self.is_sandbox_based, (
1199
+ 'Applicable only to non-sandbox-based features. '
1200
+ 'For sandbox-based features, use `Sandbox.new_session` instead.'
1201
+ )
1202
+ try:
1203
+ self._non_sandbox_based_session_id = session_id
1204
+ self.setup_session()
1205
+ yield self
1206
+ finally:
1207
+ try:
1208
+ # Since the session is ended, we don't want to raise any errors during
1209
+ # session teardown to the user. So we catch all exceptions here.
1210
+ # However the event handler will still be notified and log the error.
1211
+ self.teardown_session()
1212
+ except BaseException: # pylint: disable=broad-except
1213
+ pass
1214
+ self._non_sandbox_based_session_id = None
1215
+
1216
+ def _on_bound(self) -> None:
1217
+ """Called when the feature is bound to a sandbox."""
1218
+ super()._on_bound()
1219
+ self._non_sandbox_based_session_id = None
813
1220
 
814
1221
 
815
1222
  def _make_path_compatible(id_str: str) -> str:
@@ -824,3 +1231,410 @@ def _make_path_compatible(id_str: str) -> str:
824
1231
  '>': '',
825
1232
  })
826
1233
  )
1234
+
1235
+
1236
+ def treat_as_sandbox_state_error(
1237
+ errors: Sequence[
1238
+ Type[BaseException] | tuple[Type[BaseException], str]
1239
+ ] | None = None
1240
+ ) -> Callable[..., Any]:
1241
+ """Decorator for Sandbox/Feature methods to convert errors to SandboxStateError.
1242
+
1243
+ Args:
1244
+ errors: A sequence of exception types or tuples of (error_type, msg_regex).
1245
+ when matched, treat the error as SandboxStateError, which will lead to
1246
+ a sandbox shutdown when caught by `Sandbox.new_session()` context manager.
1247
+
1248
+ Returns:
1249
+ The decorator function.
1250
+ """
1251
+
1252
+ def decorator(func):
1253
+ @functools.wraps(func)
1254
+ def method_wrapper(self, *args, **kwargs) -> Any:
1255
+ """Helper function to safely execute logics in the sandbox."""
1256
+
1257
+ assert isinstance(self, (Sandbox, Feature)), self
1258
+ sandbox = self.sandbox if isinstance(self, Feature) else self
1259
+
1260
+ try:
1261
+ # Execute the service function.
1262
+ return func(self, *args, **kwargs)
1263
+ except BaseException as e:
1264
+ if pg.match_error(e, errors):
1265
+ state_error = SandboxStateError(
1266
+ 'Sandbox encountered an unexpected error executing '
1267
+ f'`{func.__name__}` (args={args!r}, kwargs={kwargs!r}): {e}',
1268
+ sandbox=sandbox
1269
+ )
1270
+ raise state_error from e
1271
+ raise
1272
+ return method_wrapper
1273
+ return decorator
1274
+
1275
+
1276
+ def log_activity(name: str | None = None):
1277
+ """Decorator for Sandbox/Feature methods to log sandbox/feature activity."""
1278
+
1279
+ def decorator(func):
1280
+ signature = pg.typing.get_signature(func)
1281
+ def to_kwargs(*args, **kwargs):
1282
+ num_non_self_args = len(signature.arg_names) - 1
1283
+ if len(args) > num_non_self_args:
1284
+ assert signature.varargs is not None, (signature, args)
1285
+ kwargs[signature.varargs.name] = tuple(args[num_non_self_args:])
1286
+ args = args[:num_non_self_args]
1287
+ for i in range(len(args)):
1288
+ # The first argument is `self`.
1289
+ kwargs[signature.arg_names[i + 1]] = args[i]
1290
+ return kwargs
1291
+
1292
+ @functools.wraps(func)
1293
+ def method_wrapper(self, *args, **kwargs) -> Any:
1294
+ """Helper function to safely execute logics in the sandbox."""
1295
+
1296
+ assert isinstance(self, (Sandbox, Feature)), self
1297
+ with self.track_activity(
1298
+ name or func.__name__,
1299
+ **to_kwargs(*args, **kwargs)
1300
+ ):
1301
+ return func(self, *args, **kwargs)
1302
+ return method_wrapper
1303
+ return decorator
1304
+
1305
+
1306
+ #
1307
+ # Interface for event handlers.
1308
+ #
1309
+
1310
+
1311
+ class _EnvironmentEventHandler:
1312
+ """Base class for event handlers of an environment."""
1313
+
1314
+ def on_environment_starting(self, environment: Environment) -> None:
1315
+ """Called when the environment is getting started.
1316
+
1317
+ Args:
1318
+ environment: The environment.
1319
+ """
1320
+
1321
+ def on_environment_start(
1322
+ self,
1323
+ environment: Environment,
1324
+ duration: float,
1325
+ error: BaseException | None
1326
+ ) -> None:
1327
+ """Called when the environment is started.
1328
+
1329
+ Args:
1330
+ environment: The environment.
1331
+ duration: The environment start duration in seconds.
1332
+ error: The error that failed the environment start. If None, the
1333
+ environment started normally.
1334
+ """
1335
+
1336
+ def on_environment_housekeep(
1337
+ self,
1338
+ environment: Environment,
1339
+ counter: int,
1340
+ duration: float,
1341
+ error: BaseException | None,
1342
+ **kwargs
1343
+ ) -> None:
1344
+ """Called when the environment finishes a round of housekeeping.
1345
+
1346
+ Args:
1347
+ environment: The environment.
1348
+ counter: Zero-based counter of the housekeeping round.
1349
+ duration: The environment start duration in seconds.
1350
+ error: The error that failed the housekeeping. If None, the
1351
+ housekeeping succeeded.
1352
+ **kwargs: Environment-specific properties computed during housekeeping.
1353
+ """
1354
+
1355
+ def on_environment_shutting_down(
1356
+ self,
1357
+ environment: Environment,
1358
+ offline_duration: float,
1359
+ ) -> None:
1360
+ """Called when the environment is shutting down.
1361
+
1362
+ Args:
1363
+ environment: The environment.
1364
+ offline_duration: The environment offline duration in seconds.
1365
+ """
1366
+
1367
+ def on_environment_shutdown(
1368
+ self,
1369
+ environment: Environment,
1370
+ duration: float,
1371
+ lifetime: float,
1372
+ error: BaseException | None
1373
+ ) -> None:
1374
+ """Called when the environment is shutdown.
1375
+
1376
+ Args:
1377
+ environment: The environment.
1378
+ duration: The environment shutdown duration in seconds.
1379
+ lifetime: The environment lifetime in seconds.
1380
+ error: The error that caused the environment to shutdown. If None, the
1381
+ environment shutdown normally.
1382
+ """
1383
+
1384
+
1385
+ class _SandboxEventHandler:
1386
+ """Base class for sandbox event handlers."""
1387
+
1388
+ def on_sandbox_start(
1389
+ self,
1390
+ sandbox: Sandbox,
1391
+ duration: float,
1392
+ error: BaseException | None
1393
+ ) -> None:
1394
+ """Called when a sandbox is started.
1395
+
1396
+ Args:
1397
+ sandbox: The sandbox.
1398
+ duration: The time spent on starting the sandbox.
1399
+ error: The error that caused the sandbox to start. If None, the sandbox
1400
+ started normally.
1401
+ """
1402
+
1403
+ def on_sandbox_status_change(
1404
+ self,
1405
+ sandbox: Sandbox,
1406
+ old_status: 'Sandbox.Status',
1407
+ new_status: 'Sandbox.Status',
1408
+ span: float,
1409
+ ) -> None:
1410
+ """Called when a sandbox status changes.
1411
+
1412
+ Args:
1413
+ sandbox: The sandbox.
1414
+ old_status: The old sandbox status.
1415
+ new_status: The new sandbox status.
1416
+ span: Time spent on the old status in seconds.
1417
+ """
1418
+
1419
+ def on_sandbox_shutdown(
1420
+ self,
1421
+ sandbox: Sandbox,
1422
+ duration: float,
1423
+ lifetime: float,
1424
+ error: BaseException | None
1425
+ ) -> None:
1426
+ """Called when a sandbox is shutdown.
1427
+
1428
+ Args:
1429
+ sandbox: The sandbox.
1430
+ duration: The time spent on shutting down the sandbox.
1431
+ lifetime: The sandbox lifetime in seconds.
1432
+ error: The error that caused the sandbox to shutdown. If None, the
1433
+ sandbox shutdown normally.
1434
+ """
1435
+
1436
+ def on_sandbox_session_start(
1437
+ self,
1438
+ sandbox: Sandbox,
1439
+ session_id: str,
1440
+ duration: float,
1441
+ error: BaseException | None
1442
+ ) -> None:
1443
+ """Called when a sandbox session starts.
1444
+
1445
+ Args:
1446
+ sandbox: The sandbox.
1447
+ session_id: The session ID.
1448
+ duration: The time spent on starting the session.
1449
+ error: The error that caused the session to start. If None, the session
1450
+ started normally.
1451
+ """
1452
+
1453
+ def on_sandbox_session_end(
1454
+ self,
1455
+ sandbox: Sandbox,
1456
+ session_id: str,
1457
+ duration: float,
1458
+ lifetime: float,
1459
+ error: BaseException | None
1460
+ ) -> None:
1461
+ """Called when a sandbox session ends.
1462
+
1463
+ Args:
1464
+ sandbox: The sandbox.
1465
+ session_id: The session ID.
1466
+ duration: The time spent on ending the session.
1467
+ lifetime: The session lifetime in seconds.
1468
+ error: The error that caused the session to end. If None, the session
1469
+ ended normally.
1470
+ """
1471
+
1472
+ def on_sandbox_activity(
1473
+ self,
1474
+ name: str,
1475
+ sandbox: Sandbox,
1476
+ session_id: str | None,
1477
+ duration: float,
1478
+ error: BaseException | None,
1479
+ **kwargs
1480
+ ) -> None:
1481
+ """Called when a sandbox activity is performed.
1482
+
1483
+ Args:
1484
+ name: The name of the sandbox activity.
1485
+ sandbox: The sandbox.
1486
+ session_id: The session ID.
1487
+ duration: The sandbox activity duration in seconds.
1488
+ error: The error that caused the sandbox activity to perform. If None,
1489
+ the sandbox activity performed normally.
1490
+ **kwargs: The keyword arguments of the sandbox activity.
1491
+ """
1492
+
1493
+ def on_sandbox_housekeep(
1494
+ self,
1495
+ sandbox: Sandbox,
1496
+ counter: int,
1497
+ duration: float,
1498
+ error: BaseException | None,
1499
+ **kwargs
1500
+ ) -> None:
1501
+ """Called when a sandbox finishes a round of housekeeping.
1502
+
1503
+ Args:
1504
+ sandbox: The sandbox.
1505
+ counter: Zero-based counter of the housekeeping round.
1506
+ duration: The sandbox housekeeping duration in seconds.
1507
+ error: The error that caused the sandbox to housekeeping. If None, the
1508
+ sandbox housekeeping normally.
1509
+ **kwargs: Sandbox-specific properties computed during housekeeping.
1510
+ """
1511
+
1512
+
1513
+ class _FeatureEventHandler:
1514
+ """Base class for feature event handlers."""
1515
+
1516
+ def on_feature_setup(
1517
+ self,
1518
+ feature: Feature,
1519
+ duration: float,
1520
+ error: BaseException | None
1521
+ ) -> None:
1522
+ """Called when a sandbox feature is setup.
1523
+
1524
+ Applicable to both sandbox-based and non-sandbox-based features.
1525
+
1526
+ Args:
1527
+ feature: The feature.
1528
+ duration: The feature setup duration in seconds.
1529
+ error: The error happened during the feature setup. If None,
1530
+ the feature setup performed normally.
1531
+ """
1532
+
1533
+ def on_feature_teardown(
1534
+ self,
1535
+ feature: Feature,
1536
+ duration: float,
1537
+ error: BaseException | None
1538
+ ) -> None:
1539
+ """Called when a sandbox feature is teardown.
1540
+
1541
+ Applicable to both sandbox-based and non-sandbox-based features.
1542
+
1543
+ Args:
1544
+ feature: The feature.
1545
+ duration: The feature teardown duration in seconds.
1546
+ error: The error happened during the feature teardown. If None,
1547
+ the feature teardown performed normally.
1548
+ """
1549
+
1550
+ def on_feature_teardown_session(
1551
+ self,
1552
+ feature: Feature,
1553
+ session_id: str,
1554
+ duration: float,
1555
+ error: BaseException | None
1556
+ ) -> None:
1557
+ """Called when a feature is teardown with a session.
1558
+
1559
+ Applicable to both sandbox-based and non-sandbox-based features.
1560
+
1561
+ Args:
1562
+ feature: The feature.
1563
+ session_id: The session ID.
1564
+ duration: The feature teardown session duration in seconds.
1565
+ error: The error happened during the feature teardown session. If
1566
+ None, the feature teardown session performed normally.
1567
+ """
1568
+
1569
+ def on_feature_setup_session(
1570
+ self,
1571
+ feature: Feature,
1572
+ session_id: str | None,
1573
+ duration: float,
1574
+ error: BaseException | None,
1575
+ ) -> None:
1576
+ """Called when a feature is setup with a session.
1577
+
1578
+ Applicable to both sandbox-based and non-sandbox-based features.
1579
+
1580
+ Args:
1581
+ feature: The feature.
1582
+ session_id: The session ID.
1583
+ duration: The feature setup session duration in seconds.
1584
+ error: The error happened during the feature setup session. If
1585
+ None, the feature setup session performed normally.
1586
+ """
1587
+
1588
+ def on_feature_activity(
1589
+ self,
1590
+ name: str,
1591
+ feature: Feature,
1592
+ session_id: str | None,
1593
+ duration: float,
1594
+ error: BaseException | None,
1595
+ **kwargs
1596
+ ) -> None:
1597
+ """Called when a feature activity is performed.
1598
+
1599
+ Applicable to both sandbox-based and non-sandbox-based features.
1600
+
1601
+ Args:
1602
+ name: The name of the feature activity.
1603
+ feature: The feature.
1604
+ session_id: The session ID. Session ID could be None if a feature
1605
+ activity is performed when setting up a session
1606
+ (e.g. BaseEnvironment.proactive_session_setup is on)
1607
+ duration: The feature activity duration in seconds.
1608
+ error: The error happened during the feature activity. If None,
1609
+ the feature activity performed normally.
1610
+ **kwargs: The keyword arguments of the feature activity.
1611
+ """
1612
+
1613
+ def on_feature_housekeep(
1614
+ self,
1615
+ feature: Feature,
1616
+ counter: int,
1617
+ duration: float,
1618
+ error: BaseException | None,
1619
+ **kwargs,
1620
+ ) -> None:
1621
+ """Called when a sandbox feature is housekeeping.
1622
+
1623
+ Applicable to both sandbox-based and non-sandbox-based features.
1624
+
1625
+ Args:
1626
+ feature: The feature.
1627
+ counter: Zero-based counter of the housekeeping round.
1628
+ duration: The feature housekeeping duration in seconds.
1629
+ error: The error happened during the feature housekeeping. If None, the
1630
+ feature housekeeping normally.
1631
+ **kwargs: Feature-specific properties computed during housekeeping.
1632
+ """
1633
+
1634
+
1635
+ class EventHandler(
1636
+ _EnvironmentEventHandler,
1637
+ _SandboxEventHandler,
1638
+ _FeatureEventHandler,
1639
+ ):
1640
+ """Base class for langfun/env handlers."""