diff --git a/python/README.md b/python/README.md index c1e52fe..cf244c6 100644 --- a/python/README.md +++ b/python/README.md @@ -1,19 +1,21 @@ # xagent-sdk-python Python client SDK for the [xAgent](https://github.com/xorbitsai/xagent) -HTTP v1 API — let a SaaS app trigger and observe xAgent agents in a -few lines. +HTTP v1 API. Lets a SaaS app authenticate as a user, mint AI agents +from templates, and trigger them — all in a handful of lines. -> **Status**: 0.1.0 — early access. The v1 backend contract is frozen -> at xagent PR #384, but the SDK surface may evolve through the 0.x -> series. Pin to a tag (see [Version policy](#version-policy)). +> **Status**: 0.2.0 — early access. **Breaking change vs 0.1.0**: the +> SDK now exposes two clients (``UserClient`` for management, +> ``AgentClient`` for runtime) instead of a single class, and `/v1/me` +> now returns a user principal instead of an agent identity. See +> [Migration from 0.1.0](#migration-from-010) below. ## Install Pin to a release tag — do **not** install from `main`: ```bash -pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.1.0#subdirectory=python" +pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.2.0#subdirectory=python" ``` The Python client lives under [`python/`](.) in the @@ -23,171 +25,272 @@ The Python client lives under [`python/`](.) in the Python 3.11+ required. Set credentials via environment (recommended) or pass them to the -constructor: +constructors: ```bash +# personal key — for UserClient (templates, agents, identity) +export XAGENT_PERSONAL_KEY="xag_personal_..." + +# runtime key — for AgentClient (chat tasks against a specific agent) export XAGENT_API_KEY="xag_..." + +# shared base URL export XAGENT_BASE_URL="https://your-xagent.example" ``` +The two env vars are intentionally distinct so you can hold both keys +in the same process without one overriding the other. + +## Migration from 0.1.0 + +0.2.0 is a breaking release. Two changes affect existing 0.1.0 code: + +1. **`XAgentClient` was renamed to `AgentClient`.** The class is + identical otherwise — only the name and import path changed. +2. **`client.me()` no longer exists on `AgentClient`.** Identity moved + to `UserClient.me()` and the response shape changed too + (`UserPrincipal` with `user_id` / `email` / `name` / + `principal_type` / `key_prefix`, replacing `MeResponse` with + `agent_id` / `agent_name`). Listing your agents now goes through + `UserClient.agents.list()` instead. + +Minimal 0.1.0 → 0.2.0 sed pass (assuming your code already had +``XAGENT_API_KEY`` set): + +```bash +# Rename the runtime client wherever it appears. +sed -i '' 's/XAgentClient/AgentClient/g' your_app.py + +# Delete imports of the removed MeResponse class; if you used the +# value, you will need to migrate to UserClient.me() returning +# UserPrincipal (see Example 1). +sed -i '' '/MeResponse/d' your_app.py +``` + +Importing the old names from 0.2.0 raises `ImportError` immediately +(not a runtime ``AttributeError`` halfway through), so missed +callsites are surfaced at startup. + ## Quick start +The happy path is two steps: use a personal key to mint or +look up an agent, then use that agent's runtime key to run tasks +against it. + ```python -from xagent_sdk import XAgentClient +from xagent_sdk import AgentClient, UserClient + +# Step 1: management — pick a template and create an agent. The +# response carries a one-time runtime key. +with UserClient() as user: # reads env vars + new_agent = user.agents.create_from_template( + "support-ai-chatbot-agent", + overrides={"name": "HR Leave Assistant"}, + ) + +runtime_key = new_agent.runtime_full_key # store in a vault +agent_id = new_agent.agent_id -with XAgentClient() as client: # reads env vars - result = client.tasks.run( - agent_id=12, - message="What is the capital of France?", +# Step 2: runtime — call the agent. +with AgentClient(api_key=runtime_key) as agent: + result = agent.tasks.run( + agent_id=agent_id, + message="How much sick leave do I have left?", ) print(result.output) - # "The capital of France is Paris." ``` -`tasks.run()` is `create` + `wait` + `steps` bundled with one deadline. -For long-running or multi-turn workflows, use the lower-level methods -directly (see [Multi-turn](#3-multi-turn-conversations) below). +The runtime key is the **only** thing the SDK exposes a copy of; the +backend stores a bcrypt hash and cannot return the secret again. +Persist it to a secret manager before discarding the +`AgentCreateResult`. ## Concepts -- **Agent**: a server-side template (prompt + tools + model config). - Issued one API key, bound 1:1 to the key. -- **Task**: one conversation session. Created with the first user - message; subsequent turns *append* to the same task. Holds the - transcript and final output. +- **User**: a human (identified by personal key) who owns agents + inside their workspace. ``UserClient.me()`` returns this user as a + ``UserPrincipal``. +- **Personal key**: ``xag_personal__`` — the user's + long-lived management credential. Authorizes ``/v1/me``, + ``/v1/templates*``, and ``/v1/agents*`` and is held by + ``UserClient``. +- **Agent**: a server-side template instance (system prompt + tools + + model config). Created via ``UserClient.agents.create()`` or + ``UserClient.agents.create_from_template()``. +- **Agent runtime key**: ``xag__`` — 1:1 with an + agent, authorizes only ``/v1/chat/tasks*``. Returned once by + ``agents.create*`` (when ``generate_runtime_key=True``) or by + ``agents.rotate_key()``. +- **Template**: a server-managed preset (Content Generator, Analyzer, + Q&A, Assistant, ...). Returned by ``UserClient.templates.list()``; + the per-template detail (``TemplateDetail.agent_config``) is the + merge target for ``create_from_template`` overrides. +- **Task**: one conversation session against an agent. Created with + the first user message; subsequent turns *append* to the same task. - **Step**: one entry on the agent's public timeline. Four types — - `message`, `thinking`, `tool_call`, `agent_delegation`. Each step - carries a `data` dict whose keys depend on the type. + ``message``, ``thinking``, ``tool_call``, ``agent_delegation``. ## Examples ### 1. Identity probe -Verify the key is valid and discover which agent it binds to: - ```python -from xagent_sdk import XAgentClient +from xagent_sdk import UserClient -with XAgentClient() as client: - me = client.me() - print(f"agent_id={me.agent_id} name={me.agent_name!r} key={me.key_prefix}") +with UserClient() as user: + me = user.me() + print(f"user_id={me.user_id} email={me.email} name={me.name}") ``` -Each call hits the backend; if you only need the identity once, store -the result. - -### 2. Single-turn with step inspection +Each call hits the backend; cache the value locally if you need it +more than once. -`run()` returns a `RunResult` carrying the final `TaskInfo` plus the -full step timeline. Filter by `StepType` to extract tool calls, -planning steps, etc.: +### 2. Pick a template, mint an agent, run it ```python -from xagent_sdk import StepType, XAgentClient +from xagent_sdk import AgentClient, UserClient + +with UserClient() as user: + templates = user.templates.list() + print([t.template_id for t in templates]) + # template ids are backend-defined, e.g. + # ['support-ai-chatbot-agent', 'sales-inbound-agent', ...] -with XAgentClient() as client: - result = client.tasks.run( - agent_id=10, - message="Calculate 17 times 23 using a tool. Reply with the number only.", + detail = user.templates.get("support-ai-chatbot-agent") + # detail.agent_config is the merge target the backend uses below + + created = user.agents.create_from_template( + "support-ai-chatbot-agent", + overrides={"name": "Policy Bot"}, + ) + print(created.agent_id, created.runtime_key_prefix) + +with AgentClient(api_key=created.runtime_full_key) as agent: + result = agent.tasks.run( + agent_id=created.agent_id, + message="Summarize today's PTO policy.", ) print(result.output) - # "391" +``` + +### 3. List existing agents - for step in result.steps: - if step.type is StepType.TOOL_CALL: - print(f"tool={step.data['name']} args={step.data['args']}") - # tool=execute_python_code args={'code': '17 * 23'} +```python +with UserClient() as user: + for agent in user.agents.list(): + print(agent.agent_id, agent.name, agent.status) ``` -### 3. Multi-turn conversations +### 4. Rotate a runtime key -Build a conversation by appending to the same `task_id`. The backend -keeps the transcript; `task.output` reflects the latest assistant turn: +Use this when a runtime key was leaked, when scheduled rotation +fires, or when the SDK consumer has lost the value (the SDK never +caches the secret — only the backend has the hash). ```python -from xagent_sdk import XAgentClient +with UserClient() as user: + rotated = user.agents.rotate_key(agent_id=42) + print("save:", rotated.full_key) + # Old runtime key is now revoked; any AgentClient still using it + # will start raising InvalidAPIKey on its next request. +``` -with XAgentClient() as client: - task = client.tasks.create(agent_id=9, message="Reply with 'first'.") - info = client.tasks.wait(task.task_id) - print(info.output) # 'first' +### 5. Multi-turn task - client.tasks.append(task.task_id, agent_id=9, message="Now reply with 'second'.") - info = client.tasks.wait(task.task_id) - print(info.output) # latest turn +```python +from xagent_sdk import AgentClient + +with AgentClient() as agent: + task = agent.tasks.create( + agent_id=42, message="Reply with 'first'." + ) + info = agent.tasks.wait(task.task_id) + print(info.output) # 'first' - steps = client.tasks.steps(task.task_id) - print(f"{len(steps)} steps across both turns") + agent.tasks.append( + task.task_id, agent_id=42, message="Now reply with 'second'." + ) + info = agent.tasks.wait(task.task_id) + print(info.output) # latest assistant turn ``` -`append()` returns immediately with `status='running'`. Either wait for -it explicitly via `wait()`, or retry on `TaskBusy` if you race: +`append()` returns immediately with `status='running'`. If you race +two appends, the loser gets `TaskBusy` (409); just wait and retry: ```python from xagent_sdk import TaskBusy try: - client.tasks.append(task.task_id, agent_id=9, message="...") + agent.tasks.append(task.task_id, agent_id=42, message="...") except TaskBusy: - client.tasks.wait(task.task_id) - client.tasks.append(task.task_id, agent_id=9, message="...") + agent.tasks.wait(task.task_id) + agent.tasks.append(task.task_id, agent_id=42, message="...") ``` -### 4. Error handling +### 6. Error handling All SDK exceptions inherit from `XAgentError` and carry `code`, -`message`, and `http_status`. The six server-mapped codes: +`message`, and `http_status`. Server-mapped codes: | Exception | HTTP | Server code | |---|---|---| | `InvalidAPIKey` | 401 | `invalid_api_key` | | `AgentNotFound` | 404 | `agent_not_found` | | `TaskNotFound` | 404 | `task_not_found` | +| `TemplateNotFound` | 404 | `template_not_found` | | `TaskBusy` | 409 | `task_busy` | -| `RateLimited` | 429 | `rate_limited` (reserved; backend does not yet emit) | -| `InternalError` | 500 | `internal_error` | | `InvalidInput` | 422 | `invalid_input` | +| `RateLimited` | 429 | `rate_limited` | +| `InternalError` | 500 | `internal_error` | -Two SDK-coined codes: +SDK-coined codes: | Exception | Cause | |---|---| | `XAgentTransportError` | network / DNS / TLS error below the HTTP layer | +| `MalformedResponse` | HTTP succeeded but the body did not match the shape the SDK needs | | `TaskTimeout` | `wait()` / `run()` deadline elapsed | -```python -from xagent_sdk import AgentNotFound, TaskTimeout, XAgentClient - -with XAgentClient() as client: - try: - result = client.tasks.run(agent_id=99999, message="hi", timeout=60) - except AgentNotFound as e: - # 404: agent_id doesn't match the key's bound agent - print(f"[{e.code}] {e.message}") - except TaskTimeout as e: - # local deadline; backend may still finish — call get() later if needed - print(f"[{e.code}] {e.message}") -``` - The SDK does **not** retry automatically. Wrap calls with your own policy (e.g., [tenacity](https://tenacity.readthedocs.io/)) if you want retry on transport errors or `TaskBusy`. ## API reference -All methods are sync. An async client lands in a later release. +All methods are synchronous. + +### `UserClient` — management surface + +Constructed with a personal key; talks to `/v1/me`, +`/v1/templates*`, and `/v1/agents*`. | Method | Returns | Notes | |---|---|---| -| `XAgentClient(api_key, base_url, ...)` | `XAgentClient` | constructor; env-var fallback for both | -| `client.me()` | `MeResponse` | identity probe (no caching) | -| `client.close()` / `with ... as client` | — | release the connection pool | -| `client.tasks.create(*, agent_id, message, metadata=None)` | `CreateTaskResult` | POST `/v1/chat/tasks`; returns immediately, `status='pending'` | -| `client.tasks.append(task_id, *, agent_id, message, metadata=None)` | `AppendResult` | POST `/v1/chat/tasks/{id}/messages`; `status='running'`; raises `TaskBusy` if prior turn is still running | -| `client.tasks.get(task_id)` | `TaskInfo` | GET `/v1/chat/tasks/{id}`; latest-turn `input`/`output` | -| `client.tasks.steps(task_id)` | `list[Step]` | GET `/v1/chat/tasks/{id}/steps`; full timeline | -| `client.tasks.wait(task_id, *, timeout=120, poll_interval=1.0)` | `TaskInfo` | poll `get()` until terminal (`COMPLETED` or `FAILED`); raises `TaskTimeout` on deadline | -| `client.tasks.run(*, agent_id, message, timeout=120, poll_interval=1.0, metadata=None)` | `RunResult` | `create` + `wait` + `steps` | +| `UserClient(personal_key, base_url, ...)` | `UserClient` | env-var fallback: `XAGENT_PERSONAL_KEY` / `XAGENT_BASE_URL` | +| `user.me()` | `UserPrincipal` | identity probe (no caching) | +| `user.templates.list()` | `list[Template]` | GET `/v1/templates` | +| `user.templates.get(template_id)` | `TemplateDetail` | GET `/v1/templates/{template_id}`; 404 → `TemplateNotFound` | +| `user.agents.list()` | `list[AgentSummary]` | GET `/v1/agents` | +| `user.agents.create(*, name, instructions, generate_runtime_key=True, metadata=None)` | `AgentCreateResult` | POST `/v1/agents`; `runtime_full_key` is one-time | +| `user.agents.create_from_template(template_id, *, overrides=None, generate_runtime_key=True)` | `AgentCreateResult` | POST `/v1/agents/from-template`; 404 → `TemplateNotFound` | +| `user.agents.rotate_key(agent_id)` | `RotateKeyResult` | POST `/v1/agents/{agent_id}/api-key`; revokes the previous runtime key atomically | +| `user.close()` / `with ... as user` | — | release the connection pool | + +### `AgentClient` — runtime surface + +Constructed with an agent runtime key; talks to `/v1/chat/tasks*` +only. + +| Method | Returns | Notes | +|---|---|---| +| `AgentClient(api_key, base_url, ...)` | `AgentClient` | env-var fallback: `XAGENT_API_KEY` / `XAGENT_BASE_URL` | +| `agent.tasks.create(*, agent_id, message, metadata=None)` | `CreateTaskResult` | POST `/v1/chat/tasks`; returns immediately, `status='pending'` | +| `agent.tasks.append(task_id, *, agent_id, message, metadata=None)` | `AppendResult` | POST `/v1/chat/tasks/{id}/messages`; `status='running'`; raises `TaskBusy` if prior turn is still running | +| `agent.tasks.get(task_id)` | `TaskInfo` | GET `/v1/chat/tasks/{id}`; latest-turn `input`/`output` | +| `agent.tasks.steps(task_id)` | `list[Step]` | GET `/v1/chat/tasks/{id}/steps`; full timeline | +| `agent.tasks.wait(task_id, *, timeout=120, poll_interval=1.0)` | `TaskInfo` | poll `get()` until terminal (`COMPLETED` or `FAILED`); raises `TaskTimeout` on deadline | +| `agent.tasks.run(*, agent_id, message, timeout=120, poll_interval=1.0, metadata=None)` | `RunResult` | `create` + `wait` + `steps` | +| `agent.close()` / `with ... as agent` | — | release the connection pool | ### Status semantics @@ -202,58 +305,73 @@ All methods are sync. An async client lands in a later release. ## Configuration ```python -XAgentClient( - api_key=None, # or env XAGENT_API_KEY - base_url=None, # or env XAGENT_BASE_URL - timeout=30.0, # per-request HTTP timeout (seconds) - max_connections=10, # httpx connection pool size - user_agent=None, # override the default "xagent-sdk-python/..." - transport=None, # custom httpx.BaseTransport (proxy / TLS / tests) +UserClient( + personal_key=None, # or env XAGENT_PERSONAL_KEY + base_url=None, # or env XAGENT_BASE_URL + timeout=30.0, # per-request HTTP timeout (seconds) + max_connections=10, # httpx connection pool size + user_agent=None, # override the default "xagent-sdk-python/..." + transport=None, # custom httpx.BaseTransport (proxy / TLS / tests) +) + +AgentClient( + api_key=None, # or env XAGENT_API_KEY + base_url=None, # or env XAGENT_BASE_URL + timeout=30.0, + max_connections=10, + user_agent=None, + transport=None, ) ``` +Both clients share the same configuration surface. Constructing both +in the same process is safe: each holds its own ``httpx.Client``, so +their default headers (and connection pools) do not bleed into each +other. + `transport=` accepts any `httpx.BaseTransport` — useful for custom retry/proxy/TLS configuration in production, and for `httpx.MockTransport` in tests. -**Threading**: `XAgentClient` is safe to share across threads. -**Fork**: close and recreate the client after `os.fork()` to avoid -socket-state corruption (a standard caveat for any HTTP client with a -persistent connection pool). +**Threading**: both clients are safe to share across threads. +**Fork**: close and recreate after `os.fork()` to avoid socket-state +corruption (standard caveat for any HTTP client with a persistent +connection pool). ## Version policy -- 0.x = alpha. Any minor bump (0.1 → 0.2) may break the surface. Patch - bumps (0.1.0 → 0.1.1) are bugfix-only. +- 0.x = alpha. Any minor bump (0.1 → 0.2 → 0.3) may break the + surface. Patch bumps (0.2.0 → 0.2.1) are bugfix-only. - A future 1.0 will lock the public API per SemVer. - **Always pin to a git tag** in production: ```bash - pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.1.0#subdirectory=python" + pip install "xagent-sdk @ git+https://github.com/xorbitsai/xagent-sdk@v0.2.0#subdirectory=python" ``` Installing from `@main` will eventually break you when the surface evolves on the 0.x track. The `#subdirectory=python` fragment is - required because the SDK lives in a subdirectory of the multi-language - monorepo. + required because the SDK lives in a subdirectory of the + multi-language monorepo. - The User-Agent header carries the SDK version - (`xagent-sdk-python/0.1.0`) so the backend can correlate issues. + (`xagent-sdk-python/0.2.0`) so the backend can correlate issues. ## Development ```bash uv sync --group dev uv run pre-commit install -uv run pytest # 68 unit tests, hermetic, ~0.5s +uv run pytest # ~120 unit tests, hermetic, ~1s ``` ### Local end-to-end tests -E2E tests require a running xAgent backend and are skipped by default. -Run them explicitly: +E2E tests require a running xAgent backend and **both** keys (one to +mint agents, one to run them). Run them explicitly: ```bash export XAGENT_BASE_URL=http://localhost:8000 +export XAGENT_PERSONAL_KEY=xag_personal_... export XAGENT_API_KEY=xag_... # macOS / corporate networks: bypass any system proxy for localhost, @@ -263,8 +381,12 @@ export NO_PROXY=localhost,127.0.0.1 uv run pytest -m e2e ``` -Set `E2E_AGENT_ID` to override the agent the smoke test points at; by -default it discovers the bound agent via `me()`. +Set `E2E_AGENT_ID` to point the runtime-only tests at a specific +agent (``AgentClient`` has no identity probe, so the runtime tests +skip when this is unset). Set `E2E_TEMPLATE_ID` to pick which template +the full-flow test instantiates from (defaults to the first listed +template), and `E2E_AGENT_NAME` to override the new agent's display +name. ## License diff --git a/python/pyproject.toml b/python/pyproject.toml index da3fb9d..7ae2aa0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "xagent-sdk" -version = "0.1.0" +version = "0.2.0" description = "Python client SDK for xAgent" readme = "README.md" requires-python = ">=3.11" diff --git a/python/src/xagent_sdk/__init__.py b/python/src/xagent_sdk/__init__.py index 402f410..737029a 100644 --- a/python/src/xagent_sdk/__init__.py +++ b/python/src/xagent_sdk/__init__.py @@ -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", @@ -40,7 +52,11 @@ "TaskNotFound", "TaskStatus", "TaskTimeout", - "XAgentClient", + "Template", + "TemplateDetail", + "TemplateNotFound", + "UserClient", + "UserPrincipal", "XAgentError", "XAgentTransportError", "__version__", diff --git a/python/src/xagent_sdk/_agents.py b/python/src/xagent_sdk/_agents.py new file mode 100644 index 0000000..3e66570 --- /dev/null +++ b/python/src/xagent_sdk/_agents.py @@ -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()) diff --git a/python/src/xagent_sdk/_base.py b/python/src/xagent_sdk/_base.py new file mode 100644 index 0000000..668a53e --- /dev/null +++ b/python/src/xagent_sdk/_base.py @@ -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 diff --git a/python/src/xagent_sdk/_http.py b/python/src/xagent_sdk/_http.py index e57ec72..7ea967c 100644 --- a/python/src/xagent_sdk/_http.py +++ b/python/src/xagent_sdk/_http.py @@ -19,8 +19,9 @@ class HTTPClient: """Thin httpx.Client wrapper for the xAgent v1 API. Owns a single httpx.Client (connection pool) for the lifetime of the - enclosing XAgentClient. Returns raw httpx.Response objects; - HTTP-status-to-exception mapping is layered on top in a later commit. + enclosing public client (AgentClient or UserClient). Returns raw + httpx.Response objects; HTTP-status-to-exception mapping is layered + on top in ``_BaseClient._request``. """ def __init__( diff --git a/python/src/xagent_sdk/_templates.py b/python/src/xagent_sdk/_templates.py new file mode 100644 index 0000000..2c79d3f --- /dev/null +++ b/python/src/xagent_sdk/_templates.py @@ -0,0 +1,59 @@ +"""The ``client.templates`` namespace exposed on ``UserClient``. + +Templates are server-managed presets (Content Generator, Analyzer, Q&A, +Assistant, ...) that a SaaS app can offer to its users when they create +an agent without writing instructions from scratch. The list endpoint +returns slim ``Template`` summaries suitable for a picker UI; the detail +endpoint returns the full ``agent_config`` dict needed by +``AgentsAPI.create_from_template``. + +This module is internal -- consumers reach it via +``user_client.templates`` -- so the class is exported via the user +client and not re-exported from ``xagent_sdk.__init__``. +""" + +from typing import TYPE_CHECKING + +from xagent_sdk.types import ( + Template, + TemplateDetail, + _parse_template_detail, + _parse_template_list, +) + +if TYPE_CHECKING: + from xagent_sdk.user_client import UserClient + + +class TemplatesAPI: + """The ``user_client.templates`` namespace.""" + + def __init__(self, client: "UserClient") -> None: + self._client = client + + def list(self) -> list[Template]: + """``GET /v1/templates`` -- list available agent templates. + + Returns a slim summary (id + name + optional description) per + template. Use ``get(template_id)`` to retrieve the full + ``agent_config`` needed by ``agents.create_from_template``. + + Returns an empty list when the backend sends ``{"templates": []}`` + or a non-dict body; raises ``InvalidAPIKey`` / other ``XAgentError`` + subclasses for HTTP errors per the standard envelope mapping. + """ + resp = self._client._request("GET", "/v1/templates") + return _parse_template_list(resp.json()) + + def get(self, template_id: str) -> TemplateDetail: + """``GET /v1/templates/{template_id}`` -- fetch template detail. + + The returned ``TemplateDetail`` includes the ``agent_config`` dict + whose shape the backend owns; ``create_from_template`` merges it + with any caller-supplied overrides server-side. + + Raises ``TemplateNotFound`` (404 ``template_not_found``) when the + backend reports the template does not exist. + """ + resp = self._client._request("GET", f"/v1/templates/{template_id}") + return _parse_template_detail(resp.json()) diff --git a/python/src/xagent_sdk/_version.py b/python/src/xagent_sdk/_version.py index 3dc1f76..d3ec452 100644 --- a/python/src/xagent_sdk/_version.py +++ b/python/src/xagent_sdk/_version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/python/src/xagent_sdk/agent_client.py b/python/src/xagent_sdk/agent_client.py new file mode 100644 index 0000000..bc2178e --- /dev/null +++ b/python/src/xagent_sdk/agent_client.py @@ -0,0 +1,56 @@ +import httpx + +from xagent_sdk._base import _BaseClient +from xagent_sdk.tasks import TasksAPI + + +class AgentClient(_BaseClient): + """Synchronous runtime client for the xAgent v1 chat-task endpoints. + + Authenticates with an **agent runtime key** (``xag__``) + issued by ``UserClient.agents.create()`` / + ``UserClient.agents.rotate_key()``. Each key is bound 1:1 to an agent + and only authorizes the ``/v1/chat/tasks/*`` surface; management + endpoints (``/v1/me``, ``/v1/templates*``, ``/v1/agents*``) require + a personal key handled by ``UserClient`` instead. + + Constructor argument resolution order for ``api_key`` and ``base_url``: + 1. Explicit keyword argument + 2. Environment variable (``XAGENT_API_KEY`` / ``XAGENT_BASE_URL``) + + Missing values at construction time raise ``ValueError`` instead of + deferring failure to the first request. + + The client owns one ``httpx.Client`` (connection pool) for its + lifetime. Use it as a context manager or call ``close()`` explicitly + to release the pool. + + Thread-safe: a single ``AgentClient`` can be shared across threads. + Not fork-safe: close and recreate the client after ``os.fork()`` to + avoid socket state corruption (a standard caveat for any HTTP client + with a persistent connection pool). + + The ``transport`` parameter accepts any ``httpx.BaseTransport``, + letting advanced users plug in custom retry, proxy, or TLS + configuration; ``httpx.MockTransport`` also works for tests. + """ + + 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: + super().__init__( + api_key=api_key, + base_url=base_url, + timeout=timeout, + max_connections=max_connections, + user_agent=user_agent, + transport=transport, + ) + self.tasks = TasksAPI(self) diff --git a/python/src/xagent_sdk/client.py b/python/src/xagent_sdk/client.py deleted file mode 100644 index f518aa2..0000000 --- a/python/src/xagent_sdk/client.py +++ /dev/null @@ -1,117 +0,0 @@ -import os -from types import TracebackType -from typing import Self - -import httpx - -from xagent_sdk._http import HTTPClient -from xagent_sdk.errors import from_response -from xagent_sdk.tasks import TasksAPI -from xagent_sdk.types import MeResponse, _parse_me - - -class XAgentClient: - """Synchronous Python client for the xAgent v1 HTTP API. - - Constructor argument resolution order for ``api_key`` and ``base_url``: - 1. Explicit keyword argument - 2. Environment variable (``XAGENT_API_KEY`` / ``XAGENT_BASE_URL``) - 3. (v0.2.0+) Hardcoded production default URL -- not yet baked in - while the xAgent team finalizes the prod endpoint. - - Missing values at construction time raise ``ValueError`` instead of - deferring failure to the first request. - - The client owns one ``httpx.Client`` (connection pool) for its - lifetime. Use it as a context manager or call ``close()`` explicitly - to release the pool. - - Thread-safe: a single ``XAgentClient`` can be shared across threads. - Not fork-safe: close and recreate the client after ``os.fork()`` to - avoid socket state corruption (a standard caveat for any HTTP client - with a persistent connection pool). - - The ``transport`` parameter accepts any ``httpx.BaseTransport``, - letting advanced users plug in custom retry, proxy, or TLS - configuration; ``httpx.MockTransport`` also works for tests. - """ - - 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("XAGENT_API_KEY") - base_url = base_url or os.environ.get("XAGENT_BASE_URL") - if not api_key: - raise ValueError("api_key required: pass api_key=... or set XAGENT_API_KEY") - if not base_url: - raise ValueError( - "base_url required: pass base_url=... or set XAGENT_BASE_URL" - ) - - self._http = HTTPClient( - base_url=base_url, - api_key=api_key, - timeout=timeout, - max_connections=max_connections, - user_agent=user_agent, - transport=transport, - ) - self.tasks = TasksAPI(self) - - def me(self) -> MeResponse: - """Probe the agent identity bound to the API key. - - Zero side-effect. Typically called once at startup to confirm the - key is valid and log which agent the client is talking to. - - Each call hits the backend; if you only need the identity once, - store the result (``identity = client.me()``) rather than - re-calling. - """ - resp = self._request("GET", "/v1/me") - return _parse_me(resp.json()) - - def close(self) -> None: - self._http.close() - - def __enter__(self) -> Self: - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - self.close() - - def _request( - self, - method: str, - path: str, - *, - json: dict[str, object] | None = None, - ) -> httpx.Response: - """Send a request and map any 4xx/5xx response to an XAgentError. - - Protected by package convention: called by ``TasksAPI`` within - ``xagent_sdk`` but not part of the user-facing API. The single - underscore signals "internal to the package" not "internal to - this class". - - Transport-level failures are already wrapped in - ``XAgentTransportError`` by ``HTTPClient.request``; this helper - only adds the V1-envelope-to-exception mapping for HTTP error - responses that do have a body. - """ - resp = self._http.request(method, path, json=json) - if resp.is_error: - raise from_response(resp) - return resp diff --git a/python/src/xagent_sdk/errors.py b/python/src/xagent_sdk/errors.py index 8e1b388..05a6d48 100644 --- a/python/src/xagent_sdk/errors.py +++ b/python/src/xagent_sdk/errors.py @@ -44,7 +44,7 @@ class TaskBusy(XAgentError): class RateLimited(XAgentError): - """HTTP 429, code ``rate_limited``. Reserved; backend does not yet emit.""" + """HTTP 429, code ``rate_limited``.""" class InternalError(XAgentError): @@ -63,6 +63,17 @@ class InvalidInput(XAgentError): """ +class TemplateNotFound(XAgentError): + """HTTP 404, code ``template_not_found``. + + Raised when ``UserClient.templates.get(template_id)`` or + ``UserClient.agents.create_from_template(template_id, ...)`` is + given an unknown ``template_id``. Distinct from ``AgentNotFound`` + because the SaaS UI path treats template-picker mismatch differently + from agent-lookup mismatch. + """ + + # SDK-coined errors (server has no equivalent code). class XAgentTransportError(XAgentError): """Network, DNS, TLS, or local timeout below the HTTP layer. @@ -72,6 +83,20 @@ class XAgentTransportError(XAgentError): """ +class MalformedResponse(XAgentError): + """The HTTP exchange succeeded but the body did not match the shape the + SDK requires to build a result. + + Raised by the response parsers (e.g. an agent-create body missing its + ``agent`` block) so the failure points at the response contract rather + than surfacing as a raw pydantic ``ValidationError`` on a derived + field. ``http_status`` is ``None`` because the status line itself was + not the problem -- the decoded payload was. The ``code`` is the + SDK-coined string ``malformed_response``; the server never emits it, + so it is absent from ``_CODE_MAP``. + """ + + class TaskTimeout(XAgentError): """``wait()`` / ``run()`` exceeded its local deadline waiting for a task to reach a terminal state. @@ -89,6 +114,7 @@ class TaskTimeout(XAgentError): "task_not_found": TaskNotFound, "task_busy": TaskBusy, "invalid_input": InvalidInput, + "template_not_found": TemplateNotFound, "rate_limited": RateLimited, "internal_error": InternalError, } diff --git a/python/src/xagent_sdk/tasks.py b/python/src/xagent_sdk/tasks.py index 33e118f..fba724e 100644 --- a/python/src/xagent_sdk/tasks.py +++ b/python/src/xagent_sdk/tasks.py @@ -16,7 +16,7 @@ ) if TYPE_CHECKING: - from xagent_sdk.client import XAgentClient + from xagent_sdk.agent_client import AgentClient # Mirrors backend ``v1/tasks.py:170``: ``_TERMINAL_STATUSES = (COMPLETED, @@ -32,7 +32,7 @@ class TasksAPI: """The ``client.tasks`` namespace. All four endpoint methods are thin wrappers over the v1 endpoints: - build a request body, hand it to ``XAgentClient._request`` for + build a request body, hand it to ``AgentClient._request`` for transport + error mapping, then parse the success body into a frozen dataclass. @@ -49,7 +49,7 @@ class TasksAPI: other error from the underlying calls. """ - def __init__(self, client: "XAgentClient") -> None: + def __init__(self, client: "AgentClient") -> None: self._client = client def create( diff --git a/python/src/xagent_sdk/types.py b/python/src/xagent_sdk/types.py index f44e17b..b27ba50 100644 --- a/python/src/xagent_sdk/types.py +++ b/python/src/xagent_sdk/types.py @@ -5,6 +5,8 @@ from pydantic import TypeAdapter +from xagent_sdk.errors import MalformedResponse + class TaskStatus(StrEnum): """Lifecycle states a task can hold. @@ -41,12 +43,108 @@ class StepType(StrEnum): @dataclass(frozen=True) -class MeResponse: - """``GET /v1/me`` payload -- agent identity bound to the presented key.""" +class UserPrincipal: + """``GET /v1/me`` payload -- the user identity the personal key + is bound to. + + ``principal_type`` is the stable enum string ``"user"`` for the + surface ``UserClient`` covers. ``key_prefix`` is the public-safe + 6-char handle (``xag_personal__...``) and is safe to log; + the secret half of the key is never returned by this endpoint. + """ + + principal_type: str + user_id: int + email: str + name: str + key_prefix: str + + +@dataclass(frozen=True) +class Template: + """``GET /v1/templates`` list entry. + + The list endpoint returns a slim summary suitable for showing in a + SaaS-app picker; the full ``agent_config`` (system prompt, tools, + model defaults) is only included in the per-template + ``GET /v1/templates/{template_id}`` response (``TemplateDetail``). + """ + + template_id: str + name: str + description: str | None = None + + +@dataclass(frozen=True) +class TemplateDetail: + """``GET /v1/templates/{template_id}`` payload. + + Extends ``Template`` with ``agent_config``, the dict that + ``UserClient.agents.create_from_template()`` merges with the caller's + ``overrides`` before posting to the backend. The shape of + ``agent_config`` is intentionally typed as ``dict[str, Any]``: it is + the backend's evolving template-config envelope (currently includes + things like ``instructions`` / ``mode`` / ``tools_default``), and + pinning a strict schema here would force SDK releases for every + backend template-config change. + """ + + template_id: str + name: str + description: str | None + agent_config: dict[str, Any] + + +@dataclass(frozen=True) +class AgentSummary: + """``GET /v1/agents`` list entry. + + Slim shape for listing agents owned by the personal key's user. + The optional ``status`` reflects the agent's published-state + (e.g. ``"active"`` / ``"draft"`` / ``"paused"``); ``None`` means the + backend omitted the field on this entry. + """ agent_id: int - agent_name: str + name: str + status: str | None = None + + +@dataclass(frozen=True) +class AgentCreateResult: + """``POST /v1/agents`` / ``POST /v1/agents/from-template`` payload. + + Carries the new agent's identity plus the **one-time** runtime key + when ``generate_runtime_key=True`` was passed (the backend default). + ``runtime_full_key`` is ``None`` only when the caller explicitly set + ``generate_runtime_key=False`` and intends to call ``rotate_key()`` + later to materialize the first runtime key. + + The ``runtime_full_key`` is the only chance the SDK or its caller + have to read the secret in cleartext; store it in a secret vault + immediately and never log it. ``runtime_key_prefix`` is the + public-safe 6-char lookup handle and is safe to log for tracing. + """ + + agent_id: int + name: str + runtime_full_key: str | None + runtime_key_prefix: str | None + + +@dataclass(frozen=True) +class RotateKeyResult: + """``POST /v1/agents/{agent_id}/api-key`` payload. + + Rotation is destructive: the previous runtime key for this agent is + revoked atomically with the new key insertion. The ``full_key`` is a + one-time payload exposed only on this response; storing it is the + caller's responsibility. + """ + + full_key: str key_prefix: str + created_at: datetime @dataclass(frozen=True) @@ -152,27 +250,147 @@ def status(self) -> TaskStatus: # module scope. ``validate_python`` handles ISO datetime parsing, enum # coercion, and Optional handling without bespoke conversion code. -_ME_ADAPTER = TypeAdapter(MeResponse) +_USER_PRINCIPAL_ADAPTER = TypeAdapter(UserPrincipal) +_TEMPLATE_LIST_ADAPTER = TypeAdapter(list[Template]) +_TEMPLATE_DETAIL_ADAPTER = TypeAdapter(TemplateDetail) +_AGENT_LIST_ADAPTER = TypeAdapter(list[AgentSummary]) +_AGENT_CREATE_ADAPTER = TypeAdapter(AgentCreateResult) +_ROTATE_KEY_ADAPTER = TypeAdapter(RotateKeyResult) _CREATE_ADAPTER = TypeAdapter(CreateTaskResult) _APPEND_ADAPTER = TypeAdapter(AppendResult) _TASK_INFO_ADAPTER = TypeAdapter(TaskInfo) _STEP_LIST_ADAPTER = TypeAdapter(list[Step]) -def _parse_me(data: dict[str, Any]) -> MeResponse: - return _ME_ADAPTER.validate_python(data) +def _require_mapping(data: Any, what: str) -> dict[str, Any]: + """Return ``data`` if it is a dict, else raise ``MalformedResponse``. + + The single-object parsers below trust ``data`` to be a JSON object; + a backend (or proxy) returning a non-object body would otherwise + surface as a raw pydantic ``ValidationError`` / ``AttributeError`` + that does not name the real cause. Mirrors the ``isinstance(data, + list)`` guard the list parsers already apply. + """ + if not isinstance(data, dict): + raise MalformedResponse( + "malformed_response", + f"expected a JSON object for {what}, got {type(data).__name__}", + http_status=None, + ) + return data + + +def _parse_user_principal(data: Any) -> UserPrincipal: + return _USER_PRINCIPAL_ADAPTER.validate_python( + _require_mapping(data, "user principal") + ) + + +def _parse_template_list(data: Any) -> list[Template]: + """Parse the raw template array returned by ``GET /v1/templates``. + + Backend returns a bare JSON array of template objects whose primary + key is ``id``; the SDK surfaces that as ``Template.template_id`` to + avoid shadowing the ``id`` builtin on public dataclass instances. + Non-list bodies degrade to an empty list (defense-in-depth against + upstream proxies returning a non-canonical shape). + """ + if not isinstance(data, list): + return [] + normalized = [_template_dict(item) for item in data if isinstance(item, dict)] + return _TEMPLATE_LIST_ADAPTER.validate_python(normalized) + + +def _parse_template_detail(data: Any) -> TemplateDetail: + return _TEMPLATE_DETAIL_ADAPTER.validate_python( + _template_dict(_require_mapping(data, "template detail")) + ) + + +def _parse_agent_list(data: Any) -> list[AgentSummary]: + """Parse the raw agent array returned by ``GET /v1/agents``. + + Same bare-array + ``id`` -> ``agent_id`` rename as + ``_parse_template_list``. + """ + if not isinstance(data, list): + return [] + normalized = [_agent_summary_dict(item) for item in data if isinstance(item, dict)] + return _AGENT_LIST_ADAPTER.validate_python(normalized) + + +def _parse_agent_create(data: Any) -> AgentCreateResult: + """Parse the nested ``{"agent": {...}, "api_key": {...}}`` payload. + + Backend returns the new agent row and (when ``generate_runtime_key`` + is true) the one-time runtime key as two siblings under separate + keys; SDK flattens them into ``AgentCreateResult`` so callers see one + record. When ``generate_runtime_key=False`` the ``api_key`` block is + absent and the runtime fields stay ``None`` -- caller is expected to + materialize a key via ``rotate_key()`` later. + + Raises ``MalformedResponse`` if the ``agent`` block is missing or + lacks ``id``/``name`` -- pydantic's raw ``ValidationError`` on + ``agent_id`` would say "Input should be a valid integer, + input_value=None" which does not point at the real cause (backend + response shape violation). + """ + data = _require_mapping(data, "agent-create") + agent = data.get("agent") + if ( + not isinstance(agent, dict) + or agent.get("id") is None + or agent.get("name") is None + ): + raise MalformedResponse( + "malformed_response", + "agent-create response missing required 'agent' block " + "(expected {'agent': {'id': int, 'name': str, ...}, 'api_key'?: {...}})", + http_status=None, + ) + api_key = data.get("api_key") + if not isinstance(api_key, dict): + api_key = {} + flat = { + "agent_id": agent.get("id"), + "name": agent.get("name"), + "runtime_full_key": api_key.get("full_key"), + "runtime_key_prefix": api_key.get("key_prefix"), + } + return _AGENT_CREATE_ADAPTER.validate_python(flat) + + +def _template_dict(item: dict[str, Any]) -> dict[str, Any]: + """Surface backend ``id`` as ``template_id`` while passing other keys + through. Falls back to a pre-existing ``template_id`` so a future + backend that renames the field at source does not silently degrade + to ``None``; pydantic drops fields the dataclass does not declare. + """ + return {**item, "template_id": item.get("id") or item.get("template_id")} + + +def _agent_summary_dict(item: dict[str, Any]) -> dict[str, Any]: + """Surface backend ``id`` as ``agent_id`` for the agent list entry, + with the same ``id``-or-existing-``agent_id`` fallback as + ``_template_dict``. + """ + return {**item, "agent_id": item.get("id") or item.get("agent_id")} + + +def _parse_rotate_key(data: Any) -> RotateKeyResult: + return _ROTATE_KEY_ADAPTER.validate_python(_require_mapping(data, "key rotation")) -def _parse_create_task(data: dict[str, Any]) -> CreateTaskResult: - return _CREATE_ADAPTER.validate_python(data) +def _parse_create_task(data: Any) -> CreateTaskResult: + return _CREATE_ADAPTER.validate_python(_require_mapping(data, "task creation")) -def _parse_append(data: dict[str, Any]) -> AppendResult: - return _APPEND_ADAPTER.validate_python(data) +def _parse_append(data: Any) -> AppendResult: + return _APPEND_ADAPTER.validate_python(_require_mapping(data, "task append")) -def _parse_task_info(data: dict[str, Any]) -> TaskInfo: - return _TASK_INFO_ADAPTER.validate_python(data) +def _parse_task_info(data: Any) -> TaskInfo: + return _TASK_INFO_ADAPTER.validate_python(_require_mapping(data, "task info")) def _parse_steps(data: Any) -> list[Step]: diff --git a/python/src/xagent_sdk/user_client.py b/python/src/xagent_sdk/user_client.py new file mode 100644 index 0000000..d347231 --- /dev/null +++ b/python/src/xagent_sdk/user_client.py @@ -0,0 +1,75 @@ +import httpx + +from xagent_sdk._agents import AgentsAPI +from xagent_sdk._base import _BaseClient +from xagent_sdk._templates import TemplatesAPI +from xagent_sdk.types import UserPrincipal, _parse_user_principal + + +class UserClient(_BaseClient): + """Synchronous management client for the xAgent v1 user-facing surface. + + Authenticates with a **personal key** (``xag_personal__`` + issued by the xAgent web UI to an individual user). Personal keys are + workspace-wide for the user and authorize the management surface + ``/v1/me`` + ``/v1/templates*`` + ``/v1/agents*``; they cannot drive + chat tasks. Use ``AgentClient`` -- with an **agent runtime key** + minted by ``UserClient.agents.create()`` / + ``UserClient.agents.rotate_key()`` -- to invoke + ``/v1/chat/tasks/*``. + + Constructor argument resolution order for ``personal_key`` and + ``base_url``: + + 1. Explicit keyword argument + 2. Environment variable + (``XAGENT_PERSONAL_KEY`` / ``XAGENT_BASE_URL``) + + Missing values at construction time raise ``ValueError`` instead of + deferring failure to the first request. ``XAGENT_PERSONAL_KEY`` is a + separate env var from ``XAGENT_API_KEY`` (which feeds ``AgentClient``) + so the two clients can coexist in the same process without sharing + state. + + The ``transport`` parameter accepts any ``httpx.BaseTransport`` for + proxy / TLS / test injection -- ``httpx.MockTransport`` is the + canonical unit-test path. + """ + + _ENV_API_KEY = "XAGENT_PERSONAL_KEY" + _API_KEY_FIELD = "personal_key" + + def __init__( + self, + personal_key: str | None = None, + base_url: str | None = None, + *, + timeout: float = 30.0, + max_connections: int = 10, + user_agent: str | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + super().__init__( + api_key=personal_key, + base_url=base_url, + timeout=timeout, + max_connections=max_connections, + user_agent=user_agent, + transport=transport, + ) + self.templates = TemplatesAPI(self) + self.agents = AgentsAPI(self) + + def me(self) -> UserPrincipal: + """``GET /v1/me`` -- identity probe for the personal key. + + Zero side-effect. Returns the user principal the personal key + belongs to (``principal_type`` / ``user_id`` / ``email`` / + ``name`` / ``key_prefix``). Use once at startup to log which + user is connected; cache the result locally if you only need it + once -- the SDK does not cache so a revoked key surfaces as + ``InvalidAPIKey`` immediately rather than silently using a + stale principal. + """ + resp = self._request("GET", "/v1/me") + return _parse_user_principal(resp.json()) diff --git a/python/tests/e2e/conftest.py b/python/tests/e2e/conftest.py index e8b5b33..c415dd7 100644 --- a/python/tests/e2e/conftest.py +++ b/python/tests/e2e/conftest.py @@ -1,13 +1,27 @@ -"""Fixtures for end-to-end tests. +"""Fixtures for end-to-end tests against a real xAgent backend. -These tests require a running xAgent backend reachable at the URL in -``XAGENT_BASE_URL`` with a valid ``XAGENT_API_KEY``. Run with:: +Two pairs of fixtures cover the two-client surface: - XAGENT_BASE_URL=http://localhost:8000 XAGENT_API_KEY=xag_... \\ +- ``user_client`` / ``patient_user_client`` -- ``UserClient`` + authenticated with ``XAGENT_PERSONAL_KEY``. Use for management + endpoints (``/v1/me``, ``/v1/templates*``, ``/v1/agents*``). + +- ``agent_client`` / ``patient_agent_client`` -- ``AgentClient`` + authenticated with ``XAGENT_API_KEY`` (an agent runtime key). Use + for ``/v1/chat/tasks/*`` calls. ``patient_*`` variants raise the + per-request HTTP timeout from 30s to 60s so tests can observe slow + POST behavior (e.g. the async-contract regression catcher). + +Run with:: + + XAGENT_BASE_URL=http://localhost:8000 \\ + XAGENT_PERSONAL_KEY=xag_personal_... \\ + XAGENT_API_KEY=xag_... \\ uv run pytest -m e2e -Without those env vars set, every test in this directory is skipped. -CI does not run e2e tests; they are local-only. +Fixtures call ``pytest.skip(...)`` when their required env vars are +missing, so a developer who only has one of the keys can still +exercise the other half of the surface. CI does not run e2e tests. """ import os @@ -15,23 +29,54 @@ import pytest -from xagent_sdk import XAgentClient +from xagent_sdk import AgentClient, UserClient -@pytest.fixture -def client() -> Iterator[XAgentClient]: - """Default e2e client with the SDK's 30s per-request HTTP timeout.""" +def _need_personal() -> tuple[str, str]: + api_key = os.environ.get("XAGENT_PERSONAL_KEY") + base_url = os.environ.get("XAGENT_BASE_URL") + if not (api_key and base_url): + pytest.skip( + "e2e management surface requires XAGENT_PERSONAL_KEY + XAGENT_BASE_URL" + ) + return api_key, base_url + + +def _need_runtime() -> tuple[str, str]: api_key = os.environ.get("XAGENT_API_KEY") base_url = os.environ.get("XAGENT_BASE_URL") if not (api_key and base_url): - pytest.skip("e2e requires XAGENT_API_KEY and XAGENT_BASE_URL") - with XAgentClient(api_key=api_key, base_url=base_url) as c: + pytest.skip("e2e runtime surface requires XAGENT_API_KEY + XAGENT_BASE_URL") + return api_key, base_url + + +@pytest.fixture +def user_client() -> Iterator[UserClient]: + """UserClient with the SDK's 30s per-request HTTP timeout.""" + personal_key, base_url = _need_personal() + with UserClient(personal_key=personal_key, base_url=base_url) as c: yield c @pytest.fixture -def patient_client() -> Iterator[XAgentClient]: - """Same as ``client`` but with a 60s per-request HTTP timeout. +def patient_user_client() -> Iterator[UserClient]: + """UserClient with a 60s per-request HTTP timeout for slow probes.""" + personal_key, base_url = _need_personal() + with UserClient(personal_key=personal_key, base_url=base_url, timeout=60.0) as c: + yield c + + +@pytest.fixture +def agent_client() -> Iterator[AgentClient]: + """AgentClient with the SDK's 30s per-request HTTP timeout.""" + api_key, base_url = _need_runtime() + with AgentClient(api_key=api_key, base_url=base_url) as c: + yield c + + +@pytest.fixture +def patient_agent_client() -> Iterator[AgentClient]: + """AgentClient with a 60s per-request HTTP timeout. Used by tests that want to *observe* an operation whose latency may exceed the SDK default (e.g. measuring whether POST is actually @@ -40,9 +85,6 @@ def patient_client() -> Iterator[XAgentClient]: this fixture lets the call complete so the test can assert on the measured latency itself, giving a clearer regression signal. """ - api_key = os.environ.get("XAGENT_API_KEY") - base_url = os.environ.get("XAGENT_BASE_URL") - if not (api_key and base_url): - pytest.skip("e2e requires XAGENT_API_KEY and XAGENT_BASE_URL") - with XAgentClient(api_key=api_key, base_url=base_url, timeout=60.0) as c: + api_key, base_url = _need_runtime() + with AgentClient(api_key=api_key, base_url=base_url, timeout=60.0) as c: yield c diff --git a/python/tests/e2e/test_smoke.py b/python/tests/e2e/test_smoke.py index 74cb54f..8b43622 100644 --- a/python/tests/e2e/test_smoke.py +++ b/python/tests/e2e/test_smoke.py @@ -1,13 +1,20 @@ -"""End-to-end smoke test against a real xAgent backend. +"""End-to-end smoke tests against a real xAgent backend. Marked ``@pytest.mark.e2e`` so the default ``pytest`` invocation skips -it. Run explicitly with:: +the whole file. Run explicitly with:: - XAGENT_BASE_URL=... XAGENT_API_KEY=... uv run pytest -m e2e + XAGENT_BASE_URL=... XAGENT_PERSONAL_KEY=... XAGENT_API_KEY=... \\ + uv run pytest -m e2e -Set ``E2E_AGENT_ID`` to override the default agent the smoke test -points at (otherwise the test calls ``me()`` to discover the agent -bound to the presented key). +Set ``E2E_AGENT_ID`` to pick the agent id used by runtime-only tests; +those tests skip when it is unset, because ``AgentClient`` exposes no +self-identity probe (discover agent ids via ``UserClient.agents.list()`` +on the personal-key path). + +Set ``E2E_TEMPLATE_ID`` to pick which template the full-flow test +instantiates an agent from; it skips if the template list is empty or +the id is absent. ``E2E_AGENT_NAME`` (default ``e2e_smoke_``) +controls the new agent's display name. """ import os @@ -15,22 +22,31 @@ import pytest -from xagent_sdk import RunResult, TaskStatus, XAgentClient +from xagent_sdk import ( + AgentClient, + RunResult, + TaskStatus, + UserClient, + UserPrincipal, +) pytestmark = pytest.mark.e2e -def test_me(client: XAgentClient) -> None: - me = client.me() - assert me.agent_id > 0 - assert me.agent_name +def test_user_me(user_client: UserClient) -> None: + me = user_client.me() + assert isinstance(me, UserPrincipal) + assert me.principal_type == "user" + assert me.user_id > 0 + assert me.email + assert me.name assert me.key_prefix -def test_create_is_async(patient_client: XAgentClient) -> None: +def test_create_is_async(patient_agent_client: AgentClient) -> None: """POST /v1/chat/tasks must return asynchronously per v1 contract. - The contract is "create the task, return 202 immediately, run LLM in + Contract is "create the task, return 202 immediately, run LLM in the background, observe transitions via GET poll". A backend that blocks POST until the LLM call completes can still produce correct final output via ``run()``, but defeats the design of having async @@ -44,10 +60,13 @@ def test_create_is_async(patient_client: XAgentClient) -> None: explicitly so the failure points at the contract violation rather than surfacing as a generic transport timeout. """ - agent_id = int(os.environ.get("E2E_AGENT_ID", str(patient_client.me().agent_id))) + agent_id_env = os.environ.get("E2E_AGENT_ID") + if not agent_id_env: + pytest.skip("E2E_AGENT_ID not set; cannot exercise runtime path") + agent_id = int(agent_id_env) t0 = time.monotonic() - created = patient_client.tasks.create(agent_id=agent_id, message="Say hi") + created = patient_agent_client.tasks.create(agent_id=agent_id, message="Say hi") post_elapsed = time.monotonic() - t0 assert created.status is TaskStatus.PENDING, ( @@ -62,9 +81,18 @@ def test_create_is_async(patient_client: XAgentClient) -> None: ) -def test_run_single_turn(client: XAgentClient) -> None: - agent_id = int(os.environ.get("E2E_AGENT_ID", str(client.me().agent_id))) - result = client.tasks.run( +def test_run_single_turn(agent_client: AgentClient) -> None: + """Single-turn runtime probe with an existing agent. + + Requires ``E2E_AGENT_ID`` because ``AgentClient`` has no identity + probe; the test would have nothing to point at without a + caller-supplied id. + """ + agent_id_env = os.environ.get("E2E_AGENT_ID") + if not agent_id_env: + pytest.skip("E2E_AGENT_ID not set; cannot exercise runtime path") + agent_id = int(agent_id_env) + result = agent_client.tasks.run( agent_id=agent_id, message="Say hi in one word", timeout=60.0, @@ -73,3 +101,48 @@ def test_run_single_turn(client: XAgentClient) -> None: assert isinstance(result, RunResult) assert result.status is TaskStatus.COMPLETED assert result.output is not None + + +def test_e2e_full_flow(user_client: UserClient) -> None: + """Pick a template, create an agent from it, run a single turn. + + 1. List templates; skip if the backend exposes none. + 2. Pick ``E2E_TEMPLATE_ID`` if set, else the first listed template, + and call ``agents.create_from_template`` to mint a fresh agent + + runtime key. + 3. Build an ``AgentClient`` with the freshly minted runtime key. + 4. Drive a single-turn ``tasks.run()`` and assert it completes. + + The test does not delete the created agent -- the SDK has no delete + method and the backend owns lifecycle cleanup. Run on a scratch + backend instance. + """ + agent_name = os.environ.get("E2E_AGENT_NAME", f"e2e_smoke_{os.getpid()}") + base_url = os.environ["XAGENT_BASE_URL"] + + templates = user_client.templates.list() + if not templates: + pytest.skip("backend exposes no templates; nothing to instantiate") + template_id = os.environ.get("E2E_TEMPLATE_ID", templates[0].template_id) + + created = user_client.agents.create_from_template( + template_id, overrides={"name": agent_name} + ) + assert created.agent_id > 0 + # generate_runtime_key defaults to True; one-time secret must be + # present so the next step has something to authenticate with. + assert created.runtime_full_key is not None + assert created.runtime_full_key.startswith("xag_") + + with AgentClient( + api_key=created.runtime_full_key, base_url=base_url, timeout=60.0 + ) as runtime: + result = runtime.tasks.run( + agent_id=created.agent_id, + message="Say hi in one word", + timeout=120.0, + poll_interval=2.0, + ) + assert isinstance(result, RunResult) + assert result.status is TaskStatus.COMPLETED + assert result.output is not None diff --git a/python/tests/unit/conftest.py b/python/tests/unit/conftest.py index 29b0dc1..29ef8b8 100644 --- a/python/tests/unit/conftest.py +++ b/python/tests/unit/conftest.py @@ -10,27 +10,28 @@ import httpx import pytest -from xagent_sdk import XAgentClient +from xagent_sdk import AgentClient @pytest.fixture(autouse=True) def clean_xagent_env(monkeypatch: pytest.MonkeyPatch) -> None: """Strip XAGENT_* env vars so tests do not inherit ambient config.""" monkeypatch.delenv("XAGENT_API_KEY", raising=False) + monkeypatch.delenv("XAGENT_PERSONAL_KEY", raising=False) monkeypatch.delenv("XAGENT_BASE_URL", raising=False) @pytest.fixture -def make_client() -> Callable[..., XAgentClient]: - """Return a factory for XAgentClient backed by an httpx.MockTransport.""" +def make_client() -> Callable[..., AgentClient]: + """Return a factory for AgentClient backed by an httpx.MockTransport.""" def _factory( handler: Callable[[httpx.Request], httpx.Response], *, api_key: str = "test_key", base_url: str = "https://test.example", - ) -> XAgentClient: - return XAgentClient( + ) -> AgentClient: + return AgentClient( api_key=api_key, base_url=base_url, transport=httpx.MockTransport(handler), diff --git a/python/tests/unit/test_agent_client.py b/python/tests/unit/test_agent_client.py new file mode 100644 index 0000000..f98a789 --- /dev/null +++ b/python/tests/unit/test_agent_client.py @@ -0,0 +1,87 @@ +"""Tests for AgentClient construction and context-manager lifecycle. + +``AgentClient`` exposes ``.tasks`` only; identity probing belongs to +``UserClient.me()`` because the runtime key has no user-shaped identity +to return. This module pins the construction contract and resource +cleanup; auth-mapping coverage for the runtime key (401 -> +``InvalidAPIKey``) lives in ``test_tasks.py`` and ``test_errors.py``. +""" + +from collections.abc import Callable + +import httpx +import pytest + +from xagent_sdk import AgentClient + + +def _ok_handler(req: httpx.Request) -> httpx.Response: + """Generic 200 OK; tests below do not actually send requests, but + ``make_client`` requires a handler argument.""" + return httpx.Response(200, json={"ok": True}) + + +class TestConstruction: + def test_explicit(self, make_client: Callable[..., AgentClient]) -> None: + with make_client(_ok_handler) as c: + # Explicit api_key from the factory default flows to the + # Authorization header verbatim. + assert c._http._client.headers["Authorization"] == "Bearer test_key" + + def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_API_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = AgentClient(transport=httpx.MockTransport(_ok_handler)) + assert c._http._client.headers["Authorization"] == "Bearer envkey" + c.close() + + def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_API_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = AgentClient( + api_key="explicit", + base_url="https://explicit", + transport=httpx.MockTransport(_ok_handler), + ) + # Bearer header comes from the explicit api_key, not the env value. + assert c._http._client.headers["Authorization"] == "Bearer explicit" + c.close() + + def test_missing_api_key(self) -> None: + with pytest.raises(ValueError, match="api_key"): + AgentClient(base_url="https://x") + + def test_missing_base_url(self) -> None: + with pytest.raises(ValueError, match="base_url"): + AgentClient(api_key="x") + + def test_empty_api_key_does_not_fall_back_to_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # An explicit empty key must raise, never silently resolve to the + # environment value -- that would authenticate as whatever agent + # XAGENT_API_KEY happens to hold. Only an omitted (None) key may + # fall back to env. + monkeypatch.setenv("XAGENT_API_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="api_key"): + AgentClient(api_key="") + + def test_empty_base_url_does_not_fall_back_to_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("XAGENT_API_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="base_url"): + AgentClient(api_key="k", base_url="") + + +class TestLifecycle: + def test_context_manager_closes( + self, make_client: Callable[..., AgentClient] + ) -> None: + c = make_client(_ok_handler) + assert c._http._client.is_closed is False + with c: + pass + assert c._http._client.is_closed is True diff --git a/python/tests/unit/test_agents_management.py b/python/tests/unit/test_agents_management.py new file mode 100644 index 0000000..f9357cc --- /dev/null +++ b/python/tests/unit/test_agents_management.py @@ -0,0 +1,312 @@ +"""Tests for UserClient.agents (AgentsAPI).""" + +import json + +import httpx +import pytest + +from xagent_sdk import ( + AgentCreateResult, + AgentNotFound, + AgentSummary, + InvalidInput, + MalformedResponse, + RotateKeyResult, + TemplateNotFound, + UserClient, +) + +from ._fixtures import error_envelope, response + + +def _make_user(handler: object) -> UserClient: + return UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(handler), # type: ignore[arg-type] + ) + + +class TestList: + def test_url_and_parse(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("agents_list")) + + with _make_user(handler) as c: + agents = c.agents.list() + + assert captured[0].method == "GET" + assert captured[0].url.path == "/v1/agents" + assert len(agents) == 3 + assert all(isinstance(a, AgentSummary) for a in agents) + # Covers the status values backend exposes. + statuses = {a.status for a in agents} + assert {"active", "draft", "paused"} <= statuses + + def test_empty_list(self) -> None: + # Canonical empty: backend returns a bare empty array. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[]) + + with _make_user(h) as c: + assert c.agents.list() == [] + + def test_non_list_body_defensive(self) -> None: + # Malformed body (dict instead of list) returns [] rather than + # raising. Mirrors _parse_steps' defense-in-depth pattern. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"agents": []}) + + with _make_user(h) as c: + assert c.agents.list() == [] + + +class TestCreate: + def test_body_shape_default_generate_key(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + result = c.agents.create( + name="HR Leave Assistant", + instructions="You are an HR assistant. ...", + ) + + assert captured[0].method == "POST" + assert captured[0].url.path == "/v1/agents" + body = json.loads(captured[0].content) + # generate_runtime_key default is True; must appear in body. + assert body == { + "name": "HR Leave Assistant", + "instructions": "You are an HR assistant. ...", + "generate_runtime_key": True, + } + assert isinstance(result, AgentCreateResult) + assert result.agent_id == 42 + # one-time runtime key carried back + assert result.runtime_full_key is not None + assert result.runtime_full_key.startswith("xag_") + assert result.runtime_key_prefix == "abc123" + + def test_generate_runtime_key_false_passes_through(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + # When generate_runtime_key=False the backend omits the + # api_key block; only the agent block is present. + return httpx.Response( + 201, + json={ + "agent": { + "id": 42, + "name": "HR Leave Assistant", + }, + }, + ) + + with _make_user(handler) as c: + result = c.agents.create( + name="HR Leave Assistant", + instructions="...", + generate_runtime_key=False, + ) + + body = json.loads(captured[0].content) + assert body["generate_runtime_key"] is False + assert result.runtime_full_key is None + assert result.runtime_key_prefix is None + + def test_generate_runtime_key_true_but_no_key_fails_closed(self) -> None: + # Backend violated the contract: generate_runtime_key=True was + # requested but the response carried no api_key block. The SDK + # must refuse rather than return runtime_full_key=None, which + # would let AgentClient(api_key=None) silently fall back to + # XAGENT_API_KEY. + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, json={"agent": {"id": 42, "name": "HR Leave Assistant"}} + ) + + with _make_user(handler) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create(name="HR Leave Assistant", instructions="...") + assert excinfo.value.code == "malformed_response" + + def test_generate_runtime_key_true_but_empty_key_fails_closed(self) -> None: + # Empty string is also not a usable runtime credential; create() + # should surface the malformed backend response immediately. + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "agent": {"id": 42, "name": "HR Leave Assistant"}, + "api_key": {"full_key": "", "key_prefix": "abc123"}, + }, + ) + + with _make_user(handler) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create(name="HR Leave Assistant", instructions="...") + assert excinfo.value.code == "malformed_response" + + def test_metadata_included_when_given(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create( + name="X", + instructions="...", + metadata={"trace_id": "abc"}, + ) + + body = json.loads(captured[0].content) + assert body["metadata"] == {"trace_id": "abc"} + + def test_no_metadata_field_when_none(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create(name="X", instructions="...") + + body = json.loads(captured[0].content) + assert "metadata" not in body + + def test_422_invalid_input(self) -> None: + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(422, json=error_envelope("validation_422")) + + with _make_user(h) as c, pytest.raises(InvalidInput): + c.agents.create(name="", instructions="") + + +class TestCreateFromTemplate: + def test_body_shape(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create_from_template( + "q_and_a", + overrides={"name": "HR Bot"}, + ) + + assert captured[0].method == "POST" + assert captured[0].url.path == "/v1/agents/from-template" + body = json.loads(captured[0].content) + # Overrides spread flat alongside template_id (matches backend + # V1AgentTemplateCreateRequest schema). + assert body == { + "template_id": "q_and_a", + "generate_runtime_key": True, + "name": "HR Bot", + } + + def test_no_override_fields_when_none(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create_from_template("q_and_a") + + body = json.loads(captured[0].content) + assert body == { + "template_id": "q_and_a", + "generate_runtime_key": True, + } + + def test_template_id_wins_over_collision_in_overrides(self) -> None: + # Defensive: a caller who passes template_id inside overrides + # should not be able to override the explicit positional arg. + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(201, json=response("agents_create")) + + with _make_user(handler) as c: + c.agents.create_from_template( + "real_template", + overrides={"template_id": "hijacked", "name": "X"}, + ) + + body = json.loads(captured[0].content) + assert body["template_id"] == "real_template" + assert body["name"] == "X" + + def test_template_not_found(self) -> None: + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("template_not_found")) + + with _make_user(h) as c, pytest.raises(TemplateNotFound): + c.agents.create_from_template("nope") + + def test_generate_runtime_key_true_but_no_key_fails_closed(self) -> None: + # Same fail-closed contract as create(): default + # generate_runtime_key=True with a keyless response must raise. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(201, json={"agent": {"id": 42, "name": "X"}}) + + with _make_user(h) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create_from_template("q_and_a") + assert excinfo.value.code == "malformed_response" + + def test_generate_runtime_key_true_but_empty_key_fails_closed(self) -> None: + # Same fail-closed contract as create(): empty full_key is not a + # usable one-time runtime key. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "agent": {"id": 42, "name": "X"}, + "api_key": {"full_key": "", "key_prefix": "abc123"}, + }, + ) + + with _make_user(h) as c, pytest.raises(MalformedResponse) as excinfo: + c.agents.create_from_template("q_and_a") + assert excinfo.value.code == "malformed_response" + + +class TestRotateKey: + def test_url_and_parse(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("rotate_key")) + + with _make_user(handler) as c: + result = c.agents.rotate_key(42) + + assert captured[0].method == "POST" + assert captured[0].url.path == "/v1/agents/42/api-key" + assert isinstance(result, RotateKeyResult) + assert result.full_key.startswith("xag_") + assert result.key_prefix == "newabc" + assert result.created_at.year == 2026 + + def test_404_agent_not_found(self) -> None: + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("agent_not_found")) + + with _make_user(h) as c, pytest.raises(AgentNotFound): + c.agents.rotate_key(99999) diff --git a/python/tests/unit/test_client.py b/python/tests/unit/test_client.py deleted file mode 100644 index d17a023..0000000 --- a/python/tests/unit/test_client.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for XAgentClient construction and the me() probe.""" - -from collections.abc import Callable - -import httpx -import pytest - -from xagent_sdk import InvalidAPIKey, MeResponse, XAgentClient - - -def _me_handler(req: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={"agent_id": 7, "agent_name": "Sales", "key_prefix": "a1B2c3"}, - ) - - -def _401_handler(req: httpx.Request) -> httpx.Response: - return httpx.Response( - 401, - json={"error": {"code": "invalid_api_key", "message": "bad"}}, - ) - - -class TestConstruction: - def test_explicit(self, make_client: Callable[..., XAgentClient]) -> None: - with make_client(_me_handler) as c: - assert c.me().agent_id == 7 - - def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("XAGENT_API_KEY", "envkey") - monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") - c = XAgentClient(transport=httpx.MockTransport(_me_handler)) - assert c.me().agent_id == 7 - c.close() - - def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("XAGENT_API_KEY", "envkey") - monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") - c = XAgentClient( - api_key="explicit", - base_url="https://explicit", - transport=httpx.MockTransport(_me_handler), - ) - # Bearer header comes from the explicit api_key, not the env value. - assert c._http._client.headers["Authorization"] == "Bearer explicit" - c.close() - - def test_missing_api_key(self) -> None: - with pytest.raises(ValueError, match="api_key"): - XAgentClient(base_url="https://x") - - def test_missing_base_url(self) -> None: - with pytest.raises(ValueError, match="base_url"): - XAgentClient(api_key="x") - - -class TestMe: - def test_returns_me_response( - self, make_client: Callable[..., XAgentClient] - ) -> None: - with make_client(_me_handler) as c: - me = c.me() - assert isinstance(me, MeResponse) - assert me.agent_name == "Sales" - - def test_401_raises_invalid_api_key( - self, make_client: Callable[..., XAgentClient] - ) -> None: - with make_client(_401_handler) as c, pytest.raises(InvalidAPIKey): - c.me() - - -class TestLifecycle: - def test_context_manager_closes( - self, make_client: Callable[..., XAgentClient] - ) -> None: - c = make_client(_me_handler) - assert c._http._client.is_closed is False - with c: - pass - assert c._http._client.is_closed is True diff --git a/python/tests/unit/test_clients_isolated.py b/python/tests/unit/test_clients_isolated.py new file mode 100644 index 0000000..81b6f4f --- /dev/null +++ b/python/tests/unit/test_clients_isolated.py @@ -0,0 +1,57 @@ +"""Invariant: AgentClient and UserClient instances do not share state. + +Each public client owns its own httpx.Client (default headers, connection +pool). Constructing both in the same process must produce two distinct +``Authorization`` headers; one client's revoke / rotate must not affect +the other. This module pins the property mechanically so a future +refactor cannot silently share an httpx instance across clients. +""" + +import httpx + +from xagent_sdk import AgentClient, UserClient + + +def _ok(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"ok": True}) + + +def test_two_clients_default_headers_isolated() -> None: + u = UserClient( + personal_key="xag_personal_p_s", + base_url="https://x", + transport=httpx.MockTransport(_ok), + ) + a = AgentClient( + api_key="xag_p_s", + base_url="https://x", + transport=httpx.MockTransport(_ok), + ) + try: + u_auth = u._http._client.headers["Authorization"] + a_auth = a._http._client.headers["Authorization"] + assert u_auth == "Bearer xag_personal_p_s" + assert a_auth == "Bearer xag_p_s" + assert u_auth != a_auth + # Two distinct httpx.Client instances (not aliasing) + assert u._http._client is not a._http._client + finally: + u.close() + a.close() + + +def test_close_one_does_not_close_other() -> None: + u = UserClient( + personal_key="xag_personal_p_s", + base_url="https://x", + transport=httpx.MockTransport(_ok), + ) + a = AgentClient( + api_key="xag_p_s", + base_url="https://x", + transport=httpx.MockTransport(_ok), + ) + u.close() + assert u._http._client.is_closed is True + assert a._http._client.is_closed is False + a.close() diff --git a/python/tests/unit/test_errors.py b/python/tests/unit/test_errors.py index dbd3b9d..a93b9c9 100644 --- a/python/tests/unit/test_errors.py +++ b/python/tests/unit/test_errors.py @@ -18,6 +18,7 @@ RateLimited, TaskBusy, TaskNotFound, + TemplateNotFound, XAgentError, ) from xagent_sdk.errors import from_response @@ -50,6 +51,7 @@ class TestFromResponseStableCodes: (401, "invalid_api_key", InvalidAPIKey), (404, "agent_not_found", AgentNotFound), (404, "task_not_found", TaskNotFound), + (404, "template_not_found", TemplateNotFound), (409, "task_busy", TaskBusy), (422, "validation_422", InvalidInput), (429, "rate_limited", RateLimited), diff --git a/python/tests/unit/test_forbidden_symbols.py b/python/tests/unit/test_forbidden_symbols.py new file mode 100644 index 0000000..0f1c952 --- /dev/null +++ b/python/tests/unit/test_forbidden_symbols.py @@ -0,0 +1,70 @@ +"""Repo-wide grep pin: forbidden symbol names must not appear in src/ +or tests/. + +Three names that resemble close-but-wrong spellings of the package's +public surface (``XAgentClient`` / ``MeResponse`` / ``_parse_me``) are +disallowed -- a typo or stale import that resurrects one of them would +otherwise survive mypy + ruff and only surface when a user hits a real +ImportError. The grep is a mechanical bottom that catches them in the +diff before merge. + +The grep excludes this file (it must reference the forbidden patterns +literally) and ``test_public_surface.py`` (which asserts the same +property via ``hasattr`` on the imported package). Documentation files +under ``python/README.md`` / ``shared/README.md`` are out of scope. +""" + +import subprocess +from pathlib import Path + + +def _repo_root() -> Path: + # tests/unit/test_forbidden_symbols.py -> parents[3] == repo root + return Path(__file__).resolve().parents[3] + + +def _grep(pattern: str) -> subprocess.CompletedProcess[str]: + repo = _repo_root() + return subprocess.run( + [ + "grep", + "-rn", + "--include=*.py", + "--exclude=test_forbidden_symbols.py", + "--exclude=test_public_surface.py", + pattern, + str(repo / "python" / "src"), + str(repo / "python" / "tests"), + ], + capture_output=True, + text=True, + check=False, + ) + + +def test_xagent_client_name_absent() -> None: + # Joined to avoid this module itself containing the literal string. + pattern = "XAgent" + "Client" + result = _grep(pattern) + assert result.returncode == 1, ( + f"forbidden symbol {pattern!r} found in source/tests; " + "the runtime client is AgentClient.\n" + result.stdout + ) + + +def test_me_response_name_absent() -> None: + pattern = "Me" + "Response" + result = _grep(pattern) + assert result.returncode == 1, ( + f"forbidden symbol {pattern!r} found in source/tests; " + "the /v1/me payload is UserPrincipal.\n" + result.stdout + ) + + +def test_parse_me_helper_absent() -> None: + pattern = r"_parse_me\b" + result = _grep(pattern) + assert result.returncode == 1, ( + f"forbidden symbol {pattern!r} found in source/tests; " + "the helper is _parse_user_principal.\n" + result.stdout + ) diff --git a/python/tests/unit/test_public_surface.py b/python/tests/unit/test_public_surface.py new file mode 100644 index 0000000..b317992 --- /dev/null +++ b/python/tests/unit/test_public_surface.py @@ -0,0 +1,88 @@ +"""Mechanical pin for the SDK public surface. + +If a future change silently widens (or narrows) ``xagent_sdk.__all__`` +the test below fails and the diff has to either justify the surface +change or roll it back. The ``XAgentClient`` / ``MeResponse`` checks +guard against a re-export accidentally re-introducing names that the +package does not expose. +""" + +import xagent_sdk + +# The full public surface. Update this set deliberately when adding new +# exports; do not loosen the assertion to a subset check. +EXPECTED_SURFACE: set[str] = { + # Clients + "AgentClient", + "UserClient", + # User principal + management dataclasses + "UserPrincipal", + "Template", + "TemplateDetail", + "AgentSummary", + "AgentCreateResult", + "RotateKeyResult", + # Runtime dataclasses + "CreateTaskResult", + "AppendResult", + "TaskInfo", + "Step", + "RunResult", + # Enums + "TaskStatus", + "StepType", + # Exception hierarchy + "XAgentError", + "InvalidAPIKey", + "AgentNotFound", + "TaskNotFound", + "TaskBusy", + "RateLimited", + "InternalError", + "InvalidInput", + "TemplateNotFound", + "MalformedResponse", + "XAgentTransportError", + "TaskTimeout", + # Version + "__version__", +} + + +def test_all_matches_expected() -> None: + actual = set(xagent_sdk.__all__) + extra = actual - EXPECTED_SURFACE + missing = EXPECTED_SURFACE - actual + assert not extra, f"extra symbols in __all__: {sorted(extra)}" + assert not missing, f"missing symbols from __all__: {sorted(missing)}" + + +def test_every_exported_name_resolves() -> None: + # __all__ is just a tuple of strings; ensure each name is actually + # importable from the package (catches typos / forgotten imports). + for name in xagent_sdk.__all__: + assert hasattr(xagent_sdk, name), ( + f"{name!r} listed in __all__ but not present on the package" + ) + + +def test_xagent_client_name_not_exposed() -> None: + assert not hasattr(xagent_sdk, "XAgentClient"), ( + "the runtime client is named AgentClient; XAgentClient must not " + "resolve via the public package" + ) + assert "XAgentClient" not in xagent_sdk.__all__ + + +def test_meresponse_name_not_exposed() -> None: + assert not hasattr(xagent_sdk, "MeResponse"), ( + "the /v1/me payload is UserPrincipal; MeResponse must not " + "resolve via the public package" + ) + assert "MeResponse" not in xagent_sdk.__all__ + + +def test_version_matches_pyproject() -> None: + # The version string the SDK announces (also in the User-Agent + # header) must match the packaged release. + assert xagent_sdk.__version__ == "0.2.0" diff --git a/python/tests/unit/test_tasks.py b/python/tests/unit/test_tasks.py index 5023b52..647185e 100644 --- a/python/tests/unit/test_tasks.py +++ b/python/tests/unit/test_tasks.py @@ -8,6 +8,7 @@ import pytest from xagent_sdk import ( + AgentClient, AgentNotFound, AppendResult, CreateTaskResult, @@ -19,12 +20,11 @@ TaskNotFound, TaskStatus, TaskTimeout, - XAgentClient, ) class TestCreate: - def test_body_shape(self, make_client: Callable[..., XAgentClient]) -> None: + def test_body_shape(self, make_client: Callable[..., AgentClient]) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -52,7 +52,7 @@ def handler(req: httpx.Request) -> httpx.Response: assert isinstance(result, CreateTaskResult) assert result.status is TaskStatus.PENDING - def test_metadata_included(self, make_client: Callable[..., XAgentClient]) -> None: + def test_metadata_included(self, make_client: Callable[..., AgentClient]) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -74,7 +74,7 @@ def handler(req: httpx.Request) -> httpx.Response: assert body["metadata"] == {"trace_id": "abc"} def test_no_metadata_field_when_none( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: captured: list[httpx.Request] = [] @@ -98,7 +98,7 @@ def handler(req: httpx.Request) -> httpx.Response: class TestAppend: - def test_url_and_body(self, make_client: Callable[..., XAgentClient]) -> None: + def test_url_and_body(self, make_client: Callable[..., AgentClient]) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -128,7 +128,7 @@ def handler(req: httpx.Request) -> httpx.Response: class TestGet: - def test_url_and_parse(self, make_client: Callable[..., XAgentClient]) -> None: + def test_url_and_parse(self, make_client: Callable[..., AgentClient]) -> None: captured: list[httpx.Request] = [] def handler(req: httpx.Request) -> httpx.Response: @@ -158,7 +158,7 @@ def handler(req: httpx.Request) -> httpx.Response: class TestSteps: def test_url_and_outer_wrapper_dropped( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: captured: list[httpx.Request] = [] @@ -220,11 +220,11 @@ class TestErrorMappingPerEndpoint: ) def test_envelope_codes( self, - make_client: Callable[..., XAgentClient], + make_client: Callable[..., AgentClient], status: int, code: str, exc_cls: type[Exception], - call: Callable[[XAgentClient], object], + call: Callable[[AgentClient], object], ) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( @@ -237,7 +237,7 @@ def h(req: httpx.Request) -> httpx.Response: # know exc_cls is a subclass here, so cast via attribute access. assert excinfo.value.code == code # type: ignore[attr-defined] - def test_steps_422(self, make_client: Callable[..., XAgentClient]) -> None: + def test_steps_422(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( 422, @@ -249,9 +249,7 @@ def h(req: httpx.Request) -> httpx.Response: class TestWait: - def test_multi_poll_terminal( - self, make_client: Callable[..., XAgentClient] - ) -> None: + def test_multi_poll_terminal(self, make_client: Callable[..., AgentClient]) -> None: calls = {"n": 0} def h(req: httpx.Request) -> httpx.Response: @@ -279,7 +277,7 @@ def h(req: httpx.Request) -> httpx.Response: assert info.status is TaskStatus.COMPLETED assert calls["n"] == 3 - def test_immediate_terminal(self, make_client: Callable[..., XAgentClient]) -> None: + def test_immediate_terminal(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( 200, @@ -301,7 +299,7 @@ def h(req: httpx.Request) -> httpx.Response: assert info.status is TaskStatus.COMPLETED - def test_timeout(self, make_client: Callable[..., XAgentClient]) -> None: + def test_timeout(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( 200, @@ -323,7 +321,7 @@ def h(req: httpx.Request) -> httpx.Response: assert "running" in excinfo.value.message def test_paused_keeps_polling( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: # PAUSED is NOT terminal (mirrors backend `v1/tasks.py:170`); # wait() should poll until the deadline elapses. @@ -346,7 +344,7 @@ def h(req: httpx.Request) -> httpx.Response: c.tasks.wait(10, timeout=0.1, poll_interval=0.02) assert "paused" in excinfo.value.message - def test_propagates_404(self, make_client: Callable[..., XAgentClient]) -> None: + def test_propagates_404(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( 404, @@ -356,7 +354,7 @@ def h(req: httpx.Request) -> httpx.Response: with make_client(h) as c, pytest.raises(TaskNotFound): c.tasks.wait(999, timeout=1.0, poll_interval=0.05) - def test_wall_clock_cap(self, make_client: Callable[..., XAgentClient]) -> None: + def test_wall_clock_cap(self, make_client: Callable[..., AgentClient]) -> None: # poll_interval >> remaining timeout. wait() must cap sleep so the # wall-clock does not overshoot. Bug version would take ~5s; we # assert <0.5s (~5x the configured timeout, well below the 5s @@ -385,7 +383,7 @@ def h(req: httpx.Request) -> httpx.Response: assert elapsed < 0.5 def test_negative_timeout_rejected( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: # The handler is never invoked; validation rejects before any HTTP. with ( @@ -395,7 +393,7 @@ def test_negative_timeout_rejected( c.tasks.wait(10, timeout=-1.0, poll_interval=0.05) def test_negative_poll_interval_rejected( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: with ( make_client(lambda req: httpx.Response(200)) as c, # noqa: ARG005 @@ -405,7 +403,7 @@ def test_negative_poll_interval_rejected( class TestRun: - def test_full_flow(self, make_client: Callable[..., XAgentClient]) -> None: + def test_full_flow(self, make_client: Callable[..., AgentClient]) -> None: state = {"polls": 0} def h(req: httpx.Request) -> httpx.Response: @@ -475,7 +473,7 @@ def h(req: httpx.Request) -> httpx.Response: assert len(result.steps) == 1 assert result.steps[0].type is StepType.MESSAGE - def test_timeout_propagates(self, make_client: Callable[..., XAgentClient]) -> None: + def test_timeout_propagates(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: method, path = req.method, req.url.path if method == "POST" and path == "/v1/chat/tasks": @@ -511,7 +509,7 @@ def h(req: httpx.Request) -> httpx.Response: ) def test_negative_timeout_rejected_before_create( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: calls = {"n": 0} @@ -525,7 +523,7 @@ def h(req: httpx.Request) -> httpx.Response: # noqa: ARG001 assert calls["n"] == 0 def test_negative_poll_interval_rejected_before_create( - self, make_client: Callable[..., XAgentClient] + self, make_client: Callable[..., AgentClient] ) -> None: calls = {"n": 0} @@ -538,7 +536,7 @@ def h(req: httpx.Request) -> httpx.Response: # noqa: ARG001 assert calls["n"] == 0 - def test_shared_deadline(self, make_client: Callable[..., XAgentClient]) -> None: + def test_shared_deadline(self, make_client: Callable[..., AgentClient]) -> None: # Inject latency into create() so that the time it consumes is # observable. Old behavior passed the full timeout to wait(), # producing total elapsed of (create_delay + timeout). New diff --git a/python/tests/unit/test_templates.py b/python/tests/unit/test_templates.py new file mode 100644 index 0000000..24bcef9 --- /dev/null +++ b/python/tests/unit/test_templates.py @@ -0,0 +1,90 @@ +"""Tests for UserClient.templates (TemplatesAPI).""" + +import httpx +import pytest + +from xagent_sdk import Template, TemplateDetail, TemplateNotFound, UserClient + +from ._fixtures import error_envelope, response + + +def _list_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=response("templates_list")) + + +def _detail_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=response("templates_detail")) + + +def _404_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=error_envelope("template_not_found")) + + +def _make_user(handler: object) -> UserClient: + return UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(handler), # type: ignore[arg-type] + ) + + +class TestList: + def test_url(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("templates_list")) + + with _make_user(handler) as c: + templates = c.templates.list() + + assert captured[0].method == "GET" + assert captured[0].url.path == "/v1/templates" + assert len(templates) == 3 + assert all(isinstance(t, Template) for t in templates) + ids = [t.template_id for t in templates] + assert "support-email-agent" in ids + assert "support-ai-chatbot-agent" in ids + + def test_empty_list(self) -> None: + # Canonical empty: backend returns a bare empty array. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[]) + + with _make_user(h) as c: + assert c.templates.list() == [] + + def test_non_list_body_defensive(self) -> None: + # Malformed body (dict instead of list) returns [] rather than + # raising. Mirrors _parse_steps' defense-in-depth pattern. + def h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"templates": []}) + + with _make_user(h) as c: + assert c.templates.list() == [] + + +class TestGet: + def test_url_and_parse(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json=response("templates_detail")) + + with _make_user(handler) as c: + detail = c.templates.get("support-ai-chatbot-agent") + + assert captured[0].method == "GET" + assert captured[0].url.path == "/v1/templates/support-ai-chatbot-agent" + assert isinstance(detail, TemplateDetail) + assert detail.template_id == "support-ai-chatbot-agent" + assert detail.name == "AI Chatbot Agent" + # agent_config is the merge target for create_from_template overrides + assert "instructions" in detail.agent_config + assert detail.agent_config["execution_mode"] == "flash" + + def test_404_template_not_found(self) -> None: + with _make_user(_404_handler) as c, pytest.raises(TemplateNotFound): + c.templates.get("not_a_real_template") diff --git a/python/tests/unit/test_types.py b/python/tests/unit/test_types.py index bc7a550..0271073 100644 --- a/python/tests/unit/test_types.py +++ b/python/tests/unit/test_types.py @@ -9,28 +9,27 @@ from xagent_sdk import ( AppendResult, CreateTaskResult, - MeResponse, RunResult, Step, StepType, TaskInfo, TaskStatus, ) +from xagent_sdk.errors import MalformedResponse from xagent_sdk.types import ( + _agent_summary_dict, + _parse_agent_create, _parse_append, _parse_create_task, - _parse_me, + _parse_rotate_key, _parse_steps, _parse_task_info, + _parse_template_detail, + _parse_user_principal, + _template_dict, ) -class TestParseMe: - def test_happy(self) -> None: - me = _parse_me({"agent_id": 7, "agent_name": "Sales", "key_prefix": "a1B2c3"}) - assert me == MeResponse(agent_id=7, agent_name="Sales", key_prefix="a1B2c3") - - class TestParseCreateTask: def test_pending_status_and_tz(self) -> None: c = _parse_create_task( @@ -192,10 +191,121 @@ def test_unknown_step_type_rejected(self) -> None: class TestFrozenDataclasses: - def test_me_frozen(self) -> None: - me = MeResponse(agent_id=1, agent_name="x", key_prefix="y") + def test_step_frozen(self) -> None: + step = Step( + id="message:1", + type=StepType.MESSAGE, + status="completed", + started_at=datetime(2026, 5, 10, tzinfo=UTC), + completed_at=datetime(2026, 5, 10, tzinfo=UTC), + data={"role": "user", "content": "hi"}, + ) with pytest.raises(FrozenInstanceError): - me.agent_name = "hacked" # type: ignore[misc] + step.id = "hacked" # type: ignore[misc] + + +class TestParseAgentCreateMalformed: + """Guard the explicit ``MalformedResponse`` raised when the backend + body lacks the required ``agent`` block. + """ + + def _good_api_key(self) -> dict[str, object]: + return { + "full_key": "xag_abc123_secret", + "key_prefix": "abc123", + "created_at": "2026-05-29T00:00:00Z", + } + + def test_missing_agent_block_raises(self) -> None: + with pytest.raises(MalformedResponse) as excinfo: + _parse_agent_create({"api_key": self._good_api_key()}) + assert excinfo.value.code == "malformed_response" + assert "'agent'" in str(excinfo.value) + + def test_agent_block_is_not_dict_raises(self) -> None: + with pytest.raises(MalformedResponse) as excinfo: + _parse_agent_create({"agent": None, "api_key": self._good_api_key()}) + assert excinfo.value.code == "malformed_response" + + def test_agent_block_missing_id_raises(self) -> None: + with pytest.raises(MalformedResponse) as excinfo: + _parse_agent_create( + {"agent": {"name": "x"}, "api_key": self._good_api_key()} + ) + assert excinfo.value.code == "malformed_response" + + def test_agent_block_missing_name_raises(self) -> None: + with pytest.raises(MalformedResponse) as excinfo: + _parse_agent_create({"agent": {"id": 42}, "api_key": self._good_api_key()}) + assert excinfo.value.code == "malformed_response" + + +class TestSingleObjectParsersRejectNonDict: + """Every single-object parser must surface MalformedResponse (not a + raw ValidationError / AttributeError) when the body is not a JSON + object. Mirrors the list parsers' isinstance guard. + """ + + @pytest.mark.parametrize( + "parser", + [ + _parse_user_principal, + _parse_template_detail, + _parse_agent_create, + _parse_rotate_key, + _parse_create_task, + _parse_append, + _parse_task_info, + ], + ) + @pytest.mark.parametrize("body", [None, [1, 2], "oops", 42, True]) + def test_non_dict_body_raises_malformed(self, parser: object, body: object) -> None: + with pytest.raises(MalformedResponse) as excinfo: + parser(body) # type: ignore[operator] + assert excinfo.value.code == "malformed_response" + + def test_agent_create_non_dict_api_key_coerced(self) -> None: + # A non-dict api_key block must not crash; runtime fields stay None. + result = _parse_agent_create( + {"agent": {"id": 7, "name": "A"}, "api_key": "not-a-dict"} + ) + assert result.agent_id == 7 + assert result.runtime_full_key is None + assert result.runtime_key_prefix is None + + +class TestNormalizeHelpers: + """Pin ``_template_dict`` / ``_agent_summary_dict`` shape contracts.""" + + def test_template_dict_id_to_template_id(self) -> None: + out = _template_dict({"id": "tpl-1", "name": "T"}) + assert out["template_id"] == "tpl-1" + assert out["name"] == "T" + + def test_template_dict_falls_back_to_existing_template_id(self) -> None: + # If a future backend drops "id" and sends "template_id" directly, + # the helper must surface the existing value instead of None. + out = _template_dict({"template_id": "tpl-2", "name": "T"}) + assert out["template_id"] == "tpl-2" + + def test_template_dict_id_wins_when_both_present(self) -> None: + # Current backend contract: "id" is the source of truth. + out = _template_dict({"id": "from_id", "template_id": "from_fallback"}) + assert out["template_id"] == "from_id" + + def test_template_dict_missing_id_yields_none(self) -> None: + # Caller (pydantic TypeAdapter) is responsible for failing on + # None; helper should not silently fabricate a value. + out = _template_dict({"name": "T"}) + assert out["template_id"] is None + + def test_agent_summary_dict_id_to_agent_id(self) -> None: + out = _agent_summary_dict({"id": 42, "name": "A"}) + assert out["agent_id"] == 42 + + def test_agent_summary_dict_falls_back_to_existing_agent_id(self) -> None: + out = _agent_summary_dict({"agent_id": 43, "name": "A"}) + assert out["agent_id"] == 43 class TestRunResult: diff --git a/python/tests/unit/test_user_client.py b/python/tests/unit/test_user_client.py new file mode 100644 index 0000000..5697836 --- /dev/null +++ b/python/tests/unit/test_user_client.py @@ -0,0 +1,116 @@ +"""Tests for UserClient construction, env-var fallback, and the me() probe.""" + +import httpx +import pytest + +from xagent_sdk import InvalidAPIKey, UserClient, UserPrincipal + +from ._fixtures import error_envelope, response + + +def _me_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=response("me_user")) + + +def _401_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(401, json=error_envelope("invalid_api_key")) + + +class TestConstruction: + def test_explicit(self) -> None: + with UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_me_handler), + ) as c: + assert c._http._client.headers["Authorization"] == "Bearer xag_personal_p_s" + + def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_PERSONAL_KEY", "xag_personal_envkey_envsec") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = UserClient(transport=httpx.MockTransport(_me_handler)) + assert ( + c._http._client.headers["Authorization"] + == "Bearer xag_personal_envkey_envsec" + ) + c.close() + + def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XAGENT_PERSONAL_KEY", "envkey") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + c = UserClient( + personal_key="explicit", + base_url="https://explicit", + transport=httpx.MockTransport(_me_handler), + ) + assert c._http._client.headers["Authorization"] == "Bearer explicit" + c.close() + + def test_missing_personal_key(self) -> None: + # The error message should name personal_key specifically so the + # caller knows which env var / kwarg to set. + with pytest.raises(ValueError, match="personal_key"): + UserClient(base_url="https://x") + + def test_missing_base_url(self) -> None: + with pytest.raises(ValueError, match="base_url"): + UserClient(personal_key="xag_personal_p_s") + + def test_does_not_read_xagent_api_key( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # XAGENT_API_KEY belongs to AgentClient; UserClient must not pick + # it up by accident. Only XAGENT_PERSONAL_KEY counts. + monkeypatch.setenv("XAGENT_API_KEY", "runtime-key-not-personal") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="personal_key"): + UserClient() + + def test_empty_personal_key_does_not_fall_back_to_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # An explicit empty key must raise, never resolve to the env value. + monkeypatch.setenv("XAGENT_PERSONAL_KEY", "xag_personal_envkey_envsec") + monkeypatch.setenv("XAGENT_BASE_URL", "https://envhost") + with pytest.raises(ValueError, match="personal_key"): + UserClient(personal_key="") + + +class TestMe: + def test_returns_user_principal(self) -> None: + with UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_me_handler), + ) as c: + me = c.me() + assert isinstance(me, UserPrincipal) + assert me.principal_type == "user" + assert me.user_id == 123 + assert me.email == "user@example.com" + assert me.name == "Alex" + assert me.key_prefix == "abc123" + + def test_401_raises_invalid_api_key(self) -> None: + with ( + UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_401_handler), + ) as c, + pytest.raises(InvalidAPIKey), + ): + c.me() + + +class TestLifecycle: + def test_context_manager_closes(self) -> None: + c = UserClient( + personal_key="xag_personal_p_s", + base_url="https://test.example", + transport=httpx.MockTransport(_me_handler), + ) + assert c._http._client.is_closed is False + with c: + pass + assert c._http._client.is_closed is True diff --git a/shared/README.md b/shared/README.md index 0adaed1..748fdc0 100644 --- a/shared/README.md +++ b/shared/README.md @@ -16,7 +16,12 @@ implicit and documented below. | File | Endpoint | Notes | |---|---|---| -| `me.json` | `GET /v1/me` | Identity probe success body | +| `me_user.json` | `GET /v1/me` | User principal: `principal_type / user_id / email / name / key_prefix` | +| `templates_list.json` | `GET /v1/templates` | Bare JSON array; each entry keys its id under `id` (SDKs surface it as `template_id`) plus `name`, optional `description` | +| `templates_detail.json` | `GET /v1/templates/{id}` | Single object keyed by `id`; carries the merge-target `agent_config` dict | +| `agents_list.json` | `GET /v1/agents` | Bare JSON array; each entry keys its id under `id` (SDKs surface it as `agent_id`); covers `active`, `draft`, `paused` status values | +| `agents_create.json` | `POST /v1/agents` or `POST /v1/agents/from-template` | Nested `{agent: {id, name, ...}, api_key: {full_key, key_prefix, created_at}}`; the `api_key` block is present only when `generate_runtime_key=True` | +| `rotate_key.json` | `POST /v1/agents/{id}/api-key` | Rotation result with one-time `full_key` and public-safe `key_prefix` | | `create_task.json` | `POST /v1/chat/tasks` (202) | Initial `status=pending` | | `append_task.json` | `POST /v1/chat/tasks/{id}/messages` (202) | `status=running`, carries `accepted_at` (not `created_at`) | | `task_info_completed.json` | `GET /v1/chat/tasks/{id}` (200) | Terminal state, `output` populated | @@ -24,7 +29,7 @@ implicit and documented below. ### `fixtures/v1/errors/` -Seven stable backend codes using the V1 envelope shape: +Stable backend codes using the V1 envelope shape: `{"error": {"code": "...", "message": "..."}}`. | File | HTTP status | Wire shape | @@ -33,8 +38,9 @@ Seven stable backend codes using the V1 envelope shape: | `agent_not_found.json` | 404 | V1 envelope | | `task_not_found.json` | 404 | V1 envelope | | `task_busy.json` | 409 | V1 envelope | +| `template_not_found.json` | 404 | V1 envelope (raised by `UserClient.templates.get()` and `UserClient.agents.create_from_template()` on unknown `template_id`) | | `validation_422.json` | 422 | V1 envelope (`invalid_input`) | -| `rate_limited.json` | 429 | V1 envelope (reserved; backend does not currently emit it) | +| `rate_limited.json` | 429 | V1 envelope | | `internal_error.json` | 500 | V1 envelope | ## Usage from a language client diff --git a/shared/fixtures/v1/errors/template_not_found.json b/shared/fixtures/v1/errors/template_not_found.json new file mode 100644 index 0000000..c31bffc --- /dev/null +++ b/shared/fixtures/v1/errors/template_not_found.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": "template_not_found", + "message": "Template not found or revoked." + } +} diff --git a/shared/fixtures/v1/responses/agents_create.json b/shared/fixtures/v1/responses/agents_create.json new file mode 100644 index 0000000..29d0361 --- /dev/null +++ b/shared/fixtures/v1/responses/agents_create.json @@ -0,0 +1,27 @@ +{ + "agent": { + "id": 42, + "user_id": 8, + "name": "HR Leave Assistant", + "description": null, + "instructions": "Answer leave-policy questions for employees.", + "execution_mode": "balanced", + "models": null, + "knowledge_bases": [], + "skills": [], + "tool_categories": [], + "suggested_prompts": [], + "logo_url": null, + "status": "draft", + "published_at": null, + "created_at": "2026-05-29T13:57:39.000000+00:00", + "updated_at": "2026-05-29T13:57:39.000000+00:00", + "widget_enabled": true, + "allowed_domains": [] + }, + "api_key": { + "full_key": "xag_abc123_secretsecretsecretsecretsecret", + "key_prefix": "abc123", + "created_at": "2026-05-29T13:57:39.780154Z" + } +} diff --git a/shared/fixtures/v1/responses/agents_list.json b/shared/fixtures/v1/responses/agents_list.json new file mode 100644 index 0000000..aba4951 --- /dev/null +++ b/shared/fixtures/v1/responses/agents_list.json @@ -0,0 +1,35 @@ +[ + { + "id": 42, + "name": "HR Leave Assistant", + "description": "Answers leave-policy questions for employees.", + "logo_url": null, + "status": "active", + "created_at": "2026-05-20T10:00:00+00:00", + "updated_at": "2026-05-29T10:00:00+00:00", + "widget_enabled": true, + "allowed_domains": [] + }, + { + "id": 43, + "name": "Policy Bot", + "description": null, + "logo_url": null, + "status": "draft", + "created_at": "2026-05-25T10:00:00+00:00", + "updated_at": "2026-05-25T10:00:00+00:00", + "widget_enabled": true, + "allowed_domains": [] + }, + { + "id": 44, + "name": "CV Screener", + "description": "Filters incoming CVs against the role spec.", + "logo_url": null, + "status": "paused", + "created_at": "2026-04-01T10:00:00+00:00", + "updated_at": "2026-05-10T10:00:00+00:00", + "widget_enabled": false, + "allowed_domains": [] + } +] diff --git a/shared/fixtures/v1/responses/me.json b/shared/fixtures/v1/responses/me.json deleted file mode 100644 index 4002dfc..0000000 --- a/shared/fixtures/v1/responses/me.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agent_id": 12, - "agent_name": "sdk-flash", - "key_prefix": "h388lh" -} diff --git a/shared/fixtures/v1/responses/me_user.json b/shared/fixtures/v1/responses/me_user.json new file mode 100644 index 0000000..b7474ea --- /dev/null +++ b/shared/fixtures/v1/responses/me_user.json @@ -0,0 +1,7 @@ +{ + "principal_type": "user", + "user_id": 123, + "email": "user@example.com", + "name": "Alex", + "key_prefix": "abc123" +} diff --git a/shared/fixtures/v1/responses/rotate_key.json b/shared/fixtures/v1/responses/rotate_key.json new file mode 100644 index 0000000..fbcbc0e --- /dev/null +++ b/shared/fixtures/v1/responses/rotate_key.json @@ -0,0 +1,5 @@ +{ + "full_key": "xag_newabc_freshsecretfreshsecretfresh", + "key_prefix": "newabc", + "created_at": "2026-05-29T10:00:00Z" +} diff --git a/shared/fixtures/v1/responses/templates_detail.json b/shared/fixtures/v1/responses/templates_detail.json new file mode 100644 index 0000000..8eb390d --- /dev/null +++ b/shared/fixtures/v1/responses/templates_detail.json @@ -0,0 +1,19 @@ +{ + "id": "support-ai-chatbot-agent", + "name": "AI Chatbot Agent", + "category": "Support", + "featured": true, + "description": "Responds to live chat enquiries instantly", + "features": ["Answers commonly asked questions for a company/group"], + "connections": [{"name": "Google Drive", "logo": "https://www.google.com/s2/favicons?domain=drive.google.com&sz=64"}], + "setup_time": "5 min setup", + "tags": [], + "author": "Xagent", + "version": "1.0", + "agent_config": { + "instructions": "You are an AI Support Chatbot. Resolve customer enquiries directly in the chat where possible.", + "execution_mode": "flash", + "skills": [], + "tool_categories": ["basic", "knowledge"] + } +} diff --git a/shared/fixtures/v1/responses/templates_list.json b/shared/fixtures/v1/responses/templates_list.json new file mode 100644 index 0000000..7337987 --- /dev/null +++ b/shared/fixtures/v1/responses/templates_list.json @@ -0,0 +1,41 @@ +[ + { + "id": "support-email-agent", + "name": "Email Agent (Support)", + "category": "Support", + "featured": false, + "description": "Monitors the support inbox, triages and responds to customer enquiries, and follows up on open tickets.", + "features": ["Sorts support tickets by urgency and deadlines"], + "connections": [{"name": "Gmail", "logo": "https://www.google.com/s2/favicons?domain=mail.google.com&sz=64"}], + "setup_time": "5 min setup", + "tags": [], + "author": "Xagent", + "version": "1.0" + }, + { + "id": "sales-inbound-agent", + "name": "Inbound Agent", + "category": "Sales", + "featured": true, + "description": "Processes inbound leads, researches prospect, scores and qualifies leads.", + "features": ["Captures leads from forms, chat, and email"], + "connections": [], + "setup_time": "5 min setup", + "tags": [], + "author": "Xagent", + "version": "1.0" + }, + { + "id": "support-ai-chatbot-agent", + "name": "AI Chatbot Agent", + "category": "Support", + "featured": true, + "description": "Responds to live chat enquiries instantly", + "features": ["Answers commonly asked questions for a company/group"], + "connections": [], + "setup_time": "5 min setup", + "tags": [], + "author": "Xagent", + "version": "1.0" + } +]