agent-studio-client 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ """Agent Studio 运行时 API 的 Python 客户端。"""
2
+
3
+ from ._meta import StudioMeta
4
+ from ._version import EVENT_SCHEMA_MAJOR, __version__
5
+ from .async_client import AsyncAgentHandle, AsyncRunHandle, AsyncStudioClient
6
+ from .client import AgentHandle, RunHandle, StudioClient
7
+ from .errors import (
8
+ StudioApiError,
9
+ StudioCompatibilityError,
10
+ StudioConnectionError,
11
+ StudioError,
12
+ StudioFeatureUnavailableError,
13
+ StudioProtocolError,
14
+ StudioStreamGapError,
15
+ )
16
+ from .models import (
17
+ ActionAccepted,
18
+ CapabilityList,
19
+ ClientCapabilities,
20
+ Event,
21
+ PendingAction,
22
+ Run,
23
+ RunStatus,
24
+ RunUsage,
25
+ Thread,
26
+ ThreadList,
27
+ ThreadMessage,
28
+ ThreadSummary,
29
+ )
30
+
31
+ __all__ = [
32
+ "EVENT_SCHEMA_MAJOR",
33
+ "ActionAccepted",
34
+ "AgentHandle",
35
+ "AsyncAgentHandle",
36
+ "AsyncRunHandle",
37
+ "AsyncStudioClient",
38
+ "CapabilityList",
39
+ "ClientCapabilities",
40
+ "Event",
41
+ "PendingAction",
42
+ "Run",
43
+ "RunHandle",
44
+ "RunStatus",
45
+ "RunUsage",
46
+ "StudioApiError",
47
+ "StudioClient",
48
+ "StudioCompatibilityError",
49
+ "StudioConnectionError",
50
+ "StudioError",
51
+ "StudioFeatureUnavailableError",
52
+ "StudioMeta",
53
+ "StudioProtocolError",
54
+ "StudioStreamGapError",
55
+ "Thread",
56
+ "ThreadList",
57
+ "ThreadMessage",
58
+ "ThreadSummary",
59
+ "__version__",
60
+ ]
agent_studio/_api.py ADDED
@@ -0,0 +1,188 @@
1
+ """运行时 API 端点定义。每个函数只描述请求与响应解析,由同步或异步客户端负责发送,两者共享同一份端点逻辑。
2
+
3
+ 路径均相对于 /v1/projects/{projectId}。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Generic, TypeVar
11
+
12
+ import httpx
13
+ from pydantic import BaseModel, ValidationError
14
+
15
+ from ._transport import check_idempotency_key, drop_none, segment
16
+ from .errors import StudioProtocolError
17
+ from .models import (
18
+ ActionAccepted,
19
+ CapabilityList,
20
+ ClientCapabilities,
21
+ Event,
22
+ PendingAction,
23
+ Run,
24
+ Thread,
25
+ ThreadList,
26
+ ThreadMessage,
27
+ )
28
+
29
+ T = TypeVar("T")
30
+ M = TypeVar("M", bound=BaseModel)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Request(Generic[T]):
35
+ method: str
36
+ path: str
37
+ parse: Callable[[Any], T]
38
+ json: dict[str, Any] | None = None
39
+ params: dict[str, Any] | None = None
40
+ headers: dict[str, str] = field(default_factory=dict)
41
+
42
+
43
+ def decode(response: httpx.Response) -> Any:
44
+ if response.status_code == 204 or not response.content:
45
+ return None
46
+ try:
47
+ return response.json()
48
+ except ValueError as error:
49
+ raise StudioProtocolError(f"响应不是 JSON(HTTP {response.status_code})") from error
50
+
51
+
52
+ def _model(model: type[M], key: str | None = None) -> Callable[[Any], M]:
53
+ def parse(body: Any) -> M:
54
+ try:
55
+ return model.model_validate(body[key] if key else body)
56
+ except (KeyError, TypeError, ValidationError) as error:
57
+ raise StudioProtocolError(f"响应不符合接口契约:{model.__name__}") from error
58
+ return parse
59
+
60
+
61
+ def _list(model: type[M], key: str) -> Callable[[Any], list[M]]:
62
+ def parse(body: Any) -> list[M]:
63
+ try:
64
+ return [model.model_validate(item) for item in body[key]]
65
+ except (KeyError, TypeError, ValidationError) as error:
66
+ raise StudioProtocolError(f"响应不符合接口契约:{model.__name__} 列表") from error
67
+ return parse
68
+
69
+
70
+ def _none(_: Any) -> None:
71
+ return None
72
+
73
+
74
+ def _raw(body: Any) -> Any:
75
+ return body
76
+
77
+
78
+ # ---- Thread ----
79
+
80
+ def create_thread(agent_id: str | None = None, agent_slug: str | None = None) -> Request[Thread]:
81
+ if (agent_id is None) == (agent_slug is None):
82
+ raise ValueError("agent_id 与 agent_slug 需且只能提供一个")
83
+ body = {"agentId": agent_id} if agent_id else {"agentSlug": agent_slug}
84
+ return Request("POST", "/threads", _model(Thread, "thread"), json=body)
85
+
86
+
87
+ def get_thread(thread_id: str) -> Request[Thread]:
88
+ return Request("GET", f"/threads/{segment(thread_id)}", _model(Thread, "thread"))
89
+
90
+
91
+ def delete_thread(thread_id: str) -> Request[None]:
92
+ return Request("DELETE", f"/threads/{segment(thread_id)}", _none)
93
+
94
+
95
+ def list_threads(agent_id: str | None = None, before: str | None = None) -> Request[ThreadList]:
96
+ return Request("GET", "/threads", _model(ThreadList), params=drop_none({"agentId": agent_id, "before": before}))
97
+
98
+
99
+ def get_thread_messages(thread_id: str) -> Request[list[ThreadMessage]]:
100
+ return Request("GET", f"/threads/{segment(thread_id)}/messages", _list(ThreadMessage, "messages"))
101
+
102
+
103
+ # ---- Run ----
104
+
105
+ def create_run(
106
+ thread_id: str,
107
+ input: str,
108
+ idempotency_key: str,
109
+ *,
110
+ context: dict[str, str | int | float | bool | None] | None = None,
111
+ attachments: list[str] | None = None,
112
+ capabilities: ClientCapabilities | None = None,
113
+ agent_version: int | None = None,
114
+ ) -> Request[Run]:
115
+ if not input.strip():
116
+ raise ValueError("input 不能为空")
117
+ if len(input) > 20000:
118
+ raise ValueError("input 不能超过 20000 个字符")
119
+ body = drop_none({
120
+ "threadId": thread_id,
121
+ "input": input,
122
+ "context": context,
123
+ "attachments": [{"fileId": file_id} for file_id in attachments] if attachments else None,
124
+ "capabilities": capabilities.model_dump(by_alias=True) if capabilities else None,
125
+ "agentVersion": agent_version,
126
+ })
127
+ return Request("POST", "/runs", _model(Run, "run"), json=body,
128
+ headers={"Idempotency-Key": check_idempotency_key(idempotency_key)})
129
+
130
+
131
+ def get_run(run_id: str) -> Request[Run]:
132
+ return Request("GET", f"/runs/{segment(run_id)}", _model(Run, "run"))
133
+
134
+
135
+ def list_runs() -> Request[list[Run]]:
136
+ return Request("GET", "/runs", _list(Run, "runs"))
137
+
138
+
139
+ def cancel_run(run_id: str) -> Request[Run]:
140
+ return Request("POST", f"/runs/{segment(run_id)}/cancel", _model(Run, "run"))
141
+
142
+
143
+ def list_events(run_id: str, after: int = 0, limit: int = 100) -> Request[list[Event]]:
144
+ if after < 0 or not 1 <= limit <= 200:
145
+ raise ValueError("after 不能为负数,limit 范围为 1~200")
146
+ return Request("GET", f"/runs/{segment(run_id)}/events", _list(Event, "events"),
147
+ params={"after": after, "limit": limit})
148
+
149
+
150
+ # ---- 审批与客户端工具 ----
151
+
152
+ def get_pending(run_id: str) -> Request[list[PendingAction]]:
153
+ return Request("GET", f"/runs/{segment(run_id)}/pending", _list(PendingAction, "pending"))
154
+
155
+
156
+ def submit_approval(run_id: str, action: PendingAction, decision: str,
157
+ args: dict[str, Any] | None = None) -> Request[ActionAccepted]:
158
+ if action.kind != "approval":
159
+ raise ValueError("客户端工具请求需用 submit_client_tool_result 回传")
160
+ if decision not in action.allowed_decisions:
161
+ raise ValueError(f"该请求不允许决定 {decision}")
162
+ if (decision == "edit") != (args is not None):
163
+ raise ValueError("只有 edit 决定需要且必须提供 args")
164
+ body = drop_none({"argsHash": action.args_hash, "decision": decision, "args": args})
165
+ return Request("POST", f"/runs/{segment(run_id)}/requests/{segment(action.id)}/decision",
166
+ _model(ActionAccepted), json=body)
167
+
168
+
169
+ def submit_client_tool_result(run_id: str, action: PendingAction, device_id: str, *, approved: bool,
170
+ result: dict[str, Any] | None = None) -> Request[ActionAccepted]:
171
+ if action.kind != "client_tool":
172
+ raise ValueError("审批请求需用 submit_approval 提交")
173
+ if approved and result is None:
174
+ raise ValueError("批准执行时必须回传 result")
175
+ body = drop_none({"deviceId": device_id, "argsHash": action.args_hash,
176
+ "decision": "approved" if approved else "rejected", "result": result if approved else None})
177
+ return Request("POST", f"/runs/{segment(run_id)}/requests/{segment(action.id)}/result",
178
+ _model(ActionAccepted), json=body)
179
+
180
+
181
+ # ---- 其他 ----
182
+
183
+ def delete_actor_data(actor_ref: str) -> Request[dict[str, Any]]:
184
+ return Request("POST", "/privacy/deletions", _raw, json={"scope": "actor", "targetRef": actor_ref})
185
+
186
+
187
+ def get_capabilities() -> Request[CapabilityList]:
188
+ return Request("GET", "/capabilities", _model(CapabilityList))
agent_studio/_meta.py ADDED
@@ -0,0 +1,41 @@
1
+ """能力发现:GET /v1/meta。旧版服务端没有该端点,视为 1.0.0 且所有新能力不可用。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from ._sse import check_event_schema
10
+ from .errors import StudioFeatureUnavailableError, StudioProtocolError
11
+ from .models import _Model
12
+
13
+ LEGACY_API_VERSION = "1.0.0"
14
+
15
+
16
+ class StudioMeta(_Model):
17
+ api_version: str
18
+ event_schema_version: str
19
+ features: dict[str, bool] = {}
20
+
21
+ def supports(self, feature: str) -> bool:
22
+ # 缺失的能力键视为不支持,服务端新增的未知键被忽略
23
+ return self.features.get(feature, False)
24
+
25
+ def require(self, feature: str) -> None:
26
+ if not self.supports(feature):
27
+ raise StudioFeatureUnavailableError(feature, self.api_version)
28
+
29
+
30
+ LEGACY_META = StudioMeta(api_version=LEGACY_API_VERSION, event_schema_version="1.0", features={})
31
+
32
+
33
+ def parse_meta(status: int, body: Any) -> StudioMeta:
34
+ if status == 404:
35
+ return LEGACY_META
36
+ try:
37
+ meta = StudioMeta.model_validate(body)
38
+ except ValidationError as error:
39
+ raise StudioProtocolError("能力发现响应不符合接口契约") from error
40
+ check_event_schema(meta.event_schema_version)
41
+ return meta
agent_studio/_sse.py ADDED
@@ -0,0 +1,71 @@
1
+ """Server-Sent Events 解析与事件序号检查,纯函数实现,同步与异步流共用。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+
8
+ from pydantic import ValidationError
9
+
10
+ from ._version import EVENT_SCHEMA_MAJOR
11
+ from .errors import StudioCompatibilityError, StudioProtocolError, StudioStreamGapError
12
+ from .models import Event
13
+
14
+ # 与服务端单帧上限对齐,防止异常服务端让客户端无限累积内存。
15
+ MAX_FRAME_BYTES = 1_048_576
16
+
17
+
18
+ class SseParser:
19
+ """逐行喂入 SSE 文本,产出每一帧的 data 字段。注释行(心跳)与 id/event 字段被忽略,序号以信封内 seq 为准。"""
20
+
21
+ def __init__(self) -> None:
22
+ self._data: list[str] = []
23
+ self._size = 0
24
+
25
+ def feed(self, line: str) -> Iterator[str]:
26
+ line = line.rstrip("\r")
27
+ if line == "":
28
+ if self._data:
29
+ data = "\n".join(self._data)
30
+ self._data, self._size = [], 0
31
+ yield data
32
+ return
33
+ if line.startswith(":"):
34
+ return
35
+ field, _, value = line.partition(":")
36
+ if field == "data":
37
+ value = value[1:] if value.startswith(" ") else value
38
+ self._size += len(value)
39
+ if self._size > MAX_FRAME_BYTES:
40
+ raise StudioProtocolError("事件帧超过 1 MiB 上限")
41
+ self._data.append(value)
42
+
43
+
44
+ class EventSequencer:
45
+ """解析事件信封、校验主版本与 seq 连续性,并维护续传游标。"""
46
+
47
+ def __init__(self, run_id: str, after: int):
48
+ if after < 0:
49
+ raise ValueError("事件游标不能为负数")
50
+ self.run_id = run_id
51
+ self.cursor = after
52
+
53
+ def accept(self, data: str) -> Event | None:
54
+ """返回新事件;重复或属于其他 Run 的事件返回 None。"""
55
+ try:
56
+ event = Event.model_validate(json.loads(data))
57
+ except (ValueError, ValidationError) as error:
58
+ raise StudioProtocolError("事件不符合接口契约") from error
59
+ check_event_schema(event.event_schema_version)
60
+ if event.run_id != self.run_id or event.seq <= self.cursor:
61
+ return None
62
+ if event.seq != self.cursor + 1:
63
+ raise StudioStreamGapError(self.cursor + 1, event.seq)
64
+ self.cursor = event.seq
65
+ return event
66
+
67
+
68
+ def check_event_schema(version: str) -> None:
69
+ major = version.split(".", 1)[0]
70
+ if major != str(EVENT_SCHEMA_MAJOR):
71
+ raise StudioCompatibilityError(f"{EVENT_SCHEMA_MAJOR}.x", version)
@@ -0,0 +1,132 @@
1
+ """同步与异步客户端共用的请求构造、错误映射与重试策略,不做任何 I/O。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import random
7
+ import re
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from email.utils import parsedate_to_datetime
12
+ from typing import Any
13
+ from urllib.parse import quote
14
+
15
+ import httpx
16
+
17
+ from ._version import __version__
18
+ from .errors import StudioApiError
19
+
20
+ logger = logging.getLogger("agent_studio")
21
+
22
+ _ACTOR = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$")
23
+ _IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
24
+ _UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
25
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ClientConfig:
30
+ base_url: str
31
+ project_id: str
32
+ api_key: str
33
+ actor_ref: str
34
+ timeout: float = 30.0
35
+ max_retries: int = 3
36
+ max_reconnects: int = 5
37
+ # 服务端每 15 秒发一次心跳;超过 3 倍心跳仍无数据视为连接失效。
38
+ heartbeat_timeout: float = 45.0
39
+
40
+ def __post_init__(self) -> None:
41
+ if not self.base_url:
42
+ raise ValueError("base_url 必填,SDK 不内置默认实例地址")
43
+ if not _UUID.match(self.project_id):
44
+ raise ValueError("project_id 必须是 UUID")
45
+ if not self.api_key.startswith("sk_studio_"):
46
+ raise ValueError("api_key 格式无效,应以 sk_studio_ 开头")
47
+ if not _ACTOR.match(self.actor_ref):
48
+ raise ValueError("actor_ref 只能包含字母、数字和 :._-,且不超过 128 个字符")
49
+ if self.max_retries < 0 or self.max_reconnects < 0:
50
+ raise ValueError("重试次数不能为负数")
51
+ object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
52
+ url = httpx.URL(self.base_url)
53
+ if url.scheme != "https" and url.host not in _LOCAL_HOSTS:
54
+ logger.warning("Studio 地址未使用 HTTPS,API Key 将以明文传输")
55
+
56
+ @property
57
+ def project_url(self) -> str:
58
+ return f"{self.base_url}/v1/projects/{self.project_id}"
59
+
60
+ def headers(self) -> dict[str, str]:
61
+ return {
62
+ "Authorization": f"Bearer {self.api_key}",
63
+ "X-Studio-Actor": self.actor_ref,
64
+ "Studio-Sdk": f"python/{__version__}",
65
+ "Accept": "application/json",
66
+ }
67
+
68
+
69
+ def segment(value: str) -> str:
70
+ """URL 路径片段转义,防止标识中的 / 或 .. 改变请求路径。"""
71
+ return quote(value, safe="")
72
+
73
+
74
+ def is_uuid(value: str) -> bool:
75
+ return bool(_UUID.match(value))
76
+
77
+
78
+ def new_idempotency_key() -> str:
79
+ return str(uuid.uuid4())
80
+
81
+
82
+ def check_idempotency_key(key: str) -> str:
83
+ if not _IDEMPOTENCY_KEY.match(key):
84
+ raise ValueError("idempotency_key 需为 8~128 个字母、数字或 ._:- 字符")
85
+ return key
86
+
87
+
88
+ def parse_retry_after(value: str | None) -> float | None:
89
+ if not value:
90
+ return None
91
+ try:
92
+ seconds = float(value)
93
+ except ValueError:
94
+ try:
95
+ seconds = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds()
96
+ except (TypeError, ValueError):
97
+ return None
98
+ return min(max(seconds, 0.0), 30.0)
99
+
100
+
101
+ def error_from_response(response: httpx.Response) -> StudioApiError:
102
+ """按服务端统一错误格式 {"error": {code, message, retryable}} 映射;非该格式(如代理 HTML)也给出稳定的错误码。"""
103
+ status = response.status_code
104
+ fallback_retryable = status == 429 or status >= 500
105
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
106
+ try:
107
+ body = response.json()
108
+ error = body["error"]
109
+ return StudioApiError(
110
+ str(error["code"]), str(error.get("message", "")), status,
111
+ bool(error.get("retryable", fallback_retryable)), retry_after,
112
+ )
113
+ except (ValueError, KeyError, TypeError):
114
+ return StudioApiError("unexpected_response", f"服务端返回了非预期响应(HTTP {status})", status,
115
+ fallback_retryable, retry_after)
116
+
117
+
118
+ def is_retryable(error: BaseException) -> bool:
119
+ if isinstance(error, StudioApiError):
120
+ return error.retryable
121
+ return isinstance(error, httpx.TransportError)
122
+
123
+
124
+ def backoff(attempt: int, error: BaseException | None = None) -> float:
125
+ """第 attempt 次重试(从 0 开始)前的等待秒数;服务端给出 Retry-After 时以其为准。"""
126
+ if isinstance(error, StudioApiError) and error.retry_after is not None:
127
+ return error.retry_after
128
+ return float(min(8.0, 0.5 * 2**attempt) * random.uniform(0.5, 1.0))
129
+
130
+
131
+ def drop_none(body: dict[str, Any]) -> dict[str, Any]:
132
+ return {key: value for key, value in body.items() if value is not None}
@@ -0,0 +1,4 @@
1
+ __version__ = "0.0.1"
2
+
3
+ # SDK 能解析的事件 Schema 主版本;服务端主版本不同时抛 StudioCompatibilityError。
4
+ EVENT_SCHEMA_MAJOR = 1