dmt-agent-api 0.3.0__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,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: dmt-agent-api
3
+ Version: 0.3.0
4
+ Summary: DMT Agent API Python SDK for DuMate
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx<1,>=0.27
8
+ Provides-Extra: notebook
9
+ Requires-Dist: ipython>=8; extra == "notebook"
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=8; extra == "test"
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
13
+
14
+ # DuMate Python SDK
15
+
16
+ 面向 DuMate 老版 `/api/v1` 的 Python 客户端。Python 3.10+,提供同步 `DuMate` 和异步 `AsyncDuMate`。当前版本 0.3.0,为独立开发的 SDK。
17
+
18
+ 应用层快速接入与前端数据协议见 [APPLICATIONS.md](docs/APPLICATIONS.md)。提供 `skills.ensure`、`conversations.create/history/stream` 和 `notebook.stream`。
19
+
20
+ ## 安装
21
+
22
+ ```bash
23
+ pip install ./dmt_agent_api-0.3.0-py3-none-any.whl
24
+ ```
25
+
26
+ 源码安装:`pip install .`。配置环境变量 `DUMATE_API_KEY`;可选 `DUMATE_BASE_URL`,默认 `https://api.dumate.cn/api/v1`。Key 仅放在后端。
27
+
28
+ ## 一次调用,等待最终结果
29
+
30
+ ```python
31
+ from dumate import DuMate
32
+
33
+ with DuMate() as client:
34
+ agent = client.agents.create(name="我的助手")
35
+ session = client.sessions.create(agent_id=agent["id"])
36
+ result = client.runs.run_and_wait(
37
+ session_id=session["id"],
38
+ message="介绍你能完成的工作",
39
+ timeout=180,
40
+ )
41
+ print(result.output_text)
42
+ ```
43
+
44
+ `runs.run_and_wait` 在客户端发送一次消息并轮询历史,关联本次用户消息与最终回答;没有对应的服务端 `/runs` 接口。适合网页的一键任务按钮:后端执行调用,完成后返回 `output_text`,前端展示结果即可。较长任务应由应用队列执行并提供任务状态查询。
45
+
46
+ 同一会话串行发送,不同会话可以并发。超时仅停止本地等待,服务端任务可能仍在执行;可查询历史或显式 `sessions.interrupt`。`timeout` 限制轮询等待,单次网络请求还受客户端 `timeout` 约束。默认等待时间无限,建议显式设置。
47
+
48
+ ## 流式事件
49
+
50
+ ```python
51
+ with client.sessions.stream(session_id, message="帮我分析这个问题") as events:
52
+ for event in events:
53
+ if event.type == "message.part.updated":
54
+ part = event.properties.get("part", {})
55
+ print(part.get("type"), part.get("text", ""))
56
+ elif event.type == "session.idle" or (
57
+ event.type == "session.status"
58
+ and event.properties.get("status", {}).get("type") == "idle"
59
+ ):
60
+ break
61
+ ```
62
+
63
+ 流建立后才发送消息。事件保留原始 `type`、`properties` 和 `raw`,未知事件也会返回;SSE `id`、`event` 字段独立保留。应用处理规则:
64
+
65
+ |数据|处理方式|
66
+ |---|---|
67
+ |message.updated 的 info|按消息 ID 更新角色、完成状态和错误|
68
+ |message.part.updated 的 part|按 part ID 替换快照;text 展示文字,reasoning 折叠展示,tool 按真实状态展示|
69
+ |增量事件|仅对明确增量字段追加;快照和增量同时出现时避免重复拼接|
70
+ |工具调用|保留调用 ID、输入、状态和输出;只展示真实事件|
71
+ |session.status / session.idle|更新运行状态;idle 还需核对历史中的最终答案或错误|
72
+ |未知事件|保留原始对象供兼容处理|
73
+
74
+ SDK 提供事件解析,不包含网页组件。断流抛出 `StreamInterruptedError`;应用先恢复 `sessions.history`,再重新订阅,不能自动重发原 query。SDK 不承诺事件重放或 Last-Event-ID 恢复。完整事件字段以服务端实际返回为准。
75
+
76
+ ## Skill
77
+
78
+ ZIP 中包含技能目录和 `SKILL.md`,文件带 YAML `name`、`description`。
79
+
80
+ ```python
81
+ skill = client.skills.upload("my-skill.zip")
82
+ mount = client.skills.mount(skill["id"])
83
+ session = client.sessions.create(agent_id=agent_id, skills=[mount])
84
+
85
+ updated = client.skills.update_zip(skill["id"], "my-skill-v2.zip")
86
+ new_mount = client.skills.mount(skill["id"])
87
+ client.sessions.update(session["id"], skills=[new_mount])
88
+ ```
89
+
90
+ 挂载使用 `skillID`、`releaseID`、`type`;`mount()` 从详情取得 `releaseId`,不能把展示版本号当作发布 ID。URL 导入用 `skills.create(url=...)`,URL 更新用 `skills.update(skill_id, url=...)`。
91
+
92
+ ## Vault、凭据和 MCP
93
+
94
+ ```python
95
+ import os
96
+
97
+ vault = client.vaults.create(name="业务系统")
98
+ credentials = client.vaults.credentials(vault["id"])
99
+ credential = credentials.create(
100
+ name="业务访问令牌",
101
+ auth={
102
+ "type": "environment_variable",
103
+ "secretName": "BUSINESS_TOKEN",
104
+ "secretValue": os.environ["BUSINESS_TOKEN"],
105
+ "networking": {
106
+ "type": "limited",
107
+ "allowedHosts": ["api.your-company.com"],
108
+ },
109
+ },
110
+ )
111
+ session = client.sessions.create(agent_id=agent_id, vault_ids=[vault["id"]])
112
+ ```
113
+
114
+ `auth` 原样传递平台的 `environment_variable`、`static_bearer` 或 `mcp_oauth` 结构,敏感值不要记录到日志。MCP 配置通过 `mcp_servers` 传入 Agent 或 Session,例如 `[{"type":"url","name":"business","url":"https://your-company.com/mcp"}]`;会话配置 MCP 时同时提供对应 `vault_ids`。各 MCP 的认证字段须按官方文档及服务商要求填写;SDK 不代办 OAuth 授权。平台未提供凭据更新接口,轮换需创建新凭据并迁移使用后删除旧凭据。
115
+
116
+ ## 文件与产物
117
+
118
+ ```python
119
+ uploaded = client.files.upload("input.pdf", session_id=session_id)
120
+ result = client.runs.run_and_wait(
121
+ session_id=session_id, message="总结附件", file_ids=[uploaded["fileID"]], timeout=300,
122
+ )
123
+ client.files.download(uploaded["fileID"], "downloaded.pdf")
124
+
125
+ mapping = client.artifacts.path_map(session_id)
126
+ for path, artifact in mapping.items():
127
+ if artifact.get("uploadStatus") == "ready":
128
+ client.artifacts.download(session_id, artifact["artifactID"], "output.bin")
129
+ break
130
+ ```
131
+
132
+ path-map 是整个会话的产物集合,不能全部当作本轮产物。按本轮回答中的文件路径、工具结果或执行前后的映射差异关联。`output.bin` 应由业务层按选中的文件设置名称;不要直接信任远端路径。
133
+
134
+ 下载返回落盘信息,默认禁止覆盖,支持 `max_bytes`。文件先写临时文件,成功后提交;校验响应长度并计算 SHA-256。产物必须 ready;存储返回 403 时刷新一次 URL。存储下载不携带 DuMate Key。URL 校验阻止明显私网地址及不安全跳转;生产环境仍需网络出口策略防止 DNS 重绑定。
135
+
136
+ 预览由应用后端代理文件,前端按 MIME 展示图片、音视频、PDF;HTML 应隔离在 sandbox iframe 中,未知类型提供下载。签名 URL 有时效,不应长期保存为产物身份。
137
+
138
+ ## 异步
139
+
140
+ ```python
141
+ import asyncio
142
+ from dumate import AsyncDuMate
143
+
144
+ async def main():
145
+ async with AsyncDuMate() as client:
146
+ session = await client.sessions.create(agent_id="你的 Agent ID")
147
+ result = await client.runs.run_and_wait(
148
+ session_id=session["id"], message="你好", timeout=180,
149
+ )
150
+ print(result.output_text)
151
+
152
+ asyncio.run(main())
153
+ ```
154
+
155
+ 异步资源方法使用 `await`;列表迭代用 `async for item in client.agents.iter()`;流用 `async with client.sessions.stream(...) as events` 和 `async for`。文件落盘及 DNS 检查仍包含短同步操作,大文件应用可用专门下载工作进程。
156
+
157
+ ## 错误与运行边界
158
+
159
+ - GET 对网络错误、429 和部分 5xx 做有限重试,默认 2 次;写操作不自动重试。
160
+ - 写请求连接失败时 `ConnectionError.outcome_unknown=True`,先核对服务端状态,避免重复创建或执行。
161
+ - `APIError` 包含状态码及请求 ID;异常文本不包含响应正文或 Key。应用不要直接打印凭据查询返回值。
162
+ - `RunTimeoutError`、`RunFailedError`、`ProtocolError` 区分等待超时、运行失败和协议异常。
163
+ - 会话与租户归属校验、鉴权、队列持久化、限流和登录恢复由业务后端负责。
164
+ - 删除 Agent 会级联删除其会话;只清理自己创建且确认不再使用的资源。
165
+
166
+ 完整方法见 [API.md](docs/API.md),验证范围见 [TESTING.md](docs/TESTING.md)。
@@ -0,0 +1,19 @@
1
+ dumate/__init__.py,sha256=pM4DaPI0169XJZmw097VrilaOZ4IGCkfaoBG6xS6pkM,947
2
+ dumate/_async_client.py,sha256=H7FwwfLbot8Plg-iC0YDu2nbGe4u4pwkEX8iW6gswlI,7563
3
+ dumate/_async_resources.py,sha256=qJnsaPxOsjB0V93pokEwoaOUq2-J2wCSEyk78Mmv-ZA,11742
4
+ dumate/_async_runs.py,sha256=6fX7dfXTKc9yFXjs5wUAo0NXIRcISb5NMonZcSJ9sj0,1713
5
+ dumate/_client.py,sha256=-f0SF13EnZONzGe6EUn74J4He5HldltYgLrTwdqChIQ,7256
6
+ dumate/_common.py,sha256=qJTPS4WhNp-bQwmA8CoD7MBFUwZhOMYhNrFmb4K5Cg8,2693
7
+ dumate/_downloads.py,sha256=h4dTzbg-jzV2hLfQHjq20aR2PetPWUd7VkprIjToSY4,3528
8
+ dumate/_sse.py,sha256=oSqbwvVsehIvsLUc7gUUMKTBSl6U6yyIOElMwl8Xj8A,3102
9
+ dumate/conversation.py,sha256=uII9ZIHjPlYzP_3QuAMUjbOsxMjbmgEPnO1QlTozAgg,8053
10
+ dumate/errors.py,sha256=cV9lgGRvmnsuPJKF94LI9Gb_qQkZsj83ccFJBajYJPI,1717
11
+ dumate/notebook.py,sha256=FMNGbNyO2wnTWdhhnH2nbs20Xik6x5IVITZYjytmZz0,2356
12
+ dumate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ dumate/resources.py,sha256=XPghfLZoffsZDfqnHJRzIblrckJIU1S4BOgpwWqpsi0,11009
14
+ dumate/runs.py,sha256=aa6FhHV_5uPwWTaRDnWdvOiFbQVR4Xr0tG83vqeyKqo,3076
15
+ dumate/types.py,sha256=2x5RDTpsd8KgvVrEobuBdilo3iqyAkPykyy2nnZBER8,1364
16
+ dmt_agent_api-0.3.0.dist-info/METADATA,sha256=em5I-IMVV0zNbFjLisZED5tKTnJgSum8FcHuYtNo5_A,8095
17
+ dmt_agent_api-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
18
+ dmt_agent_api-0.3.0.dist-info/top_level.txt,sha256=_hxsJUveyKhaX0dtmcHT4BlVj-4LB20fCcORTF50ZCM,7
19
+ dmt_agent_api-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dumate
dumate/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ from ._client import DuMate
2
+ from ._async_client import AsyncDuMate
3
+ from .types import Event, SendReceipt, RunResult, SkillMount, MCPServer, FileMount
4
+ from .errors import (DuMateError, APIError, AuthenticationError, PermissionDeniedError,
5
+ NotFoundError, ConflictError, RateLimitError, ConnectionError,
6
+ ProtocolError, PaginationError, StreamInterruptedError,
7
+ RunTimeoutError, RunFailedError, DownloadError, ArtifactNotReadyError)
8
+
9
+ __version__ = "0.3.0"
10
+ __all__ = ["DuMate", "AsyncDuMate", "Event", "SendReceipt", "RunResult", "SkillMount", "MCPServer", "FileMount",
11
+ "DuMateError", "APIError", "AuthenticationError", "PermissionDeniedError", "NotFoundError", "ConflictError",
12
+ "RateLimitError", "ConnectionError", "ProtocolError", "PaginationError", "StreamInterruptedError",
13
+ "RunTimeoutError", "RunFailedError", "DownloadError", "ArtifactNotReadyError"]
@@ -0,0 +1,151 @@
1
+ # Generated by tools/generate_async.py; edit synchronous source.
2
+ import asyncio
3
+ import mimetypes
4
+ import time
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from urllib.parse import urljoin
8
+ import httpx
9
+ from . import _async_resources as resources
10
+ from ._common import config, check_response, decode, delay, request_id, segment
11
+ from ._downloads import public_url, select_artifact, AtomicDownload
12
+ from ._sse import SSEDecoder
13
+ from .errors import ConnectionError, ProtocolError, StreamInterruptedError, DownloadError, APIError
14
+ from .types import SendReceipt
15
+
16
+ class AsyncDuMate:
17
+
18
+ def __init__(self, api_key=None, *, base_url=None, timeout=60.0, stream_timeout=60.0, max_retries=2, max_frame_bytes=4 * 1024 * 1024, transport=None):
19
+ key, base = config(api_key, base_url, max_retries)
20
+ self.base_url = base
21
+ self.max_retries = max_retries
22
+ self.max_frame_bytes = max_frame_bytes
23
+ self.stream_timeout = stream_timeout
24
+ self._http = httpx.AsyncClient(base_url=base + '/', headers={'Authorization': 'Bearer ' + key, 'User-Agent': 'dumate-agent-sdk/0.2.0'}, timeout=timeout, follow_redirects=False, trust_env=False, transport=transport)
25
+ self._storage = httpx.AsyncClient(timeout=timeout, follow_redirects=False, trust_env=False, headers={'Accept-Encoding': 'identity'})
26
+ self.agents = resources.AsyncAgents(self, 'agents')
27
+ self.skills = resources.AsyncSkills(self, 'skills')
28
+ self.vaults = resources.AsyncVaults(self, 'vaults')
29
+ self.sessions = resources.AsyncSessions(self, 'sessions')
30
+ self.files = resources.AsyncFiles(self, 'files')
31
+ self.artifacts = resources.AsyncArtifacts(self, '')
32
+ from ._async_runs import AsyncRuns
33
+ self.runs = AsyncRuns(self)
34
+ from .conversation import AsyncConversations
35
+ self.conversations = AsyncConversations(self)
36
+
37
+ async def __aenter__(self):
38
+ return self
39
+
40
+ async def __aexit__(self, *args):
41
+ await self.close()
42
+
43
+ async def close(self):
44
+ await self._http.aclose()
45
+ await self._storage.aclose()
46
+
47
+ async def _response(self, method, path, **kwargs):
48
+ for attempt in range(self.max_retries + 1):
49
+ try:
50
+ response = await self._http.request(method, path, **kwargs)
51
+ except httpx.TransportError:
52
+ if method == 'GET' and attempt < self.max_retries:
53
+ await asyncio.sleep(delay(None, attempt))
54
+ continue
55
+ raise ConnectionError(outcome_unknown=method != 'GET') from None
56
+ if method == 'GET' and response.status_code in (429, 500, 502, 503, 504) and (attempt < self.max_retries):
57
+ wait = delay(response, attempt)
58
+ await response.aclose()
59
+ await asyncio.sleep(wait)
60
+ continue
61
+ check_response(response)
62
+ return response
63
+ raise AssertionError('Unreachable')
64
+
65
+ async def _request(self, method, path, **kwargs):
66
+ response = await self._response(method, path, **kwargs)
67
+ try:
68
+ return decode(response)
69
+ finally:
70
+ await response.aclose()
71
+
72
+ async def _upload(self, method, path, file, *, data=None):
73
+ path_file = Path(file)
74
+ with path_file.open('rb') as handle:
75
+ return await self._request(method, path, data=data, files={'file': (path_file.name, handle, mimetypes.guess_type(path_file.name)[0] or 'application/octet-stream')})
76
+
77
+ async def _send(self, session_id, event):
78
+ response = await self._response('POST', f'sessions/{segment(session_id)}/events', json={'events': [event]})
79
+ try:
80
+ return SendReceipt(session_id, request_id(response))
81
+ finally:
82
+ await response.aclose()
83
+
84
+ @asynccontextmanager
85
+ async def _stream(self, session_id, *, message=None, file_ids=None):
86
+ try:
87
+ async with self._http.stream('GET', f'sessions/{segment(session_id)}/events/stream', headers={'Accept': 'text/event-stream'}, timeout=self.stream_timeout) as response:
88
+ check_response(response)
89
+ if 'text/event-stream' not in response.headers.get('content-type', '').lower():
90
+ raise ProtocolError('Expected text/event-stream')
91
+ if message is not None:
92
+ await self.sessions.send(session_id, message=message, file_ids=file_ids)
93
+ yield self._events(response, session_id)
94
+ except httpx.TransportError:
95
+ raise StreamInterruptedError(session_id) from None
96
+
97
+ async def _events(self, response, session_id):
98
+ decoder = SSEDecoder(self.max_frame_bytes)
99
+ try:
100
+ async for chunk in response.aiter_bytes():
101
+ for event in decoder.feed(chunk):
102
+ if event.session_id is None or event.session_id == session_id:
103
+ yield event
104
+ decoder.finish()
105
+ except httpx.TransportError:
106
+ raise StreamInterruptedError(session_id) from None
107
+
108
+ async def _save(self, response, destination, *, max_bytes, overwrite):
109
+ target = AtomicDownload(destination, max_bytes=max_bytes, overwrite=overwrite, headers=response.headers)
110
+ try:
111
+ async for chunk in response.aiter_raw():
112
+ target.write(chunk)
113
+ return target.commit()
114
+ except httpx.TransportError:
115
+ raise DownloadError('Download interrupted; destination was not committed') from None
116
+ finally:
117
+ target.close()
118
+
119
+ async def _download_api(self, path, destination, *, max_bytes, overwrite):
120
+ try:
121
+ async with self._http.stream('GET', path, headers={'Accept-Encoding': 'identity'}) as response:
122
+ check_response(response)
123
+ return await self._save(response, destination, max_bytes=max_bytes, overwrite=overwrite)
124
+ except httpx.TransportError:
125
+ raise DownloadError('File download connection failed') from None
126
+
127
+ async def _download_artifact(self, session_id, artifact_id, destination, *, max_bytes, overwrite):
128
+ for attempt in range(2):
129
+ row = select_artifact(await self.artifacts.path_map(session_id), artifact_id)
130
+ url = public_url(row.get('downloadUrl'))
131
+ expired = False
132
+ try:
133
+ for hop in range(5):
134
+ async with self._storage.stream('GET', url) as response:
135
+ if response.status_code in (301, 302, 303, 307, 308):
136
+ location = response.headers.get('location')
137
+ if not location or hop == 4:
138
+ raise DownloadError('Artifact redirect limit reached')
139
+ url = public_url(urljoin(url, location))
140
+ continue
141
+ if response.status_code == 403 and attempt == 0:
142
+ expired = True
143
+ break
144
+ if response.status_code != 200:
145
+ raise DownloadError(f'Artifact storage HTTP {response.status_code}')
146
+ return await self._save(response, destination, max_bytes=max_bytes, overwrite=overwrite)
147
+ except httpx.TransportError:
148
+ raise DownloadError('Artifact download connection failed') from None
149
+ if not expired:
150
+ raise DownloadError('Artifact download failed')
151
+ raise DownloadError('Artifact download failed after refresh')
@@ -0,0 +1,217 @@
1
+ # Generated by tools/generate_async.py; edit synchronous source.
2
+ import asyncio
3
+ """Legacy API resources. Responses retain the original JSON field names."""
4
+ from ._common import segment, fields
5
+ from .errors import PaginationError, ProtocolError
6
+
7
+ class AsyncResource:
8
+
9
+ def __init__(self, client, path):
10
+ self._client = client
11
+ self._path = path
12
+
13
+ async def iter(self, **params):
14
+ """Iterate cursor pages; raise rather than silently return partial data."""
15
+ cursor = params.pop('cursor', None)
16
+ seen = set()
17
+ while True:
18
+ page = await self.list(cursor=cursor, **params)
19
+ if not isinstance(page, dict) or not isinstance(page.get('data'), list):
20
+ raise PaginationError('Invalid list response')
21
+ for item in page['data']:
22
+ yield item
23
+ if page.get('hasMore') is False:
24
+ return
25
+ if page.get('hasMore') is not True:
26
+ raise PaginationError('Missing explicit hasMore')
27
+ nxt = page.get('nextCursor')
28
+ if not isinstance(nxt, (str, int)) or isinstance(nxt, bool) or (not str(nxt)) or (str(nxt) == str(cursor)) or (str(nxt) in seen):
29
+ raise PaginationError('Cursor did not advance')
30
+ seen.add(str(nxt))
31
+ cursor = str(nxt)
32
+
33
+ class AsyncAgents(AsyncResource):
34
+
35
+ async def list(self, *, keyword=None, cursor=None, limit=None):
36
+ return await self._client._request('GET', self._path, params=fields(keyword=keyword, cursor=cursor, limit=limit))
37
+
38
+ async def create(self, *, name, description=None, system=None, skills=None, files=None, mcp_servers=None, tools=None):
39
+ return await self._client._request('POST', self._path, json=fields(name=name, description=description, system=system, skills=skills, files=files, mcpServers=mcp_servers, tools=tools))
40
+
41
+ async def get(self, agent_id, *, version=None):
42
+ return await self._client._request('GET', f'{self._path}/{segment(agent_id)}', params=fields(version=version))
43
+
44
+ async def update(self, agent_id, *, version, system=None, skills=None, files=None, mcp_servers=None, tools=None):
45
+ return await self._client._request('POST', f'{self._path}/{segment(agent_id)}', json=fields(version=version, system=system, skills=skills, files=files, mcpServers=mcp_servers, tools=tools))
46
+
47
+ async def delete(self, agent_id):
48
+ """Destructive: the platform also deletes associated sessions."""
49
+ return await self._client._request('DELETE', f'{self._path}/{segment(agent_id)}')
50
+
51
+ class AsyncSkills(AsyncResource):
52
+
53
+ async def list(self, *, page=1, page_size=20, skill_id=None, keyword=None, order=None):
54
+ return await self._client._request('GET', self._path, params=fields(page=page, pageSize=page_size, skillId=skill_id, keyword=keyword, order=order))
55
+
56
+ async def iter(self, *, page_size=20, **params):
57
+ page_number, count, total = (1, 0, None)
58
+ seen = set()
59
+ while True:
60
+ page = await self.list(page=page_number, page_size=page_size, **params)
61
+ if not isinstance(page, dict) or not isinstance(page.get('data'), list) or (not isinstance(page.get('total'), int)):
62
+ raise PaginationError('Invalid Skill page')
63
+ if total is not None and page['total'] != total:
64
+ raise PaginationError('Skill total changed while paginating')
65
+ total = page['total']
66
+ for row in page['data']:
67
+ identity = (row.get('id'), row.get('releaseId'))
68
+ if not identity[0] or identity in seen:
69
+ raise PaginationError('Repeated or unidentified Skill')
70
+ seen.add(identity)
71
+ count += 1
72
+ yield row
73
+ if count == total:
74
+ return
75
+ if count > total or not page['data']:
76
+ raise PaginationError('Skill page count inconsistent with total')
77
+ page_number += 1
78
+
79
+ async def ensure(self, *, name, file):
80
+ """Reuse an exact name; upload only when absent. Does not update existing content."""
81
+ matches = [row async for row in self.iter(keyword=name) if row.get('name') == name]
82
+ if len(matches) > 1:
83
+ raise ProtocolError('Multiple matching Skills; select an explicit ID')
84
+ return matches[0] if matches else await self.upload(file)
85
+
86
+ async def upload(self, file):
87
+ return await self._client._upload('POST', f'{self._path}/upload', file)
88
+
89
+ async def create(self, *, url):
90
+ return await self._client._request('POST', self._path, json={'url': url})
91
+
92
+ async def get(self, skill_id, *, release_id=None):
93
+ return await self._client._request('GET', f'{self._path}/{segment(skill_id)}', params=fields(releaseId=release_id))
94
+
95
+ async def update(self, skill_id, *, url):
96
+ return await self._client._request('POST', f'{self._path}/{segment(skill_id)}', json={'url': url})
97
+
98
+ async def update_zip(self, skill_id, file):
99
+ return await self._client._upload('POST', f'{self._path}/{segment(skill_id)}/update', file)
100
+
101
+ async def delete(self, skill_id):
102
+ return await self._client._request('DELETE', f'{self._path}/{segment(skill_id)}')
103
+
104
+ async def mount(self, skill_id, *, release_id=None, type='custom'):
105
+ """Read releaseId, never confuse it with the display version."""
106
+ skill = await self.get(skill_id, release_id=release_id)
107
+ if not skill.get('releaseId'):
108
+ raise ProtocolError('Skill releaseId is not available yet')
109
+ if type not in ('custom', 'official'):
110
+ raise ValueError('type must be custom or official')
111
+ return {'skillID': skill_id, 'releaseID': skill['releaseId'], 'type': type}
112
+
113
+ class AsyncVaults(AsyncResource):
114
+
115
+ async def list(self, *, cursor=None, limit=None):
116
+ return await self._client._request('GET', self._path, params=fields(cursor=cursor, limit=limit))
117
+
118
+ async def create(self, *, name):
119
+ return await self._client._request('POST', self._path, json={'name': name})
120
+
121
+ async def get(self, vault_id):
122
+ return await self._client._request('GET', f'{self._path}/{segment(vault_id)}')
123
+
124
+ async def delete(self, vault_id):
125
+ return await self._client._request('DELETE', f'{self._path}/{segment(vault_id)}')
126
+
127
+ def credentials(self, vault_id):
128
+ return AsyncCredentials(self._client, f'{self._path}/{segment(vault_id)}/credentials')
129
+
130
+ class AsyncCredentials(AsyncResource):
131
+
132
+ async def list(self, *, cursor=None, limit=None, auth_type=None):
133
+ return await self._client._request('GET', self._path, params=fields(cursor=cursor, limit=limit, authType=auth_type))
134
+
135
+ async def create(self, *, name, auth):
136
+ return await self._client._request('POST', self._path, json={'name': name, 'auth': auth})
137
+
138
+ async def get(self, credential_id):
139
+ return await self._client._request('GET', f'{self._path}/{segment(credential_id)}')
140
+
141
+ async def delete(self, credential_id):
142
+ return await self._client._request('DELETE', f'{self._path}/{segment(credential_id)}')
143
+
144
+ class AsyncSessions(AsyncResource):
145
+
146
+ async def list(self, *, cursor=None, limit=None, status=None):
147
+ return await self._client._request('GET', self._path, params=fields(cursor=cursor, limit=limit, status=status))
148
+
149
+ async def create(self, *, agent_id, metadata=None, vault_ids=None, skills=None, mcp_servers=None):
150
+ return await self._client._request('POST', self._path, json=fields(agent={'id': agent_id}, metadata=metadata, vaultIds=vault_ids, skills=skills, mcpServers=mcp_servers))
151
+
152
+ async def get(self, session_id):
153
+ return await self._client._request('GET', f'{self._path}/{segment(session_id)}')
154
+
155
+ async def update(self, session_id, *, metadata=None, vault_ids=None, skills=None, mcp_servers=None):
156
+ return await self._client._request('POST', f'{self._path}/{segment(session_id)}', json=fields(metadata=metadata, vaultIds=vault_ids, skills=skills, mcpServers=mcp_servers))
157
+
158
+ async def delete(self, session_id):
159
+ return await self._client._request('DELETE', f'{self._path}/{segment(session_id)}')
160
+
161
+ async def history(self, session_id, *, limit=None, paginated=False):
162
+ return await self._client._request('GET', f'{self._path}/{segment(session_id)}/events', params=fields(limit=limit, paginated=str(paginated).lower()))
163
+
164
+ async def send(self, session_id, *, message, file_ids=None):
165
+ content = [{'type': 'text', 'text': message}]
166
+ content.extend(({'type': 'file', 'file_id': fid} for fid in file_ids or []))
167
+ return await self._client._send(session_id, {'type': 'user.message', 'content': content})
168
+
169
+ async def send_event(self, session_id, event):
170
+ if not isinstance(event, dict) or not isinstance(event.get('type'), str):
171
+ raise ValueError('event must be a single typed object')
172
+ return await self._client._request('POST', f'{self._path}/{segment(session_id)}/events', json={'events': [event]})
173
+
174
+ async def interrupt(self, session_id):
175
+ return await self.send_event(session_id, {'type': 'user.interrupt'})
176
+
177
+ async def submit_tool_result(self, session_id, *, custom_tool_use_id, content, is_error=False):
178
+ return await self.send_event(session_id, {'type': 'user.custom_tool_result', 'content': [{'customToolUseId': custom_tool_use_id, 'content': content, 'isError': is_error}]})
179
+
180
+ async def resource_points(self, session_id, *, start_at=None, end_at=None):
181
+ if start_at is not None and start_at < 0 or (end_at is not None and end_at < 0):
182
+ raise ValueError('timestamps must be non-negative')
183
+ if start_at is not None and end_at is not None and (start_at >= end_at):
184
+ raise ValueError('start_at must be less than end_at')
185
+ return await self._client._request('GET', f'{self._path}/{segment(session_id)}/resource-points', params=fields(startAt=start_at, endAt=end_at))
186
+
187
+ def stream(self, session_id, *, message=None, file_ids=None):
188
+ """Context manager; connect before optional send, never automatically resend."""
189
+ return self._client._stream(session_id, message=message, file_ids=file_ids)
190
+
191
+ class AsyncFiles(AsyncResource):
192
+
193
+ async def list(self, *, cursor=None, limit=None, order=None, file_ids=None):
194
+ return await self._client._request('GET', self._path, params=fields(cursor=cursor, limit=limit, order=order, fileIds=file_ids))
195
+
196
+ async def upload(self, file, *, session_id=None, mime_type=None):
197
+ return await self._client._upload('POST', self._path, file, data=fields(sessionId=session_id, mimeType=mime_type))
198
+
199
+ async def update(self, file_id, file):
200
+ return await self._client._upload('PUT', f'{self._path}/{segment(file_id)}', file)
201
+
202
+ async def get(self, file_id):
203
+ return await self._client._request('GET', f'{self._path}/{segment(file_id)}')
204
+
205
+ async def delete(self, file_id):
206
+ return await self._client._request('DELETE', f'{self._path}/{segment(file_id)}')
207
+
208
+ async def download(self, file_id, destination, *, max_bytes=100 * 1024 * 1024, overwrite=False):
209
+ return await self._client._download_api(f'{self._path}/{segment(file_id)}/content', destination, max_bytes=max_bytes, overwrite=overwrite)
210
+
211
+ class AsyncArtifacts(AsyncResource):
212
+
213
+ async def path_map(self, session_id, *, paths=None):
214
+ return await self._client._request('GET', f'sessions/{segment(session_id)}/artifact/path-map', params=fields(path=paths))
215
+
216
+ async def download(self, session_id, artifact_id, destination, *, max_bytes=100 * 1024 * 1024, overwrite=False):
217
+ return await self._client._download_artifact(session_id, artifact_id, destination, max_bytes=max_bytes, overwrite=overwrite)
dumate/_async_runs.py ADDED
@@ -0,0 +1,34 @@
1
+ # Generated by tools/generate_async.py; edit synchronous source.
2
+ import asyncio
3
+ from .runs import correlated_result
4
+ 'Client-side convenience calls over sessions/events, not a server /runs API.'
5
+ import time
6
+ from ._common import history_messages
7
+ from .errors import ProtocolError, RunTimeoutError, RunFailedError
8
+ from .types import RunResult
9
+
10
+ class AsyncRuns:
11
+
12
+ def __init__(self, client):
13
+ self._client = client
14
+
15
+ async def run_and_wait(self, *, session_id, message, file_ids=None, timeout=None, poll_interval=2.0):
16
+ """Poll authoritative history. No event replay, persistence or automatic resubmit.
17
+
18
+ One caller at a time per session. Timeout stops waiting, not remote execution.
19
+ """
20
+ if poll_interval <= 0 or (timeout is not None and timeout <= 0):
21
+ raise ValueError('poll_interval and timeout must be positive')
22
+ baseline = history_messages(await self._client.sessions.history(session_id))
23
+ baseline_ids = {x['info'].get('id') for x in baseline}
24
+ receipt = await self._client.sessions.send(session_id, message=message, file_ids=file_ids)
25
+ deadline = None if timeout is None else time.monotonic() + timeout
26
+ while True:
27
+ rows = await self._client.sessions.history(session_id)
28
+ result = correlated_result(session_id, rows, baseline_ids, message, receipt.request_id)
29
+ if result is not None:
30
+ return result
31
+ if deadline is not None and time.monotonic() >= deadline:
32
+ raise RunTimeoutError(session_id)
33
+ wait = poll_interval if deadline is None else min(poll_interval, max(0, deadline - time.monotonic()))
34
+ await asyncio.sleep(wait)