millwork 0.1.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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
millwork/chat.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Chat: messages the session owns, a turn, and the reply handle that streams.
|
|
2
|
+
|
|
3
|
+
On the wire a reply is the AI SDK UI message stream: ``text-start``,
|
|
4
|
+
``text-delta``, ``tool-input-available`` and so on, one chunk per
|
|
5
|
+
``Stream`` message addressed to the Chat node. The bundle parses that into
|
|
6
|
+
message parts and renders them. Between turns the finished messages travel
|
|
7
|
+
as a prop; the in-flight message is the stream's alone.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import logging
|
|
15
|
+
import secrets
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import AsyncIterator, Sequence
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
from millwork.files import FileRef
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from millwork.nodes import Component
|
|
26
|
+
from millwork.session import Session
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("millwork.chat")
|
|
29
|
+
|
|
30
|
+
Role = Literal["user", "assistant", "system"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UIMessage(BaseModel):
|
|
34
|
+
"""The AI SDK message shape: a role and a list of parts."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="allow")
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
role: Role
|
|
40
|
+
parts: list[dict[str, Any]] = Field(default_factory=list)
|
|
41
|
+
metadata: dict[str, Any] | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def text(self) -> str:
|
|
45
|
+
return "".join(p.get("text", "") for p in self.parts if p.get("type") == "text")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Thread:
|
|
49
|
+
"""One Chat node's messages, kept by the session under the node's key."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.messages: list[UIMessage] = []
|
|
53
|
+
self.inflight: UIMessage | None = None
|
|
54
|
+
self.turn_task: asyncio.Task[Any] | None = None
|
|
55
|
+
self.inline: dict[str, Component] = {}
|
|
56
|
+
self.suggestions: list[str] = []
|
|
57
|
+
# The most messages the session keeps for this thread; the oldest go.
|
|
58
|
+
# The Chat's ``max_messages`` sets it, so a thread never outgrows what
|
|
59
|
+
# the app asked for, whatever a visitor sends.
|
|
60
|
+
self.limit = 200
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def busy(self) -> bool:
|
|
64
|
+
"""From the moment a turn's handler starts until it ends, not only while it streams."""
|
|
65
|
+
return self.inflight is not None or self.turn_task is not None
|
|
66
|
+
|
|
67
|
+
def clear(self) -> None:
|
|
68
|
+
self.messages.clear()
|
|
69
|
+
self.inline.clear()
|
|
70
|
+
self.suggestions.clear()
|
|
71
|
+
|
|
72
|
+
def load(self, messages: Sequence[UIMessage | dict[str, Any]]) -> None:
|
|
73
|
+
self.messages = [m if isinstance(m, UIMessage) else UIMessage(**m) for m in messages]
|
|
74
|
+
self._trim()
|
|
75
|
+
|
|
76
|
+
def append(self, message: UIMessage) -> None:
|
|
77
|
+
self.messages.append(message)
|
|
78
|
+
self._trim()
|
|
79
|
+
|
|
80
|
+
def _trim(self) -> None:
|
|
81
|
+
extra = len(self.messages) - self.limit
|
|
82
|
+
if extra > 0:
|
|
83
|
+
del self.messages[:extra]
|
|
84
|
+
self._sweep_inline()
|
|
85
|
+
|
|
86
|
+
def find(self, message_id: str) -> int | None:
|
|
87
|
+
return next((i for i, m in enumerate(self.messages) if m.id == message_id), None)
|
|
88
|
+
|
|
89
|
+
def truncate(self, index: int) -> None:
|
|
90
|
+
"""Drop the messages from ``index`` on, and the inline components only they used."""
|
|
91
|
+
del self.messages[index:]
|
|
92
|
+
self._sweep_inline()
|
|
93
|
+
self.suggestions.clear()
|
|
94
|
+
|
|
95
|
+
def _sweep_inline(self) -> None:
|
|
96
|
+
"""Drop the inline components no remaining message uses."""
|
|
97
|
+
kept = {
|
|
98
|
+
p.get("data", {}).get("slot")
|
|
99
|
+
for m in self.messages
|
|
100
|
+
for p in m.parts
|
|
101
|
+
if p.get("type") == "data-component"
|
|
102
|
+
}
|
|
103
|
+
for slot in [s for s in self.inline if s not in kept]:
|
|
104
|
+
del self.inline[slot]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ChatTurn:
|
|
108
|
+
"""What ``on_message`` receives: the user's message and a way to reply."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
session: Session[Any],
|
|
113
|
+
node_id: str,
|
|
114
|
+
thread: Thread,
|
|
115
|
+
message: UIMessage,
|
|
116
|
+
files: list[FileRef],
|
|
117
|
+
command: str | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.session = session
|
|
120
|
+
self.node_id = node_id
|
|
121
|
+
self.thread = thread
|
|
122
|
+
self.message = message
|
|
123
|
+
self.files = files
|
|
124
|
+
self.command = command
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def text(self) -> str:
|
|
128
|
+
return self.message.text
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def history(self) -> list[UIMessage]:
|
|
132
|
+
"""Every message before this turn's, oldest first."""
|
|
133
|
+
return [m for m in self.thread.messages if m.id != self.message.id]
|
|
134
|
+
|
|
135
|
+
@contextlib.asynccontextmanager
|
|
136
|
+
async def reply(self) -> AsyncIterator[Reply]:
|
|
137
|
+
reply = Reply(self.session, self.node_id, self.thread)
|
|
138
|
+
await reply._start()
|
|
139
|
+
try:
|
|
140
|
+
yield reply
|
|
141
|
+
except asyncio.CancelledError:
|
|
142
|
+
await reply._finish("stopped")
|
|
143
|
+
raise
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
log.exception("chat turn failed")
|
|
146
|
+
await reply.error(str(exc) or type(exc).__name__)
|
|
147
|
+
await reply._finish("error")
|
|
148
|
+
raise
|
|
149
|
+
else:
|
|
150
|
+
await reply._finish("stop")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Reply:
|
|
154
|
+
"""Streams one assistant message. Each call is a chunk on the wire and a part in the message."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, session: Session[Any], node_id: str, thread: Thread) -> None:
|
|
157
|
+
self._s = session
|
|
158
|
+
self._node = node_id
|
|
159
|
+
self._thread = thread
|
|
160
|
+
# When the reply began and how long it took, kept in the message's metadata
|
|
161
|
+
# so the time shows on a reload too; the tab reads them when asked to.
|
|
162
|
+
self.message = UIMessage(
|
|
163
|
+
id=secrets.token_urlsafe(8), role="assistant", metadata={"at": time.time()}
|
|
164
|
+
)
|
|
165
|
+
self._text_id: str | None = None
|
|
166
|
+
self._finished = False
|
|
167
|
+
self._started = time.monotonic()
|
|
168
|
+
self._tool_started: dict[str, float] = {}
|
|
169
|
+
|
|
170
|
+
async def _send(self, chunk: dict[str, Any]) -> None:
|
|
171
|
+
await self._s.stream(self._node, chunk)
|
|
172
|
+
|
|
173
|
+
async def _start(self) -> None:
|
|
174
|
+
self._thread.inflight = self.message
|
|
175
|
+
self._thread.suggestions = []
|
|
176
|
+
self._s.refresh()
|
|
177
|
+
start = {"type": "start", "messageId": self.message.id}
|
|
178
|
+
await self._send({**start, "messageMetadata": self.message.metadata})
|
|
179
|
+
await self._send({"type": "start-step"})
|
|
180
|
+
|
|
181
|
+
async def _finish(self, reason: str) -> None:
|
|
182
|
+
if self._finished:
|
|
183
|
+
return
|
|
184
|
+
self._finished = True
|
|
185
|
+
await self._end_text()
|
|
186
|
+
took = round(time.monotonic() - self._started, 3)
|
|
187
|
+
self.message.metadata = {**(self.message.metadata or {}), "took": took}
|
|
188
|
+
await self._send({"type": "message-metadata", "messageMetadata": {"took": took}})
|
|
189
|
+
await self._send({"type": "finish-step"})
|
|
190
|
+
await self._send({"type": "finish", "finishReason": reason})
|
|
191
|
+
self._thread.append(self.message)
|
|
192
|
+
self._thread.inflight = None
|
|
193
|
+
self._s.refresh()
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------- parts
|
|
196
|
+
|
|
197
|
+
async def _end_text(self) -> None:
|
|
198
|
+
if self._text_id is not None:
|
|
199
|
+
await self._send({"type": "text-end", "id": self._text_id})
|
|
200
|
+
self._text_id = None
|
|
201
|
+
|
|
202
|
+
async def text(self, chunk: str) -> None:
|
|
203
|
+
"""Append text to the reply; consecutive calls extend one text part."""
|
|
204
|
+
if not chunk:
|
|
205
|
+
return
|
|
206
|
+
if self._text_id is None:
|
|
207
|
+
self._text_id = secrets.token_urlsafe(6)
|
|
208
|
+
self.message.parts.append({"type": "text", "text": "", "state": "streaming"})
|
|
209
|
+
await self._send({"type": "text-start", "id": self._text_id})
|
|
210
|
+
self.message.parts[-1]["text"] += chunk
|
|
211
|
+
await self._send({"type": "text-delta", "id": self._text_id, "delta": chunk})
|
|
212
|
+
|
|
213
|
+
@contextlib.asynccontextmanager
|
|
214
|
+
async def reasoning(self) -> AsyncIterator[ReasoningHandle]:
|
|
215
|
+
await self._end_text()
|
|
216
|
+
rid = secrets.token_urlsafe(6)
|
|
217
|
+
part: dict[str, Any] = {"type": "reasoning", "text": "", "state": "streaming"}
|
|
218
|
+
self.message.parts.append(part)
|
|
219
|
+
await self._send({"type": "reasoning-start", "id": rid})
|
|
220
|
+
try:
|
|
221
|
+
yield ReasoningHandle(self, rid, part)
|
|
222
|
+
finally:
|
|
223
|
+
part["state"] = "done"
|
|
224
|
+
await self._send({"type": "reasoning-end", "id": rid})
|
|
225
|
+
|
|
226
|
+
async def tool(self, name: str, input: Any = None) -> ToolHandle:
|
|
227
|
+
"""A tool call: its input now, its output or error through the handle."""
|
|
228
|
+
await self._end_text()
|
|
229
|
+
call_id = secrets.token_urlsafe(6)
|
|
230
|
+
part: dict[str, Any] = {
|
|
231
|
+
"type": f"tool-{name}",
|
|
232
|
+
"toolCallId": call_id,
|
|
233
|
+
"state": "input-available",
|
|
234
|
+
"input": input,
|
|
235
|
+
}
|
|
236
|
+
self.message.parts.append(part)
|
|
237
|
+
self._tool_started[call_id] = time.monotonic()
|
|
238
|
+
await self._send(
|
|
239
|
+
{
|
|
240
|
+
"type": "tool-input-available",
|
|
241
|
+
"toolCallId": call_id,
|
|
242
|
+
"toolName": name,
|
|
243
|
+
"input": input,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
return ToolHandle(self, call_id, part)
|
|
247
|
+
|
|
248
|
+
def _took(self, call_id: str) -> float:
|
|
249
|
+
started = self._tool_started.pop(call_id, None)
|
|
250
|
+
return round(time.monotonic() - started, 3) if started is not None else 0.0
|
|
251
|
+
|
|
252
|
+
@contextlib.asynccontextmanager
|
|
253
|
+
async def step(self, label: str) -> AsyncIterator[ToolHandle]:
|
|
254
|
+
"""A labelled step in the transcript: a tool call without a tool."""
|
|
255
|
+
handle = await self.tool("step", {"label": label})
|
|
256
|
+
try:
|
|
257
|
+
yield handle
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
await handle.error(str(exc) or type(exc).__name__)
|
|
260
|
+
raise
|
|
261
|
+
else:
|
|
262
|
+
if handle.part["state"] == "input-available":
|
|
263
|
+
await handle.output(None)
|
|
264
|
+
|
|
265
|
+
async def source(
|
|
266
|
+
self, title: str, url: str | None = None, snippet: str | None = None, **meta: Any
|
|
267
|
+
) -> None:
|
|
268
|
+
await self._end_text()
|
|
269
|
+
sid = secrets.token_urlsafe(6)
|
|
270
|
+
if url:
|
|
271
|
+
part = {
|
|
272
|
+
"type": "source-url",
|
|
273
|
+
"sourceId": sid,
|
|
274
|
+
"url": url,
|
|
275
|
+
"title": title,
|
|
276
|
+
**({"snippet": snippet} if snippet else {}),
|
|
277
|
+
**meta,
|
|
278
|
+
}
|
|
279
|
+
self.message.parts.append(part)
|
|
280
|
+
await self._send(part)
|
|
281
|
+
else:
|
|
282
|
+
part = {
|
|
283
|
+
"type": "source-document",
|
|
284
|
+
"sourceId": sid,
|
|
285
|
+
"mediaType": meta.pop("media_type", "text/plain"),
|
|
286
|
+
"title": title,
|
|
287
|
+
**({"snippet": snippet} if snippet else {}),
|
|
288
|
+
**meta,
|
|
289
|
+
}
|
|
290
|
+
self.message.parts.append(part)
|
|
291
|
+
await self._send(part)
|
|
292
|
+
|
|
293
|
+
async def file(
|
|
294
|
+
self, content: FileRef | bytes | str, name: str | None = None, mime: str | None = None
|
|
295
|
+
) -> FileRef:
|
|
296
|
+
"""A file in the reply, stored on the session; the tab fetches it by id."""
|
|
297
|
+
from millwork.files import content_to_file
|
|
298
|
+
|
|
299
|
+
ref = (
|
|
300
|
+
content
|
|
301
|
+
if isinstance(content, FileRef)
|
|
302
|
+
else content_to_file(self._s, content, name or "file", mime)
|
|
303
|
+
)
|
|
304
|
+
await self._end_text()
|
|
305
|
+
part = {
|
|
306
|
+
"type": "file",
|
|
307
|
+
"mediaType": ref.mime,
|
|
308
|
+
"url": "",
|
|
309
|
+
"filename": ref.name,
|
|
310
|
+
"fileId": ref.id,
|
|
311
|
+
}
|
|
312
|
+
self.message.parts.append(part)
|
|
313
|
+
await self._send(part)
|
|
314
|
+
return ref
|
|
315
|
+
|
|
316
|
+
async def image(
|
|
317
|
+
self, content: FileRef | bytes, name: str = "image.png", mime: str = "image/png"
|
|
318
|
+
) -> FileRef:
|
|
319
|
+
return await self.file(content, name, mime)
|
|
320
|
+
|
|
321
|
+
async def status(self, text: str) -> None:
|
|
322
|
+
"""A transient marker under the message while it streams."""
|
|
323
|
+
await self._send({"type": "data-status", "data": {"text": text}, "transient": True})
|
|
324
|
+
|
|
325
|
+
async def suggestions(self, items: list[str]) -> None:
|
|
326
|
+
self._thread.suggestions = list(items)
|
|
327
|
+
await self._send(
|
|
328
|
+
{"type": "data-suggestions", "data": {"items": list(items)}, "transient": True}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
async def data(self, kind: str, payload: Any, *, id: str | None = None) -> None:
|
|
332
|
+
"""A custom part rendered by the app's ``components/<kind>.jsx``."""
|
|
333
|
+
await self._end_text()
|
|
334
|
+
part: dict[str, Any] = {"type": f"data-{kind}", "data": payload}
|
|
335
|
+
if id:
|
|
336
|
+
part["id"] = id
|
|
337
|
+
for i, existing in enumerate(self.message.parts):
|
|
338
|
+
if existing.get("type") == part["type"] and existing.get("id") == id:
|
|
339
|
+
self.message.parts[i] = part
|
|
340
|
+
break
|
|
341
|
+
else:
|
|
342
|
+
self.message.parts.append(part)
|
|
343
|
+
else:
|
|
344
|
+
self.message.parts.append(part)
|
|
345
|
+
await self._send(part)
|
|
346
|
+
|
|
347
|
+
async def component(self, component: Component) -> str:
|
|
348
|
+
"""A catalogue (or custom) component inside the message, patched like any node."""
|
|
349
|
+
await self._end_text()
|
|
350
|
+
part_id = secrets.token_urlsafe(6)
|
|
351
|
+
self._thread.inline[part_id] = component
|
|
352
|
+
part = {"type": "data-component", "id": part_id, "data": {"slot": part_id}}
|
|
353
|
+
self.message.parts.append(part)
|
|
354
|
+
self._s.refresh()
|
|
355
|
+
await self._send(part)
|
|
356
|
+
return part_id
|
|
357
|
+
|
|
358
|
+
async def error(self, text: str) -> None:
|
|
359
|
+
await self._send({"type": "error", "errorText": text})
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class ReasoningHandle:
|
|
363
|
+
def __init__(self, reply: Reply, rid: str, part: dict[str, Any]) -> None:
|
|
364
|
+
self._reply = reply
|
|
365
|
+
self._id = rid
|
|
366
|
+
self._part = part
|
|
367
|
+
|
|
368
|
+
async def text(self, chunk: str) -> None:
|
|
369
|
+
if not chunk:
|
|
370
|
+
return
|
|
371
|
+
self._part["text"] += chunk
|
|
372
|
+
await self._reply._send({"type": "reasoning-delta", "id": self._id, "delta": chunk})
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class ToolHandle:
|
|
376
|
+
def __init__(self, reply: Reply, call_id: str, part: dict[str, Any]) -> None:
|
|
377
|
+
self._reply = reply
|
|
378
|
+
self.id = call_id
|
|
379
|
+
self.part = part
|
|
380
|
+
|
|
381
|
+
async def output(self, result: Any) -> None:
|
|
382
|
+
took = self._reply._took(self.id)
|
|
383
|
+
self.part["state"] = "output-available"
|
|
384
|
+
self.part["output"] = result
|
|
385
|
+
self.part["took"] = took
|
|
386
|
+
await self._reply._send(
|
|
387
|
+
{"type": "tool-output-available", "toolCallId": self.id, "output": result, "took": took}
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
async def error(self, text: str) -> None:
|
|
391
|
+
took = self._reply._took(self.id)
|
|
392
|
+
self.part["state"] = "output-error"
|
|
393
|
+
self.part["errorText"] = text
|
|
394
|
+
self.part["took"] = took
|
|
395
|
+
await self._reply._send(
|
|
396
|
+
{"type": "tool-output-error", "toolCallId": self.id, "errorText": text, "took": took}
|
|
397
|
+
)
|