From 268b70aca18658dc90d67aa254e7f0544366fb33 Mon Sep 17 00:00:00 2001 From: Lingrui Mei Date: Thu, 2 Jul 2026 23:38:42 +0800 Subject: [PATCH] provider: add uv SandboxProvider (PR #122 stage C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of efedacf from origin/abridge/gateway-tito (+ its ruff import-order hunk from 90f970a): a lightweight SandboxProvider at plugins/providers/uv that materializes a venv with uv and launches the runtime server as a local subprocess — for dev / eval / CI where container isolation isn't needed. Registered as entry point 'uv'; wired into pyright include/extraPaths (workspace membership via the existing plugins/providers/* glob). Fixes on top of the port (planned + adversarial review): - stdout drain: the runtime's merged stdout/stderr is drained for the sandbox's lifetime into a bounded tail (8x64KiB). Previously nothing read the pipe after health — asyncio flow control pauses at ~192KiB buffered, so a server that logged past that blocked mid-write and wedged every in-flight rollout. The tail also feeds the exited-before-health diagnostic (was a one-shot read). - that diagnostic path uses asyncio.wait, not wait_for — wait_for cancels the drain on timeout and delete()'s re-await then surfaced a bare CancelledError instead of the RuntimeError diagnostic (repro: a grandchild holding the pipe open past the 2s window). - ports are reserved in-process until the sandbox dies (same _inflight_ports guard as DockerProvider) — the subprocess binds the number seconds after allocation, so concurrent creates could collide: worst case two rollouts silently sharing one runtime. - a failed venv materialization removes its mkdtemp root (a retry loop leaked one partial venv per attempt). - the default uv binary resolves via uv.find_uv_bin() — the packaged wheel's binary is not on PATH under systemd/cron/absolute-path launches; a bare FileNotFoundError also now carries install guidance. - README/docstring examples close the provider (aclose is the only thing that removes a materialized venv). Tests: flood server proving the drain (pre-fix it wedges mid-write), grandchild-held pipe diagnostic, port-collision guard, temp-root cleanup, packaged-uv resolution off-PATH. Co-authored-by: FatPigeorz Co-Authored-By: Claude Fable 5 --- plugins/providers/uv/README.md | 34 ++ plugins/providers/uv/agentix/provider/uv.py | 336 ++++++++++++++++++ plugins/providers/uv/pyproject.toml | 33 ++ .../providers/uv/tests/test_uv_provider.py | 242 +++++++++++++ pyproject.toml | 2 + uv.lock | 42 +++ 6 files changed, 689 insertions(+) create mode 100644 plugins/providers/uv/README.md create mode 100644 plugins/providers/uv/agentix/provider/uv.py create mode 100644 plugins/providers/uv/pyproject.toml create mode 100644 plugins/providers/uv/tests/test_uv_provider.py diff --git a/plugins/providers/uv/README.md b/plugins/providers/uv/README.md new file mode 100644 index 0000000..32087c0 --- /dev/null +++ b/plugins/providers/uv/README.md @@ -0,0 +1,34 @@ +# agentix-provider-uv + +A lightweight Agentix `SandboxProvider` that runs the runtime from a +**uv-materialized virtualenv** — no Docker image, no Nix bundle. + +`uv` builds a venv for the target project (so its importable callables + +`agentixx` core are present), then the runtime server is launched as a local +subprocess (`python -m uvicorn agentix.runtime.server.app:app`). The worker the +server spawns inherits that interpreter, so `await sandbox.remote(fn, ...)` runs +against the project's real dependencies. + +```python +from agentix.provider.base import SandboxConfig +from agentix.provider.uv import UvProvider, UvProviderConfig + +# materialize from a project (must depend on agentixx) +provider = UvProvider(UvProviderConfig(project=".")) +# ...or reuse a prebuilt env and skip materialization +provider = UvProvider(UvProviderConfig(reuse_venv="/path/to/venv")) + +try: + async with provider.session(SandboxConfig(image="uv", bundle="uv")) as sandbox: + result = await sandbox.remote(my_rollout, task=task) +finally: + await provider.aclose() # removes a venv the provider materialized +``` + +`SandboxConfig.image` / `bundle` are unused (placeholders); only `env` is +honored. This backend runs on the host with **no container isolation** — use it +for fast local dev / eval / CI, and a container provider (`docker` / +`apptainer`) or managed backend for untrusted code or hard resource limits. + +`providers().get("uv")` resolves after `uv sync`. There is no `agentix deploy +uv` — the runtime is materialized from source, so there is no bundle artifact. diff --git a/plugins/providers/uv/agentix/provider/uv.py b/plugins/providers/uv/agentix/provider/uv.py new file mode 100644 index 0000000..e350099 --- /dev/null +++ b/plugins/providers/uv/agentix/provider/uv.py @@ -0,0 +1,336 @@ +"""uv SandboxProvider — run the Agentix runtime from a uv-materialized venv. + +A lightweight provider that skips the Docker/Nix bundle entirely. `uv` +materializes a virtualenv for the target project (so its importable callables +plus `agentixx` core are present), then the runtime server is launched as a +local subprocess (`python -m uvicorn agentix.runtime.server.app:app`). The +worker subprocess the server spawns inherits that interpreter +(`sys.executable`), so `await sandbox.remote(fn, ...)` runs `fn` against the +project's real dependencies — no container, no rebuild. + +Aimed at local dev / eval / CI where Docker is unavailable or too slow. It +trades isolation for speed: the runtime runs on the host, not in a sandboxed +container. For untrusted code or hard resource limits, use a container +provider (`docker` / `apptainer`) or a managed backend instead. + + from agentix.provider.uv import UvProvider, UvProviderConfig + + provider = UvProvider(UvProviderConfig(project=".")) # uv pip install -e . + try: + async with provider.session(SandboxConfig(image="uv", bundle="uv")) as sandbox: + result = await sandbox.remote(my_rollout, task=task) + finally: + await provider.aclose() # removes the venv this provider materialized + +`SandboxConfig.image` / `bundle` are unused here (there is no image or bundle); +pass any placeholder. Only `SandboxConfig.env` is honored — merged into the +runtime server's environment. Backend settings live in `UvProviderConfig`, +mirroring how other providers take a backend config object. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import shutil +import socket +import tempfile +import uuid +from collections import deque +from dataclasses import dataclass +from pathlib import Path + +from agentix.provider.base import ( + Sandbox, + SandboxConfig, + SandboxId, + SandboxInfo, + SandboxProvider, +) + +logger = logging.getLogger("agentix.provider.uv") + +_RUNTIME_APP = "agentix.runtime.server.app:app" + + +@dataclass +class UvProviderConfig: + """Backend config for `UvProvider`. + + Either point at a `project` to materialize a fresh venv (`uv venv` + + `uv pip install -e ` — the project must depend on `agentixx`), or + point `reuse_venv` at an existing interpreter env to skip materialization + (fast iteration / CI where the env is prebuilt). + """ + + project: str | None = None + python: str = "3.12" + index_url: str | None = None + extra_index_url: tuple[str, ...] = () + install: tuple[str, ...] = () + reuse_venv: str | None = None + uv_bin: str = "uv" + host: str = "127.0.0.1" + ws: str = "auto" + health_timeout: float = 60.0 + + def __post_init__(self) -> None: + if self.project is None and self.reuse_venv is None: + raise ValueError("UvProviderConfig needs either `project` or `reuse_venv`") + + def resolved_uv_bin(self) -> str: + """The uv executable to shell out to. An explicit `uv_bin` wins; the + bare default prefers the binary shipped by the `uv` wheel (this + package depends on it), because the venv's bin/ is not on PATH under + systemd/cron/absolute-path launches — exactly the environments where + no system-wide uv exists.""" + if self.uv_bin != "uv": + return self.uv_bin + try: + from uv import find_uv_bin + + return find_uv_bin() + except (ImportError, FileNotFoundError): + return self.uv_bin + + +# Bounded log tail retained per sandbox: last N chunks of <= 64KiB each. Big +# enough for a useful crash diagnostic, small enough to never matter. +_TAIL_CHUNKS = 8 + + +@dataclass +class _Running: + proc: asyncio.subprocess.Process + port: int + # The runtime's merged stdout/stderr MUST be drained for the sandbox's + # lifetime: asyncio's flow control pauses the pipe once ~192KiB is + # buffered, and a server that logs past that blocks mid-write — wedging + # every in-flight rollout. The drain keeps only a bounded tail. + drain: asyncio.Task + tail: deque[bytes] + + +async def _drain_stdout(proc: asyncio.subprocess.Process, tail: deque[bytes]) -> None: + if proc.stdout is None: + return + while True: + chunk = await proc.stdout.read(65536) + if not chunk: + return + tail.append(chunk) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +async def _run(*argv: str, timeout: float = 1800.0) -> None: + try: + proc = await asyncio.create_subprocess_exec( + *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + ) + except FileNotFoundError as e: + raise RuntimeError( + f"{argv[0]!r} not found — install uv (`pip install uv`) or point " + "UvProviderConfig.uv_bin at the executable" + ) from e + try: + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except TimeoutError: + proc.kill() + raise + if proc.returncode != 0: + tail = out.decode(errors="replace")[-2000:] if out else "" + raise RuntimeError(f"command failed (rc={proc.returncode}): {' '.join(argv)}\n{tail}") + + +class UvProvider(SandboxProvider): + """Provision sandboxes as a runtime server launched from a uv venv.""" + + def __init__(self, config: UvProviderConfig | None = None) -> None: + if config is None: + config = UvProviderConfig(project=".") + self.config = config + self._running: dict[SandboxId, _Running] = {} + self._venv: Path | None = None + self._owned_venv_root: Path | None = None + self._venv_lock = asyncio.Lock() + self._inflight_ports: set[int] = set() + + def _allocate_port(self) -> int: + # Ask the kernel for a free TCP port, then reserve it in-process: the + # subprocess only binds it seconds later (interpreter boot + imports), + # so two concurrent creates that each bind-and-close could otherwise + # collide on the same number. Same guard as DockerProvider. + for _ in range(100): + port = _free_port() + if port not in self._inflight_ports: + self._inflight_ports.add(port) + return port + raise RuntimeError("could not allocate a free host port") + + async def _ensure_venv(self) -> Path: + """Materialize (once) and return the venv whose `python` runs the + runtime. Reused across every `create()` on this provider.""" + if self.config.reuse_venv is not None: + return Path(self.config.reuse_venv) + async with self._venv_lock: + if self._venv is not None: + return self._venv + root = Path(tempfile.mkdtemp(prefix="agentix-uv-")) + uv_bin = self.config.resolved_uv_bin() + try: + venv = root / "venv" + await _run(uv_bin, "venv", "--python", self.config.python, str(venv)) + py = str(venv / "bin" / "python") + idx: list[str] = [] + if self.config.index_url: + idx += ["--index-url", self.config.index_url] + for extra in self.config.extra_index_url: + idx += ["--extra-index-url", extra] + targets: list[str] = [] + if self.config.project is not None: + targets += ["-e", self.config.project] + targets += list(self.config.install) + if targets: + await _run(uv_bin, "pip", "install", "--python", py, *idx, *targets) + except BaseException: + # A failed materialization must not orphan the temp root — a + # retry loop would leak one partial venv per attempt. + shutil.rmtree(root, ignore_errors=True) + raise + self._venv = venv + self._owned_venv_root = root + return venv + + async def create(self, config: SandboxConfig) -> Sandbox: + venv = await self._ensure_venv() + python = str(venv / "bin" / "python") + port = self._allocate_port() + + env = dict(os.environ) + env.setdefault("AGENTIX_LOG_CONTEXT", "uv-sandbox-{uname}") + if config.env: + env.update(config.env) + + cmd = [ + python, "-m", "uvicorn", _RUNTIME_APP, + "--host", self.config.host, "--port", str(port), + "--log-level", "error", "--ws", self.config.ws, "--lifespan", "on", + ] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + ) + except BaseException: + self._inflight_ports.discard(port) + raise + sandbox_id = SandboxId(f"uv-{uuid.uuid4().hex[:12]}") + tail: deque[bytes] = deque(maxlen=_TAIL_CHUNKS) + drain = asyncio.create_task(_drain_stdout(proc, tail)) + self._running[sandbox_id] = _Running(proc=proc, port=port, drain=drain, tail=tail) + try: + await self._wait_healthy(sandbox_id, port, proc) + except BaseException: + await self.delete(sandbox_id) + raise + return Sandbox( + sandbox_id=sandbox_id, + runtime_url=f"http://{self.config.host}:{port}", + status="running", + ) + + async def _wait_healthy(self, sandbox_id: SandboxId, port: int, proc: asyncio.subprocess.Process) -> None: + # Raw TCP GET /health — never via an HTTP client that honors proxy env + # vars, which would hang a loopback probe behind a corp proxy. + attempts = max(1, int(self.config.health_timeout / 0.5)) + for _ in range(attempts): + if proc.returncode is not None: + # The drain task owns the pipe — give it a moment to see EOF, + # then report the bounded tail it kept. asyncio.wait (unlike + # wait_for) does NOT cancel the task on timeout: a grandchild + # holding the pipe open must not turn this diagnostic into a + # CancelledError when delete() later awaits the drain. + out = b"" + running = self._running.get(sandbox_id) + if running is not None: + await asyncio.wait({running.drain}, timeout=2) + out = b"".join(running.tail) + raise RuntimeError( + f"runtime server (uv) exited rc={proc.returncode} before health: " + f"{out.decode(errors='replace')[-2000:]}" + ) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(self.config.host, port), timeout=2 + ) + except (TimeoutError, OSError): + await asyncio.sleep(0.5) + continue + try: + writer.write(b"GET /health HTTP/1.0\r\nHost: localhost\r\n\r\n") + await writer.drain() + status_line = await asyncio.wait_for(reader.readline(), timeout=2) + if status_line.startswith(b"HTTP/1.") and b" 200 " in status_line: + return + except (TimeoutError, OSError): + pass + finally: + writer.close() + with contextlib.suppress(OSError): + await writer.wait_closed() + await asyncio.sleep(0.5) + raise TimeoutError(f"runtime server (uv) not healthy on :{port}") + + async def get(self, sandbox_id: SandboxId) -> SandboxInfo: + running = self._running.get(sandbox_id) + if running is None: + raise KeyError(f"Sandbox not found: {sandbox_id}") + status = "running" if running.proc.returncode is None else "exited" + return SandboxInfo( + sandbox_id=sandbox_id, + runtime_url=f"http://{self.config.host}:{running.port}", + status=status, + ) + + async def delete(self, sandbox_id: SandboxId) -> None: + running = self._running.pop(sandbox_id, None) + if running is None: + return + await self._terminate(running.proc, sandbox_id) + self._inflight_ports.discard(running.port) + # After process death the pipe reaches EOF and the drain task ends on + # its own; cancel only if it doesn't (e.g. an orphaned grandchild + # still holds the write end open). + done, _ = await asyncio.wait({running.drain}, timeout=2) + if not done: + running.drain.cancel() + with contextlib.suppress(asyncio.CancelledError): + await running.drain + + async def _terminate(self, proc: asyncio.subprocess.Process, sandbox_id: SandboxId) -> None: + if proc.returncode is not None: + return + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=10.0) + except TimeoutError: + logger.warning("uv runtime %s did not exit after SIGTERM; SIGKILL", sandbox_id) + proc.kill() + with contextlib.suppress(Exception): + await asyncio.wait_for(proc.wait(), timeout=5.0) + + async def aclose(self) -> None: + """Terminate every running sandbox and remove a venv this provider + materialized. An externally supplied `reuse_venv` is left untouched.""" + for sandbox_id in list(self._running): + await self.delete(sandbox_id) + if self._owned_venv_root is not None: + shutil.rmtree(self._owned_venv_root, ignore_errors=True) + self._owned_venv_root = None + self._venv = None diff --git a/plugins/providers/uv/pyproject.toml b/plugins/providers/uv/pyproject.toml new file mode 100644 index 0000000..285cc89 --- /dev/null +++ b/plugins/providers/uv/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "agentix-provider-uv" +version = "0.1.0" +description = "uv-materialized local runtime provider for Agentix (no Docker/Nix bundle)" +requires-python = ">=3.11" +dependencies = [ + # Protocol + dataclasses (`SandboxProvider`, `Sandbox`, `SandboxConfig`, + # `SandboxInfo`, `SandboxId`) all live in core agentix. + "agentixx", + # The provider shells out to `uv` to materialize the runtime venv; depend + # on it so the backend works without a system-wide uv install. + "uv>=0.5", +] + +# `agentixx` is the monorepo workspace root — used editable, never from PyPI. +[tool.uv.sources] +agentixx = { workspace = true } + +# `uv sync` makes `providers().get("uv")` resolve — the registry walks this +# entry-point group. There is no `agentix deploy uv`: this backend materializes +# the runtime from source via uv, so there is no bundle artifact to deploy. +[project.entry-points."agentix.provider"] +uv = "agentix.provider.uv:UvProvider" + +[tool.hatch.build.targets.wheel] +# One file at `agentix/provider/uv.py`. The `agentix` and `agentix/provider` +# dirs carry no __init__.py here — those belong to core agentix; this wheel +# installs a sibling into the same namespace. +packages = ["agentix"] diff --git a/plugins/providers/uv/tests/test_uv_provider.py b/plugins/providers/uv/tests/test_uv_provider.py new file mode 100644 index 0000000..85067e7 --- /dev/null +++ b/plugins/providers/uv/tests/test_uv_provider.py @@ -0,0 +1,242 @@ +"""uv provider: launch the runtime from a venv and drive a real remote() call. + +Uses `reuse_venv` pointed at the interpreter running the tests (it already has +`agentixx` + uvicorn), so the test needs no uv materialization. Remote targets +are stdlib functions (`math.*`) — always importable by the worker, so the test +exercises the provider's runtime wiring without packaging a fixture module. (A +user's own rollout module is reached the same way every provider does it: +installed into the venv via `UvProviderConfig.project` / `install`.) +""" + +from __future__ import annotations + +import math +import sys + +import pytest +from agentix.provider.uv import UvProvider, UvProviderConfig + +from agentix.provider.base import SandboxConfig, SandboxProvider + + +def _reuse_venv() -> str: + # venv root of the interpreter running the tests. Use sys.prefix, NOT a + # resolved sys.executable: the venv's bin/python is a symlink, and resolving + # it jumps to the base interpreter (whose env lacks agentixx). + return sys.prefix + + +def test_config_requires_project_or_venv(): + with pytest.raises(ValueError): + UvProviderConfig() + + +def test_is_sandboxprovider(): + provider = UvProvider(UvProviderConfig(reuse_venv=_reuse_venv())) + assert isinstance(provider, SandboxProvider) + + +@pytest.mark.asyncio +async def test_remote_roundtrip(): + provider = UvProvider(UvProviderConfig(reuse_venv=_reuse_venv())) + try: + async with provider.session(SandboxConfig(image="uv", bundle="uv")) as sandbox: + assert (await sandbox.health()).version + assert await sandbox.remote(math.factorial, 5) == 120 + assert await sandbox.remote(math.gcd, 12, 8) == 4 + finally: + await provider.aclose() + + +@pytest.mark.asyncio +async def test_get_and_delete(): + provider = UvProvider(UvProviderConfig(reuse_venv=_reuse_venv())) + try: + sandbox = await provider.create(SandboxConfig(image="uv", bundle="uv")) + info = await provider.get(sandbox.sandbox_id) + assert info.status == "running" + await sandbox.aclose() + await provider.delete(sandbox.sandbox_id) + with pytest.raises(KeyError): + await provider.get(sandbox.sandbox_id) + finally: + await provider.aclose() + + +# A fake "runtime server" for pipe-handling tests: binds, answers /health, +# then floods stdout and exits. Undrained, asyncio's flow control pauses the +# pipe at ~192KiB (2x the StreamReader limit + one pipe buffer) and the child +# BLOCKS mid-write — a server that logs past that wedges every in-flight +# rollout on that sandbox. +_FLOOD_SERVER = ''' +import socket, sys +port = int(sys.argv[sys.argv.index("--port") + 1]) +srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(("127.0.0.1", port)); srv.listen() +conn, _ = srv.accept() +conn.recv(65536) +conn.sendall(b"HTTP/1.0 200 OK\\r\\ncontent-length: 2\\r\\n\\r\\nok") +conn.close() +sys.stdout.write("x" * (4 * 1024 * 1024)) # 4 MiB of "logs" +sys.stdout.write("TAIL-MARKER") +sys.stdout.flush() +''' + +_EXITING_SERVER = ''' +import sys +sys.stdout.write("boom-marker: refusing to start") +sys.stdout.flush() +sys.exit(3) +''' + + +def _fake_venv(tmp_path, server_source: str) -> str: + """A venv whose bin/python launches `server_source` instead of uvicorn.""" + script = tmp_path / "server.py" + script.write_text(server_source) + bin_dir = tmp_path / "venv" / "bin" + bin_dir.mkdir(parents=True) + shim = bin_dir / "python" + shim.write_text(f"#!/bin/sh\nexec {sys.executable} {script} \"$@\"\n") + shim.chmod(0o755) + return str(tmp_path / "venv") + + +@pytest.mark.asyncio +async def test_server_stdout_is_drained_not_retained(tmp_path): + """The runtime's stdout must be continuously drained for the sandbox's + lifetime: undrained, a server that logs past the pipe flow-control limit + (~192KiB) blocks mid-write and wedges every in-flight rollout. The drain + keeps only a BOUNDED tail in host memory.""" + import asyncio + + provider = UvProvider( + UvProviderConfig(reuse_venv=_fake_venv(tmp_path, _FLOOD_SERVER), health_timeout=10.0) + ) + try: + sandbox = await provider.create(SandboxConfig(image="uv", bundle="uv")) + running = provider._running[sandbox.sandbox_id] # noqa: SLF001 + # the fake server floods 4 MiB after health, then exits — it can only + # finish if someone is draining the pipe + for _ in range(100): + if running.proc.returncode is not None: + break + await asyncio.sleep(0.1) + assert running.proc.returncode is not None, ( + "server wedged mid-write: stdout pipe is not being drained" + ) + # deterministic: the drain ends once the pipe hits EOF + await asyncio.wait_for(running.drain, timeout=10) + + retained = sum(len(c) for c in running.tail) + assert retained < 1024 * 1024, ( + f"provider retains {retained} bytes of server stdout in host memory" + ) + # ... but the LAST output survives in the bounded tail for diagnostics + assert b"TAIL-MARKER" in b"".join(running.tail) + finally: + await provider.aclose() + + +@pytest.mark.asyncio +async def test_early_exit_diagnostics_survive_draining(tmp_path): + """A server that dies before health must still surface its output in the + error — the drain task, not a one-shot read, owns the pipe.""" + provider = UvProvider( + UvProviderConfig(reuse_venv=_fake_venv(tmp_path, _EXITING_SERVER), health_timeout=10.0) + ) + try: + with pytest.raises(RuntimeError, match="boom-marker"): + await provider.create(SandboxConfig(image="uv", bundle="uv")) + finally: + await provider.aclose() + + +# Serves /health forever on the given port (terminates cleanly on SIGTERM). +_HEALTHY_SERVER = ''' +import socket, sys +port = int(sys.argv[sys.argv.index("--port") + 1]) +srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(("127.0.0.1", port)); srv.listen() +while True: + conn, _ = srv.accept() + conn.recv(65536) + conn.sendall(b"HTTP/1.0 200 OK\\r\\ncontent-length: 2\\r\\n\\r\\nok") + conn.close() +''' + +# Dies before health, but first spawns a grandchild that inherits (and holds +# open) the merged stdout pipe — so the drain sees no EOF for several seconds. +_ORPHAN_HOLDER_SERVER = ''' +import subprocess, sys +sys.stdout.write("boom-diagnostic: refusing to start") +sys.stdout.flush() +subprocess.Popen([sys.executable, "-c", "import time; time.sleep(6)"]) +sys.exit(3) +''' + + +@pytest.mark.asyncio +async def test_early_exit_diagnostic_survives_pipe_held_by_grandchild(tmp_path): + """When a grandchild holds the pipe open past the 2s diagnostic wait, + create() must still raise the diagnostic RuntimeError — never a bare + CancelledError from re-awaiting a drain task that wait_for() cancelled.""" + provider = UvProvider( + UvProviderConfig(reuse_venv=_fake_venv(tmp_path, _ORPHAN_HOLDER_SERVER), health_timeout=10.0) + ) + try: + with pytest.raises(RuntimeError, match="boom-diagnostic"): + await provider.create(SandboxConfig(image="uv", bundle="uv")) + finally: + await provider.aclose() + + +@pytest.mark.asyncio +async def test_concurrent_creates_never_share_a_port(tmp_path, monkeypatch): + """The allocated port is only bound by the subprocess seconds later, so it + must be reserved in-process until the sandbox dies — two creates that each + bind-and-close can otherwise collide (same guard as DockerProvider).""" + import agentix.provider.uv as uv_mod + + f1, f2 = uv_mod._free_port(), uv_mod._free_port() + ports = iter([f1, f1, f2]) # the kernel hands out the same number twice + monkeypatch.setattr(uv_mod, "_free_port", lambda: next(ports)) + + provider = UvProvider( + UvProviderConfig(reuse_venv=_fake_venv(tmp_path, _HEALTHY_SERVER), health_timeout=10.0) + ) + try: + s1 = await provider.create(SandboxConfig(image="uv", bundle="uv")) + s2 = await provider.create(SandboxConfig(image="uv", bundle="uv")) + p1 = provider._running[s1.sandbox_id].port # noqa: SLF001 + p2 = provider._running[s2.sandbox_id].port # noqa: SLF001 + assert p1 != p2 + finally: + await provider.aclose() + + +@pytest.mark.asyncio +async def test_failed_materialization_removes_temp_root(tmp_path, monkeypatch): + """A failed `uv venv` / `uv pip install` must not orphan the mkdtemp root — + a retry loop would otherwise leak one partial venv per attempt.""" + import tempfile + + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + provider = UvProvider(UvProviderConfig(project=".", uv_bin="/usr/bin/false")) + with pytest.raises(RuntimeError): + await provider._ensure_venv() # noqa: SLF001 + assert list(tmp_path.iterdir()) == [] + + +def test_default_uv_bin_resolves_packaged_binary(monkeypatch): + """The packaged `uv` dependency must be found even when the venv's bin is + not on PATH (systemd/cron/absolute-path launches) — that's the whole point + of depending on the `uv` wheel.""" + from pathlib import Path + + monkeypatch.setenv("PATH", "/usr/bin:/bin") + resolved = UvProviderConfig(project=".").resolved_uv_bin() + assert resolved != "uv" + assert Path(resolved).is_file() diff --git a/pyproject.toml b/pyproject.toml index b9bd318..87a5854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ include = [ "plugins/providers/docker/agentix", "plugins/providers/daytona/agentix", "plugins/providers/e2b/agentix", + "plugins/providers/uv/agentix", "plugins/runtime-basic/agentix", "plugins/tito/agentix", "plugins/trace-otel/agentix", @@ -144,6 +145,7 @@ extraPaths = [ "plugins/providers/docker", "plugins/providers/daytona", "plugins/providers/e2b", + "plugins/providers/uv", "plugins/runtime-basic", "plugins/tito", "plugins/trace-otel", diff --git a/uv.lock b/uv.lock index 86d4313..047b584 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ members = [ "agentix-provider-daytona", "agentix-provider-docker", "agentix-provider-e2b", + "agentix-provider-uv", "agentix-runner", "agentix-runtime-basic", "agentix-tito", @@ -172,6 +173,21 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "agentixx", editable = "." }] +[[package]] +name = "agentix-provider-uv" +version = "0.1.0" +source = { editable = "plugins/providers/uv" } +dependencies = [ + { name = "agentixx" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "agentixx", editable = "." }, + { name = "uv", specifier = ">=0.5" }, +] + [[package]] name = "agentix-runner" version = "0.1.0" @@ -3783,6 +3799,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uv" +version = "0.11.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/cb/5efc713948ddb10b00abfb51bfd429221c720175557f9c7965fea2448fe4/uv-0.11.26.tar.gz", hash = "sha256:2a433ece2ace088dd572d8abb0e6bd9a4ecb0e10bc9856447bbb37545f384f29", size = 4331220, upload-time = "2026-06-30T14:52:03.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/71/86dbffac9e26df28a16639c426cf4ba572aaf43d9231463e0dca337895b2/uv-0.11.26-py3-none-linux_armv6l.whl", hash = "sha256:fb97bf04512dfe16d86084e75d8129701fc8da9fb40de8746b73c3aa617c5897", size = 25197324, upload-time = "2026-06-30T14:50:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/ec/80/525b73c8188e7052343e7109466a08fcd5195055aff4b0346ce3622e48cb/uv-0.11.26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a58a06e5a4b0035538d3ab4160ad74c716076ea7148eb3317171c6276ac020b4", size = 24179172, upload-time = "2026-06-30T14:50:56.52Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/cf7b94ed3b1932c2a62573dcd388ad6c1da5c52111cd71ab7f20faa4a0aa/uv-0.11.26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b6d078d2ce83897884c2330c0676f27be4bf3d223fb2a409460f579fb5f0a98", size = 22949576, upload-time = "2026-06-30T14:51:00.538Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fd/71fa021f6909c4139d8354bea623b5e0ef0ce4a08da250da1a1645528da2/uv-0.11.26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1cd9ba4951681ce17f1703106266fcbe27aaa7d37f07d53cce8b5686d68a8755", size = 24936673, upload-time = "2026-06-30T14:51:04.496Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/273425e58a8812423e3d1f6c5da1015e636fbf13a83d104317ca37e16304/uv-0.11.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:e4f4c3268e69ac96f01972274a62f5f930c03cbc680adba6f21e63237ba3a639", size = 24719617, upload-time = "2026-06-30T14:51:08.419Z" }, + { url = "https://files.pythonhosted.org/packages/81/f8/1601e2acc7c54963814b4831eab996d8599e690712722c5acec5114860be/uv-0.11.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:efcbe0e187846f5ddba23bcaed17e4f9cd2463da5c45bdb5869616f686d713ff", size = 24734176, upload-time = "2026-06-30T14:51:12.685Z" }, + { url = "https://files.pythonhosted.org/packages/88/d2/a8a422e54c08cf4b8d51bedb9dbdd3cc233aa290ad8b3ee0438c0c02a3a5/uv-0.11.26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:120ab2de93164d08cf5950f7fe18cbebe3ff670865ae41a292452bab2346477f", size = 26158780, upload-time = "2026-06-30T14:51:16.514Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/647fe5fdc888a3d27f79977877ce4e88052fe9be5398371e51bb134fc262/uv-0.11.26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9052bf27c7ee426901f35a48715fa9288ce631c1878b91c9a6c950288f4b8633", size = 27009550, upload-time = "2026-06-30T14:51:20.659Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/85d8e762ad83b0f14fae2255b0578c4fd7dc915746f81b64ed786342627a/uv-0.11.26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:efdddfcc9b1b790c5f7985c5c183c851682ced165b44ffa914f4947f5cad1fbf", size = 26183777, upload-time = "2026-06-30T14:51:24.715Z" }, + { url = "https://files.pythonhosted.org/packages/d3/00/478c3a870dcac690b8c337ee950a60a952e817f574945e85155c3cc0ab34/uv-0.11.26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dcf4e0b5b5cbdc242dcb002f1f8d99e7cf8c043609869228a9ce15e095c0b18", size = 26260589, upload-time = "2026-06-30T14:51:28.809Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/e4e43e106fb8cdc026b97491ea4600f4194a9c4da0b4e4e30c2a7dceb268/uv-0.11.26-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866ae8d28f7381c15de0906a284c1e97916424c635bf40f7960b3fc889cd725e", size = 25073850, upload-time = "2026-06-30T14:51:32.717Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c2/e772b7e6c8a835e8bf6739a391cdfc8e8e244c5c496d9b40625068b59ff4/uv-0.11.26-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:22f6d62e794b252ff3a1e2dfe5010cc76208f90b2c906e54971a0223ad6f16bc", size = 25682609, upload-time = "2026-06-30T14:51:36.888Z" }, + { url = "https://files.pythonhosted.org/packages/1a/69/ea77209a224a23a399cb7f6414f77ef032bd9e083e01199a0ebebf0d3ff2/uv-0.11.26-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:edd0c12b75141a6d830d138a91e366ad66e630f1c1dcaf83b8325b80cbacfcbb", size = 25800556, upload-time = "2026-06-30T14:51:40.937Z" }, + { url = "https://files.pythonhosted.org/packages/77/60/b6c0c03d2538a016b6624fa251960012e564ea02f841e958c7d60e974685/uv-0.11.26-py3-none-musllinux_1_1_i686.whl", hash = "sha256:af6a45b11a569cc4d2437e89a25a53dcf753f2a02a8f2de96be09b9b942cb3ec", size = 25385658, upload-time = "2026-06-30T14:51:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e7/46881ff9164aa2e7c649901837d58eee3c57beb3b0fcc0fea6a4e40cf8f3/uv-0.11.26-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c28822517d03aebbe9549aaaecc88ad580e4b2b6a927abffe5774a74d6ba09f6", size = 26551013, upload-time = "2026-06-30T14:51:49.062Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/380dad6c2bbe12417025aacd12cfc08322ed4c9dd8f760bff7035b86f22d/uv-0.11.26-py3-none-win32.whl", hash = "sha256:79e5c1b3410047e1962290c3b7b8f512d2c1bb95200c60b016f7729287cf34c0", size = 23947180, upload-time = "2026-06-30T14:51:53.065Z" }, + { url = "https://files.pythonhosted.org/packages/d0/13/9c588226d5b478328d739e654944430719f3ffe8999d6a24d425ec9664ab/uv-0.11.26-py3-none-win_amd64.whl", hash = "sha256:d95567e9470dc48ff03265f420c3c6973f6437f18a79d5e00b6eb4b2d9379907", size = 26909320, upload-time = "2026-06-30T14:51:57.235Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/ea66b12813878797126e2b3aca124b1c9c5ef53120702d1c00172f90a21d/uv-0.11.26-py3-none-win_arm64.whl", hash = "sha256:7e69d1569afbb936e7bf4e4ab2f72d606405f4a68f380f088a0b2233e84e056a", size = 25176820, upload-time = "2026-06-30T14:52:01.05Z" }, +] + [[package]] name = "uvicorn" version = "0.47.0"