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
134 changes: 134 additions & 0 deletions agent_assembly/runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Runtime auto-detection and lifecycle management for the `aasm` sidecar (F115 / AAASM-1205).

The `init_assembly()` exported here is intentionally NOT re-exported from
`agent_assembly` at the top level: the existing gateway-based
`agent_assembly.init_assembly` keeps its meaning. Opt in to the runtime-managed
flow with ``from agent_assembly.runtime import init_assembly``.
"""

from __future__ import annotations

import os
import shutil
import socket
import subprocess
from pathlib import Path

__all__ = [
"BINARY_NAME",
"DEFAULT_PORT",
"DEFAULT_RUNTIME_HOST",
"INSTALL_HINT",
"find_aasm_binary",
"init_assembly",
"is_running",
"start_runtime",
]

BINARY_NAME = "aasm"
DEFAULT_PORT = 7878
DEFAULT_RUNTIME_HOST = "127.0.0.1"

USER_LOCAL_BIN = Path.home() / ".local" / "bin"
WHEEL_BUNDLED_BIN = Path(__file__).resolve().parent / "bin"
DOCKER_BASE_BIN = Path("/usr/local/bin")

RUNTIME_LOG_FILENAME = ".aasm-runtime.log"

INSTALL_HINT = (
"agent-assembly runtime not found.\n"
" Install with: pip install agent-assembly-python[runtime]\n"
" Or manually: brew install agent-assembly/tap/aasm\n"
" curl -fsSL https://get.agent-assembly.io | sh"
)


def find_aasm_binary() -> Path | None:
"""Locate the `aasm` binary across the 5 supported install paths.

Search order: ``$PATH`` (covers Homebrew and ``cargo install``) →
``~/.local/bin/aasm`` (curl installer default) →
``agent_assembly/bin/aasm`` (wheel-bundled with the ``[runtime]`` extra) →
``/usr/local/bin/aasm`` (Docker base image). Returns the first executable
match, or ``None`` when no candidate exists.
"""
path_hit = shutil.which(BINARY_NAME)
if path_hit:
return Path(path_hit)
for candidate in (
USER_LOCAL_BIN / BINARY_NAME,
WHEEL_BUNDLED_BIN / BINARY_NAME,
DOCKER_BASE_BIN / BINARY_NAME,
):
if candidate.is_file() and os.access(candidate, os.X_OK):
return candidate
return None


def is_running(port: int = DEFAULT_PORT, *, host: str = DEFAULT_RUNTIME_HOST) -> bool:
"""Return True iff a local TCP listener accepts a connect on ``host:port``.

A 100 ms connect window keeps the probe cheap on the common idle path; any
socket error (refused, timeout, unreachable) is treated as no-sidecar.
"""
try:
with socket.create_connection((host, port), timeout=0.1):
return True
except OSError:
return False


def start_runtime(
binary: Path,
*,
port: int = DEFAULT_PORT,
log_dir: Path | None = None,
) -> subprocess.Popen[bytes]:
"""Spawn ``aasm serve --port <port>`` as a detached background subprocess.

Stdout and stderr are appended to ``<log_dir>/.aasm-runtime.log`` (default
log directory is the current working directory) so the sidecar outlives
the parent. ``start_new_session=True`` detaches the child from this
process's controlling terminal.
"""
target_dir = log_dir if log_dir is not None else Path.cwd()
log_path = target_dir / RUNTIME_LOG_FILENAME
log_file = log_path.open("ab")
return subprocess.Popen(
[str(binary), "serve", "--port", str(port)],
stdout=log_file,
stderr=log_file,
stdin=subprocess.DEVNULL,
start_new_session=True,
)


def init_assembly(
agent_id: str | None = None,
*,
port: int = DEFAULT_PORT,
) -> None:
"""Ensure the local ``aasm`` sidecar is running, starting it if necessary.

Lifecycle per F115 / AAASM-1205:

1. Probe ``host:port`` via :func:`is_running`; return early if the sidecar
is already up (idempotent re-init).
2. Resolve the binary via :func:`find_aasm_binary`.
3. Spawn the sidecar via :func:`start_runtime`.

``agent_id`` is accepted to keep the ticket-specified signature stable;
actual register-and-connect is performed by the existing gateway-aware
``agent_assembly.init_assembly`` once the sidecar is reachable.

Raises:
RuntimeError: when no ``aasm`` binary is found on disk. The message
contains copy-paste install commands for all supported channels.
"""
del agent_id # not consumed at the lifecycle layer; see docstring
if is_running(port):
return
binary = find_aasm_binary()
if binary is None:
raise RuntimeError(INSTALL_HINT)
start_runtime(binary, port=port)
89 changes: 89 additions & 0 deletions test/unit/test_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Unit tests for agent_assembly.runtime (AAASM-1227 / F115).

Covers the four scenarios from the AAASM-1230 AC checklist:
* binary-in-PATH
* binary-bundled (in agent_assembly/bin/aasm)
* binary-not-found
* already-running (init_assembly skips spawn when sidecar reachable)
"""

from __future__ import annotations

import stat
from pathlib import Path
from unittest.mock import MagicMock

import pytest

from agent_assembly import runtime


def _make_fake_aasm(directory: Path) -> Path:
"""Write an executable `aasm` shim into ``directory`` and return its path."""
path = directory / runtime.BINARY_NAME
path.write_text("#!/bin/sh\nexit 0\n")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return path


def test_find_aasm_binary_returns_path_hit_when_on_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""binary-in-PATH: shutil.which hit returns immediately, ahead of every fallback."""
fake = _make_fake_aasm(tmp_path)
monkeypatch.setenv("PATH", str(tmp_path))

resolved = runtime.find_aasm_binary()

assert resolved == fake # noqa: S101 — pytest assertion


def test_init_assembly_raises_runtime_error_with_install_hint_when_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""binary-not-found: init_assembly raises RuntimeError whose message is the
INSTALL_HINT (which includes the pip/brew/curl copy-paste commands)."""
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
monkeypatch.setenv("PATH", str(empty_dir))
monkeypatch.setattr(runtime, "USER_LOCAL_BIN", tmp_path / "no-such-local-bin")
monkeypatch.setattr(runtime, "WHEEL_BUNDLED_BIN", tmp_path / "no-such-wheel-bin")
monkeypatch.setattr(runtime, "DOCKER_BASE_BIN", tmp_path / "no-such-docker-bin")
# Ensure is_running returns False so the orchestrator reaches find_aasm_binary.
monkeypatch.setattr(runtime, "is_running", lambda *_args, **_kw: False)

with pytest.raises(RuntimeError) as exc_info:
runtime.init_assembly()

assert str(exc_info.value) == runtime.INSTALL_HINT
assert "agent-assembly runtime not found" in str(exc_info.value)
assert "pip install" in str(exc_info.value)


def test_find_aasm_binary_returns_wheel_bundled_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""binary-bundled: when PATH and ~/.local/bin both miss, the wheel-bundled
location (`agent_assembly/bin/aasm`) is the next checked fallback."""
fake_wheel_bin = tmp_path / "wheel_bin"
fake_wheel_bin.mkdir()
fake = _make_fake_aasm(fake_wheel_bin)
monkeypatch.setenv("PATH", str(tmp_path / "empty")) # not on PATH
monkeypatch.setattr(runtime, "USER_LOCAL_BIN", tmp_path / "no-such-local-bin")
monkeypatch.setattr(runtime, "WHEEL_BUNDLED_BIN", fake_wheel_bin)
monkeypatch.setattr(runtime, "DOCKER_BASE_BIN", tmp_path / "no-such-docker-bin")

resolved = runtime.find_aasm_binary()

assert resolved == fake


def test_init_assembly_idempotent_when_already_running(monkeypatch: pytest.MonkeyPatch) -> None:
"""already-running: init_assembly returns early without calling
find_aasm_binary or start_runtime when is_running reports True."""
find_spy = MagicMock(return_value=None)
start_spy = MagicMock()
monkeypatch.setattr(runtime, "is_running", lambda *_args, **_kw: True)
monkeypatch.setattr(runtime, "find_aasm_binary", find_spy)
monkeypatch.setattr(runtime, "start_runtime", start_spy)

runtime.init_assembly()

find_spy.assert_not_called()
start_spy.assert_not_called()