diff --git a/examples/agent-framework-local/.gitignore b/examples/agent-framework-local/.gitignore new file mode 100644 index 0000000..5b849d4 --- /dev/null +++ b/examples/agent-framework-local/.gitignore @@ -0,0 +1,2 @@ +.unikraft/ +agent-framework-local-initrd.cpio diff --git a/examples/agent-framework-local/Dockerfile b/examples/agent-framework-local/Dockerfile new file mode 100644 index 0000000..558a9df --- /dev/null +++ b/examples/agent-framework-local/Dockerfile @@ -0,0 +1,47 @@ +# agent-framework-local — a Microsoft Agent Framework agent doing REAL, fully +# offline inference inside a Hyperlight micro-VM. +# +# Bundles llama-cpp-python + a small Qwen2.5-0.5B-Instruct GGUF into the CPIO, +# so the model runs entirely in-guest — no network, no API keys. + +ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest +ARG DEPS_BASE=local-python-base-dev:latest + +# Stage 1: python deps — agent-framework-core + llama-cpp-python. Two constraints +# drive the build: (1) the guest only exposes SSE (no AVX), and (2) native deps +# should be built against the same Ubuntu 22.04 / glibc 2.35 ABI as python-base. +# The Justfile materializes DEPS_BASE from the python-dev stage in +# ../../runtimes/python.Dockerfile. +FROM ${DEPS_BASE} AS deps +RUN apt-get update && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* +RUN python3.12 -m pip install --no-cache-dir cmake +RUN python3.12 -m pip install --target=/deps --no-cache-dir --prefer-binary agent-framework-core +# SSE-only, no AVX/AVX2/AVX512/FMA/F16C (all VEX-encoded and unsupported here), +# and no OpenMP (avoids a libgomp runtime dependency; ggml falls back to pthreads). +ENV CMAKE_ARGS="-DGGML_NATIVE=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_AVX512=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF -DGGML_OPENMP=OFF" +RUN python3.12 -m pip install --target=/deps --no-cache-dir --no-binary llama-cpp-python llama-cpp-python +RUN set -eux; \ + find /deps -maxdepth 1 -type d -name '*.dist-info' -exec rm -rf {} + 2>/dev/null || true; \ + find /deps \( -type d -name tests -o -type d -name test \) -exec rm -rf {} + 2>/dev/null || true; \ + find /deps -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true; \ + find /deps -type f -name '*.pyc' -delete 2>/dev/null || true + +# Stage 2: download the GGUF model (kept in its own stage for layer caching). +FROM debian:bookworm-slim AS model +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +ARG MODEL_URL=https://huggingface.co/bartowski/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/Qwen2.5-0.5B-Instruct-Q4_K_M.gguf +RUN curl -fL --retry 3 -o /model.gguf "${MODEL_URL}" + +# Stage 3: assemble rootfs. +FROM ${BASE} AS rootfs +COPY --from=deps /deps /usr/local/lib/python3.12/site-packages +COPY --from=model /model.gguf /model.gguf +COPY agent.py /agent.py + +# Stage 4: pack as CPIO. +FROM alpine:3.20 AS cpio +RUN apk add --no-cache cpio findutils +COPY --from=rootfs / /rootfs/ +RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/examples/agent-framework-local/Justfile b/examples/agent-framework-local/Justfile new file mode 100644 index 0000000..78ed4f8 --- /dev/null +++ b/examples/agent-framework-local/Justfile @@ -0,0 +1,65 @@ +# agent-framework-local on Hyperlight +# +# A Microsoft Agent Framework agent doing REAL, fully offline inference with a +# small GGUF model (llama-cpp-python) baked into the image — no network, no keys. +# +# just build - Fetch the (shared python-agent) kernel from GHCR +# just rootfs - Build the initrd CPIO (installs llama-cpp-python + model) +# just run - Execute agent.py inside the micro-VM +# just clean - Remove build artifacts + +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] +export DOCKER_BUILDKIT := "0" + +kernel := ".unikraft/build/agent-framework-local-hyperlight_hyperlight-x86_64" +initrd := "agent-framework-local-initrd.cpio" +memory := "3Gi" +image := "agent-framework-local-hyperlight" +base := "local-python-base:latest" +deps_base := "local-python-base-dev:latest" +ghcr_kernel := "ghcr.io/hyperlight-dev/hyperlight-unikraft/python-agent-kernel:latest" + +run: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /agent.py + +rootfs: python-base + docker build --platform linux/amd64 --build-arg BASE={{base}} --build-arg DEPS_BASE={{deps_base}} --target cpio -t {{image}}-cpio . + - docker rm -f {{image}}-tmp + docker create --name {{image}}-tmp {{image}}-cpio /bin/true + docker cp {{image}}-tmp:/output.cpio ./{{initrd}} + docker rm -f {{image}}-tmp + +# Build matching Ubuntu 22.04 / glibc 2.35 Python dev/runtime base images locally. +python-base: + docker build --platform linux/amd64 --target python-dev -t {{deps_base}} -f ../../runtimes/python.Dockerfile ../../runtimes + docker build --platform linux/amd64 -t {{base}} -f ../../runtimes/python.Dockerfile ../../runtimes + +# Fetch the prebuilt kernel from GHCR (reused from the python-agent example). +[unix] +build: + docker pull {{ghcr_kernel}} + - docker rm -f {{image}}-kernel-tmp + docker create --name {{image}}-kernel-tmp {{ghcr_kernel}} /kernel + mkdir -p $(dirname {{kernel}}) + docker cp {{image}}-kernel-tmp:/kernel ./{{kernel}} + docker rm -f {{image}}-kernel-tmp + +[windows] +build: + docker pull {{ghcr_kernel}} + - docker rm -f {{image}}-kernel-tmp + docker create --name {{image}}-kernel-tmp {{ghcr_kernel}} /kernel + New-Item -ItemType Directory -Force -Path (Split-Path {{kernel}}) | Out-Null + docker cp {{image}}-kernel-tmp:/kernel ./{{kernel}} + docker rm -f {{image}}-kernel-tmp + +rebuild: clean build rootfs + +[unix] +clean: + rm -rf .unikraft {{initrd}} + +[windows] +clean: + if (Test-Path .unikraft) { Remove-Item -Recurse -Force .unikraft } + if (Test-Path {{initrd}}) { Remove-Item -Force {{initrd}} } diff --git a/examples/agent-framework-local/README.md b/examples/agent-framework-local/README.md new file mode 100644 index 0000000..aa73e86 --- /dev/null +++ b/examples/agent-framework-local/README.md @@ -0,0 +1,55 @@ +# agent-framework-local on Hyperlight + +Run a [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) +agent doing **real, fully offline inference** inside a +[Hyperlight](https://github.com/hyperlight-dev/hyperlight) micro-VM. + +`agent.py` is a real `agent_framework` `Agent` backed by a custom `BaseChatClient` +that runs a small GGUF model with **llama-cpp-python**. The model is baked into the +image, so inference happens entirely in-guest — **no network, no API keys**. + +- Model: `Qwen2.5-0.5B-Instruct` (Q4_K_M, ~397 MB), swappable via the `MODEL_URL` + build arg in the `Dockerfile`. + +## Run + +```sh +just build # fetch the prebuilt Python kernel from GHCR +just rootfs # build the initrd CPIO (compiles llama.cpp + bakes in the model) +just run # run the agent inside the micro-VM +``` + +Example output (generation is ~4–5 tok/s: single vCPU, SSE-only): + +``` +User: In one sentence, what is a micro-VM? +Agent: A micro-VM refers to a lightweight virtual machine that is small enough to run on a single hardware device, typically a server or cloud instance. +``` + +## Why the build looks the way it does + +Getting a native inference stack to run on the trimmed `python-base` + +identity-mapped unikernel needed a few specific choices, all in the `Dockerfile` +and `agent.py`: + +- **SSE-only llama.cpp.** The guest CPU exposes SSE/SSE2/SSE4 but **not AVX/AVX2** + (numpy reports `AVX: False`). The prebuilt `llama-cpp-python` CPU wheels assume + AVX2 and crash with an illegal instruction, so we compile llama.cpp from source + with `-DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF`. +- **Matching glibc + OpenMP off.** `python-base` ships Ubuntu glibc 2.35, so the + Justfile builds `local-python-base-dev` from the `python-dev` stage in + `runtimes/python.Dockerfile` and compiles native deps against that same ABI. + We disable OpenMP + (`-DGGML_OPENMP=OFF`) to avoid a libgomp runtime dependency. +- **Small buffers, no mmap.** `n_ctx=512`, `n_batch=64`, and `use_mmap=False` keep + the compute buffers within the identity-mapped guest memory (larger values crash + it). Run with `--memory 3Gi`. +- **stdlib stubs.** `python-base` trims `multiprocessing` and `sqlite3`, which + llama-cpp-python imports transitively; `agent.py` registers minimal stubs since + neither is actually used here. +- **`run_sync` instead of `asyncio.run()`.** The unikernel has no + `socket.socketpair()`, which asyncio's event loop requires; the synchronous + inference call never suspends, so we step the coroutine directly. + +The kernel is generic (it runs `/usr/local/bin/python3 `), so this example +reuses the published `python-agent-kernel` instead of building its own. diff --git a/examples/agent-framework-local/agent.py b/examples/agent-framework-local/agent.py new file mode 100644 index 0000000..520c996 --- /dev/null +++ b/examples/agent-framework-local/agent.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""A Microsoft Agent Framework agent doing REAL, fully offline inference. + +The agent is a real `agent_framework` Agent, backed by a custom ChatClient that +runs a small GGUF model with llama-cpp-python. The model is baked into the +image, so inference happens entirely inside the Hyperlight micro-VM — no network +access and no API keys. +""" + +import logging +import os +import sys +import types +import warnings + +# Keep the demo output clean. +warnings.filterwarnings("ignore") +logging.getLogger("agent_framework").setLevel(logging.ERROR) + +# The trimmed python-base omits a few stdlib modules that llama-cpp-python imports +# transitively (multiprocessing via llama.py, sqlite3 via diskcache). This example +# never uses either — inference is single-process and we don't use the disk cache — +# so we register minimal stubs so the imports succeed. +_mp = types.ModuleType("multiprocessing") +_mp.cpu_count = lambda: os.cpu_count() or 1 +sys.modules.setdefault("multiprocessing", _mp) + +_sq = types.ModuleType("sqlite3") +_sq.Binary = memoryview + + +class _SqliteError(Exception): + pass + + +_sq.Error = _sq.OperationalError = _sq.DatabaseError = _SqliteError +_sq.IntegrityError = _sq.ProgrammingError = _SqliteError +_sq.connect = lambda *a, **k: None +_sq.register_adapter = _sq.register_converter = lambda *a, **k: None +_sq.PARSE_DECLTYPES = 1 +_sq.PARSE_COLNAMES = 2 +_sq.Row = object +_sq.sqlite_version = _sq.version = "0" +sys.modules.setdefault("sqlite3", _sq) + +from collections.abc import Awaitable, Coroutine, Mapping, Sequence # noqa: E402 +from typing import Any # noqa: E402 + +from agent_framework import ( # noqa: E402 + Agent, + BaseChatClient, + ChatResponse, + Message, +) +from llama_cpp import Llama # noqa: E402 + +MODEL_PATH = "/model.gguf" + + +class LlamaCppChatClient(BaseChatClient): + """Chat client backed by a local llama.cpp GGUF model.""" + + def __init__(self, model_path: str = MODEL_PATH, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._llm = Llama( + model_path=model_path, + n_ctx=512, + n_batch=64, # small compute buffers to fit the identity-mapped guest + n_threads=max(1, os.cpu_count() or 1), + use_mmap=False, # the guest ramfs doesn't support file-backed mmap + verbose=False, + ) + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool = False, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse]: + chat = [ + {"role": m.role, "content": m.text} + for m in messages + if m.text + ] + completion = self._llm.create_chat_completion( + messages=chat, + max_tokens=64, + temperature=0.7, + ) + reply = completion["choices"][0]["message"]["content"].strip() + response = ChatResponse( + messages=[Message(role="assistant", contents=[reply])], + model=os.path.basename(MODEL_PATH), + ) + + async def _get() -> ChatResponse: + return response + + return _get() + + +def run_sync(coro: Coroutine[Any, Any, Any]) -> Any: + """Run a coroutine that performs no real async I/O, without an event loop. + + `asyncio.run()` can't be used here: it builds a Unix selector event loop + whose wake-up self-pipe needs `socket.socketpair()`, which this unikernel + doesn't implement (ENOSYS). The client's inference call is synchronous and + never suspends, so we can just step the coroutine to completion. + """ + try: + while True: + pending = coro.send(None) + if pending is not None: + raise RuntimeError(f"coroutine requires an event loop (awaited {pending!r})") + except StopIteration as exc: + return exc.value + + +async def main() -> None: + agent = Agent( + client=LlamaCppChatClient(), + name="LocalHyperlightAgent", + instructions="You are a concise assistant. Answer in one short sentence.", + ) + query = "In one sentence, what is a vm?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.messages[0].text}") + + +if __name__ == "__main__": + run_sync(main()) diff --git a/examples/agent-framework-remote/.gitignore b/examples/agent-framework-remote/.gitignore new file mode 100644 index 0000000..2da237a --- /dev/null +++ b/examples/agent-framework-remote/.gitignore @@ -0,0 +1,4 @@ +.unikraft/ +agent-framework-remote-initrd.cpio +.pyhl/ +secrets/ diff --git a/examples/agent-framework-remote/Dockerfile b/examples/agent-framework-remote/Dockerfile new file mode 100644 index 0000000..fdadc25 --- /dev/null +++ b/examples/agent-framework-remote/Dockerfile @@ -0,0 +1,46 @@ +# agent-framework-remote (driver/pyhl stack, minimal rootfs) +# +# Builds a SMALL driver rootfs: the shipped hl_pydriver interpreter shim (extracted +# from the published driver image, so it stays version-matched to the GHCR driver +# kernel) + agent-framework-core only — dropping the ~1 GB preloaded data-science +# stack the general-purpose pyhl image carries. Gives the warm-snapshot speed of +# the driver stack with a ~60 MB initrd. + +ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest + +# Stage 1: agent-framework-core, trimmed. +FROM python:3.12-slim AS deps +RUN pip install --target=/deps --no-cache-dir --prefer-binary agent-framework-core +RUN set -eux; \ + find /deps -maxdepth 1 -type d -name '*.dist-info' -exec rm -rf {} + 2>/dev/null || true; \ + find /deps \( -type d -name tests -o -type d -name test \) -exec rm -rf {} + 2>/dev/null || true; \ + find /deps -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true; \ + find /deps -type f -name '*.pyc' -delete 2>/dev/null || true; \ + find /deps -name '*.so*' -exec strip --strip-unneeded {} + 2>/dev/null || true + +# Stage 2: extract just hl_pydriver (+ pydoc stub) from the shipped driver rootfs. +FROM ghcr.io/hyperlight-dev/hyperlight-unikraft/python-agent-driver-initrd:latest AS shipped +FROM alpine:3.20 AS extract +RUN apk add --no-cache cpio +COPY --from=shipped /initrd.cpio /i.cpio +RUN mkdir -p /x && cd /x && cpio -idm 'bin/hl_pydriver' 'usr/local/lib/python3.12/pydoc.py' < /i.cpio 2>/dev/null && ls -l /x/bin/hl_pydriver + +# Stage 3: minimal rootfs = python-base + hl_pydriver + agent-framework-core. +FROM ${BASE} AS rootfs +COPY --from=deps /deps /usr/local/lib/python3.12/site-packages +COPY --from=extract /x/bin/hl_pydriver /bin/hl_pydriver +COPY --from=extract /x/usr/local/lib/python3.12/pydoc.py /usr/local/lib/python3.12/pydoc.py + +# Stage 4: pack CPIO + DNS + CA certs for outbound TLS. +FROM alpine:3.20 AS cpio +RUN apk add --no-cache cpio findutils ca-certificates +COPY --from=rootfs / /rootfs/ +RUN mkdir -p /rootfs/etc && \ + echo "nameserver 8.8.8.8" > /rootfs/etc/resolv.conf && \ + echo "nameserver 8.8.4.4" >> /rootfs/etc/resolv.conf && \ + echo "hosts: files dns" > /rootfs/etc/nsswitch.conf && \ + echo "127.0.0.1 localhost" > /rootfs/etc/hosts +RUN mkdir -p /rootfs/etc/ssl/certs /rootfs/usr/lib/ssl && \ + cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ && \ + ln -sf /etc/ssl/certs/ca-certificates.crt /rootfs/usr/lib/ssl/cert.pem +RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/examples/agent-framework-remote/Justfile b/examples/agent-framework-remote/Justfile new file mode 100644 index 0000000..895e5e3 --- /dev/null +++ b/examples/agent-framework-remote/Justfile @@ -0,0 +1,61 @@ +# agent-framework-remote on Hyperlight (python-agent-driver / pyhl stack) +# +# A Microsoft Agent Framework agent that calls a REMOTE hosted model +# (GitHub Models, OpenAI-compatible) over the network from inside a micro-VM, +# using the shipped python-agent-driver + `pyhl` stack (warmed-interpreter +# snapshot + host-proxied sockets). The driver kernel/rootfs already include +# networking + hostfs, so no custom kernel build is needed. +# +# Uses the in-repo `pyhl` by default via `cargo run`; set PYHL=/path/to/pyhl to +# use an installed binary instead. +# +# just build - Fetch the shipped driver kernel from GHCR +# just rootfs - Build the initrd: shipped driver rootfs + agent-framework-core +# just setup - One-time: warm a Python snapshot (pyhl setup) +# just run - Run agent.py inside the micro-VM (needs a token; see below) +# just clean - Remove build artifacts +# +# Token: `just run` uses $GITHUB_TOKEN when set, otherwise `gh auth token`. +# pyhl passes it into the guest with `--env GITHUB_TOKEN` without writing it to disk. + +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] +export DOCKER_BUILDKIT := "0" + +kernel := ".unikraft/build/agent-framework-remote-driver_hyperlight-x86_64" +initrd := "agent-framework-remote-initrd.cpio" +image := "agent-framework-remote-cpio" +home := ".pyhl" +pyhl := env_var_or_default("PYHL", "cargo run --manifest-path ../../host/Cargo.toml --bin pyhl --") +ghcr_kernel := "ghcr.io/hyperlight-dev/hyperlight-unikraft/python-agent-driver-kernel:latest" + +# Fetch the shipped driver kernel from GHCR (has networking + hostfs). +build: + mkdir -p {{parent_directory(kernel)}} + - docker rm -f {{image}}-kernel-tmp + docker create --name {{image}}-kernel-tmp {{ghcr_kernel}} /kernel + docker cp {{image}}-kernel-tmp:/kernel ./{{kernel}} + docker rm -f {{image}}-kernel-tmp + +# Augment the shipped driver rootfs with agent-framework-core. +rootfs: + docker build --platform linux/amd64 --pull --target cpio -t {{image}} . + - docker rm -f {{image}}-tmp + docker create --name {{image}}-tmp {{image}} /bin/true + docker cp {{image}}-tmp:/output.cpio ./{{initrd}} + docker rm -f {{image}}-tmp + +# One-time: warm up + persist a Python snapshot (pyhl setup). +[unix] +setup: + {{pyhl}} setup --from . --dest {{home}} --force + +# Run the agent (restores the warm snapshot, ~2 s). +[unix] +run: + if [ -z "${GITHUB_TOKEN:-}" ]; then export GITHUB_TOKEN="$(gh auth token)"; fi; {{pyhl}} run --dest {{home}} agent.py --net-allow models.github.ai --env GITHUB_TOKEN --env GITHUB_MODELS_MODEL --env GITHUB_MODELS_MAX_COMPLETION_TOKENS --env GITHUB_MODELS_REASONING_EFFORT + +rebuild: clean build rootfs setup + +[unix] +clean: + rm -rf .unikraft {{initrd}} {{home}} diff --git a/examples/agent-framework-remote/README.md b/examples/agent-framework-remote/README.md new file mode 100644 index 0000000..4a0489b --- /dev/null +++ b/examples/agent-framework-remote/README.md @@ -0,0 +1,98 @@ +# agent-framework-remote on Hyperlight + +Run a [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) +agent that calls a **remote hosted model** — [GitHub Models](https://github.com/marketplace/models) +(OpenAI-compatible) — over the network from inside a +[Hyperlight](https://github.com/hyperlight-dev/hyperlight) micro-VM. + +This example uses the **python-agent-driver / `pyhl` stack**: a warmed CPython +interpreter that is snapshotted once and then restored per run (~2–3 s/run, no +kernel boot). The shipped driver kernel already includes **host-proxied +networking + hostfs**, so there is no custom kernel to build. The rootfs is kept +small (~64 MB) by taking only the shipped `hl_pydriver` interpreter shim plus +`agent-framework-core` — not the ~1 GB preloaded data-science stack the +general-purpose pyhl image carries. + +`agent.py` is a real `agent_framework` `Agent` backed by a custom `BaseChatClient` +that POSTs to the GitHub Models API. The token is provided at run time through +`pyhl run --env GITHUB_TOKEN` — never baked into the image or written to disk. + +## Prerequisites + +- Rust/Cargo, so the Justfile can run the in-repo **`pyhl`** with `cargo run`: + + ```sh + cargo build --manifest-path ../../host/Cargo.toml --bin pyhl + ``` + + To use an installed `pyhl` instead, point the Justfile at it: + + ```sh + export PYHL=pyhl + ``` + + (The published GHCR driver image is version-matched to released `pyhl` builds.) +- A token valid for GitHub Models in `$GITHUB_TOKEN`, or an authenticated + [`gh`](https://cli.github.com/) CLI so `just run` can call `gh auth token`. + To set the token explicitly: + + ```sh + export GITHUB_TOKEN="$(gh auth token)" + ``` + +## Run + +```sh +just build # fetch the shipped driver kernel from GHCR +just rootfs # build a ~64 MB initrd: shipped hl_pydriver + agent-framework-core +just setup # one-time: warm up + persist a Python snapshot (~24 s) +just run # run the agent (restores the snapshot, ~2-3 s) +``` + +Example output: + +``` +User: In one short sentence, what is Hyperlight? +Agent: Hyperlight is a technology that enables the transmission of data ... +``` + +To use a different model, set `GITHUB_MODELS_MODEL` (default `openai/gpt-4o-mini`). +For example: + +```sh +GITHUB_MODELS_MODEL=openai/gpt-5 just run +``` + +The example uses `max_completion_tokens` for compatibility with GPT-5 models. +For `openai/gpt-5*`, it defaults to `GITHUB_MODELS_MAX_COMPLETION_TOKENS=1024` +and `GITHUB_MODELS_REASONING_EFFORT=minimal`; set either variable before +`just run` to override those defaults. + +## How it works + +- **Shipped driver stack, minimal rootfs.** The `python-agent-driver` kernel + published to GHCR enables networking (`CONFIG_LIBPOSIX_SOCKET` + + `CONFIG_LIBHOSTSOCK`) and hostfs. `just build` pulls the kernel; `just rootfs` + extracts just the shipped `hl_pydriver` (so it stays version-matched to that + kernel) and lays it onto `python-base` with `agent-framework-core` added + (pydantic, an agent-framework dependency, is already in `python-base`). This + keeps the initrd ~64 MB instead of the ~1 GB general-purpose pyhl image. +- **`--net` + egress.** The guest has no network unless `--net` is passed. The + shipped rootfs already ships CA certificates + `/etc/resolv.conf` for outbound + TLS. The Justfile uses `--net-allow models.github.ai` so the guest can only + reach the GitHub Models endpoint. +- **Token via `--env`.** `just run` passes `$GITHUB_TOKEN` into the guest Python + environment with `pyhl run --env GITHUB_TOKEN`. If `GITHUB_MODELS_MODEL` is set, + the Justfile passes that through the same way. +- **Synchronous client.** Agent Framework provides `OpenAIChatCompletionClient` + for normal Python apps, but it uses the async OpenAI SDK. This example keeps a + tiny blocking `urllib` client and steps the coroutine directly instead of + `asyncio.run()`, keeping the guest free of an event loop (whose self-pipe needs + `socket.socketpair()`). + +## Note on the plain-`hyperlight-unikraft` alternative + +The GHCR kernels for the *non-driver* examples (e.g. `python-agent-kernel`) are +built **without** networking, so a plain `hyperlight-unikraft ... -- /script.py` +launch can't open sockets there. Networking requires either this driver stack or a +kernel built with the socket libraries (as in the `networking-py` example). diff --git a/examples/agent-framework-remote/agent.py b/examples/agent-framework-remote/agent.py new file mode 100644 index 0000000..e723ab7 --- /dev/null +++ b/examples/agent-framework-remote/agent.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""A Microsoft Agent Framework agent using a REMOTE hosted model. + +The agent is a real `agent_framework` Agent, backed by a custom ChatClient that +calls the GitHub Models inference API (OpenAI-compatible) over the network. It +runs inside a Hyperlight micro-VM (python-agent-driver / pyhl stack) with network +enabled; the API token is passed into the guest environment by `pyhl run --env` +(never baked into the image or written to disk). + +Agent Framework provides `OpenAIChatCompletionClient` for normal Python apps, +but that client uses the async OpenAI SDK. This example instead uses a small +synchronous `urllib` client and steps the coroutine directly rather than via +`asyncio.run()`, keeping it free of an event loop (whose self-pipe needs +`socket.socketpair()`, absent from the non-networking base kernels) and portable +across every kernel in this repo. +""" + +import json +import logging +import os +import urllib.error +import urllib.request +import warnings + +# Keep the demo output clean. +warnings.filterwarnings("ignore") +logging.getLogger("agent_framework").setLevel(logging.ERROR) + +from collections.abc import Awaitable, Coroutine, Mapping, Sequence # noqa: E402 +from typing import Any # noqa: E402 + +from agent_framework import ( # noqa: E402 + Agent, + BaseChatClient, + ChatResponse, + Message, +) + +ENDPOINT = "https://models.github.ai/inference/chat/completions" +MODEL = os.environ.get("GITHUB_MODELS_MODEL", "openai/gpt-4o-mini") +MAX_COMPLETION_TOKENS = int( + os.environ.get( + "GITHUB_MODELS_MAX_COMPLETION_TOKENS", + "1024" if MODEL.startswith("openai/gpt-5") else "128", + ) +) +REASONING_EFFORT = os.environ.get( + "GITHUB_MODELS_REASONING_EFFORT", + "minimal" if MODEL.startswith("openai/gpt-5") else "", +).strip() + + +class GitHubModelsChatClient(BaseChatClient): + """Chat client that calls the GitHub Models API synchronously via urllib.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._token = os.environ.get("GITHUB_TOKEN", "").strip() + if not self._token: + raise RuntimeError("GITHUB_TOKEN must be set on the host and passed with pyhl run --env GITHUB_TOKEN") + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool = False, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse]: + payload = { + "model": MODEL, + "messages": [{"role": m.role, "content": m.text} for m in messages if m.text], + "max_completion_tokens": MAX_COMPLETION_TOKENS, + } + if REASONING_EFFORT: + payload["reasoning_effort"] = REASONING_EFFORT + req = urllib.request.Request( + ENDPOINT, + data=json.dumps(payload).encode(), + headers={ + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + data = json.load(resp) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace").strip() + try: + message = json.loads(body)["error"]["message"] + except Exception: + message = body or exc.reason + raise RuntimeError(f"GitHub Models API returned HTTP {exc.code} for {MODEL}: {message}") from exc + + reply = data["choices"][0]["message"]["content"].strip() + if not reply: + raise RuntimeError( + f"GitHub Models API returned an empty response for {MODEL}; " + "increase GITHUB_MODELS_MAX_COMPLETION_TOKENS or lower GITHUB_MODELS_REASONING_EFFORT" + ) + response = ChatResponse( + messages=[Message(role="assistant", contents=[reply])], + model=data.get("model", MODEL), + ) + + async def _get() -> ChatResponse: + return response + + return _get() + + +def run_sync(coro: Coroutine[Any, Any, Any]) -> Any: + """Run a coroutine that performs no real async I/O, without an event loop. + + `asyncio.run()` can't be used here: it builds a Unix selector event loop + whose wake-up self-pipe needs `socket.socketpair()`, which this unikernel + doesn't implement (ENOSYS). The HTTP call is synchronous and never suspends, + so we can just step the coroutine to completion. + """ + try: + while True: + pending = coro.send(None) + if pending is not None: + raise RuntimeError(f"coroutine requires an event loop (awaited {pending!r})") + except StopIteration as exc: + return exc.value + + +async def main() -> None: + agent = Agent( + client=GitHubModelsChatClient(), + name="RemoteHyperlightAgent", + instructions="You are a helpful assistant. Answer concisely.", + ) + query = "In one short sentence, what is the Hyperlight CNCF project?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.messages[0].text}") + + +if __name__ == "__main__": + run_sync(main()) diff --git a/host/src/bin/pyhl.rs b/host/src/bin/pyhl.rs index 6f5bf2a..5baeae8 100644 --- a/host/src/bin/pyhl.rs +++ b/host/src/bin/pyhl.rs @@ -37,6 +37,46 @@ fn parse_mount(spec: &str) -> Result { Preopen::parse_cli(spec).map_err(|e| anyhow!("invalid --mount {:?}: {}", spec, e)) } +/// Parse a `--env NAME[=VALUE]` argument. `NAME` copies from the host +/// environment when set; `NAME=VALUE` injects an explicit value. +fn parse_env(spec: &str) -> Result> { + let (name, value) = match spec.split_once('=') { + Some((name, value)) => (name, Some(value.to_string())), + None => match std::env::var(spec) { + Ok(value) => (spec, Some(value)), + Err(std::env::VarError::NotPresent) => return Ok(None), + Err(std::env::VarError::NotUnicode(_)) => { + bail!("host environment variable {:?} is not valid UTF-8", spec) + } + }, + }; + + if name.is_empty() || name.contains('=') || name.contains('\0') { + bail!("invalid environment variable name {:?}", name); + } + + let value = value.expect("value is always present after match"); + if value.contains('\0') { + bail!("environment variable {:?} contains a NUL byte", name); + } + + Ok(Some((name.to_string(), value))) +} + +fn env_prelude(env_vars: &[(String, String)]) -> Result { + if env_vars.is_empty() { + return Ok(String::new()); + } + + let encoded = serde_json::to_string(env_vars).context("encode --env values")?; + Ok(format!( + "import os as _pyhl_os\n\ + for _pyhl_k, _pyhl_v in {encoded}:\n\ + \x20 _pyhl_os.environ[_pyhl_k] = _pyhl_v\n\ + del _pyhl_os, _pyhl_k, _pyhl_v\n" + )) +} + fn build_network_policy( net: bool, net_allow: &[String], @@ -255,6 +295,12 @@ struct RunArgs { #[arg(long = "mount", value_name = "HOST[:GUEST]")] mounts: Vec, + /// Set an environment variable inside the guest Python process. + /// Use NAME to copy NAME from the host when set, or NAME=VALUE to + /// pass an explicit value. Repeatable. + #[arg(short = 'e', long = "env", value_name = "NAME[=VALUE]")] + env: Vec, + /// Enable guest networking. #[arg(long)] net: bool, @@ -569,6 +615,13 @@ fn cmd_run(args: RunArgs) -> Result<()> { &args.net_block, listen_ports.is_some(), )?; + let mut env_vars = Vec::new(); + for spec in &args.env { + if let Some(env_var) = parse_env(spec)? { + env_vars.push(env_var); + } + } + let env_prelude = env_prelude(&env_vars)?; let t_load = Instant::now(); let initrd = home.join(INITRD_FILE); @@ -604,11 +657,12 @@ fn cmd_run(args: RunArgs) -> Result<()> { // asked for bit-for-bit reproducibility across calls. Every run // picks up a new seed so np.random.randint / random.random // match python3's "different result every invocation" behavior. - let payload = if args.deterministic { - code.clone() - } else { - let mut full = String::with_capacity(code.len() + 256); - full.push_str(&reseed_prelude()); + let payload = { + let mut full = String::with_capacity(code.len() + env_prelude.len() + 256); + full.push_str(&env_prelude); + if !args.deterministic { + full.push_str(&reseed_prelude()); + } full.push_str(&code); full }; @@ -692,3 +746,29 @@ fn main() -> Result<()> { Command::Run(args) => cmd_run(args), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_env_accepts_explicit_value() { + assert_eq!( + parse_env("GITHUB_TOKEN=abc=123").unwrap(), + Some(("GITHUB_TOKEN".to_string(), "abc=123".to_string())) + ); + } + + #[test] + fn parse_env_rejects_empty_name() { + let err = parse_env("=secret").unwrap_err().to_string(); + assert!(err.contains("invalid environment variable name")); + } + + #[test] + fn env_prelude_json_escapes_values() { + let prelude = env_prelude(&[("A".to_string(), "quoted \" value".to_string())]).unwrap(); + assert!(prelude.contains("os.environ")); + assert!(prelude.contains("quoted \\\" value")); + } +} diff --git a/runtimes/python.Dockerfile b/runtimes/python.Dockerfile index d4976a9..321a421 100644 --- a/runtimes/python.Dockerfile +++ b/runtimes/python.Dockerfile @@ -1,7 +1,7 @@ # https://github.com/revsys/optimized-python-docker/blob/master/Dockerfile # https://github.com/docker-library/python/blob/master/3.12/slim-bullseye/Dockerfile -FROM ubuntu:22.04 as base +FROM ubuntu:22.04 as python-dev ENV PATH /usr/local/bin:$PATH ENV DEBIAN_FRONTEND=noninteractive @@ -33,7 +33,10 @@ RUN set -xe ; \ RUN set -xe ; \ make -j $(( 1 * $( egrep '^processor[[:space:]]+:' /proc/cpuinfo | wc -l ) )) ; \ - make install + make install ; \ + ldconfig + +FROM python-dev as base RUN set -xe ; \ find /usr/local -type f -name "*.so" -exec strip --strip-unneeded {} + ; \