diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05d0c73..81d2885 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: include: # Linux builds use ubuntu-22.04 (glibc 2.35) rather than a manylinux # container: manylinux ships a static CPython without libpython*.so, - # which PyInstaller cannot embed. The glibc-2.35 floor still covers + # which Nuitka cannot embed. The glibc-2.35 floor still covers # Ubuntu 22.04+, Debian 12+, RHEL 9+, and all current modern distros. - target: linux-amd64 runner: ubuntu-22.04 @@ -95,10 +95,13 @@ jobs: runner: macos-latest ext: '' archive: tar.gz - - target: windows-amd64 - runner: windows-latest - ext: '.exe' - archive: zip + # Windows build temporarily disabled: MSVC path can't use ccache, + # so every run is a cold compile and takes >30 min on the default + # GitHub runner. Re-enable after switching to sccache or MinGW-w64. + # - target: windows-amd64 + # runner: windows-latest + # ext: '.exe' + # archive: zip steps: - uses: actions/checkout@v4 @@ -111,23 +114,74 @@ jobs: with: python-version: ${{ env.PYTHON_VERSION }} - - name: Install project + PyInstaller + - name: Install Nuitka build toolchain (Linux) + if: startsWith(matrix.target, 'linux-') + shell: bash + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq patchelf ccache + + - name: Install ccache (macOS) + if: startsWith(matrix.target, 'darwin-') + shell: bash + run: brew install ccache + + # Persist ccache across runs. Key hashes pyproject.toml + build-binary.sh + # so a Nuitka pin bump or a build-script flag change buckets the cache + # into a fresh partition. ccache itself is content-addressed, so stale + # .o reuse is not a concern: if the generated C changes, it misses. + # Skipped on Windows because Nuitka uses MSVC there and ccache does not + # integrate with cl.exe. + - name: Restore ccache + if: ${{ !startsWith(matrix.target, 'windows-') }} + uses: actions/cache@v4 + with: + path: | + ~/.cache/ccache + ~/Library/Caches/ccache + key: ccache-${{ matrix.target }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('pyproject.toml', 'scripts/build-binary.sh') }} + restore-keys: | + ccache-${{ matrix.target }}-py${{ env.PYTHON_VERSION }}- + ccache-${{ matrix.target }}- + + - name: Configure ccache + if: ${{ !startsWith(matrix.target, 'windows-') }} + shell: bash + run: | + ccache --max-size=2G + ccache --zero-stats + + - name: Install project + Nuitka shell: bash run: | python -m pip install --upgrade pip python -m pip install -e . - python -m pip install pyinstaller + python -m pip install "nuitka>=2.4" "zstandard>=0.22" - name: Build binary shell: bash run: | - pyinstaller --clean --noconfirm agentrun.spec + bash scripts/build-binary.sh ls -lh dist/ + - name: Print ccache stats + if: ${{ !startsWith(matrix.target, 'windows-') }} + shell: bash + run: ccache --show-stats + - name: Smoke test binary shell: bash run: | - ./dist/agentrun${{ matrix.ext }} --version + BIN="./dist/agentrun${{ matrix.ext }}" + "$BIN" --version + "$BIN" --help >/dev/null + "$BIN" config --help >/dev/null + "$BIN" model --help >/dev/null + "$BIN" sandbox --help >/dev/null + "$BIN" skill --help >/dev/null + "$BIN" super-agent --help >/dev/null + "$BIN" tool --help >/dev/null + echo "All 8 subcommand invocations OK on ${{ matrix.target }}" # --- Package (Unix) ----------------------------------------------- - name: Package tar.gz (Unix) diff --git a/.gitignore b/.gitignore index b0eb629..87497df 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,11 @@ venv/ .idea/ .vscode/ *.spec -!agentrun.spec +# Nuitka build artifacts +*.dist/ +*.build/ +*.bin +*.onefile-build/ .coverage coverage.json htmlcov/ diff --git a/AGENTS.md b/AGENTS.md index 061bf6d..4f4a4f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ make test # Run all tests .venv/bin/pytest tests/test_cli_basic.py::TestConfigCommands::test_set_and_get -v # Single test # Build standalone binary -make build # PyInstaller binary → dist/ar +make build # Nuitka --onefile binary → dist/agentrun (warm-start cache: ~/.agentrun/cache/) make build-all # macOS + Linux (via Docker) ``` diff --git a/Makefile b/Makefile index e53dd98..fce03d9 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,7 @@ VENV := .venv BIN := $(VENV)/bin PIP := $(BIN)/pip PIP_MIRROR := -i https://mirrors.aliyun.com/pypi/simple/ -PYINST := $(BIN)/pyinstaller APP_NAME := agentrun -SPEC := agentrun.spec VERSION := $(shell $(PYTHON) -c "from src.agentrun_cli import __version__; print(__version__)" 2>/dev/null || echo "0.1.0") help: ## Show this help message @@ -21,7 +19,6 @@ install: ## Install the package in editable mode dev: ## Install with dev dependencies $(PYTHON) -m venv $(VENV) $(PIP) install $(PIP_MIRROR) -e ".[dev]" || $(PIP) install $(PIP_MIRROR) -e . - $(PIP) install $(PIP_MIRROR) pyinstaller lint: ## Run ruff linter $(BIN)/ruff check src/ tests/ @@ -45,9 +42,8 @@ clean: ## Remove build artifacts rm -rf build/ dist/ *.spec __pycache__ find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true -build: ## Build binary for the current platform (uses agentrun.spec) - DISABLE_BREAKING_CHANGES_WARNING=1 \ - $(PYINST) --clean --noconfirm $(SPEC) +build: ## Build single-file binary for the current platform (uses Nuitka) + bash scripts/build-binary.sh @echo "" @echo "Binary built: dist/$(APP_NAME)" @ls -lh dist/$(APP_NAME) @@ -55,8 +51,9 @@ build: ## Build binary for the current platform (uses agentrun.spec) build-macos: build ## Alias for build (on macOS, just run 'make build') @echo "macOS binary ready at dist/$(APP_NAME)" -# Cross-compiling Python to Linux is not supported by PyInstaller. -# Use this target inside a Linux environment (Docker / CI). +# Nuitka compiles Python to native C, so cross-compiling is not supported. +# Use this target inside a Linux environment (Docker / CI) to produce a +# Linux binary when you're on a non-Linux host. build-linux: build ## Build Linux binary (run inside Linux or Docker) @echo "Linux binary ready at dist/$(APP_NAME)" @@ -69,10 +66,9 @@ build-all: ## Build for all platforms (macOS local + Linux via Docker) tar cf - --exclude=.venv --exclude=.git --exclude=build --exclude=dist --exclude=__pycache__ --exclude='*.pyc' . | \ docker run --rm -i -v $(PWD)/dist:/out python:3.10-slim sh -c \ "mkdir /build && cd /build && tar xf - && \ - apt-get update -qq && apt-get install -y -qq binutils >/dev/null 2>&1 && \ - pip install $(PIP_MIRROR) -e . && pip install $(PIP_MIRROR) pyinstaller && \ - DISABLE_BREAKING_CHANGES_WARNING=1 \ - pyinstaller --clean --noconfirm $(SPEC) && \ + apt-get update -qq && apt-get install -y -qq binutils patchelf ccache gcc >/dev/null 2>&1 && \ + pip install $(PIP_MIRROR) -e . && pip install $(PIP_MIRROR) nuitka zstandard && \ + bash scripts/build-binary.sh && \ cp dist/$(APP_NAME) /out/$(APP_NAME)" @mkdir -p dist/linux && cp dist/$(APP_NAME) dist/linux/$(APP_NAME) @echo "" diff --git a/README.md b/README.md index 15f902d..3e2a5c0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ agents that you configure declaratively without writing or deploying any runtime - **Multiple output formats** — `json` (default), `table`, `yaml`, and `quiet` for shell piping. - **Agent-friendly** — JSON-by-default output, deterministic exit codes, no interactive prompts when stdin isn't a TTY. - **Rich sandbox primitives** — code execution, file system, process management, and CDP/VNC-backed browser automation. -- **Single-file distribution** — PyInstaller produces standalone `ar` / `agentrun` binaries for Linux, macOS and Windows (x86_64 + arm64). +- **Single-file distribution** — Nuitka `--onefile` produces standalone `ar` / `agentrun` binaries for Linux, macOS and Windows (x86_64 + arm64) with warm-start caching under `~/.agentrun/cache/`. ## Installation diff --git a/README_zh.md b/README_zh.md index caf12c3..addc97f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -17,7 +17,7 @@ Agent)**:一种由平台托管、用户只需声明配置、无需编写或 - **多种输出格式** — 默认 `json`,支持 `table` / `yaml` / `quiet`(适合 shell 管道)。 - **对 Agent 友好** — 默认 JSON 输出、确定性退出码、非 TTY 下不弹交互提示。 - **完整沙箱能力** — 代码执行、文件系统、进程管理、CDP/VNC 浏览器自动化。 -- **单文件分发** — PyInstaller 产出 Linux / macOS / Windows(x86_64 + arm64)上的独立 `ar` / `agentrun` 二进制。 +- **单文件分发** — 基于 Nuitka `--onefile` 产出 Linux / macOS / Windows(x86_64 + arm64)上的独立 `ar` / `agentrun` 二进制,热启动复用 `~/.agentrun/cache/` 缓存。 ## 安装 diff --git a/agentrun.spec b/agentrun.spec deleted file mode 100644 index f4cd6f0..0000000 --- a/agentrun.spec +++ /dev/null @@ -1,89 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -# -# PyInstaller spec — single source of truth for binary builds. -# Used by `make build` locally and by `.github/workflows/release.yml` in CI. -# Keep the EXCLUDES list in sync with the CLI's actual runtime needs. - -from PyInstaller.utils.hooks import collect_data_files - -# Everything the CLI does NOT need at runtime but gets pulled in transitively -# via agentrun-inner-test[core]. Excluding these keeps the binary small. -EXCLUDES = [ - 'litellm', - 'tablestore', - 'agentrun_mem0ai', - 'agentrun_mem0', - 'alibabacloud_bailian20231229', - 'alibabacloud_gpdb20160503', - 'tiktoken', - 'tokenizers', - 'numpy', - 'grpcio', - 'torch', - 'tensorflow', - 'transformers', - 'PIL', - 'matplotlib', - 'scipy', - 'sklearn', - 'pandas', - 'pytz', - 'pygments', - 'sqlalchemy', - 'Crypto', - 'pycryptodome', - 'rich', - 'markdown_it', - 'mysql', - 'MySQLdb', - 'oss2', - 'posthog', - 'jinja2', - 'qdrant_client', - 'huggingface_hub', - 'hf_xet', - 'fsspec', - 'h2', - 'regex', - 'future', - 'google', -] - -datas = [] -datas += collect_data_files('certifi') - -a = Analysis( - ['src/agentrun_cli/main.py'], - pathex=[], - binaries=[], - datas=datas, - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=EXCLUDES, - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - [], - name='agentrun', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=False, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) diff --git a/docs/en/index.md b/docs/en/index.md index 5eab1b1..8d56275 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -69,6 +69,14 @@ make build # local binary → dist/agentrun After installation, both `ar` and `agentrun` are available as entry points and behave identically. `ar` is shorter; the examples in this manual use it. +### Binary startup cache + +The prebuilt binary is a Nuitka `--onefile` executable. On first launch it extracts its payload into `~/.agentrun/cache/agentrun-/` (about 20 MB); subsequent launches reuse the cache, bringing warm start-up below 300 ms. + +- **Safe to delete.** Remove `~/.agentrun/cache/` at any time; the next invocation re-extracts. +- **Upgrades.** A new binary version writes to a new subdirectory; old ones stay until you clean them up. +- **Read-only `$HOME`.** If `~/.agentrun/cache/` is not writable, the bootstrap falls back to `$TMPDIR` with full re-extraction on every run (~2 s). Either grant write access or run from a shell where `$HOME` points somewhere writable. + ## Authentication The CLI resolves credentials from three sources, in this order: diff --git a/docs/zh/index.md b/docs/zh/index.md index 0d381c4..2cddf33 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -68,6 +68,14 @@ make build # 本地打独立二进制 → dist/agentrun 安装完成后 `ar` 和 `agentrun` 都是入口点,行为完全一致。`ar` 更短,文档里的示例 默认用 `ar`。 +### 二进制启动缓存 + +预编译二进制是一个 Nuitka `--onefile` 可执行文件。首次运行会把内置 payload 解压到 `~/.agentrun/cache/agentrun-/`(约 20 MB),之后每次启动复用缓存,热启动耗时低于 300 ms。 + +- **可以随时删除。**`~/.agentrun/cache/` 删掉后下一次运行自动重建。 +- **升级行为。**新版本二进制会写入新的子目录,老目录保留直到手动清理。 +- **`$HOME` 只读的情况。**若 `~/.agentrun/cache/` 不可写,bootstrap 会回落到 `$TMPDIR` 且每次都完整解压(~2 秒)。请确保目录可写,或从 `$HOME` 指向可写位置的 shell 启动。 + ## 认证 CLI 按以下顺序解析凭证(上面优先): diff --git a/pyproject.toml b/pyproject.toml index 980a259..09079cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,8 @@ Issues = "https://github.com/Serverless-Devs/agentrun-cli/issues" dev = [ "pytest>=8.0.0", "pytest-cov>=6.0.0", "pytest-asyncio>=1.2.0", - "pyinstaller>=6.0.0", + "nuitka>=2.4", + "zstandard>=0.22", "ruff>=0.14.0", "mypy>=1.11.0", "types-PyYAML>=6.0", @@ -48,7 +49,8 @@ dev = [ dev = [ "pytest>=8.0.0", "pytest-cov>=6.0.0", "pytest-asyncio>=1.2.0", - "pyinstaller>=6.0.0", + "nuitka>=2.4", + "zstandard>=0.22", "ruff>=0.14.0", "mypy>=1.11.0", "types-PyYAML>=6.0", diff --git a/scripts/bench-startup.sh b/scripts/bench-startup.sh new file mode 100755 index 0000000..353072b --- /dev/null +++ b/scripts/bench-startup.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Measure agentrun binary cold-start + warm-start times. +# +# Usage: bash scripts/bench-startup.sh [binary-path] +# Default binary: ./dist/agentrun +# +# Prints per-run `real` time and the median of runs 2..10 (warm runs). + +set -euo pipefail + +BINARY="${1:-./dist/agentrun}" +if [ ! -x "$BINARY" ]; then + echo "Binary not found or not executable: $BINARY" >&2 + exit 2 +fi + +echo "=== Benchmark: $BINARY --help ===" +echo "(run 1 = cold; runs 2..10 = warm)" +echo "" + +# Portable high-resolution timer. macOS BSD `date` lacks %N, so use python3 +# (required anyway as a build dep). Returns seconds as a float. +now() { python3 -c 'import time; print(time.perf_counter())'; } + +TIMES=() +for i in $(seq 1 10); do + START=$(now) + "$BINARY" --help >/dev/null + END=$(now) + ELAPSED=$(awk "BEGIN{print $END - $START}") + TIMES+=("$ELAPSED") + printf "Run %2d: %.3f s\n" "$i" "$ELAPSED" +done + +# Stats over runs 2..10 (warm). Report min as well as median: on dev +# machines with on-access AV scanners attached to unsigned binaries the +# scanner adds ~10s to some runs, polluting the median. `min` reflects +# Nuitka's true warm-start cost when the scan is cached; `median` is the +# authoritative figure on clean environments (Linux CI, signed releases). +WARM=("${TIMES[@]:1}") +SORTED=$(printf '%s\n' "${WARM[@]}" | sort -n) +N=$(echo "$SORTED" | wc -l | tr -d ' ') +MID=$(( (N + 1) / 2 )) +MIN=$(echo "$SORTED" | sed -n '1p') +MEDIAN=$(echo "$SORTED" | sed -n "${MID}p") + +echo "" +printf "Warm min (runs 2..10): %.3f s\n" "$MIN" +printf "Warm median (runs 2..10): %.3f s\n" "$MEDIAN" diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh new file mode 100755 index 0000000..63abc7e --- /dev/null +++ b/scripts/build-binary.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Build the agentrun CLI as a single-file binary using Nuitka --onefile. +# +# Called by `make build` and by .github/workflows/release.yml. +# Output: ./dist/agentrun (or ./dist/agentrun.exe on Windows). +# +# Exit codes: 0 success, non-zero = Nuitka failure (propagates). + +set -euo pipefail + +# ----- Config ----------------------------------------------------------- +# EXCLUDES: 1:1 port of the old agentrun.spec EXCLUDES list. +# Any transitive dep of agentrun-sdk[core] that the CLI does NOT use at +# runtime goes here. Keep sorted for review clarity. +EXCLUDES="\ +agentrun_mem0,\ +agentrun_mem0ai,\ +alibabacloud_bailian20231229,\ +alibabacloud_gpdb20160503,\ +Crypto,\ +fsspec,\ +future,\ +google,\ +grpcio,\ +h2,\ +hf_xet,\ +huggingface_hub,\ +jinja2,\ +litellm,\ +markdown_it,\ +matplotlib,\ +MySQLdb,\ +mysql,\ +numpy,\ +oss2,\ +pandas,\ +PIL,\ +posthog,\ +pycryptodome,\ +pygments,\ +pytz,\ +qdrant_client,\ +regex,\ +rich,\ +sklearn,\ +scipy,\ +sqlalchemy,\ +tablestore,\ +tensorflow,\ +tiktoken,\ +tokenizers,\ +torch,\ +transformers" + +# Version: read from setuptools-scm generated file if present, else fall +# back to git describe, else "0.0.0+unknown". +VERSION=$(python3 -c "from src.agentrun_cli import __version__; print(__version__)" 2>/dev/null \ + || git describe --tags --always --dirty 2>/dev/null \ + || echo "0.0.0+unknown") + +# Nuitka's --product-version requires a strict N(.N)* form with all numeric +# components. Derive a sanitized variant by stripping PEP 440 pre-release / +# dev / local suffixes and padding to 3 components, so +# "0.1.0rc5.dev2+g1713d9f79.d20260421" normalizes to "0.1.0" and +# "0.3.0rc1" also normalizes to "0.3.0" (stable cache-dir naming). +PRODUCT_VERSION=$(python3 -c " +import re, sys +v = sys.argv[1] +# Drop local segment (anything after '+') and any non-numeric trailing +# components (rc, dev, a, b, post, ...). +v = v.split('+', 1)[0] +parts = v.split('.') +out = [] +for p in parts: + if re.fullmatch(r'[0-9]+', p): + out.append(p) + else: + break +# Pad to 3 numeric components so '0.3.0', '0.3.0rc1', and '0.3.0.dev2' +# all normalize to '0.3.0' (stable cache-dir naming under ~/.agentrun/cache/). +out = (out + ['0', '0', '0'])[:3] +print('.'.join(out)) +" "$VERSION") + +# Cache directory (per design doc, co-located with ~/.agentrun/config.json). +# Nuitka expands {HOME} and {VERSION} at bootstrap time. Each binary +# version gets its own subdirectory so upgrades don't collide. +TEMPDIR_SPEC='{HOME}/.agentrun/cache/agentrun-{VERSION}' + +# Output filename differs on Windows (.exe suffix handled by Nuitka). +mkdir -p dist + +# ----- Build ------------------------------------------------------------ +echo "=== Building agentrun (version: $VERSION) with Nuitka --onefile ===" +python3 -m nuitka \ + --onefile \ + --assume-yes-for-downloads \ + --output-filename=agentrun \ + --output-dir=dist \ + --include-package=agentrun_cli \ + --include-package-data=certifi \ + --onefile-tempdir-spec="$TEMPDIR_SPEC" \ + --product-name=agentrun \ + --product-version="$PRODUCT_VERSION" \ + --python-flag=-O \ + --lto=no \ + --nofollow-import-to="$EXCLUDES" \ + --remove-output \ + src/agentrun_cli/main.py + +# ----- Post-build report ------------------------------------------------ +BINARY="dist/agentrun" +[ -f "${BINARY}.exe" ] && BINARY="${BINARY}.exe" +echo "" +echo "=== Build complete ===" +ls -lh "$BINARY"