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
@@ -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())
|
||||
@@ -1,188 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
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
|
||||
registers them only with `HyperlightCodeActProvider`. The model therefore sees a
|
||||
single `execute_code` tool and must call the provider-owned tools from inside
|
||||
the sandbox with `call_tool(...)`.
|
||||
"""
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_CYAN = "\033[36m"
|
||||
_YELLOW = "\033[33m"
|
||||
_GREEN = "\033[32m"
|
||||
_DIM = "\033[2m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
class _ColoredFormatter(logging.Formatter):
|
||||
"""Dim logger output so it does not compete with sample prints."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return f"{_DIM}{super().format(record)}{_RESET}"
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logging.getLogger().handlers[0].setFormatter(
|
||||
_ColoredFormatter("[%(asctime)s] %(levelname)s: %(message)s"),
|
||||
)
|
||||
|
||||
|
||||
@function_middleware
|
||||
async def log_function_calls(
|
||||
context: FunctionInvocationContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Log tool calls, including readable execute_code blocks."""
|
||||
import time
|
||||
|
||||
function_name = context.function.name
|
||||
arguments = context.arguments if isinstance(context.arguments, dict) else {}
|
||||
|
||||
if function_name == "execute_code" and "code" in arguments:
|
||||
print(f"\n{_YELLOW}{'─' * 60}")
|
||||
print("▶ execute_code")
|
||||
print(f"{'─' * 60}{_RESET}")
|
||||
print(arguments["code"])
|
||||
print(f"{_YELLOW}{'─' * 60}{_RESET}")
|
||||
else:
|
||||
pairs = ", ".join(f"{name}={value!r}" for name, value in arguments.items())
|
||||
print(f"\n{_YELLOW}▶ {function_name}({pairs}){_RESET}")
|
||||
|
||||
start = time.perf_counter()
|
||||
await call_next()
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
result = context.result
|
||||
if function_name == "execute_code" and isinstance(result, list):
|
||||
for output in result:
|
||||
if output.type == "text" and output.text:
|
||||
print(f"{_GREEN}stdout:\n{output.text}{_RESET}")
|
||||
elif output.type == "error" and output.error_details:
|
||||
print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}")
|
||||
else:
|
||||
print(f"{_YELLOW}◀ {function_name} → {result!r}{_RESET}")
|
||||
|
||||
print(f"{_DIM} ({elapsed:.4f}s){_RESET}")
|
||||
|
||||
|
||||
@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, [])
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the provider-owned Hyperlight CodeAct sample."""
|
||||
# 1. Create the Hyperlight-backed provider and register sandbox tools on it.
|
||||
codeact = HyperlightCodeActProvider(
|
||||
tools=[compute, fetch_data],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# 2. Create the client and the agent.
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
name="HyperlightCodeActProviderAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
context_providers=[codeact],
|
||||
middleware=[log_function_calls],
|
||||
)
|
||||
|
||||
# 3. Run a request that should use execute_code plus provider-owned tools.
|
||||
query = (
|
||||
"Fetch all users, find admins, multiply 7*(3*2), and print the users, "
|
||||
"admins, and multiplication result. Use execute_code and call_tool(...) "
|
||||
"inside the sandbox."
|
||||
)
|
||||
print(f"{_CYAN}{'=' * 60}")
|
||||
print("Hyperlight CodeAct provider sample")
|
||||
print(f"{'=' * 60}{_RESET}")
|
||||
print(f"{_CYAN}User: {query}{_RESET}")
|
||||
result = await agent.run(query)
|
||||
print(f"{_CYAN}Agent: {result.text}{_RESET}")
|
||||
|
||||
|
||||
"""
|
||||
Sample output (shape only):
|
||||
|
||||
============================================================
|
||||
Hyperlight CodeAct provider sample
|
||||
============================================================
|
||||
User: Fetch all users, find admins, multiply 7*(3*2), ...
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
▶ execute_code
|
||||
────────────────────────────────────────────────────────────
|
||||
users = call_tool("fetch_data", table="users")
|
||||
admins = [user for user in users if user["role"] == "admin"]
|
||||
result = call_tool("compute", operation="multiply", a=7, b=6)
|
||||
print("Users:", users)
|
||||
print("Admins:", admins)
|
||||
print("7 * 6 =", result)
|
||||
────────────────────────────────────────────────────────────
|
||||
stdout:
|
||||
Users: [...]
|
||||
Admins: [...]
|
||||
7 * 6 = 42.0
|
||||
(0.0xxx s)
|
||||
Agent: ...
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,133 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
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
|
||||
sample creates a `HyperlightExecuteCodeTool` directly, extracts its CodeAct
|
||||
instructions once, and passes both to the `Agent` constructor at build time.
|
||||
|
||||
This avoids the per-run provider lifecycle (`before_run` / `after_run`) and is
|
||||
well-suited when the tool registry, file mounts, and network allow-list are
|
||||
fixed for the agent's lifetime. The tradeoff is that dynamic tool or capability
|
||||
changes between runs are not supported — any mutations to the tool would not
|
||||
update the agent's instructions automatically.
|
||||
"""
|
||||
|
||||
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 used by 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")
|
||||
def fetch_data(
|
||||
table: Annotated[str, "Name of the simulated table to query."],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch simulated records from a named table."""
|
||||
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, [])
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def send_email(
|
||||
to: Annotated[str, "Recipient email address."],
|
||||
subject: Annotated[str, "Email subject line."],
|
||||
body: Annotated[str, "Email body text."],
|
||||
) -> str:
|
||||
"""Simulate sending an email (direct-only tool, not available inside the sandbox)."""
|
||||
return f"Email sent to {to}: {subject}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the manual static-wiring sample."""
|
||||
# 1. Create the execute_code tool and register sandbox tools on it.
|
||||
execute_code = HyperlightExecuteCodeTool(
|
||||
tools=[compute, fetch_data],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# 2. Build CodeAct instructions once. Setting tools_visible_to_model=False
|
||||
# tells the instructions builder that sandbox tools are not in the agent's
|
||||
# direct tool list, so the model must use call_tool(...) inside execute_code.
|
||||
codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False)
|
||||
|
||||
# 3. Create the client and the agent with everything wired at construction time.
|
||||
# - send_email is a direct-only tool (not available inside the sandbox).
|
||||
# - execute_code carries sandbox tools (compute, fetch_data) via call_tool.
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
name="ManualWiringAgent",
|
||||
instructions=f"You are a helpful assistant.\n\n{codeact_instructions}",
|
||||
tools=[send_email, execute_code],
|
||||
)
|
||||
|
||||
# 4. Run a request that exercises both the sandbox and the direct tool.
|
||||
print("=" * 60)
|
||||
print("Manual static-wiring CodeAct sample")
|
||||
print("=" * 60)
|
||||
query = (
|
||||
"Fetch all users, find admins, multiply 6*7, and print the users, admins, "
|
||||
"and multiplication result. Use one execute_code call. "
|
||||
"Then send an email to admin@example.com summarising the results."
|
||||
)
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
"""
|
||||
Sample output (shape only):
|
||||
|
||||
============================================================
|
||||
Manual static-wiring CodeAct sample
|
||||
============================================================
|
||||
User: Fetch all users, find admins, multiply 6*7, ...
|
||||
Agent: ...
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,110 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
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
|
||||
own description advertises `call_tool(...)`, the registered sandbox tools, and
|
||||
the current capability configuration, so no extra CodeAct-specific agent
|
||||
instructions are required.
|
||||
"""
|
||||
|
||||
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 used by 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")
|
||||
def fetch_data(
|
||||
table: Annotated[str, "Name of the simulated table to query."],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch simulated records from a named table."""
|
||||
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, [])
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the standalone execute_code sample."""
|
||||
# 1. Create the packaged execute_code tool and register sandbox tools on it.
|
||||
execute_code = HyperlightExecuteCodeTool(
|
||||
tools=[compute, fetch_data],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# 2. Create the client and the agent.
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
name="HyperlightExecuteCodeToolAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
tools=execute_code,
|
||||
)
|
||||
|
||||
# 3. Run one request through the direct-tool surface.
|
||||
print("=" * 60)
|
||||
print("Hyperlight execute_code tool sample")
|
||||
print("=" * 60)
|
||||
query = (
|
||||
"Fetch all users, find admins, multiply 6*7, and print the users, admins, "
|
||||
"and multiplication result. Use one execute_code call."
|
||||
)
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
"""
|
||||
Sample output (shape only):
|
||||
|
||||
============================================================
|
||||
Hyperlight execute_code tool sample
|
||||
============================================================
|
||||
User: Fetch all users, find admins, multiply 6*7, ...
|
||||
Agent: ...
|
||||
"""
|
||||
|
||||
|
||||
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 == []
|
||||
|
||||
Reference in New Issue
Block a user