Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | — |

Expand Down
55 changes: 47 additions & 8 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ 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

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
Expand Down Expand Up @@ -292,6 +294,43 @@ 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

# 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, base_url=base_url) as agent:
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:
Expand Down Expand Up @@ -341,20 +380,20 @@ 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
evolves on the 0.x track. The `#subdirectory=python` fragment is
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

Expand Down
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.2.0"
version = "0.3.0"

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.

This bumps the package to 0.3.0, but python/README.md still says the SDK status is 0.2.0 and the install command still pins @v0.2.0; the root README also still lists the Python client as 0.1.0. Anyone following the README will install a version that does not contain the new xagent_sdk.cloud.WorkspaceClient module, so the new example fails at import time. Please update the README status/install tag to v0.3.0 and sync the root README version as well.

description = "Python client SDK for xAgent"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
42 changes: 32 additions & 10 deletions python/src/xagent_sdk/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,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.

Expand All @@ -36,6 +62,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,
Expand All @@ -47,16 +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)
if base_url is None:
base_url = os.environ.get("XAGENT_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: "
Expand Down
6 changes: 5 additions & 1 deletion python/src/xagent_sdk/_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"""

from typing import TYPE_CHECKING
from urllib.parse import quote

from xagent_sdk.types import (
Template,
Expand Down Expand Up @@ -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())
2 changes: 1 addition & 1 deletion python/src/xagent_sdk/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.3.0"
3 changes: 3 additions & 0 deletions python/src/xagent_sdk/cloud/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from xagent_sdk.cloud.workspace_client import WorkspaceClient

__all__ = ["WorkspaceClient"]
183 changes: 183 additions & 0 deletions python/src/xagent_sdk/cloud/_agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""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 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

# 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}


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: _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.

``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: _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.

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_<prefix>_<secret>``) 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())
Loading
Loading