diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 7d9cc65767..ec996de657 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -34,7 +34,7 @@ Status is grouped into these buckets: | `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | | `agent-framework-gemini` | `python/packages/gemini` | `alpha` | | `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` | -| `agent-framework-hyperlight` | `python/packages/hyperlight` | `alpha` | +| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` | | `agent-framework-lab` | `python/packages/lab` | `beta` | | `agent-framework-mem0` | `python/packages/mem0` | `beta` | | `agent-framework-ollama` | `python/packages/ollama` | `beta` | diff --git a/python/packages/core/agent_framework/hyperlight/__init__.py b/python/packages/core/agent_framework/hyperlight/__init__.py new file mode 100644 index 0000000000..da2b416333 --- /dev/null +++ b/python/packages/core/agent_framework/hyperlight/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Hyperlight CodeAct namespace for optional Agent Framework connectors. + +This module lazily re-exports objects from ``agent-framework-hyperlight``. +""" + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "AllowedDomain": ("agent_framework_hyperlight", "agent-framework-hyperlight"), + "AllowedDomainInput": ("agent_framework_hyperlight", "agent-framework-hyperlight"), + "FileMount": ("agent_framework_hyperlight", "agent-framework-hyperlight"), + "FileMountInput": ("agent_framework_hyperlight", "agent-framework-hyperlight"), + "HyperlightCodeActProvider": ("agent_framework_hyperlight", "agent-framework-hyperlight"), + "HyperlightExecuteCodeTool": ("agent_framework_hyperlight", "agent-framework-hyperlight"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." + ) from exc + raise AttributeError(f"Module `hyperlight` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index de1b586f2d..4aabeec96a 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -48,6 +48,7 @@ all = [ "agent-framework-foundry", "agent-framework-foundry-local", "agent-framework-github-copilot; python_version >= '3.11'", + "agent-framework-hyperlight; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'", "agent-framework-lab", "agent-framework-mem0", "agent-framework-ollama", diff --git a/python/packages/core/tests/core/test_hyperlight_namespace.py b/python/packages/core/tests/core/test_hyperlight_namespace.py new file mode 100644 index 0000000000..f76d6180b9 --- /dev/null +++ b/python/packages/core/tests/core/test_hyperlight_namespace.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from types import ModuleType + +import pytest + +import agent_framework.hyperlight as hyperlight + + +def test_hyperlight_namespace_dir_lists_lazy_exports() -> None: + names = dir(hyperlight) + for expected in ( + "AllowedDomain", + "AllowedDomainInput", + "FileMount", + "FileMountInput", + "HyperlightCodeActProvider", + "HyperlightExecuteCodeTool", + ): + assert expected in names + + +def test_hyperlight_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None: + sentinel = object() + fake_module = ModuleType("agent_framework_hyperlight") + fake_module.HyperlightCodeActProvider = sentinel # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "agent_framework_hyperlight", fake_module) + + assert hyperlight.HyperlightCodeActProvider is sentinel + + +def test_hyperlight_namespace_unknown_attribute_raises_attribute_error() -> None: + with pytest.raises(AttributeError, match="Module `hyperlight` has no attribute DoesNotExist."): + _ = hyperlight.DoesNotExist # type: ignore[attr-defined] + + +def test_hyperlight_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "agent_framework_hyperlight", None) + + with pytest.raises(ModuleNotFoundError, match="agent-framework-hyperlight"): + _ = hyperlight.HyperlightCodeActProvider diff --git a/python/packages/hyperlight/README.md b/python/packages/hyperlight/README.md index afc36f9365..08b188ddb8 100644 --- a/python/packages/hyperlight/README.md +++ b/python/packages/hyperlight/README.md @@ -1,6 +1,6 @@ # agent-framework-hyperlight -Alpha Hyperlight-backed CodeAct integrations for Microsoft Agent Framework. +Hyperlight-backed CodeAct integrations for Microsoft Agent Framework. ## Installation @@ -121,8 +121,9 @@ codeact = HyperlightCodeActProvider( ## Notes - This package is intentionally separate from `agent-framework-core` so CodeAct - usage and installation remain optional. -- Alpha-package samples live under `packages/hyperlight/samples/`. + usage and installation remain optional. With `agent-framework-core[all]` (or + the meta `agent-framework`) installed it is also reachable through the + lazy-loading namespace `agent_framework.hyperlight`. - `file_mounts` accepts a single string shorthand, an explicit `(host_path, mount_path)` pair, or a `FileMount` named tuple. The host-side path in the explicit forms may be a `str` or `Path`. Use the explicit two-value form when 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 f91eeb5215..582cbece22 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -8,10 +8,10 @@ import shutil import threading import time from collections.abc import Callable, Sequence -from concurrent.futures import Future, ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor from contextlib import suppress from copy import copy -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path, PurePosixPath from tempfile import TemporaryDirectory from typing import Any, Protocol, TypeGuard, TypeVar, cast @@ -92,39 +92,208 @@ _T = TypeVar("_T") class _SandboxWorker: - """Single-threaded executor that confines all sandbox operations to one OS thread. + """Thread-confined actor that owns a sandbox + snapshot. - The Hyperlight ``WasmSandbox`` is declared ``unsendable`` in PyO3, meaning it can only be - accessed from the OS thread that created it; touching it from any other thread triggers a - Rust panic that cannot be caught from Python. Every cached :class:`_SandboxEntry` therefore - owns its own ``_SandboxWorker``, and *all* lifecycle and execution calls against the - underlying sandbox object must be routed through :meth:`submit`/:meth:`run`. + The Hyperlight ``WasmSandbox`` is declared ``unsendable`` in PyO3: it can only be + accessed *and dropped* from the OS thread that created it. Touching or + releasing it on any other thread triggers a Rust panic + (``"_native_wasm::WasmSandbox is unsendable, but is being dropped on another thread"``) + that cannot be caught from Python. + + To make this guarantee airtight, this class is an actor: the underlying + sandbox and snapshot are stored ONLY as worker-local state and are never + exposed to or returned to other threads. Public methods submit closures to + the dedicated single-thread executor and return only sendable results. + Because no caller can ever obtain a strong reference to the unsendable + objects, no caller can ever cause them to be dropped on the wrong thread. + + Exception isolation: exceptions raised inside worker closures carry a + ``__traceback__`` whose frames retain references to local variables -- + including PyO3 unsendable sandbox/native_result objects. Letting such an + exception propagate to the calling thread would defeat the actor model: + when the calling thread GCs the exception, the traceback's frame locals + are dropped on the wrong thread and PyO3 panics. To prevent this, every + exception raised inside a worker closure is caught on the worker, the + traceback is dropped while still on the worker thread, and a sanitized + copy (preserving message and exception type) is re-raised on the caller. """ - __slots__ = ("_executor",) + __slots__ = ("_executor", "_initialized", "_sandbox", "_snapshot") def __init__(self, *, name: str = "hl-sandbox") -> None: self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix=name) + # _sandbox/_snapshot are accessed/mutated ONLY from worker-side closures. + self._sandbox: Any = None + self._snapshot: Any = None + self._initialized = False - def submit(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> Future[_T]: - return self._executor.submit(fn, *args, **kwargs) + def _run_on_worker(self, fn: Callable[[], _T]) -> _T: + """Run ``fn`` on the worker thread; sanitize any exception's traceback there. - def run(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> _T: - return self._executor.submit(fn, *args, **kwargs).result() + If ``fn`` raises, the exception's ``__traceback__`` is dropped on the worker + thread (so any PyO3 unsendable locals captured in frame locals are released + on the owner thread) and a fresh exception of the same type is raised on + the caller's thread carrying only the original message. + """ - def shutdown(self) -> None: - # Do not block on shutdown; stop accepting new tasks, but allow the currently running - # task and any already-queued tasks to finish before the worker thread exits. + def _wrapped() -> tuple[bool, Any]: + try: + return True, fn() + except BaseException as exc: + exc_type = type(exc) + # Capture args (usually (message,)) so the re-raised exception keeps the + # original shape for types whose constructor doesn't accept a single str. + # Coerce each arg to ``str`` on the worker thread: if a caller-supplied + # callback (or an underlying SDK) constructed the exception with a PyO3 + # unsendable object in args, forwarding it as-is would re-introduce the + # same cross-thread Drop hazard the traceback nulling avoids. Strings + # are always sendable. Fall back to the str() form if args is empty. + exc_args: tuple[str, ...] = tuple(str(a) for a in exc.args) if exc.args else (str(exc),) + # Drop the traceback on the worker thread so frame locals (which + # may include PyO3 unsendable objects) are released here, not on + # the caller thread that will receive the wrapped exception. + exc.__traceback__ = None + del exc + return False, (exc_type, exc_args) + + ok, payload = self._executor.submit(_wrapped).result() + if ok: + return cast(_T, payload) + exc_type, exc_args = cast(tuple[type[BaseException], tuple[str, ...]], payload) + # Re-raise a fresh instance with no chained traceback frames from the worker. + # If the exception type's constructor rejects the captured args (rare), fall + # back to a RuntimeError carrying the string form so we never lose the signal. + try: + raise exc_type(*exc_args) + except TypeError: + raise RuntimeError(f"{exc_type.__name__}: {exc_args}") from None + + def initialize(self, build_fn: Callable[[], tuple[Any, Any]]) -> None: + """Build and install the sandbox+snapshot on the worker thread. + + ``build_fn`` is invoked with no arguments on the worker thread. It must + return ``(sandbox, snapshot)``. Both references are retained as worker- + local attributes; they do not escape this thread. + """ + + def _init_on_worker() -> None: + sandbox, snapshot = build_fn() + self._sandbox = sandbox + self._snapshot = snapshot + self._initialized = True + # Locals fall out of scope on the worker thread; the worker-local + # attributes hold the only strong refs from now on. + + self._run_on_worker(_init_on_worker) + + def execute( + self, + *, + code: str, + output_dir: TemporaryDirectory[str] | None, + build_contents: Callable[..., list[Content]], + ) -> list[Content]: + """Restore + run + build sendable contents — all on the worker thread. + + Returns a plain ``list[Content]`` whose elements never carry strong + references to the underlying sandbox or snapshot. + """ + + def _on_worker() -> list[Content]: + sandbox = self._sandbox + snapshot = self._snapshot + sandbox.restore(snapshot) + _clear_directory(output_dir) + result = sandbox.run(code=code) + try: + return build_contents( + result=result, + sandbox=sandbox, + output_dir=output_dir, + code=code, + ) + finally: + # ``result`` may carry a back-reference to the sandbox. Force its + # final dec_ref on this thread so Drop runs here, not on whatever + # thread later GCs the ``Content`` list. + del result + + return self._run_on_worker(_on_worker) + + def is_alive(self) -> bool: + """Return ``True`` while the worker thread can still accept new submissions. + + Useful for tests/observability; returns ``False`` after ``dispose()``. + """ + try: + self._executor.submit(lambda: None).result(timeout=1.0) + except RuntimeError: + return False + return True + + def dispose(self) -> None: + """Release the sandbox+snapshot on the owner worker thread, then shut down. + + Safe to call multiple times. After ``dispose`` returns, the sandbox/ + snapshot are guaranteed to have been released on the worker thread; any + remaining references held elsewhere have already been impossible (they + never leaked out of this object). + """ + + def _dispose_on_worker() -> None: + sandbox = self._sandbox + snapshot = self._snapshot + self._sandbox = None + self._snapshot = None + close_hook = ( + (getattr(sandbox, "close", None) or getattr(sandbox, "shutdown", None)) if sandbox is not None else None + ) + if callable(close_hook): + with suppress(Exception): + close_hook() + # ``sandbox`` and ``snapshot`` are local on the worker thread and + # will be dec_ref'd here when this frame returns -> Drop on worker. + del sandbox, snapshot + + if self._initialized: + try: + # Use the bare executor here -- _dispose_on_worker swallows its + # own errors and never raises, so traceback sanitization is not + # needed and we want dispose to remain robust during teardown. + self._executor.submit(_dispose_on_worker).result() + except RuntimeError: + # Worker already shut down; sandbox/snapshot will leak rather + # than panic on the wrong thread. This is the safest fallback. + pass + finally: + self._initialized = False + # Do not block on shutdown; stop accepting new tasks, but allow any + # already-queued task (including the dispose closure above) to finish. self._executor.shutdown(wait=False, cancel_futures=False) @dataclass class _SandboxEntry: - sandbox: Any - snapshot: Any + """Per-config cached sandbox handle. + + The unsendable sandbox/snapshot live inside ``worker`` and never appear as + Python attributes on this object. Anything stored here is sendable and + safe to GC on any thread. + """ + + worker: _SandboxWorker input_dir: TemporaryDirectory[str] | None output_dir: TemporaryDirectory[str] | None - worker: _SandboxWorker = field(default_factory=_SandboxWorker) + + def dispose(self) -> None: + """Release the sandbox+snapshot on the worker thread and clean up temp dirs.""" + self.worker.dispose() + for tmp_dir in (self.input_dir, self.output_dir): + if tmp_dir is not None: + with suppress(Exception): + tmp_dir.cleanup() + self.input_dir = None + self.output_dir = None def _load_sandbox_class() -> type[Any]: @@ -432,6 +601,23 @@ def _parse_output_files( return [] +def _result_snapshot(result: Any) -> dict[str, Any]: + """Return a sendable plain-dict snapshot of a sandbox.run() result. + + The Hyperlight ``WasmSandbox.run()`` return value is a PyO3 ``unsendable`` object that + can carry a back-reference to the sandbox itself. Storing it on + ``Content.raw_representation`` lets it ride out of the owner thread and be garbage + collected elsewhere, which trips the PyO3 ``Drop`` panic. Build a thread-safe summary + of the fields we actually surface and forward that instead, so the original result can + be released on the worker thread that produced it. + """ + return { + "success": bool(getattr(result, "success", False)), + "stdout": str(getattr(result, "stdout", "") or ""), + "stderr": str(getattr(result, "stderr", "") or ""), + } + + def _build_execution_contents( *, result: Any, @@ -442,10 +628,11 @@ def _build_execution_contents( success = bool(getattr(result, "success", False)) stdout = str(getattr(result, "stdout", "") or "").replace("\r\n", "\n") or None stderr = str(getattr(result, "stderr", "") or "").replace("\r\n", "\n") or None + snapshot = _result_snapshot(result) outputs: list[Content] = [] if stdout is not None: - outputs.append(Content.from_text(stdout, raw_representation=result)) + outputs.append(Content.from_text(stdout, raw_representation=snapshot)) outputs.extend( _parse_output_files( @@ -457,7 +644,7 @@ def _build_execution_contents( if success: if stderr is not None: - outputs.append(Content.from_text(stderr, raw_representation=result)) + outputs.append(Content.from_text(stderr, raw_representation=snapshot)) if not outputs: outputs.append(Content.from_text("Code executed successfully without output.")) return outputs @@ -467,7 +654,7 @@ def _build_execution_contents( Content.from_error( message="Execution error", error_details=error_details, - raw_representation=result, + raw_representation=snapshot, ) ) return outputs @@ -533,21 +720,14 @@ class _SandboxRegistry(SandboxRuntime): Entries are keyed by ``config.cache_key()``. All operations against the underlying sandbox object are routed through the entry's dedicated single-threaded worker, which both serializes concurrent callers and satisfies the PyO3 ``unsendable`` invariant - that the sandbox can only be touched from the thread that created it. + that the sandbox can only be touched from the thread that created it. The unsendable + objects never escape the worker; this method returns only sendable plain Python data. """ entry = self._get_or_create_entry(config) - return entry.worker.run(self._run_on_worker, entry, code) - - @staticmethod - def _run_on_worker(entry: _SandboxEntry, code: str) -> list[Content]: - entry.sandbox.restore(entry.snapshot) - _clear_directory(entry.output_dir) - result = entry.sandbox.run(code=code) - return _build_execution_contents( - result=result, - sandbox=entry.sandbox, - output_dir=entry.output_dir, + return entry.worker.execute( code=code, + output_dir=entry.output_dir, + build_contents=_build_execution_contents, ) def _get_or_create_entry(self, config: _RunConfig) -> _SandboxEntry: @@ -562,22 +742,19 @@ class _SandboxRegistry(SandboxRuntime): def close(self) -> None: """Shut down all per-entry worker threads and release per-entry resources. - Safe to call multiple times. Runs any sandbox close hook on the entry's - own worker thread to honor the PyO3 ``unsendable`` invariant. + Safe to call multiple times. Each entry's sandbox/snapshot is disposed on the + worker thread that created it to honor the PyO3 ``unsendable`` invariant. """ with self._entries_lock: entries = list(self._entries.values()) self._entries.clear() - for entry in entries: - close_hook = getattr(entry.sandbox, "close", None) or getattr(entry.sandbox, "shutdown", None) - if callable(close_hook): - with suppress(Exception): - entry.worker.run(close_hook) - entry.worker.shutdown() - for tmp_dir in (entry.input_dir, entry.output_dir): - if tmp_dir is not None: - with suppress(Exception): - tmp_dir.cleanup() + try: + for entry in entries: + entry.dispose() + finally: + # Drop our local strong references; entries' own refs to sandbox/snapshot + # were already moved into the per-worker disposal closure inside dispose(). + del entries def _create_entry(self, config: _RunConfig) -> _SandboxEntry: input_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None @@ -617,8 +794,6 @@ class _SandboxRegistry(SandboxRuntime): methods=list(allowed_domain.methods) if allowed_domain.methods is not None else None, ) - worker = _SandboxWorker() - def _build_sandbox() -> tuple[Any, Any]: sandbox = _create_sandbox() _configure_sandbox(sandbox=sandbox, expand_missing_scheme=False) @@ -636,18 +811,17 @@ class _SandboxRegistry(SandboxRuntime): snapshot = sandbox.snapshot() return sandbox, snapshot + worker = _SandboxWorker() try: - sandbox, snapshot = worker.run(_build_sandbox) + worker.initialize(_build_sandbox) except BaseException: - worker.shutdown() + worker.dispose() raise return _SandboxEntry( - sandbox=sandbox, - snapshot=snapshot, + worker=worker, input_dir=input_dir_handle, output_dir=output_dir_handle, - worker=worker, ) diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml index 02b5d2fe3d..b61d9591ed 100644 --- a/python/packages/hyperlight/pyproject.toml +++ b/python/packages/hyperlight/pyproject.toml @@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0a260429" +version = "1.0.0b260501" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -23,9 +23,9 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.2.2,<2", - "hyperlight-sandbox>=0.3.0,<0.4", - "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'", - "hyperlight-sandbox-python-guest>=0.3.0,<0.4", + "hyperlight-sandbox>=0.4.0,<0.5", + "hyperlight-sandbox-backend-wasm>=0.4.0,<0.5 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'", + "hyperlight-sandbox-python-guest>=0.4.0,<0.5", ] [tool.uv] @@ -53,7 +53,6 @@ markers = [ extend = "../../pyproject.toml" [tool.ruff.lint.per-file-ignores] -"samples/**" = ["INP", "T201"] "tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] [tool.coverage.run] @@ -82,7 +81,7 @@ disallow_untyped_decorators = true [tool.bandit] targets = ["agent_framework_hyperlight"] -exclude_dirs = ["tests", "samples"] +exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" diff --git a/python/packages/hyperlight/samples/README.md b/python/packages/hyperlight/samples/README.md deleted file mode 100644 index aa6aeeee1c..0000000000 --- a/python/packages/hyperlight/samples/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Hyperlight CodeAct samples - -These samples demonstrate the alpha `agent-framework-hyperlight` package. - -## When to use which pattern - -- **Provider pattern** (`codeact_context_provider.py`): Use when the tool - registry, file mounts, or network allow-list may change between runs, or when - you want the provider to manage CodeAct instructions and approval computation - automatically on every invocation. This is the recommended default for - production agents that need dynamic capability management or concurrent runs - sharing one provider. - -- **Manual static wiring** (`codeact_manual_wiring.py`): Use when the sandbox - tool set and capabilities are fixed for the agent's lifetime. This pattern - builds instructions once, passes `execute_code` alongside direct tools in - `tools=`, and skips the per-run provider lifecycle entirely. Simpler setup, - but changes to the tool registry after construction will not update the - agent's instructions automatically. - -- **Standalone tool** (`codeact_tool.py`): Use for the simplest integration - where `execute_code` is added directly to the agent tool list. The tool's own - description advertises `call_tool(...)` and the registered sandbox tools, so - no extra agent instructions are needed. Best for quick prototyping or when - CodeAct is just another tool alongside the agent's direct tools. - -## Samples - -- `codeact_context_provider.py` shows the provider-owned CodeAct model where the - agent only sees `execute_code` and sandbox tools are owned by - `HyperlightCodeActProvider`. -- `codeact_manual_wiring.py` shows static wiring where `HyperlightExecuteCodeTool` - and its instructions are passed directly to the `Agent` constructor. -- `codeact_tool.py` shows the standalone `HyperlightExecuteCodeTool` surface - where `execute_code` is added directly to the agent tool list. - -Run the samples from the repository after installing the workspace dependencies: - -```bash -uv run --directory packages/hyperlight python samples/codeact_context_provider.py -uv run --directory packages/hyperlight python samples/codeact_manual_wiring.py -uv run --directory packages/hyperlight python samples/codeact_tool.py -``` diff --git a/python/packages/hyperlight/samples/codeact_benchmark.py b/python/packages/hyperlight/samples/codeact_benchmark.py deleted file mode 100644 index 275187d3b8..0000000000 --- a/python/packages/hyperlight/samples/codeact_benchmark.py +++ /dev/null @@ -1,253 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Benchmark CodeAct vs. traditional tool-calling for a multi-tool-call task. - -This sample runs the same prompt against the same FoundryChatClient twice: - -1. **Traditional tool-calling**: the five business tools are passed directly to - the agent, so the model calls each tool individually via the LLM tool-call - interface. -2. **CodeAct**: the same tools are registered on a HyperlightCodeActProvider - and the model sees a single ``execute_code`` tool that calls them from - inside the Hyperlight sandbox via ``call_tool(...)``. - -The task (computing grand totals per user) naturally requires many tool calls -to complete. At the end, the sample prints elapsed time and token usage for -each run so the two approaches can be compared. - -Run with: - cd python - uv run --directory packages/hyperlight python samples/codeact_benchmark.py - -Required environment variables (loaded from ``.env`` if present): - FOUNDRY_PROJECT_ENDPOINT - FOUNDRY_MODEL -""" - -from __future__ import annotations - -import asyncio -import os -import time -from typing import Annotated, Any, Literal - -from agent_framework import Agent, AgentResponse, UsageDetails -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from pydantic import BaseModel, Field - -from agent_framework_hyperlight import HyperlightCodeActProvider - -load_dotenv() - - -# 1. Deterministic "business" data and tools. - -_USERS: list[dict[str, Any]] = [ - {"id": 1, "name": "Alice", "region": "EU", "tier": "gold"}, - {"id": 2, "name": "Bob", "region": "US", "tier": "silver"}, - {"id": 3, "name": "Charlie", "region": "US", "tier": "gold"}, - {"id": 4, "name": "Diana", "region": "APAC", "tier": "bronze"}, - {"id": 5, "name": "Evan", "region": "EU", "tier": "silver"}, - {"id": 6, "name": "Fiona", "region": "US", "tier": "gold"}, - {"id": 7, "name": "George", "region": "APAC", "tier": "gold"}, - {"id": 8, "name": "Hana", "region": "EU", "tier": "bronze"}, -] - -_ORDERS: dict[int, list[dict[str, Any]]] = { - 1: [{"product": "Widget", "qty": 3, "unit_price": 9.99}, {"product": "Gadget", "qty": 1, "unit_price": 19.99}], - 2: [{"product": "Widget", "qty": 1, "unit_price": 9.99}], - 3: [{"product": "Gadget", "qty": 2, "unit_price": 19.99}, {"product": "Thingamajig", "qty": 4, "unit_price": 4.50}], - 4: [{"product": "Widget", "qty": 10, "unit_price": 9.99}], - 5: [{"product": "Gadget", "qty": 1, "unit_price": 19.99}], - 6: [{"product": "Widget", "qty": 2, "unit_price": 9.99}, {"product": "Thingamajig", "qty": 5, "unit_price": 4.50}], - 7: [{"product": "Gadget", "qty": 3, "unit_price": 19.99}], - 8: [{"product": "Thingamajig", "qty": 2, "unit_price": 4.50}], -} - -_DISCOUNTS: dict[str, float] = {"gold": 0.20, "silver": 0.10, "bronze": 0.05} -_TAX_RATES: dict[str, float] = {"EU": 0.21, "US": 0.08, "APAC": 0.10} - - -def list_users() -> list[dict[str, Any]]: - """Return all users as a list of dictionaries. - - Each entry has keys: id (int), name (str), region (str), tier (str). - """ - return _USERS - - -def get_orders_for_user( - user_id: Annotated[int, "The user id whose orders to retrieve."], -) -> list[dict[str, Any]]: - """Return the user's orders as a list of dictionaries. - - Each entry has keys: product (str), qty (int), unit_price (float). - """ - return _ORDERS.get(user_id, []) - - -def get_discount_rate( - tier: Annotated[Literal["gold", "silver", "bronze"], "The customer tier."], -) -> float: - """Return the discount rate as a float fraction (e.g. 0.2 for 20%).""" - return _DISCOUNTS[tier] - - -def get_tax_rate( - region: Annotated[Literal["EU", "US", "APAC"], "The region code."], -) -> float: - """Return the tax rate as a float fraction (e.g. 0.21 for 21%).""" - return _TAX_RATES[region] - - -def compute_line_total( - qty: Annotated[int, "Line item quantity."], - unit_price: Annotated[float, "Line item unit price."], - discount_rate: Annotated[float, "Discount rate as a fraction (e.g. 0.2 for 20%)."], - tax_rate: Annotated[float, "Tax rate as a fraction (e.g. 0.21 for 21%)."], -) -> float: - """Compute a single order line total. - - Formula: qty * unit_price * (1 - discount_rate) * (1 + tax_rate), rounded to 2 decimals. - """ - subtotal = qty * unit_price - discounted = subtotal * (1.0 - discount_rate) - return round(discounted * (1.0 + tax_rate), 2) - - -TOOLS = [list_users, get_orders_for_user, get_discount_rate, get_tax_rate, compute_line_total] - - -# 2. Structured output schema shared between both runs. - - -class UserTotal(BaseModel): - """A user's grand total of all their orders.""" - - user_id: int = Field(description="The user's id.") - name: str = Field(description="The user's display name.") - grand_total: float = Field(description="Sum of all line totals, rounded to 2 decimals.") - - -class UserGrandTotals(BaseModel): - """Structured output schema for both runs.""" - - results: list[UserTotal] = Field(description="One entry per user, sorted by grand_total descending.") - - -INSTRUCTIONS = "You are a careful assistant. Use the provided tools for every lookup and computation." - -BENCHMARK_PROMPT = ( - "For every user in our system (there are 8 of them), compute the grand total of all their orders. " - "Use the compute_line_total tool for each user's orders, after looking up the relevant discount and " - "tax rates for that user. " - "Use the provided tools for EVERY data lookup (users, orders, discount rates, tax rates) and for EVERY " - "line-total computation via compute_line_total — do not invent values or hardcode any numbers. " - "The total per order item should apply the discount first and then the tax " - "(e.g. total = qty * unit_price * (1-discount) * (1+tax)). " - "Return one entry per user, sorted by grand_total descending." -) - - -def get_client() -> FoundryChatClient: - """Create a FoundryChatClient from environment variables.""" - return FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ) - - -# 3. Two runners that share the same tools, prompt, and structured output schema. - - -async def _run_traditional() -> tuple[float, AgentResponse]: - agent = Agent( - client=get_client(), - name="TraditionalAgent", - instructions=INSTRUCTIONS, - tools=TOOLS, - default_options={"response_format": UserGrandTotals}, - ) - start = time.perf_counter() - result = await agent.run(BENCHMARK_PROMPT) - elapsed = time.perf_counter() - start - return elapsed, result - - -async def _run_codeact() -> tuple[float, AgentResponse]: - codeact = HyperlightCodeActProvider( - tools=TOOLS, - approval_mode="never_require", - ) - agent = Agent( - client=get_client(), - name="CodeActAgent", - instructions=INSTRUCTIONS, - context_providers=[codeact], - default_options={"response_format": UserGrandTotals}, - ) - start = time.perf_counter() - result = await agent.run(BENCHMARK_PROMPT) - elapsed = time.perf_counter() - start - return elapsed, result - - -# 4. Report results side by side. - - -def _print_section(title: str) -> None: - bar = "=" * 70 - print(f"\n{bar}\n{title}\n{bar}") - - -def _format_usage(usage: UsageDetails | None) -> str: - if usage is None: - return "usage=" - return ( - f"input={usage.get('input_token_count') or 0:>6} " - f"output={usage.get('output_token_count') or 0:>6} " - f"total={usage.get('total_token_count') or 0:>6}" - ) - - -def _print_results(result: AgentResponse) -> None: - if result.value is not None: - for row in result.value.results: - print(f" user_id={row.user_id:>2} name={row.name:<8} grand_total={row.grand_total:>8.2f}") - else: - print(result.text) - - -async def main() -> None: - """Run the benchmark and print a comparison.""" - trad_time, trad_result = await _run_traditional() - code_time, code_result = await _run_codeact() - - _print_section("Traditional tool-calling") - print(f"time={trad_time:7.2f}s {_format_usage(trad_result.usage_details)}") - _print_results(trad_result) - - _print_section("CodeAct (HyperlightCodeActProvider)") - print(f"time={code_time:7.2f}s {_format_usage(code_result.usage_details)}") - _print_results(code_result) - - _print_section("Comparison") - trad_total = (trad_result.usage_details or {}).get("total_token_count") or 0 - code_total = (code_result.usage_details or {}).get("total_token_count") or 0 - - def pct(new: float, old: float) -> str: - if old == 0: - return "n/a" - delta = (new - old) / old * 100 - sign = "+" if delta >= 0 else "" - return f"{sign}{delta:.1f}%" - - print(f"time : traditional={trad_time:7.2f}s codeact={code_time:7.2f}s delta={pct(code_time, trad_time)}") - print(f"tokens : traditional={trad_total:7d} codeact={code_total:7d} delta={pct(code_total, trad_total)}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index e41a5a6ee1..9611978744 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -3,6 +3,9 @@ from __future__ import annotations import asyncio +import contextlib +import dataclasses +import gc import importlib.metadata import importlib.util import inspect @@ -1042,9 +1045,8 @@ def test_sandbox_registry_close_shuts_down_workers(monkeypatch: pytest.MonkeyPat registry.close() assert registry._entries == {} - # Submitting after shutdown must fail; this proves the executor was actually torn down. - with pytest.raises(RuntimeError): - worker.submit(lambda: None) + # After shutdown, the worker must report itself as no longer accepting work. + assert worker.is_alive() is False def test_sandbox_registry_close_releases_per_entry_resources(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -1125,3 +1127,243 @@ async def test_make_sandbox_callback_propagates_exceptions() -> None: callback = execute_code_module._make_sandbox_callback(boom) with pytest.raises(RuntimeError, match="nope"): callback(x=1) + + +class _OwnerThreadTrackedResult: + """Fake sandbox.run() return value that mirrors a PyO3 ``unsendable`` object's Drop. + + Records (rather than panics, since CPython swallows __del__ exceptions) the OS thread + that finalized the object, so tests can assert it was dropped on the sandbox's owner + thread and not on whatever thread happened to GC it. + """ + + drop_thread_violations: list[str] = [] + + def __init__(self, *, owner_thread: int, success: bool = True, stdout: str = "", stderr: str = "") -> None: + self._owner_thread = owner_thread + self.success = success + self.stdout = stdout + self.stderr = stderr + + def __del__(self) -> None: + ident = threading.get_ident() + if ident != self._owner_thread: + type(self).drop_thread_violations.append( + f"_OwnerThreadTrackedResult dropped on thread {ident}, owner was {self._owner_thread}" + ) + + +class _ResultDropTrackingFakeSandbox(_FakeSandbox): + """Fake sandbox whose ``run()`` returns an owner-thread-tracking result.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._owner_thread = threading.get_ident() + + def run(self, code: str) -> Any: + del code + # Real Hyperlight runs almost always have non-empty stdout (the executed Python + # ``print`` output); that is the path where _build_execution_contents attaches + # raw_representation=result and the unsendable object escapes the worker thread. + return _OwnerThreadTrackedResult(owner_thread=self._owner_thread, success=True, stdout="hello\n") + + +def test_sandbox_run_result_is_finalized_on_owner_thread(monkeypatch: pytest.MonkeyPatch) -> None: + """Regression: the object returned by ``sandbox.run`` must not escape its owner thread. + + The Hyperlight ``WasmSandbox`` is unsendable; the value its ``run()`` returns can carry + a back-reference to the sandbox and is itself unsendable. Attaching it to + ``Content.raw_representation`` lets it ride out of the worker thread and be garbage + collected on whichever thread the asyncio loop / agent state ends up on, which trips + the PyO3 ``Drop`` panic. Drop must happen on the worker thread that ran ``run()``. + """ + _OwnerThreadTrackedResult.drop_thread_violations.clear() + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _ResultDropTrackingFakeSandbox) + + execute_code = HyperlightExecuteCodeTool() + + def _drive() -> None: + # Run the whole invocation inside a helper frame so every local + # reference (contents, awaitable, asyncio frames) dies when the + # function returns. Anything still pinning the result is the bug. + contents = asyncio.run(execute_code.invoke(arguments={"code": "None"})) + assert contents and contents[0].type == "text" + + _drive() + for _ in range(3): + gc.collect() + + assert _OwnerThreadTrackedResult.drop_thread_violations == [] + + +def test_sandbox_is_finalized_on_owner_thread_after_registry_close(monkeypatch: pytest.MonkeyPatch) -> None: + """Regression: dropping the sandbox object itself must occur on its owner thread. + + ``_SandboxRegistry.close()`` previously held entries in a local list whose lifetime + extended onto the caller's thread. When that list went out of scope the unsendable + sandbox was finalized on the caller's thread, panicking PyO3 with + "WasmSandbox is unsendable, but is being dropped by another thread". + """ + drop_violations: list[str] = [] + + class _OwnerDropFakeSandbox(_FakeSandbox): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._owner_thread = threading.get_ident() + # Do not pin ourselves on the class-level instances list; we want the + # registry/entry to hold the only strong reference so that dispose-time + # drop is what determines the finalizer thread. + _FakeSandbox.instances.remove(self) + + def __del__(self) -> None: + ident = threading.get_ident() + if ident != self._owner_thread: + drop_violations.append(f"sandbox dropped on thread {ident}, owner was {self._owner_thread}") + + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _OwnerDropFakeSandbox) + + registry = execute_code_module._SandboxRegistry() + execute_code = HyperlightExecuteCodeTool(_registry=registry) + asyncio.run(execute_code.invoke(arguments={"code": "None"})) + + registry.close() + + # Release the registry/tool references and force a GC. With the fix in place the + # sandbox is already disposed on the worker thread inside close(); dropping these + # local references must not trigger a wrong-thread __del__ now. + del registry + del execute_code + for _ in range(3): + gc.collect() + + assert drop_violations == [], f"sandbox was dropped off-thread despite registry close: {drop_violations}" + + +def test_worker_failure_does_not_leak_unsendable_via_exception_traceback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: an exception raised inside a worker closure must not leak unsendable refs. + + Production failure mode: ``_build_sandbox`` (or ``sandbox.run``) raises on the + worker thread. ``concurrent.futures`` propagates the exception via + ``Future.result()`` to the caller's thread. Python's exception object retains + ``__traceback__`` whose frames reference local variables -- including the + partially-built PyO3 unsendable sandbox. When the caller's thread eventually + GCs the exception, those locals are dec_ref'd on the wrong thread and PyO3 + panics with + ``_native_wasm::WasmSandbox is unsendable, but is being dropped on another thread``. + + The fix routes every worker closure through ``_run_on_worker``, which catches + the exception on the worker thread, drops its traceback there, and re-raises + a fresh exception on the caller side carrying only the message. + """ + drop_violations: list[str] = [] + + class _RaisingFakeSandbox(_FakeSandbox): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._owner_thread = threading.get_ident() + _FakeSandbox.instances.remove(self) + # Simulate production bug: build raises while ``self`` is alive in + # the calling frame's locals -- the exception traceback will retain + # a reference to this object. + raise RuntimeError("simulated build failure with unsendable in frame locals") + + def __del__(self) -> None: + ident = threading.get_ident() + if ident != self._owner_thread: + drop_violations.append(f"sandbox dropped on thread {ident}, owner was {self._owner_thread}") + + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _RaisingFakeSandbox) + + registry = execute_code_module._SandboxRegistry() + execute_code = HyperlightExecuteCodeTool(_registry=registry) + + async def _drive(tool: HyperlightExecuteCodeTool) -> None: + for _ in range(4): + with contextlib.suppress(Exception): + await tool.invoke(arguments={"code": "None"}) + + asyncio.run(_drive(execute_code)) + registry.close() + + del registry + del execute_code + for _ in range(5): + gc.collect() + + assert drop_violations == [], ( + f"sandbox dropped off-thread despite worker raising on the owner thread: {drop_violations}" + ) + + +def test_sandbox_entry_does_not_expose_unsendable_attributes() -> None: + """Architectural regression: the entry must not hold sandbox/snapshot as attributes. + + The unsendable PyO3 sandbox/snapshot must live ONLY inside the per-entry worker + thread, accessible only via worker-submitted closures. Any direct ``entry.sandbox`` + or ``entry.snapshot`` attribute would let callers obtain a strong reference that + can be released on a non-owner thread, triggering PyO3's unsendable Drop panic + (the production bug we are fixing). + """ + fields = {f.name for f in dataclasses.fields(execute_code_module._SandboxEntry)} + 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"} + + +def test_sandbox_survives_external_thread_holding_stale_reference( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: stale refs held by external executors must not cause wrong-thread Drop. + + Production traceback was ``concurrent.futures.thread._worker:95 del work_item`` on + ``asyncio_0`` -- an external ``ThreadPoolExecutor`` whose ``_WorkItem`` transitively + held a strong reference to the sandbox via ``self._registry.execute``. When that + work_item was deleted on the external worker thread, the sandbox's refcount could + reach zero there, panicking PyO3. + + With the actor-model refactor, ``HyperlightExecuteCodeTool._run_code`` runs the + sandbox call via ``asyncio.to_thread(self._registry.execute, ...)`` which creates + an external work_item containing ``self._registry.execute`` -- but that reference + transitively holds only the registry, not the sandbox. The sandbox lives entirely + inside the per-entry ``_SandboxWorker`` and never escapes; so when the external + work_item is deleted on a non-owner thread, the sandbox's refcount cannot reach + zero there. + """ + drop_violations: list[str] = [] + + class _OwnerDropFakeSandbox(_FakeSandbox): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._owner_thread = threading.get_ident() + _FakeSandbox.instances.remove(self) + + def __del__(self) -> None: + ident = threading.get_ident() + if ident != self._owner_thread: + drop_violations.append(f"sandbox dropped on thread {ident}, owner was {self._owner_thread}") + + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _OwnerDropFakeSandbox) + + registry = execute_code_module._SandboxRegistry() + execute_code = HyperlightExecuteCodeTool(_registry=registry) + + async def _drive_many(tool: HyperlightExecuteCodeTool) -> None: + # Many concurrent invocations push work_items into asyncio's default executor; + # each work_item's args transitively reference the registry. If the registry + # were the sandbox holder, the work_items' deletion on asyncio_0/asyncio_1 etc. + # could trigger a wrong-thread Drop -- which is exactly the production bug. + await asyncio.gather(*[tool.invoke(arguments={"code": "None"}) for _ in range(8)]) + + asyncio.run(_drive_many(execute_code)) + registry.close() + + del registry + del execute_code + for _ in range(5): + gc.collect() + + assert drop_violations == [] diff --git a/python/samples/02-agents/context_providers/code_act/README.md b/python/samples/02-agents/context_providers/code_act/README.md new file mode 100644 index 0000000000..264123d70f --- /dev/null +++ b/python/samples/02-agents/context_providers/code_act/README.md @@ -0,0 +1,31 @@ +# Hyperlight CodeAct context provider + +Demonstrates the provider-owned [Hyperlight](https://github.com/hyperlight-dev/hyperlight) +CodeAct flow. `HyperlightCodeActProvider` injects an `execute_code` tool into the +agent and keeps the registered sandbox tools (`compute`, `fetch_data`) hidden +from the model — the model must call them from inside the sandbox using +`call_tool(...)`. + +## Installation + +```bash +pip install agent-framework agent-framework-hyperlight --pre +``` + +> The Hyperlight Wasm backend is currently published only for `linux/x86_64` and +> `win32/AMD64` with Python `<3.14`. On other platforms `execute_code` will fail +> at runtime when it tries to create the sandbox. + +## Prerequisites + +- An Azure AI Foundry project endpoint (`FOUNDRY_PROJECT_ENDPOINT`) +- A deployed model (`FOUNDRY_MODEL`) +- Azure CLI authenticated (`az login`) + +## Run + +```bash +python code_act.py +``` + +See [`code_act.py`](code_act.py) for the full annotated example. diff --git a/python/packages/hyperlight/samples/codeact_context_provider.py b/python/samples/02-agents/context_providers/code_act/code_act.py similarity index 98% rename from python/packages/hyperlight/samples/codeact_context_provider.py rename to python/samples/02-agents/context_providers/code_act/code_act.py index 81b55034e5..b85574f2ff 100644 --- a/python/packages/hyperlight/samples/codeact_context_provider.py +++ b/python/samples/02-agents/context_providers/code_act/code_act.py @@ -10,11 +10,10 @@ from typing import Annotated, Any, Literal from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool from agent_framework.foundry import FoundryChatClient +from agent_framework.hyperlight import HyperlightCodeActProvider from azure.identity import AzureCliCredential from dotenv import load_dotenv -from agent_framework_hyperlight import HyperlightCodeActProvider - """This sample demonstrates the provider-owned Hyperlight CodeAct flow. The sample keeps `compute` and `fetch_data` off the direct agent tool surface and diff --git a/python/samples/02-agents/tools/local_code_interpreter/README.md b/python/samples/02-agents/tools/local_code_interpreter/README.md new file mode 100644 index 0000000000..3b3b19365a --- /dev/null +++ b/python/samples/02-agents/tools/local_code_interpreter/README.md @@ -0,0 +1,37 @@ +# Hyperlight local code interpreter + +Demonstrates the standalone [Hyperlight](https://github.com/hyperlight-dev/hyperlight) +`HyperlightExecuteCodeTool` — a sandboxed local code interpreter that the agent +can invoke directly. Two patterns are shown: + +| File | Pattern | +|------|---------| +| [`local_code_interpreter.py`](local_code_interpreter.py) | **Standalone tool** — `HyperlightExecuteCodeTool` is added to the agent tool list and self-describes its sandbox tools, so no extra agent instructions are needed. Best for quick prototyping. | +| [`local_code_interpreter_manual_wiring.py`](local_code_interpreter_manual_wiring.py) | **Manual static wiring** — sandbox tools and CodeAct instructions are built once and passed to the `Agent` constructor alongside a direct-only tool (`send_email`). Best when the tool set is fixed for the agent's lifetime. | + +For the recommended provider-driven pattern (with dynamic tool / capability +management), see +[`../../context_providers/code_act/`](../../context_providers/code_act/). + +## Installation + +```bash +pip install agent-framework agent-framework-hyperlight --pre +``` + +> The Hyperlight Wasm backend is currently published only for `linux/x86_64` and +> `win32/AMD64` with Python `<3.14`. On other platforms `execute_code` will fail +> at runtime when it tries to create the sandbox. + +## Prerequisites + +- An Azure AI Foundry project endpoint (`FOUNDRY_PROJECT_ENDPOINT`) +- A deployed model (`FOUNDRY_MODEL`) +- Azure CLI authenticated (`az login`) + +## Run + +```bash +python local_code_interpreter.py +python local_code_interpreter_manual_wiring.py +``` diff --git a/python/packages/hyperlight/samples/codeact_tool.py b/python/samples/02-agents/tools/local_code_interpreter/local_code_interpreter.py similarity index 98% rename from python/packages/hyperlight/samples/codeact_tool.py rename to python/samples/02-agents/tools/local_code_interpreter/local_code_interpreter.py index 64c0e6fde5..87ccb58124 100644 --- a/python/packages/hyperlight/samples/codeact_tool.py +++ b/python/samples/02-agents/tools/local_code_interpreter/local_code_interpreter.py @@ -8,11 +8,10 @@ from typing import Annotated, Any, Literal from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient +from agent_framework.hyperlight import HyperlightExecuteCodeTool from azure.identity import AzureCliCredential from dotenv import load_dotenv -from agent_framework_hyperlight import HyperlightExecuteCodeTool - """This sample demonstrates the standalone Hyperlight execute_code tool. The sample adds `HyperlightExecuteCodeTool` directly to the agent. The tool's diff --git a/python/packages/hyperlight/samples/codeact_manual_wiring.py b/python/samples/02-agents/tools/local_code_interpreter/local_code_interpreter_manual_wiring.py similarity index 98% rename from python/packages/hyperlight/samples/codeact_manual_wiring.py rename to python/samples/02-agents/tools/local_code_interpreter/local_code_interpreter_manual_wiring.py index c7a4761efb..ad2c67aacc 100644 --- a/python/packages/hyperlight/samples/codeact_manual_wiring.py +++ b/python/samples/02-agents/tools/local_code_interpreter/local_code_interpreter_manual_wiring.py @@ -8,11 +8,10 @@ from typing import Annotated, Any, Literal from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient +from agent_framework.hyperlight import HyperlightExecuteCodeTool from azure.identity import AzureCliCredential from dotenv import load_dotenv -from agent_framework_hyperlight import HyperlightExecuteCodeTool - """This sample demonstrates manual static wiring of CodeAct without a provider. Instead of using `HyperlightCodeActProvider` with `context_providers=`, this diff --git a/python/samples/04-hosting/container/hyperlight_codeact/.dockerignore b/python/samples/04-hosting/container/hyperlight_codeact/.dockerignore new file mode 100644 index 0000000000..0f0d55d2ae --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python diff --git a/python/samples/04-hosting/container/hyperlight_codeact/.env.example b/python/samples/04-hosting/container/hyperlight_codeact/.env.example new file mode 100644 index 0000000000..2a38d9c9b8 --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." diff --git a/python/samples/04-hosting/container/hyperlight_codeact/Dockerfile b/python/samples/04-hosting/container/hyperlight_codeact/Dockerfile new file mode 100644 index 0000000000..218ba87b8b --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/Dockerfile @@ -0,0 +1,36 @@ +# Build this image with the repository's `python/` directory as the build context so +# the in-tree agent-framework packages can be installed from source. From the repo root: +# +# docker build \ +# -f python/samples/04-hosting/foundry-hosted-agents/responses/08_hyperlight_codeact/Dockerfile \ +# -t .azurecr.io/: \ +# python/ +FROM python:3.12-slim + +WORKDIR /app + +# Copy the in-tree agent-framework packages we need. Order matters for editable +# installs because of inter-package dependencies; we install in dependency order +# below. Hyperlight backends are platform gated, so we install them via pip +# resolution rather than copying the wheels. +COPY packages/core /opt/af/core +COPY packages/openai /opt/af/openai +COPY packages/foundry /opt/af/foundry +COPY packages/foundry_hosting /opt/af/foundry_hosting +COPY packages/hyperlight /opt/af/hyperlight + +# Copy just the sample we care about into the user agent location. +COPY samples/04-hosting/foundry-hosted-agents/responses/08_hyperlight_codeact/ /app/user_agent/ +WORKDIR /app/user_agent + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir /opt/af/core \ + && pip install --no-cache-dir /opt/af/openai \ + && pip install --no-cache-dir /opt/af/foundry \ + && pip install --no-cache-dir /opt/af/foundry_hosting \ + && pip install --no-cache-dir /opt/af/hyperlight \ + && if grep -Eq '^[[:space:]]*[^#[:space:]]' requirements.txt; then pip install --no-cache-dir -r requirements.txt; fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/python/samples/04-hosting/container/hyperlight_codeact/README.md b/python/samples/04-hosting/container/hyperlight_codeact/README.md new file mode 100644 index 0000000000..44c9a53e91 --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/README.md @@ -0,0 +1,85 @@ +# What this sample demonstrates + +An [Agent Framework](https://github.com/microsoft/agent-framework) agent that +runs Python in a [Hyperlight](https://github.com/hyperlight-dev/hyperlight) +WebAssembly sandbox via the **CodeAct** pattern, hosted using the **Responses +protocol**. The model is only given a single `execute_code` tool. Local Python +tools (`compute`, `fetch_data`) are registered on `HyperlightCodeActProvider` +and are reachable from inside the sandbox via `call_tool(...)`, never as +direct LLM tools. All of this can be run as a container, however not under all circumstances. + +> **⚠️ Foundry hosted-agent runtime support is in progress.** +> Hyperlight requires a hypervisor (`/dev/kvm` on Linux, MSHV on Windows). The +> default Foundry hosted-agent runtime does not currently expose a hypervisor +> to the workload container, so deploying this sample as a Foundry hosted +> agent will fail at runtime with +> `Failed to create sandbox: ... No Hypervisor was found for Sandbox`. +> The sample container itself works end-to-end when run locally with +> `docker run --device=/dev/kvm ...` (see [Hypervisor requirement](#hypervisor-requirement) +> below). We are working with the platform team to enable a hypervisor-capable +> hosting target. + +## How It Works + +### Model integration + +The agent uses `FoundryChatClient` to talk to a Foundry-hosted model deployment. +A `HyperlightCodeActProvider` is attached as a context provider, which on every +run injects the `execute_code` tool plus the CodeAct instructions that teach the +model how to author Python that calls `call_tool(...)` for sandbox-only tools. + +See [`main.py`](main.py) for the full implementation. + +### Agent hosting + +The agent is hosted with `ResponsesHostServer` from +`agent-framework-foundry-hosting`, which exposes a REST endpoint compatible with +the OpenAI Responses protocol. + +> The Hyperlight Wasm backend is currently published only for `linux/x86_64` and +> `win32/AMD64` with Python `<3.14`. The hosted container runs `python:3.12-slim` +> on linux/x86_64, which is supported. + +### Hypervisor requirement + +Hyperlight executes guest WebAssembly inside a micro-VM and **requires a +hypervisor on the host**: + +- **Linux:** `/dev/kvm` must be present *and* the container must have access to + it (`docker run --device=/dev/kvm ...`). +- **Windows:** the Microsoft Hypervisor Platform (MSHV) must be enabled. + +Without a hypervisor, sandbox creation fails with: + +``` +Failed to create sandbox: failed to build ProtoWasmSandbox: No Hypervisor was found for Sandbox +``` + +This affects hosted environments that don't expose `/dev/kvm` to the workload +container (most managed PaaS, including the default Foundry hosted-agent +runtime). To run this sample as a hosted agent you need a hosting target with +nested virtualization and `/dev/kvm` device passthrough — for example an Azure +VM, AKS nodes with KVM enabled, or Azure Container Instances configured for +nested virt. + +## Running the Agent Host + +Follow the instructions in the +[Running the Agent Host Locally](../../foundry-hosted-agents//README.md#running-the-agent-host-locally) +section of the README in the Foundry Hosted Agent directory. + +## Interacting with the agent + +Send a POST request to the server with a JSON body containing an `"input"` +field. The model should respond by calling `execute_code` with Python that uses +`call_tool(...)` to reach the sandbox-only tools: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Fetch all users, find the admins, multiply 7 by 6, and print the users, admins and multiplication result. Use execute_code with call_tool(...)."}' +``` + +## Deploying the Agent to Foundry + +Deploying this container to Foundry will not work yet, as soon as it does, we will update this sample. diff --git a/python/samples/04-hosting/container/hyperlight_codeact/agent.manifest.yaml b/python/samples/04-hosting/container/hyperlight_codeact/agent.manifest.yaml new file mode 100644 index 0000000000..623c663291 --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/agent.manifest.yaml @@ -0,0 +1,24 @@ +name: agent-framework-agent-with-hyperlight-codeact-responses +description: > + An Agent Framework agent with a Hyperlight CodeAct sandbox hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Hyperlight CodeAct +template: + name: agent-framework-agent-with-hyperlight-codeact-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/container/hyperlight_codeact/agent.yaml b/python/samples/04-hosting/container/hyperlight_codeact/agent.yaml new file mode 100644 index 0000000000..3355fb51ab --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/agent.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: agent-framework-agent-with-hyperlight-codeact-responses +description: | + An Agent Framework agent with a Hyperlight CodeAct sandbox hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Hyperlight CodeAct +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "1" + memory: 2Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: gpt-4.1-mini diff --git a/python/samples/04-hosting/container/hyperlight_codeact/call_server.py b/python/samples/04-hosting/container/hyperlight_codeact/call_server.py new file mode 100644 index 0000000000..e57da7c086 --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/call_server.py @@ -0,0 +1,41 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "openai>=1.50,<3", +# "azure-identity>=1.19,<2", +# ] +# /// +# Run with: uv run call_server.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Call the deployed Hyperlight CodeAct Foundry hosted agent via the OpenAI client.""" + +import os + +from azure.identity import AzureCliCredential +from openai import OpenAI + +# Set FOUNDRY_AGENT_ENDPOINT to your deployed agent endpoint, e.g. +# https://.services.ai.azure.com/api/projects//agents/ +ENDPOINT = os.environ.get( + "FOUNDRY_AGENT_ENDPOINT", + "https://.services.ai.azure.com" + "/api/projects//agents/", +) +SCOPE = "https://ai.azure.com/.default" +PROMPT = ( + "Fetch all users, find the admins, multiply 7 by 6, and print the users, " + "admins and multiplication result. Use execute_code with call_tool(...)." +) + + +def main() -> None: + token = AzureCliCredential().get_token(SCOPE).token + client = OpenAI(base_url=ENDPOINT, api_key=token, default_query={"api-version": "v1"}) + response = client.responses.create(model="hosted-agent", input=PROMPT) + print(response.output_text) + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/container/hyperlight_codeact/main.py b/python/samples/04-hosting/container/hyperlight_codeact/main.py new file mode 100644 index 0000000000..c84c355560 --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/main.py @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from typing import Annotated, Any, Literal + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient +from agent_framework.hyperlight import HyperlightCodeActProvider +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +@tool(approval_mode="never_require") +def compute( + operation: Annotated[ + Literal["add", "subtract", "multiply", "divide"], + "Math operation: add, subtract, multiply, or divide.", + ], + a: Annotated[float, "First numeric operand."], + b: Annotated[float, "Second numeric operand."], +) -> float: + """Perform a math operation for sandboxed code.""" + operations = { + "add": a + b, + "subtract": a - b, + "multiply": a * b, + "divide": a / b if b else float("inf"), + } + return operations[operation] + + +@tool(approval_mode="never_require") +async def fetch_data( + table: Annotated[str, "Name of the simulated table to query."], +) -> list[dict[str, Any]]: + """Fetch records from a named table.""" + await asyncio.sleep(0.5) + data: dict[str, list[dict[str, Any]]] = { + "users": [ + {"id": 1, "name": "Alice", "role": "admin"}, + {"id": 2, "name": "Bob", "role": "user"}, + {"id": 3, "name": "Charlie", "role": "admin"}, + ], + "products": [ + {"id": 101, "name": "Widget", "price": 9.99}, + {"id": 102, "name": "Gadget", "price": 19.99}, + ], + } + return data.get(table, []) + + +def main(): + # 1. Create the Foundry chat client. + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), + function_invocation_configuration={"include_detailed_errors": True}, + ) + + # 2. Register sandbox tools on a Hyperlight CodeAct provider. The model only + # sees `execute_code`; `compute` and `fetch_data` are reachable from + # inside the sandbox via `call_tool(...)`. + codeact = HyperlightCodeActProvider( + tools=[compute, fetch_data], + approval_mode="never_require", + ) + + # 3. Build the agent. History is managed by the hosting infrastructure, so + # request the model not to persist server-side conversation state. + agent = Agent( + client=client, + instructions="You are a helpful assistant. Keep your answers brief.", + context_providers=[codeact], + default_options={"store": False}, + ) + + # 4. Serve the agent over the Foundry Responses protocol. + server = ResponsesHostServer(agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/container/hyperlight_codeact/requirements.txt b/python/samples/04-hosting/container/hyperlight_codeact/requirements.txt new file mode 100644 index 0000000000..aab05449a3 --- /dev/null +++ b/python/samples/04-hosting/container/hyperlight_codeact/requirements.txt @@ -0,0 +1,3 @@ +# agent-framework, agent-framework-foundry-hosting, and agent-framework-hyperlight +# are installed from local source by the Dockerfile (build context = python/). +# Add any sample-only third-party deps here. diff --git a/python/samples/04-hosting/foundry-hosted-agents/README.md b/python/samples/04-hosting/foundry-hosted-agents/README.md index 974b1cc975..fedde797d4 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/README.md @@ -222,4 +222,4 @@ This will package your agent and deploy it to the Foundry environment, making it For the full deployment guide, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). -Once deployed, learn more about how to manage deployed agents in the [official management guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/manage-hosted-agent). \ No newline at end of file +Once deployed, learn more about how to manage deployed agents in the [official management guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/manage-hosted-agent). diff --git a/python/uv.lock b/python/uv.lock index 85a968174a..b2b5106c48 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -379,6 +379,7 @@ all = [ { name = "agent-framework-foundry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-foundry-local", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-github-copilot", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "agent-framework-hyperlight", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -407,6 +408,7 @@ requires-dist = [ { name = "agent-framework-foundry", marker = "extra == 'all'", editable = "packages/foundry" }, { name = "agent-framework-foundry-local", marker = "extra == 'all'", editable = "packages/foundry_local" }, { name = "agent-framework-github-copilot", marker = "python_full_version >= '3.11' and extra == 'all'", editable = "packages/github_copilot" }, + { name = "agent-framework-hyperlight", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'all') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'all')", editable = "packages/hyperlight" }, { name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, { name = "agent-framework-ollama", marker = "extra == 'all'", editable = "packages/ollama" }, @@ -601,7 +603,7 @@ requires-dist = [ [[package]] name = "agent-framework-hyperlight" -version = "1.0.0a260429" +version = "1.0.0b260501" source = { editable = "packages/hyperlight" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -613,9 +615,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "hyperlight-sandbox", specifier = ">=0.3.0,<0.4" }, - { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')", specifier = ">=0.3.0,<0.4" }, - { name = "hyperlight-sandbox-python-guest", specifier = ">=0.3.0,<0.4" }, + { name = "hyperlight-sandbox", specifier = ">=0.4.0,<0.5" }, + { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')", specifier = ">=0.4.0,<0.5" }, + { name = "hyperlight-sandbox-python-guest", specifier = ">=0.4.0,<0.5" }, ] [[package]] @@ -1636,7 +1638,7 @@ name = "clr-loader" version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } wheels = [ @@ -2949,35 +2951,35 @@ wheels = [ [[package]] name = "hyperlight-sandbox" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/fe/ce88996ea3e3e05130d6f0e8cd2ffbe9ab9bf3d9448b7050d4b8d0802b0a/hyperlight_sandbox-0.3.0.tar.gz", hash = "sha256:00491ce267ffbdb206377c79b4afd86510177ad73f4daf2ef7fce02b54eaf801", size = 9251, upload-time = "2026-04-07T03:49:52.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/5e/14c69eac7e1c74fbd556c6f890729a3d232d32d65cd9f8cfde72c0534e61/hyperlight_sandbox-0.4.0.tar.gz", hash = "sha256:90d7b91d4d8e17054e282b0daed55c261392a748dafc57e6416d3184cdac910b", size = 9262, upload-time = "2026-05-02T00:00:02.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/33/e6dcd6729308d13570ae2d3be0e476019a6f3fea387d7549bb1f77ce0408/hyperlight_sandbox-0.3.0-py3-none-any.whl", hash = "sha256:ba8e6779d64e9c187acd93456851ebafaed2f49380e5d132bc0906a4080d2217", size = 5723, upload-time = "2026-04-07T03:49:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e3/b8c106a274c08a30261105afa5511e0ec55960e86b2f6c51e3095e96647c/hyperlight_sandbox-0.4.0-py3-none-any.whl", hash = "sha256:7ae44d2448ed6ecadb368373c7e45eb395521e7774c86a1cbc1ef9cdfc25cd2a", size = 5723, upload-time = "2026-05-02T00:00:03.811Z" }, ] [[package]] name = "hyperlight-sandbox-backend-wasm" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/91/c9d68cad7996fdd2f1facef1453156bdd8d52eefa976cc8c827c13029497/hyperlight_sandbox_backend_wasm-0.3.0-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:eda362f5f737b0823326290d7627c76ce0547a78e70f07f8c9d177e34622fc02", size = 3806454, upload-time = "2026-04-07T03:49:24.238Z" }, - { url = "https://files.pythonhosted.org/packages/9a/6f/6b2399a1caf59dd19b635d99ee1add0c975af7bc3317f5d0f1f9c3f90aa0/hyperlight_sandbox_backend_wasm-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:79347b7ae94f2786691b04cb52130dabc5991e0c03b42a24bad8adc766832655", size = 3283951, upload-time = "2026-04-07T03:49:17.137Z" }, - { url = "https://files.pythonhosted.org/packages/23/f2/b380c34a0ce8d486a05adb66757f98cca029e1fb1c96b1c29be0d25d3882/hyperlight_sandbox_backend_wasm-0.3.0-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:aff9eec4803fb535a140298e2632529f4150fcf3c6ea3ff2ae4571572a836116", size = 3806601, upload-time = "2026-04-07T03:49:22.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5a/fb78cfd934e0523887b8d5b073b7b2aed3b545add21cda3aa95929ac1659/hyperlight_sandbox_backend_wasm-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:b6151704dd19862c9869b115752b4504b45d0b2eeb46aa9385a1a3b8be11cfa8", size = 3284164, upload-time = "2026-04-07T03:49:18.556Z" }, - { url = "https://files.pythonhosted.org/packages/21/bc/4e21f5c7ccd9307ac63a61c71b62a57ee4a9e6eec77fc72ff072907a21f5/hyperlight_sandbox_backend_wasm-0.3.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:cfd1d22ce221774d82a5174d268d56ff70fc1a23fb993a6491358b5d0ed169bf", size = 3802901, upload-time = "2026-04-07T03:49:19.845Z" }, - { url = "https://files.pythonhosted.org/packages/9a/41/646be9b0c7bb0f9192e45a77414673aa414eb316c92b5312efe6fb4ce802/hyperlight_sandbox_backend_wasm-0.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:229ab494a422f2de895a2a27ad6a6a2daed710ea062d7c213878bbe5f5b32fa7", size = 3281220, upload-time = "2026-04-07T03:49:21.368Z" }, - { url = "https://files.pythonhosted.org/packages/74/3a/f8ec4a41fffba4036dfc3cbddc3dfb6e87466b01afe1cb0a50cc6a0f0eed/hyperlight_sandbox_backend_wasm-0.3.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:b91905ee2ddd36a78b0dd13b1a62be99a995a45121587c111692591e40b36912", size = 3802789, upload-time = "2026-04-07T03:49:15.614Z" }, - { url = "https://files.pythonhosted.org/packages/3c/62/dfa8c15102f9b8ec5c3b5ffb54b99d60c75e7a6e4d00540757656bc5a5d8/hyperlight_sandbox_backend_wasm-0.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:eff682761c3b86abfe7e0d523ea0e6d5c7e8299302917c53918743b82c9d1ea2", size = 3280501, upload-time = "2026-04-07T03:49:13.939Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7c/355864a5bc814eeb8788ea35eb2cdf6c60f34afd7a7cd4433a368f26f60e/hyperlight_sandbox_backend_wasm-0.4.0-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:5b5da47c21aeebd2a7bb9394bbd481fe285fafc5b48d1f3685034e203219bcb5", size = 3921265, upload-time = "2026-05-01T23:59:20.42Z" }, + { url = "https://files.pythonhosted.org/packages/46/ec/628fafcbf1483f86df6bf9412b23da68b5cd1156d91b0998ed5c62d0b9c4/hyperlight_sandbox_backend_wasm-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:c514e662fed41ee8222b09da5f1130ac856c91cc4523b354bb8e7b96da352611", size = 3387473, upload-time = "2026-05-01T23:59:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/09812f51a02e39236bfb5bfc40b4021e98f07e2f32f8d6a72745884d49f8/hyperlight_sandbox_backend_wasm-0.4.0-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:c0935698f58a144150000e978e061fa78daa6f2ac1861980b32571f7c27a53fd", size = 3921230, upload-time = "2026-05-01T23:59:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/56181b5a21c17ad4636686de3463706a85883aa70dd3b1b160dd7e95627b/hyperlight_sandbox_backend_wasm-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b9486b7617615334d4b06d9462ad57edfe3f161d40d880b866db7d240b4d04e", size = 3388119, upload-time = "2026-05-01T23:59:21.966Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/3cdf21594eb28de7ca1a5a1ade27e137c8f3d7ab48d65fed87a3b74c4039/hyperlight_sandbox_backend_wasm-0.4.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:ff4627950708909202ee24c6175dc41e9c05479f89393575e3de0f14e6f5a193", size = 3918189, upload-time = "2026-05-01T23:59:16.666Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/b1bb9893bbeb979d133dc542520125dcbf8394d1a2537e753118b37c7cab/hyperlight_sandbox_backend_wasm-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cce7dc28b9ded034a11a9a8cf7b9ffb838e29006be8d2e01646dd131ba501b73", size = 3383520, upload-time = "2026-05-01T23:59:27.261Z" }, + { url = "https://files.pythonhosted.org/packages/8c/29/deee4e31086628750f0ce1f67da1e28c613fd2df68465de130cbfe51e72d/hyperlight_sandbox_backend_wasm-0.4.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:88e194515e4784f68676b6906c98a4000f913c93172cf07981d8a977e756bbd6", size = 3917939, upload-time = "2026-05-01T23:59:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/15/2a/6822aec3c04c46893406d0d6ed576dbdb4b5c1d76a0124dc220bb45b0d34/hyperlight_sandbox_backend_wasm-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:d1cd2269a5651ea9be1f94a3e3388f6af69e41dbc2b808c3b806481fe17ce163", size = 3383110, upload-time = "2026-05-01T23:59:23.736Z" }, ] [[package]] name = "hyperlight-sandbox-python-guest" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/6a/f182c4315d31a98dd3b82f9274638e3adb399779584af93c5087bb2f814f/hyperlight_sandbox_python_guest-0.3.0.tar.gz", hash = "sha256:b1de5d8e87375dc6bef744ecd7ae2a7f43d5f6b913b4e990e9872bd439c0b19e", size = 21554625, upload-time = "2026-04-07T03:49:42.672Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fd/816d1f3f277ff149a45da5381967aa04c22bc7702b5c14f0acfd9db2cee7/hyperlight_sandbox_python_guest-0.4.0.tar.gz", hash = "sha256:64c3c6c13fe550bf5b680fa0b965cf62bc4668084cc275c3467e3c015e6ead36", size = 21657381, upload-time = "2026-05-01T23:59:46.589Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/8e/4cd754928464f56528645c7421ccbb3fcbe45ad2542f899712b0f2f2c0e1/hyperlight_sandbox_python_guest-0.3.0-py3-none-any.whl", hash = "sha256:3c55a7420666ad9a208893dbdf7ad1b5c8ad4f3a94b1a56e64979719c7ce95c1", size = 21716481, upload-time = "2026-04-07T03:49:39.885Z" }, + { url = "https://files.pythonhosted.org/packages/98/ba/efb9aacf993f0ac142da5beb9177b221e49dc860c6ea398de236015a52a0/hyperlight_sandbox_python_guest-0.4.0-py3-none-any.whl", hash = "sha256:0789eb794b99606288402ed3921b5e2630800a69d24117ecd9b82e816568202d", size = 21822062, upload-time = "2026-05-01T23:59:50.99Z" }, ] [[package]] @@ -5132,8 +5134,8 @@ name = "powerfx" version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" } wheels = [ @@ -5806,7 +5808,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [