dex-agents-cli-core 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ """The shared CLI core of the Dex Agents CLI commands.
10
+
11
+ A harness package implements ``AgentHarness`` and calls ``run_agent_cli`` with it.
12
+ Import this package before any module that imports gRPC, such as ``dex``.
13
+ """
14
+
15
+ import os
16
+
17
+ # gRPC's fork handlers print into every shell command the local tools fork; the CLI never
18
+ # forks a gRPC user. The variable only takes effect before gRPC is first imported.
19
+ os.environ.setdefault("GRPC_ENABLE_FORK_SUPPORT", "false")
20
+
21
+ # isort: split
22
+
23
+ from dex_agents_cli_core.cli import command_name, run_agent_cli
24
+ from dex_agents_cli_core.config import (
25
+ AUTO,
26
+ ConfigError,
27
+ ConnectorEntry,
28
+ EffectiveConfig,
29
+ MediaConfig,
30
+ TurnConfigResolution,
31
+ canonical_operation_name,
32
+ is_shell_command_allowed,
33
+ parse_config_yaml,
34
+ resolve_config,
35
+ resolve_turn_config,
36
+ )
37
+ from dex_agents_cli_core.connectors import (
38
+ CONNECTOR_TOOL_META_KEYS,
39
+ CONNECTORS_TOOL_SOURCE_NAME,
40
+ ConnectorsMcpToolSource,
41
+ ConnectorToolMetaKeys,
42
+ )
43
+ from dex_agents_cli_core.harness import (
44
+ SESSION_ACTION_PERMISSION,
45
+ AgentHarness,
46
+ HarnessContext,
47
+ LocalToolGroup,
48
+ SessionEnvironmentIdentity,
49
+ current_session_environment_identity,
50
+ namespace_session_flow_type,
51
+ session_flow_type,
52
+ )
53
+ from dex_agents_cli_core.local_tools import LOCAL_TOOL_SOURCE_NAME, LocalToolSource
54
+ from dex_agents_cli_core.tool_policy import (
55
+ is_local_tool_group_enabled,
56
+ is_shell_command_approval_required,
57
+ resolve_turn_tool_policy,
58
+ )
59
+ from dex_agents_cli_core.turns import (
60
+ ResolvedTurn,
61
+ TurnConfigResolver,
62
+ current_turn_config,
63
+ with_turn_config,
64
+ )
65
+
66
+ __all__ = [
67
+ "AUTO",
68
+ "CONNECTORS_TOOL_SOURCE_NAME",
69
+ "CONNECTOR_TOOL_META_KEYS",
70
+ "LOCAL_TOOL_SOURCE_NAME",
71
+ "SESSION_ACTION_PERMISSION",
72
+ "AgentHarness",
73
+ "ConfigError",
74
+ "ConnectorEntry",
75
+ "ConnectorToolMetaKeys",
76
+ "ConnectorsMcpToolSource",
77
+ "EffectiveConfig",
78
+ "HarnessContext",
79
+ "LocalToolGroup",
80
+ "LocalToolSource",
81
+ "MediaConfig",
82
+ "ResolvedTurn",
83
+ "SessionEnvironmentIdentity",
84
+ "TurnConfigResolution",
85
+ "TurnConfigResolver",
86
+ "canonical_operation_name",
87
+ "command_name",
88
+ "current_session_environment_identity",
89
+ "current_turn_config",
90
+ "is_local_tool_group_enabled",
91
+ "is_shell_command_allowed",
92
+ "is_shell_command_approval_required",
93
+ "namespace_session_flow_type",
94
+ "parse_config_yaml",
95
+ "resolve_config",
96
+ "resolve_turn_config",
97
+ "resolve_turn_tool_policy",
98
+ "run_agent_cli",
99
+ "session_flow_type",
100
+ "with_turn_config",
101
+ ]
@@ -0,0 +1,538 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ """The terminal chat: messages, streamed replies, approvals, questions, and Ctrl-C."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import json
16
+ import os
17
+ import signal
18
+ import stat
19
+ import sys
20
+ from collections import deque
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass, field
23
+ from datetime import timedelta
24
+ from enum import Enum
25
+ from pathlib import Path
26
+ from typing import Any, TextIO
27
+
28
+ from dex import AsyncClient, LongPollTimeoutError
29
+
30
+ from dex_agents import (
31
+ MAX_IMAGE_BYTES,
32
+ MAX_IMAGES_PER_MESSAGE,
33
+ AgentEvent,
34
+ AgentEventType,
35
+ AgentImageReference,
36
+ AgentInterrupt,
37
+ AgentSessionClient,
38
+ AgentSessionStatus,
39
+ AgentSnapshot,
40
+ ImageValidationError,
41
+ InterruptKind,
42
+ MediaStore,
43
+ MessageRole,
44
+ detect_image_mime_type,
45
+ )
46
+
47
+ #: The line that ends the chat, like Ctrl-D.
48
+ EXIT_COMMAND = "/exit"
49
+ #: The command that attaches an image file to the next message: ``/image <path>``.
50
+ IMAGE_COMMAND = "/image"
51
+ _POLL_INTERVAL_SECONDS = 0.3
52
+ _EVENT_LONG_POLL = timedelta(seconds=5)
53
+ _MAX_SHOWN_ARGUMENT_CHARACTERS = 2000
54
+ _KIB = 1024
55
+
56
+
57
+ class TerminalSignal(Enum):
58
+ """Input that is not a line: Ctrl-C, or the end of input (Ctrl-D)."""
59
+
60
+ INTERRUPT = "interrupt"
61
+ END_OF_INPUT = "end_of_input"
62
+
63
+
64
+ TerminalInput = str | TerminalSignal
65
+
66
+
67
+ class Terminal:
68
+ """Read lines and Ctrl-C from the terminal on the event loop, and write output.
69
+
70
+ Lines are read with ``loop.add_reader``, so no thread blocks on input,
71
+ and SIGINT becomes ``TerminalSignal.INTERRUPT`` instead of
72
+ ``KeyboardInterrupt``.
73
+ """
74
+
75
+ def __init__(self, input_fd: int | None = None, output: TextIO | None = None) -> None:
76
+ """Use a terminal's file descriptors.
77
+
78
+ Args:
79
+ input_fd: The input file descriptor; ``None`` uses standard input.
80
+ output: The output stream; ``None`` uses standard output.
81
+ """
82
+ self._input_fd = sys.stdin.fileno() if input_fd is None else input_fd
83
+ self._output = output or sys.stdout
84
+ self._queue: asyncio.Queue[TerminalInput] = asyncio.Queue()
85
+ self._partial = b""
86
+ self._is_input_open = True
87
+
88
+ def start(self) -> None:
89
+ """Start reading input and catching Ctrl-C."""
90
+ loop = asyncio.get_running_loop()
91
+ try:
92
+ loop.add_reader(self._input_fd, self._read_input)
93
+ except (OSError, ValueError):
94
+ # Input the selector cannot watch, such as /dev/null, reads as ended.
95
+ self._is_input_open = False
96
+ self._queue.put_nowait(TerminalSignal.END_OF_INPUT)
97
+ loop.add_signal_handler(signal.SIGINT, self._queue.put_nowait, TerminalSignal.INTERRUPT)
98
+
99
+ def stop(self) -> None:
100
+ """Stop reading input and restore Ctrl-C."""
101
+ loop = asyncio.get_running_loop()
102
+ if self._is_input_open:
103
+ loop.remove_reader(self._input_fd)
104
+ loop.remove_signal_handler(signal.SIGINT)
105
+
106
+ async def next_input(self, timeout: float | None = None) -> TerminalInput | None:
107
+ """Return the next line or signal.
108
+
109
+ Args:
110
+ timeout: Seconds to wait, or ``None`` to wait for input.
111
+
112
+ Returns:
113
+ The input, or ``None`` when the timeout passes first.
114
+ """
115
+ try:
116
+ return await asyncio.wait_for(self._queue.get(), timeout)
117
+ except TimeoutError:
118
+ return None
119
+
120
+ def write(self, text: str) -> None:
121
+ """Write text and flush it."""
122
+ self._output.write(text)
123
+ self._output.flush()
124
+
125
+ def _read_input(self) -> None:
126
+ try:
127
+ chunk = os.read(self._input_fd, 4096)
128
+ except BlockingIOError:
129
+ return
130
+ except OSError:
131
+ chunk = b""
132
+ if not chunk:
133
+ asyncio.get_running_loop().remove_reader(self._input_fd)
134
+ self._is_input_open = False
135
+ if self._partial:
136
+ self._queue.put_nowait(self._partial.decode("utf-8", errors="replace"))
137
+ self._partial = b""
138
+ self._queue.put_nowait(TerminalSignal.END_OF_INPUT)
139
+ return
140
+ self._partial += chunk
141
+ *lines, self._partial = self._partial.split(b"\n")
142
+ for line in lines:
143
+ self._queue.put_nowait(line.decode("utf-8", errors="replace").rstrip("\r"))
144
+
145
+
146
+ @dataclass
147
+ class _TurnView:
148
+ """What the terminal showed of one turn."""
149
+
150
+ is_text_shown: bool = False
151
+ is_failure_shown: bool = False
152
+ attempts: dict[str, int] = field(default_factory=dict)
153
+
154
+
155
+ class TerminalChat:
156
+ """Chat with one agent session in the terminal.
157
+
158
+ Each line is a user message. Replies and tool calls stream from the
159
+ session's events. Approvals and questions are answered inline. The first
160
+ Ctrl-C during a turn cancels it; another Ctrl-C, or Ctrl-C, Ctrl-D, or
161
+ ``/exit`` while idle, exits. Exiting never ends the session.
162
+ ``/image <path>`` copies an image file into the media store and sends it
163
+ with the next message.
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ client: AsyncClient,
169
+ sessions: AgentSessionClient,
170
+ session_id: str,
171
+ terminal: Terminal,
172
+ resume_command: str,
173
+ *,
174
+ media_store: MediaStore,
175
+ home: Path,
176
+ ) -> None:
177
+ """Chat with one session.
178
+
179
+ Args:
180
+ client: The Dex Client, to find the newest session event.
181
+ sessions: The session client.
182
+ session_id: The session.
183
+ terminal: The terminal.
184
+ resume_command: The command that resumes this session, shown on exit.
185
+ media_store: The store that ``/image`` uploads to.
186
+ home: The home directory that a leading ``~`` in an image path means.
187
+ """
188
+ self._client = client
189
+ self._sessions = sessions
190
+ self._session_id = session_id
191
+ self._terminal = terminal
192
+ self._resume_command = resume_command
193
+ self._media_store = media_store
194
+ self._home = home
195
+ self._typeahead: deque[str] = deque()
196
+ self._pending_images: list[AgentImageReference] = []
197
+ self._is_exit_requested = False
198
+ self._is_input_ended = False
199
+ self._has_failure = False
200
+
201
+ async def run(self) -> bool:
202
+ """Chat until the user exits.
203
+
204
+ Returns:
205
+ ``False`` when a turn or the session failed during the chat, so the
206
+ command exits with code 1; ``True`` otherwise.
207
+ """
208
+ self._terminal.write(
209
+ f"Type a message; {IMAGE_COMMAND} <path> attaches an image to the next one; "
210
+ f"{EXIT_COMMAND} quits. Ctrl-C cancels a turn, and again exits.\n"
211
+ )
212
+ snapshot = await self._sessions.get_agent_snapshot(self._session_id)
213
+ if snapshot.state is not None and snapshot.state.active_turn_id is not None:
214
+ self._terminal.write("A turn is running; following it.\n")
215
+ await self._follow_turn(snapshot.state.turn_count, await self._event_tail())
216
+ while not self._is_exit_requested:
217
+ self._terminal.write("\n> ")
218
+ line = await self._next_line()
219
+ if line is None:
220
+ break
221
+ if not line.strip():
222
+ continue
223
+ if line.strip() == EXIT_COMMAND:
224
+ break
225
+ command, _, argument = line.strip().partition(" ")
226
+ if command == IMAGE_COMMAND:
227
+ await self._attach_image(argument)
228
+ continue
229
+ await self._send(line)
230
+ self._terminal.write(
231
+ f"\nSession {self._session_id} keeps running. Resume it with: "
232
+ f"{self._resume_command} --resume {self._session_id}\n"
233
+ )
234
+ return not self._has_failure
235
+
236
+ async def _send(self, text: str) -> None:
237
+ snapshot = await self._sessions.get_agent_snapshot(self._session_id)
238
+ if snapshot.state is None or snapshot.state.status is AgentSessionStatus.FAILED:
239
+ reason = snapshot.state.last_turn_error if snapshot.state else "it has not started"
240
+ self._terminal.write(f"[session failed] {reason}\n")
241
+ self._has_failure = True
242
+ self._is_exit_requested = True
243
+ return
244
+ tail = await self._event_tail()
245
+ images, self._pending_images = self._pending_images, []
246
+ if not await self._sessions.send_user_message(self._session_id, text, images=images):
247
+ if images:
248
+ self._terminal.write(
249
+ "[not sent] this agent does not take images. The attached images "
250
+ f"({_count(images, 'image')}) were removed; send the message again "
251
+ "without them.\n"
252
+ )
253
+ else:
254
+ self._terminal.write("[not sent]\n")
255
+ return
256
+ await self._follow_turn(snapshot.state.turn_count, tail)
257
+
258
+ async def _attach_image(self, argument: str) -> None:
259
+ """Copy one image file into the media store and keep it for the next message."""
260
+ path_text = _unquote(argument.strip())
261
+ if not path_text:
262
+ self._terminal.write(f"[image not attached] usage: {IMAGE_COMMAND} <path>\n")
263
+ return
264
+ if len(self._pending_images) >= MAX_IMAGES_PER_MESSAGE:
265
+ self._terminal.write(
266
+ f"[image not attached] a message carries at most {MAX_IMAGES_PER_MESSAGE} images\n"
267
+ )
268
+ return
269
+ path = self._resolve_image_path(path_text)
270
+ try:
271
+ data = _read_image_file(path)
272
+ mime_type = detect_image_mime_type(data)
273
+ except OSError as error:
274
+ self._terminal.write(f"[image not attached] {error}\n")
275
+ return
276
+ except ImageValidationError as error:
277
+ self._terminal.write(f"[image not attached] {path}: {error}\n")
278
+ return
279
+ try:
280
+ reference = await self._media_store.put_image(data)
281
+ except Exception as error: # Each store raises its own upload errors, such as S3's.
282
+ self._terminal.write(f"[image not attached] cannot store {path}: {error}\n")
283
+ return
284
+ self._pending_images.append(reference)
285
+ self._terminal.write(
286
+ f"[image attached] {path.name} ({mime_type}, {_format_size(len(data))}); "
287
+ f"it goes with your next message ({_count(self._pending_images, 'image')} attached)\n"
288
+ )
289
+
290
+ def _resolve_image_path(self, path_text: str) -> Path:
291
+ if path_text == "~" or path_text.startswith("~/"):
292
+ return self._home / path_text[2:]
293
+ return Path.cwd() / path_text
294
+
295
+ async def _follow_turn(self, turn_count_before: int, event_tail: str) -> None:
296
+ view = _TurnView()
297
+ events = asyncio.create_task(self._render_events(event_tail, view))
298
+ handled_interrupts: set[str] = set()
299
+ is_cancelling = False
300
+ try:
301
+ while True:
302
+ snapshot = await self._sessions.get_agent_snapshot(self._session_id)
303
+ state = snapshot.state
304
+ if state is None:
305
+ return
306
+ if state.status is AgentSessionStatus.FAILED:
307
+ self._terminal.write(f"\n[session failed] {state.last_turn_error}\n")
308
+ self._has_failure = True
309
+ self._is_exit_requested = True
310
+ return
311
+ if state.turn_count > turn_count_before and state.active_turn_id is None:
312
+ await asyncio.sleep(_POLL_INTERVAL_SECONDS)
313
+ self._finish_turn(snapshot, f"turn-{turn_count_before + 1}", view)
314
+ return
315
+ if not is_cancelling:
316
+ for interrupt in snapshot.pending_interrupts:
317
+ if interrupt.interrupt_id in handled_interrupts:
318
+ continue
319
+ handled_interrupts.add(interrupt.interrupt_id)
320
+ if not await self._answer_interrupt(interrupt):
321
+ if self._is_exit_requested:
322
+ return
323
+ is_cancelling = await self._on_interrupt_key(is_cancelling)
324
+ break
325
+ received = await self._terminal.next_input(_POLL_INTERVAL_SECONDS)
326
+ if received is TerminalSignal.INTERRUPT:
327
+ is_cancelling = await self._on_interrupt_key(is_cancelling)
328
+ elif received is TerminalSignal.END_OF_INPUT:
329
+ # Piped input ends early; the turn still finishes before the command exits.
330
+ self._is_input_ended = True
331
+ elif isinstance(received, str):
332
+ self._typeahead.append(received)
333
+ if self._is_exit_requested:
334
+ return
335
+ finally:
336
+ events.cancel()
337
+ with contextlib.suppress(asyncio.CancelledError):
338
+ await events
339
+
340
+ async def _on_interrupt_key(self, is_cancelling: bool) -> bool:
341
+ """Cancel the turn on the first Ctrl-C and request exit on the next; return cancelling."""
342
+ if is_cancelling:
343
+ self._is_exit_requested = True
344
+ return True
345
+ self._terminal.write("\n[cancelling the turn; Ctrl-C again exits]\n")
346
+ if not await self._sessions.cancel_turn(self._session_id):
347
+ self._terminal.write("[no turn to cancel]\n")
348
+ return True
349
+
350
+ async def _answer_interrupt(self, interrupt: AgentInterrupt) -> bool:
351
+ """Ask the user about one interrupt; return ``False`` when they pressed Ctrl-C."""
352
+ if interrupt.kind is InterruptKind.QUESTION:
353
+ return await self._answer_question(interrupt)
354
+ arguments = interrupt.arguments
355
+ if len(arguments) > _MAX_SHOWN_ARGUMENT_CHARACTERS:
356
+ arguments = arguments[:_MAX_SHOWN_ARGUMENT_CHARACTERS] + " ..."
357
+ self._terminal.write(
358
+ f"\n[approval] {interrupt.tool_name} {arguments}\nApprove? [y/N, or n <reason>] "
359
+ )
360
+ answer = await self._next_answer()
361
+ if answer is None:
362
+ return False
363
+ approved = answer.strip().lower() in ("y", "yes")
364
+ reason = None
365
+ if not approved:
366
+ words = answer.strip().split(maxsplit=1)
367
+ if words and words[0].lower() in ("n", "no"):
368
+ words = words[1:]
369
+ reason = words[0] if words else None
370
+ resolved = await self._sessions.resolve_interrupt(
371
+ self._session_id, interrupt.interrupt_id, approved=approved, rejection_message=reason
372
+ )
373
+ self._terminal.write(
374
+ ("[approved]\n" if approved else "[rejected]\n") if resolved else "[already resolved]\n"
375
+ )
376
+ return True
377
+
378
+ async def _answer_question(self, interrupt: AgentInterrupt) -> bool:
379
+ choices = "".join(
380
+ f"\n {index}) {choice}" for index, choice in enumerate(interrupt.choices, 1)
381
+ )
382
+ self._terminal.write(
383
+ f"\n[question] {interrupt.question}{choices}\nAnswer (empty declines): "
384
+ )
385
+ answer = await self._next_answer()
386
+ if answer is None:
387
+ return False
388
+ answer = answer.strip()
389
+ if not answer:
390
+ resolved = await self._sessions.resolve_interrupt(
391
+ self._session_id, interrupt.interrupt_id, approved=False
392
+ )
393
+ else:
394
+ if answer.isdigit() and 1 <= int(answer) <= len(interrupt.choices):
395
+ answer = interrupt.choices[int(answer) - 1]
396
+ resolved = await self._sessions.answer_question(
397
+ self._session_id, interrupt.interrupt_id, answer
398
+ )
399
+ self._terminal.write("[answered]\n" if resolved else "[not accepted]\n")
400
+ return True
401
+
402
+ def _finish_turn(self, snapshot: AgentSnapshot, turn_id: str, view: _TurnView) -> None:
403
+ if not view.is_text_shown:
404
+ replies = [
405
+ message.text
406
+ for message in snapshot.messages
407
+ if message.turn_id == turn_id
408
+ and message.role is MessageRole.ASSISTANT
409
+ and message.text
410
+ ]
411
+ if replies:
412
+ self._terminal.write(replies[-1])
413
+ state = snapshot.state
414
+ if state is not None and state.last_turn_error and not view.is_failure_shown:
415
+ self._terminal.write(f"\n[turn failed] {state.last_turn_error}")
416
+ if state is not None and state.last_turn_error:
417
+ self._has_failure = True
418
+ self._terminal.write("\n")
419
+
420
+ async def _render_events(self, resume_token: str, view: _TurnView) -> None:
421
+ token = resume_token
422
+ while True:
423
+ try:
424
+ message = await self._sessions.read_agent_event(
425
+ self._session_id, token, _EVENT_LONG_POLL
426
+ )
427
+ except LongPollTimeoutError:
428
+ continue
429
+ token = message.resume_token
430
+ self._render_event(message.value, view)
431
+
432
+ def _render_event(self, event: AgentEvent, view: _TurnView) -> None:
433
+ if event.step_execution_id:
434
+ seen = view.attempts.get(event.step_execution_id, 0)
435
+ if event.attempt < seen:
436
+ return
437
+ if seen and event.attempt > seen and view.is_text_shown:
438
+ self._terminal.write("\n[retrying]\n")
439
+ view.attempts[event.step_execution_id] = event.attempt
440
+ payload = _read_payload(event.payload)
441
+ match event.type:
442
+ case AgentEventType.TEXT_DELTA:
443
+ text = str(payload.get("text", ""))
444
+ if text:
445
+ view.is_text_shown = True
446
+ self._terminal.write(text)
447
+ case AgentEventType.TOOL_CALL_STARTED:
448
+ self._terminal.write(f"\n[tool] {payload.get('tool_name')} started\n")
449
+ case AgentEventType.TOOL_CALL_FINISHED:
450
+ self._terminal.write(f"[tool] {payload.get('tool_name')} {payload.get('status')}\n")
451
+ case AgentEventType.TURN_FAILED:
452
+ view.is_failure_shown = True
453
+ self._terminal.write(f"\n[turn failed] {payload.get('error')}")
454
+ case AgentEventType.TURN_CANCELLED:
455
+ self._terminal.write("\n[turn cancelled]")
456
+
457
+ async def _next_line(self) -> str | None:
458
+ """Return the next message line; ``None`` when the user exits."""
459
+ if self._typeahead:
460
+ line = self._typeahead.popleft()
461
+ self._terminal.write(line + "\n")
462
+ return line
463
+ if self._is_input_ended:
464
+ self._is_exit_requested = True
465
+ return None
466
+ received = await self._terminal.next_input()
467
+ if isinstance(received, str):
468
+ return received
469
+ self._is_exit_requested = True
470
+ return None
471
+
472
+ async def _next_answer(self) -> str | None:
473
+ """Return the answer to a prompt; ``None`` for Ctrl-C or the end of input."""
474
+ if self._typeahead:
475
+ return self._typeahead.popleft()
476
+ if self._is_input_ended:
477
+ self._is_exit_requested = True
478
+ return None
479
+ received = await self._terminal.next_input()
480
+ if received is TerminalSignal.END_OF_INPUT:
481
+ self._is_input_ended = True
482
+ self._is_exit_requested = True
483
+ return received if isinstance(received, str) else None
484
+
485
+ async def _event_tail(self) -> str:
486
+ """Return the resume token of the newest event, so old events are not shown again."""
487
+ page = await self._client.list_stream_messages(
488
+ self._session_id, self._sessions.flow.agent_events, 1
489
+ )
490
+ return page.messages[0].resume_token if page.messages else ""
491
+
492
+
493
+ def _read_image_file(path: Path) -> bytes:
494
+ """Read an image file, refusing a missing, special, or oversized file before reading it."""
495
+ try:
496
+ status = path.stat()
497
+ except FileNotFoundError as error:
498
+ raise FileNotFoundError(f"{path} does not exist") from error
499
+ if not stat.S_ISREG(status.st_mode):
500
+ raise OSError(f"{path} is not a regular file")
501
+ if status.st_size > MAX_IMAGE_BYTES:
502
+ raise OSError(_too_large_message(path, status.st_size))
503
+ with path.open("rb") as file:
504
+ data = file.read(MAX_IMAGE_BYTES + 1)
505
+ if len(data) > MAX_IMAGE_BYTES:
506
+ raise OSError(_too_large_message(path, len(data)))
507
+ return data
508
+
509
+
510
+ def _too_large_message(path: Path, size: int) -> str:
511
+ return f"{path} has {size:,} bytes; an image may have at most {MAX_IMAGE_BYTES:,}"
512
+
513
+
514
+ def _format_size(size: int) -> str:
515
+ if size < _KIB:
516
+ return f"{size} bytes"
517
+ if size < _KIB * _KIB:
518
+ return f"{round(size / _KIB)} KB"
519
+ return f"{size / (_KIB * _KIB):.1f} MB"
520
+
521
+
522
+ def _count(items: Sequence[object], noun: str) -> str:
523
+ return f"1 {noun}" if len(items) == 1 else f"{len(items)} {noun}s"
524
+
525
+
526
+ def _unquote(text: str) -> str:
527
+ """Drop one pair of matching quotes, which terminals add around a dropped file's path."""
528
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
529
+ return text[1:-1]
530
+ return text
531
+
532
+
533
+ def _read_payload(payload: str) -> dict[str, Any]:
534
+ try:
535
+ parsed = json.loads(payload)
536
+ except json.JSONDecodeError:
537
+ return {}
538
+ return parsed if isinstance(parsed, dict) else {}