Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/agent-framework-local/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.unikraft/
agent-framework-local-initrd.cpio
47 changes: 47 additions & 0 deletions examples/agent-framework-local/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions examples/agent-framework-local/Justfile
Original file line number Diff line number Diff line change
@@ -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}} }
55 changes: 55 additions & 0 deletions examples/agent-framework-local/README.md
Original file line number Diff line number Diff line change
@@ -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 <cmdline>`), so this example
reuses the published `python-agent-kernel` instead of building its own.
134 changes: 134 additions & 0 deletions examples/agent-framework-local/agent.py
Original file line number Diff line number Diff line change
@@ -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())
4 changes: 4 additions & 0 deletions examples/agent-framework-remote/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.unikraft/
agent-framework-remote-initrd.cpio
.pyhl/
secrets/
46 changes: 46 additions & 0 deletions examples/agent-framework-remote/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading