mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Fix hyperlight WasmSandbox cross-thread Drop and harden hosted-agent sample (#5603)
* update hyperlight to beta and move samples, add hosted agent sample * Python: Fix hyperlight WasmSandbox cross-thread Drop and harden sample Root cause: when a worker-side closure raised, the exception's __traceback__ retained frame locals that included the partially constructed PyO3 sandbox. Future.result() re-raised that exception on the caller thread, and when the caller's exception was eventually GC'd the frame locals were released off-thread, dec_ref'ing the unsendable sandbox from the wrong thread and tripping the PyO3 panic '_native_wasm::WasmSandbox is unsendable, but is being dropped on another thread'. Fix: * Add _SandboxWorker._run_on_worker which catches every exception on the worker, drops __traceback__ there, deletes the original exception, and re-raises a fresh instance on the caller thread. initialize and execute route through it; dispose keeps its bare-submit semantics. * Add an opt-in diagnostic module _drop_diagnostic (no-op unless HYPERLIGHT_TRACE_DROPS=1) that installs a sys.unraisablehook and dumps owner-thread + per-thread stacks on any future cross-thread unsendable Drop. Useful for triaging similar PyO3 regressions. * Tests: cross-thread invocation, traceback-leak isolation, _SandboxEntry attribute-shape check, and a stale-reference stress test driven through asyncio.to_thread. Sample (samples/04-hosting/foundry-hosted-agents/responses/06_hyperlight_codeact): * Dockerfile installs agent-framework-* from in-tree source with python/ as build context so unreleased fixes can be validated end-to-end. * call_server.py pins the Responses API version. * main.py enables include_detailed_errors=True so future tool failures surface the actual exception text instead of a bare 'Error: Function failed.' string. * README.md documents the in-tree-package build and the Hyperlight hypervisor requirement (/dev/kvm on Linux, MSHV on Windows). Hosted environments without hypervisor passthrough surface 'No Hypervisor was found for Sandbox'; this is a hosting constraint, not a hyperlight bug. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: remove _drop_diagnostic from hyperlight package The diagnostic module was useful while bisecting the cross-thread Drop bug, but it is no longer needed now that _SandboxWorker._run_on_worker prevents the panic at the source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: address PR review feedback on hyperlight - Use lazy agent_framework.hyperlight import in sample main.py. - Env-driven endpoint (FOUNDRY_AGENT_ENDPOINT) in call_server.py; remove personal URLs. - Align agent.yaml model deployment with manifest (gpt-4.1-mini). - Tighten Dockerfile requirements guard; drop dangling deploy.ps1 reference. - Preserve exception args when sanitizing tracebacks in _run_on_worker. - Add public _SandboxWorker.is_alive(); update test to avoid private attr. - Add namespace coverage tests for agent_framework.hyperlight lazy loader. - Add prominent note: Foundry hosted-agent runtime does not yet support Hyperlight (no hypervisor exposed); container works locally with /dev/kvm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: bump hyperlight-sandbox dependencies to 0.4.x Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: renumber hyperlight codeact sample to 08 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Coerce worker exception args to strings for cross-thread safety Stringify exc.args on the worker thread before propagating, so any PyO3 unsendable object captured in args (e.g. via a caller-supplied callback or underlying SDK) cannot be Dropped on the calling thread. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * moved sample --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
36b9b41e3b
commit
57c901a245
@@ -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` |
|
||||
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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=<none>"
|
||||
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())
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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.
|
||||
+1
-2
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
+1
-2
@@ -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
|
||||
+1
-2
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
@@ -0,0 +1,2 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
@@ -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 <acr>.azurecr.io/<image>:<tag> \
|
||||
# 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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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://<your-foundry-resource>.services.ai.azure.com/api/projects/<project>/agents/<agent-name>
|
||||
ENDPOINT = os.environ.get(
|
||||
"FOUNDRY_AGENT_ENDPOINT",
|
||||
"https://<your-foundry-resource>.services.ai.azure.com"
|
||||
"/api/projects/<project>/agents/<agent-name>",
|
||||
)
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
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).
|
||||
|
||||
Generated
+25
-23
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user