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/_client.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
import time
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
import httpx
|
|
7
|
+
from . import resources
|
|
8
|
+
from ._common import config, check_response, decode, delay, request_id, segment
|
|
9
|
+
from ._downloads import public_url, select_artifact, AtomicDownload
|
|
10
|
+
from ._sse import SSEDecoder
|
|
11
|
+
from .errors import ConnectionError, ProtocolError, StreamInterruptedError, DownloadError, APIError
|
|
12
|
+
from .types import SendReceipt
|
|
13
|
+
|
|
14
|
+
class DuMate:
|
|
15
|
+
def __init__(self, api_key=None, *, base_url=None, timeout=60.0, stream_timeout=60.0,
|
|
16
|
+
max_retries=2, max_frame_bytes=4*1024*1024, transport=None):
|
|
17
|
+
key, base = config(api_key, base_url, max_retries)
|
|
18
|
+
self.base_url = base
|
|
19
|
+
self.max_retries = max_retries
|
|
20
|
+
self.max_frame_bytes = max_frame_bytes
|
|
21
|
+
self.stream_timeout = stream_timeout
|
|
22
|
+
self._http = httpx.Client(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)
|
|
23
|
+
self._storage = httpx.Client(timeout=timeout, follow_redirects=False, trust_env=False, headers={"Accept-Encoding": "identity"})
|
|
24
|
+
self.agents = resources.Agents(self, "agents")
|
|
25
|
+
self.skills = resources.Skills(self, "skills")
|
|
26
|
+
self.vaults = resources.Vaults(self, "vaults")
|
|
27
|
+
self.sessions = resources.Sessions(self, "sessions")
|
|
28
|
+
self.files = resources.Files(self, "files")
|
|
29
|
+
self.artifacts = resources.Artifacts(self, "")
|
|
30
|
+
from .runs import Runs
|
|
31
|
+
self.runs = Runs(self)
|
|
32
|
+
from .conversation import Conversations
|
|
33
|
+
from .notebook import Notebook
|
|
34
|
+
self.conversations = Conversations(self)
|
|
35
|
+
self.notebook = Notebook(self)
|
|
36
|
+
|
|
37
|
+
def __enter__(self):
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def __exit__(self, *args):
|
|
41
|
+
self.close()
|
|
42
|
+
|
|
43
|
+
def close(self):
|
|
44
|
+
self._http.close()
|
|
45
|
+
self._storage.close()
|
|
46
|
+
|
|
47
|
+
def _response(self, method, path, **kwargs):
|
|
48
|
+
for attempt in range(self.max_retries + 1):
|
|
49
|
+
try:
|
|
50
|
+
response = self._http.request(method, path, **kwargs)
|
|
51
|
+
except httpx.TransportError:
|
|
52
|
+
if method == "GET" and attempt < self.max_retries:
|
|
53
|
+
time.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
|
+
response.close()
|
|
59
|
+
time.sleep(wait)
|
|
60
|
+
continue
|
|
61
|
+
check_response(response)
|
|
62
|
+
return response
|
|
63
|
+
raise AssertionError("Unreachable")
|
|
64
|
+
|
|
65
|
+
def _request(self, method, path, **kwargs):
|
|
66
|
+
response = self._response(method, path, **kwargs)
|
|
67
|
+
try:
|
|
68
|
+
return decode(response)
|
|
69
|
+
finally:
|
|
70
|
+
response.close()
|
|
71
|
+
|
|
72
|
+
def _upload(self, method, path, file, *, data=None):
|
|
73
|
+
path_file = Path(file)
|
|
74
|
+
with path_file.open("rb") as handle:
|
|
75
|
+
return 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
|
+
def _send(self, session_id, event):
|
|
78
|
+
response = 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
|
+
response.close()
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def _stream(self, session_id, *, message=None, file_ids=None):
|
|
86
|
+
try:
|
|
87
|
+
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
|
+
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
|
+
def _events(self, response, session_id):
|
|
98
|
+
decoder = SSEDecoder(self.max_frame_bytes)
|
|
99
|
+
try:
|
|
100
|
+
for chunk in response.iter_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
|
+
def _save(self, response, destination, *, max_bytes, overwrite):
|
|
109
|
+
target = AtomicDownload(destination, max_bytes=max_bytes, overwrite=overwrite, headers=response.headers)
|
|
110
|
+
try:
|
|
111
|
+
for chunk in response.iter_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
|
+
def _download_api(self, path, destination, *, max_bytes, overwrite):
|
|
120
|
+
try:
|
|
121
|
+
with self._http.stream("GET", path, headers={"Accept-Encoding": "identity"}) as response:
|
|
122
|
+
check_response(response)
|
|
123
|
+
return 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
|
+
def _download_artifact(self, session_id, artifact_id, destination, *, max_bytes, overwrite):
|
|
128
|
+
for attempt in range(2):
|
|
129
|
+
row = select_artifact(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
|
+
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 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")
|
dumate/_common.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import quote, urlsplit
|
|
6
|
+
from .errors import (APIError, AuthenticationError, PermissionDeniedError,
|
|
7
|
+
NotFoundError, ConflictError, RateLimitError, ProtocolError)
|
|
8
|
+
|
|
9
|
+
BASE_URL = "https://api.dumate.cn/api/v1"
|
|
10
|
+
|
|
11
|
+
def segment(value: str) -> str:
|
|
12
|
+
if not isinstance(value, str) or not value or value in (".", ".."):
|
|
13
|
+
raise ValueError("A non-empty resource ID is required")
|
|
14
|
+
return quote(value, safe="")
|
|
15
|
+
|
|
16
|
+
def fields(**values):
|
|
17
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
18
|
+
|
|
19
|
+
def config(api_key, base_url, max_retries):
|
|
20
|
+
key = api_key or os.getenv("DUMATE_API_KEY") or os.getenv("AGENT_API_KEY")
|
|
21
|
+
if not key or not key.strip():
|
|
22
|
+
raise ValueError("Set DUMATE_API_KEY or pass api_key")
|
|
23
|
+
base = (base_url or os.getenv("DUMATE_BASE_URL") or BASE_URL).rstrip("/")
|
|
24
|
+
u = urlsplit(base)
|
|
25
|
+
if u.scheme != "https" or not u.hostname or u.username or u.password or u.query or u.fragment:
|
|
26
|
+
raise ValueError("base_url must be an HTTPS API root without credentials/query/fragment")
|
|
27
|
+
if not isinstance(max_retries, int) or max_retries < 0:
|
|
28
|
+
raise ValueError("max_retries must be non-negative")
|
|
29
|
+
return key, base
|
|
30
|
+
|
|
31
|
+
def check_response(response):
|
|
32
|
+
if 200 <= response.status_code < 300:
|
|
33
|
+
return
|
|
34
|
+
cls = {401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError,
|
|
35
|
+
409: ConflictError, 429: RateLimitError}.get(response.status_code, APIError)
|
|
36
|
+
raise cls(response.status_code, request_id=request_id(response))
|
|
37
|
+
|
|
38
|
+
def request_id(response):
|
|
39
|
+
raw = response.headers.get("x-bce-request-id") or response.headers.get("x-request-id")
|
|
40
|
+
return raw if raw and re.fullmatch(r"[a-zA-Z0-9_.:-]{1,200}", raw) else None
|
|
41
|
+
|
|
42
|
+
def decode(response):
|
|
43
|
+
check_response(response)
|
|
44
|
+
if response.status_code == 204 or not response.content:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return response.json()
|
|
48
|
+
except ValueError:
|
|
49
|
+
raise ProtocolError("Expected JSON response") from None
|
|
50
|
+
|
|
51
|
+
def delay(response, attempt):
|
|
52
|
+
val = response.headers.get("retry-after", "") if response else ""
|
|
53
|
+
try:
|
|
54
|
+
num = float(val)
|
|
55
|
+
if math.isfinite(num):
|
|
56
|
+
return max(0.0, min(num, 30.0))
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
return min(0.5 * (2 ** attempt), 8.0)
|
|
60
|
+
|
|
61
|
+
def history_messages(value):
|
|
62
|
+
rows = value if isinstance(value, list) else value.get("messages") if isinstance(value, dict) else None
|
|
63
|
+
if not isinstance(rows, list) or any(not isinstance(x, dict) or not isinstance(x.get("info"), dict) or not isinstance(x.get("parts"), list) for x in rows):
|
|
64
|
+
raise ProtocolError("Invalid history response")
|
|
65
|
+
return rows
|
dumate/_downloads.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import ipaddress
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
from .errors import DownloadError, ArtifactNotReadyError, ProtocolError
|
|
9
|
+
|
|
10
|
+
def public_url(url):
|
|
11
|
+
if not isinstance(url, str):
|
|
12
|
+
raise DownloadError("Missing artifact download URL")
|
|
13
|
+
u = urlsplit(url)
|
|
14
|
+
if u.scheme != "https" or not u.hostname or u.username or u.password or u.fragment or u.port not in (None, 443):
|
|
15
|
+
raise DownloadError("Artifact URL must use public HTTPS")
|
|
16
|
+
try:
|
|
17
|
+
addresses = socket.getaddrinfo(u.hostname, 443, type=socket.SOCK_STREAM)
|
|
18
|
+
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
|
|
19
|
+
raise DownloadError("Non-public artifact host rejected")
|
|
20
|
+
except (OSError, ValueError):
|
|
21
|
+
raise DownloadError("Cannot validate artifact host") from None
|
|
22
|
+
return url
|
|
23
|
+
|
|
24
|
+
def select_artifact(mapping, artifact_id):
|
|
25
|
+
if not isinstance(mapping, dict):
|
|
26
|
+
raise ProtocolError("Invalid artifact path map")
|
|
27
|
+
rows = [x for x in mapping.values() if isinstance(x, dict) and x.get("artifactID") == artifact_id]
|
|
28
|
+
if len(rows) != 1:
|
|
29
|
+
raise DownloadError("Artifact is missing or ambiguous in this session")
|
|
30
|
+
row = rows[0]
|
|
31
|
+
if row.get("uploadStatus") != "ready":
|
|
32
|
+
raise ArtifactNotReadyError("Artifact is not ready")
|
|
33
|
+
return row
|
|
34
|
+
|
|
35
|
+
class AtomicDownload:
|
|
36
|
+
def __init__(self, destination, *, max_bytes, overwrite, headers):
|
|
37
|
+
if max_bytes <= 0:
|
|
38
|
+
raise ValueError("max_bytes must be positive")
|
|
39
|
+
self.destination = Path(destination)
|
|
40
|
+
self.max_bytes, self.overwrite = max_bytes, overwrite
|
|
41
|
+
self.size = 0
|
|
42
|
+
self.hash = hashlib.sha256()
|
|
43
|
+
self.expected = None
|
|
44
|
+
value = headers.get("content-length")
|
|
45
|
+
if value is not None:
|
|
46
|
+
try:
|
|
47
|
+
self.expected = int(value)
|
|
48
|
+
if self.expected < 0:
|
|
49
|
+
raise ValueError
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise DownloadError("Invalid Content-Length") from None
|
|
52
|
+
if headers.get("content-encoding", "identity").lower() != "identity":
|
|
53
|
+
raise DownloadError("Encoded downloads are not supported; identity encoding required")
|
|
54
|
+
if self.expected is not None and self.expected > max_bytes:
|
|
55
|
+
raise DownloadError("Download exceeds max_bytes")
|
|
56
|
+
if self.destination.exists() and not overwrite:
|
|
57
|
+
raise FileExistsError(str(self.destination))
|
|
58
|
+
self.destination.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
fd, name = tempfile.mkstemp(prefix=".dumate-", dir=self.destination.parent)
|
|
60
|
+
self.tmp = Path(name)
|
|
61
|
+
self.file = os.fdopen(fd, "wb")
|
|
62
|
+
|
|
63
|
+
def write(self, data):
|
|
64
|
+
self.size += len(data)
|
|
65
|
+
if self.size > self.max_bytes:
|
|
66
|
+
raise DownloadError("Download exceeds max_bytes")
|
|
67
|
+
self.hash.update(data)
|
|
68
|
+
self.file.write(data)
|
|
69
|
+
|
|
70
|
+
def commit(self):
|
|
71
|
+
if self.expected is not None and self.expected != self.size:
|
|
72
|
+
raise DownloadError("Incomplete download")
|
|
73
|
+
self.file.flush()
|
|
74
|
+
os.fsync(self.file.fileno())
|
|
75
|
+
self.file.close()
|
|
76
|
+
if self.overwrite:
|
|
77
|
+
os.replace(self.tmp, self.destination)
|
|
78
|
+
else:
|
|
79
|
+
os.link(self.tmp, self.destination)
|
|
80
|
+
self.tmp.unlink()
|
|
81
|
+
return {"path": str(self.destination), "bytes": self.size, "sha256": self.hash.hexdigest()}
|
|
82
|
+
|
|
83
|
+
def close(self):
|
|
84
|
+
self.file.close()
|
|
85
|
+
self.tmp.unlink(missing_ok=True)
|
dumate/_sse.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import codecs
|
|
2
|
+
import json
|
|
3
|
+
from .errors import ProtocolError
|
|
4
|
+
from .types import Event
|
|
5
|
+
|
|
6
|
+
class SSEDecoder:
|
|
7
|
+
"""Incremental UTF-8 + CR/LF/CRLF parser with per-frame memory bound."""
|
|
8
|
+
def __init__(self, max_frame_bytes: int = 4 * 1024 * 1024):
|
|
9
|
+
if max_frame_bytes <= 0:
|
|
10
|
+
raise ValueError("max_frame_bytes must be positive")
|
|
11
|
+
self.max_bytes = max_frame_bytes
|
|
12
|
+
self.decoder = codecs.getincrementaldecoder("utf-8")("strict")
|
|
13
|
+
self.line = []
|
|
14
|
+
self.data = []
|
|
15
|
+
self.event = None
|
|
16
|
+
self.id = None
|
|
17
|
+
self.size = 0
|
|
18
|
+
self.after_cr = False
|
|
19
|
+
self.first = True
|
|
20
|
+
|
|
21
|
+
def feed(self, chunk: bytes):
|
|
22
|
+
try:
|
|
23
|
+
text = self.decoder.decode(chunk)
|
|
24
|
+
except UnicodeDecodeError:
|
|
25
|
+
raise ProtocolError("Invalid UTF-8 in SSE") from None
|
|
26
|
+
result = []
|
|
27
|
+
for char in text:
|
|
28
|
+
if self.first:
|
|
29
|
+
self.first = False
|
|
30
|
+
if char == "\ufeff":
|
|
31
|
+
continue
|
|
32
|
+
if self.after_cr:
|
|
33
|
+
self.after_cr = False
|
|
34
|
+
if char == "\n":
|
|
35
|
+
continue
|
|
36
|
+
self.size += len(char.encode("utf-8"))
|
|
37
|
+
if self.size > self.max_bytes:
|
|
38
|
+
raise ProtocolError("SSE frame exceeds max_frame_bytes")
|
|
39
|
+
if char in "\r\n":
|
|
40
|
+
item = self._line("".join(self.line))
|
|
41
|
+
self.line.clear()
|
|
42
|
+
self.after_cr = char == "\r"
|
|
43
|
+
if item is not None:
|
|
44
|
+
result.append(item)
|
|
45
|
+
else:
|
|
46
|
+
self.line.append(char)
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
def _line(self, line):
|
|
50
|
+
if not line:
|
|
51
|
+
item = None
|
|
52
|
+
if self.data:
|
|
53
|
+
try:
|
|
54
|
+
raw = json.loads("\n".join(self.data))
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
raise ProtocolError("Invalid JSON in SSE data") from None
|
|
57
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("type"), str):
|
|
58
|
+
raise ProtocolError("SSE data must contain a string type")
|
|
59
|
+
props = raw.get("properties", {})
|
|
60
|
+
if not isinstance(props, dict):
|
|
61
|
+
raise ProtocolError("SSE properties must be an object")
|
|
62
|
+
item = Event(raw["type"], props, raw, self.id, self.event)
|
|
63
|
+
self.data.clear()
|
|
64
|
+
self.event = None
|
|
65
|
+
self.size = 0
|
|
66
|
+
return item
|
|
67
|
+
if line.startswith(":"):
|
|
68
|
+
return None
|
|
69
|
+
key, _, val = line.partition(":")
|
|
70
|
+
if val.startswith(" "):
|
|
71
|
+
val = val[1:]
|
|
72
|
+
if key == "data":
|
|
73
|
+
self.data.append(val)
|
|
74
|
+
elif key == "event":
|
|
75
|
+
self.event = val
|
|
76
|
+
elif key == "id" and "\x00" not in val:
|
|
77
|
+
self.id = val
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def finish(self):
|
|
81
|
+
try:
|
|
82
|
+
self.decoder.decode(b"", final=True)
|
|
83
|
+
except UnicodeDecodeError:
|
|
84
|
+
raise ProtocolError("Truncated UTF-8 SSE stream") from None
|
|
85
|
+
if self.line or self.data:
|
|
86
|
+
raise ProtocolError("Incomplete SSE frame at EOF")
|
dumate/conversation.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Portable conversation projection shared by backend and notebook consumers."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from contextlib import contextmanager, asynccontextmanager
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConversationState:
|
|
7
|
+
def __init__(self, session_id):
|
|
8
|
+
self.session_id = session_id
|
|
9
|
+
self.messages = {}
|
|
10
|
+
self.parts = {}
|
|
11
|
+
self.status = "unknown"
|
|
12
|
+
self.revision = 0
|
|
13
|
+
self._pending = {}
|
|
14
|
+
|
|
15
|
+
def apply(self, event):
|
|
16
|
+
if event.session_id and event.session_id != self.session_id:
|
|
17
|
+
return
|
|
18
|
+
p = event.properties
|
|
19
|
+
if event.type == "message.updated":
|
|
20
|
+
info = p.get("info", {})
|
|
21
|
+
if info.get("id"):
|
|
22
|
+
self.messages[info["id"]] = deepcopy(info)
|
|
23
|
+
elif event.type == "message.part.updated":
|
|
24
|
+
part = p.get("part", {})
|
|
25
|
+
if part.get("id"):
|
|
26
|
+
self.parts[part["id"]] = deepcopy(part)
|
|
27
|
+
self._pending.pop(part["id"], None)
|
|
28
|
+
elif event.type == "message.part.delta":
|
|
29
|
+
pid, field, delta = p.get("partID"), p.get("field"), p.get("delta")
|
|
30
|
+
if pid and field == "text" and isinstance(delta, str):
|
|
31
|
+
if pid in self.parts:
|
|
32
|
+
self.parts[pid][field] = self.parts[pid].get(field, "") + delta
|
|
33
|
+
else:
|
|
34
|
+
self._pending[pid] = True
|
|
35
|
+
elif event.type == "session.status":
|
|
36
|
+
self.status = p.get("status", {}).get("type", "unknown")
|
|
37
|
+
elif event.type == "session.idle":
|
|
38
|
+
self.status = "idle"
|
|
39
|
+
elif event.type == "session.error":
|
|
40
|
+
self.status = "error"
|
|
41
|
+
self.revision += 1
|
|
42
|
+
|
|
43
|
+
def restore(self, history):
|
|
44
|
+
from ._common import history_messages
|
|
45
|
+
self.messages.clear()
|
|
46
|
+
self.parts.clear()
|
|
47
|
+
self._pending.clear()
|
|
48
|
+
for row in history_messages(history):
|
|
49
|
+
info = row["info"]
|
|
50
|
+
if info.get("sessionID") not in (None, self.session_id):
|
|
51
|
+
continue
|
|
52
|
+
self.messages[info["id"]] = deepcopy(info)
|
|
53
|
+
for part in row["parts"]:
|
|
54
|
+
if part.get("id"):
|
|
55
|
+
part = deepcopy(part)
|
|
56
|
+
part.setdefault("messageID", info["id"])
|
|
57
|
+
self.parts[part["id"]] = part
|
|
58
|
+
self.revision += 1
|
|
59
|
+
|
|
60
|
+
def snapshot(self):
|
|
61
|
+
rows = []
|
|
62
|
+
for mid, info in self.messages.items():
|
|
63
|
+
rows.append({"id": mid, "role": info.get("role"),
|
|
64
|
+
"finish": info.get("finish"), "error": deepcopy(info.get("error")),
|
|
65
|
+
"parts": [deepcopy(p) for p in self.parts.values() if p.get("messageID") == mid]})
|
|
66
|
+
return {"schema_version": 1, "session_id": self.session_id,
|
|
67
|
+
"revision": self.revision, "status": self.status,
|
|
68
|
+
"needs_history": bool(self._pending), "messages": rows}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Conversations:
|
|
72
|
+
def __init__(self, client):
|
|
73
|
+
self.client = client
|
|
74
|
+
|
|
75
|
+
def create(self, *, agent_id=None, name="Assistant", skills=None, vault_ids=None, mcp_servers=None):
|
|
76
|
+
agent = None
|
|
77
|
+
if agent_id is None:
|
|
78
|
+
agent = self.client.agents.create(name=name)
|
|
79
|
+
agent_id = agent["id"]
|
|
80
|
+
try:
|
|
81
|
+
session = self.client.sessions.create(agent_id=agent_id, skills=skills,
|
|
82
|
+
vault_ids=vault_ids, mcp_servers=mcp_servers)
|
|
83
|
+
except Exception as error:
|
|
84
|
+
error.created_agent_id = agent_id if agent else None
|
|
85
|
+
raise
|
|
86
|
+
return {"agent_id": agent_id, "session_id": session["id"], "created_agent": agent is not None}
|
|
87
|
+
|
|
88
|
+
def send(self, session_id, *, message, file_ids=None):
|
|
89
|
+
"""Send one message without subscribing to events."""
|
|
90
|
+
return self.client.sessions.send(session_id, message=message, file_ids=file_ids)
|
|
91
|
+
|
|
92
|
+
def run(self, session_id, *, message, file_ids=None, timeout=300, poll_interval=2.0):
|
|
93
|
+
"""Send once and return the correlated final result."""
|
|
94
|
+
return self.client.runs.run_and_wait(session_id=session_id, message=message,
|
|
95
|
+
file_ids=file_ids, timeout=timeout,
|
|
96
|
+
poll_interval=poll_interval)
|
|
97
|
+
|
|
98
|
+
def save(self, snapshot, path):
|
|
99
|
+
"""Persist a JSON-serializable snapshot for frontend reload or audit."""
|
|
100
|
+
import json
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
target = Path(path)
|
|
103
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
target.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
105
|
+
return target
|
|
106
|
+
|
|
107
|
+
def history(self, session_id):
|
|
108
|
+
state = ConversationState(session_id)
|
|
109
|
+
state.restore(self.client.sessions.history(session_id))
|
|
110
|
+
return state.snapshot()
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def stream(self, session_id, *, message=None, file_ids=None):
|
|
114
|
+
state = ConversationState(session_id)
|
|
115
|
+
state.restore(self.client.sessions.history(session_id))
|
|
116
|
+
with self.client.sessions.stream(session_id, message=message, file_ids=file_ids) as events:
|
|
117
|
+
def updates():
|
|
118
|
+
try:
|
|
119
|
+
for event in events:
|
|
120
|
+
state.apply(event)
|
|
121
|
+
yield state.snapshot()
|
|
122
|
+
if state.status in ("idle", "error"):
|
|
123
|
+
break
|
|
124
|
+
state.restore(self.client.sessions.history(session_id))
|
|
125
|
+
yield state.snapshot()
|
|
126
|
+
except Exception:
|
|
127
|
+
state.status = "interrupted"
|
|
128
|
+
raise
|
|
129
|
+
yield updates()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AsyncConversations:
|
|
133
|
+
def __init__(self, client):
|
|
134
|
+
self.client = client
|
|
135
|
+
|
|
136
|
+
async def create(self, *, agent_id=None, name="Assistant", skills=None, vault_ids=None, mcp_servers=None):
|
|
137
|
+
agent = None
|
|
138
|
+
if agent_id is None:
|
|
139
|
+
agent = await self.client.agents.create(name=name)
|
|
140
|
+
agent_id = agent["id"]
|
|
141
|
+
try:
|
|
142
|
+
session = await self.client.sessions.create(agent_id=agent_id, skills=skills,
|
|
143
|
+
vault_ids=vault_ids, mcp_servers=mcp_servers)
|
|
144
|
+
except Exception as error:
|
|
145
|
+
error.created_agent_id = agent_id if agent else None
|
|
146
|
+
raise
|
|
147
|
+
return {"agent_id": agent_id, "session_id": session["id"], "created_agent": agent is not None}
|
|
148
|
+
|
|
149
|
+
async def send(self, session_id, *, message, file_ids=None):
|
|
150
|
+
return await self.client.sessions.send(session_id, message=message, file_ids=file_ids)
|
|
151
|
+
|
|
152
|
+
async def run(self, session_id, *, message, file_ids=None, timeout=300, poll_interval=2.0):
|
|
153
|
+
return await self.client.runs.run_and_wait(session_id=session_id, message=message,
|
|
154
|
+
file_ids=file_ids, timeout=timeout,
|
|
155
|
+
poll_interval=poll_interval)
|
|
156
|
+
|
|
157
|
+
async def save(self, snapshot, path):
|
|
158
|
+
import json
|
|
159
|
+
from pathlib import Path
|
|
160
|
+
target = Path(path)
|
|
161
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
target.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
163
|
+
return target
|
|
164
|
+
|
|
165
|
+
async def history(self, session_id):
|
|
166
|
+
state = ConversationState(session_id)
|
|
167
|
+
state.restore(await self.client.sessions.history(session_id))
|
|
168
|
+
return state.snapshot()
|
|
169
|
+
|
|
170
|
+
@asynccontextmanager
|
|
171
|
+
async def stream(self, session_id, *, message=None, file_ids=None):
|
|
172
|
+
state = ConversationState(session_id)
|
|
173
|
+
state.restore(await self.client.sessions.history(session_id))
|
|
174
|
+
async with self.client.sessions.stream(session_id, message=message, file_ids=file_ids) as events:
|
|
175
|
+
async def updates():
|
|
176
|
+
async for event in events:
|
|
177
|
+
state.apply(event)
|
|
178
|
+
yield state.snapshot()
|
|
179
|
+
if state.status in ("idle", "error"):
|
|
180
|
+
break
|
|
181
|
+
state.restore(await self.client.sessions.history(session_id))
|
|
182
|
+
yield state.snapshot()
|
|
183
|
+
yield updates()
|
dumate/errors.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Errors deliberately exclude request bodies, credentials and signed URLs."""
|
|
2
|
+
|
|
3
|
+
class DuMateError(Exception):
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
class APIError(DuMateError):
|
|
7
|
+
def __init__(self, status_code: int, *, request_id: str | None = None):
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
self.request_id = request_id
|
|
10
|
+
super().__init__(f"DuMate HTTP {status_code}")
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(APIError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
class PermissionDeniedError(APIError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
class NotFoundError(APIError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
class ConflictError(APIError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
class RateLimitError(APIError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class ConnectionError(DuMateError):
|
|
28
|
+
def __init__(self, *, outcome_unknown: bool):
|
|
29
|
+
self.outcome_unknown = outcome_unknown
|
|
30
|
+
super().__init__("Request failed; write outcome is unknown, do not blindly resubmit."
|
|
31
|
+
if outcome_unknown else "DuMate connection failed.")
|
|
32
|
+
|
|
33
|
+
class ProtocolError(DuMateError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
class PaginationError(ProtocolError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
class StreamInterruptedError(DuMateError):
|
|
40
|
+
def __init__(self, session_id: str):
|
|
41
|
+
self.session_id = session_id
|
|
42
|
+
super().__init__("Event stream interrupted. Query session history before deciding next action.")
|
|
43
|
+
|
|
44
|
+
class RunTimeoutError(DuMateError):
|
|
45
|
+
def __init__(self, session_id: str):
|
|
46
|
+
self.session_id = session_id
|
|
47
|
+
super().__init__("Wait deadline reached; the remote task has NOT been cancelled.")
|
|
48
|
+
|
|
49
|
+
class RunFailedError(DuMateError):
|
|
50
|
+
def __init__(self, session_id: str):
|
|
51
|
+
self.session_id = session_id
|
|
52
|
+
super().__init__("Remote session reported an error; inspect authorized session history.")
|
|
53
|
+
|
|
54
|
+
class DownloadError(DuMateError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
class ArtifactNotReadyError(DownloadError):
|
|
58
|
+
pass
|
dumate/notebook.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Optional IPython adapter. Values are escaped before rendering."""
|
|
2
|
+
import json
|
|
3
|
+
from html import escape
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def render(snapshot):
|
|
7
|
+
blocks = ["<div style='font:14px/1.6 system-ui;max-width:960px'>",
|
|
8
|
+
"<div>" + escape(snapshot["status"]) + "</div>"]
|
|
9
|
+
for message in snapshot["messages"]:
|
|
10
|
+
if message["role"] != "assistant":
|
|
11
|
+
continue
|
|
12
|
+
for part in message["parts"]:
|
|
13
|
+
kind = part.get("type")
|
|
14
|
+
if kind == "text":
|
|
15
|
+
blocks.append("<div style='white-space:pre-wrap;margin:12px 0'>" + escape(part.get("text", "")) + "</div>")
|
|
16
|
+
elif kind == "reasoning":
|
|
17
|
+
blocks.append("<details><summary>思考</summary><pre style='white-space:pre-wrap'>" + escape(part.get("text", "")) + "</pre></details>")
|
|
18
|
+
elif kind == "tool":
|
|
19
|
+
state = part.get("state") or {}
|
|
20
|
+
blocks.append("<details open><summary>" + escape(str(part.get("tool", "工具"))) + " · " + escape(str(state.get("status", ""))) + "</summary>")
|
|
21
|
+
for key in ("input", "output", "error"):
|
|
22
|
+
if key in state:
|
|
23
|
+
value = state[key]
|
|
24
|
+
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2)
|
|
25
|
+
blocks.append("<b>" + key + "</b><pre style='white-space:pre-wrap;max-height:300px;overflow:auto'>" + escape(text) + "</pre>")
|
|
26
|
+
blocks.append("</details>")
|
|
27
|
+
return "".join(blocks) + "</div>"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Notebook:
|
|
31
|
+
def __init__(self, client):
|
|
32
|
+
self.client = client
|
|
33
|
+
|
|
34
|
+
def stream(self, session_id, *, message, file_ids=None):
|
|
35
|
+
try:
|
|
36
|
+
from IPython.display import HTML, display
|
|
37
|
+
except ImportError:
|
|
38
|
+
raise ImportError('Install dumate-agent-sdk[notebook] to use notebook.stream') from None
|
|
39
|
+
panel = display(HTML("连接中…"), display_id=True)
|
|
40
|
+
final = None
|
|
41
|
+
try:
|
|
42
|
+
with self.client.conversations.stream(session_id, message=message, file_ids=file_ids) as updates:
|
|
43
|
+
for final in updates:
|
|
44
|
+
panel.update(HTML(render(final)))
|
|
45
|
+
except Exception:
|
|
46
|
+
panel.update(HTML((render(final) if final else "") + "<p>连接或执行异常。请查询历史,勿重复发送。</p>"))
|
|
47
|
+
raise
|
|
48
|
+
return final
|
dumate/py.typed
ADDED
|
File without changes
|