Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8566be7
refactor(client): extract _BaseClient for shared transport plumbing
AlexLiu190625 May 29, 2026
24d9e8d
refactor(client): rename XAgentClient to AgentClient and drop me()
AlexLiu190625 May 29, 2026
7405e61
feat(types): add UserPrincipal, Template, Agent, RotateKey + Template…
AlexLiu190625 May 29, 2026
4d93ad8
feat(client): UserClient with templates and agents namespaces
AlexLiu190625 May 29, 2026
c4308a9
test: shared fixtures and unit tests for UserClient + templates + agents
AlexLiu190625 May 29, 2026
2ddbe11
chore: bump SDK to 0.2.0 and pin the public surface
AlexLiu190625 May 29, 2026
bdfcf46
docs(readme): two-client flow, migration guide, and e2e harness
AlexLiu190625 May 29, 2026
c1135f9
fix(parse): align v1 response parsing with real backend shapes
AlexLiu190625 May 29, 2026
d9701ff
review: harden agent-create parsing, fall back on rename helpers, str…
AlexLiu190625 May 29, 2026
fa6440a
refactor(errors): add MalformedResponse for decode-shape failures
AlexLiu190625 May 30, 2026
3bc7692
harden single-object parsers against non-dict response bodies
AlexLiu190625 May 30, 2026
b30185b
fail closed when generate_runtime_key=True returns no key; fix shared…
AlexLiu190625 May 30, 2026
362acd8
strip process-history wording from e2e + README; rename grep-guard test
AlexLiu190625 May 30, 2026
347ba37
reject empty runtime key, not just None, in the fail-closed check
AlexLiu190625 May 31, 2026
a9a86ab
enforce the non-empty-key invariant at the client boundary, not the c…
AlexLiu190625 May 31, 2026
f395f80
fix(sdk): reject empty runtime keys from create responses
rogercloud May 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
352 changes: 237 additions & 115 deletions python/README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 20 additions & 4 deletions python/src/xagent_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,49 @@
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,
InvalidAPIKey,
InvalidInput,
MalformedResponse,
RateLimited,
TaskBusy,
TaskNotFound,
TaskTimeout,
TemplateNotFound,
XAgentError,
XAgentTransportError,
)
from xagent_sdk.types import (
AgentCreateResult,
AgentSummary,
AppendResult,
CreateTaskResult,
MeResponse,
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",
"MeResponse",
"MalformedResponse",
"RateLimited",
"RotateKeyResult",
"RunResult",
"Step",
"StepType",
Expand All @@ -40,7 +52,11 @@
"TaskNotFound",
"TaskStatus",
"TaskTimeout",
"XAgentClient",
"Template",
"TemplateDetail",
"TemplateNotFound",
"UserClient",
"UserPrincipal",
"XAgentError",
"XAgentTransportError",
"__version__",
Expand Down
190 changes: 190 additions & 0 deletions python/src/xagent_sdk/_agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""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.errors import MalformedResponse
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


def _require_runtime_key(
result: AgentCreateResult, generate_runtime_key: bool
) -> AgentCreateResult:
"""Fail closed when a key was requested but the response lacks one.

``generate_runtime_key=True`` is a promise that the response carries a
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 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",
http_status=None,
)
return result


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.
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,
"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 _require_runtime_key(
_parse_agent_create(resp.json()), generate_runtime_key
)

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 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 (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:
``AgentCreateResult``; see ``create()`` for field semantics.

Raises:
TemplateNotFound: 404 ``template_not_found`` -- unknown
``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 {}),
"template_id": template_id,
"generate_runtime_key": generate_runtime_key,
}
resp = self._client._request("POST", "/v1/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/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())
114 changes: 114 additions & 0 deletions python/src/xagent_sdk/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Internal base class shared by every public SDK client.

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
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:
# 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: "
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
5 changes: 3 additions & 2 deletions python/src/xagent_sdk/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down
Loading
Loading