Skip to content

Commit 109fa8d

Browse files
committed
Add Agent Framework examples
Signed-off-by: James Sturtevant <jsturtevant@gmail.com>
1 parent b316b19 commit 109fa8d

12 files changed

Lines changed: 746 additions & 7 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.unikraft/
2+
agent-framework-local-initrd.cpio
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# agent-framework-local — a Microsoft Agent Framework agent doing REAL, fully
2+
# offline inference inside a Hyperlight micro-VM.
3+
#
4+
# Bundles llama-cpp-python + a small Qwen2.5-0.5B-Instruct GGUF into the CPIO,
5+
# so the model runs entirely in-guest — no network, no API keys.
6+
7+
ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest
8+
ARG DEPS_BASE=local-python-base-dev:latest
9+
10+
# Stage 1: python deps — agent-framework-core + llama-cpp-python. Two constraints
11+
# drive the build: (1) the guest only exposes SSE (no AVX), and (2) native deps
12+
# should be built against the same Ubuntu 22.04 / glibc 2.35 ABI as python-base.
13+
# The Justfile materializes DEPS_BASE from the python-dev stage in
14+
# ../../runtimes/python.Dockerfile.
15+
FROM ${DEPS_BASE} AS deps
16+
RUN apt-get update && apt-get install -y --no-install-recommends git \
17+
&& rm -rf /var/lib/apt/lists/*
18+
RUN python3.12 -m pip install --no-cache-dir cmake
19+
RUN python3.12 -m pip install --target=/deps --no-cache-dir --prefer-binary agent-framework-core
20+
# SSE-only, no AVX/AVX2/AVX512/FMA/F16C (all VEX-encoded and unsupported here),
21+
# and no OpenMP (avoids a libgomp runtime dependency; ggml falls back to pthreads).
22+
ENV CMAKE_ARGS="-DGGML_NATIVE=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_AVX512=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF -DGGML_OPENMP=OFF"
23+
RUN python3.12 -m pip install --target=/deps --no-cache-dir --no-binary llama-cpp-python llama-cpp-python
24+
RUN set -eux; \
25+
find /deps -maxdepth 1 -type d -name '*.dist-info' -exec rm -rf {} + 2>/dev/null || true; \
26+
find /deps \( -type d -name tests -o -type d -name test \) -exec rm -rf {} + 2>/dev/null || true; \
27+
find /deps -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true; \
28+
find /deps -type f -name '*.pyc' -delete 2>/dev/null || true
29+
30+
# Stage 2: download the GGUF model (kept in its own stage for layer caching).
31+
FROM debian:bookworm-slim AS model
32+
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
33+
&& rm -rf /var/lib/apt/lists/*
34+
ARG MODEL_URL=https://huggingface.co/bartowski/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/Qwen2.5-0.5B-Instruct-Q4_K_M.gguf
35+
RUN curl -fL --retry 3 -o /model.gguf "${MODEL_URL}"
36+
37+
# Stage 3: assemble rootfs.
38+
FROM ${BASE} AS rootfs
39+
COPY --from=deps /deps /usr/local/lib/python3.12/site-packages
40+
COPY --from=model /model.gguf /model.gguf
41+
COPY agent.py /agent.py
42+
43+
# Stage 4: pack as CPIO.
44+
FROM alpine:3.20 AS cpio
45+
RUN apk add --no-cache cpio findutils
46+
COPY --from=rootfs / /rootfs/
47+
RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# agent-framework-local on Hyperlight
2+
#
3+
# A Microsoft Agent Framework agent doing REAL, fully offline inference with a
4+
# small GGUF model (llama-cpp-python) baked into the image — no network, no keys.
5+
#
6+
# just build - Fetch the (shared python-agent) kernel from GHCR
7+
# just rootfs - Build the initrd CPIO (installs llama-cpp-python + model)
8+
# just run - Execute agent.py inside the micro-VM
9+
# just clean - Remove build artifacts
10+
11+
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
12+
export DOCKER_BUILDKIT := "0"
13+
14+
kernel := ".unikraft/build/agent-framework-local-hyperlight_hyperlight-x86_64"
15+
initrd := "agent-framework-local-initrd.cpio"
16+
memory := "3Gi"
17+
image := "agent-framework-local-hyperlight"
18+
base := "local-python-base:latest"
19+
deps_base := "local-python-base-dev:latest"
20+
ghcr_kernel := "ghcr.io/hyperlight-dev/hyperlight-unikraft/python-agent-kernel:latest"
21+
22+
run:
23+
hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /agent.py
24+
25+
rootfs: python-base
26+
docker build --platform linux/amd64 --build-arg BASE={{base}} --build-arg DEPS_BASE={{deps_base}} --target cpio -t {{image}}-cpio .
27+
- docker rm -f {{image}}-tmp
28+
docker create --name {{image}}-tmp {{image}}-cpio /bin/true
29+
docker cp {{image}}-tmp:/output.cpio ./{{initrd}}
30+
docker rm -f {{image}}-tmp
31+
32+
# Build matching Ubuntu 22.04 / glibc 2.35 Python dev/runtime base images locally.
33+
python-base:
34+
docker build --platform linux/amd64 --target python-dev -t {{deps_base}} -f ../../runtimes/python.Dockerfile ../../runtimes
35+
docker build --platform linux/amd64 -t {{base}} -f ../../runtimes/python.Dockerfile ../../runtimes
36+
37+
# Fetch the prebuilt kernel from GHCR (reused from the python-agent example).
38+
[unix]
39+
build:
40+
docker pull {{ghcr_kernel}}
41+
- docker rm -f {{image}}-kernel-tmp
42+
docker create --name {{image}}-kernel-tmp {{ghcr_kernel}} /kernel
43+
mkdir -p $(dirname {{kernel}})
44+
docker cp {{image}}-kernel-tmp:/kernel ./{{kernel}}
45+
docker rm -f {{image}}-kernel-tmp
46+
47+
[windows]
48+
build:
49+
docker pull {{ghcr_kernel}}
50+
- docker rm -f {{image}}-kernel-tmp
51+
docker create --name {{image}}-kernel-tmp {{ghcr_kernel}} /kernel
52+
New-Item -ItemType Directory -Force -Path (Split-Path {{kernel}}) | Out-Null
53+
docker cp {{image}}-kernel-tmp:/kernel ./{{kernel}}
54+
docker rm -f {{image}}-kernel-tmp
55+
56+
rebuild: clean build rootfs
57+
58+
[unix]
59+
clean:
60+
rm -rf .unikraft {{initrd}}
61+
62+
[windows]
63+
clean:
64+
if (Test-Path .unikraft) { Remove-Item -Recurse -Force .unikraft }
65+
if (Test-Path {{initrd}}) { Remove-Item -Force {{initrd}} }
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# agent-framework-local on Hyperlight
2+
3+
Run a [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
4+
agent doing **real, fully offline inference** inside a
5+
[Hyperlight](https://github.com/hyperlight-dev/hyperlight) micro-VM.
6+
7+
`agent.py` is a real `agent_framework` `Agent` backed by a custom `BaseChatClient`
8+
that runs a small GGUF model with **llama-cpp-python**. The model is baked into the
9+
image, so inference happens entirely in-guest — **no network, no API keys**.
10+
11+
- Model: `Qwen2.5-0.5B-Instruct` (Q4_K_M, ~397 MB), swappable via the `MODEL_URL`
12+
build arg in the `Dockerfile`.
13+
14+
## Run
15+
16+
```sh
17+
just build # fetch the prebuilt Python kernel from GHCR
18+
just rootfs # build the initrd CPIO (compiles llama.cpp + bakes in the model)
19+
just run # run the agent inside the micro-VM
20+
```
21+
22+
Example output (generation is ~4–5 tok/s: single vCPU, SSE-only):
23+
24+
```
25+
User: In one sentence, what is a micro-VM?
26+
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.
27+
```
28+
29+
## Why the build looks the way it does
30+
31+
Getting a native inference stack to run on the trimmed `python-base` +
32+
identity-mapped unikernel needed a few specific choices, all in the `Dockerfile`
33+
and `agent.py`:
34+
35+
- **SSE-only llama.cpp.** The guest CPU exposes SSE/SSE2/SSE4 but **not AVX/AVX2**
36+
(numpy reports `AVX: False`). The prebuilt `llama-cpp-python` CPU wheels assume
37+
AVX2 and crash with an illegal instruction, so we compile llama.cpp from source
38+
with `-DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF`.
39+
- **Matching glibc + OpenMP off.** `python-base` ships Ubuntu glibc 2.35, so the
40+
Justfile builds `local-python-base-dev` from the `python-dev` stage in
41+
`runtimes/python.Dockerfile` and compiles native deps against that same ABI.
42+
We disable OpenMP
43+
(`-DGGML_OPENMP=OFF`) to avoid a libgomp runtime dependency.
44+
- **Small buffers, no mmap.** `n_ctx=512`, `n_batch=64`, and `use_mmap=False` keep
45+
the compute buffers within the identity-mapped guest memory (larger values crash
46+
it). Run with `--memory 3Gi`.
47+
- **stdlib stubs.** `python-base` trims `multiprocessing` and `sqlite3`, which
48+
llama-cpp-python imports transitively; `agent.py` registers minimal stubs since
49+
neither is actually used here.
50+
- **`run_sync` instead of `asyncio.run()`.** The unikernel has no
51+
`socket.socketpair()`, which asyncio's event loop requires; the synchronous
52+
inference call never suspends, so we step the coroutine directly.
53+
54+
The kernel is generic (it runs `/usr/local/bin/python3 <cmdline>`), so this example
55+
reuses the published `python-agent-kernel` instead of building its own.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env python3
2+
"""A Microsoft Agent Framework agent doing REAL, fully offline inference.
3+
4+
The agent is a real `agent_framework` Agent, backed by a custom ChatClient that
5+
runs a small GGUF model with llama-cpp-python. The model is baked into the
6+
image, so inference happens entirely inside the Hyperlight micro-VM — no network
7+
access and no API keys.
8+
"""
9+
10+
import logging
11+
import os
12+
import sys
13+
import types
14+
import warnings
15+
16+
# Keep the demo output clean.
17+
warnings.filterwarnings("ignore")
18+
logging.getLogger("agent_framework").setLevel(logging.ERROR)
19+
20+
# The trimmed python-base omits a few stdlib modules that llama-cpp-python imports
21+
# transitively (multiprocessing via llama.py, sqlite3 via diskcache). This example
22+
# never uses either — inference is single-process and we don't use the disk cache —
23+
# so we register minimal stubs so the imports succeed.
24+
_mp = types.ModuleType("multiprocessing")
25+
_mp.cpu_count = lambda: os.cpu_count() or 1
26+
sys.modules.setdefault("multiprocessing", _mp)
27+
28+
_sq = types.ModuleType("sqlite3")
29+
_sq.Binary = memoryview
30+
31+
32+
class _SqliteError(Exception):
33+
pass
34+
35+
36+
_sq.Error = _sq.OperationalError = _sq.DatabaseError = _SqliteError
37+
_sq.IntegrityError = _sq.ProgrammingError = _SqliteError
38+
_sq.connect = lambda *a, **k: None
39+
_sq.register_adapter = _sq.register_converter = lambda *a, **k: None
40+
_sq.PARSE_DECLTYPES = 1
41+
_sq.PARSE_COLNAMES = 2
42+
_sq.Row = object
43+
_sq.sqlite_version = _sq.version = "0"
44+
sys.modules.setdefault("sqlite3", _sq)
45+
46+
from collections.abc import Awaitable, Coroutine, Mapping, Sequence # noqa: E402
47+
from typing import Any # noqa: E402
48+
49+
from agent_framework import ( # noqa: E402
50+
Agent,
51+
BaseChatClient,
52+
ChatResponse,
53+
Message,
54+
)
55+
from llama_cpp import Llama # noqa: E402
56+
57+
MODEL_PATH = "/model.gguf"
58+
59+
60+
class LlamaCppChatClient(BaseChatClient):
61+
"""Chat client backed by a local llama.cpp GGUF model."""
62+
63+
def __init__(self, model_path: str = MODEL_PATH, **kwargs: Any) -> None:
64+
super().__init__(**kwargs)
65+
self._llm = Llama(
66+
model_path=model_path,
67+
n_ctx=512,
68+
n_batch=64, # small compute buffers to fit the identity-mapped guest
69+
n_threads=max(1, os.cpu_count() or 1),
70+
use_mmap=False, # the guest ramfs doesn't support file-backed mmap
71+
verbose=False,
72+
)
73+
74+
def _inner_get_response(
75+
self,
76+
*,
77+
messages: Sequence[Message],
78+
stream: bool = False,
79+
options: Mapping[str, Any],
80+
**kwargs: Any,
81+
) -> Awaitable[ChatResponse]:
82+
chat = [
83+
{"role": m.role, "content": m.text}
84+
for m in messages
85+
if m.text
86+
]
87+
completion = self._llm.create_chat_completion(
88+
messages=chat,
89+
max_tokens=64,
90+
temperature=0.7,
91+
)
92+
reply = completion["choices"][0]["message"]["content"].strip()
93+
response = ChatResponse(
94+
messages=[Message(role="assistant", contents=[reply])],
95+
model=os.path.basename(MODEL_PATH),
96+
)
97+
98+
async def _get() -> ChatResponse:
99+
return response
100+
101+
return _get()
102+
103+
104+
def run_sync(coro: Coroutine[Any, Any, Any]) -> Any:
105+
"""Run a coroutine that performs no real async I/O, without an event loop.
106+
107+
`asyncio.run()` can't be used here: it builds a Unix selector event loop
108+
whose wake-up self-pipe needs `socket.socketpair()`, which this unikernel
109+
doesn't implement (ENOSYS). The client's inference call is synchronous and
110+
never suspends, so we can just step the coroutine to completion.
111+
"""
112+
try:
113+
while True:
114+
pending = coro.send(None)
115+
if pending is not None:
116+
raise RuntimeError(f"coroutine requires an event loop (awaited {pending!r})")
117+
except StopIteration as exc:
118+
return exc.value
119+
120+
121+
async def main() -> None:
122+
agent = Agent(
123+
client=LlamaCppChatClient(),
124+
name="LocalHyperlightAgent",
125+
instructions="You are a concise assistant. Answer in one short sentence.",
126+
)
127+
query = "In one sentence, what is a vm?"
128+
print(f"User: {query}")
129+
result = await agent.run(query)
130+
print(f"Agent: {result.messages[0].text}")
131+
132+
133+
if __name__ == "__main__":
134+
run_sync(main())
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.unikraft/
2+
agent-framework-remote-initrd.cpio
3+
.pyhl/
4+
secrets/
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# agent-framework-remote (driver/pyhl stack, minimal rootfs)
2+
#
3+
# Builds a SMALL driver rootfs: the shipped hl_pydriver interpreter shim (extracted
4+
# from the published driver image, so it stays version-matched to the GHCR driver
5+
# kernel) + agent-framework-core only — dropping the ~1 GB preloaded data-science
6+
# stack the general-purpose pyhl image carries. Gives the warm-snapshot speed of
7+
# the driver stack with a ~60 MB initrd.
8+
9+
ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest
10+
11+
# Stage 1: agent-framework-core, trimmed.
12+
FROM python:3.12-slim AS deps
13+
RUN pip install --target=/deps --no-cache-dir --prefer-binary agent-framework-core
14+
RUN set -eux; \
15+
find /deps -maxdepth 1 -type d -name '*.dist-info' -exec rm -rf {} + 2>/dev/null || true; \
16+
find /deps \( -type d -name tests -o -type d -name test \) -exec rm -rf {} + 2>/dev/null || true; \
17+
find /deps -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true; \
18+
find /deps -type f -name '*.pyc' -delete 2>/dev/null || true; \
19+
find /deps -name '*.so*' -exec strip --strip-unneeded {} + 2>/dev/null || true
20+
21+
# Stage 2: extract just hl_pydriver (+ pydoc stub) from the shipped driver rootfs.
22+
FROM ghcr.io/hyperlight-dev/hyperlight-unikraft/python-agent-driver-initrd:latest AS shipped
23+
FROM alpine:3.20 AS extract
24+
RUN apk add --no-cache cpio
25+
COPY --from=shipped /initrd.cpio /i.cpio
26+
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
27+
28+
# Stage 3: minimal rootfs = python-base + hl_pydriver + agent-framework-core.
29+
FROM ${BASE} AS rootfs
30+
COPY --from=deps /deps /usr/local/lib/python3.12/site-packages
31+
COPY --from=extract /x/bin/hl_pydriver /bin/hl_pydriver
32+
COPY --from=extract /x/usr/local/lib/python3.12/pydoc.py /usr/local/lib/python3.12/pydoc.py
33+
34+
# Stage 4: pack CPIO + DNS + CA certs for outbound TLS.
35+
FROM alpine:3.20 AS cpio
36+
RUN apk add --no-cache cpio findutils ca-certificates
37+
COPY --from=rootfs / /rootfs/
38+
RUN mkdir -p /rootfs/etc && \
39+
echo "nameserver 8.8.8.8" > /rootfs/etc/resolv.conf && \
40+
echo "nameserver 8.8.4.4" >> /rootfs/etc/resolv.conf && \
41+
echo "hosts: files dns" > /rootfs/etc/nsswitch.conf && \
42+
echo "127.0.0.1 localhost" > /rootfs/etc/hosts
43+
RUN mkdir -p /rootfs/etc/ssl/certs /rootfs/usr/lib/ssl && \
44+
cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ && \
45+
ln -sf /etc/ssl/certs/ca-certificates.crt /rootfs/usr/lib/ssl/cert.pem
46+
RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null

0 commit comments

Comments
 (0)