zrb 1.21.29__py3-none-any.whl → 2.0.0a4__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.

Potentially problematic release.


This version of zrb might be problematic. Click here for more details.

Files changed (192) hide show
  1. zrb/__init__.py +118 -129
  2. zrb/builtin/__init__.py +54 -2
  3. zrb/builtin/llm/chat.py +147 -0
  4. zrb/callback/callback.py +8 -1
  5. zrb/cmd/cmd_result.py +2 -1
  6. zrb/config/config.py +491 -280
  7. zrb/config/helper.py +84 -0
  8. zrb/config/web_auth_config.py +50 -35
  9. zrb/context/any_shared_context.py +13 -2
  10. zrb/context/context.py +31 -3
  11. zrb/context/print_fn.py +13 -0
  12. zrb/context/shared_context.py +14 -1
  13. zrb/input/option_input.py +30 -2
  14. zrb/llm/agent/__init__.py +9 -0
  15. zrb/llm/agent/agent.py +215 -0
  16. zrb/llm/agent/summarizer.py +20 -0
  17. zrb/llm/app/__init__.py +10 -0
  18. zrb/llm/app/completion.py +281 -0
  19. zrb/llm/app/confirmation/allow_tool.py +66 -0
  20. zrb/llm/app/confirmation/handler.py +178 -0
  21. zrb/llm/app/confirmation/replace_confirmation.py +77 -0
  22. zrb/llm/app/keybinding.py +34 -0
  23. zrb/llm/app/layout.py +117 -0
  24. zrb/llm/app/lexer.py +155 -0
  25. zrb/llm/app/redirection.py +28 -0
  26. zrb/llm/app/style.py +16 -0
  27. zrb/llm/app/ui.py +733 -0
  28. zrb/llm/config/__init__.py +4 -0
  29. zrb/llm/config/config.py +122 -0
  30. zrb/llm/config/limiter.py +247 -0
  31. zrb/llm/history_manager/__init__.py +4 -0
  32. zrb/llm/history_manager/any_history_manager.py +23 -0
  33. zrb/llm/history_manager/file_history_manager.py +91 -0
  34. zrb/llm/history_processor/summarizer.py +108 -0
  35. zrb/llm/note/__init__.py +3 -0
  36. zrb/llm/note/manager.py +122 -0
  37. zrb/llm/prompt/__init__.py +29 -0
  38. zrb/llm/prompt/claude_compatibility.py +92 -0
  39. zrb/llm/prompt/compose.py +55 -0
  40. zrb/llm/prompt/default.py +51 -0
  41. zrb/llm/prompt/markdown/mandate.md +23 -0
  42. zrb/llm/prompt/markdown/persona.md +3 -0
  43. zrb/llm/prompt/markdown/summarizer.md +21 -0
  44. zrb/llm/prompt/note.py +41 -0
  45. zrb/llm/prompt/system_context.py +46 -0
  46. zrb/llm/prompt/zrb.py +41 -0
  47. zrb/llm/skill/__init__.py +3 -0
  48. zrb/llm/skill/manager.py +86 -0
  49. zrb/llm/task/__init__.py +4 -0
  50. zrb/llm/task/llm_chat_task.py +316 -0
  51. zrb/llm/task/llm_task.py +245 -0
  52. zrb/llm/tool/__init__.py +39 -0
  53. zrb/llm/tool/bash.py +75 -0
  54. zrb/llm/tool/code.py +266 -0
  55. zrb/llm/tool/file.py +419 -0
  56. zrb/llm/tool/note.py +70 -0
  57. zrb/{builtin/llm → llm}/tool/rag.py +8 -5
  58. zrb/llm/tool/search/brave.py +53 -0
  59. zrb/llm/tool/search/searxng.py +47 -0
  60. zrb/llm/tool/search/serpapi.py +47 -0
  61. zrb/llm/tool/skill.py +19 -0
  62. zrb/llm/tool/sub_agent.py +70 -0
  63. zrb/llm/tool/web.py +97 -0
  64. zrb/llm/tool/zrb_task.py +66 -0
  65. zrb/llm/util/attachment.py +101 -0
  66. zrb/llm/util/prompt.py +104 -0
  67. zrb/llm/util/stream_response.py +178 -0
  68. zrb/session/any_session.py +0 -3
  69. zrb/session/session.py +1 -1
  70. zrb/task/base/context.py +25 -13
  71. zrb/task/base/execution.py +52 -47
  72. zrb/task/base/lifecycle.py +7 -4
  73. zrb/task/base_task.py +48 -49
  74. zrb/task/base_trigger.py +4 -1
  75. zrb/task/cmd_task.py +6 -0
  76. zrb/task/http_check.py +11 -5
  77. zrb/task/make_task.py +3 -0
  78. zrb/task/rsync_task.py +5 -0
  79. zrb/task/scaffolder.py +7 -4
  80. zrb/task/scheduler.py +3 -0
  81. zrb/task/tcp_check.py +6 -4
  82. zrb/util/ascii_art/art/bee.txt +17 -0
  83. zrb/util/ascii_art/art/cat.txt +9 -0
  84. zrb/util/ascii_art/art/ghost.txt +16 -0
  85. zrb/util/ascii_art/art/panda.txt +17 -0
  86. zrb/util/ascii_art/art/rose.txt +14 -0
  87. zrb/util/ascii_art/art/unicorn.txt +15 -0
  88. zrb/util/ascii_art/banner.py +92 -0
  89. zrb/util/cli/markdown.py +22 -2
  90. zrb/util/cmd/command.py +33 -10
  91. zrb/util/file.py +51 -32
  92. zrb/util/match.py +78 -0
  93. zrb/util/run.py +3 -3
  94. {zrb-1.21.29.dist-info → zrb-2.0.0a4.dist-info}/METADATA +9 -15
  95. {zrb-1.21.29.dist-info → zrb-2.0.0a4.dist-info}/RECORD +100 -128
  96. zrb/attr/__init__.py +0 -0
  97. zrb/builtin/llm/attachment.py +0 -40
  98. zrb/builtin/llm/chat_completion.py +0 -274
  99. zrb/builtin/llm/chat_session.py +0 -270
  100. zrb/builtin/llm/chat_session_cmd.py +0 -288
  101. zrb/builtin/llm/chat_trigger.py +0 -79
  102. zrb/builtin/llm/history.py +0 -71
  103. zrb/builtin/llm/input.py +0 -27
  104. zrb/builtin/llm/llm_ask.py +0 -269
  105. zrb/builtin/llm/previous-session.js +0 -21
  106. zrb/builtin/llm/tool/__init__.py +0 -0
  107. zrb/builtin/llm/tool/api.py +0 -75
  108. zrb/builtin/llm/tool/cli.py +0 -52
  109. zrb/builtin/llm/tool/code.py +0 -236
  110. zrb/builtin/llm/tool/file.py +0 -560
  111. zrb/builtin/llm/tool/note.py +0 -84
  112. zrb/builtin/llm/tool/sub_agent.py +0 -150
  113. zrb/builtin/llm/tool/web.py +0 -171
  114. zrb/builtin/project/__init__.py +0 -0
  115. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/__init__.py +0 -0
  116. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/template/app_template/module/my_module/service/__init__.py +0 -0
  117. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/common/__init__.py +0 -0
  118. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/__init__.py +0 -0
  119. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/__init__.py +0 -0
  120. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/permission/__init__.py +0 -0
  121. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/role/__init__.py +0 -0
  122. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/user/__init__.py +0 -0
  123. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/schema/__init__.py +0 -0
  124. zrb/builtin/project/create/__init__.py +0 -0
  125. zrb/builtin/shell/__init__.py +0 -0
  126. zrb/builtin/shell/autocomplete/__init__.py +0 -0
  127. zrb/callback/__init__.py +0 -0
  128. zrb/cmd/__init__.py +0 -0
  129. zrb/config/default_prompt/interactive_system_prompt.md +0 -29
  130. zrb/config/default_prompt/persona.md +0 -1
  131. zrb/config/default_prompt/summarization_prompt.md +0 -57
  132. zrb/config/default_prompt/system_prompt.md +0 -38
  133. zrb/config/llm_config.py +0 -339
  134. zrb/config/llm_context/config.py +0 -166
  135. zrb/config/llm_context/config_parser.py +0 -40
  136. zrb/config/llm_context/workflow.py +0 -81
  137. zrb/config/llm_rate_limitter.py +0 -190
  138. zrb/content_transformer/__init__.py +0 -0
  139. zrb/context/__init__.py +0 -0
  140. zrb/dot_dict/__init__.py +0 -0
  141. zrb/env/__init__.py +0 -0
  142. zrb/group/__init__.py +0 -0
  143. zrb/input/__init__.py +0 -0
  144. zrb/runner/__init__.py +0 -0
  145. zrb/runner/web_route/__init__.py +0 -0
  146. zrb/runner/web_route/home_page/__init__.py +0 -0
  147. zrb/session/__init__.py +0 -0
  148. zrb/session_state_log/__init__.py +0 -0
  149. zrb/session_state_logger/__init__.py +0 -0
  150. zrb/task/__init__.py +0 -0
  151. zrb/task/base/__init__.py +0 -0
  152. zrb/task/llm/__init__.py +0 -0
  153. zrb/task/llm/agent.py +0 -204
  154. zrb/task/llm/agent_runner.py +0 -152
  155. zrb/task/llm/config.py +0 -122
  156. zrb/task/llm/conversation_history.py +0 -209
  157. zrb/task/llm/conversation_history_model.py +0 -67
  158. zrb/task/llm/default_workflow/coding/workflow.md +0 -41
  159. zrb/task/llm/default_workflow/copywriting/workflow.md +0 -68
  160. zrb/task/llm/default_workflow/git/workflow.md +0 -118
  161. zrb/task/llm/default_workflow/golang/workflow.md +0 -128
  162. zrb/task/llm/default_workflow/html-css/workflow.md +0 -135
  163. zrb/task/llm/default_workflow/java/workflow.md +0 -146
  164. zrb/task/llm/default_workflow/javascript/workflow.md +0 -158
  165. zrb/task/llm/default_workflow/python/workflow.md +0 -160
  166. zrb/task/llm/default_workflow/researching/workflow.md +0 -153
  167. zrb/task/llm/default_workflow/rust/workflow.md +0 -162
  168. zrb/task/llm/default_workflow/shell/workflow.md +0 -299
  169. zrb/task/llm/error.py +0 -95
  170. zrb/task/llm/file_replacement.py +0 -206
  171. zrb/task/llm/file_tool_model.py +0 -57
  172. zrb/task/llm/history_processor.py +0 -206
  173. zrb/task/llm/history_summarization.py +0 -25
  174. zrb/task/llm/print_node.py +0 -221
  175. zrb/task/llm/prompt.py +0 -321
  176. zrb/task/llm/subagent_conversation_history.py +0 -41
  177. zrb/task/llm/tool_wrapper.py +0 -361
  178. zrb/task/llm/typing.py +0 -3
  179. zrb/task/llm/workflow.py +0 -76
  180. zrb/task/llm_task.py +0 -379
  181. zrb/task_status/__init__.py +0 -0
  182. zrb/util/__init__.py +0 -0
  183. zrb/util/cli/__init__.py +0 -0
  184. zrb/util/cmd/__init__.py +0 -0
  185. zrb/util/codemod/__init__.py +0 -0
  186. zrb/util/string/__init__.py +0 -0
  187. zrb/xcom/__init__.py +0 -0
  188. /zrb/{config/default_prompt/file_extractor_system_prompt.md → llm/prompt/markdown/file_extractor.md} +0 -0
  189. /zrb/{config/default_prompt/repo_extractor_system_prompt.md → llm/prompt/markdown/repo_extractor.md} +0 -0
  190. /zrb/{config/default_prompt/repo_summarizer_system_prompt.md → llm/prompt/markdown/repo_summarizer.md} +0 -0
  191. {zrb-1.21.29.dist-info → zrb-2.0.0a4.dist-info}/WHEEL +0 -0
  192. {zrb-1.21.29.dist-info → zrb-2.0.0a4.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,281 @@
1
+ import os
2
+ from datetime import datetime
3
+ from typing import Iterable
4
+
5
+ from prompt_toolkit.completion import (
6
+ CompleteEvent,
7
+ Completer,
8
+ Completion,
9
+ PathCompleter,
10
+ )
11
+ from prompt_toolkit.document import Document
12
+
13
+ from zrb.llm.history_manager.any_history_manager import AnyHistoryManager
14
+ from zrb.util.match import fuzzy_match
15
+
16
+
17
+ class InputCompleter(Completer):
18
+ def __init__(
19
+ self,
20
+ history_manager: AnyHistoryManager,
21
+ attach_commands: list[str] = [],
22
+ exit_commands: list[str] = [],
23
+ info_commands: list[str] = [],
24
+ save_commands: list[str] = [],
25
+ load_commands: list[str] = [],
26
+ redirect_output_commands: list[str] = [],
27
+ summarize_commands: list[str] = [],
28
+ exec_commands: list[str] = [],
29
+ ):
30
+ self._history_manager = history_manager
31
+ self._attach_commands = attach_commands
32
+ self._exit_commands = exit_commands
33
+ self._info_commands = info_commands
34
+ self._save_commands = save_commands
35
+ self._load_commands = load_commands
36
+ self._redirect_output_commands = redirect_output_commands
37
+ self._summarize_commands = summarize_commands
38
+ self._exec_commands = exec_commands
39
+ # expanduser=True allows ~/path
40
+ self._path_completer = PathCompleter(expanduser=True)
41
+ # Cache for file listing to improve performance
42
+ self._file_cache: list[str] | None = None
43
+ self._file_cache_time = 0
44
+ self._cmd_history = self._get_cmd_history()
45
+
46
+ def get_completions(
47
+ self, document: Document, complete_event: CompleteEvent
48
+ ) -> Iterable[Completion]:
49
+ text_before_cursor = document.text_before_cursor.lstrip()
50
+ word = document.get_word_before_cursor(WORD=True)
51
+
52
+ all_commands = (
53
+ self._exit_commands
54
+ + self._attach_commands
55
+ + self._summarize_commands
56
+ + self._info_commands
57
+ + self._save_commands
58
+ + self._load_commands
59
+ + self._redirect_output_commands
60
+ + self._exec_commands
61
+ )
62
+ command_prefixes = {cmd[0] for cmd in all_commands if cmd}
63
+
64
+ # 1. Command and Argument Completion
65
+ if text_before_cursor and text_before_cursor[0] in command_prefixes:
66
+ parts = text_before_cursor.split()
67
+ # Check if we are typing the command itself or arguments
68
+ is_typing_command = len(parts) == 1 and not text_before_cursor.endswith(" ")
69
+ is_typing_arg = (len(parts) == 1 and text_before_cursor.endswith(" ")) or (
70
+ len(parts) >= 2
71
+ )
72
+
73
+ if is_typing_command:
74
+ lower_word = word.lower()
75
+ prefix = text_before_cursor[0]
76
+ for cmd in all_commands:
77
+ if cmd.startswith(prefix) and cmd.lower().startswith(lower_word):
78
+ yield Completion(cmd, start_position=-len(word))
79
+ return
80
+
81
+ if is_typing_arg:
82
+ cmd = parts[0]
83
+ arg_prefix = text_before_cursor[len(cmd) :].lstrip()
84
+
85
+ # Exec Command: Suggest History
86
+ if self._is_command(cmd, self._exec_commands):
87
+ # Filter history
88
+ matches = [h for h in self._cmd_history if h.startswith(arg_prefix)]
89
+ # Sort matches by length (shorter first) as heuristic? Or just recent?
90
+ # Since _cmd_history is set (unique), we lose order.
91
+ # But Python 3.7+ dicts preserve insertion order, so if we used dict keys, we kept order.
92
+ # Let's assume _get_cmd_history returns recent last.
93
+ # We reverse to show most recent first.
94
+ for h in reversed(matches):
95
+ yield Completion(h, start_position=-len(arg_prefix))
96
+ return
97
+
98
+ # Check if we are typing the second part (argument) strictly
99
+ # (Re-evaluating logic for other commands which only take 1 arg usually)
100
+ if not (
101
+ (len(parts) == 1 and text_before_cursor.endswith(" "))
102
+ or (len(parts) == 2 and not text_before_cursor.endswith(" "))
103
+ ):
104
+ return
105
+
106
+ arg_prefix = parts[1] if len(parts) == 2 else ""
107
+
108
+ # Save Command: Suggest Timestamp
109
+ if self._is_command(cmd, self._save_commands):
110
+ ts = datetime.now().strftime("%Y-%m-%d-%H-%M")
111
+ if ts.startswith(arg_prefix):
112
+ yield Completion(ts, start_position=-len(arg_prefix))
113
+ return
114
+
115
+ # Redirect Command: Suggest Timestamp.txt
116
+ if self._is_command(cmd, self._redirect_output_commands):
117
+ ts = datetime.now().strftime("%Y-%m-%d-%H-%M.txt")
118
+ if ts.startswith(arg_prefix):
119
+ yield Completion(ts, start_position=-len(arg_prefix))
120
+ return
121
+
122
+ # Load Command: Search History
123
+ if self._is_command(cmd, self._load_commands):
124
+ results = self._history_manager.search(arg_prefix)
125
+ for res in results[:10]:
126
+ yield Completion(res, start_position=-len(arg_prefix))
127
+ return
128
+
129
+ # Attach Command: Suggest Files
130
+ if self._is_command(cmd, self._attach_commands):
131
+ yield from self._get_file_completions(
132
+ arg_prefix, complete_event, only_files=True
133
+ )
134
+ return
135
+
136
+ # Other commands (Exit, Info, Summarize) need no completion
137
+ return
138
+
139
+ # 2. File Completion (@)
140
+ if word.startswith("@"):
141
+ path_part = word[1:]
142
+ yield from self._get_file_completions(
143
+ path_part, complete_event, only_files=False
144
+ )
145
+
146
+ def _get_cmd_history(self) -> list[str]:
147
+ history_files = [
148
+ os.path.expanduser("~/.bash_history"),
149
+ os.path.expanduser("~/.zsh_history"),
150
+ ]
151
+ unique_cmds = {} # Use dict to preserve order (insertion order)
152
+
153
+ for hist_file in history_files:
154
+ if not os.path.exists(hist_file):
155
+ continue
156
+ try:
157
+ with open(hist_file, "r", errors="ignore") as f:
158
+ for line in f:
159
+ line = line.strip()
160
+ if not line:
161
+ continue
162
+ # Handle zsh timestamp format: : 1612345678:0;command
163
+ if line.startswith(": ") and ";" in line:
164
+ parts = line.split(";", 1)
165
+ if len(parts) == 2:
166
+ line = parts[1]
167
+
168
+ if line:
169
+ # Remove existing to update position to end (most recent)
170
+ if line in unique_cmds:
171
+ del unique_cmds[line]
172
+ unique_cmds[line] = None
173
+ except Exception:
174
+ pass
175
+
176
+ return list(unique_cmds.keys())
177
+
178
+ def _is_command(self, cmd: str, cmd_list: list[str]) -> bool:
179
+ return cmd.lower() in [c.lower() for c in cmd_list]
180
+
181
+ def _get_file_completions(
182
+ self, text: str, complete_event: CompleteEvent, only_files: bool = False
183
+ ) -> Iterable[Completion]:
184
+ # Logic:
185
+ # - If text indicates path traversal (/, ., ~), use PathCompleter
186
+ # - Else, check file count. If < 5000, use Fuzzy. Else use PathCompleter.
187
+
188
+ if self._is_path_navigation(text):
189
+ yield from self._get_path_completions(text, complete_event, only_files)
190
+ return
191
+
192
+ # Count files (cached strategy could be added here if needed)
193
+ files = self._get_recursive_files(limit=5000)
194
+ if len(files) < 5000:
195
+ # Fuzzy Match
196
+ yield from self._get_fuzzy_completions(text, files, only_files)
197
+ else:
198
+ # Fallback to PathCompleter for large repos
199
+ yield from self._get_path_completions(text, complete_event, only_files)
200
+
201
+ def _is_path_navigation(self, text: str) -> bool:
202
+ return (
203
+ text.startswith("/")
204
+ or text.startswith(".")
205
+ or text.startswith("~")
206
+ or os.sep in text
207
+ )
208
+
209
+ def _get_path_completions(
210
+ self, text: str, complete_event: CompleteEvent, only_files: bool
211
+ ) -> Iterable[Completion]:
212
+ # PathCompleter needs a document where text represents the path
213
+ fake_document = Document(text=text, cursor_position=len(text))
214
+ for c in self._path_completer.get_completions(fake_document, complete_event):
215
+ if only_files:
216
+ # Check if the completed path is a directory
217
+ # Note: 'text' is the prefix. c.text is the completion suffix.
218
+ # We need to reconstruct full path to check isdir
219
+ # This is tricky with PathCompleter's internal logic.
220
+ # A simple heuristic: if it ends with path separator, it's a dir.
221
+ if c.text.endswith(os.sep):
222
+ continue
223
+ yield c
224
+
225
+ def _get_fuzzy_completions(
226
+ self, text: str, files: list[str], only_files: bool
227
+ ) -> Iterable[Completion]:
228
+ matches = []
229
+ for f in files:
230
+ if only_files and f.endswith(os.sep):
231
+ continue
232
+ is_match, score = fuzzy_match(f, text)
233
+ if is_match:
234
+ matches.append((score, f))
235
+
236
+ # Sort by score (lower is better)
237
+ matches.sort(key=lambda x: x[0])
238
+
239
+ # Return top 20
240
+ for _, f in matches[:20]:
241
+ yield Completion(f, start_position=-len(text))
242
+
243
+ def _get_recursive_files(self, root: str = ".", limit: int = 5000) -> list[str]:
244
+ # Simple walker with exclusions
245
+ paths = []
246
+ # Check if current dir is hidden
247
+ cwd_is_hidden = os.path.basename(os.path.abspath(root)).startswith(".")
248
+
249
+ try:
250
+ for dirpath, dirnames, filenames in os.walk(root):
251
+ # Exclude hidden directories unless root is hidden
252
+ if not cwd_is_hidden:
253
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
254
+
255
+ # Exclude common ignores
256
+ dirnames[:] = [
257
+ d
258
+ for d in dirnames
259
+ if d not in ("node_modules", "__pycache__", "venv", ".venv")
260
+ ]
261
+
262
+ rel_dir = os.path.relpath(dirpath, root)
263
+ if rel_dir == ".":
264
+ rel_dir = ""
265
+
266
+ # Add directories
267
+ for d in dirnames:
268
+ paths.append(os.path.join(rel_dir, d) + os.sep)
269
+ if len(paths) >= limit:
270
+ return paths
271
+
272
+ # Add files
273
+ for f in filenames:
274
+ if not cwd_is_hidden and f.startswith("."):
275
+ continue
276
+ paths.append(os.path.join(rel_dir, f))
277
+ if len(paths) >= limit:
278
+ return paths
279
+ except Exception:
280
+ pass
281
+ return paths
@@ -0,0 +1,66 @@
1
+ import json
2
+ import re
3
+ from typing import Any, Awaitable, Callable, Dict, Optional
4
+
5
+ from zrb.llm.app.confirmation.handler import ConfirmationMiddleware, UIProtocol
6
+
7
+
8
+ def allow_tool_usage(
9
+ tool_name: str, kwargs: Optional[Dict[str, str]] = None
10
+ ) -> ConfirmationMiddleware:
11
+ """
12
+ Creates a confirmation middleware that automatically approves a tool execution
13
+ if it matches the specified tool_name and argument constraints.
14
+
15
+ :param tool_name: The name of the tool to allow.
16
+ :param kwargs: A dictionary of regex patterns for arguments.
17
+ If None or empty, the tool is allowed regardless of arguments.
18
+ If provided, arguments in the tool call must match the regex patterns
19
+ specified in kwargs (only for arguments present in both).
20
+ :return: A ConfirmationMiddleware function.
21
+ """
22
+ from pydantic_ai import ToolApproved
23
+
24
+ async def middleware(
25
+ ui: UIProtocol,
26
+ call: Any,
27
+ response: str,
28
+ next_handler: Callable[[UIProtocol, Any, str], Awaitable[Any]],
29
+ ) -> Any:
30
+ # Check if tool name matches
31
+ if call.tool_name != tool_name:
32
+ return await next_handler(ui, call, response)
33
+
34
+ # If kwargs is empty or None, approve
35
+ if not kwargs:
36
+ ui.append_to_output(f"\n✅ Auto-approved tool: {tool_name}")
37
+ return ToolApproved()
38
+
39
+ # Parse arguments
40
+ try:
41
+ args = call.args
42
+ if isinstance(args, str):
43
+ args = json.loads(args)
44
+
45
+ if not isinstance(args, dict):
46
+ # If args is not a dict (e.g. primitive), and kwargs is not empty,
47
+ # we assume it doesn't match complex constraints (or we can't check keys).
48
+ # So we delegate to the next handler.
49
+ return await next_handler(ui, call, response)
50
+
51
+ except (json.JSONDecodeError, ValueError):
52
+ return await next_handler(ui, call, response)
53
+
54
+ # Check constraints
55
+ # "all parameter in the call parameter has to match the ones in kwargs (if that parameter defined in the kwargs)"
56
+ for arg_name, arg_value in args.items():
57
+ if arg_name in kwargs:
58
+ pattern = kwargs[arg_name]
59
+ # Convert arg_value to string for regex matching
60
+ if not re.search(pattern, str(arg_value)):
61
+ return await next_handler(ui, call, response)
62
+
63
+ ui.append_to_output(f"\n✅ Auto-approved tool: {tool_name} with matching args")
64
+ return ToolApproved()
65
+
66
+ return middleware
@@ -0,0 +1,178 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ from typing import Any, Awaitable, Callable, Protocol, TextIO
6
+
7
+ import yaml
8
+
9
+ from zrb.config.config import CFG
10
+ from zrb.util.yaml import yaml_dump
11
+
12
+
13
+ class UIProtocol(Protocol):
14
+ async def ask_user(self, prompt: str) -> str: ...
15
+
16
+ def append_to_output(
17
+ self,
18
+ *values: object,
19
+ sep: str = " ",
20
+ end: str = "\n",
21
+ file: TextIO | None = None,
22
+ flush: bool = False,
23
+ ): ...
24
+
25
+
26
+ ConfirmationMiddleware = Callable[
27
+ [UIProtocol, Any, str, Callable[[UIProtocol, Any, str], Awaitable[Any]]],
28
+ Awaitable[Any],
29
+ ]
30
+
31
+
32
+ class ConfirmationHandler:
33
+ def __init__(self, middlewares: list[ConfirmationMiddleware]):
34
+ self._middlewares = middlewares
35
+
36
+ def add_middleware(self, *middleware: ConfirmationMiddleware):
37
+ self.prepend_middleware(*middleware)
38
+
39
+ def prepend_middleware(self, *middleware: ConfirmationMiddleware):
40
+ self._middlewares = list(middleware) + self._middlewares
41
+
42
+ async def handle(self, ui: UIProtocol, call: Any) -> Any:
43
+ while True:
44
+ message = self._get_confirm_user_message(call)
45
+ ui.append_to_output(f"\n\n{message}", end="")
46
+ # Wait for user input
47
+ user_input = await ui.ask_user("")
48
+ user_response = user_input.strip()
49
+
50
+ # Build the chain
51
+ async def _next(
52
+ ui: UIProtocol, call: Any, response: str, index: int
53
+ ) -> Any:
54
+ if index >= len(self._middlewares):
55
+ # Default if no middleware handles it
56
+ return None
57
+ middleware = self._middlewares[index]
58
+ return await middleware(
59
+ ui,
60
+ call,
61
+ response,
62
+ lambda u, c, r: _next(u, c, r, index + 1),
63
+ )
64
+
65
+ result = await _next(ui, call, user_response, 0)
66
+ if result is None:
67
+ continue
68
+ return result
69
+
70
+ def _get_confirm_user_message(self, call: Any) -> str:
71
+ args_section = ""
72
+ if f"{call.args}" != "{}":
73
+ args_str = self._format_args(call.args)
74
+ args_section = f" Arguments:\n{args_str}\n"
75
+ return (
76
+ f" 🎰 Executing tool '{call.tool_name}'\n"
77
+ f"{args_section}"
78
+ " ❓ Allow tool Execution? (✅ Y | 🛑 n | ✏️ e)? "
79
+ )
80
+
81
+ def _format_args(self, args: Any) -> str:
82
+ indent = " " * 7
83
+ try:
84
+ if isinstance(args, str):
85
+ try:
86
+ args = json.loads(args)
87
+ except json.JSONDecodeError:
88
+ pass
89
+ args_str = yaml_dump(args)
90
+ # Indent nicely for display
91
+ return "\n".join([f"{indent}{line}" for line in args_str.splitlines()])
92
+ except Exception:
93
+ return f"{indent}{args}"
94
+
95
+
96
+ async def last_confirmation(
97
+ ui: UIProtocol,
98
+ call: Any,
99
+ user_response: str,
100
+ next_handler: Callable[[UIProtocol, Any, str], Awaitable[Any]],
101
+ ) -> Any:
102
+ from pydantic_ai import ToolApproved, ToolDenied
103
+
104
+ if user_response.lower() in ("y", "yes", "ok", "okay", ""):
105
+ ui.append_to_output("\n✅ Execution approved.")
106
+ return ToolApproved()
107
+ elif user_response.lower() in ("n", "no"):
108
+ ui.append_to_output("\n🛑 Execution denied.")
109
+ return ToolDenied("User denied execution")
110
+ elif user_response.lower() in ("e", "edit"):
111
+ # Edit logic
112
+ try:
113
+ args = call.args
114
+ if isinstance(args, str):
115
+ try:
116
+ args = json.loads(args)
117
+ except json.JSONDecodeError:
118
+ pass
119
+
120
+ # YAML for editing
121
+ is_yaml_edit = True
122
+ try:
123
+ content = yaml_dump(args)
124
+ extension = ".yaml"
125
+ except Exception:
126
+ # Fallback to JSON
127
+ content = json.dumps(args, indent=2)
128
+ extension = ".json"
129
+ is_yaml_edit = False
130
+
131
+ new_content = await wait_edit_content(
132
+ text_editor=CFG.DEFAULT_EDITOR,
133
+ content=content,
134
+ extension=extension,
135
+ )
136
+
137
+ # Compare content
138
+ if new_content == content:
139
+ ui.append_to_output("\nℹ️ No changes made.")
140
+ return None
141
+
142
+ try:
143
+ if is_yaml_edit:
144
+ new_args = yaml.safe_load(new_content)
145
+ else:
146
+ new_args = json.loads(new_content)
147
+ ui.append_to_output("\n✅ Execution approved (with modification).")
148
+ return ToolApproved(override_args=new_args)
149
+ except Exception as e:
150
+ ui.append_to_output(f"\n❌ Invalid format: {e}. ", end="")
151
+ # Return None to signal loop retry
152
+ return None
153
+
154
+ except Exception as e:
155
+ ui.append_to_output(f"\n❌ Error editing: {e}. ", end="")
156
+ return None
157
+ else:
158
+ ui.append_to_output("\n🛑 Execution denied.")
159
+ return ToolDenied(f"User denied execution with message: {user_response}")
160
+
161
+
162
+ async def wait_edit_content(
163
+ text_editor: str, content: str, extension: str = ".txt"
164
+ ) -> str:
165
+ from prompt_toolkit.application import run_in_terminal
166
+
167
+ # Write temporary file
168
+ with tempfile.NamedTemporaryFile(suffix=extension, mode="w+", delete=False) as tf:
169
+ tf.write(content)
170
+ tf_path = tf.name
171
+
172
+ # Edit and wait
173
+ await run_in_terminal(lambda: subprocess.call([text_editor, tf_path]))
174
+ with open(tf_path, "r") as tf:
175
+ new_content = tf.read()
176
+ os.remove(tf_path)
177
+
178
+ return new_content
@@ -0,0 +1,77 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ from typing import Any, Awaitable, Callable
6
+
7
+ from prompt_toolkit.application import run_in_terminal
8
+
9
+ from zrb.config.config import CFG
10
+ from zrb.llm.app.confirmation.handler import UIProtocol
11
+
12
+
13
+ async def replace_confirmation(
14
+ ui: UIProtocol,
15
+ call: Any,
16
+ response: str,
17
+ next_handler: Callable[[UIProtocol, Any, str], Awaitable[Any]],
18
+ ) -> Any:
19
+ from pydantic_ai import ToolApproved
20
+
21
+ if call.tool_name != "replace_in_file":
22
+ return await next_handler(ui, call, response)
23
+
24
+ if response.lower() not in ("e", "edit"):
25
+ return await next_handler(ui, call, response)
26
+
27
+ # It is replace_in_file and user wants to edit
28
+ args = call.args
29
+ if isinstance(args, str):
30
+ try:
31
+ args = json.loads(args)
32
+ except json.JSONDecodeError:
33
+ pass
34
+
35
+ old_text = args.get("old_text", "")
36
+ new_text = args.get("new_text", "")
37
+
38
+ # Create temporary files
39
+ with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".old") as tf_old:
40
+ tf_old.write(old_text)
41
+ old_path = tf_old.name
42
+
43
+ with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".new") as tf_new:
44
+ tf_new.write(new_text)
45
+ new_path = tf_new.name
46
+
47
+ try:
48
+ # Prepare command
49
+ cmd_tpl = CFG.DEFAULT_DIFF_EDIT_COMMAND_TPL
50
+ cmd = cmd_tpl.format(old=old_path, new=new_path)
51
+
52
+ # Run command
53
+ await run_in_terminal(lambda: subprocess.call(cmd, shell=True))
54
+
55
+ # Read back new content
56
+ with open(new_path, "r") as f:
57
+ edited_new_text = f.read()
58
+
59
+ if edited_new_text != new_text:
60
+ # Update args
61
+ new_args = dict(args)
62
+ new_args["new_text"] = edited_new_text
63
+ ui.append_to_output("\n✅ Replacement modified.")
64
+ return ToolApproved(override_args=new_args)
65
+ else:
66
+ ui.append_to_output("\nℹ️ No changes made.")
67
+ return None
68
+
69
+ except Exception as e:
70
+ ui.append_to_output(f"\n❌ Error during diff edit: {e}")
71
+ return None
72
+ finally:
73
+ # Cleanup
74
+ if os.path.exists(old_path):
75
+ os.remove(old_path)
76
+ if os.path.exists(new_path):
77
+ os.remove(new_path)
@@ -0,0 +1,34 @@
1
+ import string
2
+
3
+ from prompt_toolkit.application import get_app
4
+ from prompt_toolkit.key_binding import KeyBindings
5
+ from prompt_toolkit.widgets import TextArea
6
+
7
+
8
+ def create_output_keybindings(input_field: TextArea) -> KeyBindings:
9
+ kb = KeyBindings()
10
+
11
+ @kb.add("escape")
12
+ def _(event):
13
+ get_app().layout.focus(input_field)
14
+
15
+ @kb.add("c-c")
16
+ def _(event):
17
+ # Copy selection to clipboard
18
+ if event.current_buffer.selection_state:
19
+ data = event.current_buffer.copy_selection()
20
+ event.app.clipboard.set_data(data)
21
+ get_app().layout.focus(input_field)
22
+
23
+ def redirect_focus(event):
24
+ get_app().layout.focus(input_field)
25
+ input_field.buffer.insert_text(event.data)
26
+
27
+ for char in string.printable:
28
+ # Skip control characters (Tab, Newline, etc.)
29
+ # to preserve navigation/standard behavior
30
+ if char in "\t\n\r\x0b\x0c":
31
+ continue
32
+ kb.add(char)(redirect_focus)
33
+
34
+ return kb