Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a5df7fa
📝 (core): Add gateway_resolver module skeleton + constants
Chisanan232 May 22, 2026
05de74c
✨ (core): Add _probe_healthz to gateway_resolver
Chisanan232 May 22, 2026
f7402c9
✅ (core): Add tests for _probe_healthz
Chisanan232 May 22, 2026
a9ca1ec
✨ (core): Add _wait_for_healthz to gateway_resolver
Chisanan232 May 22, 2026
56ad6f4
✅ (core): Add tests for _wait_for_healthz
Chisanan232 May 22, 2026
b894a80
✨ (core): Add _load_config_file with optional pyyaml import
Chisanan232 May 22, 2026
b4c7255
✅ (core): Add tests for _load_config_file
Chisanan232 May 22, 2026
c4a82d0
✨ (core): Add _auto_start_gateway
Chisanan232 May 22, 2026
90c3bc3
✅ (core): Add tests for _auto_start_gateway
Chisanan232 May 22, 2026
61e7710
✨ (core): Add resolve_gateway_url public resolver
Chisanan232 May 22, 2026
9d5be44
✅ (core): Add precedence tests for resolve_gateway_url
Chisanan232 May 22, 2026
a110ec6
✨ (core): Add resolve_api_key public resolver
Chisanan232 May 22, 2026
fd5116f
✅ (core): Add precedence tests for resolve_api_key
Chisanan232 May 22, 2026
7864eea
♻️ (assembly): Wire gateway resolver into init_assembly
Chisanan232 May 22, 2026
b80096b
🚨 (core): Make gateway_resolver + tests mypy-clean
Chisanan232 May 22, 2026
7f97c43
✅ (test): Drop empty-string assertions from invalid-config test
Chisanan232 May 22, 2026
6f936ff
✅ (test): Add zero-arg init_assembly resolves local default
Chisanan232 May 22, 2026
024a89f
✅ (test): Add regression for explicit gateway_url / api_key callers
Chisanan232 May 22, 2026
ddb76f2
✅ (integration): Align invalid-config test with new resolver contract
Chisanan232 May 22, 2026
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: 12 additions & 6 deletions agent_assembly/core/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
*,
Expand All @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
183 changes: 183 additions & 0 deletions agent_assembly/core/gateway_resolver.py
Original file line number Diff line number Diff line change
@@ -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 ""
12 changes: 3 additions & 9 deletions test/integration/test_assembly_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)


Expand Down
Loading