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.
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