diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index 06e35bf..5af1210 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -12,6 +12,7 @@ from agent_assembly.adapters.langchain.runtime import get_active_callback_handler from agent_assembly.adapters.registry import AdapterRegistry from agent_assembly.client.gateway import GatewayClient +from agent_assembly.core.gateway_resolver import resolve_api_key, resolve_gateway_url from agent_assembly.core.spawn import _SPAWN_CTX from agent_assembly.exceptions import AssemblyError, ConfigurationError @@ -104,8 +105,8 @@ def shutdown(self) -> None: def init_assembly( - gateway_url: str, - api_key: str, + gateway_url: str | None = None, + api_key: str | None = None, agent_id: str | None = None, mode: RuntimeMode = "auto", *, @@ -119,8 +120,15 @@ def init_assembly( Uses ``AdapterRegistry.get_available_adapters_by_priority()`` as the single detection path for framework adapters (see ADR-0001). + + With no ``gateway_url`` / ``api_key`` arguments the SDK falls back + through the resolver chain (env → config file → local default with + optional auto-start) per Epic 17 S-G — see + ``agent_assembly.core.gateway_resolver``. """ - _validate_inputs(gateway_url=gateway_url, api_key=api_key, mode=mode) + gateway_url = resolve_gateway_url(gateway_url) + api_key = resolve_api_key(api_key) + _validate_inputs(gateway_url=gateway_url, mode=mode) if delegation_reason is not None and len(delegation_reason) > 256: raise ValueError("delegation_reason must be <= 256 characters") @@ -182,11 +190,9 @@ def init_assembly( return context -def _validate_inputs(*, gateway_url: str, api_key: str, mode: RuntimeMode) -> None: +def _validate_inputs(*, gateway_url: str, mode: RuntimeMode) -> None: if not gateway_url: raise ConfigurationError("gateway_url is required") - if not api_key: - raise ConfigurationError("api_key is required") if mode not in _VALID_RUNTIME_MODES: raise ConfigurationError("mode must be one of: auto, ebpf, proxy, sdk-only") diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py new file mode 100644 index 0000000..998a724 --- /dev/null +++ b/agent_assembly/core/gateway_resolver.py @@ -0,0 +1,183 @@ +"""Resolve the gateway URL and API key for ``init_assembly``. + +Implements the zero-config developer-experience contract from Epic 17 (S-G): +``init_assembly()`` with no arguments and no environment variables should +discover a local gateway at ``http://localhost:7391`` — probing it, and +auto-starting ``aasm start --mode local --foreground`` when not running. + +Resolution precedence (highest first):: + + 1. Explicit kwarg passed to init_assembly + 2. Environment variable (AAASM_GATEWAY_URL / AAASM_API_KEY) + 3. Config file (~/.aasm/config.yaml, optional dependency) + 4. Local default: probe http://localhost:7391, auto-start if absent +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any + +import httpx + +from agent_assembly.exceptions import ConfigurationError, GatewayError + +DEFAULT_GATEWAY_URL = "http://localhost:7391" +DEFAULT_HEALTHZ_PATH = "/healthz" +DEFAULT_PROBE_TIMEOUT_SECONDS = 0.5 +DEFAULT_AUTO_START_TIMEOUT_SECONDS = 5.0 +DEFAULT_CONFIG_FILE_PATH = "~/.aasm/config.yaml" + +ENV_GATEWAY_URL = "AAASM_GATEWAY_URL" +ENV_API_KEY = "AAASM_API_KEY" + +AASM_AUTO_START_ARGV = ("start", "--mode", "local", "--foreground") + + +def _probe_healthz(base_url: str, timeout: float = DEFAULT_PROBE_TIMEOUT_SECONDS) -> bool: + """Return True if a gateway responds 2xx at ``{base_url}/healthz``. + + A short timeout keeps the local-dev probe near-instant when nothing is + listening; any network/HTTP error is swallowed and reported as False. + """ + url = base_url.rstrip("/") + DEFAULT_HEALTHZ_PATH + try: + response = httpx.get(url, timeout=timeout) + except httpx.HTTPError: + return False + status: int = response.status_code + return 200 <= status < 300 + + +def _wait_for_healthz( + base_url: str, + timeout: float = DEFAULT_AUTO_START_TIMEOUT_SECONDS, + poll_interval: float = 0.1, +) -> bool: + """Poll the gateway healthz endpoint until success or timeout. + + Returns True as soon as ``_probe_healthz`` succeeds. Returns False if + the gateway is not ready within ``timeout`` seconds. The poll interval + is short (default 100ms) so the auto-start path feels instant when the + local CP comes up quickly. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _probe_healthz(base_url): + return True + time.sleep(poll_interval) + return _probe_healthz(base_url) + + +def _load_config_file(path: str = DEFAULT_CONFIG_FILE_PATH) -> dict[str, Any]: + """Load ``~/.aasm/config.yaml`` if present. + + Returns an empty dict when the file is missing, when PyYAML is not + installed (it is a soft dependency for SDK consumers), or when the + file's contents are not a mapping. This keeps the resolver chain + purely advisory at step 3 — never raises. + """ + try: + import yaml # type: ignore[import-untyped,unused-ignore] # noqa: PLC0415 — soft dep + except ImportError: + return {} + + resolved = Path(path).expanduser() + if not resolved.is_file(): + return {} + + try: + loaded = yaml.safe_load(resolved.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return {} + return loaded if isinstance(loaded, dict) else {} + + +def _auto_start_gateway( + base_url: str = DEFAULT_GATEWAY_URL, + timeout: float = DEFAULT_AUTO_START_TIMEOUT_SECONDS, +) -> None: + """Spawn ``aasm start --mode local --foreground`` and wait until ready. + + Raises ``ConfigurationError`` if the ``aasm`` binary is not on the + caller's PATH — the SDK cannot meaningfully auto-start without it. + Raises ``GatewayError`` if the spawned gateway does not respond to + ``/healthz`` within ``timeout`` seconds. + + The subprocess is detached via ``start_new_session=True`` so it + survives the parent Python process — matching the ``docker``-style + daemon hand-off described in Epic 17 S-G. + """ + aasm_path = shutil.which("aasm") + if aasm_path is None: + raise ConfigurationError( + f"No gateway found at {base_url} and 'aasm' is not on PATH. " + "Install it with: pip install agent-assembly[cli]" + ) + + subprocess.Popen( + [aasm_path, *AASM_AUTO_START_ARGV], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + if not _wait_for_healthz(base_url, timeout=timeout): + raise GatewayError(f"Auto-started gateway at {base_url} did not become ready within {timeout:g} seconds") + + +def resolve_gateway_url(explicit: str | None = None) -> str: + """Resolve the gateway URL using the 4-step precedence chain. + + Returns the resolved URL. May spawn a local ``aasm`` subprocess + (step 4 only). Raises ``ConfigurationError`` / ``GatewayError`` from + ``_auto_start_gateway`` when the local default is needed but cannot + be brought up. + """ + if explicit: + return explicit + + env_value = os.environ.get(ENV_GATEWAY_URL) + if env_value: + return env_value + + config = _load_config_file() + agent_section = config.get("agent") + if isinstance(agent_section, dict): + config_url = agent_section.get("gateway_url") + if isinstance(config_url, str) and config_url: + return config_url + + if _probe_healthz(DEFAULT_GATEWAY_URL): + return DEFAULT_GATEWAY_URL + + _auto_start_gateway(DEFAULT_GATEWAY_URL) + return DEFAULT_GATEWAY_URL + + +def resolve_api_key(explicit: str | None = None) -> str: + """Resolve the API key using the same 4-step precedence as the URL. + + Returns the resolved key (possibly empty for local mode, which + accepts unauthenticated agents). Never raises — an empty API key + is the documented "local dev" default per Epic 17. + """ + if explicit: + return explicit + + env_value = os.environ.get(ENV_API_KEY) + if env_value: + return env_value + + config = _load_config_file() + agent_section = config.get("agent") + if isinstance(agent_section, dict): + config_key = agent_section.get("api_key") + if isinstance(config_key, str) and config_key: + return config_key + + return "" diff --git a/test/integration/test_assembly_integration.py b/test/integration/test_assembly_integration.py index 79cdf04..4461e59 100644 --- a/test/integration/test_assembly_integration.py +++ b/test/integration/test_assembly_integration.py @@ -27,18 +27,12 @@ def test_init_assembly_with_valid_config(): @pytest.mark.integration def test_init_assembly_with_invalid_config(): - """Test that assembly initialization fails with invalid configuration.""" - with pytest.raises(ConfigurationError): - init_assembly( - gateway_url="", # Invalid: empty URL - api_key="test-api-key", - agent_id="test-agent-001", - ) - + """Test that assembly initialization fails with an unknown runtime mode.""" with pytest.raises(ConfigurationError): init_assembly( gateway_url="http://localhost:8080", - api_key="", # Invalid: empty API key + api_key="test-api-key", + mode="invalid-mode", # type: ignore[arg-type] ) diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py new file mode 100644 index 0000000..c4f6879 --- /dev/null +++ b/test/unit/core/test_gateway_resolver.py @@ -0,0 +1,200 @@ +"""Tests for the gateway URL / API key resolver (AAASM-1846).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from agent_assembly.core import gateway_resolver + +_RESOLVER_MOD = "agent_assembly.core.gateway_resolver" + + +class TestProbeHealthz: + def test_returns_true_on_2xx_response(self) -> None: + fake_response = MagicMock(status_code=200) + with patch(f"{_RESOLVER_MOD}.httpx.get", return_value=fake_response) as mock_get: + assert gateway_resolver._probe_healthz("http://localhost:7391") is True + + called_url = mock_get.call_args.args[0] + assert called_url == "http://localhost:7391/healthz" + + def test_returns_false_when_httpx_raises(self) -> None: + with patch(f"{_RESOLVER_MOD}.httpx.get", side_effect=httpx.ConnectError("refused")): + assert gateway_resolver._probe_healthz("http://localhost:7391") is False + + @pytest.mark.parametrize("status", [400, 404, 500, 503]) # type: ignore[misc] + def test_returns_false_on_non_2xx(self, status: int) -> None: + fake_response = MagicMock(status_code=status) + with patch(f"{_RESOLVER_MOD}.httpx.get", return_value=fake_response): + assert gateway_resolver._probe_healthz("http://localhost:7391") is False + + +class TestWaitForHealthz: + def test_returns_true_when_probe_succeeds_immediately(self) -> None: + with patch.object(gateway_resolver, "_probe_healthz", return_value=True) as mock_probe: + result = gateway_resolver._wait_for_healthz("http://localhost:7391", timeout=5.0) + assert result is True + assert mock_probe.call_count == 1 + + def test_returns_true_after_initial_failures(self) -> None: + probe_results = iter([False, False, True]) + with ( + patch.object(gateway_resolver, "_probe_healthz", side_effect=probe_results), + patch(f"{_RESOLVER_MOD}.time.sleep") as mock_sleep, + ): + result = gateway_resolver._wait_for_healthz("http://localhost:7391", timeout=5.0, poll_interval=0.01) + assert result is True + assert mock_sleep.call_count == 2 + + def test_returns_false_when_timeout_elapses(self) -> None: + with ( + patch.object(gateway_resolver, "_probe_healthz", return_value=False), + patch(f"{_RESOLVER_MOD}.time.sleep"), + ): + result = gateway_resolver._wait_for_healthz("http://localhost:7391", timeout=0.05, poll_interval=0.01) + assert result is False + + +class TestLoadConfigFile: + def test_returns_empty_when_file_missing(self, tmp_path: Path) -> None: + missing = tmp_path / "absent.yaml" + assert gateway_resolver._load_config_file(str(missing)) == {} + + def test_returns_parsed_mapping(self, tmp_path: Path) -> None: + cfg = tmp_path / "config.yaml" + cfg.write_text( + 'agent:\n gateway_url: "http://staging.internal:7391"\n api_key: "k-1"\n', + encoding="utf-8", + ) + loaded = gateway_resolver._load_config_file(str(cfg)) + assert loaded == {"agent": {"gateway_url": "http://staging.internal:7391", "api_key": "k-1"}} + + def test_returns_empty_on_non_mapping_root(self, tmp_path: Path) -> None: + cfg = tmp_path / "config.yaml" + cfg.write_text("- just-a-list\n", encoding="utf-8") + assert gateway_resolver._load_config_file(str(cfg)) == {} + + def test_returns_empty_when_pyyaml_missing(self, tmp_path: Path) -> None: + cfg = tmp_path / "config.yaml" + cfg.write_text("agent:\n gateway_url: x\n", encoding="utf-8") + with patch.dict("sys.modules", {"yaml": None}): + assert gateway_resolver._load_config_file(str(cfg)) == {} + + +class TestAutoStartGateway: + def test_raises_configuration_error_when_aasm_not_on_path(self) -> None: + from agent_assembly.exceptions import ConfigurationError + + with ( + patch(f"{_RESOLVER_MOD}.shutil.which", return_value=None), + pytest.raises(ConfigurationError, match="'aasm' is not on PATH"), + ): + gateway_resolver._auto_start_gateway() + + def test_spawns_subprocess_and_returns_when_ready(self) -> None: + with ( + patch(f"{_RESOLVER_MOD}.shutil.which", return_value="/usr/local/bin/aasm"), + patch(f"{_RESOLVER_MOD}.subprocess.Popen") as mock_popen, + patch.object(gateway_resolver, "_wait_for_healthz", return_value=True), + ): + gateway_resolver._auto_start_gateway() + + args, kwargs = mock_popen.call_args + assert args[0] == [ + "/usr/local/bin/aasm", + "start", + "--mode", + "local", + "--foreground", + ] + assert kwargs.get("start_new_session") is True + + def test_raises_gateway_error_on_timeout(self) -> None: + from agent_assembly.exceptions import GatewayError + + with ( + patch(f"{_RESOLVER_MOD}.shutil.which", return_value="/usr/local/bin/aasm"), + patch(f"{_RESOLVER_MOD}.subprocess.Popen"), + patch.object(gateway_resolver, "_wait_for_healthz", return_value=False), + pytest.raises(GatewayError, match="did not become ready"), + ): + gateway_resolver._auto_start_gateway(timeout=0.1) + + +class TestResolveGatewayUrl: + def test_explicit_argument_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(gateway_resolver.ENV_GATEWAY_URL, "http://from-env:7391") + result = gateway_resolver.resolve_gateway_url("http://explicit:7391") + assert result == "http://explicit:7391" + + def test_env_var_takes_precedence_over_config_and_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(gateway_resolver.ENV_GATEWAY_URL, "http://from-env:7391") + with patch.object( + gateway_resolver, + "_load_config_file", + return_value={"agent": {"gateway_url": "http://from-config:7391"}}, + ): + assert gateway_resolver.resolve_gateway_url() == "http://from-env:7391" + + def test_config_file_used_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_URL, raising=False) + with patch.object( + gateway_resolver, + "_load_config_file", + return_value={"agent": {"gateway_url": "http://from-config:7391"}}, + ): + assert gateway_resolver.resolve_gateway_url() == "http://from-config:7391" + + def test_local_default_returned_when_probe_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_URL, raising=False) + with ( + patch.object(gateway_resolver, "_load_config_file", return_value={}), + patch.object(gateway_resolver, "_probe_healthz", return_value=True), + patch.object(gateway_resolver, "_auto_start_gateway") as mock_auto_start, + ): + assert gateway_resolver.resolve_gateway_url() == gateway_resolver.DEFAULT_GATEWAY_URL + mock_auto_start.assert_not_called() + + def test_auto_start_invoked_when_probe_fails(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_URL, raising=False) + with ( + patch.object(gateway_resolver, "_load_config_file", return_value={}), + patch.object(gateway_resolver, "_probe_healthz", return_value=False), + patch.object(gateway_resolver, "_auto_start_gateway") as mock_auto_start, + ): + result = gateway_resolver.resolve_gateway_url() + assert result == gateway_resolver.DEFAULT_GATEWAY_URL + mock_auto_start.assert_called_once_with(gateway_resolver.DEFAULT_GATEWAY_URL) + + +class TestResolveApiKey: + def test_explicit_argument_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(gateway_resolver.ENV_API_KEY, "k-env") + assert gateway_resolver.resolve_api_key("k-explicit") == "k-explicit" + + def test_env_var_takes_precedence_over_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(gateway_resolver.ENV_API_KEY, "k-env") + with patch.object( + gateway_resolver, + "_load_config_file", + return_value={"agent": {"api_key": "k-config"}}, + ): + assert gateway_resolver.resolve_api_key() == "k-env" + + def test_config_file_used_when_env_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(gateway_resolver.ENV_API_KEY, raising=False) + with patch.object( + gateway_resolver, + "_load_config_file", + return_value={"agent": {"api_key": "k-config"}}, + ): + assert gateway_resolver.resolve_api_key() == "k-config" + + def test_returns_empty_string_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(gateway_resolver.ENV_API_KEY, raising=False) + with patch.object(gateway_resolver, "_load_config_file", return_value={}): + assert gateway_resolver.resolve_api_key() == "" diff --git a/test/unit/test_assembly.py b/test/unit/test_assembly.py index 8f66e87..8bf0c1a 100644 --- a/test/unit/test_assembly.py +++ b/test/unit/test_assembly.py @@ -67,20 +67,6 @@ def test_init_assembly_with_valid_config_returns_context( def test_init_assembly_with_invalid_config() -> None: - with pytest.raises(ConfigurationError): - init_assembly( - gateway_url="", - api_key="test-api-key", - agent_id="test-agent-001", - ) - - with pytest.raises(ConfigurationError): - init_assembly( - gateway_url="http://localhost:8080", - api_key="", - agent_id="test-agent-001", - ) - with pytest.raises(ConfigurationError): init_assembly( gateway_url="http://localhost:8080", @@ -89,6 +75,65 @@ def test_init_assembly_with_invalid_config() -> None: ) +def test_init_assembly_zero_arg_resolves_local_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """AAASM-1846 AC: init_assembly() with no args connects to local default.""" + from agent_assembly.core import gateway_resolver + + monkeypatch.setattr(gateway_resolver, "_probe_healthz", lambda _url: True) + monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_URL, raising=False) + monkeypatch.delenv(gateway_resolver.ENV_API_KEY, raising=False) + monkeypatch.setattr(gateway_resolver, "_load_config_file", lambda: {}) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr( + core_assembly, + "_start_network_layer", + lambda **kwargs: ("sdk-only", core_assembly._noop_shutdown), + ) + + context = init_assembly() + try: + assert context.client.gateway_url == gateway_resolver.DEFAULT_GATEWAY_URL + assert context.client.api_key == "" + finally: + context.shutdown() + + +def test_init_assembly_explicit_args_bypass_resolver( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """AAASM-1846 regression: explicit gateway_url / api_key still bind verbatim.""" + from agent_assembly.core import gateway_resolver + + def _fail_probe(_url: str) -> bool: + raise AssertionError("resolver should not probe when explicit args provided") + + def _fail_auto_start(_url: str = "") -> None: + raise AssertionError("resolver should not auto-start when explicit args provided") + + monkeypatch.setattr(gateway_resolver, "_probe_healthz", _fail_probe) + monkeypatch.setattr(gateway_resolver, "_auto_start_gateway", _fail_auto_start) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr( + core_assembly, + "_start_network_layer", + lambda **kwargs: ("sdk-only", core_assembly._noop_shutdown), + ) + + context = init_assembly( + gateway_url="http://explicit.gw:9999", + api_key="explicit-key", + agent_id="agent-x", + ) + try: + assert context.client.gateway_url == "http://explicit.gw:9999" + assert context.client.api_key == "explicit-key" + assert context.client.agent_id == "agent-x" + finally: + context.shutdown() + + def test_mode_sdk_only_skips_network_layer() -> None: network_mode, shutdown = core_assembly._start_network_layer(client=object(), mode="sdk-only") assert network_mode == "sdk-only"