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.
Files changed (120) hide show
  1. batrachian_toad-0.5.22.dist-info/METADATA +197 -0
  2. batrachian_toad-0.5.22.dist-info/RECORD +120 -0
  3. batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
  4. batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
  5. batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
  6. toad/__init__.py +46 -0
  7. toad/__main__.py +4 -0
  8. toad/_loop.py +86 -0
  9. toad/about.py +90 -0
  10. toad/acp/agent.py +671 -0
  11. toad/acp/api.py +47 -0
  12. toad/acp/encode_tool_call_id.py +12 -0
  13. toad/acp/messages.py +138 -0
  14. toad/acp/prompt.py +54 -0
  15. toad/acp/protocol.py +426 -0
  16. toad/agent.py +62 -0
  17. toad/agent_schema.py +70 -0
  18. toad/agents.py +45 -0
  19. toad/ansi/__init__.py +1 -0
  20. toad/ansi/_ansi.py +1612 -0
  21. toad/ansi/_ansi_colors.py +264 -0
  22. toad/ansi/_control_codes.py +37 -0
  23. toad/ansi/_keys.py +251 -0
  24. toad/ansi/_sgr_styles.py +64 -0
  25. toad/ansi/_stream_parser.py +418 -0
  26. toad/answer.py +22 -0
  27. toad/app.py +557 -0
  28. toad/atomic.py +37 -0
  29. toad/cli.py +257 -0
  30. toad/code_analyze.py +28 -0
  31. toad/complete.py +34 -0
  32. toad/constants.py +58 -0
  33. toad/conversation_markdown.py +19 -0
  34. toad/danger.py +371 -0
  35. toad/data/agents/ampcode.com.toml +51 -0
  36. toad/data/agents/augmentcode.com.toml +40 -0
  37. toad/data/agents/claude.com.toml +41 -0
  38. toad/data/agents/docker.com.toml +59 -0
  39. toad/data/agents/geminicli.com.toml +28 -0
  40. toad/data/agents/goose.ai.toml +51 -0
  41. toad/data/agents/inference.huggingface.co.toml +33 -0
  42. toad/data/agents/kimi.com.toml +35 -0
  43. toad/data/agents/openai.com.toml +53 -0
  44. toad/data/agents/opencode.ai.toml +61 -0
  45. toad/data/agents/openhands.dev.toml +44 -0
  46. toad/data/agents/stakpak.dev.toml +61 -0
  47. toad/data/agents/vibe.mistral.ai.toml +27 -0
  48. toad/data/agents/vtcode.dev.toml +62 -0
  49. toad/data/images/frog.png +0 -0
  50. toad/data/sounds/turn-over.wav +0 -0
  51. toad/db.py +5 -0
  52. toad/dec.py +332 -0
  53. toad/directory.py +234 -0
  54. toad/directory_watcher.py +96 -0
  55. toad/fuzzy.py +140 -0
  56. toad/gist.py +2 -0
  57. toad/history.py +138 -0
  58. toad/jsonrpc.py +576 -0
  59. toad/menus.py +14 -0
  60. toad/messages.py +74 -0
  61. toad/option_content.py +51 -0
  62. toad/os.py +0 -0
  63. toad/path_complete.py +145 -0
  64. toad/path_filter.py +124 -0
  65. toad/paths.py +71 -0
  66. toad/pill.py +23 -0
  67. toad/prompt/extract.py +19 -0
  68. toad/prompt/resource.py +68 -0
  69. toad/protocol.py +28 -0
  70. toad/screens/action_modal.py +94 -0
  71. toad/screens/agent_modal.py +172 -0
  72. toad/screens/command_edit_modal.py +58 -0
  73. toad/screens/main.py +192 -0
  74. toad/screens/permissions.py +390 -0
  75. toad/screens/permissions.tcss +72 -0
  76. toad/screens/settings.py +254 -0
  77. toad/screens/settings.tcss +101 -0
  78. toad/screens/store.py +476 -0
  79. toad/screens/store.tcss +261 -0
  80. toad/settings.py +354 -0
  81. toad/settings_schema.py +318 -0
  82. toad/shell.py +263 -0
  83. toad/shell_read.py +42 -0
  84. toad/slash_command.py +34 -0
  85. toad/toad.tcss +752 -0
  86. toad/version.py +80 -0
  87. toad/visuals/columns.py +273 -0
  88. toad/widgets/agent_response.py +79 -0
  89. toad/widgets/agent_thought.py +41 -0
  90. toad/widgets/command_pane.py +224 -0
  91. toad/widgets/condensed_path.py +93 -0
  92. toad/widgets/conversation.py +1626 -0
  93. toad/widgets/danger_warning.py +65 -0
  94. toad/widgets/diff_view.py +709 -0
  95. toad/widgets/flash.py +81 -0
  96. toad/widgets/future_text.py +126 -0
  97. toad/widgets/grid_select.py +223 -0
  98. toad/widgets/highlighted_textarea.py +180 -0
  99. toad/widgets/mandelbrot.py +294 -0
  100. toad/widgets/markdown_note.py +13 -0
  101. toad/widgets/menu.py +147 -0
  102. toad/widgets/non_selectable_label.py +5 -0
  103. toad/widgets/note.py +18 -0
  104. toad/widgets/path_search.py +381 -0
  105. toad/widgets/plan.py +180 -0
  106. toad/widgets/project_directory_tree.py +74 -0
  107. toad/widgets/prompt.py +741 -0
  108. toad/widgets/question.py +337 -0
  109. toad/widgets/shell_result.py +35 -0
  110. toad/widgets/shell_terminal.py +18 -0
  111. toad/widgets/side_bar.py +74 -0
  112. toad/widgets/slash_complete.py +211 -0
  113. toad/widgets/strike_text.py +66 -0
  114. toad/widgets/terminal.py +526 -0
  115. toad/widgets/terminal_tool.py +338 -0
  116. toad/widgets/throbber.py +90 -0
  117. toad/widgets/tool_call.py +303 -0
  118. toad/widgets/user_input.py +23 -0
  119. toad/widgets/version.py +5 -0
  120. 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()