From 8566be76e9c3f1e80d38fc166701cd22a0f7a52a Mon Sep 17 00:00:00 2001 From: Alexliu Date: Fri, 29 May 2026 19:10:17 +0800 Subject: [PATCH 01/16] refactor(client): extract _BaseClient for shared transport plumbing Hoist the env-variable resolution, HTTPClient ownership, _request helper (4xx/5xx -> XAgentError mapping), close(), and the context-manager protocol out of XAgentClient into a new _BaseClient base class in xagent_sdk/_base.py. XAgentClient now inherits from it and only declares its own __init__ glue (super().__init__ + self.tasks = TasksAPI(self)) and the public me() probe. The base class customizes per subclass via two ClassVars (_ENV_API_KEY, _API_KEY_FIELD), so the upcoming UserClient can reuse the same plumbing while reading XAGENT_PERSONAL_KEY and showing a tailored ValueError when the key is missing. No public behavior change; all 85 unit tests still pass. --- python/src/xagent_sdk/_base.py | 106 ++++++++++++++++++++++++++++++++ python/src/xagent_sdk/client.py | 60 ++---------------- 2 files changed, 110 insertions(+), 56 deletions(-) create mode 100644 python/src/xagent_sdk/_base.py diff --git a/python/src/xagent_sdk/_base.py b/python/src/xagent_sdk/_base.py new file mode 100644 index 0000000..899c8c3 --- /dev/null +++ b/python/src/xagent_sdk/_base.py @@ -0,0 +1,106 @@ +"""Internal base class shared by every public SDK client. + +The two public clients (``XAgentClient`` / ``AgentClient`` for runtime, and +the upcoming ``UserClient`` for management) only differ in which env var +provides the API key fallback and which surface methods they expose. +Everything else -- env resolution, ``HTTPClient`` ownership, the +4xx/5xx-to-exception mapping in ``_request``, ``close()``, and the +context-manager protocol -- is identical, so it lives here. + +Subclasses customize the key resolution by overriding two class +attributes: + +- ``_ENV_API_KEY``: the environment variable consulted when the caller + does not pass an explicit key (``XAGENT_API_KEY`` for the runtime + client, ``XAGENT_PERSONAL_KEY`` for the user client). +- ``_API_KEY_FIELD``: the parameter name to use in the ``ValueError`` + message when the key is missing. Showing the right name keeps the + error actionable for whichever public surface raised it. +""" + +import os +from types import TracebackType +from typing import ClassVar, Self + +import httpx + +from xagent_sdk._http import HTTPClient +from xagent_sdk.errors import from_response + + +class _BaseClient: + """Shared transport plumbing for SDK clients. + + Not part of the public surface; subclasses are. + """ + + _ENV_API_KEY: ClassVar[str] = "XAGENT_API_KEY" + _API_KEY_FIELD: ClassVar[str] = "api_key" + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + *, + timeout: float = 30.0, + max_connections: int = 10, + user_agent: str | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + api_key = api_key or os.environ.get(self._ENV_API_KEY) + base_url = base_url or os.environ.get("XAGENT_BASE_URL") + if not api_key: + raise ValueError( + f"{self._API_KEY_FIELD} required: " + f"pass {self._API_KEY_FIELD}=... or set {self._ENV_API_KEY}" + ) + if not base_url: + raise ValueError( + "base_url required: pass base_url=... or set XAGENT_BASE_URL" + ) + + self._http = HTTPClient( + base_url=base_url, + api_key=api_key, + timeout=timeout, + max_connections=max_connections, + user_agent=user_agent, + transport=transport, + ) + + def close(self) -> None: + self._http.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def _request( + self, + method: str, + path: str, + *, + json: dict[str, object] | None = None, + ) -> httpx.Response: + """Send a request and map any 4xx/5xx response to an XAgentError. + + Protected by package convention: called by API namespace classes + within ``xagent_sdk`` (TasksAPI, TemplatesAPI, AgentsAPI) but not + part of the user-facing API. + + Transport-level failures are already wrapped in + ``XAgentTransportError`` by ``HTTPClient.request``; this helper + only adds the V1-envelope-to-exception mapping for HTTP error + responses that do have a body. + """ + resp = self._http.request(method, path, json=json) + if resp.is_error: + raise from_response(resp) + return resp diff --git a/python/src/xagent_sdk/client.py b/python/src/xagent_sdk/client.py index f518aa2..bd0a50f 100644 --- a/python/src/xagent_sdk/client.py +++ b/python/src/xagent_sdk/client.py @@ -1,16 +1,11 @@ -import os -from types import TracebackType -from typing import Self - import httpx -from xagent_sdk._http import HTTPClient -from xagent_sdk.errors import from_response +from xagent_sdk._base import _BaseClient from xagent_sdk.tasks import TasksAPI from xagent_sdk.types import MeResponse, _parse_me -class XAgentClient: +class XAgentClient(_BaseClient): """Synchronous Python client for the xAgent v1 HTTP API. Constructor argument resolution order for ``api_key`` and ``base_url``: @@ -46,18 +41,9 @@ def __init__( user_agent: str | None = None, transport: httpx.BaseTransport | None = None, ) -> None: - api_key = api_key or os.environ.get("XAGENT_API_KEY") - base_url = base_url or os.environ.get("XAGENT_BASE_URL") - if not api_key: - raise ValueError("api_key required: pass api_key=... or set XAGENT_API_KEY") - if not base_url: - raise ValueError( - "base_url required: pass base_url=... or set XAGENT_BASE_URL" - ) - - self._http = HTTPClient( - base_url=base_url, + super().__init__( api_key=api_key, + base_url=base_url, timeout=timeout, max_connections=max_connections, user_agent=user_agent, @@ -77,41 +63,3 @@ def me(self) -> MeResponse: """ resp = self._request("GET", "/v1/me") return _parse_me(resp.json()) - - def close(self) -> None: - self._http.close() - - def __enter__(self) -> Self: - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - self.close() - - def _request( - self, - method: str, - path: str, - *, - json: dict[str, object] | None = None, - ) -> httpx.Response: - """Send a request and map any 4xx/5xx response to an XAgentError. - - Protected by package convention: called by ``TasksAPI`` within - ``xagent_sdk`` but not part of the user-facing API. The single - underscore signals "internal to the package" not "internal to - this class". - - Transport-level failures are already wrapped in - ``XAgentTransportError`` by ``HTTPClient.request``; this helper - only adds the V1-envelope-to-exception mapping for HTTP error - responses that do have a body. - """ - resp = self._http.request(method, path, json=json) - if resp.is_error: - raise from_response(resp) - return resp From 24d9e8d6f617da5dafb19226cc5bdd71ae87cd95 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Fri, 29 May 2026 19:14:32 +0800 Subject: [PATCH 02/16] refactor(client): rename XAgentClient to AgentClient and drop me() 0.2.0 splits the SDK into two public clients: AgentClient for runtime chat-task calls (this commit) and UserClient for management endpoints (landing in a later commit). The runtime client used to be called XAgentClient and exposed a me() probe that returned the agent identity bound to the runtime key; in the new model identity lives on the personal key and is read via UserClient.me(), so AgentClient loses me() entirely. Changes in this commit: - git mv src/xagent_sdk/client.py -> agent_client.py and rename the class XAgentClient -> AgentClient; the class no longer imports MeResponse / _parse_me and no longer has a me() method. - Update tasks.py forward-ref ("XAgentClient" -> "AgentClient") and the TasksAPI docstring reference accordingly. - Update __init__.py to re-export AgentClient instead of XAgentClient (MeResponse re-export stays until Phase F). - git mv tests/unit/test_client.py -> test_agent_client.py; delete the TestMe class (the two me() tests are subsumed by test_errors.py's parametrize for 401 -> InvalidAPIKey and by the env-var fallback assertions which now inspect the Authorization header directly). - Batch-rename XAgentClient -> AgentClient in tests/unit/conftest.py, tests/unit/test_tasks.py, tests/e2e/conftest.py, and tests/e2e/test_smoke.py. The e2e test_smoke still references client.me(); that file is rewritten in Phase G when the two-step personal+runtime flow lands. - clean_xagent_env autouse now also clears XAGENT_PERSONAL_KEY so UserClient tests landing next phases stay isolated. No public behavior change for the surviving runtime surface; 83 unit tests pass (down from 85: deleted two me() tests). --- python/src/xagent_sdk/__init__.py | 4 +- .../xagent_sdk/{client.py => agent_client.py} | 29 +++---- python/src/xagent_sdk/tasks.py | 6 +- python/tests/e2e/conftest.py | 10 +-- python/tests/e2e/test_smoke.py | 8 +- python/tests/unit/conftest.py | 11 +-- python/tests/unit/test_agent_client.py | 68 +++++++++++++++ python/tests/unit/test_client.py | 82 ------------------- python/tests/unit/test_tasks.py | 48 ++++++----- 9 files changed, 122 insertions(+), 144 deletions(-) rename python/src/xagent_sdk/{client.py => agent_client.py} (67%) create mode 100644 python/tests/unit/test_agent_client.py delete mode 100644 python/tests/unit/test_client.py diff --git a/python/src/xagent_sdk/__init__.py b/python/src/xagent_sdk/__init__.py index 402f410..56fba52 100644 --- a/python/src/xagent_sdk/__init__.py +++ b/python/src/xagent_sdk/__init__.py @@ -1,5 +1,5 @@ from xagent_sdk._version import __version__ -from xagent_sdk.client import XAgentClient +from xagent_sdk.agent_client import AgentClient from xagent_sdk.errors import ( AgentNotFound, InternalError, @@ -24,6 +24,7 @@ ) __all__ = [ + "AgentClient", "AgentNotFound", "AppendResult", "CreateTaskResult", @@ -40,7 +41,6 @@ "TaskNotFound", "TaskStatus", "TaskTimeout", - "XAgentClient", "XAgentError", "XAgentTransportError", "__version__", diff --git a/python/src/xagent_sdk/client.py b/python/src/xagent_sdk/agent_client.py similarity index 67% rename from python/src/xagent_sdk/client.py rename to python/src/xagent_sdk/agent_client.py index bd0a50f..5f4ddf5 100644 --- a/python/src/xagent_sdk/client.py +++ b/python/src/xagent_sdk/agent_client.py @@ -2,16 +2,22 @@ from xagent_sdk._base import _BaseClient from xagent_sdk.tasks import TasksAPI -from xagent_sdk.types import MeResponse, _parse_me -class XAgentClient(_BaseClient): - """Synchronous Python client for the xAgent v1 HTTP API. +class AgentClient(_BaseClient): + """Synchronous runtime client for the xAgent v1 chat-task endpoints. + + Authenticates with an **agent runtime key** (``xag__``) + issued by ``UserClient.agents.create()`` / + ``UserClient.agents.rotate_key()``. Each key is bound 1:1 to an agent + and only authorizes the ``/v1/chat/tasks/*`` surface; management + endpoints (``/v1/me``, ``/v1/templates*``, ``/v1/agents*``) require + a personal key handled by ``UserClient`` instead. Constructor argument resolution order for ``api_key`` and ``base_url``: 1. Explicit keyword argument 2. Environment variable (``XAGENT_API_KEY`` / ``XAGENT_BASE_URL``) - 3. (v0.2.0+) Hardcoded production default URL -- not yet baked in + 3. (v0.3.0+) Hardcoded production default URL -- not yet baked in while the xAgent team finalizes the prod endpoint. Missing values at construction time raise ``ValueError`` instead of @@ -21,7 +27,7 @@ class XAgentClient(_BaseClient): lifetime. Use it as a context manager or call ``close()`` explicitly to release the pool. - Thread-safe: a single ``XAgentClient`` can be shared across threads. + Thread-safe: a single ``AgentClient`` can be shared across threads. Not fork-safe: close and recreate the client after ``os.fork()`` to avoid socket state corruption (a standard caveat for any HTTP client with a persistent connection pool). @@ -50,16 +56,3 @@ def __init__( transport=transport, ) self.tasks = TasksAPI(self) - - def me(self) -> MeResponse: - """Probe the agent identity bound to the API key. - - Zero side-effect. Typically called once at startup to confirm the - key is valid and log which agent the client is talking to. - - Each call hits the backend; if you only need the identity once, - store the result (``identity = client.me()``) rather than - re-calling. - """ - resp = self._request("GET", "/v1/me") - return _parse_me(resp.json()) diff --git a/python/src/xagent_sdk/tasks.py b/python/src/xagent_sdk/tasks.py index 33e118f..fba724e 100644 --- a/python/src/xagent_sdk/tasks.py +++ b/python/src/xagent_sdk/tasks.py @@ -16,7 +16,7 @@ ) if TYPE_CHECKING: - from xagent_sdk.client import XAgentClient + from xagent_sdk.agent_client import AgentClient # Mirrors backend ``v1/tasks.py:170``: ``_TERMINAL_STATUSES = (COMPLETED, @@ -32,7 +32,7 @@ class TasksAPI: """The ``client.tasks`` namespace. All four endpoint methods are thin wrappers over the v1 endpoints: - build a request body, hand it to ``XAgentClient._request`` for + build a request body, hand it to ``AgentClient._request`` for transport + error mapping, then parse the success body into a frozen dataclass. @@ -49,7 +49,7 @@ class TasksAPI: other error from the underlying calls. """ - def __init__(self, client: "XAgentClient") -> None: + def __init__(self, client: "AgentClient") -> None: self._client = client def create( diff --git a/python/tests/e2e/conftest.py b/python/tests/e2e/conftest.py index e8b5b33..86833d9 100644 --- a/python/tests/e2e/conftest.py +++ b/python/tests/e2e/conftest.py @@ -15,22 +15,22 @@ import pytest -from xagent_sdk import XAgentClient +from xagent_sdk import AgentClient @pytest.fixture -def client() -> Iterator[XAgentClient]: +def client() -> Iterator[AgentClient]: """Default e2e client with the SDK's 30s per-request HTTP timeout.""" api_key = os.environ.get("XAGENT_API_KEY") base_url = os.environ.get("XAGENT_BASE_URL") if not (api_key and base_url): pytest.skip("e2e requires XAGENT_API_KEY and XAGENT_BASE_URL") - with XAgentClient(api_key=api_key, base_url=base_url) as c: + with AgentClient(api_key=api_key, base_url=base_url) as c: yield c @pytest.fixture -def patient_client() -> Iterator[XAgentClient]: +def patient_client() -> Iterator[AgentClient]: """Same as ``client`` but with a 60s per-request HTTP timeout. Used by tests that want to *observe* an operation whose latency may @@ -44,5 +44,5 @@ def patient_client() -> Iterator[XAgentClient]: base_url = os.environ.get("XAGENT_BASE_URL") if not (api_key and base_url): pytest.skip("e2e requires XAGENT_API_KEY and XAGENT_BASE_URL") - with XAgentClient(api_key=api_key, base_url=base_url, timeout=60.0) as c: + with AgentClient(api_key=api_key, base_url=base_url, timeout=60.0) as c: yield c diff --git a/python/tests/e2e/test_smoke.py b/python/tests/e2e/test_smoke.py index 74cb54f..952da0a 100644 --- a/python/tests/e2e/test_smoke.py +++ b/python/tests/e2e/test_smoke.py @@ -15,19 +15,19 @@ import pytest -from xagent_sdk import RunResult, TaskStatus, XAgentClient +from xagent_sdk import AgentClient, RunResult, TaskStatus pytestmark = pytest.mark.e2e -def test_me(client: XAgentClient) -> None: +def test_me(client: AgentClient) -> None: me = client.me() assert me.agent_id > 0 assert me.agent_name assert me.key_prefix -def test_create_is_async(patient_client: XAgentClient) -> None: +def test_create_is_async(patient_client: AgentClient) -> None: """POST /v1/chat/tasks must return asynchronously per v1 contract. The contract is "create the task, return 202 immediately, run LLM in @@ -62,7 +62,7 @@ def test_create_is_async(patient_client: XAgentClient) -> None: ) -def test_run_single_turn(client: XAgentClient) -> None: +def test_run_single_turn(client: AgentClient) -> None: agent_id = int(os.environ.get("E2E_AGENT_ID", str(client.me().agent_id))) result = client.tasks.run( agent_id=agent_id, diff --git a/python/tests/unit/conftest.py b/python/tests/unit/conftest.py index 29b0dc1..29ef8b8 100644 --- a/python/tests/unit/conftest.py +++ b/python/tests/unit/conftest.py @@ -10,27 +10,28 @@ import httpx import pytest -from xagent_sdk import XAgentClient +from xagent_sdk import AgentClient @pytest.fixture(autouse=True) def clean_xagent_env(monkeypatch: pytest.MonkeyPatch) -> None: """Strip XAGENT_* env vars so tests do not inherit ambient config.""" monkeypatch.delenv("XAGENT_API_KEY", raising=False) + monkeypatch.delenv("XAGENT_PERSONAL_KEY", raising=False) monkeypatch.delenv("XAGENT_BASE_URL", raising=False) @pytest.fixture -def make_client() -> Callable[..., XAgentClient]: - """Return a factory for XAgentClient backed by an httpx.MockTransport.""" +def make_client() -> Callable[..., AgentClient]: + """Return a factory for AgentClient backed by an httpx.MockTransport.""" def _factory( handler: Callable[[httpx.Request], httpx.Response], *, api_key: str = "test_key", base_url: str = "https://test.example", - ) -> XAgentClient: - return XAgentClient( + ) -> AgentClient: + return AgentClient( api_key=api_key, base_url=base_url, transport=httpx.MockTransport(handler), diff --git a/python/tests/unit/test_agent_client.py b/python/tests/unit/test_agent_client.py new file mode 100644 index 0000000..61c2e7e --- /dev/null +++ b/python/tests/unit/test_agent_client.py @@ -0,0 +1,68 @@ +"""Tests for AgentClient construction and context-manager lifecycle. + +The ``me()`` method used to live here in 0.1.0 (it returned the +agent identity bound to the runtime key). 0.2.0 moved identity to +``UserClient.me()`` and ``AgentClient`` no longer has a probe method, +so this module focuses on the construction contract and resource +cleanup. Auth-mapping coverage for the agent runtime key (401 -> +``InvalidAPIKey``) lives in ``test_tasks.py`` and ``test_errors.py``. +""" + +from collections.abc import Callable + +import httpx +import pytest + +from xagent_sdk import AgentClient + + +def _ok_handler(req: httpx.Request) -> httpx.Response: + """Generic 200 OK; tests below do not actually send requests, but + ``make_client`` requires a handler argument.""" + return httpx.Response(200, json={"ok": True}) + + +class TestConstruction: + def test_explicit(self, make_client: Callable[..., AgentClient]) -> None: + with make_client(_ok_handler) as c: + # Explicit api_key from the factory default flows to the + # Authorization header verbatim. + assert c._http._client.headers["Authorization"] == "Bearer test_key" + + def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_API_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = AgentClient(transport=httpx.MockTransport(_ok_handler)) + assert c._http._client.headers["Authorization"] == "Bearer envkey" + c.close() + + def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_API_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = AgentClient( + api_key="explicit", + base_url="https://explicit", + transport=httpx.MockTransport(_ok_handler), + ) + # Bearer header comes from the explicit api_key, not the env value. + assert c._http._client.headers["Authorization"] == "Bearer explicit" + c.close() + + def test_missing_api_key(self) -> None: + with pytest.raises(ValueError, match="api_key"): + AgentClient(base_url="https://x") + + def test_missing_base_url(self) -> None: + with pytest.raises(ValueError, match="base_url"): + AgentClient(api_key="x") + + +class TestLifecycle: + def test_context_manager_closes( + self, make_client: Callable[..., AgentClient] + ) -> None: + c = make_client(_ok_handler) + assert c._http._client.is_closed is False + with c: + pass + assert c._http._client.is_closed is True diff --git a/python/tests/unit/test_client.py b/python/tests/unit/test_client.py deleted file mode 100644 index d17a023..0000000 --- a/python/tests/unit/test_client.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for XAgentClient construction and the me() probe.""" - -from collections.abc import Callable - -import httpx -import pytest - -from xagent_sdk import InvalidAPIKey, MeResponse, XAgentClient - - -def _me_handler(req: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={"agent_id": 7, "agent_name": "Sales", "key_prefix": "a1B2c3"}, - ) - - -def _401_handler(req: httpx.Request) -> httpx.Response: - return httpx.Response( - 401, - json={"error": {"code": "invalid_api_key", "message": "bad"}}, - ) - - -class TestConstruction: - def test_explicit(self, make_client: Callable[..., XAgentClient]) -> None: - with make_client(_me_handler) as c: - assert c.me().agent_id == 7 - - def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("XAGENT_API_KEY", "envkey") - monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") - c = XAgentClient(transport=httpx.MockTransport(_me_handler)) - assert c.me().agent_id == 7 - c.close() - - def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("XAGENT_API_KEY", "envkey") - monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") - c = XAgentClient( - api_key="explicit", - base_url="https://explicit", - transport=httpx.MockTransport(_me_handler), - ) - # Bearer header comes from the explicit api_key, not the env value. - assert c._http._client.headers["Authorization"] == "Bearer explicit" - c.close() - - def test_missing_api_key(self) -> None: - with pytest.raises(ValueError, match="api_key"): - XAgentClient(base_url="https://x") - - def test_missing_base_url(self) -> None: - with pytest.raises(ValueError, match="base_url"): - XAgentClient(api_key="x") - - -class TestMe: - def test_returns_me_response( - self, make_client: Callable[..., XAgentClient] - ) -> None: - with make_client(_me_handler) as c: - me = c.me() - assert isinstance(me, MeResponse) - assert me.agent_name == "Sales" - - def test_401_raises_invalid_api_key( - self, make_client: Callable[..., XAgentClient] - ) -> None: - with make_client(_401_handler) as c, pytest.raises(InvalidAPIKey): - c.me() - - -class TestLifecycle: - def test_context_manager_closes( - self, make_client: Callable[..., XAgentClient] - ) -> None: - c = make_client(_me_handler) - assert c._http._client.is_closed is False - with c: - pass - assert c._http._client.is_closed is True diff --git a/python/tests/unit/test_tasks.py b/python/tests/unit/test_tasks.py index 5023b52..647185e 100644 --- a/python/tests/unit/test_tasks.py +++ b/python/tests/unit/test_tasks.py @@ -8,6 +8,7 @@ import pytest from xagent_sdk import ( + AgentClient, AgentNotFound, AppendResult, CreateTaskResult, @@ -19,12 +20,11 @@ TaskNotFound, TaskStatus, TaskTimeout, - XAgentClient, ) class TestCreate: - def test_body_shape(self, make_client: Callable[..., XAgentClient]) -> None: + def test_body_shape(self, make_client: Callable[..., AgentClient]) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -52,7 +52,7 @@ def handler(req: httpx.Request) -> httpx.Response: assert isinstance(result, CreateTaskResult) assert result.status is TaskStatus.PENDING - def test_metadata_included(self, make_client: Callable[..., XAgentClient]) -> None: + def test_metadata_included(self, make_client: Callable[..., AgentClient]) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -74,7 +74,7 @@ def handler(req: httpx.Request) -> httpx.Response: assert body["metadata"] == {"trace_id": "abc"} def test_no_metadata_field_when_none( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: captured: list[httpx.Request] = [] @@ -98,7 +98,7 @@ def handler(req: httpx.Request) -> httpx.Response: class TestAppend: - def test_url_and_body(self, make_client: Callable[..., XAgentClient]) -> None: + def test_url_and_body(self, make_client: Callable[..., AgentClient]) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -128,7 +128,7 @@ def handler(req: httpx.Request) -> httpx.Response: class TestGet: - def test_url_and_parse(self, make_client: Callable[..., XAgentClient]) -> None: + def test_url_and_parse(self, make_client: Callable[..., AgentClient]) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -158,7 +158,7 @@ def handler(req: httpx.Request) -> httpx.Response: class TestSteps: def test_url_and_outer_wrapper_dropped( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: captured: list[httpx.Request] = [] @@ -220,11 +220,11 @@ class TestErrorMappingPerEndpoint: ) def test_envelope_codes( self, - make_client: Callable[..., XAgentClient], + make_client: Callable[..., AgentClient], status: int, code: str, exc_cls: type[Exception], - call: Callable[[XAgentClient], object], + call: Callable[[AgentClient], object], ) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( @@ -237,7 +237,7 @@ def h(req: httpx.Request) -> httpx.Response: # know exc_cls is a subclass here, so cast via attribute access. assert excinfo.value.code == code # type: ignore[attr-defined] - def test_steps_422(self, make_client: Callable[..., XAgentClient]) -> None: + def test_steps_422(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( 422, @@ -249,9 +249,7 @@ def h(req: httpx.Request) -> httpx.Response: class TestWait: - def test_multi_poll_terminal( - self, make_client: Callable[..., XAgentClient] - ) -> None: + def test_multi_poll_terminal(self, make_client: Callable[..., AgentClient]) -> None: calls = {"n": 0} def h(req: httpx.Request) -> httpx.Response: @@ -279,7 +277,7 @@ def h(req: httpx.Request) -> httpx.Response: assert info.status is TaskStatus.COMPLETED assert calls["n"] == 3 - def test_immediate_terminal(self, make_client: Callable[..., XAgentClient]) -> None: + def test_immediate_terminal(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( 200, @@ -301,7 +299,7 @@ def h(req: httpx.Request) -> httpx.Response: assert info.status is TaskStatus.COMPLETED - def test_timeout(self, make_client: Callable[..., XAgentClient]) -> None: + def test_timeout(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( 200, @@ -323,7 +321,7 @@ def h(req: httpx.Request) -> httpx.Response: assert "running" in excinfo.value.message def test_paused_keeps_polling( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: # PAUSED is NOT terminal (mirrors backend `v1/tasks.py:170`); # wait() should poll until the deadline elapses. @@ -346,7 +344,7 @@ def h(req: httpx.Request) -> httpx.Response: c.tasks.wait(10, timeout=0.1, poll_interval=0.02) assert "paused" in excinfo.value.message - def test_propagates_404(self, make_client: Callable[..., XAgentClient]) -> None: + def test_propagates_404(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( 404, @@ -356,7 +354,7 @@ def h(req: httpx.Request) -> httpx.Response: with make_client(h) as c, pytest.raises(TaskNotFound): c.tasks.wait(999, timeout=1.0, poll_interval=0.05) - def test_wall_clock_cap(self, make_client: Callable[..., XAgentClient]) -> None: + def test_wall_clock_cap(self, make_client: Callable[..., AgentClient]) -> None: # poll_interval >> remaining timeout. wait() must cap sleep so the # wall-clock does not overshoot. Bug version would take ~5s; we # assert <0.5s (~5x the configured timeout, well below the 5s @@ -385,7 +383,7 @@ def h(req: httpx.Request) -> httpx.Response: assert elapsed < 0.5 def test_negative_timeout_rejected( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: # The handler is never invoked; validation rejects before any HTTP. with ( @@ -395,7 +393,7 @@ def test_negative_timeout_rejected( c.tasks.wait(10, timeout=-1.0, poll_interval=0.05) def test_negative_poll_interval_rejected( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: with ( make_client(lambda req: httpx.Response(200)) as c, # noqa: ARG005 @@ -405,7 +403,7 @@ def test_negative_poll_interval_rejected( class TestRun: - def test_full_flow(self, make_client: Callable[..., XAgentClient]) -> None: + def test_full_flow(self, make_client: Callable[..., AgentClient]) -> None: state = {"polls": 0} def h(req: httpx.Request) -> httpx.Response: @@ -475,7 +473,7 @@ def h(req: httpx.Request) -> httpx.Response: assert len(result.steps) == 1 assert result.steps[0].type is StepType.MESSAGE - def test_timeout_propagates(self, make_client: Callable[..., XAgentClient]) -> None: + def test_timeout_propagates(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: method, path = req.method, req.url.path if method == "POST" and path == "/v1/chat/tasks": @@ -511,7 +509,7 @@ def h(req: httpx.Request) -> httpx.Response: ) def test_negative_timeout_rejected_before_create( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: calls = {"n": 0} @@ -525,7 +523,7 @@ def h(req: httpx.Request) -> httpx.Response: # noqa: ARG001 assert calls["n"] == 0 def test_negative_poll_interval_rejected_before_create( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: calls = {"n": 0} @@ -538,7 +536,7 @@ def h(req: httpx.Request) -> httpx.Response: # noqa: ARG001 assert calls["n"] == 0 - def test_shared_deadline(self, make_client: Callable[..., XAgentClient]) -> None: + def test_shared_deadline(self, make_client: Callable[..., AgentClient]) -> None: # Inject latency into create() so that the time it consumes is # observable. Old behavior passed the full timeout to wait(), # producing total elapsed of (create_delay + timeout). New From 7405e61679def4c2ee5262a52bee9671da2a0137 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Fri, 29 May 2026 19:18:25 +0800 Subject: [PATCH 03/16] feat(types): add UserPrincipal, Template, Agent, RotateKey + TemplateNotFound Land the dataclass surface and error mapping the upcoming UserClient needs. The 0.1.0 MeResponse class is removed in the same change to avoid leaving a stub that would import-resolve but no longer matches the /v1/me response shape (which now returns a user principal rather than an agent identity). New dataclasses in types.py: - UserPrincipal -- /v1/me response. Replaces MeResponse. - Template / TemplateDetail -- /v1/templates list entry + detail (the detail variant adds agent_config, the merge target for create_from_template overrides). - AgentSummary -- /v1/agents list entry. - AgentCreateResult -- /v1/agents and /v1/agents/from-template response, carries the one-time runtime_full_key when the backend default generate_runtime_key=True path is taken. - RotateKeyResult -- /v1/agents/{id}/api-key response. The method name documents the rotation side-effect; the dataclass exposes the one-time full_key plus the public-safe key_prefix. Six new private TypeAdapter helpers (_parse_user_principal / _parse_template_list / _parse_template_detail / _parse_agent_list / _parse_agent_create / _parse_rotate_key) follow the existing pydantic v2 pattern used by _parse_task_info etc. The list parsers defensively short-circuit to [] on non-dict input, mirroring _parse_steps. New error class TemplateNotFound (404 code template_not_found), registered in errors._CODE_MAP. Distinct from AgentNotFound because the SaaS template-picker UX treats the two mismatches differently. Public surface adjustments in this commit are deliberately minimal: remove MeResponse from xagent_sdk/__init__.py + tests/unit/test_types.py so the package still imports, but defer the rest of the surface shuffle (adding the seven new names, asserting the expected __all__) to Phase F. Test rename test_me_frozen -> test_step_frozen keeps the frozen-dataclass invariant covered. 82 unit tests pass. --- python/src/xagent_sdk/__init__.py | 2 - python/src/xagent_sdk/errors.py | 12 +++ python/src/xagent_sdk/types.py | 154 ++++++++++++++++++++++++++++-- python/tests/unit/test_types.py | 21 ++-- 4 files changed, 170 insertions(+), 19 deletions(-) diff --git a/python/src/xagent_sdk/__init__.py b/python/src/xagent_sdk/__init__.py index 56fba52..8fc523c 100644 --- a/python/src/xagent_sdk/__init__.py +++ b/python/src/xagent_sdk/__init__.py @@ -15,7 +15,6 @@ from xagent_sdk.types import ( AppendResult, CreateTaskResult, - MeResponse, RunResult, Step, StepType, @@ -31,7 +30,6 @@ "InternalError", "InvalidAPIKey", "InvalidInput", - "MeResponse", "RateLimited", "RunResult", "Step", diff --git a/python/src/xagent_sdk/errors.py b/python/src/xagent_sdk/errors.py index 8e1b388..8ff5d19 100644 --- a/python/src/xagent_sdk/errors.py +++ b/python/src/xagent_sdk/errors.py @@ -63,6 +63,17 @@ class InvalidInput(XAgentError): """ +class TemplateNotFound(XAgentError): + """HTTP 404, code ``template_not_found``. + + Raised when ``UserClient.templates.get(template_id)`` or + ``UserClient.agents.create_from_template(template_id, ...)`` is + given an unknown ``template_id``. Distinct from ``AgentNotFound`` + because the SaaS UI path treats template-picker mismatch differently + from agent-lookup mismatch. + """ + + # SDK-coined errors (server has no equivalent code). class XAgentTransportError(XAgentError): """Network, DNS, TLS, or local timeout below the HTTP layer. @@ -89,6 +100,7 @@ class TaskTimeout(XAgentError): "task_not_found": TaskNotFound, "task_busy": TaskBusy, "invalid_input": InvalidInput, + "template_not_found": TemplateNotFound, "rate_limited": RateLimited, "internal_error": InternalError, } diff --git a/python/src/xagent_sdk/types.py b/python/src/xagent_sdk/types.py index f44e17b..2394c73 100644 --- a/python/src/xagent_sdk/types.py +++ b/python/src/xagent_sdk/types.py @@ -41,12 +41,115 @@ class StepType(StrEnum): @dataclass(frozen=True) -class MeResponse: - """``GET /v1/me`` payload -- agent identity bound to the presented key.""" +class UserPrincipal: + """``GET /v1/me`` payload (0.2.0+) -- user identity bound to the + presented personal key. + + Replaces the 0.1.0 ``MeResponse`` shape (``agent_id`` / ``agent_name`` / + ``key_prefix``) since ``/v1/me`` is now a personal-key endpoint that + returns the **user** the key belongs to. To look up which agent a + runtime key corresponds to in 0.2.0, list agents via + ``UserClient.agents.list()`` and match against ``AgentSummary.name`` + or ``agent_id``. + + ``principal_type`` is a stable enum string today (``"user"``); future + backends may add new principal kinds (e.g., service accounts) which + would land as new ``UserClient`` subclasses, not as silent additions + here. + """ + + principal_type: str + user_id: int + email: str + name: str + key_prefix: str + + +@dataclass(frozen=True) +class Template: + """``GET /v1/templates`` list entry. + + The list endpoint returns a slim summary suitable for showing in a + SaaS-app picker; the full ``agent_config`` (system prompt, tools, + model defaults) is only included in the per-template + ``GET /v1/templates/{template_id}`` response (``TemplateDetail``). + """ + + template_id: str + name: str + description: str | None = None + + +@dataclass(frozen=True) +class TemplateDetail: + """``GET /v1/templates/{template_id}`` payload. + + Extends ``Template`` with ``agent_config``, the dict that + ``UserClient.agents.create_from_template()`` merges with the caller's + ``overrides`` before posting to the backend. The shape of + ``agent_config`` is intentionally typed as ``dict[str, Any]``: it is + the backend's evolving template-config envelope (currently includes + things like ``instructions`` / ``mode`` / ``tools_default``), and + pinning a strict schema here would force SDK releases for every + backend template-config change. + """ + + template_id: str + name: str + description: str | None + agent_config: dict[str, Any] + + +@dataclass(frozen=True) +class AgentSummary: + """``GET /v1/agents`` list entry. + + Slim shape for listing agents owned by the personal key's user. + The optional ``status`` reflects the agent's published-state + (e.g. ``"active"`` / ``"draft"`` / ``"paused"``); backend may omit it + in early Phase 2 wire shapes. + """ + + agent_id: int + name: str + status: str | None = None + + +@dataclass(frozen=True) +class AgentCreateResult: + """``POST /v1/agents`` / ``POST /v1/agents/from-template`` payload. + + Carries the new agent's identity plus the **one-time** runtime key + when ``generate_runtime_key=True`` was passed (the backend default). + ``runtime_full_key`` is ``None`` only when the caller explicitly set + ``generate_runtime_key=False`` and intends to call ``rotate_key()`` + later to materialize the first runtime key. + + The ``runtime_full_key`` is the only chance the SDK or its caller + have to read the secret in cleartext; store it in a secret vault + immediately and never log it. ``runtime_key_prefix`` is the + public-safe 6-char lookup handle and is safe to log for tracing. + """ agent_id: int - agent_name: str + name: str + runtime_full_key: str | None + runtime_key_prefix: str | None + + +@dataclass(frozen=True) +class RotateKeyResult: + """``POST /v1/agents/{agent_id}/api-key`` payload. + + Rotation is destructive: the previous runtime key for this agent is + revoked atomically with the new key insertion. The ``full_key`` is a + one-time payload exposed only on this response; storing it is the + caller's responsibility. + """ + + full_key: str key_prefix: str + created_at: datetime @dataclass(frozen=True) @@ -152,15 +255,54 @@ def status(self) -> TaskStatus: # module scope. ``validate_python`` handles ISO datetime parsing, enum # coercion, and Optional handling without bespoke conversion code. -_ME_ADAPTER = TypeAdapter(MeResponse) +_USER_PRINCIPAL_ADAPTER = TypeAdapter(UserPrincipal) +_TEMPLATE_LIST_ADAPTER = TypeAdapter(list[Template]) +_TEMPLATE_DETAIL_ADAPTER = TypeAdapter(TemplateDetail) +_AGENT_LIST_ADAPTER = TypeAdapter(list[AgentSummary]) +_AGENT_CREATE_ADAPTER = TypeAdapter(AgentCreateResult) +_ROTATE_KEY_ADAPTER = TypeAdapter(RotateKeyResult) _CREATE_ADAPTER = TypeAdapter(CreateTaskResult) _APPEND_ADAPTER = TypeAdapter(AppendResult) _TASK_INFO_ADAPTER = TypeAdapter(TaskInfo) _STEP_LIST_ADAPTER = TypeAdapter(list[Step]) -def _parse_me(data: dict[str, Any]) -> MeResponse: - return _ME_ADAPTER.validate_python(data) +def _parse_user_principal(data: dict[str, Any]) -> UserPrincipal: + return _USER_PRINCIPAL_ADAPTER.validate_python(data) + + +def _parse_template_list(data: Any) -> list[Template]: + """Extract and parse the ``templates`` array from a list response. + + Mirrors ``_parse_steps`` defense-in-depth: non-dict body or missing + ``templates`` key returns an empty list rather than raising + ``AttributeError``. + """ + if not isinstance(data, dict): + return [] + return _TEMPLATE_LIST_ADAPTER.validate_python(data.get("templates", [])) + + +def _parse_template_detail(data: dict[str, Any]) -> TemplateDetail: + return _TEMPLATE_DETAIL_ADAPTER.validate_python(data) + + +def _parse_agent_list(data: Any) -> list[AgentSummary]: + """Extract and parse the ``agents`` array from a list response. + + Same defensive shape as ``_parse_template_list`` / ``_parse_steps``. + """ + if not isinstance(data, dict): + return [] + return _AGENT_LIST_ADAPTER.validate_python(data.get("agents", [])) + + +def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: + return _AGENT_CREATE_ADAPTER.validate_python(data) + + +def _parse_rotate_key(data: dict[str, Any]) -> RotateKeyResult: + return _ROTATE_KEY_ADAPTER.validate_python(data) def _parse_create_task(data: dict[str, Any]) -> CreateTaskResult: diff --git a/python/tests/unit/test_types.py b/python/tests/unit/test_types.py index bc7a550..26ceefd 100644 --- a/python/tests/unit/test_types.py +++ b/python/tests/unit/test_types.py @@ -9,7 +9,6 @@ from xagent_sdk import ( AppendResult, CreateTaskResult, - MeResponse, RunResult, Step, StepType, @@ -19,18 +18,11 @@ from xagent_sdk.types import ( _parse_append, _parse_create_task, - _parse_me, _parse_steps, _parse_task_info, ) -class TestParseMe: - def test_happy(self) -> None: - me = _parse_me({"agent_id": 7, "agent_name": "Sales", "key_prefix": "a1B2c3"}) - assert me == MeResponse(agent_id=7, agent_name="Sales", key_prefix="a1B2c3") - - class TestParseCreateTask: def test_pending_status_and_tz(self) -> None: c = _parse_create_task( @@ -192,10 +184,17 @@ def test_unknown_step_type_rejected(self) -> None: class TestFrozenDataclasses: - def test_me_frozen(self) -> None: - me = MeResponse(agent_id=1, agent_name="x", key_prefix="y") + def test_step_frozen(self) -> None: + step = Step( + id="message:1", + type=StepType.MESSAGE, + status="completed", + started_at=datetime(2026, 5, 10, tzinfo=UTC), + completed_at=datetime(2026, 5, 10, tzinfo=UTC), + data={"role": "user", "content": "hi"}, + ) with pytest.raises(FrozenInstanceError): - me.agent_name = "hacked" # type: ignore[misc] + step.id = "hacked" # type: ignore[misc] class TestRunResult: From 4d93ad8d93dce73627c1cba2f0a0e00c84c95a66 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Fri, 29 May 2026 19:20:36 +0800 Subject: [PATCH 04/16] feat(client): UserClient with templates and agents namespaces Land the personal-key management client and its two namespaces. src/xagent_sdk/user_client.py: - UserClient extends _BaseClient with _ENV_API_KEY=XAGENT_PERSONAL_KEY and _API_KEY_FIELD=personal_key, so its constructor reads personal_key from the kwarg or env and the missing-key ValueError message correctly names the personal_key parameter. - me() returns the new UserPrincipal (user_id / email / name / principal_type / key_prefix) parsed via _parse_user_principal. - Composes .templates = TemplatesAPI(self) and .agents = AgentsAPI(self) at construction time, following the same pattern AgentClient uses for .tasks. src/xagent_sdk/_templates.py: - TemplatesAPI.list() -> list[Template] hits GET /v1/templates and defensively returns [] for non-dict bodies (mirrors _parse_steps). - TemplatesAPI.get(template_id) -> TemplateDetail hits GET /v1/templates/{template_id}; backend 404 with template_not_found surfaces as TemplateNotFound via the V1 envelope mapping wired in the previous commit. src/xagent_sdk/_agents.py: - AgentsAPI.list() -> list[AgentSummary]: GET /v1/agents. - AgentsAPI.create(name, instructions, generate_runtime_key=True, metadata=None): POST /v1/agents. metadata is omitted from the wire when None, matching tasks.create's convention. The returned AgentCreateResult carries runtime_full_key as a one-time payload when generate_runtime_key=True; docstring warns to vault and never log it. - AgentsAPI.create_from_template(template_id, overrides=None, generate_runtime_key=True): POST /v1/agents/from-template. overrides accepts a Mapping[str, Any] and is converted to a dict before serialization; SDK does no client-side schema validation -- backend 422 invalid_input on malformed overrides. - AgentsAPI.rotate_key(agent_id): POST /v1/agents/{id}/api-key. The method name encodes the destructive side effect (revoke + insert atomically); docstring spells out that existing AgentClient instances using the old key will start receiving InvalidAPIKey on the next request. Both namespace classes follow the existing TasksAPI pattern: hold a reference to the owning client and route every call through self._client._request, then dispatch to a _parse_* helper on the success body. No tests for the new classes in this commit -- fixtures and unit tests land in Phase E. 82 unit tests still pass. --- python/src/xagent_sdk/_agents.py | 157 +++++++++++++++++++++++++++ python/src/xagent_sdk/_templates.py | 59 ++++++++++ python/src/xagent_sdk/user_client.py | 77 +++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 python/src/xagent_sdk/_agents.py create mode 100644 python/src/xagent_sdk/_templates.py create mode 100644 python/src/xagent_sdk/user_client.py diff --git a/python/src/xagent_sdk/_agents.py b/python/src/xagent_sdk/_agents.py new file mode 100644 index 0000000..6bb4d2c --- /dev/null +++ b/python/src/xagent_sdk/_agents.py @@ -0,0 +1,157 @@ +"""The ``client.agents`` namespace exposed on ``UserClient``. + +Provides agent lifecycle management for the personal-key authenticated +caller: list the user's agents, create new ones (structured or from a +template), and rotate the runtime API key on an existing agent. + +``create()`` and ``create_from_template()`` default to +``generate_runtime_key=True`` to match the backend default; the returned +``AgentCreateResult.runtime_full_key`` is a **one-time** payload. The SDK +deliberately does not cache it -- the only chance to read the secret is +the immediate response. Pass ``generate_runtime_key=False`` when the +caller plans to rotate later via ``rotate_key()``. +""" + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from xagent_sdk.types import ( + AgentCreateResult, + AgentSummary, + RotateKeyResult, + _parse_agent_create, + _parse_agent_list, + _parse_rotate_key, +) + +if TYPE_CHECKING: + from xagent_sdk.user_client import UserClient + + +class AgentsAPI: + """The ``user_client.agents`` namespace.""" + + def __init__(self, client: "UserClient") -> None: + self._client = client + + def list(self) -> list[AgentSummary]: + """``GET /v1/agents`` -- list agents owned by the personal key's user. + + Returns slim summaries (id + name + optional status). Returns an + empty list when the backend sends ``{"agents": []}`` or a non-dict + body. Standard error mapping applies (``InvalidAPIKey``, etc.). + """ + resp = self._client._request("GET", "/v1/agents") + return _parse_agent_list(resp.json()) + + def create( + self, + *, + name: str, + instructions: str, + generate_runtime_key: bool = True, + metadata: dict[str, Any] | None = None, + ) -> AgentCreateResult: + """``POST /v1/agents`` -- structured agent creation. + + Args: + name: Display name shown in agent pickers; backend enforces + uniqueness within the user's agent set per its own policy. + instructions: System prompt / role description. + generate_runtime_key: When ``True`` (default), the backend + provisions a fresh runtime key in the same transaction + and returns it via ``AgentCreateResult.runtime_full_key``. + Set ``False`` when the caller intends to issue the first + runtime key later via ``rotate_key()``. + metadata: Free-form correlation data the backend persists + without interpretation; analogous to ``tasks.create``'s + ``metadata`` parameter. Omitted from the wire when None. + + Returns: + ``AgentCreateResult`` with ``agent_id``, ``name``, and + (when ``generate_runtime_key=True``) ``runtime_full_key`` + + ``runtime_key_prefix``. ``runtime_full_key`` is one-time; + persist to a secret vault and never log. + + Raises: + InvalidInput: 422 -- backend rejected the body (e.g. empty + ``name`` or ``instructions``). + InvalidAPIKey: 401 -- personal key invalid / revoked. + """ + body: dict[str, Any] = { + "name": name, + "instructions": instructions, + "generate_runtime_key": generate_runtime_key, + } + if metadata is not None: + body["metadata"] = metadata + resp = self._client._request("POST", "/v1/agents", json=body) + return _parse_agent_create(resp.json()) + + def create_from_template( + self, + template_id: str, + *, + overrides: Mapping[str, Any] | None = None, + generate_runtime_key: bool = True, + ) -> AgentCreateResult: + """``POST /v1/agents/from-template`` -- create an agent by template. + + The backend loads the template's ``agent_config`` and merges it + with ``overrides`` (caller-supplied dict, deep-merged server-side) + before persisting the agent. The SDK passes ``overrides`` through + verbatim and does **not** validate keys against any template + schema -- the backend rejects invalid overrides with 422 + ``invalid_input``. + + Args: + template_id: Template identifier from + ``templates.list()`` / ``templates.get()``. + overrides: Optional dict of fields to override on the + template's ``agent_config`` (e.g. a new ``name``, custom + ``instructions``). Omitted from the wire when None. + generate_runtime_key: Same semantics as ``create()``. + + Returns: + ``AgentCreateResult``; see ``create()`` for field semantics. + + Raises: + TemplateNotFound: 404 ``template_not_found`` -- unknown + ``template_id``. + InvalidInput: 422 -- overrides contain disallowed fields or + malformed values. + InvalidAPIKey: 401 -- personal key invalid / revoked. + """ + body: dict[str, Any] = { + "template_id": template_id, + "generate_runtime_key": generate_runtime_key, + } + if overrides is not None: + body["overrides"] = dict(overrides) + resp = self._client._request("POST", "/v1/agents/from-template", json=body) + return _parse_agent_create(resp.json()) + + def rotate_key(self, agent_id: int) -> RotateKeyResult: + """``POST /v1/agents/{agent_id}/api-key`` -- rotate runtime key. + + Destructive: the previous runtime key for ``agent_id`` is + revoked atomically with the new key insertion. The returned + ``full_key`` is a **one-time** payload -- existing AgentClient + instances using the old key will start receiving + ``InvalidAPIKey`` on the next request. + + Args: + agent_id: Target agent. Must be owned by the personal key's + user. + + Returns: + ``RotateKeyResult`` with ``full_key`` (one-time secret), + ``key_prefix`` (public-safe handle), and ``created_at``. + + Raises: + AgentNotFound: 404 ``agent_not_found`` -- agent does not + exist or is not owned by the calling user. + InvalidAPIKey: 401 -- personal key invalid / revoked. + """ + resp = self._client._request("POST", f"/v1/agents/{agent_id}/api-key") + return _parse_rotate_key(resp.json()) diff --git a/python/src/xagent_sdk/_templates.py b/python/src/xagent_sdk/_templates.py new file mode 100644 index 0000000..2c79d3f --- /dev/null +++ b/python/src/xagent_sdk/_templates.py @@ -0,0 +1,59 @@ +"""The ``client.templates`` namespace exposed on ``UserClient``. + +Templates are server-managed presets (Content Generator, Analyzer, Q&A, +Assistant, ...) that a SaaS app can offer to its users when they create +an agent without writing instructions from scratch. The list endpoint +returns slim ``Template`` summaries suitable for a picker UI; the detail +endpoint returns the full ``agent_config`` dict needed by +``AgentsAPI.create_from_template``. + +This module is internal -- consumers reach it via +``user_client.templates`` -- so the class is exported via the user +client and not re-exported from ``xagent_sdk.__init__``. +""" + +from typing import TYPE_CHECKING + +from xagent_sdk.types import ( + Template, + TemplateDetail, + _parse_template_detail, + _parse_template_list, +) + +if TYPE_CHECKING: + from xagent_sdk.user_client import UserClient + + +class TemplatesAPI: + """The ``user_client.templates`` namespace.""" + + def __init__(self, client: "UserClient") -> None: + self._client = client + + def list(self) -> list[Template]: + """``GET /v1/templates`` -- list available agent templates. + + Returns a slim summary (id + name + optional description) per + template. Use ``get(template_id)`` to retrieve the full + ``agent_config`` needed by ``agents.create_from_template``. + + Returns an empty list when the backend sends ``{"templates": []}`` + or a non-dict body; raises ``InvalidAPIKey`` / other ``XAgentError`` + subclasses for HTTP errors per the standard envelope mapping. + """ + resp = self._client._request("GET", "/v1/templates") + return _parse_template_list(resp.json()) + + def get(self, template_id: str) -> TemplateDetail: + """``GET /v1/templates/{template_id}`` -- fetch template detail. + + The returned ``TemplateDetail`` includes the ``agent_config`` dict + whose shape the backend owns; ``create_from_template`` merges it + with any caller-supplied overrides server-side. + + Raises ``TemplateNotFound`` (404 ``template_not_found``) when the + backend reports the template does not exist. + """ + resp = self._client._request("GET", f"/v1/templates/{template_id}") + return _parse_template_detail(resp.json()) diff --git a/python/src/xagent_sdk/user_client.py b/python/src/xagent_sdk/user_client.py new file mode 100644 index 0000000..9f0bf44 --- /dev/null +++ b/python/src/xagent_sdk/user_client.py @@ -0,0 +1,77 @@ +import httpx + +from xagent_sdk._agents import AgentsAPI +from xagent_sdk._base import _BaseClient +from xagent_sdk._templates import TemplatesAPI +from xagent_sdk.types import UserPrincipal, _parse_user_principal + + +class UserClient(_BaseClient): + """Synchronous management client for the xAgent v1 user-facing surface. + + Authenticates with a **personal key** (``xag_personal__`` + issued by the xAgent web UI to an individual user). Personal keys are + workspace-wide for the user and authorize the management surface + ``/v1/me`` + ``/v1/templates*`` + ``/v1/agents*``; they cannot drive + chat tasks. Use ``AgentClient`` -- with an **agent runtime key** + minted by ``UserClient.agents.create()`` / + ``UserClient.agents.rotate_key()`` -- to invoke + ``/v1/chat/tasks/*``. + + Constructor argument resolution order for ``personal_key`` and + ``base_url``: + + 1. Explicit keyword argument + 2. Environment variable + (``XAGENT_PERSONAL_KEY`` / ``XAGENT_BASE_URL``) + 3. (v0.3.0+) Hardcoded production default URL -- not yet baked in + while the xAgent team finalizes the prod endpoint. + + Missing values at construction time raise ``ValueError`` instead of + deferring failure to the first request. ``XAGENT_PERSONAL_KEY`` is a + separate env var from ``XAGENT_API_KEY`` (which feeds ``AgentClient``) + so the two clients can coexist in the same process without sharing + state. + + The ``transport`` parameter accepts any ``httpx.BaseTransport`` for + proxy / TLS / test injection -- ``httpx.MockTransport`` is the + canonical unit-test path. + """ + + _ENV_API_KEY = "XAGENT_PERSONAL_KEY" + _API_KEY_FIELD = "personal_key" + + def __init__( + self, + personal_key: str | None = None, + base_url: str | None = None, + *, + timeout: float = 30.0, + max_connections: int = 10, + user_agent: str | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + super().__init__( + api_key=personal_key, + base_url=base_url, + timeout=timeout, + max_connections=max_connections, + user_agent=user_agent, + transport=transport, + ) + self.templates = TemplatesAPI(self) + self.agents = AgentsAPI(self) + + def me(self) -> UserPrincipal: + """``GET /v1/me`` -- identity probe for the personal key. + + Zero side-effect. Returns the user principal the personal key + belongs to (``principal_type`` / ``user_id`` / ``email`` / + ``name`` / ``key_prefix``). Use once at startup to log which + user is connected; cache the result locally if you only need it + once -- the SDK does not cache so a revoked key surfaces as + ``InvalidAPIKey`` immediately rather than silently using a + stale principal. + """ + resp = self._request("GET", "/v1/me") + return _parse_user_principal(resp.json()) From c4308a9271455a301d42ed34b1b185ea941cc683 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Fri, 29 May 2026 19:26:00 +0800 Subject: [PATCH 05/16] test: shared fixtures and unit tests for UserClient + templates + agents Cover the 0.2.0 management surface with hermetic unit tests and the canonical wire fixtures other language clients will reuse. shared/fixtures/v1/responses/: - DELETE the 0.1.0 me.json (agent identity shape) and replace with me_user.json (UserPrincipal shape: principal_type / user_id / email / name / key_prefix). - NEW templates_list.json (slim wrapper {templates: [...]} covering the four Phase 2 default templates: Content Generator, Analyzer, Q&A, Assistant). - NEW templates_detail.json (full agent_config dict, the merge target for create_from_template overrides). - NEW agents_list.json (covers all three documented status values: active / draft / paused). - NEW agents_create.json (default generate_runtime_key=True path carrying the one-time runtime_full_key + the public runtime_key_prefix). - NEW rotate_key.json (rotation result with one-time full_key, public key_prefix, and timestamp). shared/fixtures/v1/errors/: - NEW template_not_found.json (V1 envelope; new stable code in this release). shared/README.md: extend both fixture tables and call out which fixture each method consumes plus the 0.2.0+ marker on the new entries. python/tests/unit/: - test_user_client.py: construction (explicit / XAGENT_PERSONAL_KEY env fallback / explicit-overrides-env / missing key / missing base_url), invariant that UserClient does NOT silently fall back to XAGENT_API_KEY (that env belongs to AgentClient), me() returns UserPrincipal with the canonical fixture values, 401 -> InvalidAPIKey, and context-manager close. - test_templates.py: list() URL + parse + empty list + defensive non-dict body; get() URL + parse + 404 -> TemplateNotFound. - test_agents_management.py: list() URL + parse + empty list; create() default-body shape (generate_runtime_key=True wire), generate_runtime_key=False pass-through, metadata included / omitted on None, 422 -> InvalidInput; create_from_template body shape with overrides, overrides omitted when None, template_not_found mapping; rotate_key() URL + parse, 404 -> AgentNotFound. - test_clients_isolated.py: two clients in the same process produce distinct Authorization headers, distinct httpx.Client instances, and closing one does not close the other. - test_errors.py: extend the stable-code parametrize with (404, "template_not_found", TemplateNotFound) so the V1 envelope mapping is fixture-driven for the new code too. python/src/xagent_sdk/__init__.py: re-export the seven new public symbols (UserClient, UserPrincipal, Template, TemplateDetail, AgentSummary, AgentCreateResult, RotateKeyResult, TemplateNotFound) so the new tests can `from xagent_sdk import ...`. Final __all__ ordering and the test_public_surface assertion land in Phase F alongside the version bump. 111 unit tests pass. --- python/src/xagent_sdk/__init__.py | 16 ++ python/tests/unit/test_agents_management.py | 218 ++++++++++++++++++ python/tests/unit/test_clients_isolated.py | 57 +++++ python/tests/unit/test_errors.py | 2 + python/tests/unit/test_templates.py | 90 ++++++++ python/tests/unit/test_user_client.py | 107 +++++++++ shared/README.md | 8 +- .../v1/errors/template_not_found.json | 6 + .../fixtures/v1/responses/agents_create.json | 6 + shared/fixtures/v1/responses/agents_list.json | 7 + shared/fixtures/v1/responses/me.json | 5 - shared/fixtures/v1/responses/me_user.json | 7 + shared/fixtures/v1/responses/rotate_key.json | 5 + .../v1/responses/templates_detail.json | 10 + .../fixtures/v1/responses/templates_list.json | 24 ++ 15 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 python/tests/unit/test_agents_management.py create mode 100644 python/tests/unit/test_clients_isolated.py create mode 100644 python/tests/unit/test_templates.py create mode 100644 python/tests/unit/test_user_client.py create mode 100644 shared/fixtures/v1/errors/template_not_found.json create mode 100644 shared/fixtures/v1/responses/agents_create.json create mode 100644 shared/fixtures/v1/responses/agents_list.json delete mode 100644 shared/fixtures/v1/responses/me.json create mode 100644 shared/fixtures/v1/responses/me_user.json create mode 100644 shared/fixtures/v1/responses/rotate_key.json create mode 100644 shared/fixtures/v1/responses/templates_detail.json create mode 100644 shared/fixtures/v1/responses/templates_list.json diff --git a/python/src/xagent_sdk/__init__.py b/python/src/xagent_sdk/__init__.py index 8fc523c..98663f4 100644 --- a/python/src/xagent_sdk/__init__.py +++ b/python/src/xagent_sdk/__init__.py @@ -9,28 +9,39 @@ TaskBusy, TaskNotFound, TaskTimeout, + TemplateNotFound, XAgentError, XAgentTransportError, ) from xagent_sdk.types import ( + AgentCreateResult, + AgentSummary, AppendResult, CreateTaskResult, + RotateKeyResult, RunResult, Step, StepType, TaskInfo, TaskStatus, + Template, + TemplateDetail, + UserPrincipal, ) +from xagent_sdk.user_client import UserClient __all__ = [ "AgentClient", + "AgentCreateResult", "AgentNotFound", + "AgentSummary", "AppendResult", "CreateTaskResult", "InternalError", "InvalidAPIKey", "InvalidInput", "RateLimited", + "RotateKeyResult", "RunResult", "Step", "StepType", @@ -39,6 +50,11 @@ "TaskNotFound", "TaskStatus", "TaskTimeout", + "Template", + "TemplateDetail", + "TemplateNotFound", + "UserClient", + "UserPrincipal", "XAgentError", "XAgentTransportError", "__version__", diff --git a/python/tests/unit/test_agents_management.py b/python/tests/unit/test_agents_management.py new file mode 100644 index 0000000..0f60714 --- /dev/null +++ b/python/tests/unit/test_agents_management.py @@ -0,0 +1,218 @@ +"""Tests for UserClient.agents (AgentsAPI).""" + +import json + +import httpx +import pytest + +from xagent_sdk import ( + AgentCreateResult, + AgentNotFound, + AgentSummary, + InvalidInput, + RotateKeyResult, + TemplateNotFound, + UserClient, +) + +from ._fixtures import error_envelope, response + + +def _make_user(handler: object) -> UserClient: + return UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(handler), # type: ignore[arg-type] + ) + + +class TestList: + def test_url_and_parse(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("agents_list")) + + with _make_user(handler) as c: + agents = c.agents.list() + + assert captured[0].method == "GET" + assert captured[0].url.path == "/v1/agents" + assert len(agents) == 3 + assert all(isinstance(a, AgentSummary) for a in agents) + # Covers the status values backend exposes. + statuses = {a.status for a in agents} + assert {"active", "draft", "paused"} <= statuses + + def test_empty_list(self) -> None: + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"agents": []}) + + with _make_user(h) as c: + assert c.agents.list() == [] + + +class TestCreate: + def test_body_shape_default_generate_key(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + result = c.agents.create( + name="HR Leave Assistant", + instructions="You are an HR assistant. ...", + ) + + assert captured[0].method == "POST" + assert captured[0].url.path == "/v1/agents" + body = json.loads(captured[0].content) + # generate_runtime_key default is True; must appear in body. + assert body == { + "name": "HR Leave Assistant", + "instructions": "You are an HR assistant. ...", + "generate_runtime_key": True, + } + assert isinstance(result, AgentCreateResult) + assert result.agent_id == 42 + # one-time runtime key carried back + assert result.runtime_full_key is not None + assert result.runtime_full_key.startswith("xag_") + assert result.runtime_key_prefix == "abc123" + + def test_generate_runtime_key_false_passes_through(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response( + 201, + json={ + "agent_id": 42, + "name": "HR Leave Assistant", + "runtime_full_key": None, + "runtime_key_prefix": None, + }, + ) + + with _make_user(handler) as c: + result = c.agents.create( + name="HR Leave Assistant", + instructions="...", + generate_runtime_key=False, + ) + + body = json.loads(captured[0].content) + assert body["generate_runtime_key"] is False + assert result.runtime_full_key is None + assert result.runtime_key_prefix is None + + def test_metadata_included_when_given(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create( + name="X", + instructions="...", + metadata={"trace_id": "abc"}, + ) + + body = json.loads(captured[0].content) + assert body["metadata"] == {"trace_id": "abc"} + + def test_no_metadata_field_when_none(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create(name="X", instructions="...") + + body = json.loads(captured[0].content) + assert "metadata" not in body + + def test_422_invalid_input(self) -> None: + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(422, json=error_envelope("validation_422")) + + with _make_user(h) as c, pytest.raises(InvalidInput): + c.agents.create(name="", instructions="") + + +class TestCreateFromTemplate: + def test_body_shape(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create_from_template( + "q_and_a", + overrides={"name": "HR Bot"}, + ) + + assert captured[0].method == "POST" + assert captured[0].url.path == "/v1/agents/from-template" + body = json.loads(captured[0].content) + assert body == { + "template_id": "q_and_a", + "generate_runtime_key": True, + "overrides": {"name": "HR Bot"}, + } + + def test_no_overrides_field_when_none(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create_from_template("q_and_a") + + body = json.loads(captured[0].content) + assert "overrides" not in body + + def test_template_not_found(self) -> None: + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("template_not_found")) + + with _make_user(h) as c, pytest.raises(TemplateNotFound): + c.agents.create_from_template("nope") + + +class TestRotateKey: + def test_url_and_parse(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("rotate_key")) + + with _make_user(handler) as c: + result = c.agents.rotate_key(42) + + assert captured[0].method == "POST" + assert captured[0].url.path == "/v1/agents/42/api-key" + assert isinstance(result, RotateKeyResult) + assert result.full_key.startswith("xag_") + assert result.key_prefix == "newabc" + assert result.created_at.year == 2026 + + def test_404_agent_not_found(self) -> None: + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("agent_not_found")) + + with _make_user(h) as c, pytest.raises(AgentNotFound): + c.agents.rotate_key(99999) diff --git a/python/tests/unit/test_clients_isolated.py b/python/tests/unit/test_clients_isolated.py new file mode 100644 index 0000000..81b6f4f --- /dev/null +++ b/python/tests/unit/test_clients_isolated.py @@ -0,0 +1,57 @@ +"""Invariant: AgentClient and UserClient instances do not share state. + +Each public client owns its own httpx.Client (default headers, connection +pool). Constructing both in the same process must produce two distinct +``Authorization`` headers; one client's revoke / rotate must not affect +the other. This module pins the property mechanically so a future +refactor cannot silently share an httpx instance across clients. +""" + +import httpx + +from xagent_sdk import AgentClient, UserClient + + +def _ok(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"ok": True}) + + +def test_two_clients_default_headers_isolated() -> None: + u = UserClient( + personal_key="xag_personal_p_s", + base_url="https://x", + transport=httpx.MockTransport(_ok), + ) + a = AgentClient( + api_key="xag_p_s", + base_url="https://x", + transport=httpx.MockTransport(_ok), + ) + try: + u_auth = u._http._client.headers["Authorization"] + a_auth = a._http._client.headers["Authorization"] + assert u_auth == "Bearer xag_personal_p_s" + assert a_auth == "Bearer xag_p_s" + assert u_auth != a_auth + # Two distinct httpx.Client instances (not aliasing) + assert u._http._client is not a._http._client + finally: + u.close() + a.close() + + +def test_close_one_does_not_close_other() -> None: + u = UserClient( + personal_key="xag_personal_p_s", + base_url="https://x", + transport=httpx.MockTransport(_ok), + ) + a = AgentClient( + api_key="xag_p_s", + base_url="https://x", + transport=httpx.MockTransport(_ok), + ) + u.close() + assert u._http._client.is_closed is True + assert a._http._client.is_closed is False + a.close() diff --git a/python/tests/unit/test_errors.py b/python/tests/unit/test_errors.py index dbd3b9d..a93b9c9 100644 --- a/python/tests/unit/test_errors.py +++ b/python/tests/unit/test_errors.py @@ -18,6 +18,7 @@ RateLimited, TaskBusy, TaskNotFound, + TemplateNotFound, XAgentError, ) from xagent_sdk.errors import from_response @@ -50,6 +51,7 @@ class TestFromResponseStableCodes: (401, "invalid_api_key", InvalidAPIKey), (404, "agent_not_found", AgentNotFound), (404, "task_not_found", TaskNotFound), + (404, "template_not_found", TemplateNotFound), (409, "task_busy", TaskBusy), (422, "validation_422", InvalidInput), (429, "rate_limited", RateLimited), diff --git a/python/tests/unit/test_templates.py b/python/tests/unit/test_templates.py new file mode 100644 index 0000000..a123ca4 --- /dev/null +++ b/python/tests/unit/test_templates.py @@ -0,0 +1,90 @@ +"""Tests for UserClient.templates (TemplatesAPI).""" + +import httpx +import pytest + +from xagent_sdk import Template, TemplateDetail, TemplateNotFound, UserClient + +from ._fixtures import error_envelope, response + + +def _list_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=response("templates_list")) + + +def _detail_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=response("templates_detail")) + + +def _404_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("template_not_found")) + + +def _make_user(handler: object) -> UserClient: + return UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(handler), # type: ignore[arg-type] + ) + + +class TestList: + def test_url(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("templates_list")) + + with _make_user(handler) as c: + templates = c.templates.list() + + assert captured[0].method == "GET" + assert captured[0].url.path == "/v1/templates" + assert len(templates) == 4 + assert all(isinstance(t, Template) for t in templates) + # Spot-check a couple + ids = [t.template_id for t in templates] + assert "content_generator" in ids + assert "q_and_a" in ids + + def test_empty_list(self) -> None: + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"templates": []}) + + with _make_user(h) as c: + assert c.templates.list() == [] + + def test_non_dict_body_defensive(self) -> None: + # Mirrors _parse_steps: malformed body returns [] rather than + # raising AttributeError. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[]) + + with _make_user(h) as c: + assert c.templates.list() == [] + + +class TestGet: + def test_url_and_parse(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("templates_detail")) + + with _make_user(handler) as c: + detail = c.templates.get("q_and_a") + + assert captured[0].method == "GET" + assert captured[0].url.path == "/v1/templates/q_and_a" + assert isinstance(detail, TemplateDetail) + assert detail.template_id == "q_and_a" + assert detail.name == "Q&A" + # agent_config is the merge target for create_from_template overrides + assert "instructions" in detail.agent_config + assert detail.agent_config["mode"] == "balanced" + + def test_404_template_not_found(self) -> None: + with _make_user(_404_handler) as c, pytest.raises(TemplateNotFound): + c.templates.get("not_a_real_template") diff --git a/python/tests/unit/test_user_client.py b/python/tests/unit/test_user_client.py new file mode 100644 index 0000000..8d5f9fd --- /dev/null +++ b/python/tests/unit/test_user_client.py @@ -0,0 +1,107 @@ +"""Tests for UserClient construction, env-var fallback, and the me() probe.""" + +import httpx +import pytest + +from xagent_sdk import InvalidAPIKey, UserClient, UserPrincipal + +from ._fixtures import error_envelope, response + + +def _me_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=response("me_user")) + + +def _401_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(401, json=error_envelope("invalid_api_key")) + + +class TestConstruction: + def test_explicit(self) -> None: + with UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_me_handler), + ) as c: + assert c._http._client.headers["Authorization"] == "Bearer xag_personal_p_s" + + def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_PERSONAL_KEY", "xag_personal_envkey_envsec") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = UserClient(transport=httpx.MockTransport(_me_handler)) + assert ( + c._http._client.headers["Authorization"] + == "Bearer xag_personal_envkey_envsec" + ) + c.close() + + def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_PERSONAL_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = UserClient( + personal_key="explicit", + base_url="https://explicit", + transport=httpx.MockTransport(_me_handler), + ) + assert c._http._client.headers["Authorization"] == "Bearer explicit" + c.close() + + def test_missing_personal_key(self) -> None: + # The error message should name personal_key specifically so the + # caller knows which env var / kwarg to set. + with pytest.raises(ValueError, match="personal_key"): + UserClient(base_url="https://x") + + def test_missing_base_url(self) -> None: + with pytest.raises(ValueError, match="base_url"): + UserClient(personal_key="xag_personal_p_s") + + def test_does_not_read_xagent_api_key( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # XAGENT_API_KEY belongs to AgentClient; UserClient must not pick + # it up by accident. Only XAGENT_PERSONAL_KEY counts. + monkeypatch.setenv("XAGENT_API_KEY", "runtime-key-not-personal") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="personal_key"): + UserClient() + + +class TestMe: + def test_returns_user_principal(self) -> None: + with UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_me_handler), + ) as c: + me = c.me() + assert isinstance(me, UserPrincipal) + assert me.principal_type == "user" + assert me.user_id == 123 + assert me.email == "user@example.com" + assert me.name == "Alex" + assert me.key_prefix == "abc123" + + def test_401_raises_invalid_api_key(self) -> None: + with ( + UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_401_handler), + ) as c, + pytest.raises(InvalidAPIKey), + ): + c.me() + + +class TestLifecycle: + def test_context_manager_closes(self) -> None: + c = UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_me_handler), + ) + assert c._http._client.is_closed is False + with c: + pass + assert c._http._client.is_closed is True diff --git a/shared/README.md b/shared/README.md index 0adaed1..14de357 100644 --- a/shared/README.md +++ b/shared/README.md @@ -16,7 +16,12 @@ implicit and documented below. | File | Endpoint | Notes | |---|---|---| -| `me.json` | `GET /v1/me` | Identity probe success body | +| `me_user.json` | `GET /v1/me` | 0.2.0+ user principal: `principal_type / user_id / email / name / key_prefix` (replaces the 0.1.0 `me.json` agent shape) | +| `templates_list.json` | `GET /v1/templates` | Wrapper `{templates: [Template]}`; slim list entries with `template_id`, `name`, optional `description` | +| `templates_detail.json` | `GET /v1/templates/{id}` | Single template with the merge-target `agent_config` dict | +| `agents_list.json` | `GET /v1/agents` | Wrapper `{agents: [AgentSummary]}`; covers `active`, `draft`, `paused` status values | +| `agents_create.json` | `POST /v1/agents` or `POST /v1/agents/from-template` | Default response with `generate_runtime_key=True`; carries the one-time `runtime_full_key` | +| `rotate_key.json` | `POST /v1/agents/{id}/api-key` | Rotation result with one-time `full_key` and public-safe `key_prefix` | | `create_task.json` | `POST /v1/chat/tasks` (202) | Initial `status=pending` | | `append_task.json` | `POST /v1/chat/tasks/{id}/messages` (202) | `status=running`, carries `accepted_at` (not `created_at`) | | `task_info_completed.json` | `GET /v1/chat/tasks/{id}` (200) | Terminal state, `output` populated | @@ -33,6 +38,7 @@ Seven stable backend codes using the V1 envelope shape: | `agent_not_found.json` | 404 | V1 envelope | | `task_not_found.json` | 404 | V1 envelope | | `task_busy.json` | 409 | V1 envelope | +| `template_not_found.json` | 404 | V1 envelope (0.2.0+; raised by `UserClient.templates.get()` and `UserClient.agents.create_from_template()` on unknown `template_id`) | | `validation_422.json` | 422 | V1 envelope (`invalid_input`) | | `rate_limited.json` | 429 | V1 envelope (reserved; backend does not currently emit it) | | `internal_error.json` | 500 | V1 envelope | diff --git a/shared/fixtures/v1/errors/template_not_found.json b/shared/fixtures/v1/errors/template_not_found.json new file mode 100644 index 0000000..c31bffc --- /dev/null +++ b/shared/fixtures/v1/errors/template_not_found.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": "template_not_found", + "message": "Template not found or revoked." + } +} diff --git a/shared/fixtures/v1/responses/agents_create.json b/shared/fixtures/v1/responses/agents_create.json new file mode 100644 index 0000000..1e46c1a --- /dev/null +++ b/shared/fixtures/v1/responses/agents_create.json @@ -0,0 +1,6 @@ +{ + "agent_id": 42, + "name": "HR Leave Assistant", + "runtime_full_key": "xag_abc123_secretsecretsecretsecretsecret", + "runtime_key_prefix": "abc123" +} diff --git a/shared/fixtures/v1/responses/agents_list.json b/shared/fixtures/v1/responses/agents_list.json new file mode 100644 index 0000000..6c9ee29 --- /dev/null +++ b/shared/fixtures/v1/responses/agents_list.json @@ -0,0 +1,7 @@ +{ + "agents": [ + {"agent_id": 42, "name": "HR Leave Assistant", "status": "active"}, + {"agent_id": 43, "name": "Policy Bot", "status": "draft"}, + {"agent_id": 44, "name": "CV Screener", "status": "paused"} + ] +} diff --git a/shared/fixtures/v1/responses/me.json b/shared/fixtures/v1/responses/me.json deleted file mode 100644 index 4002dfc..0000000 --- a/shared/fixtures/v1/responses/me.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agent_id": 12, - "agent_name": "sdk-flash", - "key_prefix": "h388lh" -} diff --git a/shared/fixtures/v1/responses/me_user.json b/shared/fixtures/v1/responses/me_user.json new file mode 100644 index 0000000..b7474ea --- /dev/null +++ b/shared/fixtures/v1/responses/me_user.json @@ -0,0 +1,7 @@ +{ + "principal_type": "user", + "user_id": 123, + "email": "user@example.com", + "name": "Alex", + "key_prefix": "abc123" +} diff --git a/shared/fixtures/v1/responses/rotate_key.json b/shared/fixtures/v1/responses/rotate_key.json new file mode 100644 index 0000000..fbcbc0e --- /dev/null +++ b/shared/fixtures/v1/responses/rotate_key.json @@ -0,0 +1,5 @@ +{ + "full_key": "xag_newabc_freshsecretfreshsecretfresh", + "key_prefix": "newabc", + "created_at": "2026-05-29T10:00:00Z" +} diff --git a/shared/fixtures/v1/responses/templates_detail.json b/shared/fixtures/v1/responses/templates_detail.json new file mode 100644 index 0000000..27bb952 --- /dev/null +++ b/shared/fixtures/v1/responses/templates_detail.json @@ -0,0 +1,10 @@ +{ + "template_id": "q_and_a", + "name": "Q&A", + "description": "Conversational chat agent grounded in a knowledge base.", + "agent_config": { + "instructions": "You are a helpful Q&A assistant. Answer concisely and cite sources when possible.", + "mode": "balanced", + "tools_default": [] + } +} diff --git a/shared/fixtures/v1/responses/templates_list.json b/shared/fixtures/v1/responses/templates_list.json new file mode 100644 index 0000000..0474f29 --- /dev/null +++ b/shared/fixtures/v1/responses/templates_list.json @@ -0,0 +1,24 @@ +{ + "templates": [ + { + "template_id": "content_generator", + "name": "Content Generator", + "description": "Generate text, documents, contracts, and policies from a brief." + }, + { + "template_id": "analyzer", + "name": "Analyzer", + "description": "Accepts uploaded files and returns structured insights, scores, or summaries." + }, + { + "template_id": "q_and_a", + "name": "Q&A", + "description": "Conversational chat agent grounded in a knowledge base." + }, + { + "template_id": "assistant", + "name": "Assistant", + "description": "Persistent assistant that can take actions based on rules." + } + ] +} From 2ddbe1133072f3acb627e366a4c294c11272849f Mon Sep 17 00:00:00 2001 From: Alexliu Date: Fri, 29 May 2026 19:30:45 +0800 Subject: [PATCH 06/16] chore: bump SDK to 0.2.0 and pin the public surface Mark the breaking release with the version bump and add two mechanical pins so the 0.1.0 -> 0.2.0 rename cannot silently regress. Version files: - python/src/xagent_sdk/_version.py: __version__ = "0.2.0". - python/pyproject.toml: project version = "0.2.0". The version also surfaces in the User-Agent header (``xagent-sdk-python/0.2.0 (httpx/...)``) so the backend can correlate traffic to release. python/tests/unit/test_public_surface.py: - EXPECTED_SURFACE: the canonical 27-name 0.2.0 __all__ set. - test_all_matches_expected: equality (not subset) check so future additions must justify themselves explicitly. - test_every_exported_name_resolves: every name in __all__ is also importable on the package (catches forgotten imports). - test_xagent_client_legacy_name_removed: hasattr + __all__ check for the renamed runtime client. - test_meresponse_legacy_name_removed: same for the replaced response dataclass. - test_version_bumped_to_0_2_0: pin the announced version string. python/tests/unit/test_check_no_legacy_callsites.py: - Three subprocess greps that scan python/src + python/tests for the legacy runtime-client name, MeResponse, and the _parse_me helper. Excludes this test file plus test_public_surface (the only places that intentionally reference the legacy patterns by design via string concatenation). Documentation files are out of scope here -- python/README.md ships its full rewrite in the next commit. Source-side cleanup pulled into this commit so the greps land green: - _base.py and _http.py docstrings referenced XAgentClient by name in narrative text that did not get updated when Phase B renamed the class. Rewritten to mention AgentClient / UserClient. - types.py::UserPrincipal docstring used to point readers at the legacy MeResponse class name; reworded to "the 0.1.0 agent-identity shape" so the grep test does not need to special-case it. 119 unit tests pass. --- python/pyproject.toml | 2 +- python/src/xagent_sdk/_base.py | 4 +- python/src/xagent_sdk/_http.py | 5 +- python/src/xagent_sdk/_version.py | 2 +- python/src/xagent_sdk/types.py | 2 +- .../unit/test_check_no_legacy_callsites.py | 74 ++++++++++++++++ python/tests/unit/test_public_surface.py | 88 +++++++++++++++++++ 7 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 python/tests/unit/test_check_no_legacy_callsites.py create mode 100644 python/tests/unit/test_public_surface.py diff --git a/python/pyproject.toml b/python/pyproject.toml index da3fb9d..7ae2aa0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "xagent-sdk" -version = "0.1.0" +version = "0.2.0" description = "Python client SDK for xAgent" readme = "README.md" requires-python = ">=3.11" diff --git a/python/src/xagent_sdk/_base.py b/python/src/xagent_sdk/_base.py index 899c8c3..7c091e8 100644 --- a/python/src/xagent_sdk/_base.py +++ b/python/src/xagent_sdk/_base.py @@ -1,7 +1,7 @@ """Internal base class shared by every public SDK client. -The two public clients (``XAgentClient`` / ``AgentClient`` for runtime, and -the upcoming ``UserClient`` for management) only differ in which env var +The two public clients (``AgentClient`` for runtime chat tasks and +``UserClient`` for management endpoints) only differ in which env var provides the API key fallback and which surface methods they expose. Everything else -- env resolution, ``HTTPClient`` ownership, the 4xx/5xx-to-exception mapping in ``_request``, ``close()``, and the diff --git a/python/src/xagent_sdk/_http.py b/python/src/xagent_sdk/_http.py index e57ec72..7ea967c 100644 --- a/python/src/xagent_sdk/_http.py +++ b/python/src/xagent_sdk/_http.py @@ -19,8 +19,9 @@ class HTTPClient: """Thin httpx.Client wrapper for the xAgent v1 API. Owns a single httpx.Client (connection pool) for the lifetime of the - enclosing XAgentClient. Returns raw httpx.Response objects; - HTTP-status-to-exception mapping is layered on top in a later commit. + enclosing public client (AgentClient or UserClient). Returns raw + httpx.Response objects; HTTP-status-to-exception mapping is layered + on top in ``_BaseClient._request``. """ def __init__( diff --git a/python/src/xagent_sdk/_version.py b/python/src/xagent_sdk/_version.py index 3dc1f76..d3ec452 100644 --- a/python/src/xagent_sdk/_version.py +++ b/python/src/xagent_sdk/_version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/python/src/xagent_sdk/types.py b/python/src/xagent_sdk/types.py index 2394c73..8b25a54 100644 --- a/python/src/xagent_sdk/types.py +++ b/python/src/xagent_sdk/types.py @@ -45,7 +45,7 @@ class UserPrincipal: """``GET /v1/me`` payload (0.2.0+) -- user identity bound to the presented personal key. - Replaces the 0.1.0 ``MeResponse`` shape (``agent_id`` / ``agent_name`` / + Replaces the 0.1.0 agent-identity shape (``agent_id`` / ``agent_name`` / ``key_prefix``) since ``/v1/me`` is now a personal-key endpoint that returns the **user** the key belongs to. To look up which agent a runtime key corresponds to in 0.2.0, list agents via diff --git a/python/tests/unit/test_check_no_legacy_callsites.py b/python/tests/unit/test_check_no_legacy_callsites.py new file mode 100644 index 0000000..d18e296 --- /dev/null +++ b/python/tests/unit/test_check_no_legacy_callsites.py @@ -0,0 +1,74 @@ +"""Repo-wide grep bottom: 0.1.0 names must be gone from src/ and tests/. + +Per CLAUDE.md "delete deprecated -> grep src + tests" rule, we keep a +mechanical assertion that the renamed / removed 0.1.0 symbols (the +legacy runtime-client class, the legacy ``/v1/me`` response dataclass, +and its private parser) do not silently linger in the SDK source or +test suite. + +The grep excludes ``test_check_no_legacy_callsites.py`` (this file -- +it references the legacy names in patterns by design) and +``test_public_surface.py`` (which asserts the rename mechanically via +attribute checks). Documentation files (``python/README.md``, +``shared/README.md``) are not in scope here; they evolve under Phase +G review. + +Runs as a regular unit test so it shows up under ``pytest`` and CI +without any extra hook plumbing. +""" + +import subprocess +from pathlib import Path + + +def _repo_root() -> Path: + # tests/unit/test_check_no_legacy_callsites.py -> parents[3] == repo root + return Path(__file__).resolve().parents[3] + + +def _grep(pattern: str) -> subprocess.CompletedProcess[str]: + repo = _repo_root() + return subprocess.run( + [ + "grep", + "-rn", + "--include=*.py", + "--exclude=test_check_no_legacy_callsites.py", + "--exclude=test_public_surface.py", + pattern, + str(repo / "python" / "src"), + str(repo / "python" / "tests"), + ], + capture_output=True, + text=True, + check=False, + ) + + +def test_no_legacy_runtime_client_name_in_src_or_tests() -> None: + # Joined to avoid this module itself containing the literal string. + pattern = "XAgent" + "Client" + result = _grep(pattern) + assert result.returncode == 1, ( + "Legacy runtime-client name found in source/tests; " + "0.2.0 renamed it to AgentClient.\n" + result.stdout + ) + + +def test_no_legacy_me_response_in_src_or_tests() -> None: + pattern = "Me" + "Response" + result = _grep(pattern) + assert result.returncode == 1, ( + "Legacy MeResponse references found in source/tests; " + "0.2.0 replaced it with UserPrincipal.\n" + result.stdout + ) + + +def test_no_legacy_parse_me_helper_in_src_or_tests() -> None: + # _parse_me was the 0.1.0 helper; replaced by _parse_user_principal. + pattern = r"_parse_me\b" + result = _grep(pattern) + assert result.returncode == 1, ( + "Legacy `_parse_me` helper references found in source/tests; " + "0.2.0 replaced it with `_parse_user_principal`.\n" + result.stdout + ) diff --git a/python/tests/unit/test_public_surface.py b/python/tests/unit/test_public_surface.py new file mode 100644 index 0000000..839ff05 --- /dev/null +++ b/python/tests/unit/test_public_surface.py @@ -0,0 +1,88 @@ +"""Mechanical pin for the SDK 0.2.0 public surface. + +If a future PR silently widens (or narrows) ``xagent_sdk.__all__`` the +test below fails and the diff has to either justify the surface change +or roll it back. The legacy-name checks (``XAgentClient`` / +``MeResponse``) verify the 0.1.0 -> 0.2.0 breaking rename so a +re-export sneaking back in trips CI. +""" + +import xagent_sdk + +# The full public 0.2.0 surface. Update this set deliberately when +# adding new exports; do not loosen the assertion to a subset check. +EXPECTED_SURFACE: set[str] = { + # Clients + "AgentClient", + "UserClient", + # User principal + management dataclasses + "UserPrincipal", + "Template", + "TemplateDetail", + "AgentSummary", + "AgentCreateResult", + "RotateKeyResult", + # Runtime dataclasses (unchanged from 0.1.0) + "CreateTaskResult", + "AppendResult", + "TaskInfo", + "Step", + "RunResult", + # Enums + "TaskStatus", + "StepType", + # Exception hierarchy + "XAgentError", + "InvalidAPIKey", + "AgentNotFound", + "TaskNotFound", + "TaskBusy", + "RateLimited", + "InternalError", + "InvalidInput", + "TemplateNotFound", + "XAgentTransportError", + "TaskTimeout", + # Version + "__version__", +} + + +def test_all_matches_expected() -> None: + actual = set(xagent_sdk.__all__) + extra = actual - EXPECTED_SURFACE + missing = EXPECTED_SURFACE - actual + assert not extra, f"extra symbols in __all__: {sorted(extra)}" + assert not missing, f"missing symbols from __all__: {sorted(missing)}" + + +def test_every_exported_name_resolves() -> None: + # __all__ is just a tuple of strings; ensure each name is actually + # importable from the package (catches typos / forgotten imports). + for name in xagent_sdk.__all__: + assert hasattr(xagent_sdk, name), ( + f"{name!r} listed in __all__ but not present on the package" + ) + + +def test_xagent_client_legacy_name_removed() -> None: + assert not hasattr(xagent_sdk, "XAgentClient"), ( + "0.2.0 renamed XAgentClient -> AgentClient; the legacy name must " + "not resolve via the public package" + ) + assert "XAgentClient" not in xagent_sdk.__all__ + + +def test_meresponse_legacy_name_removed() -> None: + assert not hasattr(xagent_sdk, "MeResponse"), ( + "0.2.0 replaced MeResponse with UserPrincipal; the legacy name " + "must not resolve via the public package" + ) + assert "MeResponse" not in xagent_sdk.__all__ + + +def test_version_bumped_to_0_2_0() -> None: + # The release-strategy pin: 0.2.0 is a breaking release and the + # version string the SDK announces (also in the User-Agent header) + # must reflect that. + assert xagent_sdk.__version__ == "0.2.0" From bdfcf46789f92ee0ac5db13ab1dd519eab5ad265 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Fri, 29 May 2026 19:37:10 +0800 Subject: [PATCH 07/16] docs(readme): two-client flow, migration guide, and e2e harness Land the 0.2.0 user-facing documentation and the e2e fixture split. This is the last commit of the breaking-release series. python/README.md (full rewrite): - Status banner calls out the breaking change vs 0.1.0 and links forward to the migration section. - Install command pinned to @v0.2.0 with #subdirectory=python. - Two distinct env vars documented (XAGENT_PERSONAL_KEY for UserClient, XAGENT_API_KEY for AgentClient) with the rationale for keeping them separate. - New "Migration from 0.1.0" section: which symbols moved, what the user-side sed pass looks like, and the fact that legacy imports fail loudly at startup rather than silently at first use. - Quick start is a two-step happy path: UserClient.agents.create_from_template to mint an agent + runtime key, then AgentClient(api_key=runtime_key) to run it. Vault-warning on the one-time runtime_full_key. - Concepts section maps the 0.2.0 vocabulary (User / Personal key / Agent / Runtime key / Template / Task / Step). - Six examples cover identity probe, template-driven create, agent list, runtime-key rotation, multi-turn append, and error handling. - API reference split into a UserClient table and an AgentClient table with their respective endpoint paths and notes. - Configuration shows both constructors side by side and reaffirms the cross-client isolation invariant the unit tests pin. - Development section: the e2e command now requires both env vars, E2E_AGENT_ID gates the runtime-only smoke tests, E2E_TEMPLATE_ID / E2E_AGENT_NAME drive the full-flow test. python/tests/e2e/conftest.py (rewrite): - Two paired fixture sets: user_client / patient_user_client (read XAGENT_PERSONAL_KEY) and agent_client / patient_agent_client (read XAGENT_API_KEY). Each pair's "patient_" variant raises the per-request HTTP timeout from 30s to 60s. - pytest.skip() is split between management (needs personal key) and runtime (needs agent key) so a developer with only one of the two halves can still exercise the other. python/tests/e2e/test_smoke.py (rewrite): - test_user_me: 0.2.0 identity probe; asserts UserPrincipal shape (principal_type / user_id / email / name / key_prefix). - test_create_is_async: same async-contract regression catcher as 0.1.0, now bound to patient_agent_client and to E2E_AGENT_ID (since AgentClient lost its identity probe in 0.2.0, the test skips when the env var is missing rather than silently failing). - test_run_single_turn: same gating; runtime-only. - test_e2e_full_flow_phase2: the Phase 2 happy path end-to-end -- list templates, create_from_template, build an AgentClient with the freshly minted runtime key, run a single-turn task, assert COMPLETED. Deliberately leaves the new agent in place since the SDK has no delete method; run against a scratch backend. 119 unit tests pass, 4 e2e tests deselected by default. --- python/README.md | 348 ++++++++++++++++++++++----------- python/tests/e2e/conftest.py | 76 +++++-- python/tests/e2e/test_smoke.py | 109 +++++++++-- 3 files changed, 384 insertions(+), 149 deletions(-) diff --git a/python/README.md b/python/README.md index c1e52fe..03fb553 100644 --- a/python/README.md +++ b/python/README.md @@ -1,19 +1,21 @@ # xagent-sdk-python Python client SDK for the [xAgent](https://github.com/xorbitsai/xagent) -HTTP v1 API — let a SaaS app trigger and observe xAgent agents in a -few lines. +HTTP v1 API. Lets a SaaS app authenticate as a user, mint AI agents +from templates, and trigger them — all in a handful of lines. -> **Status**: 0.1.0 — early access. The v1 backend contract is frozen -> at xagent PR #384, but the SDK surface may evolve through the 0.x -> series. Pin to a tag (see [Version policy](#version-policy)). +> **Status**: 0.2.0 — early access. **Breaking change vs 0.1.0**: the +> SDK now exposes two clients (``UserClient`` for management, +> ``AgentClient`` for runtime) instead of a single class, and `/v1/me` +> now returns a user principal instead of an agent identity. See +> [Migration from 0.1.0](#migration-from-010) below. ## Install Pin to a release tag — do **not** install from `main`: ```bash -pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.1.0#subdirectory=python" +pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.2.0#subdirectory=python" ``` The Python client lives under [`python/`](.) in the @@ -23,171 +25,270 @@ The Python client lives under [`python/`](.) in the Python 3.11+ required. Set credentials via environment (recommended) or pass them to the -constructor: +constructors: ```bash +# personal key — for UserClient (templates, agents, identity) +export XAGENT_PERSONAL_KEY="xag_personal_..." + +# runtime key — for AgentClient (chat tasks against a specific agent) export XAGENT_API_KEY="xag_..." + +# shared base URL export XAGENT_BASE_URL="https://your-xagent.example" ``` +The two env vars are intentionally distinct so you can hold both keys +in the same process without one overriding the other. + +## Migration from 0.1.0 + +0.2.0 is a breaking release. Two changes affect existing 0.1.0 code: + +1. **`XAgentClient` was renamed to `AgentClient`.** The class is + identical otherwise — only the name and import path changed. +2. **`client.me()` no longer exists on `AgentClient`.** Identity moved + to `UserClient.me()` and the response shape changed too + (`UserPrincipal` with `user_id` / `email` / `name` / + `principal_type` / `key_prefix`, replacing `MeResponse` with + `agent_id` / `agent_name`). Listing your agents now goes through + `UserClient.agents.list()` instead. + +Minimal 0.1.0 → 0.2.0 sed pass (assuming your code already had +``XAGENT_API_KEY`` set): + +```bash +# Rename the runtime client wherever it appears. +sed -i '' 's/XAgentClient/AgentClient/g' your_app.py + +# Delete imports of the removed MeResponse class; if you used the +# value, you will need to migrate to UserClient.me() returning +# UserPrincipal (see Example 1). +sed -i '' '/MeResponse/d' your_app.py +``` + +Importing the old names from 0.2.0 raises `ImportError` immediately +(not a runtime ``AttributeError`` halfway through), so missed +callsites are surfaced at startup. + ## Quick start +The Phase 2 happy path is two steps: use a personal key to mint or +look up an agent, then use that agent's runtime key to run tasks +against it. + ```python -from xagent_sdk import XAgentClient +from xagent_sdk import AgentClient, UserClient + +# Step 1: management — pick a template and create an agent. The +# response carries a one-time runtime key. +with UserClient() as user: # reads env vars + new_agent = user.agents.create_from_template( + "q_and_a", + overrides={"name": "HR Leave Assistant"}, + ) + +runtime_key = new_agent.runtime_full_key # store in a vault +agent_id = new_agent.agent_id -with XAgentClient() as client: # reads env vars - result = client.tasks.run( - agent_id=12, - message="What is the capital of France?", +# Step 2: runtime — call the agent. +with AgentClient(api_key=runtime_key) as agent: + result = agent.tasks.run( + agent_id=agent_id, + message="How much sick leave do I have left?", ) print(result.output) - # "The capital of France is Paris." ``` -`tasks.run()` is `create` + `wait` + `steps` bundled with one deadline. -For long-running or multi-turn workflows, use the lower-level methods -directly (see [Multi-turn](#3-multi-turn-conversations) below). +The runtime key is the **only** thing the SDK exposes a copy of; the +backend stores a bcrypt hash and cannot return the secret again. +Persist it to a secret manager before discarding the +`AgentCreateResult`. ## Concepts -- **Agent**: a server-side template (prompt + tools + model config). - Issued one API key, bound 1:1 to the key. -- **Task**: one conversation session. Created with the first user - message; subsequent turns *append* to the same task. Holds the - transcript and final output. +- **User**: a human (identified by personal key) who owns agents + inside their workspace. ``UserClient.me()`` returns this user as a + ``UserPrincipal``. +- **Personal key**: ``xag_personal__`` — the user's + long-lived management credential. Authorizes ``/v1/me``, + ``/v1/templates*``, and ``/v1/agents*`` and is held by + ``UserClient``. +- **Agent**: a server-side template instance (system prompt + tools + + model config). Created via ``UserClient.agents.create()`` or + ``UserClient.agents.create_from_template()``. +- **Agent runtime key**: ``xag__`` — 1:1 with an + agent, authorizes only ``/v1/chat/tasks*``. Returned once by + ``agents.create*`` (when ``generate_runtime_key=True``) or by + ``agents.rotate_key()``. +- **Template**: a server-managed preset (Content Generator, Analyzer, + Q&A, Assistant, ...). Returned by ``UserClient.templates.list()``; + the per-template detail (``TemplateDetail.agent_config``) is the + merge target for ``create_from_template`` overrides. +- **Task**: one conversation session against an agent. Created with + the first user message; subsequent turns *append* to the same task. - **Step**: one entry on the agent's public timeline. Four types — - `message`, `thinking`, `tool_call`, `agent_delegation`. Each step - carries a `data` dict whose keys depend on the type. + ``message``, ``thinking``, ``tool_call``, ``agent_delegation``. ## Examples ### 1. Identity probe -Verify the key is valid and discover which agent it binds to: - ```python -from xagent_sdk import XAgentClient +from xagent_sdk import UserClient -with XAgentClient() as client: - me = client.me() - print(f"agent_id={me.agent_id} name={me.agent_name!r} key={me.key_prefix}") +with UserClient() as user: + me = user.me() + print(f"user_id={me.user_id} email={me.email} name={me.name}") ``` -Each call hits the backend; if you only need the identity once, store -the result. - -### 2. Single-turn with step inspection +Each call hits the backend; cache the value locally if you need it +more than once. -`run()` returns a `RunResult` carrying the final `TaskInfo` plus the -full step timeline. Filter by `StepType` to extract tool calls, -planning steps, etc.: +### 2. Pick a template, mint an agent, run it ```python -from xagent_sdk import StepType, XAgentClient +from xagent_sdk import AgentClient, UserClient + +with UserClient() as user: + templates = user.templates.list() + print([t.template_id for t in templates]) + # ['content_generator', 'analyzer', 'q_and_a', 'assistant'] -with XAgentClient() as client: - result = client.tasks.run( - agent_id=10, - message="Calculate 17 times 23 using a tool. Reply with the number only.", + detail = user.templates.get("q_and_a") + # detail.agent_config is the merge target the backend uses below + + created = user.agents.create_from_template( + "q_and_a", + overrides={"name": "Policy Bot"}, + ) + print(created.agent_id, created.runtime_key_prefix) + +with AgentClient(api_key=created.runtime_full_key) as agent: + result = agent.tasks.run( + agent_id=created.agent_id, + message="Summarize today's PTO policy.", ) print(result.output) - # "391" +``` + +### 3. List existing agents - for step in result.steps: - if step.type is StepType.TOOL_CALL: - print(f"tool={step.data['name']} args={step.data['args']}") - # tool=execute_python_code args={'code': '17 * 23'} +```python +with UserClient() as user: + for agent in user.agents.list(): + print(agent.agent_id, agent.name, agent.status) ``` -### 3. Multi-turn conversations +### 4. Rotate a runtime key -Build a conversation by appending to the same `task_id`. The backend -keeps the transcript; `task.output` reflects the latest assistant turn: +Use this when a runtime key was leaked, when scheduled rotation +fires, or when the SDK consumer has lost the value (the SDK never +caches the secret — only the backend has the hash). ```python -from xagent_sdk import XAgentClient +with UserClient() as user: + rotated = user.agents.rotate_key(agent_id=42) + print("save:", rotated.full_key) + # Old runtime key is now revoked; any AgentClient still using it + # will start raising InvalidAPIKey on its next request. +``` -with XAgentClient() as client: - task = client.tasks.create(agent_id=9, message="Reply with 'first'.") - info = client.tasks.wait(task.task_id) - print(info.output) # 'first' +### 5. Multi-turn task - client.tasks.append(task.task_id, agent_id=9, message="Now reply with 'second'.") - info = client.tasks.wait(task.task_id) - print(info.output) # latest turn +```python +from xagent_sdk import AgentClient + +with AgentClient() as agent: + task = agent.tasks.create( + agent_id=42, message="Reply with 'first'." + ) + info = agent.tasks.wait(task.task_id) + print(info.output) # 'first' - steps = client.tasks.steps(task.task_id) - print(f"{len(steps)} steps across both turns") + agent.tasks.append( + task.task_id, agent_id=42, message="Now reply with 'second'." + ) + info = agent.tasks.wait(task.task_id) + print(info.output) # latest assistant turn ``` -`append()` returns immediately with `status='running'`. Either wait for -it explicitly via `wait()`, or retry on `TaskBusy` if you race: +`append()` returns immediately with `status='running'`. If you race +two appends, the loser gets `TaskBusy` (409); just wait and retry: ```python from xagent_sdk import TaskBusy try: - client.tasks.append(task.task_id, agent_id=9, message="...") + agent.tasks.append(task.task_id, agent_id=42, message="...") except TaskBusy: - client.tasks.wait(task.task_id) - client.tasks.append(task.task_id, agent_id=9, message="...") + agent.tasks.wait(task.task_id) + agent.tasks.append(task.task_id, agent_id=42, message="...") ``` -### 4. Error handling +### 6. Error handling All SDK exceptions inherit from `XAgentError` and carry `code`, -`message`, and `http_status`. The six server-mapped codes: +`message`, and `http_status`. Server-mapped codes: | Exception | HTTP | Server code | |---|---|---| | `InvalidAPIKey` | 401 | `invalid_api_key` | | `AgentNotFound` | 404 | `agent_not_found` | | `TaskNotFound` | 404 | `task_not_found` | +| `TemplateNotFound` | 404 | `template_not_found` | | `TaskBusy` | 409 | `task_busy` | +| `InvalidInput` | 422 | `invalid_input` | | `RateLimited` | 429 | `rate_limited` (reserved; backend does not yet emit) | | `InternalError` | 500 | `internal_error` | -| `InvalidInput` | 422 | `invalid_input` | -Two SDK-coined codes: +SDK-coined codes: | Exception | Cause | |---|---| | `XAgentTransportError` | network / DNS / TLS error below the HTTP layer | | `TaskTimeout` | `wait()` / `run()` deadline elapsed | -```python -from xagent_sdk import AgentNotFound, TaskTimeout, XAgentClient - -with XAgentClient() as client: - try: - result = client.tasks.run(agent_id=99999, message="hi", timeout=60) - except AgentNotFound as e: - # 404: agent_id doesn't match the key's bound agent - print(f"[{e.code}] {e.message}") - except TaskTimeout as e: - # local deadline; backend may still finish — call get() later if needed - print(f"[{e.code}] {e.message}") -``` - The SDK does **not** retry automatically. Wrap calls with your own policy (e.g., [tenacity](https://tenacity.readthedocs.io/)) if you want retry on transport errors or `TaskBusy`. ## API reference -All methods are sync. An async client lands in a later release. +All methods are sync. An async client is on the Phase 3 roadmap. + +### `UserClient` — management surface + +Constructed with a personal key; talks to `/v1/me`, +`/v1/templates*`, and `/v1/agents*`. | Method | Returns | Notes | |---|---|---| -| `XAgentClient(api_key, base_url, ...)` | `XAgentClient` | constructor; env-var fallback for both | -| `client.me()` | `MeResponse` | identity probe (no caching) | -| `client.close()` / `with ... as client` | — | release the connection pool | -| `client.tasks.create(*, agent_id, message, metadata=None)` | `CreateTaskResult` | POST `/v1/chat/tasks`; returns immediately, `status='pending'` | -| `client.tasks.append(task_id, *, agent_id, message, metadata=None)` | `AppendResult` | POST `/v1/chat/tasks/{id}/messages`; `status='running'`; raises `TaskBusy` if prior turn is still running | -| `client.tasks.get(task_id)` | `TaskInfo` | GET `/v1/chat/tasks/{id}`; latest-turn `input`/`output` | -| `client.tasks.steps(task_id)` | `list[Step]` | GET `/v1/chat/tasks/{id}/steps`; full timeline | -| `client.tasks.wait(task_id, *, timeout=120, poll_interval=1.0)` | `TaskInfo` | poll `get()` until terminal (`COMPLETED` or `FAILED`); raises `TaskTimeout` on deadline | -| `client.tasks.run(*, agent_id, message, timeout=120, poll_interval=1.0, metadata=None)` | `RunResult` | `create` + `wait` + `steps` | +| `UserClient(personal_key, base_url, ...)` | `UserClient` | env-var fallback: `XAGENT_PERSONAL_KEY` / `XAGENT_BASE_URL` | +| `user.me()` | `UserPrincipal` | identity probe (no caching) | +| `user.templates.list()` | `list[Template]` | GET `/v1/templates` | +| `user.templates.get(template_id)` | `TemplateDetail` | GET `/v1/templates/{template_id}`; 404 → `TemplateNotFound` | +| `user.agents.list()` | `list[AgentSummary]` | GET `/v1/agents` | +| `user.agents.create(*, name, instructions, generate_runtime_key=True, metadata=None)` | `AgentCreateResult` | POST `/v1/agents`; `runtime_full_key` is one-time | +| `user.agents.create_from_template(template_id, *, overrides=None, generate_runtime_key=True)` | `AgentCreateResult` | POST `/v1/agents/from-template`; 404 → `TemplateNotFound` | +| `user.agents.rotate_key(agent_id)` | `RotateKeyResult` | POST `/v1/agents/{agent_id}/api-key`; revokes the previous runtime key atomically | +| `user.close()` / `with ... as user` | — | release the connection pool | + +### `AgentClient` — runtime surface + +Constructed with an agent runtime key; talks to `/v1/chat/tasks*` +only. + +| Method | Returns | Notes | +|---|---|---| +| `AgentClient(api_key, base_url, ...)` | `AgentClient` | env-var fallback: `XAGENT_API_KEY` / `XAGENT_BASE_URL` | +| `agent.tasks.create(*, agent_id, message, metadata=None)` | `CreateTaskResult` | POST `/v1/chat/tasks`; returns immediately, `status='pending'` | +| `agent.tasks.append(task_id, *, agent_id, message, metadata=None)` | `AppendResult` | POST `/v1/chat/tasks/{id}/messages`; `status='running'`; raises `TaskBusy` if prior turn is still running | +| `agent.tasks.get(task_id)` | `TaskInfo` | GET `/v1/chat/tasks/{id}`; latest-turn `input`/`output` | +| `agent.tasks.steps(task_id)` | `list[Step]` | GET `/v1/chat/tasks/{id}/steps`; full timeline | +| `agent.tasks.wait(task_id, *, timeout=120, poll_interval=1.0)` | `TaskInfo` | poll `get()` until terminal (`COMPLETED` or `FAILED`); raises `TaskTimeout` on deadline | +| `agent.tasks.run(*, agent_id, message, timeout=120, poll_interval=1.0, metadata=None)` | `RunResult` | `create` + `wait` + `steps` | +| `agent.close()` / `with ... as agent` | — | release the connection pool | ### Status semantics @@ -202,58 +303,73 @@ All methods are sync. An async client lands in a later release. ## Configuration ```python -XAgentClient( - api_key=None, # or env XAGENT_API_KEY - base_url=None, # or env XAGENT_BASE_URL - timeout=30.0, # per-request HTTP timeout (seconds) - max_connections=10, # httpx connection pool size - user_agent=None, # override the default "xagent-sdk-python/..." - transport=None, # custom httpx.BaseTransport (proxy / TLS / tests) +UserClient( + personal_key=None, # or env XAGENT_PERSONAL_KEY + base_url=None, # or env XAGENT_BASE_URL + timeout=30.0, # per-request HTTP timeout (seconds) + max_connections=10, # httpx connection pool size + user_agent=None, # override the default "xagent-sdk-python/..." + transport=None, # custom httpx.BaseTransport (proxy / TLS / tests) +) + +AgentClient( + api_key=None, # or env XAGENT_API_KEY + base_url=None, # or env XAGENT_BASE_URL + timeout=30.0, + max_connections=10, + user_agent=None, + transport=None, ) ``` +Both clients share the same configuration surface. Constructing both +in the same process is safe: each holds its own ``httpx.Client``, so +their default headers (and connection pools) do not bleed into each +other. + `transport=` accepts any `httpx.BaseTransport` — useful for custom retry/proxy/TLS configuration in production, and for `httpx.MockTransport` in tests. -**Threading**: `XAgentClient` is safe to share across threads. -**Fork**: close and recreate the client after `os.fork()` to avoid -socket-state corruption (a standard caveat for any HTTP client with a -persistent connection pool). +**Threading**: both clients are safe to share across threads. +**Fork**: close and recreate after `os.fork()` to avoid socket-state +corruption (standard caveat for any HTTP client with a persistent +connection pool). ## Version policy -- 0.x = alpha. Any minor bump (0.1 → 0.2) may break the surface. Patch - bumps (0.1.0 → 0.1.1) are bugfix-only. +- 0.x = alpha. Any minor bump (0.1 → 0.2 → 0.3) may break the + surface. Patch bumps (0.2.0 → 0.2.1) are bugfix-only. - A future 1.0 will lock the public API per SemVer. - **Always pin to a git tag** in production: ```bash - pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.1.0#subdirectory=python" + pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.2.0#subdirectory=python" ``` Installing from `@main` will eventually break you when the surface evolves on the 0.x track. The `#subdirectory=python` fragment is - required because the SDK lives in a subdirectory of the multi-language - monorepo. + required because the SDK lives in a subdirectory of the + multi-language monorepo. - The User-Agent header carries the SDK version - (`xagent-sdk-python/0.1.0`) so the backend can correlate issues. + (`xagent-sdk-python/0.2.0`) so the backend can correlate issues. ## Development ```bash uv sync --group dev uv run pre-commit install -uv run pytest # 68 unit tests, hermetic, ~0.5s +uv run pytest # ~120 unit tests, hermetic, ~1s ``` ### Local end-to-end tests -E2E tests require a running xAgent backend and are skipped by default. -Run them explicitly: +E2E tests require a running xAgent backend and **both** keys (one to +mint agents, one to run them). Run them explicitly: ```bash export XAGENT_BASE_URL=http://localhost:8000 +export XAGENT_PERSONAL_KEY=xag_personal_... export XAGENT_API_KEY=xag_... # macOS / corporate networks: bypass any system proxy for localhost, @@ -263,8 +379,12 @@ export NO_PROXY=localhost,127.0.0.1 uv run pytest -m e2e ``` -Set `E2E_AGENT_ID` to override the agent the smoke test points at; by -default it discovers the bound agent via `me()`. +Set `E2E_AGENT_ID` to point the runtime-only tests at a specific +agent (0.2.0 ``AgentClient`` no longer has an identity probe, so the +runtime tests skip when this is unset). Set `E2E_TEMPLATE_ID` +(default ``q_and_a``) to pick which template the full-flow test +instantiates from, and `E2E_AGENT_NAME` to override the new agent's +display name. ## License diff --git a/python/tests/e2e/conftest.py b/python/tests/e2e/conftest.py index 86833d9..a3fead5 100644 --- a/python/tests/e2e/conftest.py +++ b/python/tests/e2e/conftest.py @@ -1,13 +1,27 @@ -"""Fixtures for end-to-end tests. +"""Fixtures for end-to-end tests against a real xAgent backend. -These tests require a running xAgent backend reachable at the URL in -``XAGENT_BASE_URL`` with a valid ``XAGENT_API_KEY``. Run with:: +Two pairs of fixtures cover the 0.2.0 surface: - XAGENT_BASE_URL=http://localhost:8000 XAGENT_API_KEY=xag_... \\ +- ``user_client`` / ``patient_user_client`` -- ``UserClient`` + authenticated with ``XAGENT_PERSONAL_KEY``. Use for management + endpoints (``/v1/me``, ``/v1/templates*``, ``/v1/agents*``). + +- ``agent_client`` / ``patient_agent_client`` -- ``AgentClient`` + authenticated with ``XAGENT_API_KEY`` (an agent runtime key). Use + for ``/v1/chat/tasks/*`` calls. ``patient_*`` variants raise the + per-request HTTP timeout from 30s to 60s so tests can observe slow + POST behavior (e.g. the async-contract regression catcher). + +Run with:: + + XAGENT_BASE_URL=http://localhost:8000 \\ + XAGENT_PERSONAL_KEY=xag_personal_... \\ + XAGENT_API_KEY=xag_... \\ uv run pytest -m e2e -Without those env vars set, every test in this directory is skipped. -CI does not run e2e tests; they are local-only. +Fixtures call ``pytest.skip(...)`` when their required env vars are +missing, so a developer who only has one of the keys can still +exercise the other half of the surface. CI does not run e2e tests. """ import os @@ -15,23 +29,54 @@ import pytest -from xagent_sdk import AgentClient +from xagent_sdk import AgentClient, UserClient -@pytest.fixture -def client() -> Iterator[AgentClient]: - """Default e2e client with the SDK's 30s per-request HTTP timeout.""" +def _need_personal() -> tuple[str, str]: + api_key = os.environ.get("XAGENT_PERSONAL_KEY") + base_url = os.environ.get("XAGENT_BASE_URL") + if not (api_key and base_url): + pytest.skip( + "e2e management surface requires XAGENT_PERSONAL_KEY + XAGENT_BASE_URL" + ) + return api_key, base_url + + +def _need_runtime() -> tuple[str, str]: api_key = os.environ.get("XAGENT_API_KEY") base_url = os.environ.get("XAGENT_BASE_URL") if not (api_key and base_url): - pytest.skip("e2e requires XAGENT_API_KEY and XAGENT_BASE_URL") + pytest.skip("e2e runtime surface requires XAGENT_API_KEY + XAGENT_BASE_URL") + return api_key, base_url + + +@pytest.fixture +def user_client() -> Iterator[UserClient]: + """UserClient with the SDK's 30s per-request HTTP timeout.""" + personal_key, base_url = _need_personal() + with UserClient(personal_key=personal_key, base_url=base_url) as c: + yield c + + +@pytest.fixture +def patient_user_client() -> Iterator[UserClient]: + """UserClient with a 60s per-request HTTP timeout for slow probes.""" + personal_key, base_url = _need_personal() + with UserClient(personal_key=personal_key, base_url=base_url, timeout=60.0) as c: + yield c + + +@pytest.fixture +def agent_client() -> Iterator[AgentClient]: + """AgentClient with the SDK's 30s per-request HTTP timeout.""" + api_key, base_url = _need_runtime() with AgentClient(api_key=api_key, base_url=base_url) as c: yield c @pytest.fixture -def patient_client() -> Iterator[AgentClient]: - """Same as ``client`` but with a 60s per-request HTTP timeout. +def patient_agent_client() -> Iterator[AgentClient]: + """AgentClient with a 60s per-request HTTP timeout. Used by tests that want to *observe* an operation whose latency may exceed the SDK default (e.g. measuring whether POST is actually @@ -40,9 +85,6 @@ def patient_client() -> Iterator[AgentClient]: this fixture lets the call complete so the test can assert on the measured latency itself, giving a clearer regression signal. """ - api_key = os.environ.get("XAGENT_API_KEY") - base_url = os.environ.get("XAGENT_BASE_URL") - if not (api_key and base_url): - pytest.skip("e2e requires XAGENT_API_KEY and XAGENT_BASE_URL") + api_key, base_url = _need_runtime() with AgentClient(api_key=api_key, base_url=base_url, timeout=60.0) as c: yield c diff --git a/python/tests/e2e/test_smoke.py b/python/tests/e2e/test_smoke.py index 952da0a..bd3b446 100644 --- a/python/tests/e2e/test_smoke.py +++ b/python/tests/e2e/test_smoke.py @@ -1,13 +1,20 @@ -"""End-to-end smoke test against a real xAgent backend. +"""End-to-end smoke tests against a real xAgent backend (0.2.0). Marked ``@pytest.mark.e2e`` so the default ``pytest`` invocation skips -it. Run explicitly with:: +the whole file. Run explicitly with:: - XAGENT_BASE_URL=... XAGENT_API_KEY=... uv run pytest -m e2e + XAGENT_BASE_URL=... XAGENT_PERSONAL_KEY=... XAGENT_API_KEY=... \\ + uv run pytest -m e2e -Set ``E2E_AGENT_ID`` to override the default agent the smoke test -points at (otherwise the test calls ``me()`` to discover the agent -bound to the presented key). +Set ``E2E_AGENT_ID`` to override the agent id used by runtime-only +tests; otherwise the runtime tests will skip when the variable is +unset, since 0.2.0 ``AgentClient`` no longer exposes a self-identity +probe (use the personal-key path / ``UserClient.agents.list()`` to +discover agent ids). + +Set ``E2E_TEMPLATE_ID`` (default ``q_and_a``) to pick which template +the full-flow test instantiates an agent from. ``E2E_AGENT_NAME`` +(default ``e2e_smoke_``) controls the new agent's display name. """ import os @@ -15,22 +22,31 @@ import pytest -from xagent_sdk import AgentClient, RunResult, TaskStatus +from xagent_sdk import ( + AgentClient, + RunResult, + TaskStatus, + UserClient, + UserPrincipal, +) pytestmark = pytest.mark.e2e -def test_me(client: AgentClient) -> None: - me = client.me() - assert me.agent_id > 0 - assert me.agent_name +def test_user_me(user_client: UserClient) -> None: + me = user_client.me() + assert isinstance(me, UserPrincipal) + assert me.principal_type == "user" + assert me.user_id > 0 + assert me.email + assert me.name assert me.key_prefix -def test_create_is_async(patient_client: AgentClient) -> None: +def test_create_is_async(patient_agent_client: AgentClient) -> None: """POST /v1/chat/tasks must return asynchronously per v1 contract. - The contract is "create the task, return 202 immediately, run LLM in + Contract is "create the task, return 202 immediately, run LLM in the background, observe transitions via GET poll". A backend that blocks POST until the LLM call completes can still produce correct final output via ``run()``, but defeats the design of having async @@ -44,10 +60,13 @@ def test_create_is_async(patient_client: AgentClient) -> None: explicitly so the failure points at the contract violation rather than surfacing as a generic transport timeout. """ - agent_id = int(os.environ.get("E2E_AGENT_ID", str(patient_client.me().agent_id))) + agent_id_env = os.environ.get("E2E_AGENT_ID") + if not agent_id_env: + pytest.skip("E2E_AGENT_ID not set; cannot exercise runtime path") + agent_id = int(agent_id_env) t0 = time.monotonic() - created = patient_client.tasks.create(agent_id=agent_id, message="Say hi") + created = patient_agent_client.tasks.create(agent_id=agent_id, message="Say hi") post_elapsed = time.monotonic() - t0 assert created.status is TaskStatus.PENDING, ( @@ -62,9 +81,18 @@ def test_create_is_async(patient_client: AgentClient) -> None: ) -def test_run_single_turn(client: AgentClient) -> None: - agent_id = int(os.environ.get("E2E_AGENT_ID", str(client.me().agent_id))) - result = client.tasks.run( +def test_run_single_turn(agent_client: AgentClient) -> None: + """Single-turn runtime probe with an existing agent. + + Requires ``E2E_AGENT_ID`` because 0.2.0 ``AgentClient`` no longer + has an identity probe; the test would have nothing to point at + without a caller-supplied id. + """ + agent_id_env = os.environ.get("E2E_AGENT_ID") + if not agent_id_env: + pytest.skip("E2E_AGENT_ID not set; cannot exercise runtime path") + agent_id = int(agent_id_env) + result = agent_client.tasks.run( agent_id=agent_id, message="Say hi in one word", timeout=60.0, @@ -73,3 +101,48 @@ def test_run_single_turn(client: AgentClient) -> None: assert isinstance(result, RunResult) assert result.status is TaskStatus.COMPLETED assert result.output is not None + + +def test_e2e_full_flow_phase2(user_client: UserClient) -> None: + """End-to-end Phase 2 flow: pick a template, create an agent, run it. + + 1. List templates and confirm at least one entry exists. + 2. Pick ``E2E_TEMPLATE_ID`` (default ``q_and_a``) and call + ``agents.create_from_template`` to mint a fresh agent + runtime + key. + 3. Build an ``AgentClient`` with the freshly minted runtime key. + 4. Drive a single-turn ``tasks.run()`` against the new agent and + assert it completes. + + The test deliberately does **not** clean up the created agent -- + Phase 2 SDK has no delete method, and the backend's own + housekeeping owns the lifecycle. Run on a scratch backend instance. + """ + template_id = os.environ.get("E2E_TEMPLATE_ID", "q_and_a") + agent_name = os.environ.get("E2E_AGENT_NAME", f"e2e_smoke_{os.getpid()}") + base_url = os.environ["XAGENT_BASE_URL"] + + templates = user_client.templates.list() + assert templates, "backend returned an empty template list" + + created = user_client.agents.create_from_template( + template_id, overrides={"name": agent_name} + ) + assert created.agent_id > 0 + # generate_runtime_key defaults to True; one-time secret must be + # present so the next step has something to authenticate with. + assert created.runtime_full_key is not None + assert created.runtime_full_key.startswith("xag_") + + with AgentClient( + api_key=created.runtime_full_key, base_url=base_url, timeout=60.0 + ) as runtime: + result = runtime.tasks.run( + agent_id=created.agent_id, + message="Say hi in one word", + timeout=120.0, + poll_interval=2.0, + ) + assert isinstance(result, RunResult) + assert result.status is TaskStatus.COMPLETED + assert result.output is not None From c1135f97ef8302d4e3734c53c254910a81d230a0 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Fri, 29 May 2026 22:54:36 +0800 Subject: [PATCH 08/16] fix(parse): align v1 response parsing with real backend shapes The 0.2.0 schema was built against an envelope-wrapped list shape ({"templates":[{template_id,name}]}, {"agents":[{agent_id,name}]}) and a flat agent-create payload ({agent_id,name,runtime_full_key,...}). Hitting the real backend revealed three mismatches that caused empty lists and broken create flows: - GET /v1/templates and GET /v1/agents return raw JSON arrays whose entries key the primary id under "id", not "template_id"/"agent_id". - POST /v1/agents and POST /v1/agents/from-template return the new agent and the one-time runtime key under nested "agent" and "api_key" blocks, not flat at the top level. - POST /v1/agents/from-template expects override fields spread flat at the request-body top level (V1AgentTemplateCreateRequest), not wrapped under an "overrides" key; the wrapped form was silently dropped, so the name override never took effect and create_from_template always reused the template's default name. Parsers in types.py now rename id -> template_id/agent_id, unwrap the nested create payload, and accept a bare list. AgentsAPI.create_from_template spreads overrides flat so backend pydantic accepts the keys. The five fixtures under shared/fixtures/v1/responses/ are rewritten to mirror the real wire format observed against localhost:8000, and the affected unit tests now pin the new shape (including a defensive "non-list body returns []" pin to mirror _parse_steps semantics). Public dataclass field names (template_id, agent_id, runtime_full_key, ...) and AgentsAPI.create_from_template's overrides=Mapping signature are unchanged. --- python/src/xagent_sdk/_agents.py | 24 +++---- python/src/xagent_sdk/types.py | 58 +++++++++++++---- python/tests/unit/test_agents_management.py | 50 ++++++++++++-- python/tests/unit/test_templates.py | 28 ++++---- .../fixtures/v1/responses/agents_create.json | 29 +++++++-- shared/fixtures/v1/responses/agents_list.json | 42 ++++++++++-- .../v1/responses/templates_detail.json | 21 ++++-- .../fixtures/v1/responses/templates_list.json | 65 ++++++++++++------- 8 files changed, 231 insertions(+), 86 deletions(-) diff --git a/python/src/xagent_sdk/_agents.py b/python/src/xagent_sdk/_agents.py index 6bb4d2c..34b69da 100644 --- a/python/src/xagent_sdk/_agents.py +++ b/python/src/xagent_sdk/_agents.py @@ -97,19 +97,21 @@ def create_from_template( ) -> AgentCreateResult: """``POST /v1/agents/from-template`` -- create an agent by template. - The backend loads the template's ``agent_config`` and merges it - with ``overrides`` (caller-supplied dict, deep-merged server-side) - before persisting the agent. The SDK passes ``overrides`` through - verbatim and does **not** validate keys against any template - schema -- the backend rejects invalid overrides with 422 - ``invalid_input``. + The backend loads the template's ``agent_config`` and overlays + any caller-supplied fields on top before persisting. ``overrides`` + keys (``name``, ``description``, ``instructions``, + ``execution_mode``, ``models``, ``knowledge_bases``, ``skills``, + ``tool_categories``, ``suggested_prompts``) are spread into the + request body alongside ``template_id`` and ``generate_runtime_key``; + unknown keys are dropped by the backend and have no effect. Args: template_id: Template identifier from ``templates.list()`` / ``templates.get()``. overrides: Optional dict of fields to override on the - template's ``agent_config`` (e.g. a new ``name``, custom - ``instructions``). Omitted from the wire when None. + template (e.g. ``{"name": "My Bot"}``). Spread flat into + the wire body; ``template_id`` and ``generate_runtime_key`` + always win over collisions. generate_runtime_key: Same semantics as ``create()``. Returns: @@ -118,16 +120,14 @@ def create_from_template( Raises: TemplateNotFound: 404 ``template_not_found`` -- unknown ``template_id``. - InvalidInput: 422 -- overrides contain disallowed fields or - malformed values. + InvalidInput: 422 -- overrides contain malformed values. InvalidAPIKey: 401 -- personal key invalid / revoked. """ body: dict[str, Any] = { + **(dict(overrides) if overrides else {}), "template_id": template_id, "generate_runtime_key": generate_runtime_key, } - if overrides is not None: - body["overrides"] = dict(overrides) resp = self._client._request("POST", "/v1/agents/from-template", json=body) return _parse_agent_create(resp.json()) diff --git a/python/src/xagent_sdk/types.py b/python/src/xagent_sdk/types.py index 8b25a54..40dcb06 100644 --- a/python/src/xagent_sdk/types.py +++ b/python/src/xagent_sdk/types.py @@ -272,33 +272,67 @@ def _parse_user_principal(data: dict[str, Any]) -> UserPrincipal: def _parse_template_list(data: Any) -> list[Template]: - """Extract and parse the ``templates`` array from a list response. + """Parse the raw template array returned by ``GET /v1/templates``. - Mirrors ``_parse_steps`` defense-in-depth: non-dict body or missing - ``templates`` key returns an empty list rather than raising - ``AttributeError``. + Backend returns a bare JSON array of template objects whose primary + key is ``id``; the SDK surfaces that as ``Template.template_id`` to + avoid shadowing the ``id`` builtin on public dataclass instances. + Non-list bodies degrade to an empty list (defense-in-depth against + upstream proxies returning a non-canonical shape). """ - if not isinstance(data, dict): + if not isinstance(data, list): return [] - return _TEMPLATE_LIST_ADAPTER.validate_python(data.get("templates", [])) + normalized = [_template_dict(item) for item in data if isinstance(item, dict)] + return _TEMPLATE_LIST_ADAPTER.validate_python(normalized) def _parse_template_detail(data: dict[str, Any]) -> TemplateDetail: - return _TEMPLATE_DETAIL_ADAPTER.validate_python(data) + return _TEMPLATE_DETAIL_ADAPTER.validate_python(_template_dict(data)) def _parse_agent_list(data: Any) -> list[AgentSummary]: - """Extract and parse the ``agents`` array from a list response. + """Parse the raw agent array returned by ``GET /v1/agents``. - Same defensive shape as ``_parse_template_list`` / ``_parse_steps``. + Same bare-array + ``id`` -> ``agent_id`` rename as + ``_parse_template_list``. """ - if not isinstance(data, dict): + if not isinstance(data, list): return [] - return _AGENT_LIST_ADAPTER.validate_python(data.get("agents", [])) + normalized = [_agent_summary_dict(item) for item in data if isinstance(item, dict)] + return _AGENT_LIST_ADAPTER.validate_python(normalized) def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: - return _AGENT_CREATE_ADAPTER.validate_python(data) + """Parse the nested ``{"agent": {...}, "api_key": {...}}`` payload. + + Backend returns the new agent row and (when ``generate_runtime_key`` + is true) the one-time runtime key as two siblings under separate + keys; SDK flattens them into ``AgentCreateResult`` so callers see one + record. When ``generate_runtime_key=False`` the ``api_key`` block is + absent and the runtime fields stay ``None`` -- caller is expected to + materialize a key via ``rotate_key()`` later. + """ + agent = data.get("agent") or {} + api_key = data.get("api_key") or {} + flat = { + "agent_id": agent.get("id"), + "name": agent.get("name"), + "runtime_full_key": api_key.get("full_key"), + "runtime_key_prefix": api_key.get("key_prefix"), + } + return _AGENT_CREATE_ADAPTER.validate_python(flat) + + +def _template_dict(item: dict[str, Any]) -> dict[str, Any]: + """Rename backend ``id`` -> ``template_id`` while passing every other + key through; pydantic drops fields the dataclass does not declare. + """ + return {**item, "template_id": item.get("id")} + + +def _agent_summary_dict(item: dict[str, Any]) -> dict[str, Any]: + """Rename backend ``id`` -> ``agent_id`` for the agent list entry.""" + return {**item, "agent_id": item.get("id")} def _parse_rotate_key(data: dict[str, Any]) -> RotateKeyResult: diff --git a/python/tests/unit/test_agents_management.py b/python/tests/unit/test_agents_management.py index 0f60714..f3d5f1d 100644 --- a/python/tests/unit/test_agents_management.py +++ b/python/tests/unit/test_agents_management.py @@ -46,6 +46,16 @@ def handler(req: httpx.Request) -> httpx.Response: assert {"active", "draft", "paused"} <= statuses def test_empty_list(self) -> None: + # Canonical empty: backend returns a bare empty array. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[]) + + with _make_user(h) as c: + assert c.agents.list() == [] + + def test_non_list_body_defensive(self) -> None: + # Malformed body (dict instead of list) returns [] rather than + # raising. Mirrors _parse_steps' defense-in-depth pattern. def h(req: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"agents": []}) @@ -88,13 +98,15 @@ def test_generate_runtime_key_false_passes_through(self) -> None: def handler(req: httpx.Request) -> httpx.Response: captured.append(req) + # When generate_runtime_key=False the backend omits the + # api_key block; only the agent block is present. return httpx.Response( 201, json={ - "agent_id": 42, - "name": "HR Leave Assistant", - "runtime_full_key": None, - "runtime_key_prefix": None, + "agent": { + "id": 42, + "name": "HR Leave Assistant", + }, }, ) @@ -165,13 +177,15 @@ def handler(req: httpx.Request) -> httpx.Response: assert captured[0].method == "POST" assert captured[0].url.path == "/v1/agents/from-template" body = json.loads(captured[0].content) + # Overrides spread flat alongside template_id (matches backend + # V1AgentTemplateCreateRequest schema). assert body == { "template_id": "q_and_a", "generate_runtime_key": True, - "overrides": {"name": "HR Bot"}, + "name": "HR Bot", } - def test_no_overrides_field_when_none(self) -> None: + def test_no_override_fields_when_none(self) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -182,7 +196,29 @@ def handler(req: httpx.Request) -> httpx.Response: c.agents.create_from_template("q_and_a") body = json.loads(captured[0].content) - assert "overrides" not in body + assert body == { + "template_id": "q_and_a", + "generate_runtime_key": True, + } + + def test_template_id_wins_over_collision_in_overrides(self) -> None: + # Defensive: a caller who passes template_id inside overrides + # should not be able to override the explicit positional arg. + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create_from_template( + "real_template", + overrides={"template_id": "hijacked", "name": "X"}, + ) + + body = json.loads(captured[0].content) + assert body["template_id"] == "real_template" + assert body["name"] == "X" def test_template_not_found(self) -> None: def h(req: httpx.Request) -> httpx.Response: diff --git a/python/tests/unit/test_templates.py b/python/tests/unit/test_templates.py index a123ca4..24bcef9 100644 --- a/python/tests/unit/test_templates.py +++ b/python/tests/unit/test_templates.py @@ -41,25 +41,25 @@ def handler(req: httpx.Request) -> httpx.Response: assert captured[0].method == "GET" assert captured[0].url.path == "/v1/templates" - assert len(templates) == 4 + assert len(templates) == 3 assert all(isinstance(t, Template) for t in templates) - # Spot-check a couple ids = [t.template_id for t in templates] - assert "content_generator" in ids - assert "q_and_a" in ids + assert "support-email-agent" in ids + assert "support-ai-chatbot-agent" in ids def test_empty_list(self) -> None: + # Canonical empty: backend returns a bare empty array. def h(req: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"templates": []}) + return httpx.Response(200, json=[]) with _make_user(h) as c: assert c.templates.list() == [] - def test_non_dict_body_defensive(self) -> None: - # Mirrors _parse_steps: malformed body returns [] rather than - # raising AttributeError. + def test_non_list_body_defensive(self) -> None: + # Malformed body (dict instead of list) returns [] rather than + # raising. Mirrors _parse_steps' defense-in-depth pattern. def h(req: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=[]) + return httpx.Response(200, json={"templates": []}) with _make_user(h) as c: assert c.templates.list() == [] @@ -74,16 +74,16 @@ def handler(req: httpx.Request) -> httpx.Response: return httpx.Response(200, json=response("templates_detail")) with _make_user(handler) as c: - detail = c.templates.get("q_and_a") + detail = c.templates.get("support-ai-chatbot-agent") assert captured[0].method == "GET" - assert captured[0].url.path == "/v1/templates/q_and_a" + assert captured[0].url.path == "/v1/templates/support-ai-chatbot-agent" assert isinstance(detail, TemplateDetail) - assert detail.template_id == "q_and_a" - assert detail.name == "Q&A" + assert detail.template_id == "support-ai-chatbot-agent" + assert detail.name == "AI Chatbot Agent" # agent_config is the merge target for create_from_template overrides assert "instructions" in detail.agent_config - assert detail.agent_config["mode"] == "balanced" + assert detail.agent_config["execution_mode"] == "flash" def test_404_template_not_found(self) -> None: with _make_user(_404_handler) as c, pytest.raises(TemplateNotFound): diff --git a/shared/fixtures/v1/responses/agents_create.json b/shared/fixtures/v1/responses/agents_create.json index 1e46c1a..29d0361 100644 --- a/shared/fixtures/v1/responses/agents_create.json +++ b/shared/fixtures/v1/responses/agents_create.json @@ -1,6 +1,27 @@ { - "agent_id": 42, - "name": "HR Leave Assistant", - "runtime_full_key": "xag_abc123_secretsecretsecretsecretsecret", - "runtime_key_prefix": "abc123" + "agent": { + "id": 42, + "user_id": 8, + "name": "HR Leave Assistant", + "description": null, + "instructions": "Answer leave-policy questions for employees.", + "execution_mode": "balanced", + "models": null, + "knowledge_bases": [], + "skills": [], + "tool_categories": [], + "suggested_prompts": [], + "logo_url": null, + "status": "draft", + "published_at": null, + "created_at": "2026-05-29T13:57:39.000000+00:00", + "updated_at": "2026-05-29T13:57:39.000000+00:00", + "widget_enabled": true, + "allowed_domains": [] + }, + "api_key": { + "full_key": "xag_abc123_secretsecretsecretsecretsecret", + "key_prefix": "abc123", + "created_at": "2026-05-29T13:57:39.780154Z" + } } diff --git a/shared/fixtures/v1/responses/agents_list.json b/shared/fixtures/v1/responses/agents_list.json index 6c9ee29..aba4951 100644 --- a/shared/fixtures/v1/responses/agents_list.json +++ b/shared/fixtures/v1/responses/agents_list.json @@ -1,7 +1,35 @@ -{ - "agents": [ - {"agent_id": 42, "name": "HR Leave Assistant", "status": "active"}, - {"agent_id": 43, "name": "Policy Bot", "status": "draft"}, - {"agent_id": 44, "name": "CV Screener", "status": "paused"} - ] -} +[ + { + "id": 42, + "name": "HR Leave Assistant", + "description": "Answers leave-policy questions for employees.", + "logo_url": null, + "status": "active", + "created_at": "2026-05-20T10:00:00+00:00", + "updated_at": "2026-05-29T10:00:00+00:00", + "widget_enabled": true, + "allowed_domains": [] + }, + { + "id": 43, + "name": "Policy Bot", + "description": null, + "logo_url": null, + "status": "draft", + "created_at": "2026-05-25T10:00:00+00:00", + "updated_at": "2026-05-25T10:00:00+00:00", + "widget_enabled": true, + "allowed_domains": [] + }, + { + "id": 44, + "name": "CV Screener", + "description": "Filters incoming CVs against the role spec.", + "logo_url": null, + "status": "paused", + "created_at": "2026-04-01T10:00:00+00:00", + "updated_at": "2026-05-10T10:00:00+00:00", + "widget_enabled": false, + "allowed_domains": [] + } +] diff --git a/shared/fixtures/v1/responses/templates_detail.json b/shared/fixtures/v1/responses/templates_detail.json index 27bb952..8eb390d 100644 --- a/shared/fixtures/v1/responses/templates_detail.json +++ b/shared/fixtures/v1/responses/templates_detail.json @@ -1,10 +1,19 @@ { - "template_id": "q_and_a", - "name": "Q&A", - "description": "Conversational chat agent grounded in a knowledge base.", + "id": "support-ai-chatbot-agent", + "name": "AI Chatbot Agent", + "category": "Support", + "featured": true, + "description": "Responds to live chat enquiries instantly", + "features": ["Answers commonly asked questions for a company/group"], + "connections": [{"name": "Google Drive", "logo": "https://www.google.com/s2/favicons?domain=drive.google.com&sz=64"}], + "setup_time": "5 min setup", + "tags": [], + "author": "Xagent", + "version": "1.0", "agent_config": { - "instructions": "You are a helpful Q&A assistant. Answer concisely and cite sources when possible.", - "mode": "balanced", - "tools_default": [] + "instructions": "You are an AI Support Chatbot. Resolve customer enquiries directly in the chat where possible.", + "execution_mode": "flash", + "skills": [], + "tool_categories": ["basic", "knowledge"] } } diff --git a/shared/fixtures/v1/responses/templates_list.json b/shared/fixtures/v1/responses/templates_list.json index 0474f29..7337987 100644 --- a/shared/fixtures/v1/responses/templates_list.json +++ b/shared/fixtures/v1/responses/templates_list.json @@ -1,24 +1,41 @@ -{ - "templates": [ - { - "template_id": "content_generator", - "name": "Content Generator", - "description": "Generate text, documents, contracts, and policies from a brief." - }, - { - "template_id": "analyzer", - "name": "Analyzer", - "description": "Accepts uploaded files and returns structured insights, scores, or summaries." - }, - { - "template_id": "q_and_a", - "name": "Q&A", - "description": "Conversational chat agent grounded in a knowledge base." - }, - { - "template_id": "assistant", - "name": "Assistant", - "description": "Persistent assistant that can take actions based on rules." - } - ] -} +[ + { + "id": "support-email-agent", + "name": "Email Agent (Support)", + "category": "Support", + "featured": false, + "description": "Monitors the support inbox, triages and responds to customer enquiries, and follows up on open tickets.", + "features": ["Sorts support tickets by urgency and deadlines"], + "connections": [{"name": "Gmail", "logo": "https://www.google.com/s2/favicons?domain=mail.google.com&sz=64"}], + "setup_time": "5 min setup", + "tags": [], + "author": "Xagent", + "version": "1.0" + }, + { + "id": "sales-inbound-agent", + "name": "Inbound Agent", + "category": "Sales", + "featured": true, + "description": "Processes inbound leads, researches prospect, scores and qualifies leads.", + "features": ["Captures leads from forms, chat, and email"], + "connections": [], + "setup_time": "5 min setup", + "tags": [], + "author": "Xagent", + "version": "1.0" + }, + { + "id": "support-ai-chatbot-agent", + "name": "AI Chatbot Agent", + "category": "Support", + "featured": true, + "description": "Responds to live chat enquiries instantly", + "features": ["Answers commonly asked questions for a company/group"], + "connections": [], + "setup_time": "5 min setup", + "tags": [], + "author": "Xagent", + "version": "1.0" + } +] From d9701ffc3d909f50f0c64eb36bca1eef9d99367d Mon Sep 17 00:00:00 2001 From: Alexliu Date: Sat, 30 May 2026 00:10:26 +0800 Subject: [PATCH 09/16] review: harden agent-create parsing, fall back on rename helpers, strip process-history wording Three changes bundled because they share the same review pass. Parser robustness - _parse_agent_create now raises XAgentTransportError("malformed_response", ...) when the response body lacks a usable "agent" block. The previous path let pydantic surface "Input should be a valid integer, input_value=None" on agent_id, which did not point at the real cause (backend response shape violation). - _template_dict / _agent_summary_dict fall back to a pre-existing "template_id" / "agent_id" when the backend omits the canonical "id" field, so a future backend rename does not silently degrade the SDK surface to None. Test coverage - New TestParseAgentCreateMalformed in test_types.py pins the four malformed-body shapes that must surface the new error code. - New TestNormalizeHelpers in test_types.py pins the id / fallback / collision behavior of the two rename helpers. Wording cleanup - Remove the "(v0.3.0+) Hardcoded production default URL -- not yet baked in" block from AgentClient / UserClient constructor docstrings: anchoring code to a future release name is process-history wording, not a current invariant. - Drop release-history paragraphs from UserPrincipal docstring, test_public_surface.py, test_check_no_legacy_callsites.py, and test_agent_client.py. The mechanical pins (forbidden symbol grep, exact __all__ set) keep their force without narrating how the codebase got here. - Trim "Reserved; backend does not yet emit" from RateLimited and "in early Phase 2 wire shapes" from AgentSummary. 131 unit tests pass; the four real-backend sanity calls (me / templates / agents / runtime key issue) still succeed against localhost:8000. --- python/src/xagent_sdk/agent_client.py | 2 - python/src/xagent_sdk/errors.py | 2 +- python/src/xagent_sdk/types.py | 61 +++++++++------ python/src/xagent_sdk/user_client.py | 2 - python/tests/unit/test_agent_client.py | 9 +-- .../unit/test_check_no_legacy_callsites.py | 46 ++++++------ python/tests/unit/test_public_surface.py | 37 +++++----- python/tests/unit/test_types.py | 74 +++++++++++++++++++ 8 files changed, 157 insertions(+), 76 deletions(-) diff --git a/python/src/xagent_sdk/agent_client.py b/python/src/xagent_sdk/agent_client.py index 5f4ddf5..bc2178e 100644 --- a/python/src/xagent_sdk/agent_client.py +++ b/python/src/xagent_sdk/agent_client.py @@ -17,8 +17,6 @@ class AgentClient(_BaseClient): Constructor argument resolution order for ``api_key`` and ``base_url``: 1. Explicit keyword argument 2. Environment variable (``XAGENT_API_KEY`` / ``XAGENT_BASE_URL``) - 3. (v0.3.0+) Hardcoded production default URL -- not yet baked in - while the xAgent team finalizes the prod endpoint. Missing values at construction time raise ``ValueError`` instead of deferring failure to the first request. diff --git a/python/src/xagent_sdk/errors.py b/python/src/xagent_sdk/errors.py index 8ff5d19..1b4ac99 100644 --- a/python/src/xagent_sdk/errors.py +++ b/python/src/xagent_sdk/errors.py @@ -44,7 +44,7 @@ class TaskBusy(XAgentError): class RateLimited(XAgentError): - """HTTP 429, code ``rate_limited``. Reserved; backend does not yet emit.""" + """HTTP 429, code ``rate_limited``.""" class InternalError(XAgentError): diff --git a/python/src/xagent_sdk/types.py b/python/src/xagent_sdk/types.py index 40dcb06..8404b29 100644 --- a/python/src/xagent_sdk/types.py +++ b/python/src/xagent_sdk/types.py @@ -5,6 +5,8 @@ from pydantic import TypeAdapter +from xagent_sdk.errors import XAgentTransportError + class TaskStatus(StrEnum): """Lifecycle states a task can hold. @@ -42,20 +44,13 @@ class StepType(StrEnum): @dataclass(frozen=True) class UserPrincipal: - """``GET /v1/me`` payload (0.2.0+) -- user identity bound to the - presented personal key. - - Replaces the 0.1.0 agent-identity shape (``agent_id`` / ``agent_name`` / - ``key_prefix``) since ``/v1/me`` is now a personal-key endpoint that - returns the **user** the key belongs to. To look up which agent a - runtime key corresponds to in 0.2.0, list agents via - ``UserClient.agents.list()`` and match against ``AgentSummary.name`` - or ``agent_id``. - - ``principal_type`` is a stable enum string today (``"user"``); future - backends may add new principal kinds (e.g., service accounts) which - would land as new ``UserClient`` subclasses, not as silent additions - here. + """``GET /v1/me`` payload -- the user identity the personal key + is bound to. + + ``principal_type`` is the stable enum string ``"user"`` for the + surface ``UserClient`` covers. ``key_prefix`` is the public-safe + 6-char handle (``xag_personal__...``) and is safe to log; + the secret half of the key is never returned by this endpoint. """ principal_type: str @@ -106,8 +101,8 @@ class AgentSummary: Slim shape for listing agents owned by the personal key's user. The optional ``status`` reflects the agent's published-state - (e.g. ``"active"`` / ``"draft"`` / ``"paused"``); backend may omit it - in early Phase 2 wire shapes. + (e.g. ``"active"`` / ``"draft"`` / ``"paused"``); ``None`` means the + backend omitted the field on this entry. """ agent_id: int @@ -311,8 +306,25 @@ def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: record. When ``generate_runtime_key=False`` the ``api_key`` block is absent and the runtime fields stay ``None`` -- caller is expected to materialize a key via ``rotate_key()`` later. + + Raises ``XAgentTransportError("malformed_response", ...)`` if the + ``agent`` block is missing or lacks ``id``/``name`` -- pydantic's + raw ``ValidationError`` on ``agent_id`` would say "Input should be a + valid integer, input_value=None" which does not point at the real + cause (backend response shape violation). """ - agent = data.get("agent") or {} + agent = data.get("agent") + if ( + not isinstance(agent, dict) + or agent.get("id") is None + or agent.get("name") is None + ): + raise XAgentTransportError( + "malformed_response", + "agent-create response missing required 'agent' block " + "(expected {'agent': {'id': int, 'name': str, ...}, 'api_key'?: {...}})", + http_status=None, + ) api_key = data.get("api_key") or {} flat = { "agent_id": agent.get("id"), @@ -324,15 +336,20 @@ def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: def _template_dict(item: dict[str, Any]) -> dict[str, Any]: - """Rename backend ``id`` -> ``template_id`` while passing every other - key through; pydantic drops fields the dataclass does not declare. + """Surface backend ``id`` as ``template_id`` while passing other keys + through. Falls back to a pre-existing ``template_id`` so a future + backend that renames the field at source does not silently degrade + to ``None``; pydantic drops fields the dataclass does not declare. """ - return {**item, "template_id": item.get("id")} + return {**item, "template_id": item.get("id") or item.get("template_id")} def _agent_summary_dict(item: dict[str, Any]) -> dict[str, Any]: - """Rename backend ``id`` -> ``agent_id`` for the agent list entry.""" - return {**item, "agent_id": item.get("id")} + """Surface backend ``id`` as ``agent_id`` for the agent list entry, + with the same ``id``-or-existing-``agent_id`` fallback as + ``_template_dict``. + """ + return {**item, "agent_id": item.get("id") or item.get("agent_id")} def _parse_rotate_key(data: dict[str, Any]) -> RotateKeyResult: diff --git a/python/src/xagent_sdk/user_client.py b/python/src/xagent_sdk/user_client.py index 9f0bf44..d347231 100644 --- a/python/src/xagent_sdk/user_client.py +++ b/python/src/xagent_sdk/user_client.py @@ -24,8 +24,6 @@ class UserClient(_BaseClient): 1. Explicit keyword argument 2. Environment variable (``XAGENT_PERSONAL_KEY`` / ``XAGENT_BASE_URL``) - 3. (v0.3.0+) Hardcoded production default URL -- not yet baked in - while the xAgent team finalizes the prod endpoint. Missing values at construction time raise ``ValueError`` instead of deferring failure to the first request. ``XAGENT_PERSONAL_KEY`` is a diff --git a/python/tests/unit/test_agent_client.py b/python/tests/unit/test_agent_client.py index 61c2e7e..6851373 100644 --- a/python/tests/unit/test_agent_client.py +++ b/python/tests/unit/test_agent_client.py @@ -1,10 +1,9 @@ """Tests for AgentClient construction and context-manager lifecycle. -The ``me()`` method used to live here in 0.1.0 (it returned the -agent identity bound to the runtime key). 0.2.0 moved identity to -``UserClient.me()`` and ``AgentClient`` no longer has a probe method, -so this module focuses on the construction contract and resource -cleanup. Auth-mapping coverage for the agent runtime key (401 -> +``AgentClient`` exposes ``.tasks`` only; identity probing belongs to +``UserClient.me()`` because the runtime key has no user-shaped identity +to return. This module pins the construction contract and resource +cleanup; auth-mapping coverage for the runtime key (401 -> ``InvalidAPIKey``) lives in ``test_tasks.py`` and ``test_errors.py``. """ diff --git a/python/tests/unit/test_check_no_legacy_callsites.py b/python/tests/unit/test_check_no_legacy_callsites.py index d18e296..f0d06e2 100644 --- a/python/tests/unit/test_check_no_legacy_callsites.py +++ b/python/tests/unit/test_check_no_legacy_callsites.py @@ -1,20 +1,17 @@ -"""Repo-wide grep bottom: 0.1.0 names must be gone from src/ and tests/. +"""Repo-wide grep pin: forbidden symbol names must not appear in src/ +or tests/. -Per CLAUDE.md "delete deprecated -> grep src + tests" rule, we keep a -mechanical assertion that the renamed / removed 0.1.0 symbols (the -legacy runtime-client class, the legacy ``/v1/me`` response dataclass, -and its private parser) do not silently linger in the SDK source or -test suite. +Three names that resemble close-but-wrong spellings of the package's +public surface (``XAgentClient`` / ``MeResponse`` / ``_parse_me``) are +disallowed -- a typo or stale import that resurrects one of them would +otherwise survive mypy + ruff and only surface when a user hits a real +ImportError. The grep is a mechanical bottom that catches them in the +diff before merge. -The grep excludes ``test_check_no_legacy_callsites.py`` (this file -- -it references the legacy names in patterns by design) and -``test_public_surface.py`` (which asserts the rename mechanically via -attribute checks). Documentation files (``python/README.md``, -``shared/README.md``) are not in scope here; they evolve under Phase -G review. - -Runs as a regular unit test so it shows up under ``pytest`` and CI -without any extra hook plumbing. +The grep excludes this file (it must reference the forbidden patterns +literally) and ``test_public_surface.py`` (which asserts the same +property via ``hasattr`` on the imported package). Documentation files +under ``python/README.md`` / ``shared/README.md`` are out of scope. """ import subprocess @@ -45,30 +42,29 @@ def _grep(pattern: str) -> subprocess.CompletedProcess[str]: ) -def test_no_legacy_runtime_client_name_in_src_or_tests() -> None: +def test_xagent_client_name_absent() -> None: # Joined to avoid this module itself containing the literal string. pattern = "XAgent" + "Client" result = _grep(pattern) assert result.returncode == 1, ( - "Legacy runtime-client name found in source/tests; " - "0.2.0 renamed it to AgentClient.\n" + result.stdout + f"forbidden symbol {pattern!r} found in source/tests; " + "the runtime client is AgentClient.\n" + result.stdout ) -def test_no_legacy_me_response_in_src_or_tests() -> None: +def test_me_response_name_absent() -> None: pattern = "Me" + "Response" result = _grep(pattern) assert result.returncode == 1, ( - "Legacy MeResponse references found in source/tests; " - "0.2.0 replaced it with UserPrincipal.\n" + result.stdout + f"forbidden symbol {pattern!r} found in source/tests; " + "the /v1/me payload is UserPrincipal.\n" + result.stdout ) -def test_no_legacy_parse_me_helper_in_src_or_tests() -> None: - # _parse_me was the 0.1.0 helper; replaced by _parse_user_principal. +def test_parse_me_helper_absent() -> None: pattern = r"_parse_me\b" result = _grep(pattern) assert result.returncode == 1, ( - "Legacy `_parse_me` helper references found in source/tests; " - "0.2.0 replaced it with `_parse_user_principal`.\n" + result.stdout + f"forbidden symbol {pattern!r} found in source/tests; " + "the helper is _parse_user_principal.\n" + result.stdout ) diff --git a/python/tests/unit/test_public_surface.py b/python/tests/unit/test_public_surface.py index 839ff05..87084a7 100644 --- a/python/tests/unit/test_public_surface.py +++ b/python/tests/unit/test_public_surface.py @@ -1,16 +1,16 @@ -"""Mechanical pin for the SDK 0.2.0 public surface. +"""Mechanical pin for the SDK public surface. -If a future PR silently widens (or narrows) ``xagent_sdk.__all__`` the -test below fails and the diff has to either justify the surface change -or roll it back. The legacy-name checks (``XAgentClient`` / -``MeResponse``) verify the 0.1.0 -> 0.2.0 breaking rename so a -re-export sneaking back in trips CI. +If a future change silently widens (or narrows) ``xagent_sdk.__all__`` +the test below fails and the diff has to either justify the surface +change or roll it back. The ``XAgentClient`` / ``MeResponse`` checks +guard against a re-export accidentally re-introducing names that the +package does not expose. """ import xagent_sdk -# The full public 0.2.0 surface. Update this set deliberately when -# adding new exports; do not loosen the assertion to a subset check. +# The full public surface. Update this set deliberately when adding new +# exports; do not loosen the assertion to a subset check. EXPECTED_SURFACE: set[str] = { # Clients "AgentClient", @@ -22,7 +22,7 @@ "AgentSummary", "AgentCreateResult", "RotateKeyResult", - # Runtime dataclasses (unchanged from 0.1.0) + # Runtime dataclasses "CreateTaskResult", "AppendResult", "TaskInfo", @@ -65,24 +65,23 @@ def test_every_exported_name_resolves() -> None: ) -def test_xagent_client_legacy_name_removed() -> None: +def test_xagent_client_name_not_exposed() -> None: assert not hasattr(xagent_sdk, "XAgentClient"), ( - "0.2.0 renamed XAgentClient -> AgentClient; the legacy name must " - "not resolve via the public package" + "the runtime client is named AgentClient; XAgentClient must not " + "resolve via the public package" ) assert "XAgentClient" not in xagent_sdk.__all__ -def test_meresponse_legacy_name_removed() -> None: +def test_meresponse_name_not_exposed() -> None: assert not hasattr(xagent_sdk, "MeResponse"), ( - "0.2.0 replaced MeResponse with UserPrincipal; the legacy name " - "must not resolve via the public package" + "the /v1/me payload is UserPrincipal; MeResponse must not " + "resolve via the public package" ) assert "MeResponse" not in xagent_sdk.__all__ -def test_version_bumped_to_0_2_0() -> None: - # The release-strategy pin: 0.2.0 is a breaking release and the - # version string the SDK announces (also in the User-Agent header) - # must reflect that. +def test_version_matches_pyproject() -> None: + # The version string the SDK announces (also in the User-Agent + # header) must match the packaged release. assert xagent_sdk.__version__ == "0.2.0" diff --git a/python/tests/unit/test_types.py b/python/tests/unit/test_types.py index 26ceefd..1376cff 100644 --- a/python/tests/unit/test_types.py +++ b/python/tests/unit/test_types.py @@ -15,11 +15,15 @@ TaskInfo, TaskStatus, ) +from xagent_sdk.errors import XAgentTransportError from xagent_sdk.types import ( + _agent_summary_dict, + _parse_agent_create, _parse_append, _parse_create_task, _parse_steps, _parse_task_info, + _template_dict, ) @@ -197,6 +201,76 @@ def test_step_frozen(self) -> None: step.id = "hacked" # type: ignore[misc] +class TestParseAgentCreateMalformed: + """Guard the explicit ``XAgentTransportError("malformed_response", ...)`` + raised when the backend body lacks the required ``agent`` block. + """ + + def _good_api_key(self) -> dict[str, object]: + return { + "full_key": "xag_abc123_secret", + "key_prefix": "abc123", + "created_at": "2026-05-29T00:00:00Z", + } + + def test_missing_agent_block_raises(self) -> None: + with pytest.raises(XAgentTransportError) as excinfo: + _parse_agent_create({"api_key": self._good_api_key()}) + assert excinfo.value.code == "malformed_response" + assert "'agent'" in str(excinfo.value) + + def test_agent_block_is_not_dict_raises(self) -> None: + with pytest.raises(XAgentTransportError) as excinfo: + _parse_agent_create({"agent": None, "api_key": self._good_api_key()}) + assert excinfo.value.code == "malformed_response" + + def test_agent_block_missing_id_raises(self) -> None: + with pytest.raises(XAgentTransportError) as excinfo: + _parse_agent_create( + {"agent": {"name": "x"}, "api_key": self._good_api_key()} + ) + assert excinfo.value.code == "malformed_response" + + def test_agent_block_missing_name_raises(self) -> None: + with pytest.raises(XAgentTransportError) as excinfo: + _parse_agent_create({"agent": {"id": 42}, "api_key": self._good_api_key()}) + assert excinfo.value.code == "malformed_response" + + +class TestNormalizeHelpers: + """Pin ``_template_dict`` / ``_agent_summary_dict`` shape contracts.""" + + def test_template_dict_id_to_template_id(self) -> None: + out = _template_dict({"id": "tpl-1", "name": "T"}) + assert out["template_id"] == "tpl-1" + assert out["name"] == "T" + + def test_template_dict_falls_back_to_existing_template_id(self) -> None: + # If a future backend drops "id" and sends "template_id" directly, + # the helper must surface the existing value instead of None. + out = _template_dict({"template_id": "tpl-2", "name": "T"}) + assert out["template_id"] == "tpl-2" + + def test_template_dict_id_wins_when_both_present(self) -> None: + # Current backend contract: "id" is the source of truth. + out = _template_dict({"id": "from_id", "template_id": "from_legacy"}) + assert out["template_id"] == "from_id" + + def test_template_dict_missing_id_yields_none(self) -> None: + # Caller (pydantic TypeAdapter) is responsible for failing on + # None; helper should not silently fabricate a value. + out = _template_dict({"name": "T"}) + assert out["template_id"] is None + + def test_agent_summary_dict_id_to_agent_id(self) -> None: + out = _agent_summary_dict({"id": 42, "name": "A"}) + assert out["agent_id"] == 42 + + def test_agent_summary_dict_falls_back_to_existing_agent_id(self) -> None: + out = _agent_summary_dict({"agent_id": 43, "name": "A"}) + assert out["agent_id"] == 43 + + class TestRunResult: def _info(self, status: TaskStatus, output: str | None) -> TaskInfo: return TaskInfo( From fa6440aa04e9097512a2c09ba2d8dfd4fa69e5fe Mon Sep 17 00:00:00 2001 From: Alexliu Date: Sat, 30 May 2026 13:33:13 +0800 Subject: [PATCH 10/16] refactor(errors): add MalformedResponse for decode-shape failures _parse_agent_create raised XAgentTransportError("malformed_response", ...) when the response body lacked its required 'agent' block. That overloaded the transport error -- the HTTP exchange had in fact succeeded; only the decoded payload was wrong. Introduce a dedicated MalformedResponse(XAgentError) so the exception type matches the failure class: the body did not match the shape the SDK needs to build a result. MalformedResponse is SDK-coined (the server never emits the code) so it stays out of _CODE_MAP but joins the public __all__ so callers can catch it. Public surface grows to 28 names; the README error table and the surface pin are updated accordingly. --- python/README.md | 1 + python/src/xagent_sdk/__init__.py | 2 ++ python/src/xagent_sdk/errors.py | 14 ++++++++++++++ python/src/xagent_sdk/types.py | 14 +++++++------- python/tests/unit/test_public_surface.py | 1 + python/tests/unit/test_types.py | 14 +++++++------- 6 files changed, 32 insertions(+), 14 deletions(-) diff --git a/python/README.md b/python/README.md index 03fb553..8273761 100644 --- a/python/README.md +++ b/python/README.md @@ -247,6 +247,7 @@ SDK-coined codes: | Exception | Cause | |---|---| | `XAgentTransportError` | network / DNS / TLS error below the HTTP layer | +| `MalformedResponse` | HTTP succeeded but the body did not match the shape the SDK needs | | `TaskTimeout` | `wait()` / `run()` deadline elapsed | The SDK does **not** retry automatically. Wrap calls with your own diff --git a/python/src/xagent_sdk/__init__.py b/python/src/xagent_sdk/__init__.py index 98663f4..737029a 100644 --- a/python/src/xagent_sdk/__init__.py +++ b/python/src/xagent_sdk/__init__.py @@ -5,6 +5,7 @@ InternalError, InvalidAPIKey, InvalidInput, + MalformedResponse, RateLimited, TaskBusy, TaskNotFound, @@ -40,6 +41,7 @@ "InternalError", "InvalidAPIKey", "InvalidInput", + "MalformedResponse", "RateLimited", "RotateKeyResult", "RunResult", diff --git a/python/src/xagent_sdk/errors.py b/python/src/xagent_sdk/errors.py index 1b4ac99..05a6d48 100644 --- a/python/src/xagent_sdk/errors.py +++ b/python/src/xagent_sdk/errors.py @@ -83,6 +83,20 @@ class XAgentTransportError(XAgentError): """ +class MalformedResponse(XAgentError): + """The HTTP exchange succeeded but the body did not match the shape the + SDK requires to build a result. + + Raised by the response parsers (e.g. an agent-create body missing its + ``agent`` block) so the failure points at the response contract rather + than surfacing as a raw pydantic ``ValidationError`` on a derived + field. ``http_status`` is ``None`` because the status line itself was + not the problem -- the decoded payload was. The ``code`` is the + SDK-coined string ``malformed_response``; the server never emits it, + so it is absent from ``_CODE_MAP``. + """ + + class TaskTimeout(XAgentError): """``wait()`` / ``run()`` exceeded its local deadline waiting for a task to reach a terminal state. diff --git a/python/src/xagent_sdk/types.py b/python/src/xagent_sdk/types.py index 8404b29..c4989d1 100644 --- a/python/src/xagent_sdk/types.py +++ b/python/src/xagent_sdk/types.py @@ -5,7 +5,7 @@ from pydantic import TypeAdapter -from xagent_sdk.errors import XAgentTransportError +from xagent_sdk.errors import MalformedResponse class TaskStatus(StrEnum): @@ -307,11 +307,11 @@ def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: absent and the runtime fields stay ``None`` -- caller is expected to materialize a key via ``rotate_key()`` later. - Raises ``XAgentTransportError("malformed_response", ...)`` if the - ``agent`` block is missing or lacks ``id``/``name`` -- pydantic's - raw ``ValidationError`` on ``agent_id`` would say "Input should be a - valid integer, input_value=None" which does not point at the real - cause (backend response shape violation). + Raises ``MalformedResponse`` if the ``agent`` block is missing or + lacks ``id``/``name`` -- pydantic's raw ``ValidationError`` on + ``agent_id`` would say "Input should be a valid integer, + input_value=None" which does not point at the real cause (backend + response shape violation). """ agent = data.get("agent") if ( @@ -319,7 +319,7 @@ def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: or agent.get("id") is None or agent.get("name") is None ): - raise XAgentTransportError( + raise MalformedResponse( "malformed_response", "agent-create response missing required 'agent' block " "(expected {'agent': {'id': int, 'name': str, ...}, 'api_key'?: {...}})", diff --git a/python/tests/unit/test_public_surface.py b/python/tests/unit/test_public_surface.py index 87084a7..b317992 100644 --- a/python/tests/unit/test_public_surface.py +++ b/python/tests/unit/test_public_surface.py @@ -41,6 +41,7 @@ "InternalError", "InvalidInput", "TemplateNotFound", + "MalformedResponse", "XAgentTransportError", "TaskTimeout", # Version diff --git a/python/tests/unit/test_types.py b/python/tests/unit/test_types.py index 1376cff..10cf86d 100644 --- a/python/tests/unit/test_types.py +++ b/python/tests/unit/test_types.py @@ -15,7 +15,7 @@ TaskInfo, TaskStatus, ) -from xagent_sdk.errors import XAgentTransportError +from xagent_sdk.errors import MalformedResponse from xagent_sdk.types import ( _agent_summary_dict, _parse_agent_create, @@ -202,8 +202,8 @@ def test_step_frozen(self) -> None: class TestParseAgentCreateMalformed: - """Guard the explicit ``XAgentTransportError("malformed_response", ...)`` - raised when the backend body lacks the required ``agent`` block. + """Guard the explicit ``MalformedResponse`` raised when the backend + body lacks the required ``agent`` block. """ def _good_api_key(self) -> dict[str, object]: @@ -214,25 +214,25 @@ def _good_api_key(self) -> dict[str, object]: } def test_missing_agent_block_raises(self) -> None: - with pytest.raises(XAgentTransportError) as excinfo: + with pytest.raises(MalformedResponse) as excinfo: _parse_agent_create({"api_key": self._good_api_key()}) assert excinfo.value.code == "malformed_response" assert "'agent'" in str(excinfo.value) def test_agent_block_is_not_dict_raises(self) -> None: - with pytest.raises(XAgentTransportError) as excinfo: + with pytest.raises(MalformedResponse) as excinfo: _parse_agent_create({"agent": None, "api_key": self._good_api_key()}) assert excinfo.value.code == "malformed_response" def test_agent_block_missing_id_raises(self) -> None: - with pytest.raises(XAgentTransportError) as excinfo: + with pytest.raises(MalformedResponse) as excinfo: _parse_agent_create( {"agent": {"name": "x"}, "api_key": self._good_api_key()} ) assert excinfo.value.code == "malformed_response" def test_agent_block_missing_name_raises(self) -> None: - with pytest.raises(XAgentTransportError) as excinfo: + with pytest.raises(MalformedResponse) as excinfo: _parse_agent_create({"agent": {"id": 42}, "api_key": self._good_api_key()}) assert excinfo.value.code == "malformed_response" From 3bc7692d0667b853245c7a9ce29bbbb3f8f69fdb Mon Sep 17 00:00:00 2001 From: Alexliu Date: Sat, 30 May 2026 13:51:52 +0800 Subject: [PATCH 11/16] harden single-object parsers against non-dict response bodies The list parsers guard with isinstance(data, list); the single-object parsers did not, so a backend (or proxy) returning a non-object body surfaced as a raw pydantic ValidationError or AttributeError that did not name the real cause. Add a shared _require_mapping(data, what) helper and route every single-object parser through it -- _parse_user_principal, _parse_template_detail, _parse_agent_create, _parse_rotate_key, _parse_create_task, _parse_append, _parse_task_info -- so they all raise MalformedResponse uniformly. _parse_agent_create additionally coerces a non-dict api_key block to {} instead of trusting its .get(). Establishes the invariant: every parser validates its input shape before trusting it. Covered by a parametrized matrix (7 parsers x 5 non-dict bodies) plus the api_key-coercion case. --- python/src/xagent_sdk/types.py | 53 ++++++++++++++++++++++++--------- python/tests/unit/test_types.py | 37 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/python/src/xagent_sdk/types.py b/python/src/xagent_sdk/types.py index c4989d1..b27ba50 100644 --- a/python/src/xagent_sdk/types.py +++ b/python/src/xagent_sdk/types.py @@ -262,8 +262,28 @@ def status(self) -> TaskStatus: _STEP_LIST_ADAPTER = TypeAdapter(list[Step]) -def _parse_user_principal(data: dict[str, Any]) -> UserPrincipal: - return _USER_PRINCIPAL_ADAPTER.validate_python(data) +def _require_mapping(data: Any, what: str) -> dict[str, Any]: + """Return ``data`` if it is a dict, else raise ``MalformedResponse``. + + The single-object parsers below trust ``data`` to be a JSON object; + a backend (or proxy) returning a non-object body would otherwise + surface as a raw pydantic ``ValidationError`` / ``AttributeError`` + that does not name the real cause. Mirrors the ``isinstance(data, + list)`` guard the list parsers already apply. + """ + if not isinstance(data, dict): + raise MalformedResponse( + "malformed_response", + f"expected a JSON object for {what}, got {type(data).__name__}", + http_status=None, + ) + return data + + +def _parse_user_principal(data: Any) -> UserPrincipal: + return _USER_PRINCIPAL_ADAPTER.validate_python( + _require_mapping(data, "user principal") + ) def _parse_template_list(data: Any) -> list[Template]: @@ -281,8 +301,10 @@ def _parse_template_list(data: Any) -> list[Template]: return _TEMPLATE_LIST_ADAPTER.validate_python(normalized) -def _parse_template_detail(data: dict[str, Any]) -> TemplateDetail: - return _TEMPLATE_DETAIL_ADAPTER.validate_python(_template_dict(data)) +def _parse_template_detail(data: Any) -> TemplateDetail: + return _TEMPLATE_DETAIL_ADAPTER.validate_python( + _template_dict(_require_mapping(data, "template detail")) + ) def _parse_agent_list(data: Any) -> list[AgentSummary]: @@ -297,7 +319,7 @@ def _parse_agent_list(data: Any) -> list[AgentSummary]: return _AGENT_LIST_ADAPTER.validate_python(normalized) -def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: +def _parse_agent_create(data: Any) -> AgentCreateResult: """Parse the nested ``{"agent": {...}, "api_key": {...}}`` payload. Backend returns the new agent row and (when ``generate_runtime_key`` @@ -313,6 +335,7 @@ def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: input_value=None" which does not point at the real cause (backend response shape violation). """ + data = _require_mapping(data, "agent-create") agent = data.get("agent") if ( not isinstance(agent, dict) @@ -325,7 +348,9 @@ def _parse_agent_create(data: dict[str, Any]) -> AgentCreateResult: "(expected {'agent': {'id': int, 'name': str, ...}, 'api_key'?: {...}})", http_status=None, ) - api_key = data.get("api_key") or {} + api_key = data.get("api_key") + if not isinstance(api_key, dict): + api_key = {} flat = { "agent_id": agent.get("id"), "name": agent.get("name"), @@ -352,20 +377,20 @@ def _agent_summary_dict(item: dict[str, Any]) -> dict[str, Any]: return {**item, "agent_id": item.get("id") or item.get("agent_id")} -def _parse_rotate_key(data: dict[str, Any]) -> RotateKeyResult: - return _ROTATE_KEY_ADAPTER.validate_python(data) +def _parse_rotate_key(data: Any) -> RotateKeyResult: + return _ROTATE_KEY_ADAPTER.validate_python(_require_mapping(data, "key rotation")) -def _parse_create_task(data: dict[str, Any]) -> CreateTaskResult: - return _CREATE_ADAPTER.validate_python(data) +def _parse_create_task(data: Any) -> CreateTaskResult: + return _CREATE_ADAPTER.validate_python(_require_mapping(data, "task creation")) -def _parse_append(data: dict[str, Any]) -> AppendResult: - return _APPEND_ADAPTER.validate_python(data) +def _parse_append(data: Any) -> AppendResult: + return _APPEND_ADAPTER.validate_python(_require_mapping(data, "task append")) -def _parse_task_info(data: dict[str, Any]) -> TaskInfo: - return _TASK_INFO_ADAPTER.validate_python(data) +def _parse_task_info(data: Any) -> TaskInfo: + return _TASK_INFO_ADAPTER.validate_python(_require_mapping(data, "task info")) def _parse_steps(data: Any) -> list[Step]: diff --git a/python/tests/unit/test_types.py b/python/tests/unit/test_types.py index 10cf86d..551f6e7 100644 --- a/python/tests/unit/test_types.py +++ b/python/tests/unit/test_types.py @@ -21,8 +21,11 @@ _parse_agent_create, _parse_append, _parse_create_task, + _parse_rotate_key, _parse_steps, _parse_task_info, + _parse_template_detail, + _parse_user_principal, _template_dict, ) @@ -237,6 +240,40 @@ def test_agent_block_missing_name_raises(self) -> None: assert excinfo.value.code == "malformed_response" +class TestSingleObjectParsersRejectNonDict: + """Every single-object parser must surface MalformedResponse (not a + raw ValidationError / AttributeError) when the body is not a JSON + object. Mirrors the list parsers' isinstance guard. + """ + + @pytest.mark.parametrize( + "parser", + [ + _parse_user_principal, + _parse_template_detail, + _parse_agent_create, + _parse_rotate_key, + _parse_create_task, + _parse_append, + _parse_task_info, + ], + ) + @pytest.mark.parametrize("body", [None, [1, 2], "oops", 42, True]) + def test_non_dict_body_raises_malformed(self, parser: object, body: object) -> None: + with pytest.raises(MalformedResponse) as excinfo: + parser(body) # type: ignore[operator] + assert excinfo.value.code == "malformed_response" + + def test_agent_create_non_dict_api_key_coerced(self) -> None: + # A non-dict api_key block must not crash; runtime fields stay None. + result = _parse_agent_create( + {"agent": {"id": 7, "name": "A"}, "api_key": "not-a-dict"} + ) + assert result.agent_id == 7 + assert result.runtime_full_key is None + assert result.runtime_key_prefix is None + + class TestNormalizeHelpers: """Pin ``_template_dict`` / ``_agent_summary_dict`` shape contracts.""" From b30185b4d3f1b02a3cb30fe41f3f7aa43c0d6b70 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Sat, 30 May 2026 22:30:51 +0800 Subject: [PATCH 12/16] fail closed when generate_runtime_key=True returns no key; fix shared fixture docs Two review findings. agents.create / create_from_template fail-closed generate_runtime_key=True is a promise the response carries a one-time runtime key. _parse_agent_create only validated the 'agent' block, so a response omitting 'api_key' yielded runtime_full_key=None -- identical to the generate_runtime_key=False path. A caller then doing AgentClient(api_key=result.runtime_full_key) passes None, which _BaseClient resolves via `api_key or os.environ[...]`, silently using XAGENT_API_KEY -- a different agent's credential. Add _require_runtime_key to both create paths: when a key was requested but the response carried none, raise MalformedResponse instead of returning a keyless result. shared/README.md fixture contract The GET /v1/templates and GET /v1/agents rows described wrapper objects ({templates: [...]} / {agents: [...]}) but the fixtures, parsers, and backend return bare JSON arrays keyed by `id`. Corrected those rows, the agents_create row (nested {agent, api_key} shape), and the stale error count so other-language clients implement the real wire contract. --- python/src/xagent_sdk/_agents.py | 39 +++++++++++++++++++-- python/tests/unit/test_agents_management.py | 26 ++++++++++++++ shared/README.md | 16 ++++----- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/python/src/xagent_sdk/_agents.py b/python/src/xagent_sdk/_agents.py index 34b69da..5b7ad67 100644 --- a/python/src/xagent_sdk/_agents.py +++ b/python/src/xagent_sdk/_agents.py @@ -15,6 +15,7 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any +from xagent_sdk.errors import MalformedResponse from xagent_sdk.types import ( AgentCreateResult, AgentSummary, @@ -28,6 +29,30 @@ from xagent_sdk.user_client import UserClient +def _require_runtime_key( + result: AgentCreateResult, generate_runtime_key: bool +) -> AgentCreateResult: + """Fail closed when a key was requested but the response carried none. + + ``generate_runtime_key=True`` is a promise that the response includes + a one-time runtime key. If the backend omits it, returning a result + with ``runtime_full_key=None`` is dangerous: the caller is expected to + do ``AgentClient(api_key=result.runtime_full_key)``, and ``None`` there + falls back to ``XAGENT_API_KEY`` in the environment -- silently using + a *different* agent's credential. Raise instead of handing back a + keyless result that invites that fallback. + """ + if generate_runtime_key and result.runtime_full_key is None: + raise MalformedResponse( + "malformed_response", + "create requested generate_runtime_key=True but the response " + "carried no runtime key; refusing to return a keyless result " + "that would let AgentClient fall back to XAGENT_API_KEY", + http_status=None, + ) + return result + + class AgentsAPI: """The ``user_client.agents`` namespace.""" @@ -77,6 +102,9 @@ def create( InvalidInput: 422 -- backend rejected the body (e.g. empty ``name`` or ``instructions``). InvalidAPIKey: 401 -- personal key invalid / revoked. + MalformedResponse: ``generate_runtime_key=True`` but the + response carried no runtime key (fail closed rather than + return a keyless result). """ body: dict[str, Any] = { "name": name, @@ -86,7 +114,9 @@ def create( if metadata is not None: body["metadata"] = metadata resp = self._client._request("POST", "/v1/agents", json=body) - return _parse_agent_create(resp.json()) + return _require_runtime_key( + _parse_agent_create(resp.json()), generate_runtime_key + ) def create_from_template( self, @@ -122,6 +152,9 @@ def create_from_template( ``template_id``. InvalidInput: 422 -- overrides contain malformed values. InvalidAPIKey: 401 -- personal key invalid / revoked. + MalformedResponse: ``generate_runtime_key=True`` but the + response carried no runtime key (fail closed rather than + return a keyless result). """ body: dict[str, Any] = { **(dict(overrides) if overrides else {}), @@ -129,7 +162,9 @@ def create_from_template( "generate_runtime_key": generate_runtime_key, } resp = self._client._request("POST", "/v1/agents/from-template", json=body) - return _parse_agent_create(resp.json()) + return _require_runtime_key( + _parse_agent_create(resp.json()), generate_runtime_key + ) def rotate_key(self, agent_id: int) -> RotateKeyResult: """``POST /v1/agents/{agent_id}/api-key`` -- rotate runtime key. diff --git a/python/tests/unit/test_agents_management.py b/python/tests/unit/test_agents_management.py index f3d5f1d..1547384 100644 --- a/python/tests/unit/test_agents_management.py +++ b/python/tests/unit/test_agents_management.py @@ -10,6 +10,7 @@ AgentNotFound, AgentSummary, InvalidInput, + MalformedResponse, RotateKeyResult, TemplateNotFound, UserClient, @@ -122,6 +123,21 @@ def handler(req: httpx.Request) -> httpx.Response: assert result.runtime_full_key is None assert result.runtime_key_prefix is None + def test_generate_runtime_key_true_but_no_key_fails_closed(self) -> None: + # Backend violated the contract: generate_runtime_key=True was + # requested but the response carried no api_key block. The SDK + # must refuse rather than return runtime_full_key=None, which + # would let AgentClient(api_key=None) silently fall back to + # XAGENT_API_KEY. + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, json={"agent": {"id": 42, "name": "HR Leave Assistant"}} + ) + + with _make_user(handler) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create(name="HR Leave Assistant", instructions="...") + assert excinfo.value.code == "malformed_response" + def test_metadata_included_when_given(self) -> None: captured: list[httpx.Request] = [] @@ -227,6 +243,16 @@ def h(req: httpx.Request) -> httpx.Response: with _make_user(h) as c, pytest.raises(TemplateNotFound): c.agents.create_from_template("nope") + def test_generate_runtime_key_true_but_no_key_fails_closed(self) -> None: + # Same fail-closed contract as create(): default + # generate_runtime_key=True with a keyless response must raise. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(201, json={"agent": {"id": 42, "name": "X"}}) + + with _make_user(h) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create_from_template("q_and_a") + assert excinfo.value.code == "malformed_response" + class TestRotateKey: def test_url_and_parse(self) -> None: diff --git a/shared/README.md b/shared/README.md index 14de357..748fdc0 100644 --- a/shared/README.md +++ b/shared/README.md @@ -16,11 +16,11 @@ implicit and documented below. | File | Endpoint | Notes | |---|---|---| -| `me_user.json` | `GET /v1/me` | 0.2.0+ user principal: `principal_type / user_id / email / name / key_prefix` (replaces the 0.1.0 `me.json` agent shape) | -| `templates_list.json` | `GET /v1/templates` | Wrapper `{templates: [Template]}`; slim list entries with `template_id`, `name`, optional `description` | -| `templates_detail.json` | `GET /v1/templates/{id}` | Single template with the merge-target `agent_config` dict | -| `agents_list.json` | `GET /v1/agents` | Wrapper `{agents: [AgentSummary]}`; covers `active`, `draft`, `paused` status values | -| `agents_create.json` | `POST /v1/agents` or `POST /v1/agents/from-template` | Default response with `generate_runtime_key=True`; carries the one-time `runtime_full_key` | +| `me_user.json` | `GET /v1/me` | User principal: `principal_type / user_id / email / name / key_prefix` | +| `templates_list.json` | `GET /v1/templates` | Bare JSON array; each entry keys its id under `id` (SDKs surface it as `template_id`) plus `name`, optional `description` | +| `templates_detail.json` | `GET /v1/templates/{id}` | Single object keyed by `id`; carries the merge-target `agent_config` dict | +| `agents_list.json` | `GET /v1/agents` | Bare JSON array; each entry keys its id under `id` (SDKs surface it as `agent_id`); covers `active`, `draft`, `paused` status values | +| `agents_create.json` | `POST /v1/agents` or `POST /v1/agents/from-template` | Nested `{agent: {id, name, ...}, api_key: {full_key, key_prefix, created_at}}`; the `api_key` block is present only when `generate_runtime_key=True` | | `rotate_key.json` | `POST /v1/agents/{id}/api-key` | Rotation result with one-time `full_key` and public-safe `key_prefix` | | `create_task.json` | `POST /v1/chat/tasks` (202) | Initial `status=pending` | | `append_task.json` | `POST /v1/chat/tasks/{id}/messages` (202) | `status=running`, carries `accepted_at` (not `created_at`) | @@ -29,7 +29,7 @@ implicit and documented below. ### `fixtures/v1/errors/` -Seven stable backend codes using the V1 envelope shape: +Stable backend codes using the V1 envelope shape: `{"error": {"code": "...", "message": "..."}}`. | File | HTTP status | Wire shape | @@ -38,9 +38,9 @@ Seven stable backend codes using the V1 envelope shape: | `agent_not_found.json` | 404 | V1 envelope | | `task_not_found.json` | 404 | V1 envelope | | `task_busy.json` | 409 | V1 envelope | -| `template_not_found.json` | 404 | V1 envelope (0.2.0+; raised by `UserClient.templates.get()` and `UserClient.agents.create_from_template()` on unknown `template_id`) | +| `template_not_found.json` | 404 | V1 envelope (raised by `UserClient.templates.get()` and `UserClient.agents.create_from_template()` on unknown `template_id`) | | `validation_422.json` | 422 | V1 envelope (`invalid_input`) | -| `rate_limited.json` | 429 | V1 envelope (reserved; backend does not currently emit it) | +| `rate_limited.json` | 429 | V1 envelope | | `internal_error.json` | 500 | V1 envelope | ## Usage from a language client From 362acd8c82c351fb723afca36bdeab55faf2eed5 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Sat, 30 May 2026 22:38:49 +0800 Subject: [PATCH 13/16] strip process-history wording from e2e + README; rename grep-guard test The earlier wording cleanup covered src/ and unit tests but missed the e2e suite and the public README. Remove the version anchors and roadmap labels that describe how the codebase evolved rather than what it does: - e2e: drop "(0.2.0)" / "Phase 2" / "no longer exposes" from module and test docstrings; rename test_e2e_full_flow_phase2 -> test_e2e_full_flow. - e2e: default E2E_TEMPLATE_ID to the first listed template instead of a hardcoded id, and skip (not fail) when the backend lists no templates. - README: drop "Phase 2 happy path", "Phase 3 roadmap", and "reserved; backend does not yet emit"; the migration guide and semver notes keep their version references because that is their purpose. - README examples used invented template ids; switch to a real backend-defined id and note the set is backend-defined. - Rename test_check_no_legacy_callsites.py -> test_forbidden_symbols.py and the leftover "from_legacy" test-data string -> "from_fallback". --- python/README.md | 25 +++++----- python/tests/e2e/conftest.py | 2 +- python/tests/e2e/test_smoke.py | 50 +++++++++---------- ...callsites.py => test_forbidden_symbols.py} | 4 +- python/tests/unit/test_types.py | 2 +- 5 files changed, 42 insertions(+), 41 deletions(-) rename python/tests/unit/{test_check_no_legacy_callsites.py => test_forbidden_symbols.py} (94%) diff --git a/python/README.md b/python/README.md index 8273761..cf244c6 100644 --- a/python/README.md +++ b/python/README.md @@ -73,7 +73,7 @@ callsites are surfaced at startup. ## Quick start -The Phase 2 happy path is two steps: use a personal key to mint or +The happy path is two steps: use a personal key to mint or look up an agent, then use that agent's runtime key to run tasks against it. @@ -84,7 +84,7 @@ from xagent_sdk import AgentClient, UserClient # response carries a one-time runtime key. with UserClient() as user: # reads env vars new_agent = user.agents.create_from_template( - "q_and_a", + "support-ai-chatbot-agent", overrides={"name": "HR Leave Assistant"}, ) @@ -153,13 +153,14 @@ from xagent_sdk import AgentClient, UserClient with UserClient() as user: templates = user.templates.list() print([t.template_id for t in templates]) - # ['content_generator', 'analyzer', 'q_and_a', 'assistant'] + # template ids are backend-defined, e.g. + # ['support-ai-chatbot-agent', 'sales-inbound-agent', ...] - detail = user.templates.get("q_and_a") + detail = user.templates.get("support-ai-chatbot-agent") # detail.agent_config is the merge target the backend uses below created = user.agents.create_from_template( - "q_and_a", + "support-ai-chatbot-agent", overrides={"name": "Policy Bot"}, ) print(created.agent_id, created.runtime_key_prefix) @@ -239,7 +240,7 @@ All SDK exceptions inherit from `XAgentError` and carry `code`, | `TemplateNotFound` | 404 | `template_not_found` | | `TaskBusy` | 409 | `task_busy` | | `InvalidInput` | 422 | `invalid_input` | -| `RateLimited` | 429 | `rate_limited` (reserved; backend does not yet emit) | +| `RateLimited` | 429 | `rate_limited` | | `InternalError` | 500 | `internal_error` | SDK-coined codes: @@ -256,7 +257,7 @@ want retry on transport errors or `TaskBusy`. ## API reference -All methods are sync. An async client is on the Phase 3 roadmap. +All methods are synchronous. ### `UserClient` — management surface @@ -381,11 +382,11 @@ uv run pytest -m e2e ``` Set `E2E_AGENT_ID` to point the runtime-only tests at a specific -agent (0.2.0 ``AgentClient`` no longer has an identity probe, so the -runtime tests skip when this is unset). Set `E2E_TEMPLATE_ID` -(default ``q_and_a``) to pick which template the full-flow test -instantiates from, and `E2E_AGENT_NAME` to override the new agent's -display name. +agent (``AgentClient`` has no identity probe, so the runtime tests +skip when this is unset). Set `E2E_TEMPLATE_ID` to pick which template +the full-flow test instantiates from (defaults to the first listed +template), and `E2E_AGENT_NAME` to override the new agent's display +name. ## License diff --git a/python/tests/e2e/conftest.py b/python/tests/e2e/conftest.py index a3fead5..c415dd7 100644 --- a/python/tests/e2e/conftest.py +++ b/python/tests/e2e/conftest.py @@ -1,6 +1,6 @@ """Fixtures for end-to-end tests against a real xAgent backend. -Two pairs of fixtures cover the 0.2.0 surface: +Two pairs of fixtures cover the two-client surface: - ``user_client`` / ``patient_user_client`` -- ``UserClient`` authenticated with ``XAGENT_PERSONAL_KEY``. Use for management diff --git a/python/tests/e2e/test_smoke.py b/python/tests/e2e/test_smoke.py index bd3b446..8b43622 100644 --- a/python/tests/e2e/test_smoke.py +++ b/python/tests/e2e/test_smoke.py @@ -1,4 +1,4 @@ -"""End-to-end smoke tests against a real xAgent backend (0.2.0). +"""End-to-end smoke tests against a real xAgent backend. Marked ``@pytest.mark.e2e`` so the default ``pytest`` invocation skips the whole file. Run explicitly with:: @@ -6,15 +6,15 @@ XAGENT_BASE_URL=... XAGENT_PERSONAL_KEY=... XAGENT_API_KEY=... \\ uv run pytest -m e2e -Set ``E2E_AGENT_ID`` to override the agent id used by runtime-only -tests; otherwise the runtime tests will skip when the variable is -unset, since 0.2.0 ``AgentClient`` no longer exposes a self-identity -probe (use the personal-key path / ``UserClient.agents.list()`` to -discover agent ids). +Set ``E2E_AGENT_ID`` to pick the agent id used by runtime-only tests; +those tests skip when it is unset, because ``AgentClient`` exposes no +self-identity probe (discover agent ids via ``UserClient.agents.list()`` +on the personal-key path). -Set ``E2E_TEMPLATE_ID`` (default ``q_and_a``) to pick which template -the full-flow test instantiates an agent from. ``E2E_AGENT_NAME`` -(default ``e2e_smoke_``) controls the new agent's display name. +Set ``E2E_TEMPLATE_ID`` to pick which template the full-flow test +instantiates an agent from; it skips if the template list is empty or +the id is absent. ``E2E_AGENT_NAME`` (default ``e2e_smoke_``) +controls the new agent's display name. """ import os @@ -84,9 +84,9 @@ def test_create_is_async(patient_agent_client: AgentClient) -> None: def test_run_single_turn(agent_client: AgentClient) -> None: """Single-turn runtime probe with an existing agent. - Requires ``E2E_AGENT_ID`` because 0.2.0 ``AgentClient`` no longer - has an identity probe; the test would have nothing to point at - without a caller-supplied id. + Requires ``E2E_AGENT_ID`` because ``AgentClient`` has no identity + probe; the test would have nothing to point at without a + caller-supplied id. """ agent_id_env = os.environ.get("E2E_AGENT_ID") if not agent_id_env: @@ -103,27 +103,27 @@ def test_run_single_turn(agent_client: AgentClient) -> None: assert result.output is not None -def test_e2e_full_flow_phase2(user_client: UserClient) -> None: - """End-to-end Phase 2 flow: pick a template, create an agent, run it. +def test_e2e_full_flow(user_client: UserClient) -> None: + """Pick a template, create an agent from it, run a single turn. - 1. List templates and confirm at least one entry exists. - 2. Pick ``E2E_TEMPLATE_ID`` (default ``q_and_a``) and call - ``agents.create_from_template`` to mint a fresh agent + runtime - key. + 1. List templates; skip if the backend exposes none. + 2. Pick ``E2E_TEMPLATE_ID`` if set, else the first listed template, + and call ``agents.create_from_template`` to mint a fresh agent + + runtime key. 3. Build an ``AgentClient`` with the freshly minted runtime key. - 4. Drive a single-turn ``tasks.run()`` against the new agent and - assert it completes. + 4. Drive a single-turn ``tasks.run()`` and assert it completes. - The test deliberately does **not** clean up the created agent -- - Phase 2 SDK has no delete method, and the backend's own - housekeeping owns the lifecycle. Run on a scratch backend instance. + The test does not delete the created agent -- the SDK has no delete + method and the backend owns lifecycle cleanup. Run on a scratch + backend instance. """ - template_id = os.environ.get("E2E_TEMPLATE_ID", "q_and_a") agent_name = os.environ.get("E2E_AGENT_NAME", f"e2e_smoke_{os.getpid()}") base_url = os.environ["XAGENT_BASE_URL"] templates = user_client.templates.list() - assert templates, "backend returned an empty template list" + if not templates: + pytest.skip("backend exposes no templates; nothing to instantiate") + template_id = os.environ.get("E2E_TEMPLATE_ID", templates[0].template_id) created = user_client.agents.create_from_template( template_id, overrides={"name": agent_name} diff --git a/python/tests/unit/test_check_no_legacy_callsites.py b/python/tests/unit/test_forbidden_symbols.py similarity index 94% rename from python/tests/unit/test_check_no_legacy_callsites.py rename to python/tests/unit/test_forbidden_symbols.py index f0d06e2..0f1c952 100644 --- a/python/tests/unit/test_check_no_legacy_callsites.py +++ b/python/tests/unit/test_forbidden_symbols.py @@ -19,7 +19,7 @@ def _repo_root() -> Path: - # tests/unit/test_check_no_legacy_callsites.py -> parents[3] == repo root + # tests/unit/test_forbidden_symbols.py -> parents[3] == repo root return Path(__file__).resolve().parents[3] @@ -30,7 +30,7 @@ def _grep(pattern: str) -> subprocess.CompletedProcess[str]: "grep", "-rn", "--include=*.py", - "--exclude=test_check_no_legacy_callsites.py", + "--exclude=test_forbidden_symbols.py", "--exclude=test_public_surface.py", pattern, str(repo / "python" / "src"), diff --git a/python/tests/unit/test_types.py b/python/tests/unit/test_types.py index 551f6e7..0271073 100644 --- a/python/tests/unit/test_types.py +++ b/python/tests/unit/test_types.py @@ -290,7 +290,7 @@ def test_template_dict_falls_back_to_existing_template_id(self) -> None: def test_template_dict_id_wins_when_both_present(self) -> None: # Current backend contract: "id" is the source of truth. - out = _template_dict({"id": "from_id", "template_id": "from_legacy"}) + out = _template_dict({"id": "from_id", "template_id": "from_fallback"}) assert out["template_id"] == "from_id" def test_template_dict_missing_id_yields_none(self) -> None: From 347ba37e910c62eed3145a3afeb096f7f61353d3 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Sun, 31 May 2026 16:44:33 +0800 Subject: [PATCH 14/16] reject empty runtime key, not just None, in the fail-closed check _require_runtime_key tested `runtime_full_key is None`, but _BaseClient resolves the key with `api_key or os.environ.get(...)` -- which falls back to XAGENT_API_KEY for any falsy value, including an empty string. A response carrying `full_key=""` therefore slipped past the guard and let AgentClient silently authenticate as a different agent. Switch the check to `not result.runtime_full_key` so it mirrors the same falsiness the resolver uses. Cover the empty-string case for both create() and create_from_template(). --- python/src/xagent_sdk/_agents.py | 20 +++++----- python/tests/unit/test_agents_management.py | 41 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/python/src/xagent_sdk/_agents.py b/python/src/xagent_sdk/_agents.py index 5b7ad67..9d89fe8 100644 --- a/python/src/xagent_sdk/_agents.py +++ b/python/src/xagent_sdk/_agents.py @@ -35,19 +35,21 @@ def _require_runtime_key( """Fail closed when a key was requested but the response carried none. ``generate_runtime_key=True`` is a promise that the response includes - a one-time runtime key. If the backend omits it, returning a result - with ``runtime_full_key=None`` is dangerous: the caller is expected to - do ``AgentClient(api_key=result.runtime_full_key)``, and ``None`` there - falls back to ``XAGENT_API_KEY`` in the environment -- silently using - a *different* agent's credential. Raise instead of handing back a - keyless result that invites that fallback. + a one-time runtime key. A missing **or empty** ``runtime_full_key`` is + dangerous: the caller is expected to do + ``AgentClient(api_key=result.runtime_full_key)``, and ``_BaseClient`` + resolves the key with ``api_key or os.environ.get(...)`` -- so any + falsy value (``None`` or ``""``) falls back to ``XAGENT_API_KEY`` and + silently authenticates as a *different* agent. The check mirrors that + falsiness rather than testing ``is None`` so the empty-string case is + rejected too. """ - if generate_runtime_key and result.runtime_full_key is None: + if generate_runtime_key and not result.runtime_full_key: raise MalformedResponse( "malformed_response", "create requested generate_runtime_key=True but the response " - "carried no runtime key; refusing to return a keyless result " - "that would let AgentClient fall back to XAGENT_API_KEY", + "carried no usable runtime key; refusing to return a keyless " + "result that would let AgentClient fall back to XAGENT_API_KEY", http_status=None, ) return result diff --git a/python/tests/unit/test_agents_management.py b/python/tests/unit/test_agents_management.py index 1547384..3a5034f 100644 --- a/python/tests/unit/test_agents_management.py +++ b/python/tests/unit/test_agents_management.py @@ -138,6 +138,27 @@ def handler(req: httpx.Request) -> httpx.Response: c.agents.create(name="HR Leave Assistant", instructions="...") assert excinfo.value.code == "malformed_response" + def test_generate_runtime_key_true_but_empty_key_fails_closed(self) -> None: + # An empty-string full_key is as dangerous as a missing one: + # AgentClient(api_key="") also falls back to XAGENT_API_KEY because + # _BaseClient resolves with `api_key or env`. Must fail closed. + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "agent": {"id": 42, "name": "X"}, + "api_key": { + "full_key": "", + "key_prefix": "", + "created_at": "2026-05-29T00:00:00Z", + }, + }, + ) + + with _make_user(handler) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create(name="X", instructions="...") + assert excinfo.value.code == "malformed_response" + def test_metadata_included_when_given(self) -> None: captured: list[httpx.Request] = [] @@ -253,6 +274,26 @@ def h(req: httpx.Request) -> httpx.Response: c.agents.create_from_template("q_and_a") assert excinfo.value.code == "malformed_response" + def test_generate_runtime_key_true_but_empty_key_fails_closed(self) -> None: + # Empty-string full_key must fail closed here too (same `or` + # fallback footgun as create()). + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "agent": {"id": 42, "name": "X"}, + "api_key": { + "full_key": "", + "key_prefix": "", + "created_at": "2026-05-29T00:00:00Z", + }, + }, + ) + + with _make_user(h) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create_from_template("q_and_a") + assert excinfo.value.code == "malformed_response" + class TestRotateKey: def test_url_and_parse(self) -> None: From a9a86ab79bf8c65f100f2b378ab11fddad0d725e Mon Sep 17 00:00:00 2001 From: Alexliu Date: Sun, 31 May 2026 16:53:39 +0800 Subject: [PATCH 15/16] enforce the non-empty-key invariant at the client boundary, not the call site The runtime-key footgun kept reappearing because the empty/missing-key invariant was being patched at each call site (agents.create paths) while the actual sharp edge lived in _BaseClient: it resolved keys with `api_key = api_key or os.environ.get(...)`, so any falsy explicit value -- None or "" -- was silently swapped for XAGENT_API_KEY, authenticating as a different agent. Fix it at the boundary instead. _BaseClient now falls back to the environment only when the argument was omitted (is None); an explicitly passed empty string reaches the existing `if not api_key: raise` guard and fails. Applied to both api_key and base_url, since both shared the same lossy `or` resolution. This splits responsibility cleanly: - "" (and any non-None falsy) -> rejected at _BaseClient construction; no call site can route it to the environment. - None from a create response (backend omitted the key block) -> still caught earlier by _require_runtime_key at create() time, because None is exactly the value _BaseClient treats as "use env", so the create guard must resolve the ambiguity with its knowledge that generate_runtime_key=True was requested. Reverted that guard from the `not`-based call-site patch back to an `is None` check scoped to its real job (missing key block). The empty-string fail-closed tests moved from the agents call sites to _BaseClient construction tests (empty api_key / base_url do not fall back to env), where the invariant now lives. --- python/src/xagent_sdk/_agents.py | 30 ++++++++------- python/src/xagent_sdk/_base.py | 12 +++++- python/tests/unit/test_agent_client.py | 20 ++++++++++ python/tests/unit/test_agents_management.py | 41 --------------------- python/tests/unit/test_user_client.py | 9 +++++ 5 files changed, 55 insertions(+), 57 deletions(-) diff --git a/python/src/xagent_sdk/_agents.py b/python/src/xagent_sdk/_agents.py index 9d89fe8..9ec7809 100644 --- a/python/src/xagent_sdk/_agents.py +++ b/python/src/xagent_sdk/_agents.py @@ -32,24 +32,26 @@ def _require_runtime_key( result: AgentCreateResult, generate_runtime_key: bool ) -> AgentCreateResult: - """Fail closed when a key was requested but the response carried none. - - ``generate_runtime_key=True`` is a promise that the response includes - a one-time runtime key. A missing **or empty** ``runtime_full_key`` is - dangerous: the caller is expected to do - ``AgentClient(api_key=result.runtime_full_key)``, and ``_BaseClient`` - resolves the key with ``api_key or os.environ.get(...)`` -- so any - falsy value (``None`` or ``""``) falls back to ``XAGENT_API_KEY`` and - silently authenticates as a *different* agent. The check mirrors that - falsiness rather than testing ``is None`` so the empty-string case is - rejected too. + """Fail closed when a key was requested but the response omitted it. + + ``generate_runtime_key=True`` is a promise that the response carries a + one-time runtime key. When the backend omits it, ``runtime_full_key`` + is ``None`` -- and ``None`` is exactly the value ``_BaseClient`` treats + as "argument not supplied", so ``AgentClient(api_key=None)`` would fall + back to ``XAGENT_API_KEY`` and silently authenticate as a *different* + agent. This guard catches that at ``create`` time with a message that + names the backend contract, rather than letting it surface later as a + generic construction error. + + The empty-string case (``runtime_full_key == ""``) is handled one layer + down: ``_BaseClient`` only env-falls-back on ``None``, so an explicit + empty key reaches its ``not api_key`` guard and raises there. """ - if generate_runtime_key and not result.runtime_full_key: + if generate_runtime_key and result.runtime_full_key is None: raise MalformedResponse( "malformed_response", "create requested generate_runtime_key=True but the response " - "carried no usable runtime key; refusing to return a keyless " - "result that would let AgentClient fall back to XAGENT_API_KEY", + "carried no runtime key; refusing to return a keyless result", http_status=None, ) return result diff --git a/python/src/xagent_sdk/_base.py b/python/src/xagent_sdk/_base.py index 7c091e8..668a53e 100644 --- a/python/src/xagent_sdk/_base.py +++ b/python/src/xagent_sdk/_base.py @@ -47,8 +47,16 @@ def __init__( user_agent: str | None = None, transport: httpx.BaseTransport | None = None, ) -> None: - api_key = api_key or os.environ.get(self._ENV_API_KEY) - base_url = base_url or os.environ.get("XAGENT_BASE_URL") + # Fall back to the environment only when the argument was *omitted* + # (left as None), never when it was passed but empty. An explicit + # empty string is a caller/upstream bug, and resolving it via + # ``arg or os.environ[...]`` would silently authenticate with a + # *different* credential -- so an empty explicit value must reach + # the ``not ...`` guard below and raise, not get swapped for env. + if api_key is None: + api_key = os.environ.get(self._ENV_API_KEY) + if base_url is None: + base_url = os.environ.get("XAGENT_BASE_URL") if not api_key: raise ValueError( f"{self._API_KEY_FIELD} required: " diff --git a/python/tests/unit/test_agent_client.py b/python/tests/unit/test_agent_client.py index 6851373..f98a789 100644 --- a/python/tests/unit/test_agent_client.py +++ b/python/tests/unit/test_agent_client.py @@ -55,6 +55,26 @@ def test_missing_base_url(self) -> None: with pytest.raises(ValueError, match="base_url"): AgentClient(api_key="x") + def test_empty_api_key_does_not_fall_back_to_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # An explicit empty key must raise, never silently resolve to the + # environment value -- that would authenticate as whatever agent + # XAGENT_API_KEY happens to hold. Only an omitted (None) key may + # fall back to env. + monkeypatch.setenv("XAGENT_API_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="api_key"): + AgentClient(api_key="") + + def test_empty_base_url_does_not_fall_back_to_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("XAGENT_API_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="base_url"): + AgentClient(api_key="k", base_url="") + class TestLifecycle: def test_context_manager_closes( diff --git a/python/tests/unit/test_agents_management.py b/python/tests/unit/test_agents_management.py index 3a5034f..1547384 100644 --- a/python/tests/unit/test_agents_management.py +++ b/python/tests/unit/test_agents_management.py @@ -138,27 +138,6 @@ def handler(req: httpx.Request) -> httpx.Response: c.agents.create(name="HR Leave Assistant", instructions="...") assert excinfo.value.code == "malformed_response" - def test_generate_runtime_key_true_but_empty_key_fails_closed(self) -> None: - # An empty-string full_key is as dangerous as a missing one: - # AgentClient(api_key="") also falls back to XAGENT_API_KEY because - # _BaseClient resolves with `api_key or env`. Must fail closed. - def handler(req: httpx.Request) -> httpx.Response: - return httpx.Response( - 201, - json={ - "agent": {"id": 42, "name": "X"}, - "api_key": { - "full_key": "", - "key_prefix": "", - "created_at": "2026-05-29T00:00:00Z", - }, - }, - ) - - with _make_user(handler) as c, pytest.raises(MalformedResponse) as excinfo: - c.agents.create(name="X", instructions="...") - assert excinfo.value.code == "malformed_response" - def test_metadata_included_when_given(self) -> None: captured: list[httpx.Request] = [] @@ -274,26 +253,6 @@ def h(req: httpx.Request) -> httpx.Response: c.agents.create_from_template("q_and_a") assert excinfo.value.code == "malformed_response" - def test_generate_runtime_key_true_but_empty_key_fails_closed(self) -> None: - # Empty-string full_key must fail closed here too (same `or` - # fallback footgun as create()). - def h(req: httpx.Request) -> httpx.Response: - return httpx.Response( - 201, - json={ - "agent": {"id": 42, "name": "X"}, - "api_key": { - "full_key": "", - "key_prefix": "", - "created_at": "2026-05-29T00:00:00Z", - }, - }, - ) - - with _make_user(h) as c, pytest.raises(MalformedResponse) as excinfo: - c.agents.create_from_template("q_and_a") - assert excinfo.value.code == "malformed_response" - class TestRotateKey: def test_url_and_parse(self) -> None: diff --git a/python/tests/unit/test_user_client.py b/python/tests/unit/test_user_client.py index 8d5f9fd..5697836 100644 --- a/python/tests/unit/test_user_client.py +++ b/python/tests/unit/test_user_client.py @@ -66,6 +66,15 @@ def test_does_not_read_xagent_api_key( with pytest.raises(ValueError, match="personal_key"): UserClient() + def test_empty_personal_key_does_not_fall_back_to_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # An explicit empty key must raise, never resolve to the env value. + monkeypatch.setenv("XAGENT_PERSONAL_KEY", "xag_personal_envkey_envsec") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="personal_key"): + UserClient(personal_key="") + class TestMe: def test_returns_user_principal(self) -> None: From f395f800943212c0bdbd2e1911697f07a3549486 Mon Sep 17 00:00:00 2001 From: Jicheng Shi Date: Sun, 31 May 2026 21:18:42 +0800 Subject: [PATCH 16/16] fix(sdk): reject empty runtime keys from create responses --- python/src/xagent_sdk/_agents.py | 20 +++++-------- python/tests/unit/test_agents_management.py | 32 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/python/src/xagent_sdk/_agents.py b/python/src/xagent_sdk/_agents.py index 9ec7809..3e66570 100644 --- a/python/src/xagent_sdk/_agents.py +++ b/python/src/xagent_sdk/_agents.py @@ -32,22 +32,16 @@ def _require_runtime_key( result: AgentCreateResult, generate_runtime_key: bool ) -> AgentCreateResult: - """Fail closed when a key was requested but the response omitted it. + """Fail closed when a key was requested but the response lacks one. ``generate_runtime_key=True`` is a promise that the response carries a - one-time runtime key. When the backend omits it, ``runtime_full_key`` - is ``None`` -- and ``None`` is exactly the value ``_BaseClient`` treats - as "argument not supplied", so ``AgentClient(api_key=None)`` would fall - back to ``XAGENT_API_KEY`` and silently authenticate as a *different* - agent. This guard catches that at ``create`` time with a message that - names the backend contract, rather than letting it surface later as a - generic construction error. - - The empty-string case (``runtime_full_key == ""``) is handled one layer - down: ``_BaseClient`` only env-falls-back on ``None``, so an explicit - empty key reaches its ``not api_key`` guard and raises there. + usable one-time runtime key. ``None`` means the backend omitted the + key block, and ``""`` is still not a usable credential. Catch both at + ``create`` time with an error that names the backend contract, rather + than returning an apparently successful result that only fails when + the caller later constructs an ``AgentClient``. """ - if generate_runtime_key and result.runtime_full_key is None: + if generate_runtime_key and not result.runtime_full_key: raise MalformedResponse( "malformed_response", "create requested generate_runtime_key=True but the response " diff --git a/python/tests/unit/test_agents_management.py b/python/tests/unit/test_agents_management.py index 1547384..f9357cc 100644 --- a/python/tests/unit/test_agents_management.py +++ b/python/tests/unit/test_agents_management.py @@ -138,6 +138,22 @@ def handler(req: httpx.Request) -> httpx.Response: c.agents.create(name="HR Leave Assistant", instructions="...") assert excinfo.value.code == "malformed_response" + def test_generate_runtime_key_true_but_empty_key_fails_closed(self) -> None: + # Empty string is also not a usable runtime credential; create() + # should surface the malformed backend response immediately. + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "agent": {"id": 42, "name": "HR Leave Assistant"}, + "api_key": {"full_key": "", "key_prefix": "abc123"}, + }, + ) + + with _make_user(handler) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create(name="HR Leave Assistant", instructions="...") + assert excinfo.value.code == "malformed_response" + def test_metadata_included_when_given(self) -> None: captured: list[httpx.Request] = [] @@ -253,6 +269,22 @@ def h(req: httpx.Request) -> httpx.Response: c.agents.create_from_template("q_and_a") assert excinfo.value.code == "malformed_response" + def test_generate_runtime_key_true_but_empty_key_fails_closed(self) -> None: + # Same fail-closed contract as create(): empty full_key is not a + # usable one-time runtime key. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "agent": {"id": 42, "name": "X"}, + "api_key": {"full_key": "", "key_prefix": "abc123"}, + }, + ) + + with _make_user(h) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create_from_template("q_and_a") + assert excinfo.value.code == "malformed_response" + class TestRotateKey: def test_url_and_parse(self) -> None: