Skip to content
Draft
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,25 @@ Initial open-source release of FrontierAgent.
- Clean-machine Linux + NVIDIA installation and release-certification guide,
distinguishing deployment health from production agent correctness.

- Standalone installation with `uv tool install`: the wheel now ships the
provider registry, and `frontier-agent` runs from any directory without a
checkout. An optional user env file (`$XDG_CONFIG_HOME/apodex/env`, default
`~/.config/apodex/env`, override with `APODEX_ENV_FILE`) holds the endpoint
below exported variables and the launch directory's `.env`; a key defined
next to a base URL is only applied together with that base URL.
- `APODEX_BUILD_CONTEXT` names a checkout to build `apodex:local` from when the
installed CLI is not one. Without an image, a checkout, or an explicit
`APODEX_IMAGE`, the Docker path stops with the options instead of silently
running natively.

### Fixed

- Native mode puts the CLI's own Python environment ahead of the inherited
`PATH`, so `read_file`, `download_file`, and `python3` inside `bash` use the
interpreter the CLI was installed with rather than a system Python.
- The Docker launcher forwards the resolved runtime variables (exported
environment, launch directory `.env`, user env file) into the container by
name with `docker run -e NAME`, so an exported value now takes precedence over
the checkout's `.env` inside the container as it already did natively.
- Apply benchmark question limits after seeded shuffling so repeated runs can
sample different questions while `--no-shuffle` keeps canonical ordering.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,26 @@ document packages are intentionally optional in native mode; the agent installs
only what a task actually needs into `<project>/.apodex/runtime/native`. The
`apodex` command is retained as a compatibility alias.

### Install once, launch from any project

To run `frontier-agent` like any other command-line tool, install it from this
repository with `uv` and keep the endpoint in one user file:

```bash
uv tool install --python 3.12 git+https://github.com/ApodexAI/FrontierAgent.git

# Put OPENAI_API_KEY, OPENAI_BASE_URL and OPENAI_MODEL into
# ${XDG_CONFIG_HOME:-$HOME/.config}/apodex/env and chmod 600 it.

cd /path/to/project
frontier-agent
```

Exported variables and a project `.env` still take precedence over the user
file. On macOS with Docker running, the container image has to be built once
from a clone. [Install once and launch from any project](docs/install/global-install.md)
covers the PATH setup, the precedence rules, the Docker step, and updating.

Prefer a script that does all of the above? `./scripts/run-macos.sh` and
`./scripts/run-linux.sh` set up a hosted-endpoint install, and
`./scripts/run-linux-gpu.sh --install-system-deps --setup-only` prepares a native,
Expand Down
30 changes: 25 additions & 5 deletions apodex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,18 @@ is enough.

## Install and run

Run from the repository root, so `frontier_agent`, `plugins` and `workflows`
import:
Two ways to install. As a standalone tool, so the command works from any
directory:

```bash
uv tool install --python 3.12 git+https://github.com/ApodexAI/FrontierAgent.git
# credentials in ${XDG_CONFIG_HOME:-$HOME/.config}/apodex/env, or exported;
# see docs/install/global-install.md
cd /path/to/your/repo && frontier-agent
```

Or from a checkout, run from the repository root so `frontier_agent`,
`plugins` and `workflows` import from the source tree:

```bash
uv sync
Expand Down Expand Up @@ -119,14 +129,24 @@ attached through the same session input manager; ordinary text is inserted into
the prompt. `Cmd+V` remains the terminal's normal text paste shortcut.

This is a local, open-source BYOK tool: there is no account or `login` command.
Keys stay in your environment or local `.env`; the TUI never asks for or displays
them. Startup validates the local configuration before opening the TUI, and
Keys stay in your environment, a local `.env`, or the optional user file
`$XDG_CONFIG_HOME/apodex/env` (default `~/.config/apodex/env`, override with
`APODEX_ENV_FILE`); the TUI never asks for or displays them. Precedence is CLI
options, then exported variables, then the launch directory's `.env`, then the
user file. The user file is read literally, without `${VAR}` expansion, and a
key it defines next to a base URL is only applied together with that base URL.
Startup validates the local configuration before opening the TUI, and
`/config` shows only safe diagnostics such as provider, model, endpoint host and
whether the required key is configured.

The first `--docker` run builds the image, which takes a few minutes
(LibreOffice and the document readers are large); later runs reuse it.
`APODEX_IMAGE` overrides the tag.
`APODEX_IMAGE` overrides the tag. Building needs a source checkout. A tool
installed with `uv tool install` has none, so it uses an image that is already
present, builds from the clone named by `APODEX_BUILD_CONTEXT`, or pulls an
explicit `APODEX_IMAGE`; with none of those it stops and lists the options
instead of running natively unannounced. Configured variables cross into the
container by name (`docker run -e NAME`), never as values on the command line.

On Linux, native mode is the default. On macOS, Docker remains preferred when
its daemon is reachable, with automatic fallback to native mode. Native mode
Expand Down
39 changes: 24 additions & 15 deletions apodex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,22 @@
from apodex.session import TerminalSession
from apodex.terminal import resolve_terminal_ui
from apodex.tui.themes import CLI_THEME_NAMES
from apodex.userenv import EnvResolution, load_environment

if TYPE_CHECKING:
from apodex.config import ModelConfig


def _load_env() -> None:
def _load_env() -> EnvResolution:
"""Load a ``.env`` (keys/base-url/model) from the launch directory or an
ancestor, the way FrontierAgent's own entry points do. ``override=False`` so
real environment variables and CLI flags always win. Must run **before**
any ``chdir`` so it finds the repo's ``.env`` rather than the target repo.
ancestor, the way FrontierAgent's own entry points do, then the optional
user env file underneath it. ``override=False`` so real environment
variables and CLI flags always win. Must run **before** any ``chdir`` so it
finds the repo's ``.env`` rather than the target repo, and before native
mode rewrites ``HOME``/``XDG_CONFIG_HOME``, so the user file is read from
the user's real config directory. See :mod:`apodex.userenv`.
"""
try:
from dotenv import find_dotenv, load_dotenv
except Exception:
return
load_dotenv(".env", override=False)
found = find_dotenv(usecwd=True)
if found:
load_dotenv(found, override=False)
return load_environment()


# (substring in an engine log message) -> clean one-line note to surface
Expand Down Expand Up @@ -301,7 +298,13 @@ async def _amain(argv: list[str] | None = None) -> int:
# keys are available (the standalone CLI isn't bootstrapped by the app).
# (The fully-local toolchain guarantee — incl. dropping E2B_API_KEY — is
# owned by TerminalSession._authorize_workspace.)
_load_env()
env_resolution = _load_env()
# Secret-free by construction (names and paths only). Printed now so the
# explanation precedes whatever the note is about — a preflight failure
# over a withheld key, say — and only once: the TUI path alone repeats
# them in its transcript, since its alternate screen covers stderr.
for note in env_resolution.notes:
print(f"apodex: {note}", file=sys.stderr)

# Textual's Kitty keyboard negotiation drops IME commits in iTerm2. Set
# the compatibility fallback before either starting the native TUI or
Expand Down Expand Up @@ -377,7 +380,10 @@ async def _amain(argv: list[str] | None = None) -> int:
if a != "--docker"]
docker_ok, docker_reason = docker_available()
if args.docker or docker_ok:
return run_in_container(passthrough, cwd=cwd)
return run_in_container(
passthrough, cwd=cwd,
forward_env=env_resolution.forwarded_names(),
)
print(
f"apodex: Docker is unavailable ({docker_reason}); using native mode.",
file=sys.stderr,
Expand Down Expand Up @@ -517,7 +523,10 @@ async def _amain(argv: list[str] | None = None) -> int:
# stderr is written moments before Textual takes the alternate screen, so a
# warning printed here is gone by the time the TUI is up. The TUI path
# carries them into the transcript instead; line mode prints as before.
startup_warnings = [warning.message for warning in runtime_config.warnings]
startup_warnings = [
*(env_resolution.notes if use_tui else ()),
*(warning.message for warning in runtime_config.warnings),
]
if not use_tui:
for message in startup_warnings:
print(f"warning: {message}", file=sys.stderr)
Expand Down
98 changes: 89 additions & 9 deletions apodex/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,20 @@
- a dedicated ``.apodex/runs/<session-id>/outputs`` directory, read-write at
``/outputs``;
- ``~/.apodex`` (session history, traces), so ``--resume`` works across runs;
- ``.env`` from the repo, for model and search credentials.
- ``.env`` from the repo, for model and search credentials, plus the resolved
runtime variables the host CLI loaded (exported environment, the launch
directory's ``.env``, the user env file) — forwarded by *name* with
``-e NAME`` so Docker reads each value from the process environment and no
secret ever lands on a command line.

The image is built on first use and reused after that. It is the same
``Dockerfile`` the benchmark runner uses, so there is one image to maintain.
Building needs a source checkout: a wheel installed with ``uv tool install``
carries no Dockerfile. Outside a checkout the launcher uses an image that is
already present, pulls an explicitly requested ``APODEX_IMAGE``, or builds
from the checkout named by ``APODEX_BUILD_CONTEXT`` — and otherwise stops
with the options spelled out rather than quietly running without the
boundary the platform default promised.
"""
from __future__ import annotations

Expand All @@ -24,12 +34,34 @@
import shutil
import subprocess
import sys
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from pathlib import Path

_DEFAULT_IMAGE = "apodex:local"
IMAGE = os.environ.get("APODEX_IMAGE", _DEFAULT_IMAGE)
_REPO_ROOT = Path(__file__).resolve().parents[1]
BUILD_CONTEXT_VAR = "APODEX_BUILD_CONTEXT"


class BuildContextUnavailable(RuntimeError):
"""No directory with a Dockerfile to build the default image from."""


def build_context(environ: Mapping[str, str] | None = None) -> Path | None:
"""The directory whose ``Dockerfile`` builds :data:`_DEFAULT_IMAGE`.

``APODEX_BUILD_CONTEXT`` names it explicitly (a FrontierAgent checkout, for
an installation that lives elsewhere). Otherwise it is this package's own
repository root when that is a checkout. ``None`` for a wheel installed
outside any checkout: ``site-packages`` has no Dockerfile.
"""
env = os.environ if environ is None else environ
override = (env.get(BUILD_CONTEXT_VAR) or "").strip()
if override:
return Path(override).expanduser()
if (_REPO_ROOT / "Dockerfile").is_file():
return _REPO_ROOT
return None


def terminal_env(environ: Mapping[str, str]) -> list[str]:
Expand Down Expand Up @@ -122,21 +154,49 @@ def image_exists(image: str = IMAGE) -> bool:
return probe.returncode == 0


def build_image(image: str = IMAGE, *, quiet: bool = False) -> None:
def build_image(
image: str = IMAGE, *, quiet: bool = False, context: Path | None = None,
) -> None:
"""Build the image from the repo Dockerfile.

Streams the build output: it takes minutes the first time (LibreOffice and
the document readers are large), and a silent multi-minute wait reads as a
hang.
hang. Raises :class:`BuildContextUnavailable` when there is no checkout
to build from; the caller turns that into user-facing guidance.
"""
print(f"apodex: building {image} (first run only, this takes a few minutes)…",
file=sys.stderr)
cmd = ["docker", "build", "-t", image, str(_REPO_ROOT)]
root = context if context is not None else build_context()
if root is None:
raise BuildContextUnavailable(
f"{image} is not present locally, and this installation is not a "
"source checkout, so it cannot be built here"
)
if not (root / "Dockerfile").is_file():
raise BuildContextUnavailable(
f"{BUILD_CONTEXT_VAR}={root} does not contain a Dockerfile"
)
print(f"apodex: building {image} from {root} (first run only, this takes a "
"few minutes)…", file=sys.stderr)
cmd = ["docker", "build", "-t", image, str(root)]
if quiet:
cmd.insert(2, "--quiet")
subprocess.run(cmd, check=True)


def _build_unavailable_message(reason: str) -> str:
return (
f"apodex: cannot use the Docker path — {reason}.\n"
" Choose one:\n"
" docker build -t apodex:local /path/to/FrontierAgent "
"(build once from a checkout)\n"
f" export {BUILD_CONTEXT_VAR}=/path/to/FrontierAgent "
"(let this command build from it)\n"
" export APODEX_IMAGE=<image you can pull> "
"(use a registry image)\n"
" frontier-agent --native … "
"(workspace-local host runtime, not an OS sandbox)"
)


def pull_image(image: str) -> bool:
"""Fetch *image* from its registry, returning whether it arrived.

Expand All @@ -148,9 +208,19 @@ def pull_image(image: str) -> bool:


def run_in_container(
argv: list[str], *, cwd: str | None = None, image: str = IMAGE,
argv: list[str],
*,
cwd: str | None = None,
image: str = IMAGE,
forward_env: Sequence[str] = (),
) -> int:
"""Re-exec ``apodex argv`` inside the container. Returns its exit code."""
"""Re-exec ``apodex argv`` inside the container. Returns its exit code.

``forward_env`` names host variables to carry inward (``-e NAME``, value
read by Docker from this process's environment). It is how an exported
key, a launch-directory ``.env`` or the user env file reach a run that the
checkout's ``--env-file`` alone would not cover.
"""
ok, why = docker_available()
if not ok:
print(
Expand All @@ -168,6 +238,9 @@ def run_in_container(
if image == _DEFAULT_IMAGE:
try:
build_image(image)
except BuildContextUnavailable as exc:
print(_build_unavailable_message(str(exc)), file=sys.stderr)
return 1
except subprocess.CalledProcessError as exc:
print(f"apodex: image build failed (exit {exc.returncode}).",
file=sys.stderr)
Expand Down Expand Up @@ -312,6 +385,13 @@ def run_in_container(
env_file = _REPO_ROOT / ".env"
if env_file.is_file():
docker_cmd += ["--env-file", str(env_file)]
# Name only: ``-e NAME`` makes Docker read the value from this process's
# environment, so the resolved key is never an argv token. Ordering after
# --env-file is what lets the host-resolved value win over the checkout's
# file, matching the exported-environment precedence native runs have.
for name in dict.fromkeys(forward_env):
if name in os.environ:
docker_cmd += ["-e", name]
docker_cmd += [image, "apodex", *_without_cwd_arg(argv)]

try:
Expand Down
35 changes: 35 additions & 0 deletions apodex/native.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,42 @@
from __future__ import annotations

import os
import sys
from collections.abc import MutableMapping
from pathlib import Path


def _interpreter_bin_dir(inherited_path: str) -> Path | None:
"""The CLI interpreter's ``bin`` directory, to lead the inherited PATH.

Native mode promises that the CLI's own Python environment is what
``python3`` means to the tools (``read_file`` and ``download_file`` pipe
their helpers to ``python3 -``; the model's ``bash`` heredocs do the same).
From a checkout, ``uv run`` puts the venv first on PATH and the promise
holds by accident. An installation made with ``uv tool install`` exposes
only ``frontier-agent``/``apodex`` on PATH, so ``python3`` fell through to
whatever the system ships — on macOS a 3.9 that cannot even parse the
readers.

``sys.executable`` is used *unresolved* on purpose: a venv's ``bin/python``
is a symlink to the base interpreter, and following it would name the base
installation's ``bin`` — the wrong environment, without the CLI's
dependencies. Only the directory itself is normalised. ``None`` when the
directory already leads the inherited PATH, so the ``uv run`` case keeps
its PATH byte-for-byte.
"""
executable = sys.executable
if not executable:
return None
bin_dir = Path(os.path.abspath(os.path.dirname(executable)))
if not bin_dir.is_dir():
return None
first = inherited_path.split(os.pathsep, 1)[0].strip() if inherited_path else ""
if first and os.path.abspath(first) == str(bin_dir):
return None
return bin_dir


def prepare_native_runtime(
workspace: str,
session_id: str,
Expand Down Expand Up @@ -69,6 +101,9 @@ def prepare_native_runtime(
dependencies / "cargo" / "bin",
]
native_path = os.pathsep.join(str(path) for path in native_bins)
interpreter_bin = _interpreter_bin_dir(inherited_path)
if interpreter_bin is not None:
native_path = f"{native_path}{os.pathsep}{interpreter_bin}"
if inherited_path:
native_path = f"{native_path}{os.pathsep}{inherited_path}"

Expand Down
Loading