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.
- dmt_agent_api-0.3.0.dist-info/METADATA +166 -0
- dmt_agent_api-0.3.0.dist-info/RECORD +19 -0
- dmt_agent_api-0.3.0.dist-info/WHEEL +5 -0
- dmt_agent_api-0.3.0.dist-info/top_level.txt +1 -0
- dumate/__init__.py +13 -0
- dumate/_async_client.py +151 -0
- dumate/_async_resources.py +217 -0
- dumate/_async_runs.py +34 -0
- dumate/_client.py +151 -0
- dumate/_common.py +65 -0
- dumate/_downloads.py +85 -0
- dumate/_sse.py +86 -0
- dumate/conversation.py +183 -0
- dumate/errors.py +58 -0
- dumate/notebook.py +48 -0
- dumate/py.typed +0 -0
- dumate/resources.py +206 -0
- dumate/runs.py +61 -0
- dumate/types.py +51 -0
dumate/resources.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Legacy API resources. Responses retain the original JSON field names."""
|
|
2
|
+
from ._common import segment, fields
|
|
3
|
+
from .errors import PaginationError, ProtocolError
|
|
4
|
+
|
|
5
|
+
class Resource:
|
|
6
|
+
def __init__(self, client, path):
|
|
7
|
+
self._client = client
|
|
8
|
+
self._path = path
|
|
9
|
+
|
|
10
|
+
def iter(self, **params):
|
|
11
|
+
"""Iterate cursor pages; raise rather than silently return partial data."""
|
|
12
|
+
cursor = params.pop("cursor", None)
|
|
13
|
+
seen = set()
|
|
14
|
+
while True:
|
|
15
|
+
page = self.list(cursor=cursor, **params)
|
|
16
|
+
if not isinstance(page, dict) or not isinstance(page.get("data"), list):
|
|
17
|
+
raise PaginationError("Invalid list response")
|
|
18
|
+
yield from page["data"]
|
|
19
|
+
if page.get("hasMore") is False:
|
|
20
|
+
return
|
|
21
|
+
if page.get("hasMore") is not True:
|
|
22
|
+
raise PaginationError("Missing explicit hasMore")
|
|
23
|
+
nxt = page.get("nextCursor")
|
|
24
|
+
if not isinstance(nxt, (str, int)) or isinstance(nxt, bool) or not str(nxt) or str(nxt) == str(cursor) or str(nxt) in seen:
|
|
25
|
+
raise PaginationError("Cursor did not advance")
|
|
26
|
+
seen.add(str(nxt))
|
|
27
|
+
cursor = str(nxt)
|
|
28
|
+
|
|
29
|
+
class Agents(Resource):
|
|
30
|
+
def list(self, *, keyword=None, cursor=None, limit=None):
|
|
31
|
+
return self._client._request("GET", self._path, params=fields(keyword=keyword, cursor=cursor, limit=limit))
|
|
32
|
+
|
|
33
|
+
def create(self, *, name, description=None, system=None, skills=None, files=None, mcp_servers=None, tools=None):
|
|
34
|
+
return self._client._request("POST", self._path, json=fields(name=name, description=description, system=system, skills=skills, files=files, mcpServers=mcp_servers, tools=tools))
|
|
35
|
+
|
|
36
|
+
def get(self, agent_id, *, version=None):
|
|
37
|
+
return self._client._request("GET", f"{self._path}/{segment(agent_id)}", params=fields(version=version))
|
|
38
|
+
|
|
39
|
+
def update(self, agent_id, *, version, system=None, skills=None, files=None, mcp_servers=None, tools=None):
|
|
40
|
+
return 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))
|
|
41
|
+
|
|
42
|
+
def delete(self, agent_id):
|
|
43
|
+
"""Destructive: the platform also deletes associated sessions."""
|
|
44
|
+
return self._client._request("DELETE", f"{self._path}/{segment(agent_id)}")
|
|
45
|
+
|
|
46
|
+
class Skills(Resource):
|
|
47
|
+
def list(self, *, page=1, page_size=20, skill_id=None, keyword=None, order=None):
|
|
48
|
+
return self._client._request("GET", self._path, params=fields(page=page, pageSize=page_size, skillId=skill_id, keyword=keyword, order=order))
|
|
49
|
+
|
|
50
|
+
def iter(self, *, page_size=20, **params):
|
|
51
|
+
page_number, count, total = 1, 0, None
|
|
52
|
+
seen = set()
|
|
53
|
+
while True:
|
|
54
|
+
page = self.list(page=page_number, page_size=page_size, **params)
|
|
55
|
+
if not isinstance(page, dict) or not isinstance(page.get("data"), list) or not isinstance(page.get("total"), int):
|
|
56
|
+
raise PaginationError("Invalid Skill page")
|
|
57
|
+
if total is not None and page["total"] != total:
|
|
58
|
+
raise PaginationError("Skill total changed while paginating")
|
|
59
|
+
total = page["total"]
|
|
60
|
+
for row in page["data"]:
|
|
61
|
+
identity = (row.get("id"), row.get("releaseId"))
|
|
62
|
+
if not identity[0] or identity in seen:
|
|
63
|
+
raise PaginationError("Repeated or unidentified Skill")
|
|
64
|
+
seen.add(identity)
|
|
65
|
+
count += 1
|
|
66
|
+
yield row
|
|
67
|
+
if count == total:
|
|
68
|
+
return
|
|
69
|
+
if count > total or not page["data"]:
|
|
70
|
+
raise PaginationError("Skill page count inconsistent with total")
|
|
71
|
+
page_number += 1
|
|
72
|
+
|
|
73
|
+
def ensure(self, *, name, file):
|
|
74
|
+
"""Reuse an exact name; upload only when absent. Does not update existing content."""
|
|
75
|
+
matches = [row for row in self.iter(keyword=name) if row.get("name") == name]
|
|
76
|
+
if len(matches) > 1:
|
|
77
|
+
raise ProtocolError("Multiple matching Skills; select an explicit ID")
|
|
78
|
+
return matches[0] if matches else self.upload(file)
|
|
79
|
+
|
|
80
|
+
def upload(self, file):
|
|
81
|
+
return self._client._upload("POST", f"{self._path}/upload", file)
|
|
82
|
+
|
|
83
|
+
def create(self, *, url):
|
|
84
|
+
return self._client._request("POST", self._path, json={"url": url})
|
|
85
|
+
|
|
86
|
+
def get(self, skill_id, *, release_id=None):
|
|
87
|
+
return self._client._request("GET", f"{self._path}/{segment(skill_id)}", params=fields(releaseId=release_id))
|
|
88
|
+
|
|
89
|
+
def update(self, skill_id, *, url):
|
|
90
|
+
return self._client._request("POST", f"{self._path}/{segment(skill_id)}", json={"url": url})
|
|
91
|
+
|
|
92
|
+
def update_zip(self, skill_id, file):
|
|
93
|
+
return self._client._upload("POST", f"{self._path}/{segment(skill_id)}/update", file)
|
|
94
|
+
|
|
95
|
+
def delete(self, skill_id):
|
|
96
|
+
return self._client._request("DELETE", f"{self._path}/{segment(skill_id)}")
|
|
97
|
+
|
|
98
|
+
def mount(self, skill_id, *, release_id=None, type="custom"):
|
|
99
|
+
"""Read releaseId, never confuse it with the display version."""
|
|
100
|
+
skill = self.get(skill_id, release_id=release_id)
|
|
101
|
+
if not skill.get("releaseId"):
|
|
102
|
+
raise ProtocolError("Skill releaseId is not available yet")
|
|
103
|
+
if type not in ("custom", "official"):
|
|
104
|
+
raise ValueError("type must be custom or official")
|
|
105
|
+
return {"skillID": skill_id, "releaseID": skill["releaseId"], "type": type}
|
|
106
|
+
|
|
107
|
+
class Vaults(Resource):
|
|
108
|
+
def list(self, *, cursor=None, limit=None):
|
|
109
|
+
return self._client._request("GET", self._path, params=fields(cursor=cursor, limit=limit))
|
|
110
|
+
|
|
111
|
+
def create(self, *, name):
|
|
112
|
+
return self._client._request("POST", self._path, json={"name": name})
|
|
113
|
+
|
|
114
|
+
def get(self, vault_id):
|
|
115
|
+
return self._client._request("GET", f"{self._path}/{segment(vault_id)}")
|
|
116
|
+
|
|
117
|
+
def delete(self, vault_id):
|
|
118
|
+
return self._client._request("DELETE", f"{self._path}/{segment(vault_id)}")
|
|
119
|
+
|
|
120
|
+
def credentials(self, vault_id):
|
|
121
|
+
return Credentials(self._client, f"{self._path}/{segment(vault_id)}/credentials")
|
|
122
|
+
|
|
123
|
+
class Credentials(Resource):
|
|
124
|
+
def list(self, *, cursor=None, limit=None, auth_type=None):
|
|
125
|
+
return self._client._request("GET", self._path, params=fields(cursor=cursor, limit=limit, authType=auth_type))
|
|
126
|
+
|
|
127
|
+
def create(self, *, name, auth):
|
|
128
|
+
return self._client._request("POST", self._path, json={"name": name, "auth": auth})
|
|
129
|
+
|
|
130
|
+
def get(self, credential_id):
|
|
131
|
+
return self._client._request("GET", f"{self._path}/{segment(credential_id)}")
|
|
132
|
+
|
|
133
|
+
def delete(self, credential_id):
|
|
134
|
+
return self._client._request("DELETE", f"{self._path}/{segment(credential_id)}")
|
|
135
|
+
|
|
136
|
+
class Sessions(Resource):
|
|
137
|
+
def list(self, *, cursor=None, limit=None, status=None):
|
|
138
|
+
return self._client._request("GET", self._path, params=fields(cursor=cursor, limit=limit, status=status))
|
|
139
|
+
|
|
140
|
+
def create(self, *, agent_id, metadata=None, vault_ids=None, skills=None, mcp_servers=None):
|
|
141
|
+
return self._client._request("POST", self._path, json=fields(agent={"id": agent_id}, metadata=metadata, vaultIds=vault_ids, skills=skills, mcpServers=mcp_servers))
|
|
142
|
+
|
|
143
|
+
def get(self, session_id):
|
|
144
|
+
return self._client._request("GET", f"{self._path}/{segment(session_id)}")
|
|
145
|
+
|
|
146
|
+
def update(self, session_id, *, metadata=None, vault_ids=None, skills=None, mcp_servers=None):
|
|
147
|
+
return self._client._request("POST", f"{self._path}/{segment(session_id)}", json=fields(metadata=metadata, vaultIds=vault_ids, skills=skills, mcpServers=mcp_servers))
|
|
148
|
+
|
|
149
|
+
def delete(self, session_id):
|
|
150
|
+
return self._client._request("DELETE", f"{self._path}/{segment(session_id)}")
|
|
151
|
+
|
|
152
|
+
def history(self, session_id, *, limit=None, paginated=False):
|
|
153
|
+
return self._client._request("GET", f"{self._path}/{segment(session_id)}/events", params=fields(limit=limit, paginated=str(paginated).lower()))
|
|
154
|
+
|
|
155
|
+
def send(self, session_id, *, message, file_ids=None):
|
|
156
|
+
content = [{"type": "text", "text": message}]
|
|
157
|
+
content.extend({"type": "file", "file_id": fid} for fid in (file_ids or []))
|
|
158
|
+
return self._client._send(session_id, {"type": "user.message", "content": content})
|
|
159
|
+
|
|
160
|
+
def send_event(self, session_id, event):
|
|
161
|
+
if not isinstance(event, dict) or not isinstance(event.get("type"), str):
|
|
162
|
+
raise ValueError("event must be a single typed object")
|
|
163
|
+
return self._client._request("POST", f"{self._path}/{segment(session_id)}/events", json={"events": [event]})
|
|
164
|
+
|
|
165
|
+
def interrupt(self, session_id):
|
|
166
|
+
return self.send_event(session_id, {"type": "user.interrupt"})
|
|
167
|
+
|
|
168
|
+
def submit_tool_result(self, session_id, *, custom_tool_use_id, content, is_error=False):
|
|
169
|
+
return self.send_event(session_id, {"type": "user.custom_tool_result", "content": [{"customToolUseId": custom_tool_use_id, "content": content, "isError": is_error}]})
|
|
170
|
+
|
|
171
|
+
def resource_points(self, session_id, *, start_at=None, end_at=None):
|
|
172
|
+
if start_at is not None and start_at < 0 or end_at is not None and end_at < 0:
|
|
173
|
+
raise ValueError("timestamps must be non-negative")
|
|
174
|
+
if start_at is not None and end_at is not None and start_at >= end_at:
|
|
175
|
+
raise ValueError("start_at must be less than end_at")
|
|
176
|
+
return self._client._request("GET", f"{self._path}/{segment(session_id)}/resource-points", params=fields(startAt=start_at, endAt=end_at))
|
|
177
|
+
|
|
178
|
+
def stream(self, session_id, *, message=None, file_ids=None):
|
|
179
|
+
"""Context manager; connect before optional send, never automatically resend."""
|
|
180
|
+
return self._client._stream(session_id, message=message, file_ids=file_ids)
|
|
181
|
+
|
|
182
|
+
class Files(Resource):
|
|
183
|
+
def list(self, *, cursor=None, limit=None, order=None, file_ids=None):
|
|
184
|
+
return self._client._request("GET", self._path, params=fields(cursor=cursor, limit=limit, order=order, fileIds=file_ids))
|
|
185
|
+
|
|
186
|
+
def upload(self, file, *, session_id=None, mime_type=None):
|
|
187
|
+
return self._client._upload("POST", self._path, file, data=fields(sessionId=session_id, mimeType=mime_type))
|
|
188
|
+
|
|
189
|
+
def update(self, file_id, file):
|
|
190
|
+
return self._client._upload("PUT", f"{self._path}/{segment(file_id)}", file)
|
|
191
|
+
|
|
192
|
+
def get(self, file_id):
|
|
193
|
+
return self._client._request("GET", f"{self._path}/{segment(file_id)}")
|
|
194
|
+
|
|
195
|
+
def delete(self, file_id):
|
|
196
|
+
return self._client._request("DELETE", f"{self._path}/{segment(file_id)}")
|
|
197
|
+
|
|
198
|
+
def download(self, file_id, destination, *, max_bytes=100 * 1024 * 1024, overwrite=False):
|
|
199
|
+
return self._client._download_api(f"{self._path}/{segment(file_id)}/content", destination, max_bytes=max_bytes, overwrite=overwrite)
|
|
200
|
+
|
|
201
|
+
class Artifacts(Resource):
|
|
202
|
+
def path_map(self, session_id, *, paths=None):
|
|
203
|
+
return self._client._request("GET", f"sessions/{segment(session_id)}/artifact/path-map", params=fields(path=paths))
|
|
204
|
+
|
|
205
|
+
def download(self, session_id, artifact_id, destination, *, max_bytes=100 * 1024 * 1024, overwrite=False):
|
|
206
|
+
return self._client._download_artifact(session_id, artifact_id, destination, max_bytes=max_bytes, overwrite=overwrite)
|
dumate/runs.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Client-side convenience calls over sessions/events, not a server /runs API."""
|
|
2
|
+
import time
|
|
3
|
+
from ._common import history_messages
|
|
4
|
+
from .errors import ProtocolError, RunTimeoutError, RunFailedError
|
|
5
|
+
from .types import RunResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def correlated_result(session_id, history, baseline_ids, message, request_id):
|
|
9
|
+
rows = history_messages(history)
|
|
10
|
+
users = []
|
|
11
|
+
for row in rows:
|
|
12
|
+
info = row["info"]
|
|
13
|
+
if info.get("sessionID") != session_id or info.get("role") != "user" or info.get("id") in baseline_ids:
|
|
14
|
+
continue
|
|
15
|
+
text = "\n".join(p.get("text", "") for p in row["parts"] if p.get("type") == "text")
|
|
16
|
+
if text == message or (request_id and info.get("requestID") == request_id):
|
|
17
|
+
users.append(info["id"])
|
|
18
|
+
if len(users) > 1:
|
|
19
|
+
raise ProtocolError("Multiple matching user messages; serialize sends within a session")
|
|
20
|
+
if not users:
|
|
21
|
+
return None
|
|
22
|
+
user_id = users[0]
|
|
23
|
+
assistants = [r for r in rows if r["info"].get("sessionID") == session_id
|
|
24
|
+
and r["info"].get("role") == "assistant" and r["info"].get("parentID") == user_id]
|
|
25
|
+
for row in reversed(assistants):
|
|
26
|
+
info = row["info"]
|
|
27
|
+
if info.get("error"):
|
|
28
|
+
raise RunFailedError(session_id)
|
|
29
|
+
if info.get("finish") != "stop" or not info.get("time", {}).get("completed"):
|
|
30
|
+
continue
|
|
31
|
+
texts = [p for p in row["parts"] if p.get("type") == "text" and p.get("phase") in (None, "final_answer")]
|
|
32
|
+
final = [p for p in texts if p.get("phase") == "final_answer"]
|
|
33
|
+
output = "\n".join(p.get("text", "") for p in (final or texts)).strip()
|
|
34
|
+
if output:
|
|
35
|
+
return RunResult(session_id, user_id, request_id, output, assistants)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
class Runs:
|
|
39
|
+
def __init__(self, client):
|
|
40
|
+
self._client = client
|
|
41
|
+
|
|
42
|
+
def run_and_wait(self, *, session_id, message, file_ids=None, timeout=None, poll_interval=2.0):
|
|
43
|
+
"""Poll authoritative history. No event replay, persistence or automatic resubmit.
|
|
44
|
+
|
|
45
|
+
One caller at a time per session. Timeout stops waiting, not remote execution.
|
|
46
|
+
"""
|
|
47
|
+
if poll_interval <= 0 or (timeout is not None and timeout <= 0):
|
|
48
|
+
raise ValueError("poll_interval and timeout must be positive")
|
|
49
|
+
baseline = history_messages(self._client.sessions.history(session_id))
|
|
50
|
+
baseline_ids = {x["info"].get("id") for x in baseline}
|
|
51
|
+
receipt = self._client.sessions.send(session_id, message=message, file_ids=file_ids)
|
|
52
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
53
|
+
while True:
|
|
54
|
+
rows = self._client.sessions.history(session_id)
|
|
55
|
+
result = correlated_result(session_id, rows, baseline_ids, message, receipt.request_id)
|
|
56
|
+
if result is not None:
|
|
57
|
+
return result
|
|
58
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
59
|
+
raise RunTimeoutError(session_id)
|
|
60
|
+
wait = poll_interval if deadline is None else min(poll_interval, max(0, deadline-time.monotonic()))
|
|
61
|
+
time.sleep(wait)
|
dumate/types.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, TypedDict, Literal
|
|
3
|
+
|
|
4
|
+
class SkillMount(TypedDict):
|
|
5
|
+
skillID: str
|
|
6
|
+
releaseID: str
|
|
7
|
+
type: Literal["custom", "official"]
|
|
8
|
+
|
|
9
|
+
class MCPServer(TypedDict):
|
|
10
|
+
type: Literal["url"]
|
|
11
|
+
name: str
|
|
12
|
+
url: str
|
|
13
|
+
|
|
14
|
+
class FileMount(TypedDict):
|
|
15
|
+
fileID: str
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Event:
|
|
19
|
+
type: str
|
|
20
|
+
properties: dict[str, Any] = field(repr=False)
|
|
21
|
+
raw: dict[str, Any] = field(repr=False)
|
|
22
|
+
id: str | None = None
|
|
23
|
+
event: str | None = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def session_id(self) -> str | None:
|
|
27
|
+
p = self.properties
|
|
28
|
+
if isinstance(p.get("sessionID"), str):
|
|
29
|
+
return p["sessionID"]
|
|
30
|
+
for key in ("part", "info"):
|
|
31
|
+
obj = p.get(key)
|
|
32
|
+
if isinstance(obj, dict) and isinstance(obj.get("sessionID"), str):
|
|
33
|
+
return obj["sessionID"]
|
|
34
|
+
if self.type == "session.updated" and isinstance(p.get("info"), dict):
|
|
35
|
+
return p["info"].get("id")
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SendReceipt:
|
|
40
|
+
session_id: str
|
|
41
|
+
request_id: str | None
|
|
42
|
+
accepted: bool = True
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class RunResult:
|
|
46
|
+
session_id: str
|
|
47
|
+
user_message_id: str
|
|
48
|
+
request_id: str | None
|
|
49
|
+
output_text: str = field(repr=False)
|
|
50
|
+
messages: list[dict[str, Any]] = field(repr=False)
|
|
51
|
+
status: str = "completed"
|