From aa68e6dd5a01f582f3430c7d31c14583e21b3e02 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 21 Sep 2026 15:44:49 +0200 Subject: [PATCH 1/2] Python: Retain Hyperlight cached tool identities --- .../_execute_code_tool.py | 3 + .../hyperlight/test_hyperlight_codeact.py | 95 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index 541a41e84d3..b28a2c4b267 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -369,6 +369,8 @@ class _SandboxEntry: worker: _SandboxWorker input_dir: TemporaryDirectory[str] | None output_dir: TemporaryDirectory[str] | None + # Keep cache-key identities alive; registered callbacks retain copies. + tools: tuple[FunctionTool, ...] def cleanup_temp_dirs(self) -> None: """Clean up temporary directories after the sandbox worker has stopped.""" @@ -1512,6 +1514,7 @@ def _build_sandbox() -> tuple[Any, Any]: worker=worker, input_dir=input_dir_handle, output_dir=output_dir_handle, + tools=config.tools, ) diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index 082ca86154d..50f2901b9c4 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -16,6 +16,7 @@ import sys import threading import time +import weakref from collections.abc import Awaitable, Callable, Coroutine, Generator, Mapping, Sequence from copy import deepcopy from dataclasses import dataclass @@ -2104,6 +2105,100 @@ async def test_description_format_reuses_sandbox_cache(monkeypatch: pytest.Monke registry.close() +@pytest.mark.parametrize("mutation", ["remove", "clear", "replace"]) +@pytest.mark.parametrize("cleanup", ["close", "discard"]) +async def test_sandbox_cache_retains_tool_identity_until_entry_removed( + monkeypatch: pytest.MonkeyPatch, mutation: str, cleanup: str +) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + registry = execute_code_module._SandboxRegistry() + + @tool(name="compute") + def original(a: int, b: int) -> int: + return a + b + + original_ref = weakref.ref(original) + execute_code = HyperlightExecuteCodeTool(tools=[original], _registry=registry) + del original + + try: + await execute_code.invoke(arguments={"code": "None"}) + if mutation == "remove": + execute_code.remove_tool("compute") + elif mutation == "clear": + execute_code.clear_tools() + else: + execute_code.add_tools(compute) + + gc.collect() + assert original_ref() is not None + assert len(registry._entries) == 1 + + if cleanup == "close": + registry.close() + else: + registry._discard_entry( + cache_key=next(iter(registry._entries)), entry=next(iter(registry._entries.values())) + ) + registry._cleanup_executor.submit(lambda: None).result(timeout=5) + + gc.collect() + assert registry._entries == {} + assert original_ref() is None + finally: + registry.close() + + +async def test_sandbox_cache_replacement_preserves_run_snapshots_and_identity_semantics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ValueEqualTool(FunctionTool): + def __eq__(self, other: object) -> bool: + return isinstance(other, FunctionTool) and self.name == other.name + + calls: list[str] = [] + + def original_callback(a: int, b: int) -> int: + calls.append("original") + return a + b + + def replacement_callback(a: int, b: int) -> int: + calls.append("replacement") + return a - b + + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + registry = execute_code_module._SandboxRegistry() + original = ValueEqualTool(name="compute", func=original_callback) + replacement = ValueEqualTool(name="compute", func=replacement_callback) + assert original == replacement + with pytest.raises(TypeError): + hash(original) + execute_code = HyperlightExecuteCodeTool(tools=[original], _registry=registry) + previous_run = execute_code.create_run_tool() + code = 'print(call_tool("compute", a=20, b=22))' + + try: + await previous_run.invoke(arguments={"code": "None"}) + execute_code.remove_tool("compute") + execute_code.add_tools(replacement) + current_run = execute_code.create_run_tool() + + result = await current_run.invoke(arguments={"code": code}) + repeated_result = await current_run.invoke(arguments={"code": code}) + assert result[0].text == repeated_result[0].text == "-2\n" + assert calls == ["replacement", "replacement"] + assert len(_FakeSandbox.instances) == 2 + + previous_result = await previous_run.invoke(arguments={"code": code}) + assert previous_result[0].text == "42\n" + assert calls == ["replacement", "replacement", "original"] + assert len(_FakeSandbox.instances) == 2 + finally: + registry.close() + + async def test_execute_code_tool_executes_with_structured_content(monkeypatch: pytest.MonkeyPatch) -> None: _FakeSandbox.instances.clear() monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) From e00a7c97672b9c8dfbbdc459ec7f6f58b72f5604 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 21 Sep 2026 16:36:12 +0200 Subject: [PATCH 2/2] Python: Update Hyperlight sandbox entry invariant --- .../hyperlight/tests/hyperlight/test_hyperlight_codeact.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index 50f2901b9c4..9af32e04054 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -3315,7 +3315,7 @@ def test_sandbox_entry_does_not_expose_unsendable_attributes() -> None: assert "sandbox" not in fields, "_SandboxEntry must not expose `sandbox` directly" assert "snapshot" not in fields, "_SandboxEntry must not expose `snapshot` directly" # Whatever attributes remain must be sendable / safe to GC on any thread. - assert fields <= {"worker", "input_dir", "output_dir"} + assert fields <= {"worker", "input_dir", "output_dir", "tools"} def test_sandbox_survives_external_thread_holding_stale_reference(