Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
349 changes: 235 additions & 114 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
157 changes: 157 additions & 0 deletions python/src/xagent_sdk/_agents.py
Original file line number Diff line number Diff line change
@@ -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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create() defaults generate_runtime_key to True, so a successful default create response is expected to include the one-time runtime key. If the backend response omits api_key or sends a non-object value here, _parse_agent_create() currently returns runtime_full_key=None; constructing an AgentClient(api_key=result.runtime_full_key) can then fall back to XAGENT_API_KEY and silently use the wrong credential. Please make the default-key path fail closed, for example by requiring api_key.full_key when generate_runtime_key=True (and applying the same check to create_from_template()).


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.
"""
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 _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())
106 changes: 106 additions & 0 deletions python/src/xagent_sdk/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""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:
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
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