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.
- dex_agents_cli_core/__init__.py +101 -0
- dex_agents_cli_core/chat.py +538 -0
- dex_agents_cli_core/cli.py +671 -0
- dex_agents_cli_core/config.py +650 -0
- dex_agents_cli_core/config_file.py +210 -0
- dex_agents_cli_core/connectors.py +531 -0
- dex_agents_cli_core/dex_server.py +345 -0
- dex_agents_cli_core/dexcli_install.py +314 -0
- dex_agents_cli_core/environment.py +136 -0
- dex_agents_cli_core/harness.py +445 -0
- dex_agents_cli_core/local_tools/__init__.py +26 -0
- dex_agents_cli_core/local_tools/browser.py +235 -0
- dex_agents_cli_core/local_tools/files.py +512 -0
- dex_agents_cli_core/local_tools/shell.py +85 -0
- dex_agents_cli_core/local_tools/source.py +458 -0
- dex_agents_cli_core/local_tools/web.py +98 -0
- dex_agents_cli_core/py.typed +0 -0
- dex_agents_cli_core/schemas/__init__.py +9 -0
- dex_agents_cli_core/schemas/config.schema.json +315 -0
- dex_agents_cli_core/schemas/session-settings.schema.json +153 -0
- dex_agents_cli_core/tool_policy.py +179 -0
- dex_agents_cli_core/turns.py +235 -0
- dex_agents_cli_core-0.5.0.dist-info/METADATA +479 -0
- dex_agents_cli_core-0.5.0.dist-info/RECORD +26 -0
- dex_agents_cli_core-0.5.0.dist-info/WHEEL +4 -0
- dex_agents_cli_core-0.5.0.dist-info/licenses/LICENSE +105 -0
|
@@ -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 {}
|