From a5df7fa5b806cac731a11336cbbd53dd1525b02e Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:03:20 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=F0=9F=93=9D=20(core):=20Add=20gateway=5F?= =?UTF-8?q?resolver=20module=20skeleton=20+=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays down the new module that will host the zero-config resolution logic for init_assembly (AAASM-1846 / E17 S-G). Defines the default gateway URL, healthz path, probe / auto-start timeouts, env-var names, and the auto-start argv tuple. No behavior yet. --- agent_assembly/core/gateway_resolver.py | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 agent_assembly/core/gateway_resolver.py diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py new file mode 100644 index 0000000..198dc55 --- /dev/null +++ b/agent_assembly/core/gateway_resolver.py @@ -0,0 +1,27 @@ +"""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 + +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") From 05de74c420eeac738fb68373578a5c8d803e8732 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:03:39 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20=5Fprobe=5Fhe?= =?UTF-8?q?althz=20to=20gateway=5Fresolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronous httpx GET against ``{base_url}/healthz`` with a tight default 500ms timeout. Any HTTPError is swallowed (timeout, connection refused, DNS failure) and surfaces as False — the resolver treats unreachable as "absent" rather than fatal. --- agent_assembly/core/gateway_resolver.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index 198dc55..65b1172 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -15,6 +15,8 @@ from __future__ import annotations +import httpx + DEFAULT_GATEWAY_URL = "http://localhost:7391" DEFAULT_HEALTHZ_PATH = "/healthz" DEFAULT_PROBE_TIMEOUT_SECONDS = 0.5 @@ -25,3 +27,17 @@ 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 + return 200 <= response.status_code < 300 From f7402c9469ec43c4f9e8b105de978843adb53645 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:03:53 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20tests=20for?= =?UTF-8?q?=20=5Fprobe=5Fhealthz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the three expected outcomes: 2xx → True (and verifies the ``/healthz`` suffix), httpx exception → False, non-2xx → False (parametrized across 400/404/500/503). All httpx calls mocked — no network. --- test/unit/core/test_gateway_resolver.py | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 test/unit/core/test_gateway_resolver.py diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py new file mode 100644 index 0000000..42924c1 --- /dev/null +++ b/test/unit/core/test_gateway_resolver.py @@ -0,0 +1,34 @@ +"""Tests for the gateway URL / API key resolver (AAASM-1846).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from agent_assembly.core import gateway_resolver + + +class TestProbeHealthz: + def test_returns_true_on_2xx_response(self) -> None: + fake_response = MagicMock(status_code=200) + with patch.object(gateway_resolver.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.object( + gateway_resolver.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]) + def test_returns_false_on_non_2xx(self, status: int) -> None: + fake_response = MagicMock(status_code=status) + with patch.object(gateway_resolver.httpx, "get", return_value=fake_response): + assert gateway_resolver._probe_healthz("http://localhost:7391") is False From a9ca1ecc68409cb46619e80c626ccaa6ac17a73e Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:04:11 +0800 Subject: [PATCH 04/19] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20=5Fwait=5Ffor?= =?UTF-8?q?=5Fhealthz=20to=20gateway=5Fresolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polls the gateway healthz endpoint until success or timeout. Used after ``_auto_start_gateway`` to know when the freshly-spawned local CP is ready to accept connections. Default 5s budget per Story AC; final re-probe after the deadline ensures borderline races resolve cleanly. --- agent_assembly/core/gateway_resolver.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index 65b1172..364e61c 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -15,6 +15,8 @@ from __future__ import annotations +import time + import httpx DEFAULT_GATEWAY_URL = "http://localhost:7391" @@ -41,3 +43,23 @@ def _probe_healthz(base_url: str, timeout: float = DEFAULT_PROBE_TIMEOUT_SECONDS except httpx.HTTPError: return False return 200 <= response.status_code < 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) From 56ad6f4b915773790f7b719779ba1d5347471c9c Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:04:28 +0800 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20tests=20for?= =?UTF-8?q?=20=5Fwait=5Ffor=5Fhealthz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behaviors: success on first probe (no sleep), success after two prior failures (verifies the poll-then-sleep loop body), and false when the timeout elapses with no success. All time.sleep calls patched so the test stays under 50ms. --- test/unit/core/test_gateway_resolver.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index 42924c1..f239fe2 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -32,3 +32,29 @@ def test_returns_false_on_non_2xx(self, status: int) -> None: fake_response = MagicMock(status_code=status) with patch.object(gateway_resolver.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.object(gateway_resolver.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.object(gateway_resolver.time, "sleep"), + ): + result = gateway_resolver._wait_for_healthz("http://localhost:7391", timeout=0.05, poll_interval=0.01) + assert result is False From b894a80e25f8cbaa23e6f30bb96d9859114605ae Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:04:56 +0800 Subject: [PATCH 06/19] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20=5Fload=5Fcon?= =?UTF-8?q?fig=5Ffile=20with=20optional=20pyyaml=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads ~/.aasm/config.yaml when present. PyYAML is treated as a soft dependency — missing import returns an empty dict so the resolver falls through to the local-default step. File-missing, OS errors, parse errors, and non-mapping payloads all collapse to the same empty result — config-file lookup is purely advisory. --- agent_assembly/core/gateway_resolver.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index 364e61c..6331d48 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -16,6 +16,8 @@ from __future__ import annotations import time +from pathlib import Path +from typing import Any import httpx @@ -63,3 +65,27 @@ def _wait_for_healthz( 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] # noqa: PLC0415 — soft dependency + 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 {} From b4c7255d803795fd62d18262f0e5b7f01afa3e6a Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:05:46 +0800 Subject: [PATCH 07/19] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20tests=20for?= =?UTF-8?q?=20=5Fload=5Fconfig=5Ffile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers four behaviors: missing file → {}, well-formed YAML → parsed mapping, non-mapping root (e.g. top-level list) → {}, and yaml-module-absent → {} (simulated by stubbing sys.modules). Uses tmp_path; no real ~/.aasm/ touched. --- test/unit/core/test_gateway_resolver.py | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index f239fe2..055831a 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import MagicMock, patch import httpx @@ -58,3 +59,29 @@ def test_returns_false_when_timeout_elapses(self) -> None: ): 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)) == {} From c4a82d01d3a70582540720c06bb440a8de43881c Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:06:21 +0800 Subject: [PATCH 08/19] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20=5Fauto=5Fsta?= =?UTF-8?q?rt=5Fgateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawns ``aasm start --mode local --foreground`` as a detached subprocess and waits for /healthz to become ready. Raises ConfigurationError when the aasm binary is missing from PATH and GatewayError when the spawned gateway doesn't come up within the timeout. The detached start_new_session=True is the docker-daemon-style hand-off that lets the gateway survive after the calling Python process exits. --- agent_assembly/core/gateway_resolver.py | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index 6331d48..d8a52d8 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -15,12 +15,16 @@ from __future__ import annotations +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 @@ -89,3 +93,36 @@ def _load_config_file(path: str = DEFAULT_CONFIG_FILE_PATH) -> dict[str, Any]: 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") From 90c3bc39d51b34f342a1ee2fb73220c470a71f40 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:07:06 +0800 Subject: [PATCH 09/19] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20tests=20for?= =?UTF-8?q?=20=5Fauto=5Fstart=5Fgateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behaviors: aasm-missing → ConfigurationError with install hint, spawn succeeds + healthz ready → returns None (also pins the argv and start_new_session=True for the detach contract), spawn succeeds but timeout elapses → GatewayError. All subprocess and HTTP calls patched. --- test/unit/core/test_gateway_resolver.py | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index 055831a..c9e9a30 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -85,3 +85,43 @@ def test_returns_empty_when_pyyaml_missing(self, tmp_path: Path) -> None: 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.object(gateway_resolver.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.object(gateway_resolver.shutil, "which", return_value="/usr/local/bin/aasm"), + patch.object(gateway_resolver.subprocess, "Popen") as mock_popen, + patch.object(gateway_resolver, "_wait_for_healthz", return_value=True), + ): + assert gateway_resolver._auto_start_gateway() is None + + 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.object(gateway_resolver.shutil, "which", return_value="/usr/local/bin/aasm"), + patch.object(gateway_resolver.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) From 61e771037a63737eada5998e86c204c3b69d9404 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:07:26 +0800 Subject: [PATCH 10/19] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20resolve=5Fgat?= =?UTF-8?q?eway=5Furl=20public=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 4-step precedence chain from Epic 17 S-G: explicit kwarg → AAASM_GATEWAY_URL env var → ~/.aasm/config.yaml agent.gateway_url → local default (probe + auto-start). Steps 1-3 short-circuit; step 4 may spawn ``aasm`` and bubble ConfigurationError/GatewayError when the local gateway is unavailable and cannot be brought up. --- agent_assembly/core/gateway_resolver.py | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index d8a52d8..0e96483 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -15,6 +15,7 @@ from __future__ import annotations +import os import shutil import subprocess import time @@ -126,3 +127,32 @@ def _auto_start_gateway( 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 From 9d5be44bb3f070422033dd5b97370ab8ffebdb2b Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:07:51 +0800 Subject: [PATCH 11/19] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20precedence=20?= =?UTF-8?q?tests=20for=20resolve=5Fgateway=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests exercising the 4-step precedence chain: explicit > env > config > local-default; the local-default branch is split into probe-hit (no auto-start) and probe-miss (auto-start invoked with the canonical localhost URL). monkeypatch handles env-var isolation so tests stay parallel-safe. --- test/unit/core/test_gateway_resolver.py | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index c9e9a30..884d48b 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -125,3 +125,49 @@ def test_raises_gateway_error_on_timeout(self) -> None: 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) From a110ec64377d5ee9f84d4b04daf2e7914cb9ffcb Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:08:07 +0800 Subject: [PATCH 12/19] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20resolve=5Fapi?= =?UTF-8?q?=5Fkey=20public=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors resolve_gateway_url's 4-step precedence chain for api_key: explicit kwarg → AAASM_API_KEY env → config file → empty default. No auto-start path — local mode is unauth-accepting per the Epic, so the empty fallback is the documented default rather than an error. --- agent_assembly/core/gateway_resolver.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index 0e96483..8c1dcdc 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -156,3 +156,27 @@ def resolve_gateway_url(explicit: str | None = None) -> str: _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 "" From fd5116fab61f9b7e8c45cb4a35339fc740c41db7 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:08:26 +0800 Subject: [PATCH 13/19] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20precedence=20?= =?UTF-8?q?tests=20for=20resolve=5Fapi=5Fkey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four tests mirroring resolve_gateway_url's chain: explicit > env > config > empty-default. No raise on missing — empty string is the documented local-mode default. --- test/unit/core/test_gateway_resolver.py | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index 884d48b..3c239c4 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -171,3 +171,32 @@ def test_auto_start_invoked_when_probe_fails(self, monkeypatch: pytest.MonkeyPat 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() == "" From 7864eea3ad60ad456592fd86591cb4e088c03f70 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:13:39 +0800 Subject: [PATCH 14/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(assembly):=20Wire?= =?UTF-8?q?=20gateway=20resolver=20into=20init=5Fassembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relaxes init_assembly to accept None for gateway_url and api_key and calls resolve_gateway_url / resolve_api_key to fill them in via the 4-step precedence chain (explicit → env → config → local default + auto-start). _validate_inputs no longer rejects empty api_key — local mode is unauth-accepting per Epic 17. Existing callers that pass a real URL + key are unaffected. --- agent_assembly/core/assembly.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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") From b80096b3476e49cde580aa26434b6b1636a39b8f Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:16:48 +0800 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=9A=A8=20(core):=20Make=20gateway?= =?UTF-8?q?=5Fresolver=20+=20tests=20mypy-clean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes so pre-commit mypy passes on the new module: explicit int annotation on httpx response.status_code to avoid Any return, multi-code type: ignore on the soft yaml import (covers both the import-untyped at runtime and the unused-ignore when stubs are present in pre-commit's isolated mypy env), and string-form patch() in the tests to avoid attr-defined complaints on module-level imports. --- agent_assembly/core/gateway_resolver.py | 5 +++-- test/unit/core/test_gateway_resolver.py | 30 ++++++++++++------------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index 8c1dcdc..998a724 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -49,7 +49,8 @@ def _probe_healthz(base_url: str, timeout: float = DEFAULT_PROBE_TIMEOUT_SECONDS response = httpx.get(url, timeout=timeout) except httpx.HTTPError: return False - return 200 <= response.status_code < 300 + status: int = response.status_code + return 200 <= status < 300 def _wait_for_healthz( @@ -81,7 +82,7 @@ def _load_config_file(path: str = DEFAULT_CONFIG_FILE_PATH) -> dict[str, Any]: purely advisory at step 3 — never raises. """ try: - import yaml # type: ignore[import-untyped] # noqa: PLC0415 — soft dependency + import yaml # type: ignore[import-untyped,unused-ignore] # noqa: PLC0415 — soft dep except ImportError: return {} diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index 3c239c4..c4f6879 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -10,28 +10,26 @@ 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.object(gateway_resolver.httpx, "get", return_value=fake_response) as mock_get: + 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.object( - gateway_resolver.httpx, - "get", - side_effect=httpx.ConnectError("refused"), - ): + 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]) + @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.object(gateway_resolver.httpx, "get", return_value=fake_response): + with patch(f"{_RESOLVER_MOD}.httpx.get", return_value=fake_response): assert gateway_resolver._probe_healthz("http://localhost:7391") is False @@ -46,7 +44,7 @@ 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.object(gateway_resolver.time, "sleep") as mock_sleep, + 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 @@ -55,7 +53,7 @@ def test_returns_true_after_initial_failures(self) -> None: def test_returns_false_when_timeout_elapses(self) -> None: with ( patch.object(gateway_resolver, "_probe_healthz", return_value=False), - patch.object(gateway_resolver.time, "sleep"), + 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 @@ -92,18 +90,18 @@ def test_raises_configuration_error_when_aasm_not_on_path(self) -> None: from agent_assembly.exceptions import ConfigurationError with ( - patch.object(gateway_resolver.shutil, "which", return_value=None), + 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.object(gateway_resolver.shutil, "which", return_value="/usr/local/bin/aasm"), - patch.object(gateway_resolver.subprocess, "Popen") as mock_popen, + 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), ): - assert gateway_resolver._auto_start_gateway() is None + gateway_resolver._auto_start_gateway() args, kwargs = mock_popen.call_args assert args[0] == [ @@ -119,8 +117,8 @@ def test_raises_gateway_error_on_timeout(self) -> None: from agent_assembly.exceptions import GatewayError with ( - patch.object(gateway_resolver.shutil, "which", return_value="/usr/local/bin/aasm"), - patch.object(gateway_resolver.subprocess, "Popen"), + 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"), ): From 7f97c43f111b105d58ce8ff54fa5239919cdc3b5 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:17:46 +0800 Subject: [PATCH 16/19] =?UTF-8?q?=E2=9C=85=20(test):=20Drop=20empty-string?= =?UTF-8?q?=20assertions=20from=20invalid-config=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty gateway_url and empty api_key are no longer hard errors — they now route through the resolver chain (env → config file → local default) per AAASM-1846. Only the truly invalid mode case still raises ConfigurationError immediately. The zero-config and explicit-args paths are covered by separate new tests. --- test/unit/test_assembly.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/test/unit/test_assembly.py b/test/unit/test_assembly.py index 8f66e87..6a41287 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", From 6f936ff7628ad08eac27bd831343f6bda98a3034 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:18:14 +0800 Subject: [PATCH 17/19] =?UTF-8?q?=E2=9C=85=20(test):=20Add=20zero-arg=20in?= =?UTF-8?q?it=5Fassembly=20resolves=20local=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the Story AAASM-1846 primary AC: init_assembly() with no arguments and no env vars connects to http://localhost:7391 and uses empty api_key. Probes are patched so the test doesn't require a real local gateway or aasm binary on PATH. --- test/unit/test_assembly.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/unit/test_assembly.py b/test/unit/test_assembly.py index 6a41287..a83ec27 100644 --- a/test/unit/test_assembly.py +++ b/test/unit/test_assembly.py @@ -75,6 +75,31 @@ 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_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" From 024a89f420787773e2830326acea1ce5fe6eac44 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:18:29 +0800 Subject: [PATCH 18/19] =?UTF-8?q?=E2=9C=85=20(test):=20Add=20regression=20?= =?UTF-8?q?for=20explicit=20gateway=5Furl=20/=20api=5Fkey=20callers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story AC: existing callers passing both gateway_url and api_key must be unaffected by the resolver path. Patches _probe_healthz and _auto_start_gateway with sentinels that raise if invoked — proves the resolver short-circuits on explicit args and the client binds verbatim. --- test/unit/test_assembly.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/unit/test_assembly.py b/test/unit/test_assembly.py index a83ec27..8bf0c1a 100644 --- a/test/unit/test_assembly.py +++ b/test/unit/test_assembly.py @@ -100,6 +100,40 @@ def test_init_assembly_zero_arg_resolves_local_default( 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" From ddb76f2bce73fd8faf7fc22bb805f144ffcf9b48 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:21:48 +0800 Subject: [PATCH 19/19] =?UTF-8?q?=E2=9C=85=20(integration):=20Align=20inva?= =?UTF-8?q?lid-config=20test=20with=20new=20resolver=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty gateway_url and empty api_key are now resolver-handled, so they no longer raise ConfigurationError directly — same behavior shift as the unit test. Keeping only the unknown-mode case, which remains an immediate ConfigurationError. Fixes integration suite regression from this branch and prevents the leaked _ACTIVE_CONTEXT chain that cascaded into the topology-registration tests. --- test/integration/test_assembly_integration.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) 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] )