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
@@ -23,8 +23,10 @@ Note that:
23
23
  """
24
24
 
25
25
  import abc
26
+ import collections
26
27
  import functools
27
28
  import random
29
+ import re
28
30
  import threading
29
31
  import time
30
32
  from typing import Annotated, Any
@@ -34,7 +36,6 @@ import langfun.core as lf
34
36
  from langfun.env import base_sandbox
35
37
  from langfun.env import interface
36
38
  from langfun.env import load_balancers
37
- from langfun.env.event_handlers import base as event_handler_base
38
39
  import pyglove as pg
39
40
 
40
41
 
@@ -46,6 +47,23 @@ class BaseEnvironment(interface.Environment):
46
47
  maintenance.
47
48
  """
48
49
 
50
+ image_ids: Annotated[
51
+ list[str],
52
+ (
53
+ 'A list of static image IDs served by the environment. '
54
+ )
55
+ ]
56
+
57
+ supports_dynamic_image_loading: Annotated[
58
+ bool,
59
+ (
60
+ 'Whether the environment supports dynamic loading of images which is '
61
+ 'not included in the `image_ids`. `image_ids` could coexist with '
62
+ 'dynamic image loading, which allows users to specify an image id '
63
+ 'that is not included in the `image_ids`.'
64
+ )
65
+ ] = False
66
+
49
67
  root_dir: Annotated[
50
68
  str | None,
51
69
  (
@@ -55,11 +73,15 @@ class BaseEnvironment(interface.Environment):
55
73
  ] = None
56
74
 
57
75
  pool_size: Annotated[
58
- int | tuple[int, int],
76
+ int | tuple[int, int] | dict[str, int | tuple[int, int]],
59
77
  (
60
78
  'The (min_size, max_size) of the sandbox pool. If an integer, it '
61
- 'will be used as both min and max size. If 0, sandboxes will be '
62
- 'created on demand and shutdown when user session ends.'
79
+ 'will be used as both min and max size. If 0, all sandboxes will be '
80
+ 'created on demand and shutdown when user session ends. If a dict, '
81
+ 'users could configure the pool size based on image IDs. The keys '
82
+ 'are regular expressions for image IDs, and the values are '
83
+ '(min_size, max_size) tuples. For dynamic image IDs, min_size will '
84
+ 'ignored while max_size will be honored.'
63
85
  )
64
86
  ] = (0, 256)
65
87
 
@@ -90,12 +112,13 @@ class BaseEnvironment(interface.Environment):
90
112
  )
91
113
  ] = True
92
114
 
93
- event_handlers: Annotated[
94
- list[event_handler_base.EventHandler],
115
+ event_handler: Annotated[
116
+ interface.EventHandler,
95
117
  (
96
118
  'User handler for the environment events.'
119
+ 'By default, the no-op event handler is used.'
97
120
  )
98
- ] = []
121
+ ] = interface.EventHandler()
99
122
 
100
123
  outage_grace_period: Annotated[
101
124
  float,
@@ -146,14 +169,39 @@ class BaseEnvironment(interface.Environment):
146
169
 
147
170
  self._status = self.Status.CREATED
148
171
  self._start_time = None
149
- self._sandbox_pool = []
150
- self._next_pooled_sandbox_id = 0
172
+
173
+ self._sandbox_pool: dict[str, list[base_sandbox.BaseSandbox]] = (
174
+ collections.defaultdict(list)
175
+ )
176
+ self._next_sandbox_id: dict[str, int] = collections.defaultdict(int)
151
177
  self._random = (
152
178
  random if self.random_seed is None else random.Random(self.random_seed)
153
179
  )
154
-
155
180
  self._housekeep_thread = None
156
181
  self._offline_start_time = None
182
+ self._non_sandbox_based_features_with_setup_called = set()
183
+
184
+ # Check image IDs and feature requirements.
185
+ self._check_image_ids()
186
+ self._check_feature_requirements()
187
+
188
+ def _check_image_ids(self) -> None:
189
+ """Checks image ids. Subclass could override this method."""
190
+
191
+ def _check_feature_requirements(self) -> None:
192
+ """Checks if the image ID is supported by the feature."""
193
+ if self.supports_dynamic_image_loading:
194
+ return
195
+ for name, feature in self.features.items():
196
+ if not feature.is_sandbox_based or any(
197
+ feature.is_applicable(image_id) for image_id in self.image_ids
198
+ ):
199
+ continue
200
+ raise ValueError(
201
+ f'Feature {name!r} is not applicable to all available images: '
202
+ f'{self.image_ids!r}. '
203
+ f'Applicable images: {feature.applicable_images}.'
204
+ )
157
205
 
158
206
  #
159
207
  # Subclasses must implement:
@@ -162,6 +210,7 @@ class BaseEnvironment(interface.Environment):
162
210
  @abc.abstractmethod
163
211
  def _create_sandbox(
164
212
  self,
213
+ image_id: str,
165
214
  sandbox_id: str,
166
215
  reusable: bool,
167
216
  proactive_session_setup: bool,
@@ -170,6 +219,7 @@ class BaseEnvironment(interface.Environment):
170
219
  """Creates a sandbox with the given identifier.
171
220
 
172
221
  Args:
222
+ image_id: The image ID to use for the sandbox.
173
223
  sandbox_id: The identifier for the sandbox.
174
224
  reusable: Whether the sandbox is reusable across user sessions.
175
225
  proactive_session_setup: Whether the sandbox performs session setup work
@@ -185,13 +235,13 @@ class BaseEnvironment(interface.Environment):
185
235
  interface.SandboxStateError: If sandbox cannot be started.
186
236
  """
187
237
 
188
- def new_session_id(self) -> str:
238
+ def new_session_id(self, feature_hint: str | None = None) -> str:
189
239
  """Generates a random session ID."""
190
240
  suffix = uuid.UUID(
191
241
  bytes=bytes(bytes(self._random.getrandbits(8) for _ in range(16))),
192
242
  version=4
193
243
  ).hex[:7]
194
- return f'session-{suffix}'
244
+ return f'{feature_hint or "unknown"}-session-{suffix}'
195
245
 
196
246
  @property
197
247
  def housekeep_counter(self) -> int:
@@ -204,42 +254,66 @@ class BaseEnvironment(interface.Environment):
204
254
 
205
255
  def stats(self) -> dict[str, Any]:
206
256
  """Returns the stats of the environment."""
207
- stats_dict = {
208
- status.value: 0
209
- for status in interface.Sandbox.Status
210
- }
211
- for sandbox in self._sandbox_pool:
212
- stats_dict[sandbox.status.value] += 1
257
+ stats_by_image_id = {}
258
+ for image_id, sandboxes in self._sandbox_pool.items():
259
+ stats_dict = {
260
+ status.value: 0
261
+ for status in interface.Sandbox.Status
262
+ }
263
+ for sandbox in sandboxes:
264
+ stats_dict[sandbox.status.value] += 1
265
+ stats_by_image_id[image_id] = stats_dict
213
266
  return {
214
- 'sandbox': stats_dict,
267
+ 'sandbox': stats_by_image_id,
215
268
  }
216
269
 
217
270
  def _start(self) -> None:
218
271
  """Implementation of starting the environment."""
219
- if self.min_pool_size > 0:
220
- # Pre-allocate the sandbox pool before usage.
221
- self._sandbox_pool = [None] * self.min_pool_size
222
- for i, sandbox, _ in lf.concurrent_map(
223
- lambda i: self._bring_up_sandbox_with_retry(
224
- sandbox_id=f'{i}:0', shutdown_env_upon_outage=False
225
- ),
226
- range(self.min_pool_size),
227
- silence_on_errors=None,
228
- max_workers=min(
229
- self.pool_operation_max_parallelism,
230
- self.min_pool_size
231
- ),
232
- ):
233
- self._sandbox_pool[i] = sandbox
234
-
235
- self._next_sandbox_id = len(self._sandbox_pool)
272
+ sandbox_startup_infos = []
273
+ self._non_sandbox_based_features_with_setup_called.clear()
274
+ # Setup all non-sandbox-based features.
275
+ for feature in self.non_sandbox_based_features():
276
+ self._non_sandbox_based_features_with_setup_called.add(feature.name)
277
+ feature.setup(sandbox=None)
278
+
279
+ # Setup sandbox pools.
280
+ for image_id in self.image_ids:
281
+ next_sandbox_id = 0
282
+ if self.enable_pooling(image_id):
283
+ min_pool_size = self.min_pool_size(image_id)
284
+ for i in range(min_pool_size):
285
+ sandbox_startup_infos.append((image_id, i))
286
+ self._sandbox_pool[image_id] = [None] * min_pool_size
287
+ next_sandbox_id = min_pool_size
288
+ self._next_sandbox_id[image_id] = next_sandbox_id
289
+
290
+ def _start_sandbox(sandbox_startup_info) -> None:
291
+ image_id, index = sandbox_startup_info
292
+ self._sandbox_pool[image_id][index] = self._bring_up_sandbox_with_retry(
293
+ image_id=image_id,
294
+ sandbox_id=f'{index}:0',
295
+ shutdown_env_upon_outage=False
296
+ )
236
297
 
237
- if self.enable_pooling:
238
- self._housekeep_thread = threading.Thread(
239
- target=self._housekeep_loop, daemon=True
298
+ if sandbox_startup_infos:
299
+ # Pre-allocate the sandbox pool before usage.
300
+ _ = list(
301
+ lf.concurrent_map(
302
+ _start_sandbox,
303
+ sandbox_startup_infos,
304
+ silence_on_errors=None,
305
+ max_workers=min(
306
+ self.pool_operation_max_parallelism,
307
+ len(sandbox_startup_infos)
308
+ ),
309
+ )
240
310
  )
241
- self._housekeep_counter = 0
242
- self._housekeep_thread.start()
311
+
312
+ self._housekeep_thread = threading.Thread(
313
+ target=self._housekeep_loop, daemon=True
314
+ )
315
+ self._housekeep_counter = 0
316
+ self._housekeep_thread.start()
243
317
 
244
318
  def _shutdown(self) -> None:
245
319
  """Implementation of shutting down the environment."""
@@ -248,30 +322,44 @@ class BaseEnvironment(interface.Environment):
248
322
  self._housekeep_thread.join()
249
323
  self._housekeep_thread = None
250
324
 
251
- def _shutdown_sandbox(sandbox: base_sandbox.BaseSandbox) -> None:
252
- if sandbox is not None:
253
- sandbox.shutdown()
325
+ # Teardown all non-sandbox-based features.
326
+ for feature in self.non_sandbox_based_features():
327
+ if feature.name in self._non_sandbox_based_features_with_setup_called:
328
+ try:
329
+ feature.teardown()
330
+ except BaseException: # pylint: disable=broad-except
331
+ pass
254
332
 
333
+ # Shutdown sandbox pools.
255
334
  if self._sandbox_pool:
256
- _ = list(
257
- lf.concurrent_map(
258
- _shutdown_sandbox,
259
- self._sandbox_pool,
260
- silence_on_errors=None,
261
- max_workers=min(
262
- self.pool_operation_max_parallelism,
263
- len(self._sandbox_pool)
264
- ),
265
- )
266
- )
267
- self._sandbox_pool = []
335
+ sandboxes = []
336
+ for sandbox in self._sandbox_pool.values():
337
+ sandboxes.extend(sandbox)
338
+ self._sandbox_pool = {}
339
+
340
+ if sandboxes:
341
+ def _shutdown_sandbox(sandbox: base_sandbox.BaseSandbox) -> None:
342
+ if sandbox is not None:
343
+ sandbox.shutdown()
344
+
345
+ _ = list(
346
+ lf.concurrent_map(
347
+ _shutdown_sandbox,
348
+ sandboxes,
349
+ silence_on_errors=None,
350
+ max_workers=min(
351
+ self.pool_operation_max_parallelism,
352
+ len(sandboxes)
353
+ ),
354
+ )
355
+ )
268
356
 
269
357
  #
270
358
  # Environment basics.
271
359
  #
272
360
 
273
361
  @property
274
- def sandbox_pool(self) -> list[base_sandbox.BaseSandbox]:
362
+ def sandbox_pool(self) -> dict[str, list[base_sandbox.BaseSandbox]]:
275
363
  """Returns the sandbox pool."""
276
364
  return self._sandbox_pool
277
365
 
@@ -280,11 +368,6 @@ class BaseEnvironment(interface.Environment):
280
368
  """Returns the working directory for the environment."""
281
369
  return self.id.working_dir(self.root_dir)
282
370
 
283
- @property
284
- def enable_pooling(self) -> bool:
285
- """Returns whether the environment enables pooling."""
286
- return self.max_pool_size > 0
287
-
288
371
  @property
289
372
  def status(self) -> interface.Environment.Status:
290
373
  """Returns whether the environment is online."""
@@ -294,19 +377,39 @@ class BaseEnvironment(interface.Environment):
294
377
  """Sets the status of the environment."""
295
378
  self._status = status
296
379
 
297
- @property
298
- def min_pool_size(self) -> int:
380
+ def enable_pooling(self, image_id: str) -> bool:
381
+ """Returns whether the environment enables pooling."""
382
+ return self.max_pool_size(image_id) > 0
383
+
384
+ def min_pool_size(self, image_id: str) -> int:
299
385
  """Returns the minimum size of the sandbox pool."""
300
- if isinstance(self.pool_size, int):
301
- return self.pool_size
302
- return self.pool_size[0]
386
+ return self._pool_size(image_id)[0]
303
387
 
304
- @property
305
- def max_pool_size(self) -> int:
388
+ def max_pool_size(self, image_id: str) -> int:
306
389
  """Returns the maximum size of the sandbox pool."""
307
- if isinstance(self.pool_size, int):
308
- return self.pool_size
309
- return self.pool_size[1]
390
+ return self._pool_size(image_id)[1]
391
+
392
+ def _pool_size(self, image_id: str) -> tuple[int, int]:
393
+ """Returns the minimum and maximum size of the sandbox pool."""
394
+ if isinstance(self.pool_size, dict):
395
+ if image_id in self.pool_size:
396
+ pool_size = self.pool_size[image_id]
397
+ else:
398
+ for k, v in self.pool_size.items():
399
+ if re.match(k, image_id):
400
+ pool_size = v
401
+ break
402
+ else:
403
+ # Default pool size is 0 and 256.
404
+ pool_size = (0, 256)
405
+ else:
406
+ pool_size = self.pool_size
407
+
408
+ if isinstance(pool_size, int):
409
+ return pool_size, pool_size
410
+ else:
411
+ assert isinstance(pool_size, tuple) and len(pool_size) == 2
412
+ return pool_size
310
413
 
311
414
  @property
312
415
  def start_time(self) -> float | None:
@@ -373,9 +476,16 @@ class BaseEnvironment(interface.Environment):
373
476
  # Environment operations.
374
477
  #
375
478
 
376
- def acquire(self) -> base_sandbox.BaseSandbox:
479
+ def acquire(
480
+ self,
481
+ image_id: str | None = None
482
+ ) -> base_sandbox.BaseSandbox:
377
483
  """Acquires a sandbox from the environment.
378
484
 
485
+ Args:
486
+ image_id: The image ID to use for the sandbox. If None, it will be
487
+ automatically determined by the environment.
488
+
379
489
  Returns:
380
490
  The acquired sandbox.
381
491
 
@@ -385,28 +495,50 @@ class BaseEnvironment(interface.Environment):
385
495
  interface.EnvironmentOverloadError: If the max pool size is reached and
386
496
  the grace period has passed.
387
497
  """
388
-
389
498
  if not self.is_online:
390
499
  raise interface.EnvironmentOutageError(
391
500
  f'Environment {self.id} is not alive.',
392
501
  environment=self,
393
502
  offline_duration=self.offline_duration,
394
503
  )
504
+ if image_id is None:
505
+ if not self.image_ids:
506
+ raise ValueError(
507
+ f'Environment {self.id} does not have a default image ID. '
508
+ 'Please specify the image ID explicitly.'
509
+ )
510
+ image_id = self.image_ids[0]
511
+ elif (image_id not in self.image_ids
512
+ and not self.supports_dynamic_image_loading):
513
+ raise ValueError(
514
+ f'Environment {self.id} does not serve image ID {image_id!r}. '
515
+ f'Please use one of the following image IDs: {self.image_ids!r} or '
516
+ f'set `{self.__class__.__name__}.supports_dynamic_image_loading` '
517
+ 'to True if dynamic image loading is supported.'
518
+ )
519
+ return self._acquire(image_id)
395
520
 
396
- if not self.enable_pooling:
521
+ def _acquire(
522
+ self,
523
+ image_id: str | None = None
524
+ ) -> base_sandbox.BaseSandbox:
525
+ """Acquires a sandbox from the environment."""
526
+ if not self.enable_pooling(image_id):
397
527
  return self._bring_up_sandbox_with_retry(
398
- sandbox_id=str(self._increment_sandbox_id()),
528
+ image_id=image_id,
529
+ sandbox_id=str(self._increment_sandbox_id(image_id)),
399
530
  set_acquired=True,
400
531
  )
401
532
 
402
533
  allocation_start_time = time.time()
534
+ sandbox_pool = self._sandbox_pool[image_id]
403
535
  while True:
404
536
  try:
405
537
  # We only append or replace items in the sandbox pool, therefore
406
538
  # there is no need to lock the pool.
407
- return self.load_balancer.acquire(self._sandbox_pool)
539
+ return self.load_balancer.acquire(sandbox_pool)
408
540
  except IndexError:
409
- if len(self._sandbox_pool) == self.max_pool_size:
541
+ if len(sandbox_pool) == self.max_pool_size(image_id):
410
542
  if time.time() - allocation_start_time > self.outage_grace_period:
411
543
  raise interface.EnvironmentOverloadError( # pylint: disable=raise-missing-from
412
544
  environment=self
@@ -415,11 +547,12 @@ class BaseEnvironment(interface.Environment):
415
547
  else:
416
548
  try:
417
549
  sandbox = self._bring_up_sandbox(
418
- sandbox_id=f'{self._increment_sandbox_id()}:0',
550
+ image_id=image_id,
551
+ sandbox_id=f'{self._increment_sandbox_id(image_id)}:0',
419
552
  set_acquired=True,
420
553
  )
421
554
  # Append is atomic and does not require locking.
422
- self._sandbox_pool.append(sandbox)
555
+ sandbox_pool.append(sandbox)
423
556
  return sandbox
424
557
  except (
425
558
  interface.EnvironmentError, interface.SandboxStateError
@@ -428,6 +561,7 @@ class BaseEnvironment(interface.Environment):
428
561
 
429
562
  def _bring_up_sandbox(
430
563
  self,
564
+ image_id: str,
431
565
  sandbox_id: str,
432
566
  set_acquired: bool = False,
433
567
  ) -> base_sandbox.BaseSandbox:
@@ -435,13 +569,12 @@ class BaseEnvironment(interface.Environment):
435
569
  env_error = None
436
570
  try:
437
571
  sandbox = self._create_sandbox(
572
+ image_id=image_id,
438
573
  sandbox_id=sandbox_id,
439
- reusable=self.enable_pooling,
574
+ reusable=self.enable_pooling(image_id),
440
575
  proactive_session_setup=self.proactive_session_setup,
441
576
  keepalive_interval=self.sandbox_keepalive_interval,
442
577
  )
443
- for handler in self.event_handlers:
444
- sandbox.add_event_handler(handler)
445
578
  sandbox.start()
446
579
  if set_acquired:
447
580
  sandbox.set_acquired()
@@ -457,6 +590,7 @@ class BaseEnvironment(interface.Environment):
457
590
 
458
591
  def _bring_up_sandbox_with_retry(
459
592
  self,
593
+ image_id: str,
460
594
  sandbox_id: str,
461
595
  set_acquired: bool = False,
462
596
  shutdown_env_upon_outage: bool = True,
@@ -464,6 +598,7 @@ class BaseEnvironment(interface.Environment):
464
598
  """Brings up a new sandbox with retry until grace period is passed.
465
599
 
466
600
  Args:
601
+ image_id: The image ID to use for the sandbox.
467
602
  sandbox_id: The ID of the sandbox to bring up.
468
603
  set_acquired: If True, the sandbox will be marked as acquired.
469
604
  shutdown_env_upon_outage: Whether to shutdown the environment when the
@@ -479,15 +614,15 @@ class BaseEnvironment(interface.Environment):
479
614
  while True:
480
615
  try:
481
616
  return self._bring_up_sandbox(
482
- sandbox_id=sandbox_id, set_acquired=set_acquired
617
+ image_id=image_id, sandbox_id=sandbox_id, set_acquired=set_acquired
483
618
  )
484
619
  except (interface.EnvironmentError, interface.SandboxStateError) as e:
485
620
  self._report_outage_or_wait(e, shutdown_env_upon_outage)
486
621
 
487
- def _increment_sandbox_id(self) -> int:
622
+ def _increment_sandbox_id(self, image_id: str) -> int:
488
623
  """Returns the next pooled sandbox ID."""
489
- x = self._next_sandbox_id
490
- self._next_sandbox_id += 1
624
+ x = self._next_sandbox_id[image_id]
625
+ self._next_sandbox_id[image_id] += 1
491
626
  return x
492
627
 
493
628
  def _report_outage_or_wait(
@@ -511,33 +646,83 @@ class BaseEnvironment(interface.Environment):
511
646
 
512
647
  def _housekeep_loop(self) -> None:
513
648
  """Housekeeping loop for the environment."""
649
+ def _indices_by_image_id(
650
+ entries: list[tuple[str, int, Any]]
651
+ ) -> dict[str, list[int]]:
652
+ indices_by_image_id = collections.defaultdict(list)
653
+ for image_id, i, _ in entries:
654
+ indices_by_image_id[image_id].append(i)
655
+ return indices_by_image_id
656
+
657
+ last_housekeep_time = {
658
+ f.name: time.time() for f in self.non_sandbox_based_features()
659
+ }
660
+
514
661
  while self._status not in (self.Status.SHUTTING_DOWN, self.Status.OFFLINE):
515
662
  housekeep_start_time = time.time()
663
+ feature_housekeep_successes = []
664
+ feature_housekeep_failures = []
665
+
666
+ # Housekeeping non-sandbox-based features.
667
+ for feature in self.non_sandbox_based_features():
668
+ if feature.housekeep_interval is None:
669
+ continue
670
+ if (last_housekeep_time[feature.name]
671
+ + feature.housekeep_interval < time.time()):
672
+ try:
673
+ feature.housekeep()
674
+ last_housekeep_time[feature.name] = time.time()
675
+ feature_housekeep_successes.append(feature.name)
676
+ except BaseException as e: # pylint: disable=broad-except
677
+ pg.logging.error(
678
+ '[%s/%s]: Feature housekeeping failed with error: %s.'
679
+ 'Shutting down environment...',
680
+ self.id,
681
+ feature.name,
682
+ e,
683
+ )
684
+ feature_housekeep_failures.append(feature.name)
685
+ self._housekeep_counter += 1
686
+ self.on_housekeep(
687
+ duration=time.time() - housekeep_start_time,
688
+ error=e,
689
+ feature_housekeep_successes=feature_housekeep_successes,
690
+ feature_housekeep_failures=feature_housekeep_failures,
691
+ )
692
+ self.shutdown()
693
+ return
516
694
 
695
+ # Replace dead sandboxes.
517
696
  is_online = True
518
- dead_pool_indices = [
519
- i for i, s in enumerate(self._sandbox_pool)
520
- if s.status == interface.Sandbox.Status.OFFLINE
521
- ]
522
- replaced_indices = []
523
-
524
- if dead_pool_indices:
525
- replaced_indices = self._replace_dead_sandboxes(dead_pool_indices)
526
- if not replaced_indices:
697
+ dead_sandbox_entries = []
698
+ for image_id, sandboxes in self._sandbox_pool.items():
699
+ for i, sandbox in enumerate(sandboxes):
700
+ if sandbox.status == interface.Sandbox.Status.OFFLINE:
701
+ dead_sandbox_entries.append((image_id, i, sandbox))
702
+
703
+ replaced_indices_by_image_id = {}
704
+
705
+ if dead_sandbox_entries:
706
+ replaced_indices_by_image_id = self._replace_dead_sandboxes(
707
+ dead_sandbox_entries
708
+ )
709
+ if not replaced_indices_by_image_id:
527
710
  is_online = self.offline_duration < self.outage_grace_period
528
711
 
529
712
  self._housekeep_counter += 1
530
713
  duration = time.time() - housekeep_start_time
714
+
531
715
  kwargs = dict(
532
- dead_pool_indices=dead_pool_indices,
533
- replaced_indices=replaced_indices,
716
+ feature_housekeep_successes=feature_housekeep_successes,
717
+ feature_housekeep_failures=feature_housekeep_failures,
718
+ dead_sandboxes=_indices_by_image_id(dead_sandbox_entries),
719
+ replaced_sandboxes=replaced_indices_by_image_id,
534
720
  offline_duration=self.offline_duration,
535
721
  )
536
722
  if is_online:
537
723
  self.on_housekeep(duration, **kwargs)
538
724
  time.sleep(self.housekeep_interval)
539
725
  else:
540
- self.shutdown()
541
726
  self.on_housekeep(
542
727
  duration,
543
728
  interface.EnvironmentOutageError(
@@ -545,50 +730,63 @@ class BaseEnvironment(interface.Environment):
545
730
  ),
546
731
  **kwargs
547
732
  )
733
+ self.shutdown()
548
734
 
549
- def _replace_dead_sandboxes(self, dead_pool_indices: list[int]) -> list[int]:
735
+ def _replace_dead_sandboxes(
736
+ self,
737
+ dead_sandbox_entries: list[tuple[str, int, base_sandbox.BaseSandbox]]
738
+ ) -> dict[str, list[int]]:
550
739
  """Replaces a dead sandbox with a new one.
551
740
 
552
741
  Args:
553
- dead_pool_indices: The indices of the dead sandboxes to replace.
742
+ dead_sandbox_entries: A list of tuples (image_id, index, sandbox) of
743
+ dead sandboxes to replace.
554
744
 
555
745
  Returns:
556
- Successfully replaced indices.
746
+ Successfully replaced sandboxes in a dict of image ID to a list of
747
+ indices.
557
748
  """
558
749
  pg.logging.warning(
559
750
  '[%s]: %s maintenance: '
560
751
  'Replacing %d dead sandbox(es) with new ones...',
561
752
  self.id,
562
753
  self.__class__.__name__,
563
- len(dead_pool_indices),
754
+ len(dead_sandbox_entries),
564
755
  )
565
- def _replace(i: int):
566
- generation = int(self._sandbox_pool[i].id.sandbox_id.split(':')[1])
567
- self._sandbox_pool[i] = self._bring_up_sandbox(f'{i}:{generation + 1}')
756
+ def _replace(sandbox_entry: tuple[str, int, base_sandbox.BaseSandbox]):
757
+ image_id, i, sandbox = sandbox_entry
758
+ generation = int(sandbox.id.sandbox_id.split(':')[-1])
759
+ replaced_sandbox = self._bring_up_sandbox(
760
+ image_id=image_id,
761
+ sandbox_id=f'{i}:{generation + 1}'
762
+ )
763
+ self._sandbox_pool[image_id][i] = replaced_sandbox
568
764
 
569
765
  # TODO(daiyip): Consider to loose the condition to allow some dead
570
766
  # sandboxes to be replaced successfully.
571
- replaced_indices = []
572
- for index, _, error in lf.concurrent_map(
573
- _replace, dead_pool_indices,
767
+ replaced_indices_by_image_id = collections.defaultdict(list)
768
+ num_replaced = 0
769
+ for (image_id, index, _), _, error in lf.concurrent_map(
770
+ _replace, dead_sandbox_entries,
574
771
  max_workers=min(
575
772
  self.pool_operation_max_parallelism,
576
- len(dead_pool_indices)
773
+ len(dead_sandbox_entries)
577
774
  ),
578
775
  ):
579
776
  if error is None:
580
- replaced_indices.append(index)
777
+ replaced_indices_by_image_id[image_id].append(index)
778
+ num_replaced += 1
581
779
 
582
780
  pg.logging.warning(
583
781
  '[%s]: %s maintenance: '
584
782
  '%d/%d dead sandbox(es) have been replaced with new ones. (slots=%s)',
585
783
  self.id,
586
784
  self.__class__.__name__,
587
- len(replaced_indices),
588
- len(dead_pool_indices),
589
- replaced_indices
785
+ num_replaced,
786
+ len(dead_sandbox_entries),
787
+ replaced_indices_by_image_id,
590
788
  )
591
- return replaced_indices
789
+ return replaced_indices_by_image_id
592
790
 
593
791
  #
594
792
  # Event handlers subclasses can override.
@@ -596,16 +794,14 @@ class BaseEnvironment(interface.Environment):
596
794
 
597
795
  def on_starting(self) -> None:
598
796
  """Called when the environment is getting started."""
599
- for handler in self.event_handlers:
600
- handler.on_environment_starting(self)
797
+ self.event_handler.on_environment_starting(self)
601
798
 
602
799
  def on_start(
603
800
  self,
604
801
  duration: float, error: BaseException | None = None
605
802
  ) -> None:
606
803
  """Called when the environment is started."""
607
- for handler in self.event_handlers:
608
- handler.on_environment_start(self, duration, error)
804
+ self.event_handler.on_environment_start(self, duration, error)
609
805
 
610
806
  def on_housekeep(
611
807
  self,
@@ -614,16 +810,13 @@ class BaseEnvironment(interface.Environment):
614
810
  **kwargs
615
811
  ) -> None:
616
812
  """Called when the environment finishes a round of housekeeping."""
617
- housekeep_counter = self.housekeep_counter
618
- for handler in self.event_handlers:
619
- handler.on_environment_housekeep(
620
- self, housekeep_counter, duration, error, **kwargs
621
- )
813
+ self.event_handler.on_environment_housekeep(
814
+ self, self.housekeep_counter, duration, error, **kwargs
815
+ )
622
816
 
623
817
  def on_shutting_down(self) -> None:
624
818
  """Called when the environment is shutting down."""
625
- for handler in self.event_handlers:
626
- handler.on_environment_shutting_down(self, self.offline_duration)
819
+ self.event_handler.on_environment_shutting_down(self, self.offline_duration)
627
820
 
628
821
  def on_shutdown(
629
822
  self,
@@ -631,5 +824,4 @@ class BaseEnvironment(interface.Environment):
631
824
  error: BaseException | None = None) -> None:
632
825
  """Called when the environment is shutdown."""
633
826
  lifetime = (time.time() - self.start_time) if self.start_time else 0.0
634
- for handler in self.event_handlers:
635
- handler.on_environment_shutdown(self, duration, lifetime, error)
827
+ self.event_handler.on_environment_shutdown(self, duration, lifetime, error)