From 5c64d58223da767512b7531ef13255560915b6f8 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 11:35:26 +0800 Subject: [PATCH 1/9] refactor(base): add _DEFAULT_BASE_URL hook for per-client default URL _BaseClient resolved base_url from explicit arg then XAGENT_BASE_URL, with no way for a client bound to a fixed hosted service to supply a default. Add a `_DEFAULT_BASE_URL` class attribute (None by default) and consult it after the env var: explicit -> env -> class default -> raise. Self-hosted clients leave it None and still require an explicit or env base URL; a client targeting a hosted endpoint sets it to that URL. The None-only fallback is preserved: an explicitly empty base_url still reaches the guard and raises rather than resolving to the default. --- python/src/xagent_sdk/_base.py | 10 ++- python/tests/unit/test_base_default_url.py | 71 ++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 python/tests/unit/test_base_default_url.py diff --git a/python/src/xagent_sdk/_base.py b/python/src/xagent_sdk/_base.py index 668a53e..1f95336 100644 --- a/python/src/xagent_sdk/_base.py +++ b/python/src/xagent_sdk/_base.py @@ -16,6 +16,11 @@ - ``_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. +- ``_DEFAULT_BASE_URL``: a hosted endpoint to fall back to when neither + an explicit ``base_url`` nor ``XAGENT_BASE_URL`` is supplied. ``None`` + means there is no default and a base URL must be provided (the + self-hosted clients); a subclass talking to a fixed hosted service + sets it to that URL. """ import os @@ -36,6 +41,7 @@ class _BaseClient: _ENV_API_KEY: ClassVar[str] = "XAGENT_API_KEY" _API_KEY_FIELD: ClassVar[str] = "api_key" + _DEFAULT_BASE_URL: ClassVar[str | None] = None def __init__( self, @@ -55,8 +61,10 @@ def __init__( # 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) + # base_url resolution: explicit arg, then env, then the class-level + # default (None for self-hosted clients, a fixed URL for hosted ones). if base_url is None: - base_url = os.environ.get("XAGENT_BASE_URL") + base_url = os.environ.get("XAGENT_BASE_URL") or self._DEFAULT_BASE_URL if not api_key: raise ValueError( f"{self._API_KEY_FIELD} required: " diff --git a/python/tests/unit/test_base_default_url.py b/python/tests/unit/test_base_default_url.py new file mode 100644 index 0000000..7e9acf1 --- /dev/null +++ b/python/tests/unit/test_base_default_url.py @@ -0,0 +1,71 @@ +"""Base-client default base-url resolution. + +`_BaseClient` resolves a base URL from, in order: the explicit argument, +``XAGENT_BASE_URL``, then the class-level ``_DEFAULT_BASE_URL``. A +subclass with no default must still require a base URL; one with a +default uses it only when nothing else is supplied. An explicitly empty +value never resolves to the default or the environment. +""" + +import httpx +import pytest + +from xagent_sdk._base import _BaseClient + + +def _ok(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={}) + + +class _NoDefault(_BaseClient): + pass + + +class _WithDefault(_BaseClient): + _DEFAULT_BASE_URL = "https://hosted.example" + + +def _bearer(c: _BaseClient) -> str: + return c._http._client.headers["Authorization"] + + +class TestNoDefault: + def test_missing_base_url_raises(self) -> None: + with pytest.raises(ValueError, match="base_url"): + _NoDefault(api_key="k") + + def test_explicit_base_url_used(self) -> None: + c = _NoDefault( + api_key="k", base_url="https://x", transport=httpx.MockTransport(_ok) + ) + assert str(c._http._client.base_url) == "https://x" + c.close() + + +class TestWithDefault: + def test_default_used_when_nothing_supplied( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("XAGENT_BASE_URL", raising=False) + c = _WithDefault(api_key="k", transport=httpx.MockTransport(_ok)) + assert str(c._http._client.base_url) == "https://hosted.example" + c.close() + + def test_explicit_overrides_default(self) -> None: + c = _WithDefault( + api_key="k", base_url="https://override", transport=httpx.MockTransport(_ok) + ) + assert str(c._http._client.base_url) == "https://override" + c.close() + + def test_env_overrides_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_BASE_URL", "https://from-env") + c = _WithDefault(api_key="k", transport=httpx.MockTransport(_ok)) + assert str(c._http._client.base_url) == "https://from-env" + c.close() + + def test_explicit_empty_base_url_does_not_resolve_to_default(self) -> None: + # An explicit "" is a caller bug; it must raise, not silently use + # the class default. + with pytest.raises(ValueError, match="base_url"): + _WithDefault(api_key="k", base_url="") From 11cad387d0198192c366dd2c12288fccd03c6b87 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 11:38:59 +0800 Subject: [PATCH 2/9] feat(cloud): WorkspaceClient + workspace agents/templates namespaces Add an additive xagent_sdk.cloud submodule for the hosted workspace-key surface. WorkspaceClient is a third _BaseClient sibling authenticated by a workspace key (XAGENT_WORKSPACE_KEY), defaulting its base URL to the hosted endpoint via the new _DEFAULT_BASE_URL hook. WorkspaceAgentsAPI / WorkspaceTemplatesAPI target /v1/workspace/* and reuse the shared response parsers, dataclasses, and the _require_runtime_key fail-closed guard from the personal-key surface -- only the paths and the create request body differ. create()/create_from_template() accept the agent-config fields the workspace endpoints take (no metadata) and spread optional fields flat, dropping None. rotate_key() mints the agent's runtime key. The submodule is not imported by the top-level package: importing xagent_sdk does not load cloud, and the top-level public surface is unchanged. --- python/src/xagent_sdk/cloud/__init__.py | 3 + python/src/xagent_sdk/cloud/_agents.py | 177 ++++++++++++++++++ python/src/xagent_sdk/cloud/_templates.py | 49 +++++ .../src/xagent_sdk/cloud/workspace_client.py | 55 ++++++ 4 files changed, 284 insertions(+) create mode 100644 python/src/xagent_sdk/cloud/__init__.py create mode 100644 python/src/xagent_sdk/cloud/_agents.py create mode 100644 python/src/xagent_sdk/cloud/_templates.py create mode 100644 python/src/xagent_sdk/cloud/workspace_client.py diff --git a/python/src/xagent_sdk/cloud/__init__.py b/python/src/xagent_sdk/cloud/__init__.py new file mode 100644 index 0000000..1a7a022 --- /dev/null +++ b/python/src/xagent_sdk/cloud/__init__.py @@ -0,0 +1,3 @@ +from xagent_sdk.cloud.workspace_client import WorkspaceClient + +__all__ = ["WorkspaceClient"] diff --git a/python/src/xagent_sdk/cloud/_agents.py b/python/src/xagent_sdk/cloud/_agents.py new file mode 100644 index 0000000..0c7e10d --- /dev/null +++ b/python/src/xagent_sdk/cloud/_agents.py @@ -0,0 +1,177 @@ +"""The ``workspace.agents`` namespace exposed on ``WorkspaceClient``. + +Agent lifecycle for a workspace-key caller: list the workspace's agents, +create new ones (structured or from a template), and mint an agent's +runtime key. Reaches the workspace-scoped ``/v1/workspace/agents*`` +endpoints; the response shapes are the same as the personal-key surface, +so the shared parsers and dataclasses in ``xagent_sdk.types`` are reused. + +``create()`` and ``create_from_template()`` default to +``generate_runtime_key=True``; the returned +``AgentCreateResult.runtime_full_key`` is a one-time secret. They fail +closed (``MalformedResponse``) if a key was requested but the response +carried none, via the shared ``_require_runtime_key`` guard. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from xagent_sdk._agents import _require_runtime_key +from xagent_sdk.types import ( + AgentCreateResult, + AgentSummary, + RotateKeyResult, + _parse_agent_create, + _parse_agent_list, + _parse_rotate_key, +) + +if TYPE_CHECKING: + from xagent_sdk.cloud.workspace_client import WorkspaceClient + + +def _drop_none(values: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in values.items() if v is not None} + + +class WorkspaceAgentsAPI: + """The ``workspace.agents`` namespace.""" + + def __init__(self, client: WorkspaceClient) -> None: + self._client = client + + def list(self) -> list[AgentSummary]: + """``GET /v1/workspace/agents`` -- list the workspace's agents. + + Returns the agents the workspace key can manage, as slim summaries + (id + name + optional status). Returns an empty list for an empty + or non-list body. Standard error mapping applies. + """ + resp = self._client._request("GET", "/v1/workspace/agents") + return _parse_agent_list(resp.json()) + + def create( + self, + *, + name: str, + instructions: str, + description: str | None = None, + execution_mode: str | None = None, + models: dict[str, Any] | None = None, + knowledge_bases: Sequence[str] | None = None, + skills: Sequence[str] | None = None, + tool_categories: Sequence[str] | None = None, + suggested_prompts: Sequence[str] | None = None, + generate_runtime_key: bool = True, + ) -> AgentCreateResult: + """``POST /v1/workspace/agents`` -- create an agent in the workspace. + + ``name`` and ``instructions`` are required; the remaining + agent-config fields are optional and omitted from the wire when + left as None. When ``generate_runtime_key`` is True (default) the + backend provisions a runtime key in the same transaction and + returns it via ``AgentCreateResult.runtime_full_key`` (one-time; + store in a secret vault and never log). + + Raises: + InvalidInput: 422 -- backend rejected the body. + InvalidAPIKey: 401 -- workspace key invalid / revoked. + MalformedResponse: ``generate_runtime_key=True`` but the + response carried no runtime key (fail closed). + """ + body: dict[str, Any] = { + "name": name, + "instructions": instructions, + "generate_runtime_key": generate_runtime_key, + } + body.update( + _drop_none( + { + "description": description, + "execution_mode": execution_mode, + "models": models, + "knowledge_bases": knowledge_bases, + "skills": skills, + "tool_categories": tool_categories, + "suggested_prompts": suggested_prompts, + } + ) + ) + resp = self._client._request("POST", "/v1/workspace/agents", json=body) + return _require_runtime_key( + _parse_agent_create(resp.json()), generate_runtime_key + ) + + def create_from_template( + self, + template_id: str, + *, + name: str | None = None, + description: str | None = None, + instructions: str | None = None, + execution_mode: str | None = None, + models: dict[str, Any] | None = None, + knowledge_bases: Sequence[str] | None = None, + skills: Sequence[str] | None = None, + tool_categories: Sequence[str] | None = None, + suggested_prompts: Sequence[str] | None = None, + generate_runtime_key: bool = True, + ) -> AgentCreateResult: + """``POST /v1/workspace/agents/from-template`` -- create from a template. + + The backend loads the template's config and overlays the supplied + fields. Override fields are spread flat into the request body + alongside ``template_id`` and ``generate_runtime_key``; each is + omitted from the wire when left as None. The template supplies any + field not overridden, so all override fields are optional here. + + Raises: + TemplateNotFound: 404 ``template_not_found`` -- unknown + ``template_id``. + InvalidInput: 422 -- override fields malformed. + InvalidAPIKey: 401 -- workspace key invalid / revoked. + MalformedResponse: ``generate_runtime_key=True`` but the + response carried no runtime key (fail closed). + """ + body: dict[str, Any] = { + "template_id": template_id, + "generate_runtime_key": generate_runtime_key, + } + body.update( + _drop_none( + { + "name": name, + "description": description, + "instructions": instructions, + "execution_mode": execution_mode, + "models": models, + "knowledge_bases": knowledge_bases, + "skills": skills, + "tool_categories": tool_categories, + "suggested_prompts": suggested_prompts, + } + ) + ) + resp = self._client._request( + "POST", "/v1/workspace/agents/from-template", json=body + ) + return _require_runtime_key( + _parse_agent_create(resp.json()), generate_runtime_key + ) + + def rotate_key(self, agent_id: int) -> RotateKeyResult: + """``POST /v1/workspace/agents/{agent_id}/api-key`` -- mint the + agent's runtime key. + + Returns a ``RotateKeyResult`` whose ``full_key`` is a one-time + runtime key (``xag__``) used by ``AgentClient`` to + run the agent. Rotation revokes the agent's previous runtime key. + + Raises: + AgentNotFound: 404 -- agent not found in this workspace. + InvalidAPIKey: 401 -- workspace key invalid / revoked. + """ + resp = self._client._request("POST", f"/v1/workspace/agents/{agent_id}/api-key") + return _parse_rotate_key(resp.json()) diff --git a/python/src/xagent_sdk/cloud/_templates.py b/python/src/xagent_sdk/cloud/_templates.py new file mode 100644 index 0000000..058bfe1 --- /dev/null +++ b/python/src/xagent_sdk/cloud/_templates.py @@ -0,0 +1,49 @@ +"""The ``workspace.templates`` namespace exposed on ``WorkspaceClient``. + +Templates are server-managed presets a workspace can offer when creating +an agent. The list endpoint returns slim ``Template`` summaries; the +detail endpoint returns the full ``agent_config`` used by +``WorkspaceAgentsAPI.create_from_template``. Response shapes match the +personal-key template surface, so the shared parsers are reused. +""" + +from typing import TYPE_CHECKING + +from xagent_sdk.types import ( + Template, + TemplateDetail, + _parse_template_detail, + _parse_template_list, +) + +if TYPE_CHECKING: + from xagent_sdk.cloud.workspace_client import WorkspaceClient + + +class WorkspaceTemplatesAPI: + """The ``workspace.templates`` namespace.""" + + def __init__(self, client: "WorkspaceClient") -> None: + self._client = client + + def list(self) -> list[Template]: + """``GET /v1/workspace/templates`` -- list available templates. + + Returns a slim summary (id + name + optional description) per + template. Returns an empty list for a non-list body. Standard + error mapping applies. + """ + resp = self._client._request("GET", "/v1/workspace/templates") + return _parse_template_list(resp.json()) + + def get(self, template_id: str) -> TemplateDetail: + """``GET /v1/workspace/templates/{template_id}`` -- template detail. + + The returned ``TemplateDetail`` includes the ``agent_config`` dict + whose shape the backend owns. + + Raises ``TemplateNotFound`` (404 ``template_not_found``) when the + template does not exist. + """ + resp = self._client._request("GET", f"/v1/workspace/templates/{template_id}") + return _parse_template_detail(resp.json()) diff --git a/python/src/xagent_sdk/cloud/workspace_client.py b/python/src/xagent_sdk/cloud/workspace_client.py new file mode 100644 index 0000000..9693c35 --- /dev/null +++ b/python/src/xagent_sdk/cloud/workspace_client.py @@ -0,0 +1,55 @@ +import httpx + +from xagent_sdk._base import _BaseClient +from xagent_sdk.cloud._agents import WorkspaceAgentsAPI +from xagent_sdk.cloud._templates import WorkspaceTemplatesAPI + + +class WorkspaceClient(_BaseClient): + """Synchronous client for the hosted xAgent workspace surface. + + Authenticates with a **workspace key** (``xag_workspace__`` + issued by the hosted app to a workspace/team). A workspace key + authorizes managing agents and reading templates for that workspace: + ``/v1/workspace/templates*`` and ``/v1/workspace/agents*``. It does + not run agents -- create or mint a runtime key here, then drive + ``/v1/chat/tasks*`` with ``AgentClient`` and that runtime key. + + Constructor argument resolution: + + - ``workspace_key``: explicit keyword, else ``XAGENT_WORKSPACE_KEY``. + A separate variable from ``XAGENT_API_KEY`` / ``XAGENT_PERSONAL_KEY`` + so the clients can coexist in one process. An explicitly empty key + raises rather than falling back to the environment. + - ``base_url``: explicit keyword, else ``XAGENT_BASE_URL``, else the + hosted default ``https://cloud.xagent.run``. + + Missing values at construction raise ``ValueError`` instead of + deferring failure to the first request. ``transport`` accepts any + ``httpx.BaseTransport`` for proxy / TLS / test injection. + """ + + _ENV_API_KEY = "XAGENT_WORKSPACE_KEY" + _API_KEY_FIELD = "workspace_key" + _DEFAULT_BASE_URL = "https://cloud.xagent.run" + + def __init__( + self, + workspace_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=workspace_key, + base_url=base_url, + timeout=timeout, + max_connections=max_connections, + user_agent=user_agent, + transport=transport, + ) + self.agents = WorkspaceAgentsAPI(self) + self.templates = WorkspaceTemplatesAPI(self) From 236cfd3709ffdf7ab016615372aa20c512e5b4c3 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 11:42:59 +0800 Subject: [PATCH 3/9] test(cloud): unit tests + surface pin for the cloud module Cover WorkspaceClient construction (env isolation to XAGENT_WORKSPACE_KEY, empty-key no env fallback, hosted default base URL), the agents and templates namespaces (path assertions, request body shape, parse, fail-closed without a runtime key, flat from-template body with no overrides wrapper, no metadata parameter), and the typed-error mappings. Pin the cloud public surface to exactly {WorkspaceClient} and assert it is not re-exported at the top level. Strip XAGENT_WORKSPACE_KEY in the shared env-cleanup fixture so cloud tests start from a clean environment. --- python/tests/unit/cloud/__init__.py | 0 python/tests/unit/cloud/test_cloud_surface.py | 24 +++ .../tests/unit/cloud/test_workspace_agents.py | 169 ++++++++++++++++++ .../tests/unit/cloud/test_workspace_client.py | 62 +++++++ .../unit/cloud/test_workspace_templates.py | 63 +++++++ python/tests/unit/conftest.py | 1 + 6 files changed, 319 insertions(+) create mode 100644 python/tests/unit/cloud/__init__.py create mode 100644 python/tests/unit/cloud/test_cloud_surface.py create mode 100644 python/tests/unit/cloud/test_workspace_agents.py create mode 100644 python/tests/unit/cloud/test_workspace_client.py create mode 100644 python/tests/unit/cloud/test_workspace_templates.py diff --git a/python/tests/unit/cloud/__init__.py b/python/tests/unit/cloud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/unit/cloud/test_cloud_surface.py b/python/tests/unit/cloud/test_cloud_surface.py new file mode 100644 index 0000000..183f8b0 --- /dev/null +++ b/python/tests/unit/cloud/test_cloud_surface.py @@ -0,0 +1,24 @@ +"""Pin for the cloud submodule public surface. + +The cloud submodule exposes exactly ``WorkspaceClient`` and is not +re-exported from the top-level package: importing ``xagent_sdk`` must not +pull in cloud or surface ``WorkspaceClient`` at the top level. +""" + +import xagent_sdk +import xagent_sdk.cloud + + +def test_cloud_all_is_exactly_workspace_client() -> None: + assert set(xagent_sdk.cloud.__all__) == {"WorkspaceClient"} + + +def test_workspace_client_resolves_from_cloud() -> None: + from xagent_sdk.cloud import WorkspaceClient + + assert WorkspaceClient.__name__ == "WorkspaceClient" + + +def test_workspace_client_not_on_top_level() -> None: + assert not hasattr(xagent_sdk, "WorkspaceClient") + assert "WorkspaceClient" not in xagent_sdk.__all__ diff --git a/python/tests/unit/cloud/test_workspace_agents.py b/python/tests/unit/cloud/test_workspace_agents.py new file mode 100644 index 0000000..38c9b04 --- /dev/null +++ b/python/tests/unit/cloud/test_workspace_agents.py @@ -0,0 +1,169 @@ +"""Tests for WorkspaceClient.agents (WorkspaceAgentsAPI).""" + +import inspect +import json + +import httpx +import pytest + +from xagent_sdk import ( + AgentCreateResult, + AgentNotFound, + AgentSummary, + MalformedResponse, + TemplateNotFound, +) +from xagent_sdk.cloud import WorkspaceClient +from xagent_sdk.cloud._agents import WorkspaceAgentsAPI + +from .._fixtures import error_envelope, response + + +def _make_ws(handler: object) -> WorkspaceClient: + return WorkspaceClient( + workspace_key="xag_workspace_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_ws(handler) as c: + agents = c.agents.list() + + assert captured[0].method == "GET" + assert captured[0].url.path == "/v1/workspace/agents" + assert agents + assert all(isinstance(a, AgentSummary) for a in agents) + + def test_empty_list(self) -> None: + with _make_ws(lambda req: httpx.Response(200, json=[])) as c: + assert c.agents.list() == [] + + def test_non_list_body_defensive(self) -> None: + with _make_ws(lambda req: httpx.Response(200, json={"agents": []})) as c: + assert c.agents.list() == [] + + +class TestCreate: + def test_body_default(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_ws(handler) as c: + result = c.agents.create(name="HR Bot", instructions="help") + + assert captured[0].method == "POST" + assert captured[0].url.path == "/v1/workspace/agents" + body = json.loads(captured[0].content) + assert body == { + "name": "HR Bot", + "instructions": "help", + "generate_runtime_key": True, + } + assert isinstance(result, AgentCreateResult) + assert result.runtime_full_key is not None + + def test_optional_fields_spread_no_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_ws(handler) as c: + c.agents.create( + name="X", + instructions="i", + description="d", + execution_mode="flash", + tool_categories=["basic"], + models=None, # stays out of the body + ) + + body = json.loads(captured[0].content) + assert body["description"] == "d" + assert body["execution_mode"] == "flash" + assert body["tool_categories"] == ["basic"] + assert "models" not in body # None dropped + assert "metadata" not in body + + def test_create_has_no_metadata_param(self) -> None: + params = inspect.signature(WorkspaceAgentsAPI.create).parameters + assert "metadata" not in params + + def test_fails_closed_without_key(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(201, json={"agent": {"id": 1, "name": "X"}}) + + with _make_ws(handler) as c, pytest.raises(MalformedResponse) as exc: + c.agents.create(name="X", instructions="i") + assert exc.value.code == "malformed_response" + + +class TestCreateFromTemplate: + def test_flat_body_no_overrides_wrapper(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_ws(handler) as c: + c.agents.create_from_template("support-ai-chatbot-agent", name="HR Bot") + + assert captured[0].url.path == "/v1/workspace/agents/from-template" + body = json.loads(captured[0].content) + assert body == { + "template_id": "support-ai-chatbot-agent", + "generate_runtime_key": True, + "name": "HR Bot", + } + assert "overrides" not in body + + def test_template_not_found(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("template_not_found")) + + with _make_ws(handler) as c, pytest.raises(TemplateNotFound): + c.agents.create_from_template("nope") + + def test_fails_closed_without_key(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(201, json={"agent": {"id": 1, "name": "X"}}) + + with _make_ws(handler) as c, pytest.raises(MalformedResponse): + c.agents.create_from_template("t") + + +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_ws(handler) as c: + result = c.agents.rotate_key(42) + + assert captured[0].method == "POST" + assert captured[0].url.path == "/v1/workspace/agents/42/api-key" + assert result.full_key.startswith("xag_") + + def test_agent_not_found(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("agent_not_found")) + + with _make_ws(handler) as c, pytest.raises(AgentNotFound): + c.agents.rotate_key(99999999) diff --git a/python/tests/unit/cloud/test_workspace_client.py b/python/tests/unit/cloud/test_workspace_client.py new file mode 100644 index 0000000..3e7de50 --- /dev/null +++ b/python/tests/unit/cloud/test_workspace_client.py @@ -0,0 +1,62 @@ +"""Tests for WorkspaceClient construction and credential resolution.""" + +import httpx +import pytest + +from xagent_sdk.cloud import WorkspaceClient + + +def _ok(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={}) + + +def _bearer(c: WorkspaceClient) -> str: + return c._http._client.headers["Authorization"] + + +class TestConstruction: + def test_explicit_key_and_url(self) -> None: + c = WorkspaceClient( + workspace_key="xag_workspace_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_ok), + ) + assert _bearer(c) == "Bearer xag_workspace_p_s" + assert str(c._http._client.base_url) == "https://test.example" + c.close() + + def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_WORKSPACE_KEY", "xag_workspace_env_sec") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = WorkspaceClient(transport=httpx.MockTransport(_ok)) + assert _bearer(c) == "Bearer xag_workspace_env_sec" + assert str(c._http._client.base_url) == "https://envhost" + c.close() + + def test_env_isolation(self, monkeypatch: pytest.MonkeyPatch) -> None: + # WorkspaceClient must read only XAGENT_WORKSPACE_KEY, never the + # runtime/personal key vars. + monkeypatch.setenv("XAGENT_API_KEY", "runtime-not-workspace") + monkeypatch.setenv("XAGENT_PERSONAL_KEY", "personal-not-workspace") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="workspace_key"): + WorkspaceClient() + + def test_default_base_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + # No explicit base_url and no env -> the hosted default applies. + monkeypatch.delenv("XAGENT_BASE_URL", raising=False) + c = WorkspaceClient( + workspace_key="xag_workspace_p_s", transport=httpx.MockTransport(_ok) + ) + assert str(c._http._client.base_url) == "https://cloud.xagent.run" + c.close() + + def test_empty_key_no_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + # An explicit empty key must raise, never resolve to the env value. + monkeypatch.setenv("XAGENT_WORKSPACE_KEY", "xag_workspace_env_sec") + with pytest.raises(ValueError, match="workspace_key"): + WorkspaceClient(workspace_key="") + + def test_missing_key(self) -> None: + with pytest.raises(ValueError, match="workspace_key"): + WorkspaceClient(base_url="https://x") diff --git a/python/tests/unit/cloud/test_workspace_templates.py b/python/tests/unit/cloud/test_workspace_templates.py new file mode 100644 index 0000000..215a3a9 --- /dev/null +++ b/python/tests/unit/cloud/test_workspace_templates.py @@ -0,0 +1,63 @@ +"""Tests for WorkspaceClient.templates (WorkspaceTemplatesAPI).""" + +import httpx +import pytest + +from xagent_sdk import Template, TemplateDetail, TemplateNotFound +from xagent_sdk.cloud import WorkspaceClient + +from .._fixtures import error_envelope, response + + +def _make_ws(handler: object) -> WorkspaceClient: + return WorkspaceClient( + workspace_key="xag_workspace_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("templates_list")) + + with _make_ws(handler) as c: + templates = c.templates.list() + + assert captured[0].method == "GET" + assert captured[0].url.path == "/v1/workspace/templates" + assert templates + assert all(isinstance(t, Template) for t in templates) + + def test_non_list_body_defensive(self) -> None: + with _make_ws(lambda req: httpx.Response(200, json={"templates": []})) 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_ws(handler) as c: + detail = c.templates.get("support-ai-chatbot-agent") + + assert ( + captured[0].url.path == "/v1/workspace/templates/support-ai-chatbot-agent" + ) + assert isinstance(detail, TemplateDetail) + assert "instructions" in detail.agent_config + + def test_404_template_not_found(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("template_not_found")) + + with _make_ws(handler) as c, pytest.raises(TemplateNotFound): + c.templates.get("nope") diff --git a/python/tests/unit/conftest.py b/python/tests/unit/conftest.py index 29ef8b8..28094db 100644 --- a/python/tests/unit/conftest.py +++ b/python/tests/unit/conftest.py @@ -18,6 +18,7 @@ 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_WORKSPACE_KEY", raising=False) monkeypatch.delenv("XAGENT_BASE_URL", raising=False) From 07ba4c460d1f056be59b2834f288193cb977452a Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 11:44:05 +0800 Subject: [PATCH 4/9] chore: bump SDK to 0.3.0 --- python/pyproject.toml | 2 +- python/src/xagent_sdk/_version.py | 2 +- python/tests/unit/test_public_surface.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 7ae2aa0..a2944d6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "xagent-sdk" -version = "0.2.0" +version = "0.3.0" description = "Python client SDK for xAgent" readme = "README.md" requires-python = ">=3.11" diff --git a/python/src/xagent_sdk/_version.py b/python/src/xagent_sdk/_version.py index d3ec452..493f741 100644 --- a/python/src/xagent_sdk/_version.py +++ b/python/src/xagent_sdk/_version.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/python/tests/unit/test_public_surface.py b/python/tests/unit/test_public_surface.py index b317992..42edaf8 100644 --- a/python/tests/unit/test_public_surface.py +++ b/python/tests/unit/test_public_surface.py @@ -85,4 +85,4 @@ def test_meresponse_name_not_exposed() -> None: 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" + assert xagent_sdk.__version__ == "0.3.0" From 4ff064c853ab066a888d56e450f71046c5730d62 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 11:45:59 +0800 Subject: [PATCH 5/9] docs(readme): cloud/workspace quickstart + e2e cloud smoke Add a WorkspaceClient section to the API reference (import path, the create -> runtime key -> AgentClient flow, env var, default URL) kept separate from the self-hosted sections. Add a workspace_client e2e fixture (gated on XAGENT_WORKSPACE_KEY) and test_cloud_smoke.py covering the full template -> create -> run flow plus unknown-template, bad-key, and no-runtime-key paths. --- python/README.md | 33 +++++++++++ python/tests/e2e/conftest.py | 21 +++++++ python/tests/e2e/test_cloud_smoke.py | 86 ++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 python/tests/e2e/test_cloud_smoke.py diff --git a/python/README.md b/python/README.md index cf244c6..bbd317d 100644 --- a/python/README.md +++ b/python/README.md @@ -292,6 +292,39 @@ only. | `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 | +### `WorkspaceClient` — hosted workspace surface (`xagent_sdk.cloud`) + +For SaaS apps on the hosted service. Constructed with a **workspace key** +and manages agents/templates scoped to a workspace. Lives under +`xagent_sdk.cloud` so the self-hosted package is unaffected — import it +explicitly: + +```python +from xagent_sdk.cloud import WorkspaceClient +from xagent_sdk import AgentClient + +with WorkspaceClient(workspace_key="xag_workspace_...") as ws: + created = ws.agents.create_from_template( + "support-ai-chatbot-agent", name="HR Leave Assistant" + ) + runtime_key = created.runtime_full_key # one-time secret + +with AgentClient(api_key=runtime_key) as agent: # run on the same surface + print(agent.tasks.run(agent_id=created.agent_id, message="Hi").output) +``` + +| Method | Returns | Notes | +|---|---|---| +| `WorkspaceClient(workspace_key, base_url, ...)` | `WorkspaceClient` | env fallback `XAGENT_WORKSPACE_KEY`; `base_url` defaults to `https://cloud.xagent.run` (override via arg / `XAGENT_BASE_URL`) | +| `ws.templates.list()` / `ws.templates.get(id)` | `list[Template]` / `TemplateDetail` | GET `/v1/workspace/templates*` | +| `ws.agents.list()` | `list[AgentSummary]` | GET `/v1/workspace/agents` | +| `ws.agents.create(*, name, instructions, description=None, execution_mode=None, models=None, knowledge_bases=None, skills=None, tool_categories=None, suggested_prompts=None, generate_runtime_key=True)` | `AgentCreateResult` | POST `/v1/workspace/agents` | +| `ws.agents.create_from_template(template_id, *, name=None, ..., generate_runtime_key=True)` | `AgentCreateResult` | POST `/v1/workspace/agents/from-template`; override fields spread flat | +| `ws.agents.rotate_key(agent_id)` | `RotateKeyResult` | POST `/v1/workspace/agents/{id}/api-key`; mints the agent's runtime key | + +The minted runtime key is an ordinary agent key — drive it with +`AgentClient` against `/v1/chat/tasks*`, exactly as above. + ### Status semantics `TaskStatus` enum: diff --git a/python/tests/e2e/conftest.py b/python/tests/e2e/conftest.py index c415dd7..d3d311f 100644 --- a/python/tests/e2e/conftest.py +++ b/python/tests/e2e/conftest.py @@ -30,6 +30,7 @@ import pytest from xagent_sdk import AgentClient, UserClient +from xagent_sdk.cloud import WorkspaceClient def _need_personal() -> tuple[str, str]: @@ -88,3 +89,23 @@ def patient_agent_client() -> Iterator[AgentClient]: api_key, base_url = _need_runtime() with AgentClient(api_key=api_key, base_url=base_url, timeout=60.0) as c: yield c + + +@pytest.fixture +def workspace_client() -> Iterator[WorkspaceClient]: + """WorkspaceClient authenticated with a workspace key. + + Requires ``XAGENT_WORKSPACE_KEY``; ``base_url`` falls back to + ``XAGENT_BASE_URL`` and then the hosted default, so a developer + pointing at a staging deploy sets ``XAGENT_BASE_URL`` while one + hitting the hosted service sets only the key. 60s per-request timeout + so agent runs created here have room to complete. + """ + workspace_key = os.environ.get("XAGENT_WORKSPACE_KEY") + if not workspace_key: + pytest.skip("e2e workspace surface requires XAGENT_WORKSPACE_KEY") + base_url = os.environ.get("XAGENT_BASE_URL") + with WorkspaceClient( + workspace_key=workspace_key, base_url=base_url, timeout=60.0 + ) as c: + yield c diff --git a/python/tests/e2e/test_cloud_smoke.py b/python/tests/e2e/test_cloud_smoke.py new file mode 100644 index 0000000..5fc144f --- /dev/null +++ b/python/tests/e2e/test_cloud_smoke.py @@ -0,0 +1,86 @@ +"""End-to-end smoke tests for the cloud / workspace surface. + +Marked ``@pytest.mark.e2e`` so the default ``pytest`` run skips them. +Run against a SaaS deploy with a workspace key:: + + XAGENT_WORKSPACE_KEY=xag_workspace_... uv run pytest -m e2e + # XAGENT_BASE_URL optional; defaults to the hosted endpoint + +``E2E_TEMPLATE_ID`` picks the template to instantiate (defaults to the +first listed). The created agent is not deleted -- run on a scratch +workspace. +""" + +import os + +import pytest + +from xagent_sdk import ( + AgentClient, + InvalidAPIKey, + RunResult, + TaskStatus, + TemplateNotFound, +) +from xagent_sdk.cloud import WorkspaceClient + +pytestmark = pytest.mark.e2e + + +def _runtime_base_url() -> str: + # The minted runtime key drives the existing /v1/chat/tasks* surface + # on the same host the workspace client targets. + return os.environ.get("XAGENT_BASE_URL") or "https://cloud.xagent.run" + + +def test_workspace_full_flow(workspace_client: WorkspaceClient) -> None: + templates = workspace_client.templates.list() + if not templates: + pytest.skip("workspace exposes no templates; nothing to instantiate") + template_id = os.environ.get("E2E_TEMPLATE_ID", templates[0].template_id) + + created = workspace_client.agents.create_from_template( + template_id, name=f"cloud_smoke_{os.getpid()}" + ) + assert created.agent_id > 0 + 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=_runtime_base_url(), timeout=120.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 + + +def test_unknown_template_raises(workspace_client: WorkspaceClient) -> None: + with pytest.raises(TemplateNotFound): + workspace_client.agents.create_from_template("does-not-exist-12345") + + +def test_bad_workspace_key_unauthorized() -> None: + base_url = os.environ.get("XAGENT_BASE_URL") + if not os.environ.get("XAGENT_WORKSPACE_KEY"): + pytest.skip("e2e workspace surface requires XAGENT_WORKSPACE_KEY") + with ( + WorkspaceClient(workspace_key="xag_workspace_bad_key", base_url=base_url) as c, + pytest.raises(InvalidAPIKey), + ): + c.agents.list() + + +def test_create_without_runtime_key(workspace_client: WorkspaceClient) -> None: + created = workspace_client.agents.create( + name=f"cloud_smoke_nokey_{os.getpid()}", + instructions="Echo one line.", + generate_runtime_key=False, + ) + assert created.agent_id > 0 + assert created.runtime_full_key is None From 6372003d79d60a735925e4844c60dd91acd2b415 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 11:58:38 +0800 Subject: [PATCH 6/9] fix(cloud): type list params as list[str], not Sequence[str] The workspace agent-config list params were typed Sequence[str] to dodge the `list` builtin being shadowed by the namespace's own `list()` method. But a bare str satisfies Sequence[str], so create(tool_categories="basic") passed type checking and put a scalar on the wire where the backend wants an array. Reference the builtin through a module-scope `_StrList = list[str]` alias instead -- it sidesteps the shadow without widening the type, so mypy now rejects a stray string. A test pins that these params never regress to a str-admitting Sequence type. --- python/src/xagent_sdk/cloud/_agents.py | 24 ++++++++++++------- .../tests/unit/cloud/test_workspace_agents.py | 15 ++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/python/src/xagent_sdk/cloud/_agents.py b/python/src/xagent_sdk/cloud/_agents.py index 0c7e10d..1b7cae4 100644 --- a/python/src/xagent_sdk/cloud/_agents.py +++ b/python/src/xagent_sdk/cloud/_agents.py @@ -15,7 +15,6 @@ from __future__ import annotations -from collections.abc import Sequence from typing import TYPE_CHECKING, Any from xagent_sdk._agents import _require_runtime_key @@ -31,6 +30,13 @@ if TYPE_CHECKING: from xagent_sdk.cloud.workspace_client import WorkspaceClient +# The ``list()`` method below shadows the ``list`` builtin inside this +# class, so ``list[str]`` annotations on the create methods cannot resolve +# to the builtin type. Reference it through a module-scope alias instead. +# (Do not switch these to ``Sequence[str]``: a bare ``str`` satisfies +# ``Sequence[str]`` and would slip through type checking onto the wire.) +_StrList = list[str] + def _drop_none(values: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in values.items() if v is not None} @@ -60,10 +66,10 @@ def create( description: str | None = None, execution_mode: str | None = None, models: dict[str, Any] | None = None, - knowledge_bases: Sequence[str] | None = None, - skills: Sequence[str] | None = None, - tool_categories: Sequence[str] | None = None, - suggested_prompts: Sequence[str] | None = None, + knowledge_bases: _StrList | None = None, + skills: _StrList | None = None, + tool_categories: _StrList | None = None, + suggested_prompts: _StrList | None = None, generate_runtime_key: bool = True, ) -> AgentCreateResult: """``POST /v1/workspace/agents`` -- create an agent in the workspace. @@ -113,10 +119,10 @@ def create_from_template( instructions: str | None = None, execution_mode: str | None = None, models: dict[str, Any] | None = None, - knowledge_bases: Sequence[str] | None = None, - skills: Sequence[str] | None = None, - tool_categories: Sequence[str] | None = None, - suggested_prompts: Sequence[str] | None = None, + knowledge_bases: _StrList | None = None, + skills: _StrList | None = None, + tool_categories: _StrList | None = None, + suggested_prompts: _StrList | None = None, generate_runtime_key: bool = True, ) -> AgentCreateResult: """``POST /v1/workspace/agents/from-template`` -- create from a template. diff --git a/python/tests/unit/cloud/test_workspace_agents.py b/python/tests/unit/cloud/test_workspace_agents.py index 38c9b04..c23ea97 100644 --- a/python/tests/unit/cloud/test_workspace_agents.py +++ b/python/tests/unit/cloud/test_workspace_agents.py @@ -102,6 +102,21 @@ def test_create_has_no_metadata_param(self) -> None: params = inspect.signature(WorkspaceAgentsAPI.create).parameters assert "metadata" not in params + def test_list_fields_are_lists_not_sequences(self) -> None: + # A bare str satisfies Sequence[str] and would reach the wire as a + # scalar; the list-valued params must be typed as concrete lists so + # type checking rejects a stray string. (Annotations are strings + # here because the module uses ``from __future__ import annotations``.) + params = inspect.signature(WorkspaceAgentsAPI.create).parameters + for field in ( + "knowledge_bases", + "skills", + "tool_categories", + "suggested_prompts", + ): + ann = params[field].annotation + assert "Sequence" not in ann, f"{field} must not admit a bare str: {ann}" + def test_fails_closed_without_key(self) -> None: def handler(req: httpx.Request) -> httpx.Response: return httpx.Response(201, json={"agent": {"id": 1, "name": "X"}}) From 7a535af4135adc626daf1bdf5d615f5f1f33a219 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 12:08:05 +0800 Subject: [PATCH 7/9] fix(templates): url-encode template_id as a single path segment templates.get() interpolated template_id straight into the path. httpx percent-encodes spaces but leaves "/", "?", "#" and "%" untouched, so a template id containing those could split the route or leak into the query string. Encode it with urllib.parse.quote(..., safe="") in both the personal-key and workspace template surfaces; ordinary slug ids pass through unchanged. Tests pin that a slash/query id stays in one path segment with an empty query. --- python/src/xagent_sdk/_templates.py | 6 +++++- python/src/xagent_sdk/cloud/_templates.py | 6 +++++- .../tests/unit/cloud/test_workspace_templates.py | 16 ++++++++++++++++ python/tests/unit/test_templates.py | 16 ++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/python/src/xagent_sdk/_templates.py b/python/src/xagent_sdk/_templates.py index 2c79d3f..e22fa8c 100644 --- a/python/src/xagent_sdk/_templates.py +++ b/python/src/xagent_sdk/_templates.py @@ -13,6 +13,7 @@ """ from typing import TYPE_CHECKING +from urllib.parse import quote from xagent_sdk.types import ( Template, @@ -55,5 +56,8 @@ def get(self, template_id: str) -> TemplateDetail: 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}") + # Encode the id as a single path segment so a value with "/", "?", + # "#" or "%" cannot alter the route or leak into the query string. + safe_id = quote(template_id, safe="") + resp = self._client._request("GET", f"/v1/templates/{safe_id}") return _parse_template_detail(resp.json()) diff --git a/python/src/xagent_sdk/cloud/_templates.py b/python/src/xagent_sdk/cloud/_templates.py index 058bfe1..bb3d5db 100644 --- a/python/src/xagent_sdk/cloud/_templates.py +++ b/python/src/xagent_sdk/cloud/_templates.py @@ -8,6 +8,7 @@ """ from typing import TYPE_CHECKING +from urllib.parse import quote from xagent_sdk.types import ( Template, @@ -45,5 +46,8 @@ def get(self, template_id: str) -> TemplateDetail: Raises ``TemplateNotFound`` (404 ``template_not_found``) when the template does not exist. """ - resp = self._client._request("GET", f"/v1/workspace/templates/{template_id}") + # Encode the id as a single path segment so a value with "/", "?", + # "#" or "%" cannot alter the route or leak into the query string. + safe_id = quote(template_id, safe="") + resp = self._client._request("GET", f"/v1/workspace/templates/{safe_id}") return _parse_template_detail(resp.json()) diff --git a/python/tests/unit/cloud/test_workspace_templates.py b/python/tests/unit/cloud/test_workspace_templates.py index 215a3a9..734b40b 100644 --- a/python/tests/unit/cloud/test_workspace_templates.py +++ b/python/tests/unit/cloud/test_workspace_templates.py @@ -61,3 +61,19 @@ def handler(req: httpx.Request) -> httpx.Response: with _make_ws(handler) as c, pytest.raises(TemplateNotFound): c.templates.get("nope") + + def test_template_id_is_path_encoded(self) -> None: + # A slash / query char in the id must stay inside one path segment, + # not split the route or leak into the query string. + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("templates_detail")) + + with _make_ws(handler) as c: + c.templates.get("a/b?x=1") + + url = captured[0].url + assert url.query == b"" + assert str(url).endswith("/v1/workspace/templates/a%2Fb%3Fx%3D1") diff --git a/python/tests/unit/test_templates.py b/python/tests/unit/test_templates.py index 24bcef9..e3fde55 100644 --- a/python/tests/unit/test_templates.py +++ b/python/tests/unit/test_templates.py @@ -88,3 +88,19 @@ def handler(req: httpx.Request) -> httpx.Response: 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") + + def test_template_id_is_path_encoded(self) -> None: + # A slash / query char in the id must stay inside one path segment, + # not split the route or leak into the query string. + 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: + c.templates.get("a/b?x=1") + + url = captured[0].url + assert url.query == b"" + assert str(url).endswith("/v1/templates/a%2Fb%3Fx%3D1") From df179b14e91bf5b0339b2ec91e5c78b26d00d017 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 15:54:24 +0800 Subject: [PATCH 8/9] fix(base): resolve credentials/URL through a single None-aware helper base_url resolution used `os.environ.get("XAGENT_BASE_URL") or self._DEFAULT_BASE_URL`, so an env var set to "" (a broken staging config) was treated as absent and silently fell back to the hosted default -- routing self-hosted/staging traffic to production. Extract a `_resolve(explicit, env_name, default)` helper used for both api_key and base_url. It only falls through to the next source when a value is genuinely absent (None); an explicit "" or an env var set to "" is returned as-is so the existing `if not value` guard fails fast. Centralizing it gives credential/URL resolution one None-vs-empty definition instead of re-deriving the chain (and re-introducing the falsy-`or` swallow) per field. Tests cover empty env base_url, empty env api key, and the workspace empty-env-url case. --- python/src/xagent_sdk/_base.py | 38 +++++++++++++------ .../tests/unit/cloud/test_workspace_client.py | 9 +++++ python/tests/unit/test_base_default_url.py | 18 +++++++++ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/python/src/xagent_sdk/_base.py b/python/src/xagent_sdk/_base.py index 1f95336..4148ba3 100644 --- a/python/src/xagent_sdk/_base.py +++ b/python/src/xagent_sdk/_base.py @@ -33,6 +33,27 @@ from xagent_sdk.errors import from_response +def _resolve( + explicit: str | None, env_name: str, default: str | None = None +) -> str | None: + """Resolve a config value: explicit argument, then env var, then default. + + Only a *genuinely absent* value (``None``) falls through to the next + source. A value that was provided but empty -- an explicit ``""`` or an + env var set to ``""`` -- is returned as-is so the caller's + ``if not value`` guard fails fast, rather than being swallowed (as a + falsy ``or`` would) and silently replaced by the env var or the hosted + default. This is the single resolution path for every credential/URL so + the empty-vs-absent distinction cannot be re-derived inconsistently. + """ + if explicit is not None: + return explicit + env = os.environ.get(env_name) + if env is not None: + return env + return default + + class _BaseClient: """Shared transport plumbing for SDK clients. @@ -53,18 +74,11 @@ def __init__( user_agent: str | None = None, transport: httpx.BaseTransport | None = None, ) -> None: - # 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) - # base_url resolution: explicit arg, then env, then the class-level - # default (None for self-hosted clients, a fixed URL for hosted ones). - if base_url is None: - base_url = os.environ.get("XAGENT_BASE_URL") or self._DEFAULT_BASE_URL + # Resolve through the shared helper so an explicitly empty key/URL + # (or an env var set to "") fails fast instead of being swapped for + # the env value or the hosted default. + api_key = _resolve(api_key, self._ENV_API_KEY) + base_url = _resolve(base_url, "XAGENT_BASE_URL", self._DEFAULT_BASE_URL) if not api_key: raise ValueError( f"{self._API_KEY_FIELD} required: " diff --git a/python/tests/unit/cloud/test_workspace_client.py b/python/tests/unit/cloud/test_workspace_client.py index 3e7de50..1e33821 100644 --- a/python/tests/unit/cloud/test_workspace_client.py +++ b/python/tests/unit/cloud/test_workspace_client.py @@ -60,3 +60,12 @@ def test_empty_key_no_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> Non def test_missing_key(self) -> None: with pytest.raises(ValueError, match="workspace_key"): WorkspaceClient(base_url="https://x") + + def test_empty_env_base_url_does_not_use_hosted_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A broken XAGENT_BASE_URL="" must fail fast, not silently route to + # the hosted default https://cloud.xagent.run. + monkeypatch.setenv("XAGENT_BASE_URL", "") + with pytest.raises(ValueError, match="base_url"): + WorkspaceClient(workspace_key="xag_workspace_p_s") diff --git a/python/tests/unit/test_base_default_url.py b/python/tests/unit/test_base_default_url.py index 7e9acf1..f432f48 100644 --- a/python/tests/unit/test_base_default_url.py +++ b/python/tests/unit/test_base_default_url.py @@ -69,3 +69,21 @@ def test_explicit_empty_base_url_does_not_resolve_to_default(self) -> None: # the class default. with pytest.raises(ValueError, match="base_url"): _WithDefault(api_key="k", base_url="") + + def test_empty_env_base_url_does_not_resolve_to_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A broken XAGENT_BASE_URL="" must fail fast, not silently fall + # back to the hosted default (which could send staging traffic to + # production). + monkeypatch.setenv("XAGENT_BASE_URL", "") + with pytest.raises(ValueError, match="base_url"): + _WithDefault(api_key="k") + + def test_empty_env_api_key_does_not_resolve_to_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A present-but-empty key env var is a broken config -> fail fast. + monkeypatch.setenv("XAGENT_API_KEY", "") + with pytest.raises(ValueError, match="api_key"): + _NoDefault(base_url="https://x") From 9ff4374136d3ef150fa104c88d8a859881f49e44 Mon Sep 17 00:00:00 2001 From: Alexliu Date: Mon, 1 Jun 2026 15:54:35 +0800 Subject: [PATCH 9/9] docs(readme): sync version to 0.3.0 and fix the cloud quickstart The package is 0.3.0 but the README status banner, install tags, version policy, User-Agent note, and the root README still said 0.2.0 / 0.1.0 -- following them installed a build without xagent_sdk.cloud, so the new example failed at import. Bump those to 0.3.0 (the Migration-from-0.1.0 section keeps its version references by design). The cloud quickstart created AgentClient without a base_url; AgentClient has no hosted default, so it raised ValueError after the agent was created. Use a shared base_url for both WorkspaceClient and AgentClient so the snippet runs as written. --- README.md | 2 +- python/README.md | 26 ++++++++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 56e3b93..7db7df3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ package, version, and tooling. | Language | Path | Status | |---|---|---| -| Python | [`python/`](./python/) | 0.1.0 — early access | +| Python | [`python/`](./python/) | 0.3.0 — early access | | TypeScript | (planned) | — | | JavaScript | (planned) | — | diff --git a/python/README.md b/python/README.md index bbd317d..9de83cc 100644 --- a/python/README.md +++ b/python/README.md @@ -4,10 +4,12 @@ Python client SDK for the [xAgent](https://github.com/xorbitsai/xagent) 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.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 +> **Status**: 0.3.0 — early access. Adds the optional +> `xagent_sdk.cloud.WorkspaceClient` (hosted workspace surface); +> additive, nothing else changes. **Breaking change vs 0.1.0**: the SDK +> exposes two clients (``UserClient`` for management, ``AgentClient`` for +> runtime) instead of a single class, and `/v1/me` returns a user +> principal instead of an agent identity. See > [Migration from 0.1.0](#migration-from-010) below. ## Install @@ -15,7 +17,7 @@ from templates, and trigger them — all in a handful of lines. Pin to a release tag — do **not** install from `main`: ```bash -pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.2.0#subdirectory=python" +pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.3.0#subdirectory=python" ``` The Python client lives under [`python/`](.) in the @@ -303,13 +305,17 @@ explicitly: from xagent_sdk.cloud import WorkspaceClient from xagent_sdk import AgentClient -with WorkspaceClient(workspace_key="xag_workspace_...") as ws: +# WorkspaceClient defaults base_url to the hosted endpoint; AgentClient +# does not, so give both the same base_url to run on one surface. +base_url = "https://cloud.xagent.run" + +with WorkspaceClient(workspace_key="xag_workspace_...", base_url=base_url) as ws: created = ws.agents.create_from_template( "support-ai-chatbot-agent", name="HR Leave Assistant" ) runtime_key = created.runtime_full_key # one-time secret -with AgentClient(api_key=runtime_key) as agent: # run on the same surface +with AgentClient(api_key=runtime_key, base_url=base_url) as agent: print(agent.tasks.run(agent_id=created.agent_id, message="Hi").output) ``` @@ -374,12 +380,12 @@ connection pool). ## Version policy - 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. + surface. Patch bumps (0.3.0 → 0.3.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.2.0#subdirectory=python" + pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.3.0#subdirectory=python" ``` Installing from `@main` will eventually break you when the surface @@ -387,7 +393,7 @@ connection pool). 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.2.0`) so the backend can correlate issues. + (`xagent-sdk-python/0.3.0`) so the backend can correlate issues. ## Development