zettcode-sdk 0.3.4__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.
- zettcode/daemon/__init__.py +17 -0
- zettcode/daemon/__main__.py +10 -0
- zettcode/daemon/cli.py +693 -0
- zettcode/daemon/companions.py +63 -0
- zettcode/daemon/process.py +559 -0
- zettcode/daemon/run.py +678 -0
- zettcode/daemon/state.py +356 -0
- zettcode/doctor.py +167 -0
- zettcode/mentions.py +320 -0
- zettcode/sdk/__init__.py +195 -0
- zettcode/sdk/builtins.py +608 -0
- zettcode/sdk/client.py +1810 -0
- zettcode/sdk/commands.py +9 -0
- zettcode/sdk/daemons.py +244 -0
- zettcode/sdk/errors.py +132 -0
- zettcode/sdk/events.py +434 -0
- zettcode/sdk/extensions/__init__.py +6 -0
- zettcode/sdk/extensions/mcp.py +276 -0
- zettcode/sdk/extensions/messaging.py +281 -0
- zettcode/sdk/handlers.py +126 -0
- zettcode/sdk/messenger.py +299 -0
- zettcode/sdk/models.py +156 -0
- zettcode/sdk/py.typed +0 -0
- zettcode/sdk/session.py +1212 -0
- zettcode/sdk/translation.py +384 -0
- zettcode/sdk/types.py +907 -0
- zettcode/table.py +95 -0
- zettcode_sdk-0.3.4.dist-info/METADATA +21 -0
- zettcode_sdk-0.3.4.dist-info/RECORD +30 -0
- zettcode_sdk-0.3.4.dist-info/WHEEL +4 -0
zettcode/sdk/session.py
ADDED
|
@@ -0,0 +1,1212 @@
|
|
|
1
|
+
"""One conversation, and the runs made in it.
|
|
2
|
+
|
|
3
|
+
A :class:`Session` is a handle on a stored conversation. :meth:`Session.prompt` sends a prompt and returns a
|
|
4
|
+
:class:`RunStream`, which is both of the things a program wants from a run. Await it and you get the result::
|
|
5
|
+
|
|
6
|
+
result = await session.prompt("fix the failing test")
|
|
7
|
+
print(result.text)
|
|
8
|
+
|
|
9
|
+
Iterate it and you get the events as they happen, which is how a program shows progress, answers a shell approval,
|
|
10
|
+
or answers a question the model asked::
|
|
11
|
+
|
|
12
|
+
async with session.prompt("fix the failing test") as run:
|
|
13
|
+
async for event in run:
|
|
14
|
+
if isinstance(event, TextDelta):
|
|
15
|
+
print(event.text, end="")
|
|
16
|
+
print(run.result.usage.total_tokens)
|
|
17
|
+
|
|
18
|
+
``stream`` and ``run`` are the same method under the names code written for other libraries tends to reach for.
|
|
19
|
+
|
|
20
|
+
A client has one run at a time, as the terminal application does, so a second run while one is in progress raises
|
|
21
|
+
:class:`~zettcode.sdk.errors.SessionBusyError` instead of interleaving two requests in one conversation.
|
|
22
|
+
|
|
23
|
+
This module imports nothing heavy at the top: the agent is loaded when a client is made, and the pieces that read its
|
|
24
|
+
messages are imported inside the functions that need them.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import inspect
|
|
31
|
+
from collections.abc import AsyncGenerator, Callable, Generator, Mapping, Sequence
|
|
32
|
+
from contextlib import aclosing
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from types import TracebackType
|
|
36
|
+
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
|
37
|
+
|
|
38
|
+
from .errors import (
|
|
39
|
+
NothingToDoError,
|
|
40
|
+
RunError,
|
|
41
|
+
RunTimeoutError,
|
|
42
|
+
SessionLockedError,
|
|
43
|
+
SessionNotFoundError,
|
|
44
|
+
ZettCodeError,
|
|
45
|
+
)
|
|
46
|
+
from .events import (
|
|
47
|
+
AnyEvent,
|
|
48
|
+
ApprovalRequested,
|
|
49
|
+
Finished,
|
|
50
|
+
ModelStopped,
|
|
51
|
+
QuestionAsked,
|
|
52
|
+
RawEvent,
|
|
53
|
+
TextDelta,
|
|
54
|
+
ToolFinished,
|
|
55
|
+
ToolStarted,
|
|
56
|
+
)
|
|
57
|
+
from .handlers import ApprovalHandler, QuestionHandler, apply_approval, apply_question
|
|
58
|
+
from .types import (
|
|
59
|
+
CacheMode,
|
|
60
|
+
Compaction,
|
|
61
|
+
ContextReport,
|
|
62
|
+
ContextShare,
|
|
63
|
+
Image,
|
|
64
|
+
IncomingMessage,
|
|
65
|
+
Message,
|
|
66
|
+
Relay,
|
|
67
|
+
ResumeInfo,
|
|
68
|
+
RunResult,
|
|
69
|
+
TaskInfo,
|
|
70
|
+
ToolCallInfo,
|
|
71
|
+
ToolRecord,
|
|
72
|
+
Usage,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
76
|
+
from zett_agent.messages import UserMessage
|
|
77
|
+
|
|
78
|
+
from zettcode.mentions import MentionRegistry
|
|
79
|
+
from zettcode.prompt import PromptPart
|
|
80
|
+
|
|
81
|
+
from .client import Client
|
|
82
|
+
|
|
83
|
+
__all__ = ["Prompt", "RunStream", "Session"]
|
|
84
|
+
|
|
85
|
+
#: What a prompt is: text, or text and images in the order they should be read.
|
|
86
|
+
Prompt: TypeAlias = "str | Sequence[str | Image]"
|
|
87
|
+
|
|
88
|
+
#: What may be called with each event of a run; a coroutine function is awaited.
|
|
89
|
+
EventCallback: TypeAlias = Callable[[AnyEvent], object]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_timeout(timeout: float | None) -> None:
|
|
93
|
+
"""Refuse a deadline that is not a positive number of seconds; ``None`` means no deadline."""
|
|
94
|
+
if timeout is None:
|
|
95
|
+
return
|
|
96
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not timeout > 0:
|
|
97
|
+
raise ValueError("timeout must be a positive number of seconds, or None")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# -- the prompt -----------------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _prompt_parts(
|
|
104
|
+
prompt: Prompt,
|
|
105
|
+
images: Sequence[Image] = (),
|
|
106
|
+
*,
|
|
107
|
+
mentions: MentionRegistry | None = None,
|
|
108
|
+
multimodal: bool = True,
|
|
109
|
+
model_name: str = "the active model",
|
|
110
|
+
) -> tuple[tuple[PromptPart, ...], str | None]:
|
|
111
|
+
"""Return the parts a prompt makes up, and the instructions its ``@`` references add.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
TypeError: A piece of the prompt is not text or an :class:`Image`.
|
|
115
|
+
ValueError: The prompt holds no text and no image.
|
|
116
|
+
ConfigurationError: The prompt carries an image and the active model does not accept images.
|
|
117
|
+
"""
|
|
118
|
+
from .errors import ConfigurationError
|
|
119
|
+
|
|
120
|
+
pieces: list[str | Image] = [prompt] if isinstance(prompt, str) else list(prompt)
|
|
121
|
+
pieces.extend(images)
|
|
122
|
+
parts: list[Any] = []
|
|
123
|
+
for piece in pieces:
|
|
124
|
+
if isinstance(piece, str):
|
|
125
|
+
parts.extend(_split_mentions(piece) if mentions is not None else [piece])
|
|
126
|
+
elif isinstance(piece, Image):
|
|
127
|
+
parts.append((bytes(piece.data), piece.media_type))
|
|
128
|
+
else:
|
|
129
|
+
raise TypeError(f"A prompt is text and images, not {type(piece).__name__}")
|
|
130
|
+
# A part is text, an image (a tuple), or a reference to something with text of its own; only the first is
|
|
131
|
+
# blank when it holds nothing but space.
|
|
132
|
+
has_text = any((isinstance(p, str) and p.strip()) or not isinstance(p, (str, tuple)) for p in parts)
|
|
133
|
+
has_image = any(isinstance(p, tuple) for p in parts)
|
|
134
|
+
if not has_text and not has_image:
|
|
135
|
+
raise ValueError("The prompt is empty")
|
|
136
|
+
if has_image and not multimodal:
|
|
137
|
+
raise ConfigurationError(
|
|
138
|
+
f"{model_name} does not accept images; choose a multimodal model with use_model() or drop the images"
|
|
139
|
+
)
|
|
140
|
+
if mentions is None:
|
|
141
|
+
return tuple(parts), None
|
|
142
|
+
from zettcode.prompt import mention_hint, mention_parts
|
|
143
|
+
|
|
144
|
+
resolved = mention_parts(parts, mentions)
|
|
145
|
+
return resolved, mention_hint(resolved, mentions)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _with_instructions(hint: str | None, instructions: str | None) -> str | None:
|
|
149
|
+
"""Return the instructions a turn carries: what ``@`` references added, then what the caller did.
|
|
150
|
+
|
|
151
|
+
They are one piece of text because the model reads them as one: both are things the reader's line did not say,
|
|
152
|
+
and both are kept out of what a restored session *shows* (the stored message carries the typed text as its
|
|
153
|
+
display, while the model keeps reading the instructions with that turn).
|
|
154
|
+
"""
|
|
155
|
+
if instructions is None or not instructions.strip():
|
|
156
|
+
return hint
|
|
157
|
+
said = instructions.strip()
|
|
158
|
+
return said if hint is None else f"{hint}\n\n{said}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _split_mentions(text: str) -> list[Any]:
|
|
162
|
+
"""Split one text run at its ``@`` tokens, keeping the order, as the terminal's composer does."""
|
|
163
|
+
from zettcode.mention import MENTION
|
|
164
|
+
from zettcode.prompt import MentionPart
|
|
165
|
+
|
|
166
|
+
split: list[Any] = []
|
|
167
|
+
cursor = 0
|
|
168
|
+
for match in MENTION.finditer(text):
|
|
169
|
+
if match.start() > cursor:
|
|
170
|
+
split.append(text[cursor : match.start()])
|
|
171
|
+
split.append(MentionPart(match.group(0)))
|
|
172
|
+
cursor = match.end()
|
|
173
|
+
if cursor < len(text):
|
|
174
|
+
split.append(text[cursor:])
|
|
175
|
+
return split
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# -- the result a stream builds as it goes --------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(slots=True)
|
|
182
|
+
class _Collector:
|
|
183
|
+
"""Accumulate the pieces of a :class:`RunResult` while the events go past."""
|
|
184
|
+
|
|
185
|
+
keep_events: bool = False
|
|
186
|
+
deltas: list[str] = field(default_factory=list)
|
|
187
|
+
final: str | None = None
|
|
188
|
+
tools: dict[str, ToolRecord] = field(default_factory=dict)
|
|
189
|
+
stop: str | None = None
|
|
190
|
+
stop_message: str | None = None
|
|
191
|
+
questions: list[str] = field(default_factory=list)
|
|
192
|
+
events: list[AnyEvent] = field(default_factory=list)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def text(self) -> str:
|
|
196
|
+
"""Return the final answer, or what was streamed when the run ended without one."""
|
|
197
|
+
return self.final if self.final else "".join(self.deltas)
|
|
198
|
+
|
|
199
|
+
def see(self, event: AnyEvent) -> None:
|
|
200
|
+
"""Fold one event in."""
|
|
201
|
+
if self.keep_events:
|
|
202
|
+
self.events.append(event)
|
|
203
|
+
if isinstance(event, TextDelta):
|
|
204
|
+
self.deltas.append(event.text)
|
|
205
|
+
elif isinstance(event, Finished):
|
|
206
|
+
self.final = event.text
|
|
207
|
+
elif isinstance(event, ToolStarted):
|
|
208
|
+
self.tools[event.call_id] = ToolRecord(event.call_id, event.name, event.arguments, "", ok=False)
|
|
209
|
+
elif isinstance(event, ToolFinished):
|
|
210
|
+
before = self.tools.get(event.call_id)
|
|
211
|
+
arguments = before.arguments if before is not None else {}
|
|
212
|
+
self.tools[event.call_id] = ToolRecord(
|
|
213
|
+
event.call_id, event.name, arguments, event.output, event.ok, event.skipped
|
|
214
|
+
)
|
|
215
|
+
elif isinstance(event, ModelStopped):
|
|
216
|
+
self.stop, self.stop_message = event.reason, event.message
|
|
217
|
+
elif isinstance(event, QuestionAsked):
|
|
218
|
+
self.questions.append(event.question)
|
|
219
|
+
|
|
220
|
+
def result(self, session_id: str, usage: Usage) -> RunResult:
|
|
221
|
+
"""Return the finished result."""
|
|
222
|
+
return RunResult(
|
|
223
|
+
text=self.text,
|
|
224
|
+
session_id=session_id,
|
|
225
|
+
usage=usage,
|
|
226
|
+
tools=tuple(self.tools.values()),
|
|
227
|
+
stop=self.stop,
|
|
228
|
+
stop_message=self.stop_message,
|
|
229
|
+
questions=tuple(self.questions),
|
|
230
|
+
events=tuple(self.events),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class _HandlerFailed(Exception):
|
|
235
|
+
"""A handler the program supplied raised; carried out of the stream untouched, not mistaken for a run failure."""
|
|
236
|
+
|
|
237
|
+
def __init__(self, error: Exception) -> None:
|
|
238
|
+
super().__init__(str(error))
|
|
239
|
+
self.error = error
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# -- one run --------------------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class RunStream:
|
|
246
|
+
"""One run: await it for the result, or iterate it for the events.
|
|
247
|
+
|
|
248
|
+
::
|
|
249
|
+
|
|
250
|
+
result = await session.prompt("explain this module") # wait for the end
|
|
251
|
+
async with session.prompt("explain this module") as run: # or watch it happen
|
|
252
|
+
async for event in run:
|
|
253
|
+
...
|
|
254
|
+
|
|
255
|
+
Use ``async with`` when you may stop early, so the run is closed even if you leave the loop: a run that is not
|
|
256
|
+
closed keeps the client busy until it is garbage collected. Awaiting closes it for you, and so does
|
|
257
|
+
:meth:`aclose`. You can also do both: iterate part of it, then ``await`` the rest.
|
|
258
|
+
|
|
259
|
+
Attributes:
|
|
260
|
+
result: The :class:`RunResult`, or ``None`` until the run has ended on its own. A run stopped early or by an
|
|
261
|
+
error has no result; :class:`~zettcode.sdk.errors.RunError` carries the text it had produced.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
session: Session,
|
|
267
|
+
source: Callable[[], Any],
|
|
268
|
+
*,
|
|
269
|
+
on_approval: ApprovalHandler | None,
|
|
270
|
+
on_question: QuestionHandler | None,
|
|
271
|
+
on_event: EventCallback | None = None,
|
|
272
|
+
timeout: float | None = None,
|
|
273
|
+
raw: bool = False,
|
|
274
|
+
keep_events: bool = False,
|
|
275
|
+
side: bool = False,
|
|
276
|
+
defer_answers: bool = False,
|
|
277
|
+
) -> None:
|
|
278
|
+
_check_timeout(timeout)
|
|
279
|
+
self._session = session
|
|
280
|
+
self._client = session._client
|
|
281
|
+
self._source = source
|
|
282
|
+
self._on_approval = on_approval
|
|
283
|
+
self._on_question = on_question
|
|
284
|
+
self._on_event = on_event
|
|
285
|
+
self._timeout = timeout
|
|
286
|
+
self._deadline = 0.0
|
|
287
|
+
self._raw = raw
|
|
288
|
+
self._side = side
|
|
289
|
+
self._defer = defer_answers
|
|
290
|
+
#: The approvals and questions a deferred run left open past their event; whatever is still open when the
|
|
291
|
+
#: run ends is denied or declined, so a suspended tool call never waits on a run that is over.
|
|
292
|
+
self._open: list[ApprovalRequested | QuestionAsked] = []
|
|
293
|
+
self._collector = _Collector(keep_events=keep_events)
|
|
294
|
+
self._baseline = Usage()
|
|
295
|
+
self._started = False
|
|
296
|
+
self._finished = False
|
|
297
|
+
self._closing = False
|
|
298
|
+
self._pending: asyncio.Task[Any] | None = None
|
|
299
|
+
self._gen = self._drive()
|
|
300
|
+
self.result: RunResult | None = None
|
|
301
|
+
|
|
302
|
+
# -- the iterator protocol --
|
|
303
|
+
def __aiter__(self) -> RunStream:
|
|
304
|
+
return self
|
|
305
|
+
|
|
306
|
+
async def __anext__(self) -> AnyEvent:
|
|
307
|
+
if self._finished and not self._started:
|
|
308
|
+
raise StopAsyncIteration
|
|
309
|
+
if not self._started:
|
|
310
|
+
await self._begin()
|
|
311
|
+
try:
|
|
312
|
+
event = await self._next()
|
|
313
|
+
except StopAsyncIteration:
|
|
314
|
+
self._complete()
|
|
315
|
+
raise
|
|
316
|
+
except BaseException:
|
|
317
|
+
self._release()
|
|
318
|
+
raise
|
|
319
|
+
self._client._stream_event(event)
|
|
320
|
+
if self._on_event is not None:
|
|
321
|
+
try:
|
|
322
|
+
outcome = self._on_event(event)
|
|
323
|
+
if inspect.isawaitable(outcome):
|
|
324
|
+
await outcome
|
|
325
|
+
except BaseException:
|
|
326
|
+
await self.aclose()
|
|
327
|
+
raise
|
|
328
|
+
return event
|
|
329
|
+
|
|
330
|
+
async def _next(self) -> AnyEvent:
|
|
331
|
+
"""Return the next event, within the run's deadline if it has one.
|
|
332
|
+
|
|
333
|
+
Each step runs in a task of the stream's own. That is what lets :meth:`aclose` stop a run that another task
|
|
334
|
+
is waiting on (a client closed under a reader, say) instead of finding the generator busy, and what lets a
|
|
335
|
+
deadline stop a step without cancelling the caller.
|
|
336
|
+
"""
|
|
337
|
+
if self._closing:
|
|
338
|
+
raise StopAsyncIteration
|
|
339
|
+
loop = asyncio.get_running_loop()
|
|
340
|
+
remaining = None if self._timeout is None else self._deadline - loop.time()
|
|
341
|
+
if remaining is not None and remaining <= 0:
|
|
342
|
+
await self.aclose()
|
|
343
|
+
raise RunTimeoutError(float(cast(float, self._timeout)), partial_text=self._collector.text)
|
|
344
|
+
step = self._pending = loop.create_task(self._step())
|
|
345
|
+
try:
|
|
346
|
+
await asyncio.wait({step}, timeout=remaining)
|
|
347
|
+
except BaseException:
|
|
348
|
+
# The caller was cancelled while waiting. The step is no one's now, so it ends with them.
|
|
349
|
+
await self._stop(step)
|
|
350
|
+
raise
|
|
351
|
+
finally:
|
|
352
|
+
self._pending = None
|
|
353
|
+
if not step.done():
|
|
354
|
+
await self._stop(step)
|
|
355
|
+
await self.aclose()
|
|
356
|
+
raise RunTimeoutError(float(cast(float, self._timeout)), partial_text=self._collector.text)
|
|
357
|
+
if step.cancelled():
|
|
358
|
+
if self._closing:
|
|
359
|
+
raise StopAsyncIteration # the run was closed under this reader; its events are over
|
|
360
|
+
raise asyncio.CancelledError
|
|
361
|
+
return step.result()
|
|
362
|
+
|
|
363
|
+
async def _step(self) -> AnyEvent:
|
|
364
|
+
return await self._gen.__anext__()
|
|
365
|
+
|
|
366
|
+
@staticmethod
|
|
367
|
+
async def _stop(step: asyncio.Task[Any]) -> None:
|
|
368
|
+
"""Cancel a step and wait for it to unwind, whatever it ends with."""
|
|
369
|
+
if not step.done():
|
|
370
|
+
step.cancel()
|
|
371
|
+
await asyncio.gather(step, return_exceptions=True)
|
|
372
|
+
|
|
373
|
+
async def aclose(self) -> None:
|
|
374
|
+
"""Stop the run, if it is still going, and give the client back. Safe to call twice.
|
|
375
|
+
|
|
376
|
+
Another task may be waiting for the next event of this very run; it is told the events are over.
|
|
377
|
+
"""
|
|
378
|
+
self._closing = True
|
|
379
|
+
self._release()
|
|
380
|
+
pending = self._pending
|
|
381
|
+
if pending is not None and pending is not asyncio.current_task():
|
|
382
|
+
await self._stop(pending)
|
|
383
|
+
await self._gen.aclose()
|
|
384
|
+
|
|
385
|
+
async def __aenter__(self) -> RunStream:
|
|
386
|
+
return self
|
|
387
|
+
|
|
388
|
+
async def __aexit__(
|
|
389
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
390
|
+
) -> None:
|
|
391
|
+
await self.aclose()
|
|
392
|
+
|
|
393
|
+
# -- awaiting --
|
|
394
|
+
def __await__(self) -> Generator[Any, None, RunResult]:
|
|
395
|
+
return self._wait().__await__()
|
|
396
|
+
|
|
397
|
+
async def _wait(self) -> RunResult:
|
|
398
|
+
"""Take the rest of the events, and return the result."""
|
|
399
|
+
async with self:
|
|
400
|
+
async for _ in self:
|
|
401
|
+
pass
|
|
402
|
+
if self.result is None:
|
|
403
|
+
raise RunError("The run ended without a result", partial_text=self._collector.text)
|
|
404
|
+
return self.result
|
|
405
|
+
|
|
406
|
+
# -- beginning and ending --
|
|
407
|
+
async def _begin(self) -> None:
|
|
408
|
+
"""Take the client for this run: the session is made current, and the busy slot is claimed."""
|
|
409
|
+
client = self._client
|
|
410
|
+
client._check_open()
|
|
411
|
+
client._claim(self)
|
|
412
|
+
self._started = True
|
|
413
|
+
try:
|
|
414
|
+
await self._session._activate(self)
|
|
415
|
+
self._baseline = self._session.usage
|
|
416
|
+
if self._timeout is not None:
|
|
417
|
+
self._deadline = asyncio.get_running_loop().time() + self._timeout
|
|
418
|
+
except BaseException:
|
|
419
|
+
self._release()
|
|
420
|
+
raise
|
|
421
|
+
|
|
422
|
+
def _release(self) -> None:
|
|
423
|
+
self._finished = True
|
|
424
|
+
self._client._release(self)
|
|
425
|
+
|
|
426
|
+
def _complete(self) -> None:
|
|
427
|
+
"""The source ran dry: build the result from what went past, then give the client back."""
|
|
428
|
+
if self.result is None:
|
|
429
|
+
self.result = self._collector.result(self._session.id, self._session.usage.since(self._baseline))
|
|
430
|
+
self._release()
|
|
431
|
+
|
|
432
|
+
# -- the run --
|
|
433
|
+
async def _drive(self) -> AsyncGenerator[AnyEvent, None]:
|
|
434
|
+
"""Pump the agent's events through ``translate``, answering the ones that wait on the program."""
|
|
435
|
+
from .translation import translate
|
|
436
|
+
|
|
437
|
+
agent = self._client._agent
|
|
438
|
+
bridge = self._client._bridge
|
|
439
|
+
runtime = agent.runtime
|
|
440
|
+
# What a hosted tool's input has streamed so far, by call: a search may send its query in fragments, and the
|
|
441
|
+
# end of the call is where the query is read.
|
|
442
|
+
hosted: dict[str, str] = {}
|
|
443
|
+
try:
|
|
444
|
+
if self._side:
|
|
445
|
+
runtime.sides.begin()
|
|
446
|
+
try:
|
|
447
|
+
async with aclosing(cast("AsyncGenerator[Any, None]", self._source())) as events:
|
|
448
|
+
async for raw in events:
|
|
449
|
+
if self._raw:
|
|
450
|
+
yield self._seen(RawEvent(raw.session_id or self._session.id, raw))
|
|
451
|
+
for event in translate(raw, bridge, hosted):
|
|
452
|
+
await self._answer(event)
|
|
453
|
+
self._hold(event)
|
|
454
|
+
yield self._seen(event)
|
|
455
|
+
self._settle(event)
|
|
456
|
+
finally:
|
|
457
|
+
for request in self._open:
|
|
458
|
+
self._default(request)
|
|
459
|
+
self._open.clear()
|
|
460
|
+
if self._side:
|
|
461
|
+
runtime.sides.end()
|
|
462
|
+
except _HandlerFailed as failure:
|
|
463
|
+
raise failure.error from None
|
|
464
|
+
except Exception as error:
|
|
465
|
+
translated = self._failure(error)
|
|
466
|
+
if translated is error:
|
|
467
|
+
raise
|
|
468
|
+
raise translated from error
|
|
469
|
+
|
|
470
|
+
def _seen(self, event: AnyEvent) -> AnyEvent:
|
|
471
|
+
self._collector.see(event)
|
|
472
|
+
return event
|
|
473
|
+
|
|
474
|
+
async def _answer(self, event: AnyEvent) -> None:
|
|
475
|
+
"""Let the program's handler answer an approval or a question before the event is yielded."""
|
|
476
|
+
try:
|
|
477
|
+
if isinstance(event, ApprovalRequested):
|
|
478
|
+
await apply_approval(event, self._on_approval)
|
|
479
|
+
elif isinstance(event, QuestionAsked):
|
|
480
|
+
await apply_question(event, self._on_question)
|
|
481
|
+
except Exception as error:
|
|
482
|
+
self._default(event)
|
|
483
|
+
raise _HandlerFailed(error) from error
|
|
484
|
+
|
|
485
|
+
def _hold(self, event: AnyEvent) -> None:
|
|
486
|
+
"""Remember a request a deferred run is about to yield unanswered, before it is yielded.
|
|
487
|
+
|
|
488
|
+
Before, and not after: a consumer that stops at this very event never resumes the generator, and the run's end
|
|
489
|
+
has to find the request to answer it.
|
|
490
|
+
"""
|
|
491
|
+
if self._defer and isinstance(event, (ApprovalRequested, QuestionAsked)) and not event.answered:
|
|
492
|
+
self._open.append(event)
|
|
493
|
+
|
|
494
|
+
def _settle(self, event: AnyEvent) -> None:
|
|
495
|
+
"""Decide what an event that was yielded and not answered comes to.
|
|
496
|
+
|
|
497
|
+
Normally it is denied or declined now. A deferred run keeps it open instead (see :meth:`_hold`), for the
|
|
498
|
+
program to answer from wherever it asks the reader, and answers it for the program only when the run ends.
|
|
499
|
+
"""
|
|
500
|
+
if self._defer and isinstance(event, (ApprovalRequested, QuestionAsked)):
|
|
501
|
+
return
|
|
502
|
+
self._default(event)
|
|
503
|
+
|
|
504
|
+
@staticmethod
|
|
505
|
+
def _default(event: AnyEvent) -> None:
|
|
506
|
+
"""Deny or decline what is still unanswered, so a suspended tool call never waits forever."""
|
|
507
|
+
if isinstance(event, ApprovalRequested) and not event.answered:
|
|
508
|
+
event.deny()
|
|
509
|
+
elif isinstance(event, QuestionAsked) and not event.answered:
|
|
510
|
+
event.decline()
|
|
511
|
+
|
|
512
|
+
def _failure(self, error: Exception) -> Exception:
|
|
513
|
+
"""Return the SDK error that stands for ``error``, keeping the original as its cause."""
|
|
514
|
+
from zett_agent.exceptions import AgentResumeError
|
|
515
|
+
|
|
516
|
+
from zettcode.engine.storage import SessionLocked
|
|
517
|
+
from zettcode.error_text import describe_error, may_pass
|
|
518
|
+
|
|
519
|
+
if isinstance(error, ZettCodeError):
|
|
520
|
+
return error
|
|
521
|
+
if isinstance(error, SessionLocked):
|
|
522
|
+
return SessionLockedError(str(error))
|
|
523
|
+
if isinstance(error, AgentResumeError):
|
|
524
|
+
return NothingToDoError(str(error))
|
|
525
|
+
return RunError(describe_error(error), retryable=may_pass(error), partial_text=self._collector.text)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# -- the session ----------------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class Session:
|
|
532
|
+
"""A conversation: run prompts in it, and read what it holds.
|
|
533
|
+
|
|
534
|
+
Get one from ``client.session`` (the current conversation), ``client.sessions.new()``,
|
|
535
|
+
``await client.sessions.open(id)`` or :meth:`fork`. A handle stays valid when the client works on another
|
|
536
|
+
conversation; the next call that needs this one switches back to it, unless a run is in progress.
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
def __init__(self, client: Client, session_id: str) -> None:
|
|
540
|
+
self._client = client
|
|
541
|
+
self.id = session_id
|
|
542
|
+
|
|
543
|
+
def __repr__(self) -> str:
|
|
544
|
+
return f"Session({self.id!r})"
|
|
545
|
+
|
|
546
|
+
# -- what it is --
|
|
547
|
+
@property
|
|
548
|
+
def title(self) -> str | None:
|
|
549
|
+
"""Return the conversation's name, or ``None`` until it has one."""
|
|
550
|
+
try:
|
|
551
|
+
return self._client._agent.runtime.persistence.session_title(self.id)
|
|
552
|
+
except (OSError, ValueError):
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
@property
|
|
556
|
+
def usage(self) -> Usage:
|
|
557
|
+
"""Return what this session has spent in total, in this process and, for a resumed one, before it."""
|
|
558
|
+
from .translation import usage_from
|
|
559
|
+
|
|
560
|
+
return usage_from(self._client._agent.runtime.usage.snapshot(self.id))
|
|
561
|
+
|
|
562
|
+
@property
|
|
563
|
+
def locked(self) -> bool:
|
|
564
|
+
"""Return whether another process has this session open, so it can be read and not changed."""
|
|
565
|
+
runtime = self._client._agent.runtime
|
|
566
|
+
return self.id == runtime.session_id and runtime.session_locked
|
|
567
|
+
|
|
568
|
+
def tasks(self) -> tuple[TaskInfo, ...]:
|
|
569
|
+
"""Return the plan the model keeps for this session, in order; empty until it makes one."""
|
|
570
|
+
self._client._check_open()
|
|
571
|
+
if not self.current:
|
|
572
|
+
return ()
|
|
573
|
+
return tuple(TaskInfo(status, content) for status, content in self._client._agent.tasks())
|
|
574
|
+
|
|
575
|
+
def retake(self) -> bool:
|
|
576
|
+
"""Try again to take the lock of a conversation another process had open, once that process has let go.
|
|
577
|
+
|
|
578
|
+
Every conversation is locked by the process that has it open. One this client resumed may be somebody else's,
|
|
579
|
+
and then it can be read and not changed (:attr:`locked`); a front end that shows it read-only can poll this and
|
|
580
|
+
become the writer the moment the other one closes.
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
Whether this client holds the lock now (also ``True`` when it already did, or the conversation is not the
|
|
584
|
+
current one, which has no lock of its own to retake).
|
|
585
|
+
|
|
586
|
+
Raises:
|
|
587
|
+
ClientClosedError: The client was closed.
|
|
588
|
+
"""
|
|
589
|
+
self._client._check_open()
|
|
590
|
+
if self.id != self._client._agent.session_id:
|
|
591
|
+
return True
|
|
592
|
+
return self._client._agent.retake_session()
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def path(self) -> Path:
|
|
596
|
+
"""Return the file the conversation is stored in."""
|
|
597
|
+
return self._client._agent.runtime.persistence.session_path(self.id)
|
|
598
|
+
|
|
599
|
+
@property
|
|
600
|
+
def current(self) -> bool:
|
|
601
|
+
"""Return whether this is the conversation the next run would use without switching."""
|
|
602
|
+
return self._client._agent.session_id == self.id
|
|
603
|
+
|
|
604
|
+
# -- running --
|
|
605
|
+
def prompt(
|
|
606
|
+
self,
|
|
607
|
+
prompt: Prompt,
|
|
608
|
+
*,
|
|
609
|
+
images: Sequence[Image] = (),
|
|
610
|
+
mentions: bool = False,
|
|
611
|
+
instructions: str | None = None,
|
|
612
|
+
on_approval: ApprovalHandler | None = None,
|
|
613
|
+
on_question: QuestionHandler | None = None,
|
|
614
|
+
on_event: EventCallback | None = None,
|
|
615
|
+
timeout: float | None = None,
|
|
616
|
+
raw: bool = False,
|
|
617
|
+
keep_events: bool = False,
|
|
618
|
+
defer_answers: bool = False,
|
|
619
|
+
cache: CacheMode | None = None,
|
|
620
|
+
) -> RunStream:
|
|
621
|
+
"""Send a prompt. Await the result of the call for the answer, or iterate it for the events.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
prompt: Text, or text and :class:`Image` pieces in reading order.
|
|
625
|
+
images: Images to put after the prompt.
|
|
626
|
+
mentions: Resolve ``@skill`` and ``@path`` references the way the terminal does. Off by default, since
|
|
627
|
+
``@`` is also in email addresses and decorators.
|
|
628
|
+
instructions: Instructions the model reads after the prompt, which the reader does not type and a
|
|
629
|
+
restored session does not show: a program that turns a line into a longer request says it here, so
|
|
630
|
+
the conversation keeps the line. Blank is the same as none.
|
|
631
|
+
on_approval: Decides each shell command the permission level asks about; see
|
|
632
|
+
:mod:`zettcode.sdk.handlers`. Without one, answer the :class:`ApprovalRequested` events yourself or
|
|
633
|
+
they are denied.
|
|
634
|
+
on_question: Answers each question the model asks. Without one, they are declined.
|
|
635
|
+
on_event: Called with every event as it happens, for progress; may be a coroutine function.
|
|
636
|
+
timeout: Seconds to allow the whole run; ``None`` waits as long as it takes.
|
|
637
|
+
raw: Also yield the underlying ``zett-agent`` event as a :class:`RawEvent` before its typed events.
|
|
638
|
+
keep_events: Keep every event in ``result.events``.
|
|
639
|
+
defer_answers: Leave an approval or a question open past the event that asked it, so a program that asks
|
|
640
|
+
its reader somewhere else (a panel, a chat) can answer it later with the event's own ``approve``,
|
|
641
|
+
``deny``, ``answer`` or ``decline``. Without it, one not answered by the time the stream is asked
|
|
642
|
+
for its next event is denied or declined. Either way, whatever is still open when the run ends is.
|
|
643
|
+
cache: How this request may use the provider's prompt cache (see :data:`~zettcode.sdk.types.CacheMode`).
|
|
644
|
+
``None`` is ``"read_write"``: the turn extends the cached prefix for the next one to read. A preset
|
|
645
|
+
that starts a turn nobody will follow — a one-off report — is worth ``"read"`` instead, which reuses
|
|
646
|
+
the conversation's warm prefix without marking its own content. A value the provider cannot honour is
|
|
647
|
+
its own business; an unknown word is a ``ValueError`` raised when the run starts, not at this call.
|
|
648
|
+
|
|
649
|
+
Raises:
|
|
650
|
+
ValueError: The prompt is empty, or ``timeout`` is not a positive number.
|
|
651
|
+
TypeError: A piece of the prompt is not text or an :class:`Image`.
|
|
652
|
+
ConfigurationError: No model is configured, or the prompt carries images and the model does not
|
|
653
|
+
accept them.
|
|
654
|
+
SessionBusyError: Another run is in progress (checked again when the run starts).
|
|
655
|
+
ClientClosedError: The client was closed.
|
|
656
|
+
RunTimeoutError: (when awaited or iterated) the run took longer than ``timeout``. It was stopped; what
|
|
657
|
+
it had added to the conversation stays.
|
|
658
|
+
RunError: (when awaited or iterated) the model, the provider or the network failed; ``retryable`` says
|
|
659
|
+
whether trying again may help.
|
|
660
|
+
"""
|
|
661
|
+
client = self._client
|
|
662
|
+
client._check_open()
|
|
663
|
+
client._check_free()
|
|
664
|
+
_check_timeout(timeout)
|
|
665
|
+
registry = client._mention_registry() if mentions else None
|
|
666
|
+
entry = client._agent.active_model
|
|
667
|
+
if entry is None:
|
|
668
|
+
from zettcode.config import NO_MODEL_REFUSAL
|
|
669
|
+
|
|
670
|
+
from .errors import ConfigurationError
|
|
671
|
+
|
|
672
|
+
raise ConfigurationError(NO_MODEL_REFUSAL)
|
|
673
|
+
parts, hint = _prompt_parts(
|
|
674
|
+
prompt,
|
|
675
|
+
images,
|
|
676
|
+
mentions=registry,
|
|
677
|
+
multimodal=entry.multimodal,
|
|
678
|
+
model_name=entry.shown_name,
|
|
679
|
+
)
|
|
680
|
+
return RunStream(
|
|
681
|
+
self,
|
|
682
|
+
lambda: client._agent.stream(parts, hint=_with_instructions(hint, instructions), side=False, cache=cache),
|
|
683
|
+
on_approval=on_approval,
|
|
684
|
+
on_question=on_question,
|
|
685
|
+
on_event=on_event,
|
|
686
|
+
timeout=timeout,
|
|
687
|
+
raw=raw,
|
|
688
|
+
keep_events=keep_events,
|
|
689
|
+
defer_answers=defer_answers,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
#: The same method as :meth:`prompt`; it returns a stream you can iterate, which is what the name says.
|
|
693
|
+
stream = prompt
|
|
694
|
+
|
|
695
|
+
def relay(
|
|
696
|
+
self,
|
|
697
|
+
message: IncomingMessage,
|
|
698
|
+
*,
|
|
699
|
+
on_approval: ApprovalHandler | None = None,
|
|
700
|
+
on_question: QuestionHandler | None = None,
|
|
701
|
+
on_event: EventCallback | None = None,
|
|
702
|
+
timeout: float | None = None,
|
|
703
|
+
raw: bool = False,
|
|
704
|
+
keep_events: bool = False,
|
|
705
|
+
defer_answers: bool = False,
|
|
706
|
+
) -> RunStream:
|
|
707
|
+
"""Run one turn for a message another session sent.
|
|
708
|
+
|
|
709
|
+
The message is what the model reads — framed with where it came from, and with the instruction that an
|
|
710
|
+
answer here goes only to this session's reader — and the text as sent is what a restored conversation
|
|
711
|
+
shows.
|
|
712
|
+
|
|
713
|
+
Raises:
|
|
714
|
+
ClientClosedError: The client was closed.
|
|
715
|
+
SessionBusyError: Another run is in progress (checked again when the run starts).
|
|
716
|
+
ValueError: ``timeout`` is not a positive number.
|
|
717
|
+
"""
|
|
718
|
+
from zettcode.relay import Relayed
|
|
719
|
+
|
|
720
|
+
client = self._client
|
|
721
|
+
client._check_open()
|
|
722
|
+
client._check_free()
|
|
723
|
+
_check_timeout(timeout)
|
|
724
|
+
|
|
725
|
+
def relayed_of(item: IncomingMessage) -> Relayed:
|
|
726
|
+
return Relayed(
|
|
727
|
+
sender_id=item.sender_id,
|
|
728
|
+
sender=item.sender,
|
|
729
|
+
text=item.text,
|
|
730
|
+
via=item.via,
|
|
731
|
+
hops=item.hops,
|
|
732
|
+
direct=item.direct,
|
|
733
|
+
mode=item.mode,
|
|
734
|
+
group=item.group,
|
|
735
|
+
batch=tuple(relayed_of(each) for each in item.batch),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
relayed = relayed_of(message)
|
|
739
|
+
return RunStream(
|
|
740
|
+
self,
|
|
741
|
+
lambda: client._agent.stream((), relayed=relayed),
|
|
742
|
+
on_approval=on_approval,
|
|
743
|
+
on_question=on_question,
|
|
744
|
+
on_event=on_event,
|
|
745
|
+
timeout=timeout,
|
|
746
|
+
raw=raw,
|
|
747
|
+
keep_events=keep_events,
|
|
748
|
+
defer_answers=defer_answers,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
async def run(
|
|
752
|
+
self,
|
|
753
|
+
prompt: Prompt,
|
|
754
|
+
*,
|
|
755
|
+
images: Sequence[Image] = (),
|
|
756
|
+
mentions: bool = False,
|
|
757
|
+
instructions: str | None = None,
|
|
758
|
+
on_approval: ApprovalHandler | None = None,
|
|
759
|
+
on_question: QuestionHandler | None = None,
|
|
760
|
+
on_event: EventCallback | None = None,
|
|
761
|
+
timeout: float | None = None,
|
|
762
|
+
keep_events: bool = False,
|
|
763
|
+
cache: CacheMode | None = None,
|
|
764
|
+
) -> RunResult:
|
|
765
|
+
"""Send a prompt and wait for the model to finish; ``await session.prompt(...)`` spelled as a call.
|
|
766
|
+
|
|
767
|
+
Takes the arguments, and raises the errors, of :meth:`prompt`.
|
|
768
|
+
"""
|
|
769
|
+
return await self.prompt(
|
|
770
|
+
prompt,
|
|
771
|
+
images=images,
|
|
772
|
+
mentions=mentions,
|
|
773
|
+
instructions=instructions,
|
|
774
|
+
on_approval=on_approval,
|
|
775
|
+
on_question=on_question,
|
|
776
|
+
on_event=on_event,
|
|
777
|
+
timeout=timeout,
|
|
778
|
+
keep_events=keep_events,
|
|
779
|
+
cache=cache,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
def ask_stream(
|
|
783
|
+
self,
|
|
784
|
+
question: str,
|
|
785
|
+
*,
|
|
786
|
+
on_event: EventCallback | None = None,
|
|
787
|
+
timeout: float | None = None,
|
|
788
|
+
cache: CacheMode = "read",
|
|
789
|
+
origin: str = "reader",
|
|
790
|
+
watched: bool | None = None,
|
|
791
|
+
) -> RunStream:
|
|
792
|
+
"""Ask a side question and return the stream of its answer, so it can be shown as it is written.
|
|
793
|
+
|
|
794
|
+
The same side question as :meth:`ask`: the model sees the whole conversation and answers on a read-only
|
|
795
|
+
footing, and neither the question nor the answer joins the history. Iterate the stream for its events
|
|
796
|
+
(``TextDelta`` carries the answer as it arrives), or await it for the result.
|
|
797
|
+
|
|
798
|
+
``origin`` says who is asking, and it is written down with the exchange: the default ``"reader"`` is a question
|
|
799
|
+
the reader asked, and a plugin asking for itself passes its own name (``"recap"``). A resumed session restores
|
|
800
|
+
the exchanges the reader watched and leaves the rest out, so a plugin's question — and the model's instructions
|
|
801
|
+
to itself that it carries — does not come back as the reader's words. Draw the part of the answer you want as a
|
|
802
|
+
message of your own.
|
|
803
|
+
|
|
804
|
+
``watched`` overrides what ``origin`` implies, and it is about a reopen only: left out, a reader's question is
|
|
805
|
+
restored and a plugin's is not, and setting it does not make a front end draw the exchange now — a shell still
|
|
806
|
+
drops a side question it did not begin, so a question never drawn comes back as a row nobody saw. Neither says
|
|
807
|
+
anything about the stream: every event of the run still reaches whoever iterates it, and a front end that shows
|
|
808
|
+
nothing of a question it did not ask drops them as they arrive.
|
|
809
|
+
|
|
810
|
+
The answer is never replayed, so the request does not extend the provider's cache by default: it starts at
|
|
811
|
+
``"read"``, which reuses the conversation's warm prefix and marks only the stable parts. Pass ``"read_write"``
|
|
812
|
+
or ``"off"`` to decide otherwise.
|
|
813
|
+
|
|
814
|
+
Raises:
|
|
815
|
+
ValueError: The question is empty.
|
|
816
|
+
SessionBusyError: A run is in progress.
|
|
817
|
+
"""
|
|
818
|
+
client = self._client
|
|
819
|
+
client._check_open()
|
|
820
|
+
client._check_free()
|
|
821
|
+
if not isinstance(question, str) or not question.strip():
|
|
822
|
+
raise ValueError("The question is empty")
|
|
823
|
+
parts: tuple[Any, ...] = (question,)
|
|
824
|
+
return RunStream(
|
|
825
|
+
self,
|
|
826
|
+
lambda: client._agent.stream(parts, side=True, origin=origin, watched=watched, cache=cache),
|
|
827
|
+
on_approval=None,
|
|
828
|
+
on_question=None,
|
|
829
|
+
on_event=on_event,
|
|
830
|
+
timeout=timeout,
|
|
831
|
+
side=True,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
async def ask(
|
|
835
|
+
self,
|
|
836
|
+
question: str,
|
|
837
|
+
*,
|
|
838
|
+
on_event: EventCallback | None = None,
|
|
839
|
+
timeout: float | None = None,
|
|
840
|
+
cache: CacheMode = "read",
|
|
841
|
+
origin: str = "reader",
|
|
842
|
+
watched: bool | None = None,
|
|
843
|
+
) -> str:
|
|
844
|
+
"""Ask a side question about the conversation and return the answer, without adding to it.
|
|
845
|
+
|
|
846
|
+
The model sees the whole conversation and answers on a read-only footing; neither the question nor the
|
|
847
|
+
answer joins the history the next run is built from. :meth:`ask_stream` is the same question with the
|
|
848
|
+
answer streamed, and takes the same ``cache`` mode: ``"read"`` by default, since the answer is not replayed.
|
|
849
|
+
``origin`` and ``watched`` are :meth:`ask_stream`'s too: a question a plugin asks for itself is not restored
|
|
850
|
+
into a resumed transcript, and a reader's is.
|
|
851
|
+
|
|
852
|
+
Raises:
|
|
853
|
+
ValueError: The question is empty.
|
|
854
|
+
"""
|
|
855
|
+
return (
|
|
856
|
+
await self.ask_stream(
|
|
857
|
+
question, on_event=on_event, timeout=timeout, cache=cache, origin=origin, watched=watched
|
|
858
|
+
)
|
|
859
|
+
).text
|
|
860
|
+
|
|
861
|
+
def steer(self, prompt: Prompt, *, images: Sequence[Image] = (), mentions: bool = False) -> str | None:
|
|
862
|
+
"""Send a message to the run in progress, which drops what it was doing to take it into account.
|
|
863
|
+
|
|
864
|
+
The message is built as a prompt is: text and pictures in order, and ``@`` references resolved when
|
|
865
|
+
``mentions`` is on.
|
|
866
|
+
|
|
867
|
+
Returns:
|
|
868
|
+
The text the run will report the message by (the ``text`` of the :class:`~zettcode.sdk.events.Steered`
|
|
869
|
+
event that follows), which a front end that shows the message as waiting matches it by; for plain text it
|
|
870
|
+
is the text itself. ``None`` when no request took it: nothing is running in this session, or the message
|
|
871
|
+
is blank. Nothing is queued for later.
|
|
872
|
+
|
|
873
|
+
Raises:
|
|
874
|
+
TypeError: A piece of the message is not text or an :class:`Image`.
|
|
875
|
+
ConfigurationError: The message carries images and the model does not accept them.
|
|
876
|
+
"""
|
|
877
|
+
client = self._client
|
|
878
|
+
if not self._running_here():
|
|
879
|
+
return None
|
|
880
|
+
registry = client._mention_registry() if mentions else None
|
|
881
|
+
entry = client._agent.active_model
|
|
882
|
+
if entry is None:
|
|
883
|
+
return None # nothing is running, since a run needs a model
|
|
884
|
+
try:
|
|
885
|
+
parts, hint = _prompt_parts(
|
|
886
|
+
prompt, images, mentions=registry, multimodal=entry.multimodal, model_name=entry.shown_name
|
|
887
|
+
)
|
|
888
|
+
except ValueError:
|
|
889
|
+
return None # blank: there is nothing to steer with
|
|
890
|
+
return self._hand_to_run(client._agent.turn(parts, hint=hint))
|
|
891
|
+
|
|
892
|
+
def _steer_message(self, message: UserMessage) -> str | None:
|
|
893
|
+
"""Send a message already made to the run in progress, and say what it will be reported as.
|
|
894
|
+
|
|
895
|
+
A message another session sent is built with the line above it that tells the model who wrote it, while the
|
|
896
|
+
text a reader sees is the message itself. Steering it as a finished message keeps both: the model reads the
|
|
897
|
+
whole user message, and the front end matches the run's report by the text this returns. It is private
|
|
898
|
+
because it exists for this repository's front ends (the terminal and the server), not for programs using the
|
|
899
|
+
SDK: those steer text with :meth:`steer`.
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
The text the run will report the message by, or ``None`` when nothing is running in this session or the
|
|
903
|
+
run refused it. Nothing is queued for later.
|
|
904
|
+
"""
|
|
905
|
+
if not self._running_here():
|
|
906
|
+
return None
|
|
907
|
+
return self._hand_to_run(message)
|
|
908
|
+
|
|
909
|
+
def _running_here(self) -> bool:
|
|
910
|
+
"""Return whether this session has a run in progress that a message can steer."""
|
|
911
|
+
client = self._client
|
|
912
|
+
running = client._stream
|
|
913
|
+
return not client._closed and running is not None and running._session.id == self.id
|
|
914
|
+
|
|
915
|
+
def _hand_to_run(self, message: str | UserMessage) -> str | None:
|
|
916
|
+
"""Hand one finished message to the running request and return the text it reports it by."""
|
|
917
|
+
client = self._client
|
|
918
|
+
text = message if isinstance(message, str) else message.text
|
|
919
|
+
return text if client._agent.steer(message) else None
|
|
920
|
+
|
|
921
|
+
async def resumable(self) -> ResumeInfo | None:
|
|
922
|
+
"""Say where the conversation was interrupted and what :meth:`resume` would do; ``None`` if it was not."""
|
|
923
|
+
from zettcode.engine.storage import SessionLocked
|
|
924
|
+
|
|
925
|
+
await self._activate()
|
|
926
|
+
try:
|
|
927
|
+
point = await self._client._agent.resume_point()
|
|
928
|
+
except SessionLocked as error:
|
|
929
|
+
raise SessionLockedError(str(error)) from error
|
|
930
|
+
return None if point is None else ResumeInfo(str(point.kind.value), point.description)
|
|
931
|
+
|
|
932
|
+
def resume_stream(
|
|
933
|
+
self,
|
|
934
|
+
*,
|
|
935
|
+
on_approval: ApprovalHandler | None = None,
|
|
936
|
+
on_question: QuestionHandler | None = None,
|
|
937
|
+
on_event: EventCallback | None = None,
|
|
938
|
+
timeout: float | None = None,
|
|
939
|
+
raw: bool = False,
|
|
940
|
+
keep_events: bool = False,
|
|
941
|
+
defer_answers: bool = False,
|
|
942
|
+
) -> RunStream:
|
|
943
|
+
"""Pick up an interrupted request where it stopped; await it or iterate it like :meth:`prompt`.
|
|
944
|
+
|
|
945
|
+
Raises:
|
|
946
|
+
NothingToDoError: Awaiting or iterating raises it when there is nothing to pick up, with the reason.
|
|
947
|
+
"""
|
|
948
|
+
client = self._client
|
|
949
|
+
client._check_open()
|
|
950
|
+
client._check_free()
|
|
951
|
+
return RunStream(
|
|
952
|
+
self,
|
|
953
|
+
lambda: client._agent.stream_resume(),
|
|
954
|
+
on_approval=on_approval,
|
|
955
|
+
on_question=on_question,
|
|
956
|
+
on_event=on_event,
|
|
957
|
+
timeout=timeout,
|
|
958
|
+
raw=raw,
|
|
959
|
+
keep_events=keep_events,
|
|
960
|
+
defer_answers=defer_answers,
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
async def resume(
|
|
964
|
+
self,
|
|
965
|
+
*,
|
|
966
|
+
on_approval: ApprovalHandler | None = None,
|
|
967
|
+
on_question: QuestionHandler | None = None,
|
|
968
|
+
on_event: EventCallback | None = None,
|
|
969
|
+
timeout: float | None = None,
|
|
970
|
+
) -> RunResult:
|
|
971
|
+
"""Pick up an interrupted request where it stopped and wait for it to finish (see :meth:`resume_stream`)."""
|
|
972
|
+
return await self.resume_stream(
|
|
973
|
+
on_approval=on_approval, on_question=on_question, on_event=on_event, timeout=timeout
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
# -- working on the conversation --
|
|
977
|
+
async def compact(self) -> Compaction | None:
|
|
978
|
+
"""Summarize the older part of the conversation now, to make room, without adding a turn.
|
|
979
|
+
|
|
980
|
+
Returns:
|
|
981
|
+
What was done, or ``None`` when there was nothing worth folding.
|
|
982
|
+
|
|
983
|
+
Raises:
|
|
984
|
+
NothingToDoError: The conversation is already compacted up to its newest message.
|
|
985
|
+
SessionLockedError: Another process has the session open.
|
|
986
|
+
"""
|
|
987
|
+
from zettcode.engine.storage import SessionLocked
|
|
988
|
+
from zettcode.error_text import describe_error, may_pass
|
|
989
|
+
|
|
990
|
+
client = self._client
|
|
991
|
+
client._check_open()
|
|
992
|
+
client._check_free()
|
|
993
|
+
await self._activate()
|
|
994
|
+
try:
|
|
995
|
+
event = await client._agent.compact()
|
|
996
|
+
except SessionLocked as error:
|
|
997
|
+
raise SessionLockedError(str(error)) from error
|
|
998
|
+
except ValueError as error:
|
|
999
|
+
raise NothingToDoError(str(error)) from error
|
|
1000
|
+
except Exception as error:
|
|
1001
|
+
raise RunError(describe_error(error), retryable=may_pass(error)) from error
|
|
1002
|
+
if event is None:
|
|
1003
|
+
return None
|
|
1004
|
+
return Compaction(
|
|
1005
|
+
event.summary,
|
|
1006
|
+
max(0, event.compressed_to - event.compressed_from + 1),
|
|
1007
|
+
max(0, event.kept_to - event.kept_from + 1),
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
async def context(self) -> ContextReport | None:
|
|
1011
|
+
"""Report what the next request would carry, by source; ``None`` before the first reply."""
|
|
1012
|
+
self._client._check_open()
|
|
1013
|
+
await self._activate()
|
|
1014
|
+
report = await self._client._agent.context_report()
|
|
1015
|
+
if report is None:
|
|
1016
|
+
return None
|
|
1017
|
+
return ContextReport(
|
|
1018
|
+
window=report.window,
|
|
1019
|
+
used=report.used,
|
|
1020
|
+
measured_by=report.measured_by,
|
|
1021
|
+
shares=tuple(ContextShare(share.name, share.tokens, share.items) for share in report.shares),
|
|
1022
|
+
partial=report.partial,
|
|
1023
|
+
from_provider=report.from_provider,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
async def rename(self, title: str) -> str:
|
|
1027
|
+
"""Give the conversation a name and return it as stored (trimmed).
|
|
1028
|
+
|
|
1029
|
+
Raises:
|
|
1030
|
+
ValueError: The title is blank or too long.
|
|
1031
|
+
SessionLockedError: Another process has the session open.
|
|
1032
|
+
"""
|
|
1033
|
+
from zettcode.engine.storage import SessionLocked
|
|
1034
|
+
|
|
1035
|
+
self._client._check_open()
|
|
1036
|
+
if not self.current:
|
|
1037
|
+
# Naming another conversation switches to it, which a run in progress forbids. Naming the current one
|
|
1038
|
+
# only writes its title, which a run does not read, so it may be done beside one.
|
|
1039
|
+
self._client._check_free()
|
|
1040
|
+
await self._activate()
|
|
1041
|
+
try:
|
|
1042
|
+
return await self._client._agent.rename_session(title)
|
|
1043
|
+
except SessionLocked as error:
|
|
1044
|
+
raise SessionLockedError(str(error)) from error
|
|
1045
|
+
|
|
1046
|
+
async def name_it(self) -> str | None:
|
|
1047
|
+
"""Ask the model for a short title from the first exchange, unless one exists; return the session's title.
|
|
1048
|
+
|
|
1049
|
+
A session that already has a title keeps it and the model is not asked. ``None`` means there was no
|
|
1050
|
+
exchange to name yet, or the model gave nothing usable.
|
|
1051
|
+
"""
|
|
1052
|
+
self._client._check_open()
|
|
1053
|
+
await self._activate()
|
|
1054
|
+
return await self._client._agent.title_session(self.id) or self.title
|
|
1055
|
+
|
|
1056
|
+
async def export(self, path: str | Path | None = None) -> Path:
|
|
1057
|
+
"""Write the conversation out as a page and return where it went.
|
|
1058
|
+
|
|
1059
|
+
Args:
|
|
1060
|
+
path: Where to write it; ``None`` writes ``zettcode-<id8>.html`` in the workspace. A relative path is taken
|
|
1061
|
+
from the working directory.
|
|
1062
|
+
|
|
1063
|
+
Raises:
|
|
1064
|
+
ValueError: The session has nothing stored yet.
|
|
1065
|
+
"""
|
|
1066
|
+
self._client._check_open()
|
|
1067
|
+
await self._activate()
|
|
1068
|
+
return await self._client._agent.export_session(path)
|
|
1069
|
+
|
|
1070
|
+
async def fork(self) -> Session:
|
|
1071
|
+
"""Copy the conversation's context into a new session and return it; this one is left as it is."""
|
|
1072
|
+
client = self._client
|
|
1073
|
+
client._check_open()
|
|
1074
|
+
client._check_free()
|
|
1075
|
+
await self._activate()
|
|
1076
|
+
try:
|
|
1077
|
+
new_id = await asyncio.to_thread(client._agent.fork_session)
|
|
1078
|
+
except (ValueError, OSError) as error:
|
|
1079
|
+
raise SessionNotFoundError(str(error)) from error
|
|
1080
|
+
return client._handle(new_id)
|
|
1081
|
+
|
|
1082
|
+
async def history(self, *, full: bool = False) -> list[Message]:
|
|
1083
|
+
"""Return the messages the model is working with: the compaction summary if any, then what followed it.
|
|
1084
|
+
|
|
1085
|
+
With ``full=True`` it returns the whole conversation on the active branch instead, as it was written: the
|
|
1086
|
+
messages a compaction folded away are there, and each summary stands (as a ``summary`` message) at the
|
|
1087
|
+
point where it was made. That is what a client shows when it reopens a session; the model still works from
|
|
1088
|
+
the shorter list.
|
|
1089
|
+
|
|
1090
|
+
Reading needs no lock, so this works on a session another process has open.
|
|
1091
|
+
|
|
1092
|
+
Raises:
|
|
1093
|
+
SessionNotFoundError: The session has not been written yet, or its file cannot be read.
|
|
1094
|
+
"""
|
|
1095
|
+
self._client._check_open()
|
|
1096
|
+
persistence = self._client._agent.runtime.persistence
|
|
1097
|
+
try:
|
|
1098
|
+
stored = await asyncio.to_thread(persistence.read, self.id)
|
|
1099
|
+
except (ValueError, OSError) as error:
|
|
1100
|
+
raise SessionNotFoundError(f"Cannot read session {self.id}: {error}") from error
|
|
1101
|
+
if stored.header is None:
|
|
1102
|
+
raise SessionNotFoundError(f"Session {self.id} has no messages yet")
|
|
1103
|
+
return messages_of(stored, full=full)
|
|
1104
|
+
|
|
1105
|
+
# -- becoming the current conversation --
|
|
1106
|
+
async def _activate(self, by: RunStream | None = None) -> None:
|
|
1107
|
+
"""Make this the client's current conversation, unless it is already."""
|
|
1108
|
+
await self._client._switch_to(self.id, by)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def messages_of(stored: Any, *, full: bool = False) -> list[Message]:
|
|
1112
|
+
"""Return the SDK's messages for a conversation the store parsed.
|
|
1113
|
+
|
|
1114
|
+
The same walk serves :meth:`Session.history` and :meth:`~zettcode.sdk.client.Sessions.history_of`, so a conversation
|
|
1115
|
+
read in place and one read by id come out the same.
|
|
1116
|
+
"""
|
|
1117
|
+
entries: list[tuple[Any, Any, float]] = []
|
|
1118
|
+
if full:
|
|
1119
|
+
branch = stored.branch()
|
|
1120
|
+
on_branch = {line.id for line in branch}
|
|
1121
|
+
summaries: dict[str, list[Any]] = {}
|
|
1122
|
+
for compaction in sorted(stored.compactions, key=lambda line: line.version):
|
|
1123
|
+
if compaction.parent in on_branch:
|
|
1124
|
+
summaries.setdefault(compaction.parent, []).append(compaction)
|
|
1125
|
+
for line in branch:
|
|
1126
|
+
if line.message[0].include_in_messages:
|
|
1127
|
+
entries.append((line.message[0], line.usage, line.timing.duration_ns / 1e9))
|
|
1128
|
+
entries.extend((compaction.message[0], None, 0.0) for compaction in summaries.get(line.id, []))
|
|
1129
|
+
else:
|
|
1130
|
+
checkpoint, tail = stored.active()
|
|
1131
|
+
if checkpoint is not None:
|
|
1132
|
+
entries.append((checkpoint.message[0], None, 0.0))
|
|
1133
|
+
for line in tail:
|
|
1134
|
+
if line.message[0].include_in_messages:
|
|
1135
|
+
entries.append((line.message[0], line.usage, line.timing.duration_ns / 1e9))
|
|
1136
|
+
return [message for item, usage, seconds in entries if (message := _message_of(item, usage, seconds)) is not None]
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _relay_of(attributes: object) -> Relay | None:
|
|
1140
|
+
"""Return who sent a stored message that another session relayed, or ``None`` for anything else."""
|
|
1141
|
+
mark = attributes.get("relay") if isinstance(attributes, Mapping) else None
|
|
1142
|
+
if not isinstance(mark, Mapping):
|
|
1143
|
+
return None
|
|
1144
|
+
sender_id, name = mark.get("id"), mark.get("name")
|
|
1145
|
+
if not isinstance(sender_id, str) or not isinstance(name, str):
|
|
1146
|
+
return None
|
|
1147
|
+
via, hops = mark.get("via"), mark.get("hops")
|
|
1148
|
+
return Relay(
|
|
1149
|
+
sender_id=sender_id,
|
|
1150
|
+
sender=name,
|
|
1151
|
+
via=via if via in ("user", "agent", "external") else "agent",
|
|
1152
|
+
hops=hops if isinstance(hops, int) and not isinstance(hops, bool) and hops >= 0 else 1,
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _message_of(item: object, usage: Any = None, seconds: float = 0.0) -> Message | None:
|
|
1157
|
+
"""Return the SDK's view of one stored message; system and bookkeeping messages are not part of the history.
|
|
1158
|
+
|
|
1159
|
+
``usage`` is what the store recorded for the model call that produced the message, and ``seconds`` how long it took.
|
|
1160
|
+
"""
|
|
1161
|
+
from zett_agent.extensions.compaction import CompactedMessage
|
|
1162
|
+
from zett_agent.messages import AssistantMessage, ImageBytesSource, ImageContent, ToolMessage, UserMessage
|
|
1163
|
+
|
|
1164
|
+
# The summary that stands in for the older messages is a user message to the model, but nobody typed it.
|
|
1165
|
+
if isinstance(item, CompactedMessage):
|
|
1166
|
+
return Message("summary", item.text)
|
|
1167
|
+
if isinstance(item, UserMessage):
|
|
1168
|
+
typed = item.attributes.get("prompt")
|
|
1169
|
+
text = typed if isinstance(typed, str) else item.text
|
|
1170
|
+
pictures = tuple(
|
|
1171
|
+
Image(part.source.data, part.source.media_type)
|
|
1172
|
+
for part in item.parts
|
|
1173
|
+
if isinstance(part, ImageContent) and isinstance(part.source, ImageBytesSource) and part.source.data
|
|
1174
|
+
)
|
|
1175
|
+
images = sum(1 for part in item.parts if isinstance(part, ImageContent))
|
|
1176
|
+
return Message("user", text, images=images, pictures=pictures, relay=_relay_of(item.attributes))
|
|
1177
|
+
if isinstance(item, AssistantMessage):
|
|
1178
|
+
calls = tuple(ToolCallInfo(call.id, call.name, dict(call.arguments)) for call in item.tool_calls)
|
|
1179
|
+
spent = (
|
|
1180
|
+
None
|
|
1181
|
+
if usage is None
|
|
1182
|
+
else Usage(
|
|
1183
|
+
input_tokens=usage.input_tokens,
|
|
1184
|
+
output_tokens=usage.output_tokens,
|
|
1185
|
+
cache_read_tokens=usage.cache_read_tokens,
|
|
1186
|
+
cache_write_tokens=usage.cache_write_tokens,
|
|
1187
|
+
reasoning_tokens=usage.reasoning_tokens,
|
|
1188
|
+
seconds=seconds,
|
|
1189
|
+
requests=1,
|
|
1190
|
+
)
|
|
1191
|
+
)
|
|
1192
|
+
return Message(
|
|
1193
|
+
"assistant",
|
|
1194
|
+
item.content if isinstance(item.content, str) else "",
|
|
1195
|
+
tool_calls=calls,
|
|
1196
|
+
usage=spent,
|
|
1197
|
+
seconds=seconds,
|
|
1198
|
+
)
|
|
1199
|
+
if isinstance(item, ToolMessage):
|
|
1200
|
+
images = (
|
|
1201
|
+
0 if isinstance(item.content, str) else sum(1 for part in item.content if isinstance(part, ImageContent))
|
|
1202
|
+
)
|
|
1203
|
+
return Message(
|
|
1204
|
+
"tool",
|
|
1205
|
+
item.text,
|
|
1206
|
+
tool_call_id=item.tool_call_id,
|
|
1207
|
+
tool_name=item.name,
|
|
1208
|
+
ok=item.success,
|
|
1209
|
+
images=images,
|
|
1210
|
+
seconds=seconds,
|
|
1211
|
+
)
|
|
1212
|
+
return None
|