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