batrachian-toad 0.5.22__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.
- batrachian_toad-0.5.22.dist-info/METADATA +197 -0
- batrachian_toad-0.5.22.dist-info/RECORD +120 -0
- batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
- batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
- batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
- toad/__init__.py +46 -0
- toad/__main__.py +4 -0
- toad/_loop.py +86 -0
- toad/about.py +90 -0
- toad/acp/agent.py +671 -0
- toad/acp/api.py +47 -0
- toad/acp/encode_tool_call_id.py +12 -0
- toad/acp/messages.py +138 -0
- toad/acp/prompt.py +54 -0
- toad/acp/protocol.py +426 -0
- toad/agent.py +62 -0
- toad/agent_schema.py +70 -0
- toad/agents.py +45 -0
- toad/ansi/__init__.py +1 -0
- toad/ansi/_ansi.py +1612 -0
- toad/ansi/_ansi_colors.py +264 -0
- toad/ansi/_control_codes.py +37 -0
- toad/ansi/_keys.py +251 -0
- toad/ansi/_sgr_styles.py +64 -0
- toad/ansi/_stream_parser.py +418 -0
- toad/answer.py +22 -0
- toad/app.py +557 -0
- toad/atomic.py +37 -0
- toad/cli.py +257 -0
- toad/code_analyze.py +28 -0
- toad/complete.py +34 -0
- toad/constants.py +58 -0
- toad/conversation_markdown.py +19 -0
- toad/danger.py +371 -0
- toad/data/agents/ampcode.com.toml +51 -0
- toad/data/agents/augmentcode.com.toml +40 -0
- toad/data/agents/claude.com.toml +41 -0
- toad/data/agents/docker.com.toml +59 -0
- toad/data/agents/geminicli.com.toml +28 -0
- toad/data/agents/goose.ai.toml +51 -0
- toad/data/agents/inference.huggingface.co.toml +33 -0
- toad/data/agents/kimi.com.toml +35 -0
- toad/data/agents/openai.com.toml +53 -0
- toad/data/agents/opencode.ai.toml +61 -0
- toad/data/agents/openhands.dev.toml +44 -0
- toad/data/agents/stakpak.dev.toml +61 -0
- toad/data/agents/vibe.mistral.ai.toml +27 -0
- toad/data/agents/vtcode.dev.toml +62 -0
- toad/data/images/frog.png +0 -0
- toad/data/sounds/turn-over.wav +0 -0
- toad/db.py +5 -0
- toad/dec.py +332 -0
- toad/directory.py +234 -0
- toad/directory_watcher.py +96 -0
- toad/fuzzy.py +140 -0
- toad/gist.py +2 -0
- toad/history.py +138 -0
- toad/jsonrpc.py +576 -0
- toad/menus.py +14 -0
- toad/messages.py +74 -0
- toad/option_content.py +51 -0
- toad/os.py +0 -0
- toad/path_complete.py +145 -0
- toad/path_filter.py +124 -0
- toad/paths.py +71 -0
- toad/pill.py +23 -0
- toad/prompt/extract.py +19 -0
- toad/prompt/resource.py +68 -0
- toad/protocol.py +28 -0
- toad/screens/action_modal.py +94 -0
- toad/screens/agent_modal.py +172 -0
- toad/screens/command_edit_modal.py +58 -0
- toad/screens/main.py +192 -0
- toad/screens/permissions.py +390 -0
- toad/screens/permissions.tcss +72 -0
- toad/screens/settings.py +254 -0
- toad/screens/settings.tcss +101 -0
- toad/screens/store.py +476 -0
- toad/screens/store.tcss +261 -0
- toad/settings.py +354 -0
- toad/settings_schema.py +318 -0
- toad/shell.py +263 -0
- toad/shell_read.py +42 -0
- toad/slash_command.py +34 -0
- toad/toad.tcss +752 -0
- toad/version.py +80 -0
- toad/visuals/columns.py +273 -0
- toad/widgets/agent_response.py +79 -0
- toad/widgets/agent_thought.py +41 -0
- toad/widgets/command_pane.py +224 -0
- toad/widgets/condensed_path.py +93 -0
- toad/widgets/conversation.py +1626 -0
- toad/widgets/danger_warning.py +65 -0
- toad/widgets/diff_view.py +709 -0
- toad/widgets/flash.py +81 -0
- toad/widgets/future_text.py +126 -0
- toad/widgets/grid_select.py +223 -0
- toad/widgets/highlighted_textarea.py +180 -0
- toad/widgets/mandelbrot.py +294 -0
- toad/widgets/markdown_note.py +13 -0
- toad/widgets/menu.py +147 -0
- toad/widgets/non_selectable_label.py +5 -0
- toad/widgets/note.py +18 -0
- toad/widgets/path_search.py +381 -0
- toad/widgets/plan.py +180 -0
- toad/widgets/project_directory_tree.py +74 -0
- toad/widgets/prompt.py +741 -0
- toad/widgets/question.py +337 -0
- toad/widgets/shell_result.py +35 -0
- toad/widgets/shell_terminal.py +18 -0
- toad/widgets/side_bar.py +74 -0
- toad/widgets/slash_complete.py +211 -0
- toad/widgets/strike_text.py +66 -0
- toad/widgets/terminal.py +526 -0
- toad/widgets/terminal_tool.py +338 -0
- toad/widgets/throbber.py +90 -0
- toad/widgets/tool_call.py +303 -0
- toad/widgets/user_input.py +23 -0
- toad/widgets/version.py +5 -0
- toad/widgets/welcome.py +31 -0
toad/acp/agent.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast, NamedTuple
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
|
|
10
|
+
import rich.repr
|
|
11
|
+
|
|
12
|
+
from textual.content import Content
|
|
13
|
+
from textual.message import Message
|
|
14
|
+
from textual.message_pump import MessagePump
|
|
15
|
+
|
|
16
|
+
from toad import jsonrpc
|
|
17
|
+
import toad
|
|
18
|
+
from toad.agent_schema import Agent as AgentData
|
|
19
|
+
from toad.agent import AgentBase, AgentReady, AgentFail
|
|
20
|
+
from toad.acp import protocol
|
|
21
|
+
from toad.acp import api
|
|
22
|
+
from toad.acp.api import API
|
|
23
|
+
from toad.acp import messages
|
|
24
|
+
from toad.acp.prompt import build as build_prompt
|
|
25
|
+
from toad import paths
|
|
26
|
+
from toad import constants
|
|
27
|
+
from toad.answer import Answer
|
|
28
|
+
|
|
29
|
+
PROTOCOL_VERSION = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Mode(NamedTuple):
|
|
33
|
+
"""An agent mode."""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
name: str
|
|
37
|
+
description: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_datetime_filename(
|
|
41
|
+
prefix: str, suffix: str, datetime_format: str | None = None
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Generate a filename which includes the current date and time.
|
|
44
|
+
|
|
45
|
+
Useful for ensuring a degree of uniqueness when saving files.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
prefix: Prefix to attach to the start of the filename, before the timestamp string.
|
|
49
|
+
suffix: Suffix to attach to the end of the filename, after the timestamp string.
|
|
50
|
+
This should include the file extension.
|
|
51
|
+
datetime_format: The format of the datetime to include in the filename.
|
|
52
|
+
If None, the ISO format will be used.
|
|
53
|
+
"""
|
|
54
|
+
if datetime_format is None:
|
|
55
|
+
dt = datetime.now().isoformat()
|
|
56
|
+
else:
|
|
57
|
+
dt = datetime.now().strftime(datetime_format)
|
|
58
|
+
|
|
59
|
+
file_name_stem = f"{prefix} {dt}"
|
|
60
|
+
for reserved in ' <>:"/\\|?*.':
|
|
61
|
+
file_name_stem = file_name_stem.replace(reserved, "_")
|
|
62
|
+
return file_name_stem + suffix
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@rich.repr.auto
|
|
66
|
+
class Agent(AgentBase):
|
|
67
|
+
"""An agent that speaks the APC (https://agentclientprotocol.com/overview/introduction) protocol."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, project_root: Path, agent: AgentData) -> None:
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
project_root: Project root path.
|
|
74
|
+
command: Command to launch agent.
|
|
75
|
+
"""
|
|
76
|
+
super().__init__(project_root)
|
|
77
|
+
|
|
78
|
+
self._agent_data = agent
|
|
79
|
+
|
|
80
|
+
self.server = jsonrpc.Server()
|
|
81
|
+
self.server.expose_instance(self)
|
|
82
|
+
|
|
83
|
+
self._agent_task: asyncio.Task | None = None
|
|
84
|
+
self._task: asyncio.Task | None = None
|
|
85
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
86
|
+
self.done_event = asyncio.Event()
|
|
87
|
+
|
|
88
|
+
self.agent_capabilities: protocol.AgentCapabilities = {
|
|
89
|
+
"loadSession": False,
|
|
90
|
+
"promptCapabilities": {
|
|
91
|
+
"audio": False,
|
|
92
|
+
"embeddedContent": False,
|
|
93
|
+
"image": False,
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
self.auth_methods: list[protocol.AuthMethod] = []
|
|
97
|
+
self.session_id: str = ""
|
|
98
|
+
self.tool_calls: dict[str, protocol.ToolCall] = {}
|
|
99
|
+
self._message_target: MessagePump | None = None
|
|
100
|
+
|
|
101
|
+
self._terminal_count: int = 0
|
|
102
|
+
|
|
103
|
+
log_filename: str = generate_datetime_filename(f"{agent['name']}", ".txt")
|
|
104
|
+
if log_path := os.environ.get("TOAD_LOG"):
|
|
105
|
+
self._log_file_path = Path(log_path).resolve().absolute()
|
|
106
|
+
else:
|
|
107
|
+
self._log_file_path = paths.get_log() / log_filename
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def command(self) -> str | None:
|
|
111
|
+
"""The command used to launch the agent, or `None` if there isn't one."""
|
|
112
|
+
acp_command = toad.get_os_matrix(self._agent_data["run_command"])
|
|
113
|
+
return acp_command
|
|
114
|
+
|
|
115
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
116
|
+
yield self.project_root_path
|
|
117
|
+
yield self.command
|
|
118
|
+
|
|
119
|
+
def log(self, line: str) -> None:
|
|
120
|
+
"""Write text to the agent log file.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
line: Text to be logged.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
if self._message_target is not None:
|
|
127
|
+
self._message_target.call_later(self._log, line)
|
|
128
|
+
|
|
129
|
+
async def _log(self, line: str) -> None:
|
|
130
|
+
"""Write text to the agent log file.
|
|
131
|
+
|
|
132
|
+
Intended to be called from `log`
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
line: Text to be logged.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
if self._message_target is None:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
def write_log(log_file_path: Path, line: str):
|
|
142
|
+
"""Write log in a thread."""
|
|
143
|
+
try:
|
|
144
|
+
with log_file_path.open("at") as log_file:
|
|
145
|
+
log_file.write(line)
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
await asyncio.to_thread(write_log, self._log_file_path, line)
|
|
150
|
+
|
|
151
|
+
def get_info(self) -> Content:
|
|
152
|
+
agent_name = self._agent_data["name"]
|
|
153
|
+
return Content(agent_name)
|
|
154
|
+
|
|
155
|
+
def start(self, message_target: MessagePump | None = None) -> None:
|
|
156
|
+
"""Start the agent."""
|
|
157
|
+
self._message_target = message_target
|
|
158
|
+
try:
|
|
159
|
+
self._log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
self._agent_task = asyncio.create_task(self._run_agent())
|
|
163
|
+
|
|
164
|
+
def send(self, request: jsonrpc.Request) -> None:
|
|
165
|
+
"""Send a request to the agent.
|
|
166
|
+
|
|
167
|
+
This is called automatically, if you go through `self.request`.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
request: JSONRPC request object.
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
assert self._process is not None, "Process should be present here"
|
|
174
|
+
|
|
175
|
+
self.log(f"[client] {request.body}")
|
|
176
|
+
if (stdin := self._process.stdin) is not None:
|
|
177
|
+
stdin.write(b"%s\n" % request.body_json)
|
|
178
|
+
|
|
179
|
+
def request(self) -> jsonrpc.Request:
|
|
180
|
+
"""Create a request object."""
|
|
181
|
+
return API.request(self.send)
|
|
182
|
+
|
|
183
|
+
def post_message(self, message: Message) -> bool:
|
|
184
|
+
"""Post a message to the message target (the Conversation).
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
message: Message object.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
`True` if the message was posted successfully, or `False` if it wasn't.
|
|
191
|
+
"""
|
|
192
|
+
if (message_target := self._message_target) is None:
|
|
193
|
+
return False
|
|
194
|
+
return message_target.post_message(message)
|
|
195
|
+
|
|
196
|
+
@jsonrpc.expose("session/update")
|
|
197
|
+
def rpc_session_update(
|
|
198
|
+
self,
|
|
199
|
+
sessionId: str,
|
|
200
|
+
update: protocol.SessionUpdate,
|
|
201
|
+
_meta: dict[str, Any] | None = None,
|
|
202
|
+
):
|
|
203
|
+
"""Agent requests an update.
|
|
204
|
+
|
|
205
|
+
https://agentclientprotocol.com/protocol/schema
|
|
206
|
+
"""
|
|
207
|
+
status_line: str | None = None
|
|
208
|
+
if _meta and (field_meta := _meta.get("field_meta")) is not None:
|
|
209
|
+
if (
|
|
210
|
+
open_hands_metrics := field_meta.get("openhands.dev/metrics")
|
|
211
|
+
) is not None:
|
|
212
|
+
status_line = open_hands_metrics.get("status_line")
|
|
213
|
+
|
|
214
|
+
match update:
|
|
215
|
+
case {
|
|
216
|
+
"sessionUpdate": "agent_message_chunk",
|
|
217
|
+
"content": {"type": type, "text": text},
|
|
218
|
+
}:
|
|
219
|
+
self.post_message(messages.Update(type, text))
|
|
220
|
+
|
|
221
|
+
case {
|
|
222
|
+
"sessionUpdate": "agent_thought_chunk",
|
|
223
|
+
"content": {"type": type, "text": text},
|
|
224
|
+
}:
|
|
225
|
+
self.post_message(messages.Thinking(type, text))
|
|
226
|
+
|
|
227
|
+
case {
|
|
228
|
+
"sessionUpdate": "tool_call",
|
|
229
|
+
"toolCallId": tool_call_id,
|
|
230
|
+
}:
|
|
231
|
+
self.tool_calls[tool_call_id] = update
|
|
232
|
+
self.post_message(messages.ToolCall(update))
|
|
233
|
+
|
|
234
|
+
case {"sessionUpdate": "plan", "entries": entries}:
|
|
235
|
+
self.post_message(messages.Plan(entries))
|
|
236
|
+
|
|
237
|
+
case {
|
|
238
|
+
"sessionUpdate": "tool_call_update",
|
|
239
|
+
"toolCallId": tool_call_id,
|
|
240
|
+
}:
|
|
241
|
+
if tool_call_id in self.tool_calls:
|
|
242
|
+
current_tool_call = self.tool_calls[tool_call_id]
|
|
243
|
+
for key, value in update.items():
|
|
244
|
+
if value is not None:
|
|
245
|
+
current_tool_call[key] = value
|
|
246
|
+
|
|
247
|
+
self.post_message(
|
|
248
|
+
messages.ToolCallUpdate(deepcopy(current_tool_call), update)
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
# The agent can send a tool call update, without previously sending the tool call *rolls eyes*
|
|
252
|
+
current_tool_call: protocol.ToolCall = {
|
|
253
|
+
"sessionUpdate": "tool_call",
|
|
254
|
+
"toolCallId": tool_call_id,
|
|
255
|
+
"title": "Tool call",
|
|
256
|
+
}
|
|
257
|
+
for key, value in update.items():
|
|
258
|
+
if value is not None:
|
|
259
|
+
current_tool_call[key] = value
|
|
260
|
+
|
|
261
|
+
self.tool_calls[tool_call_id] = current_tool_call
|
|
262
|
+
self.post_message(messages.ToolCall(current_tool_call))
|
|
263
|
+
|
|
264
|
+
case {
|
|
265
|
+
"sessionUpdate": "available_commands_update",
|
|
266
|
+
"availableCommands": available_commands,
|
|
267
|
+
}:
|
|
268
|
+
self.post_message(messages.AvailableCommandsUpdate(available_commands))
|
|
269
|
+
|
|
270
|
+
case {"sessionUpdate": "current_mode_update", "currentModeId": mode_id}:
|
|
271
|
+
self.post_message(messages.ModeUpdate(mode_id))
|
|
272
|
+
|
|
273
|
+
if status_line is not None:
|
|
274
|
+
self.post_message(messages.UpdateStatusLine(status_line))
|
|
275
|
+
|
|
276
|
+
@jsonrpc.expose("session/request_permission")
|
|
277
|
+
async def rpc_request_permission(
|
|
278
|
+
self,
|
|
279
|
+
sessionId: str,
|
|
280
|
+
options: list[protocol.PermissionOption],
|
|
281
|
+
toolCall: protocol.ToolCallUpdatePermissionRequest,
|
|
282
|
+
_meta: dict | None = None,
|
|
283
|
+
) -> protocol.RequestPermissionResponse:
|
|
284
|
+
"""Agent requests permission to make a tool call.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
sessionId: The session ID.
|
|
288
|
+
options: A list of permission options (potential replies).
|
|
289
|
+
toolCall: The tool or tools the agent is requesting permission to call.
|
|
290
|
+
_meta: Optional meta information.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
The response to the permission request.
|
|
294
|
+
"""
|
|
295
|
+
result_future: asyncio.Future[Answer] = asyncio.Future()
|
|
296
|
+
tool_call_id = toolCall["toolCallId"]
|
|
297
|
+
if tool_call_id not in self.tool_calls:
|
|
298
|
+
permission_tool_call = toolCall.copy()
|
|
299
|
+
permission_tool_call.pop("sessionUpdate", None)
|
|
300
|
+
tool_call = cast(protocol.ToolCall, permission_tool_call)
|
|
301
|
+
self.tool_calls[tool_call_id] = deepcopy(tool_call)
|
|
302
|
+
else:
|
|
303
|
+
tool_call = deepcopy(self.tool_calls[tool_call_id])
|
|
304
|
+
|
|
305
|
+
message = messages.RequestPermission(options, tool_call, result_future)
|
|
306
|
+
self.post_message(message)
|
|
307
|
+
await result_future
|
|
308
|
+
ask_result = result_future.result()
|
|
309
|
+
|
|
310
|
+
request_permission_outcome: protocol.OutcomeSelected = {
|
|
311
|
+
"optionId": ask_result.id,
|
|
312
|
+
"outcome": "selected",
|
|
313
|
+
}
|
|
314
|
+
result: protocol.RequestPermissionResponse = {
|
|
315
|
+
"outcome": request_permission_outcome
|
|
316
|
+
}
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
@jsonrpc.expose("fs/read_text_file")
|
|
320
|
+
def rpc_read_text_file(
|
|
321
|
+
self,
|
|
322
|
+
sessionId: str,
|
|
323
|
+
path: str,
|
|
324
|
+
line: int | None = None,
|
|
325
|
+
limit: int | None = None,
|
|
326
|
+
) -> dict[str, str]:
|
|
327
|
+
"""Read a file in the project."""
|
|
328
|
+
# TODO: what if the read is outside of the project path?
|
|
329
|
+
# https://agentclientprotocol.com/protocol/file-system#reading-files
|
|
330
|
+
read_path = self.project_root_path / path
|
|
331
|
+
try:
|
|
332
|
+
text = read_path.read_text(encoding="utf-8", errors="ignore")
|
|
333
|
+
except IOError:
|
|
334
|
+
text = ""
|
|
335
|
+
if line is not None:
|
|
336
|
+
line = max(0, line - 1)
|
|
337
|
+
if limit is None:
|
|
338
|
+
text = "\n".join(text.splitlines()[line:])
|
|
339
|
+
else:
|
|
340
|
+
text = "\n".join(text.splitlines()[line : line + limit])
|
|
341
|
+
return {"content": text}
|
|
342
|
+
|
|
343
|
+
@jsonrpc.expose("fs/write_text_file")
|
|
344
|
+
def rpc_write_text_file(self, sessionId: str, path: str, content: str) -> None:
|
|
345
|
+
# TODO: What if the agent wants to write outside of the project path?
|
|
346
|
+
# https://agentclientprotocol.com/protocol/file-system#writing-files
|
|
347
|
+
|
|
348
|
+
write_path = self.project_root_path / path
|
|
349
|
+
write_path.write_text(content, encoding="utf-8", errors="ignore")
|
|
350
|
+
|
|
351
|
+
# https://agentclientprotocol.com/protocol/schema#createterminalrequest
|
|
352
|
+
@jsonrpc.expose("terminal/create")
|
|
353
|
+
async def rpc_terminal_create(
|
|
354
|
+
self,
|
|
355
|
+
command: str,
|
|
356
|
+
_meta: dict | None = None,
|
|
357
|
+
args: list[str] | None = None,
|
|
358
|
+
cwd: str | None = None,
|
|
359
|
+
env: list[protocol.EnvVariable] | None = None,
|
|
360
|
+
outputByteLimit: int | None = None,
|
|
361
|
+
sessionId: str | None = None,
|
|
362
|
+
) -> protocol.CreateTerminalResponse:
|
|
363
|
+
# Assign a terminal id
|
|
364
|
+
self._terminal_count = self._terminal_count + 1
|
|
365
|
+
terminal_id = f"terminal-{self._terminal_count}"
|
|
366
|
+
|
|
367
|
+
terminal_env = (
|
|
368
|
+
{variable["name"]: variable["value"] for variable in env} if env else {}
|
|
369
|
+
)
|
|
370
|
+
result_future: asyncio.Future[bool] = asyncio.Future()
|
|
371
|
+
self.post_message(
|
|
372
|
+
messages.CreateTerminal(
|
|
373
|
+
terminal_id,
|
|
374
|
+
command=command,
|
|
375
|
+
args=args,
|
|
376
|
+
cwd=cwd,
|
|
377
|
+
env=terminal_env,
|
|
378
|
+
output_byte_limit=outputByteLimit,
|
|
379
|
+
result_future=result_future,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
await result_future
|
|
383
|
+
if not result_future.result():
|
|
384
|
+
raise jsonrpc.JSONRPCError("Failed to create a terminal.")
|
|
385
|
+
return {"terminalId": terminal_id}
|
|
386
|
+
|
|
387
|
+
# https://agentclientprotocol.com/protocol/schema#killterminalcommandrequest
|
|
388
|
+
@jsonrpc.expose("terminal/kill")
|
|
389
|
+
def rpc_terminal_kill(
|
|
390
|
+
self, sessionID: str, terminalId: str, _meta: dict | None = None
|
|
391
|
+
) -> protocol.KillTerminalCommandResponse:
|
|
392
|
+
self.post_message(messages.KillTerminal(terminalId))
|
|
393
|
+
return {}
|
|
394
|
+
|
|
395
|
+
# https://agentclientprotocol.com/protocol/schema#terminal%2Foutput
|
|
396
|
+
@jsonrpc.expose("terminal/output")
|
|
397
|
+
async def rpc_terminal_output(
|
|
398
|
+
self, sessionId: str, terminalId: str, _meta: dict | None = None
|
|
399
|
+
) -> protocol.TerminalOutputResponse:
|
|
400
|
+
from toad.widgets.terminal_tool import ToolState
|
|
401
|
+
|
|
402
|
+
result_future: asyncio.Future[ToolState] = asyncio.Future()
|
|
403
|
+
|
|
404
|
+
if not self.post_message(messages.GetTerminalState(terminalId, result_future)):
|
|
405
|
+
raise RuntimeError("Unable to get terminal output")
|
|
406
|
+
|
|
407
|
+
await result_future
|
|
408
|
+
terminal_state = result_future.result()
|
|
409
|
+
|
|
410
|
+
result: protocol.TerminalOutputResponse = {
|
|
411
|
+
"output": terminal_state.output,
|
|
412
|
+
"truncated": terminal_state.truncated,
|
|
413
|
+
}
|
|
414
|
+
if (return_code := terminal_state.return_code) is not None:
|
|
415
|
+
result["exitStatus"] = {"exitCode": return_code}
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
# https://agentclientprotocol.com/protocol/schema#terminal%2Frelease
|
|
419
|
+
@jsonrpc.expose("terminal/release")
|
|
420
|
+
def rpc_terminal_release(
|
|
421
|
+
self, sessionId: str, terminalId: str, _meta: dict | None = None
|
|
422
|
+
) -> protocol.ReleaseTerminalResponse:
|
|
423
|
+
self.post_message(messages.ReleaseTerminal(terminalId))
|
|
424
|
+
return {}
|
|
425
|
+
|
|
426
|
+
# https://agentclientprotocol.com/protocol/schema#terminal%2Fwait-for-exit
|
|
427
|
+
@jsonrpc.expose("terminal/wait_for_exit")
|
|
428
|
+
async def rpc_terminal_wait_for_exit(
|
|
429
|
+
self, sessionId: str, terminalId: str, _meta: dict | None = None
|
|
430
|
+
) -> protocol.WaitForTerminalExitResponse:
|
|
431
|
+
result_future: asyncio.Future[tuple[int, str | None]] = asyncio.Future()
|
|
432
|
+
if not self.post_message(
|
|
433
|
+
messages.WaitForTerminalExit(terminalId, result_future)
|
|
434
|
+
):
|
|
435
|
+
raise RuntimeError("Unable to wait for terminal exit; no terminal found")
|
|
436
|
+
|
|
437
|
+
await result_future
|
|
438
|
+
return_code, signal = result_future.result()
|
|
439
|
+
return {"exitCode": return_code, "signal": signal}
|
|
440
|
+
|
|
441
|
+
async def _run_agent(self) -> None:
|
|
442
|
+
"""Task to communicate with the agent subprocess."""
|
|
443
|
+
|
|
444
|
+
PIPE = asyncio.subprocess.PIPE
|
|
445
|
+
env = os.environ.copy()
|
|
446
|
+
env["TOAD_CWD"] = str(Path("./").absolute())
|
|
447
|
+
|
|
448
|
+
if (command := self.command) is None:
|
|
449
|
+
self.post_message(
|
|
450
|
+
AgentFail("Failed to start agent; no run command for this OS")
|
|
451
|
+
)
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
process = self._process = await asyncio.create_subprocess_shell(
|
|
456
|
+
command,
|
|
457
|
+
stdin=PIPE,
|
|
458
|
+
stdout=PIPE,
|
|
459
|
+
stderr=PIPE,
|
|
460
|
+
env=env,
|
|
461
|
+
cwd=str(self.project_root_path),
|
|
462
|
+
limit=10 * 1024 * 1024,
|
|
463
|
+
)
|
|
464
|
+
except Exception as error:
|
|
465
|
+
self.post_message(AgentFail("Failed to start agent", details=str(error)))
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
self._task = asyncio.create_task(self.run())
|
|
469
|
+
|
|
470
|
+
assert process.stdout is not None
|
|
471
|
+
assert process.stdin is not None
|
|
472
|
+
|
|
473
|
+
tasks: set[asyncio.Task] = set()
|
|
474
|
+
|
|
475
|
+
async def call_jsonrpc(request: jsonrpc.JSONObject | jsonrpc.JSONList) -> None:
|
|
476
|
+
try:
|
|
477
|
+
if (result := await self.server.call(request)) is not None:
|
|
478
|
+
result_json = json.dumps(result).encode("utf-8")
|
|
479
|
+
if process.stdin is not None:
|
|
480
|
+
process.stdin.write(b"%s\n" % result_json)
|
|
481
|
+
finally:
|
|
482
|
+
if (task := asyncio.current_task()) is not None:
|
|
483
|
+
tasks.discard(task)
|
|
484
|
+
|
|
485
|
+
while line := await process.stdout.readline():
|
|
486
|
+
# This line should contain JSON, which may be:
|
|
487
|
+
# A) a JSONRPC request
|
|
488
|
+
# B) a JSONRPC response to a previous request
|
|
489
|
+
if not line.strip():
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
line_str = line.decode("utf-8")
|
|
494
|
+
except Exception as error:
|
|
495
|
+
self.log(f"[error] Unable to decode utf-8 from agent: {error}")
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
self.log(f"[agent] {line_str}")
|
|
499
|
+
try:
|
|
500
|
+
agent_data: jsonrpc.JSONType = json.loads(line_str)
|
|
501
|
+
except Exception as error:
|
|
502
|
+
self.log(f"[error] failed to decode JSON from agent: {error}")
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
if isinstance(agent_data, dict):
|
|
506
|
+
if "result" in agent_data or "error" in agent_data:
|
|
507
|
+
API.process_response(agent_data)
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
elif isinstance(agent_data, list):
|
|
511
|
+
if not all(isinstance(datum, dict) for datum in agent_data):
|
|
512
|
+
self.log(f"[error] Agent sent invalid data: {agent_data!r}")
|
|
513
|
+
continue
|
|
514
|
+
if all(
|
|
515
|
+
isinstance(datum, dict) and ("result" in datum or "error" in datum)
|
|
516
|
+
for datum in agent_data
|
|
517
|
+
):
|
|
518
|
+
API.process_response(agent_data)
|
|
519
|
+
continue
|
|
520
|
+
|
|
521
|
+
if not isinstance(agent_data, dict):
|
|
522
|
+
self.log("[error] Invalid JSON from agent {agent_data!r}")
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
# By this point we know it is a JSON RPC call
|
|
526
|
+
assert isinstance(agent_data, dict)
|
|
527
|
+
tasks.add(asyncio.create_task(call_jsonrpc(agent_data)))
|
|
528
|
+
|
|
529
|
+
if process.returncode:
|
|
530
|
+
assert process.stderr is not None
|
|
531
|
+
fail_details = (await process.stderr.read()).decode("utf-8", "replace")
|
|
532
|
+
self.post_message(
|
|
533
|
+
AgentFail(
|
|
534
|
+
f"Agent returned a failure code: [b]{process.returncode}",
|
|
535
|
+
details=fail_details,
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
self._process = None
|
|
540
|
+
|
|
541
|
+
async def stop(self) -> None:
|
|
542
|
+
"""Gracefully stop the process."""
|
|
543
|
+
if self._process is not None:
|
|
544
|
+
self._process.terminate()
|
|
545
|
+
|
|
546
|
+
async def run(self) -> None:
|
|
547
|
+
"""The main logic of the Agent."""
|
|
548
|
+
if constants.ACP_INITIALIZE:
|
|
549
|
+
try:
|
|
550
|
+
# Boilerplate to initialize comms
|
|
551
|
+
await self.acp_initialize()
|
|
552
|
+
# Create a new session
|
|
553
|
+
await self.acp_new_session()
|
|
554
|
+
except jsonrpc.APIError as error:
|
|
555
|
+
if isinstance(error.data, dict):
|
|
556
|
+
reason = str(error.data.get("reason") or "")
|
|
557
|
+
details = str(error.data.get("details") or "")
|
|
558
|
+
else:
|
|
559
|
+
reason = "Failed to initialize agent"
|
|
560
|
+
details = ""
|
|
561
|
+
self.post_message(AgentFail(reason, details))
|
|
562
|
+
|
|
563
|
+
self.post_message(AgentReady())
|
|
564
|
+
|
|
565
|
+
async def send_prompt(self, prompt: str) -> str | None:
|
|
566
|
+
"""Send a prompt to the agent.
|
|
567
|
+
|
|
568
|
+
!!! note
|
|
569
|
+
This method blocks as it may defer to a thread to read resources.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
prompt: Prompt text.
|
|
573
|
+
"""
|
|
574
|
+
prompt_content_blocks = await asyncio.to_thread(
|
|
575
|
+
build_prompt, self.project_root_path, prompt
|
|
576
|
+
)
|
|
577
|
+
return await self.acp_session_prompt(prompt_content_blocks)
|
|
578
|
+
|
|
579
|
+
async def acp_initialize(self):
|
|
580
|
+
"""Initialize agent."""
|
|
581
|
+
with self.request():
|
|
582
|
+
initialize_response = api.initialize(
|
|
583
|
+
PROTOCOL_VERSION,
|
|
584
|
+
{
|
|
585
|
+
"fs": {
|
|
586
|
+
"readTextFile": True,
|
|
587
|
+
"writeTextFile": True,
|
|
588
|
+
},
|
|
589
|
+
"terminal": True,
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
"name": toad.NAME,
|
|
593
|
+
"title": toad.TITLE,
|
|
594
|
+
"version": toad.get_version(),
|
|
595
|
+
},
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
response = await initialize_response.wait()
|
|
599
|
+
assert response is not None
|
|
600
|
+
|
|
601
|
+
# Store agents capabilities
|
|
602
|
+
if agent_capabilities := response.get("agentCapabilities"):
|
|
603
|
+
self.agent_capabilities = agent_capabilities
|
|
604
|
+
if auth_methods := response.get("authMethods"):
|
|
605
|
+
self.auth_methods = auth_methods
|
|
606
|
+
|
|
607
|
+
async def acp_new_session(self) -> None:
|
|
608
|
+
"""Create a new session."""
|
|
609
|
+
with self.request():
|
|
610
|
+
session_new_response = api.session_new(
|
|
611
|
+
str(self.project_root_path),
|
|
612
|
+
[],
|
|
613
|
+
)
|
|
614
|
+
response = await session_new_response.wait()
|
|
615
|
+
assert response is not None
|
|
616
|
+
self.session_id = response["sessionId"]
|
|
617
|
+
if (modes := response.get("modes", None)) is not None:
|
|
618
|
+
current_mode = modes["currentModeId"]
|
|
619
|
+
available_modes = modes["availableModes"]
|
|
620
|
+
modes_update = {
|
|
621
|
+
mode["id"]: Mode(
|
|
622
|
+
mode["id"], mode["name"], mode.get("description", None)
|
|
623
|
+
)
|
|
624
|
+
for mode in available_modes
|
|
625
|
+
}
|
|
626
|
+
self.post_message(messages.SetModes(current_mode, modes_update))
|
|
627
|
+
|
|
628
|
+
async def acp_session_prompt(
|
|
629
|
+
self, prompt: list[protocol.ContentBlock]
|
|
630
|
+
) -> str | None:
|
|
631
|
+
"""Send the prompt to the agent.
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
The stop reason.
|
|
635
|
+
|
|
636
|
+
"""
|
|
637
|
+
with self.request():
|
|
638
|
+
session_prompt = api.session_prompt(prompt, self.session_id)
|
|
639
|
+
result = await session_prompt.wait()
|
|
640
|
+
assert result is not None
|
|
641
|
+
return result.get("stopReason")
|
|
642
|
+
|
|
643
|
+
async def acp_session_set_mode(self, mode_id: str) -> str | None:
|
|
644
|
+
"""Update the current mode with the agent."""
|
|
645
|
+
with self.request():
|
|
646
|
+
response = api.session_set_mode(self.session_id, mode_id)
|
|
647
|
+
try:
|
|
648
|
+
await response.wait()
|
|
649
|
+
except jsonrpc.APIError as error:
|
|
650
|
+
match error.data:
|
|
651
|
+
case {"details": details}:
|
|
652
|
+
return details if isinstance(details, str) else "Failed to set mode"
|
|
653
|
+
return "Failed to set mode"
|
|
654
|
+
else:
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
async def set_mode(self, mode_id: str) -> str | None:
|
|
658
|
+
return await self.acp_session_set_mode(mode_id)
|
|
659
|
+
|
|
660
|
+
async def acp_session_cancel(self) -> bool:
|
|
661
|
+
with self.request():
|
|
662
|
+
response = api.session_cancel(self.session_id, {})
|
|
663
|
+
try:
|
|
664
|
+
await response.wait()
|
|
665
|
+
except jsonrpc.APIError:
|
|
666
|
+
# No-op if there is nothing to cancel
|
|
667
|
+
return False
|
|
668
|
+
return True
|
|
669
|
+
|
|
670
|
+
async def cancel(self) -> bool:
|
|
671
|
+
return await self.acp_session_cancel()
|