keystone-agent-tools 0.1.5__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,18 @@
1
+ """The tools a platform agent uses on files, shared by visual-builder and master-agent (FDP-6967).
2
+
3
+ * :mod:`keystone.agent_tools.files` — the file-artifact client (create / render as background
4
+ jobs, FDP-7030) and the file tools the model calls (``read_artifact``, ``search_in_artifact``,
5
+ ``search_across_thread``, ``create_artifact``, ``render_artifact``, ``edit_artifact`` and the
6
+ ``list`` / ``get_metadata`` / ``publish`` tools they refer to). The names, arguments and the
7
+ words the model reads are written ONCE, here.
8
+ * :mod:`keystone.agent_tools.kb` — the extended knowledge-base toolset (FR-180, FDP-7133): the six
9
+ ``kb_*`` tools, their descriptions and the Knowledge Bases block of the system prompt, with rag's
10
+ agent guide (and default metadata filter) of each KB. visual-builder and pro-code use it as is.
11
+ * :mod:`keystone.agent_tools.attachments` — how the files attached to a turn become content
12
+ blocks for the model: one token budget per turn, pictures downscaled to the provider limit,
13
+ what could not be sent noted for the model.
14
+
15
+ What a service still owns: who the call acts as (its headers), which turn a file belongs to, how
16
+ a file card reaches its chat, and the shape of a tool result in its graph state. A service plugs
17
+ those in through :class:`keystone.agent_tools.files.FileToolHost`.
18
+ """
@@ -0,0 +1,61 @@
1
+ """How a turn's attachments reach the model (FDP-6967, item 2) — one set of rules for every agent.
2
+
3
+ from keystone.agent_tools.attachments import AttachedFile, TurnPictures, document_blocks, image_blocks
4
+
5
+ See :mod:`keystone.agent_tools.attachments.blocks` for the rules, :mod:`.budget` for the turn budget
6
+ and :mod:`.images` for fitting a picture to the provider's limit.
7
+ """
8
+
9
+ from keystone.agent_tools.attachments.audio import AUDIO_HEARD_NOTE, audio_blocks, transcript_blocks
10
+ from keystone.agent_tools.attachments.blocks import (
11
+ IMAGE_MIMES,
12
+ AttachedFile,
13
+ AttachmentLimits,
14
+ FileBlocks,
15
+ TurnPictures,
16
+ aggregate_refused_note,
17
+ attached_marker,
18
+ document_blocks,
19
+ fmt_size,
20
+ image_blocks,
21
+ is_document,
22
+ is_image,
23
+ named,
24
+ text_cap_chars,
25
+ )
26
+ from keystone.agent_tools.attachments.budget import (
27
+ MIN_FLOOR_TOKENS_PER_FILE,
28
+ STATIC_TURN_ATTACHMENT_BUDGET,
29
+ cap_bytes_for_share,
30
+ compute_share_per_file,
31
+ refuses_content,
32
+ )
33
+ from keystone.agent_tools.attachments.images import DecodeRefused, fit_image, fit_image_async
34
+
35
+ __all__ = [
36
+ "AUDIO_HEARD_NOTE",
37
+ "audio_blocks",
38
+ "transcript_blocks",
39
+ "IMAGE_MIMES",
40
+ "MIN_FLOOR_TOKENS_PER_FILE",
41
+ "STATIC_TURN_ATTACHMENT_BUDGET",
42
+ "AttachedFile",
43
+ "AttachmentLimits",
44
+ "DecodeRefused",
45
+ "FileBlocks",
46
+ "TurnPictures",
47
+ "aggregate_refused_note",
48
+ "attached_marker",
49
+ "cap_bytes_for_share",
50
+ "compute_share_per_file",
51
+ "document_blocks",
52
+ "fit_image",
53
+ "fit_image_async",
54
+ "fmt_size",
55
+ "image_blocks",
56
+ "is_document",
57
+ "is_image",
58
+ "named",
59
+ "refuses_content",
60
+ "text_cap_chars",
61
+ ]
@@ -0,0 +1,104 @@
1
+ """An audio clip attached to a turn, as blocks: its transcript, or why it could not be heard (FDP-6967).
2
+
3
+ The words are visual-builder's (``services/agent_version/attachments.py``), which master-agent had
4
+ copied; now one copy. :func:`audio_blocks` calls the shared speech-to-text client
5
+ (:mod:`keystone.agent_tools.transcription`) — a host passes its identity headers, the agent's STT
6
+ model, and how to reach the clip (bytes, a spool, or a presigned URL for a long one).
7
+ """
8
+
9
+ # Feature: FR-130 | Tracking: FDP-3032, FDP-6967
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any
15
+
16
+ from keystone.agent_tools.attachments.blocks import AttachedFile, AttachmentLimits, FileBlocks, text_cap_chars
17
+ from keystone.agent_tools.transcription import Transcript, TranscriptionFailed
18
+
19
+ __all__ = ["AUDIO_HEARD_NOTE", "audio_blocks", "transcript_blocks"]
20
+
21
+ _logger = logging.getLogger("keystone.agent_tools.attachments")
22
+
23
+ #: Once per turn that carries a heard clip: without it models answered "I can't listen to audio, but
24
+ #: I can read the transcript you attached" — the block read to them as a transcript the USER pasted.
25
+ AUDIO_HEARD_NOTE = (
26
+ "[system-note: the audio above is what the user said, heard through speech-to-text. "
27
+ "Answer from what was said; do not say you cannot listen to audio.]"
28
+ )
29
+
30
+
31
+ def transcript_blocks(name: str | None, result: Transcript | str, *, cap_chars: int) -> list[dict[str, Any]]:
32
+ """What the model reads for one clip: its transcript, or (``result`` a str) why it was not heard."""
33
+ label = name or "audio"
34
+ if isinstance(result, str):
35
+ return [
36
+ {
37
+ "type": "text",
38
+ "text": (
39
+ f"[system-note: the user attached the audio file {label}, but it could not be "
40
+ f"transcribed ({result}). Tell the user you could not hear it and ask them to "
41
+ "send it again or type the message.]"
42
+ ),
43
+ }
44
+ ]
45
+ text = result.text
46
+ if not text:
47
+ return [
48
+ {
49
+ "type": "text",
50
+ "text": f"[system-note: the audio file {label} was transcribed, but no speech was found in it.]",
51
+ }
52
+ ]
53
+ length = f", {result.duration_seconds:.0f} s" if result.duration_seconds else ""
54
+ note = ""
55
+ if cap_chars > 0 and len(text) > cap_chars:
56
+ text = text[:cap_chars]
57
+ note = " Only the start is shown: the conversation has no room for the rest."
58
+ # Worded as audio the agent HEARD, not a transcript the user supplied (see AUDIO_HEARD_NOTE).
59
+ return [
60
+ {
61
+ "type": "text",
62
+ "text": (
63
+ f"[The user sent an audio clip ({label}{length}). What was said, transcribed "
64
+ f"automatically (a word may be misheard):{note}]\n{text}"
65
+ ),
66
+ }
67
+ ]
68
+
69
+
70
+ async def audio_blocks(
71
+ transcriber: Any,
72
+ file: AttachedFile,
73
+ *,
74
+ model: str,
75
+ headers: dict[str, str],
76
+ share_tokens: int,
77
+ limits: AttachmentLimits = AttachmentLimits(), # noqa: B008 - frozen, shared default
78
+ content: bytes | None = None,
79
+ open_content: Any = None,
80
+ audio_url: Any = None,
81
+ ) -> FileBlocks:
82
+ """One clip through the shared STT client. ``status``: ``shown`` when heard (speech or not),
83
+ ``unreadable`` when it could not be transcribed — never raises."""
84
+ try:
85
+ heard = await transcriber.transcribe(
86
+ filename=file.name,
87
+ mime=file.base_mime,
88
+ model=model,
89
+ headers=headers,
90
+ size=file.size,
91
+ content=content,
92
+ open_content=open_content,
93
+ audio_url=audio_url,
94
+ )
95
+ except TranscriptionFailed as exc:
96
+ _logger.warning("audio attachment not transcribed artifact_id=%s error=%s", file.artifact_id, exc)
97
+ reason = (
98
+ "it is over the size limit"
99
+ if exc.code == "AUDIO_TOO_LARGE"
100
+ else "the speech-to-text service could not process it"
101
+ )
102
+ return FileBlocks(transcript_blocks(file.name, reason, cap_chars=0), "unreadable")
103
+ cap = text_cap_chars(share_tokens, limits)
104
+ return FileBlocks(transcript_blocks(file.name, heard, cap_chars=cap), "shown")
@@ -0,0 +1,491 @@
1
+ """The files attached to a turn, as content blocks for the model (FDP-6967, item 2).
2
+
3
+ One set of rules for visual-builder and master-agent. The owner's table:
4
+
5
+ ========================================== =====================================================
6
+ What is in the file What the model gets
7
+ ========================================== =====================================================
8
+ text, tables (any document) text (Markdown tables), read by file-artifact
9
+ native Office chart (docx / pptx / xlsx) its data as text (type, title, series, values)
10
+ embedded picture (Office ``media/``) an image block — SMALL file only, capped
11
+ a PDF page the page rendered as an image — SMALL PDF only
12
+ a large file text only, and a note saying pictures were not sent
13
+ a photo / screenshot an image block, downscaled to the provider limit
14
+ ========================================== =====================================================
15
+
16
+ **Budget.** 130,000 tokens per turn split evenly across the files (:mod:`.budget`); a file's share
17
+ caps its text (sent as ``max_chars``, so file-artifact never ships more) and its picture bytes.
18
+ Pictures also have a count: :attr:`AttachmentLimits.images_per_file` and
19
+ :attr:`AttachmentLimits.images_per_turn` (:class:`TurnPictures`). Whatever is left out is said in
20
+ a note, first, so the model never answers from half a file as if it were whole.
21
+
22
+ **What stays the host's.** Fetching a photo's bytes, transcribing audio, binding a file to a
23
+ thread, the per-service wording around the blocks: this module only makes blocks. Nothing here
24
+ parses a document — file-artifact does, off this process — and the one CPU-bound step (fitting a
25
+ picture) runs in a worker thread.
26
+
27
+ The numbers marked *mạch B* come from the FDP-6967 dev measurements (2026-10-29): a PDF page is
28
+ ≈1.7k tokens either as a document or as a picture; Claude and Nova read every number from a chart
29
+ sent as picture + chart text; a ``document`` block of a DOCX loses its pictures and of a PPTX is
30
+ refused by litellm — hence pictures and text, never a ``document`` block.
31
+ """
32
+
33
+ # Feature: FR-78 item 2 | Tracking: FDP-6967
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import base64
39
+ import dataclasses
40
+ import logging
41
+ from dataclasses import dataclass
42
+ from typing import IO, Any, Literal
43
+
44
+ from keystone.agent_tools.attachments.budget import (
45
+ MIN_FLOOR_TOKENS_PER_FILE,
46
+ STATIC_TURN_ATTACHMENT_BUDGET,
47
+ cap_bytes_for_share,
48
+ )
49
+ from keystone.agent_tools.attachments.images import MAX_IMAGE_BYTES, DecodeRefused, fit_image_async
50
+ from keystone.agent_tools.files.wire import FileArtifactError
51
+
52
+ __all__ = [
53
+ "IMAGE_MIMES",
54
+ "AttachedFile",
55
+ "AttachmentLimits",
56
+ "FileBlocks",
57
+ "TurnPictures",
58
+ "aggregate_refused_note",
59
+ "attached_marker",
60
+ "document_blocks",
61
+ "fmt_size",
62
+ "image_blocks",
63
+ "is_document",
64
+ "is_image",
65
+ "named",
66
+ "text_cap_chars",
67
+ ]
68
+
69
+ _logger = logging.getLogger("keystone.agent_tools.attachments")
70
+
71
+ #: Raster types every provider takes as an image block. An SVG is text.
72
+ IMAGE_MIMES = frozenset({"image/png", "image/jpeg", "image/webp", "image/gif"})
73
+
74
+ _PDF = "application/pdf"
75
+ _DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
76
+ _XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
77
+ _PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
78
+ #: The documents whose pictures / charts file-artifact can add (``include_images`` / ``include_charts``).
79
+ _RICH = frozenset({_PDF, _DOCX, _XLSX, _PPTX})
80
+ _OFFICE = frozenset({_DOCX, _XLSX, _PPTX})
81
+ #: Read as text by file-artifact besides ``text/*``.
82
+ _TEXTUAL = frozenset(
83
+ {
84
+ "application/json",
85
+ "application/xml",
86
+ "application/yaml",
87
+ "application/x-yaml",
88
+ "image/svg+xml",
89
+ "application/rtf",
90
+ "application/msword",
91
+ "application/vnd.ms-excel",
92
+ "application/vnd.ms-powerpoint",
93
+ "application/xhtml+xml",
94
+ }
95
+ )
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class AttachmentLimits:
100
+ """The knobs. Defaults are the owner's / mạch B's numbers; a host may pass its own (config)."""
101
+
102
+ #: Tokens one turn's attachments may use together.
103
+ budget_tokens: int = STATIC_TURN_ATTACHMENT_BUDGET
104
+ #: A file's text never exceeds this many characters, whatever its share (VB's 200 KB).
105
+ max_text_chars: int = 200_000
106
+ #: "Small file" (mạch B): up to this size a document's pictures / pages are sent.
107
+ small_file_bytes: int = 4 * 1024 * 1024
108
+ #: "Small PDF" (mạch B): at most this many pages; and the text window of every PDF.
109
+ small_pdf_pages: int = 20
110
+ #: Pictures from one file, and from the whole turn (mạch B: 10 / 20).
111
+ images_per_file: int = 10
112
+ images_per_turn: int = 20
113
+ #: One picture's raw bytes (Bedrock: 3.75 MB).
114
+ image_max_bytes: int = MAX_IMAGE_BYTES
115
+
116
+ def __post_init__(self) -> None:
117
+ # A host builds this from its settings at start: a zero or negative knob is a typo there,
118
+ # refused loudly rather than silently sending nothing.
119
+ for name in (
120
+ "budget_tokens",
121
+ "max_text_chars",
122
+ "small_file_bytes",
123
+ "small_pdf_pages",
124
+ "images_per_file",
125
+ "images_per_turn",
126
+ "image_max_bytes",
127
+ ):
128
+ if getattr(self, name) <= 0:
129
+ raise ValueError(f"AttachmentLimits.{name} must be > 0, got {getattr(self, name)!r}")
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class AttachedFile:
134
+ artifact_id: str
135
+ name: str
136
+ mime: str
137
+ size: int = 0
138
+
139
+ @property
140
+ def base_mime(self) -> str:
141
+ return self.mime.split(";", 1)[0].strip().lower()
142
+
143
+
144
+ @dataclass(frozen=True, slots=True)
145
+ class FileBlocks:
146
+ """One file's blocks. ``status``: ``shown`` (content reached the model), ``named`` (only its name
147
+ and why — a type nobody reads, a picture over the limits) or ``unreadable`` (a failure)."""
148
+
149
+ blocks: list[dict[str, Any]]
150
+ status: Literal["shown", "named", "unreadable"] = "shown"
151
+ #: How many pictures this file put in the turn.
152
+ pictures: int = 0
153
+
154
+
155
+ class TurnPictures:
156
+ """The turn's picture count, shared by every file of the turn.
157
+
158
+ A document reserves its pictures BEFORE it is read (:meth:`hold`) and gives back what it did not
159
+ use once read (:meth:`release`). A host reads a turn's files together, so a photo can ask while
160
+ a document still holds a reservation it will mostly give back: :meth:`take_waiting` waits for
161
+ those reads to end instead of reporting the turn full. Without the wait, two small documents
162
+ (10 reserved each) left a photo attached with them unshown — "this message already carries as
163
+ many pictures as one turn may" — though they used two pictures (dev, 2026-10-30).
164
+ """
165
+
166
+ def __init__(self, limit: int) -> None:
167
+ self.left = max(0, limit)
168
+ #: Pictures reserved by documents still being read.
169
+ self.held = 0
170
+ self._changed: asyncio.Event | None = None
171
+
172
+ def take(self, wanted: int) -> int:
173
+ granted = max(0, min(wanted, self.left))
174
+ self.left -= granted
175
+ return granted
176
+
177
+ def hold(self, wanted: int) -> int:
178
+ """Reserve up to ``wanted`` pictures for a document about to be read."""
179
+ granted = self.take(wanted)
180
+ self.held += granted
181
+ return granted
182
+
183
+ def release(self, held: int, used: int) -> None:
184
+ """A document's read ended: of the ``held`` pictures it reserved it used ``used``."""
185
+ self.held -= held
186
+ self.left += held - min(used, held)
187
+ if self._changed is not None:
188
+ self._changed.set()
189
+ self._changed = None
190
+
191
+ async def take_waiting(self, wanted: int) -> int:
192
+ """:meth:`take`, but while documents still hold pictures, wait for them before saying none."""
193
+ while True:
194
+ granted = self.take(wanted)
195
+ if granted or self.held <= 0:
196
+ return granted
197
+ if self._changed is None:
198
+ self._changed = asyncio.Event()
199
+ await self._changed.wait()
200
+
201
+
202
+ def is_image(mime: str) -> bool:
203
+ return mime.split(";", 1)[0].strip().lower() in IMAGE_MIMES
204
+
205
+
206
+ def is_document(mime: str, name: str = "") -> bool:
207
+ base = mime.split(";", 1)[0].strip().lower()
208
+ if base in _RICH or base in _TEXTUAL or (base.startswith("text/")):
209
+ return True
210
+ # Browsers send a .docx as a generic type now and then.
211
+ return base in {"application/octet-stream", "application/zip"} and name.lower().endswith(
212
+ (".docx", ".xlsx", ".pptx", ".pdf")
213
+ )
214
+
215
+
216
+ def _effective_mime(file: AttachedFile) -> str:
217
+ base = file.base_mime
218
+ if base in {"application/octet-stream", "application/zip"}:
219
+ ext = file.name.lower().rsplit(".", 1)[-1]
220
+ return {"docx": _DOCX, "xlsx": _XLSX, "pptx": _PPTX, "pdf": _PDF}.get(ext, base)
221
+ return base
222
+
223
+
224
+ def fmt_size(n_bytes: int) -> str:
225
+ """visual-builder's size label."""
226
+ if n_bytes < 1024:
227
+ return f"{n_bytes}B"
228
+ if n_bytes < 1024 * 1024:
229
+ return f"{n_bytes / 1024:.1f}KB"
230
+ return f"{n_bytes / (1024 * 1024):.1f}MB"
231
+
232
+
233
+ def attached_marker(file: AttachedFile) -> dict[str, Any]:
234
+ """``[Attached: artifact_id=<id> (name=..., mime=..., size=...)]`` — the id the file tools take."""
235
+ return {
236
+ "type": "text",
237
+ "text": (
238
+ f"[Attached: artifact_id={file.artifact_id} (name={file.name or 'unknown'}, "
239
+ f"mime={file.mime}, size={fmt_size(file.size)})]"
240
+ ),
241
+ }
242
+
243
+
244
+ def named(file: AttachedFile, why: str) -> dict[str, Any]:
245
+ """The file named, not shown, with the reason — so the model neither invents it nor denies it."""
246
+ return {
247
+ "type": "text",
248
+ "text": (
249
+ f"[system-note: the user attached {file.name} ({file.mime}), but its content is not shown: "
250
+ f"{why}. Say what the file is, and offer what you can do without its content.]"
251
+ ),
252
+ }
253
+
254
+
255
+ def aggregate_refused_note(n_files: int, share_tokens: int) -> dict[str, Any]:
256
+ return {
257
+ "type": "text",
258
+ "text": (
259
+ f"[system-note: ARTIFACT_AGGREGATE_TRUNCATED — {n_files} files attached but conversation "
260
+ f"context is nearly full ({share_tokens} tokens/file share < {MIN_FLOOR_TOKENS_PER_FILE} "
261
+ "minimum). Content omitted; agent has metadata only. Start a new conversation to attach "
262
+ "large files.]"
263
+ ),
264
+ }
265
+
266
+
267
+ def text_cap_chars(share_tokens: int, limits: AttachmentLimits) -> int:
268
+ """A file's text cap: its share at 4 characters a token, never above the per-file ceiling."""
269
+ return max(0, min(limits.max_text_chars, cap_bytes_for_share("text/plain", share_tokens)))
270
+
271
+
272
+ def _note(text: str) -> dict[str, Any]:
273
+ return {"type": "text", "text": text}
274
+
275
+
276
+ def _image_block(data: bytes, media_type: str) -> dict[str, Any]:
277
+ return {
278
+ "type": "image",
279
+ "source": {"type": "base64", "media_type": media_type, "data": base64.b64encode(data).decode("ascii")},
280
+ }
281
+
282
+
283
+ # ── documents: read by file-artifact ────────────────────────────────────────
284
+
285
+
286
+ class _Held:
287
+ """One document's reservation: given back exactly once, whatever way the read ends."""
288
+
289
+ def __init__(self, pictures: TurnPictures, n: int) -> None:
290
+ self.pictures = pictures
291
+ self.n = n
292
+ self._open = True
293
+
294
+ def release(self, used: int) -> None:
295
+ if self._open:
296
+ self._open = False
297
+ self.pictures.release(self.n, used)
298
+
299
+
300
+ async def document_blocks(
301
+ client: Any,
302
+ file: AttachedFile,
303
+ *,
304
+ share_tokens: int,
305
+ n_files: int,
306
+ pictures: TurnPictures,
307
+ limits: AttachmentLimits = AttachmentLimits(), # noqa: B008 - frozen, shared default
308
+ ocr: dict[str, Any] | None = None,
309
+ ) -> FileBlocks:
310
+ """A document's blocks: notes first, then its text, its charts as text, then its pictures.
311
+
312
+ ``client`` is a :class:`~keystone.agent_tools.files.FileArtifactClient` (or a view of one)
313
+ acting as whoever may read the file. Never raises: a refusal is a note.
314
+ """
315
+ mime = _effective_mime(file)
316
+ rich_type = mime in _RICH
317
+ # Size 0 means "unknown" (no Content-Length on the fetch): not counted as small.
318
+ small = 0 < file.size <= limits.small_file_bytes
319
+ # Reserved BEFORE the read (and the unused part given back after): with several files read
320
+ # together, the turn's pictures go to the files in the order they were attached. A photo read
321
+ # alongside waits for this reservation to end (TurnPictures.take_waiting), so it must always end.
322
+ want_pictures = rich_type and small and pictures.left > 0
323
+ held = _Held(pictures, pictures.hold(limits.images_per_file) if want_pictures else 0)
324
+ try:
325
+ return await _document_blocks_held(
326
+ client, file, share_tokens=share_tokens, n_files=n_files, held=held, limits=limits, ocr=ocr
327
+ )
328
+ finally:
329
+ held.release(0) # no-op once the read gave back what it did not use
330
+
331
+
332
+ async def _document_blocks_held(
333
+ client: Any,
334
+ file: AttachedFile,
335
+ *,
336
+ share_tokens: int,
337
+ n_files: int,
338
+ held: _Held,
339
+ limits: AttachmentLimits,
340
+ ocr: dict[str, Any] | None,
341
+ ) -> FileBlocks:
342
+ mime = _effective_mime(file)
343
+ cap = text_cap_chars(share_tokens, limits)
344
+ rich_type = mime in _RICH
345
+ small = 0 < file.size <= limits.small_file_bytes
346
+ per_file = held.n
347
+ kwargs: dict[str, Any] = {"artifact_id": file.artifact_id, "max_chars": max(1, cap)}
348
+ if mime == _PDF:
349
+ kwargs["range_spec"] = f"1-{limits.small_pdf_pages}"
350
+ if rich_type:
351
+ # Charts are text and cheap; for a PDF this also returns its page count.
352
+ kwargs["include_charts"] = True
353
+ if per_file:
354
+ kwargs["include_images"] = True
355
+ kwargs["max_images"] = per_file
356
+ if mime == _PDF:
357
+ # Pages are rendered only for a small PDF — file-artifact checks the count first.
358
+ kwargs["pdf_max_pages"] = limits.small_pdf_pages
359
+ if ocr is not None:
360
+ kwargs["ocr"] = ocr
361
+ try:
362
+ try:
363
+ result = await client.read_artifact(**kwargs)
364
+ except Exception as exc:
365
+ # A file-artifact older than these options refuses them (422, extra="forbid"): read the
366
+ # plain text window instead, so a rolling deploy never makes a file unreadable.
367
+ if _status_of(exc) != 422 or set(kwargs) <= {"artifact_id", "range_spec", "ocr"}:
368
+ raise
369
+ plain = {k: v for k, v in kwargs.items() if k in {"artifact_id", "range_spec", "ocr"}}
370
+ result = await client.read_artifact(**plain)
371
+ if len(result.text) > cap:
372
+ result = dataclasses.replace(
373
+ result, text=result.text[:cap], notes=(*result.notes, f"[Đã cắt: chỉ gửi {cap} ký tự đầu.]")
374
+ )
375
+ except FileArtifactError as exc:
376
+ held.release(0)
377
+ if exc.status == 415 or exc.code == "FA_UNSUPPORTED_MEDIA_TYPE":
378
+ return FileBlocks([named(file, f"this assistant cannot read {file.mime} files")], "named")
379
+ _logger.warning("attachment not read artifact_id=%s code=%s", file.artifact_id, exc.code)
380
+ return FileBlocks([_unreadable(file)], "unreadable")
381
+ except Exception as exc: # a host's own typed errors (visual-builder raises VBError subclasses)
382
+ held.release(0)
383
+ status = _status_of(exc)
384
+ if status == 415:
385
+ return FileBlocks([named(file, f"this assistant cannot read {file.mime} files")], "named")
386
+ _logger.warning("attachment not read artifact_id=%s error=%s", file.artifact_id, exc)
387
+ return FileBlocks([_unreadable(file)], "unreadable")
388
+
389
+ notes: list[str] = list(result.notes)
390
+ page_count = result.meta.get("page_count") if isinstance(result.meta, dict) else None
391
+ images = list(result.images)
392
+ if mime == _PDF and isinstance(page_count, int) and page_count > limits.small_pdf_pages:
393
+ notes.insert(
394
+ 0,
395
+ f"[Tài liệu PDF có {page_count} trang: chỉ đọc chữ của {limits.small_pdf_pages} trang đầu. "
396
+ "Trả lời theo phần này và nói rõ khi câu hỏi nằm ở trang sau.]",
397
+ )
398
+ if images:
399
+ images = [] # a long PDF is not "small": text only
400
+ notes.append("[PDF dài: không gửi ảnh trang.]")
401
+ if rich_type and not small and file.size:
402
+ notes.append(
403
+ f"[File lớn ({fmt_size(file.size)}): chỉ gửi chữ; ảnh và biểu đồ dạng ảnh trong file không được gửi.]"
404
+ )
405
+ elif rich_type and small and not per_file:
406
+ notes.append("[Lượt này đã đủ số ảnh: ảnh trong file này không được gửi.]")
407
+
408
+ blocks: list[dict[str, Any]] = [_note(f"[system-note: {file.name} — {n.strip().strip('[]')}]") for n in notes]
409
+ text = result.text or ""
410
+ if text.strip():
411
+ blocks.append(_note(f"[Attached file content: {file.name}]\n{text}"))
412
+ for chart in result.charts:
413
+ blocks.append(_note(f"[Biểu đồ trong {file.name}]\n{chart}"))
414
+
415
+ granted = min(len(images), per_file)
416
+ held.release(granted) # the reserved pictures this file did not use go back
417
+ sent = 0
418
+ byte_cap = min(limits.image_max_bytes, cap_bytes_for_share("image/png", share_tokens))
419
+ for image in images[:granted]:
420
+ try:
421
+ raw = base64.b64decode(image["data"], validate=True)
422
+ except (ValueError, KeyError, TypeError):
423
+ continue
424
+ fitted = await _fit(raw, str(image.get("mime") or "image/png"), file.name, byte_cap, n_files)
425
+ if fitted is None:
426
+ continue
427
+ data, media_type, _ = fitted
428
+ source = image.get("source")
429
+ blocks.append(_note(f"[Ảnh trong {file.name}{f' ({source})' if source else ''}]"))
430
+ blocks.append(_image_block(data, media_type))
431
+ sent += 1
432
+ if len(images) > granted:
433
+ blocks.append(_note(f"[system-note: {file.name} — {len(images) - granted} ảnh không gửi vì đã đủ số ảnh.]"))
434
+ if not text.strip() and not result.charts and not sent:
435
+ return FileBlocks([*blocks, named(file, "no text could be read from it")], "named", sent)
436
+ return FileBlocks(blocks, "shown", sent)
437
+
438
+
439
+ def _status_of(exc: BaseException) -> int | None:
440
+ """The HTTP status of a refusal: the lib's ``FileArtifactError`` or a host's typed error."""
441
+ status = getattr(exc, "status", None)
442
+ if isinstance(status, int) and status:
443
+ return status
444
+ details = getattr(exc, "details", None)
445
+ found = details.get("status") if isinstance(details, dict) else None
446
+ return found if isinstance(found, int) else None
447
+
448
+
449
+ def _unreadable(file: AttachedFile) -> dict[str, Any]:
450
+ return _note(
451
+ f"[system-note: the user attached {file.name} ({file.mime}), but it could not be read right now. "
452
+ "Say so plainly and offer to have it sent again — do not answer as if nothing was attached.]"
453
+ )
454
+
455
+
456
+ async def _fit(raw: bytes | IO[bytes], mime: str, name: str, byte_cap: int, n_files: int):
457
+ try:
458
+ return await fit_image_async(raw, mime, name, byte_cap=byte_cap, n_files=n_files)
459
+ except DecodeRefused:
460
+ return None
461
+
462
+
463
+ # ── a photo / screenshot: the host fetched its bytes ────────────────────────
464
+
465
+
466
+ async def image_blocks(
467
+ content: bytes | IO[bytes],
468
+ file: AttachedFile,
469
+ *,
470
+ share_tokens: int,
471
+ n_files: int,
472
+ pictures: TurnPictures,
473
+ limits: AttachmentLimits = AttachmentLimits(), # noqa: B008 - frozen, shared default
474
+ ) -> FileBlocks:
475
+ """A picture the user attached, downscaled to the provider's limit (in a worker thread)."""
476
+ if await pictures.take_waiting(1) == 0:
477
+ return FileBlocks([named(file, "this message already carries as many pictures as one turn may")], "named")
478
+ byte_cap = min(limits.image_max_bytes, cap_bytes_for_share(file.base_mime, share_tokens))
479
+ try:
480
+ fitted = await fit_image_async(content, file.base_mime, file.name, byte_cap=byte_cap, n_files=n_files)
481
+ except DecodeRefused as refused:
482
+ pictures.left += 1
483
+ size = f"{refused.width}x{refused.height} px " if refused.width else ""
484
+ return FileBlocks([named(file, f"the {size}picture is over the safe decode limit")], "named")
485
+ if fitted is None:
486
+ pictures.left += 1
487
+ return FileBlocks([named(file, "it is too large to display here and could not be reduced")], "named")
488
+ data, media_type, note = fitted
489
+ blocks = [_note(note)] if note else []
490
+ blocks.append(_image_block(data, media_type))
491
+ return FileBlocks(blocks, "shown", 1)