Skip to content

Commit 0eb43a1

Browse files
authored
Merge pull request #48 from AI-agent-assembly/v0.0.1/AAASM-1227/feat/runtime_lifecycle
[AAASM-1227] ✨ (runtime): Add SDK runtime auto-detection and lifecycle (F115)
2 parents de34f14 + 8d0ed04 commit 0eb43a1

2 files changed

Lines changed: 223 additions & 0 deletions

File tree

agent_assembly/runtime.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Runtime auto-detection and lifecycle management for the `aasm` sidecar (F115 / AAASM-1205).
2+
3+
The `init_assembly()` exported here is intentionally NOT re-exported from
4+
`agent_assembly` at the top level: the existing gateway-based
5+
`agent_assembly.init_assembly` keeps its meaning. Opt in to the runtime-managed
6+
flow with ``from agent_assembly.runtime import init_assembly``.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
import shutil
13+
import socket
14+
import subprocess
15+
from pathlib import Path
16+
17+
__all__ = [
18+
"BINARY_NAME",
19+
"DEFAULT_PORT",
20+
"DEFAULT_RUNTIME_HOST",
21+
"INSTALL_HINT",
22+
"find_aasm_binary",
23+
"init_assembly",
24+
"is_running",
25+
"start_runtime",
26+
]
27+
28+
BINARY_NAME = "aasm"
29+
DEFAULT_PORT = 7878
30+
DEFAULT_RUNTIME_HOST = "127.0.0.1"
31+
32+
USER_LOCAL_BIN = Path.home() / ".local" / "bin"
33+
WHEEL_BUNDLED_BIN = Path(__file__).resolve().parent / "bin"
34+
DOCKER_BASE_BIN = Path("/usr/local/bin")
35+
36+
RUNTIME_LOG_FILENAME = ".aasm-runtime.log"
37+
38+
INSTALL_HINT = (
39+
"agent-assembly runtime not found.\n"
40+
" Install with: pip install agent-assembly-python[runtime]\n"
41+
" Or manually: brew install agent-assembly/tap/aasm\n"
42+
" curl -fsSL https://get.agent-assembly.io | sh"
43+
)
44+
45+
46+
def find_aasm_binary() -> Path | None:
47+
"""Locate the `aasm` binary across the 5 supported install paths.
48+
49+
Search order: ``$PATH`` (covers Homebrew and ``cargo install``) →
50+
``~/.local/bin/aasm`` (curl installer default) →
51+
``agent_assembly/bin/aasm`` (wheel-bundled with the ``[runtime]`` extra) →
52+
``/usr/local/bin/aasm`` (Docker base image). Returns the first executable
53+
match, or ``None`` when no candidate exists.
54+
"""
55+
path_hit = shutil.which(BINARY_NAME)
56+
if path_hit:
57+
return Path(path_hit)
58+
for candidate in (
59+
USER_LOCAL_BIN / BINARY_NAME,
60+
WHEEL_BUNDLED_BIN / BINARY_NAME,
61+
DOCKER_BASE_BIN / BINARY_NAME,
62+
):
63+
if candidate.is_file() and os.access(candidate, os.X_OK):
64+
return candidate
65+
return None
66+
67+
68+
def is_running(port: int = DEFAULT_PORT, *, host: str = DEFAULT_RUNTIME_HOST) -> bool:
69+
"""Return True iff a local TCP listener accepts a connect on ``host:port``.
70+
71+
A 100 ms connect window keeps the probe cheap on the common idle path; any
72+
socket error (refused, timeout, unreachable) is treated as no-sidecar.
73+
"""
74+
try:
75+
with socket.create_connection((host, port), timeout=0.1):
76+
return True
77+
except OSError:
78+
return False
79+
80+
81+
def start_runtime(
82+
binary: Path,
83+
*,
84+
port: int = DEFAULT_PORT,
85+
log_dir: Path | None = None,
86+
) -> subprocess.Popen[bytes]:
87+
"""Spawn ``aasm serve --port <port>`` as a detached background subprocess.
88+
89+
Stdout and stderr are appended to ``<log_dir>/.aasm-runtime.log`` (default
90+
log directory is the current working directory) so the sidecar outlives
91+
the parent. ``start_new_session=True`` detaches the child from this
92+
process's controlling terminal.
93+
"""
94+
target_dir = log_dir if log_dir is not None else Path.cwd()
95+
log_path = target_dir / RUNTIME_LOG_FILENAME
96+
log_file = log_path.open("ab")
97+
return subprocess.Popen(
98+
[str(binary), "serve", "--port", str(port)],
99+
stdout=log_file,
100+
stderr=log_file,
101+
stdin=subprocess.DEVNULL,
102+
start_new_session=True,
103+
)
104+
105+
106+
def init_assembly(
107+
agent_id: str | None = None,
108+
*,
109+
port: int = DEFAULT_PORT,
110+
) -> None:
111+
"""Ensure the local ``aasm`` sidecar is running, starting it if necessary.
112+
113+
Lifecycle per F115 / AAASM-1205:
114+
115+
1. Probe ``host:port`` via :func:`is_running`; return early if the sidecar
116+
is already up (idempotent re-init).
117+
2. Resolve the binary via :func:`find_aasm_binary`.
118+
3. Spawn the sidecar via :func:`start_runtime`.
119+
120+
``agent_id`` is accepted to keep the ticket-specified signature stable;
121+
actual register-and-connect is performed by the existing gateway-aware
122+
``agent_assembly.init_assembly`` once the sidecar is reachable.
123+
124+
Raises:
125+
RuntimeError: when no ``aasm`` binary is found on disk. The message
126+
contains copy-paste install commands for all supported channels.
127+
"""
128+
del agent_id # not consumed at the lifecycle layer; see docstring
129+
if is_running(port):
130+
return
131+
binary = find_aasm_binary()
132+
if binary is None:
133+
raise RuntimeError(INSTALL_HINT)
134+
start_runtime(binary, port=port)

test/unit/test_runtime.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Unit tests for agent_assembly.runtime (AAASM-1227 / F115).
2+
3+
Covers the four scenarios from the AAASM-1230 AC checklist:
4+
* binary-in-PATH
5+
* binary-bundled (in agent_assembly/bin/aasm)
6+
* binary-not-found
7+
* already-running (init_assembly skips spawn when sidecar reachable)
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import stat
13+
from pathlib import Path
14+
from unittest.mock import MagicMock
15+
16+
import pytest
17+
18+
from agent_assembly import runtime
19+
20+
21+
def _make_fake_aasm(directory: Path) -> Path:
22+
"""Write an executable `aasm` shim into ``directory`` and return its path."""
23+
path = directory / runtime.BINARY_NAME
24+
path.write_text("#!/bin/sh\nexit 0\n")
25+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
26+
return path
27+
28+
29+
def test_find_aasm_binary_returns_path_hit_when_on_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
30+
"""binary-in-PATH: shutil.which hit returns immediately, ahead of every fallback."""
31+
fake = _make_fake_aasm(tmp_path)
32+
monkeypatch.setenv("PATH", str(tmp_path))
33+
34+
resolved = runtime.find_aasm_binary()
35+
36+
assert resolved == fake # noqa: S101 — pytest assertion
37+
38+
39+
def test_init_assembly_raises_runtime_error_with_install_hint_when_missing(
40+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
41+
) -> None:
42+
"""binary-not-found: init_assembly raises RuntimeError whose message is the
43+
INSTALL_HINT (which includes the pip/brew/curl copy-paste commands)."""
44+
empty_dir = tmp_path / "empty"
45+
empty_dir.mkdir()
46+
monkeypatch.setenv("PATH", str(empty_dir))
47+
monkeypatch.setattr(runtime, "USER_LOCAL_BIN", tmp_path / "no-such-local-bin")
48+
monkeypatch.setattr(runtime, "WHEEL_BUNDLED_BIN", tmp_path / "no-such-wheel-bin")
49+
monkeypatch.setattr(runtime, "DOCKER_BASE_BIN", tmp_path / "no-such-docker-bin")
50+
# Ensure is_running returns False so the orchestrator reaches find_aasm_binary.
51+
monkeypatch.setattr(runtime, "is_running", lambda *_args, **_kw: False)
52+
53+
with pytest.raises(RuntimeError) as exc_info:
54+
runtime.init_assembly()
55+
56+
assert str(exc_info.value) == runtime.INSTALL_HINT
57+
assert "agent-assembly runtime not found" in str(exc_info.value)
58+
assert "pip install" in str(exc_info.value)
59+
60+
61+
def test_find_aasm_binary_returns_wheel_bundled_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
62+
"""binary-bundled: when PATH and ~/.local/bin both miss, the wheel-bundled
63+
location (`agent_assembly/bin/aasm`) is the next checked fallback."""
64+
fake_wheel_bin = tmp_path / "wheel_bin"
65+
fake_wheel_bin.mkdir()
66+
fake = _make_fake_aasm(fake_wheel_bin)
67+
monkeypatch.setenv("PATH", str(tmp_path / "empty")) # not on PATH
68+
monkeypatch.setattr(runtime, "USER_LOCAL_BIN", tmp_path / "no-such-local-bin")
69+
monkeypatch.setattr(runtime, "WHEEL_BUNDLED_BIN", fake_wheel_bin)
70+
monkeypatch.setattr(runtime, "DOCKER_BASE_BIN", tmp_path / "no-such-docker-bin")
71+
72+
resolved = runtime.find_aasm_binary()
73+
74+
assert resolved == fake
75+
76+
77+
def test_init_assembly_idempotent_when_already_running(monkeypatch: pytest.MonkeyPatch) -> None:
78+
"""already-running: init_assembly returns early without calling
79+
find_aasm_binary or start_runtime when is_running reports True."""
80+
find_spy = MagicMock(return_value=None)
81+
start_spy = MagicMock()
82+
monkeypatch.setattr(runtime, "is_running", lambda *_args, **_kw: True)
83+
monkeypatch.setattr(runtime, "find_aasm_binary", find_spy)
84+
monkeypatch.setattr(runtime, "start_runtime", start_spy)
85+
86+
runtime.init_assembly()
87+
88+
find_spy.assert_not_called()
89+
start_spy.assert_not_called()

0 commit comments

Comments
 (0)