zrb 1.21.31__py3-none-any.whl → 1.21.43__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 (46) hide show
  1. zrb/builtin/llm/chat_completion.py +94 -84
  2. zrb/builtin/llm/chat_session.py +90 -30
  3. zrb/builtin/llm/chat_session_cmd.py +115 -22
  4. zrb/builtin/llm/chat_trigger.py +92 -5
  5. zrb/builtin/llm/history.py +14 -7
  6. zrb/builtin/llm/llm_ask.py +16 -7
  7. zrb/builtin/llm/tool/cli.py +34 -15
  8. zrb/builtin/llm/tool/file.py +14 -2
  9. zrb/builtin/llm/tool/search/brave.py +8 -2
  10. zrb/builtin/llm/tool/search/searxng.py +8 -2
  11. zrb/builtin/llm/tool/search/serpapi.py +8 -2
  12. zrb/builtin/llm/tool/sub_agent.py +4 -1
  13. zrb/builtin/llm/tool/web.py +5 -0
  14. zrb/builtin/llm/xcom_names.py +3 -0
  15. zrb/callback/callback.py +8 -1
  16. zrb/cmd/cmd_result.py +2 -1
  17. zrb/config/config.py +6 -2
  18. zrb/config/default_prompt/interactive_system_prompt.md +15 -12
  19. zrb/config/default_prompt/system_prompt.md +16 -18
  20. zrb/config/llm_rate_limitter.py +36 -13
  21. zrb/context/context.py +11 -0
  22. zrb/input/option_input.py +30 -2
  23. zrb/task/base/context.py +25 -13
  24. zrb/task/base/execution.py +52 -47
  25. zrb/task/base/lifecycle.py +1 -1
  26. zrb/task/base_task.py +31 -45
  27. zrb/task/base_trigger.py +0 -1
  28. zrb/task/cmd_task.py +3 -0
  29. zrb/task/llm/agent.py +39 -31
  30. zrb/task/llm/agent_runner.py +65 -3
  31. zrb/task/llm/default_workflow/researching/workflow.md +2 -0
  32. zrb/task/llm/history_list.py +13 -0
  33. zrb/task/llm/history_processor.py +4 -13
  34. zrb/task/llm/print_node.py +79 -25
  35. zrb/task/llm/prompt.py +70 -40
  36. zrb/task/llm/tool_wrapper.py +4 -1
  37. zrb/task/llm/workflow.py +54 -15
  38. zrb/task/llm_task.py +87 -33
  39. zrb/task/rsync_task.py +2 -0
  40. zrb/util/cmd/command.py +33 -10
  41. zrb/util/match.py +71 -0
  42. zrb/util/run.py +3 -3
  43. {zrb-1.21.31.dist-info → zrb-1.21.43.dist-info}/METADATA +1 -1
  44. {zrb-1.21.31.dist-info → zrb-1.21.43.dist-info}/RECORD +46 -43
  45. {zrb-1.21.31.dist-info → zrb-1.21.43.dist-info}/WHEEL +0 -0
  46. {zrb-1.21.31.dist-info → zrb-1.21.43.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  import os
2
- import subprocess
2
+ from typing import Callable
3
3
 
4
+ from zrb.config.config import CFG
4
5
  from zrb.context.any_context import AnyContext
5
6
  from zrb.task.llm.workflow import get_available_workflows
6
7
  from zrb.util.cli.markdown import render_markdown
@@ -10,7 +11,8 @@ from zrb.util.cli.style import (
10
11
  stylize_error,
11
12
  stylize_faint,
12
13
  )
13
- from zrb.util.file import write_file
14
+ from zrb.util.cmd.command import run_command
15
+ from zrb.util.file import read_file, write_file
14
16
  from zrb.util.markdown import make_markdown_section
15
17
  from zrb.util.string.conversion import FALSE_STRS, TRUE_STRS, to_boolean
16
18
 
@@ -18,14 +20,20 @@ MULTILINE_START_CMD = ["/multi", "/multiline"]
18
20
  MULTILINE_END_CMD = ["/end"]
19
21
  QUIT_CMD = ["/bye", "/quit", "/q", "/exit"]
20
22
  WORKFLOW_CMD = ["/workflow", "/workflows", "/skill", "/skills", "/w"]
23
+ RESPONSE_CMD = ["/response", "/res"]
21
24
  SAVE_CMD = ["/save", "/s"]
25
+ LOAD_CMD = ["/load", "/l"]
22
26
  ATTACHMENT_CMD = ["/attachment", "/attachments", "/attach"]
23
27
  YOLO_CMD = ["/yolo"]
24
28
  HELP_CMD = ["/help", "/info"]
29
+ RUN_CLI_CMD = ["/run", "/exec", "/execute", "/cmd", "/cli", "!"]
30
+ SESSION_CMD = ["/session", "/conversation", "/convo"]
31
+
25
32
  ADD_SUB_CMD = ["add"]
26
33
  SET_SUB_CMD = ["set"]
27
34
  CLEAR_SUB_CMD = ["clear"]
28
- RUN_CLI_CMD = ["/run", "/exec", "/execute", "/cmd", "/cli", "!"]
35
+ SAVE_SUB_CMD = ["save"]
36
+ LOAD_SUB_CMD = ["load"]
29
37
 
30
38
  # Command display constants
31
39
  MULTILINE_START_CMD_DESC = "Start multiline input"
@@ -33,14 +41,20 @@ MULTILINE_END_CMD_DESC = "End multiline input"
33
41
  QUIT_CMD_DESC = "Quit from chat session"
34
42
  WORKFLOW_CMD_DESC = "Show active workflows"
35
43
  WORKFLOW_ADD_SUB_CMD_DESC = (
36
- "Add active workflow "
37
- f"(e.g., `{WORKFLOW_CMD[0]} {ADD_SUB_CMD[0]} coding,researching`)"
44
+ "Add active workflow " f"(e.g., `{WORKFLOW_CMD[0]} {ADD_SUB_CMD[0]} coding`)"
38
45
  )
39
46
  WORKFLOW_SET_SUB_CMD_DESC = (
40
- "Set active workflows " f"(e.g., `{WORKFLOW_CMD[0]} {SET_SUB_CMD[0]} coding,`)"
47
+ "Set active workflows "
48
+ f"(e.g., `{WORKFLOW_CMD[0]} {SET_SUB_CMD[0]} coding,researching`)"
41
49
  )
42
50
  WORKFLOW_CLEAR_SUB_CMD_DESC = "Deactivate all workflows"
43
- SAVE_CMD_DESC = f"Save last response to a file (e.g., `{SAVE_CMD[0]} conclusion.md`)"
51
+ RESPONSE_CMD_DESC = "Manage response"
52
+ RESPONSE_SAVE_SUB_CMD_DESC = (
53
+ "Save last response to a file "
54
+ f"(e.g., `{RESPONSE_CMD[0]} {SAVE_SUB_CMD[0]} conclusion.md`)"
55
+ )
56
+ SAVE_CMD_DESC = "Save current session " f"(e.g., `{SAVE_CMD[0]} save-point`)"
57
+ LOAD_CMD_DESC = "Load session " f"(e.g., `{LOAD_CMD[0]} save-point`)"
44
58
  ATTACHMENT_CMD_DESC = "Show current attachment"
45
59
  ATTACHMENT_ADD_SUB_CMD_DESC = (
46
60
  "Attach a file " f"(e.g., `{ATTACHMENT_CMD[0]} {ADD_SUB_CMD[0]} ./logo.png`)"
@@ -59,6 +73,7 @@ YOLO_SET_TRUE_CMD_DESC = "Activate YOLO mode for all tools"
59
73
  YOLO_SET_FALSE_CMD_DESC = "Deactivate YOLO mode for all tools"
60
74
  RUN_CLI_CMD_DESC = "Run a non-interactive CLI command"
61
75
  HELP_CMD_DESC = "Show info/help"
76
+ SESSION_CMD_DESC = "Show current session"
62
77
 
63
78
 
64
79
  def print_current_yolo_mode(
@@ -73,7 +88,9 @@ def print_current_yolo_mode(
73
88
 
74
89
  def print_current_attachments(ctx: AnyContext, current_attachments_value: str) -> None:
75
90
  attachments_str = (
76
- current_attachments_value if current_attachments_value != "" else "*Not Set*"
91
+ ", ".join(current_attachments_value.split(","))
92
+ if current_attachments_value != ""
93
+ else "*Not Set*"
77
94
  )
78
95
  ctx.print(render_markdown(f"📎 Current attachments: {attachments_str}"), plain=True)
79
96
  ctx.print("", plain=True)
@@ -87,7 +104,7 @@ def print_current_workflows(ctx: AnyContext, current_workflows_value: str) -> No
87
104
  else "*No Available Workflow*"
88
105
  )
89
106
  current_workflows_str = (
90
- current_workflows_value
107
+ ", ".join(current_workflows_value.split(","))
91
108
  if current_workflows_value != ""
92
109
  else "*No Active Workflow*"
93
110
  )
@@ -105,8 +122,10 @@ def print_current_workflows(ctx: AnyContext, current_workflows_value: str) -> No
105
122
  ctx.print("", plain=True)
106
123
 
107
124
 
108
- def save_final_result(ctx: AnyContext, user_input: str, final_result: str) -> None:
109
- save_path = get_command_param(user_input, SAVE_CMD)
125
+ def handle_response_cmd(ctx: AnyContext, user_input: str, final_result: str) -> None:
126
+ if not is_command_match(user_input, RESPONSE_CMD, SAVE_SUB_CMD):
127
+ return
128
+ save_path = get_command_param(user_input, RESPONSE_CMD, SAVE_SUB_CMD)
110
129
  save_path = os.path.expanduser(save_path)
111
130
  if os.path.exists(save_path):
112
131
  ctx.print(
@@ -118,13 +137,12 @@ def save_final_result(ctx: AnyContext, user_input: str, final_result: str) -> No
118
137
  ctx.print(f"Response saved to {save_path}", plain=True)
119
138
 
120
139
 
121
- def run_cli_command(ctx: AnyContext, user_input: str) -> None:
140
+ async def run_cli_command(ctx: AnyContext, user_input: str) -> None:
122
141
  command = get_command_param(user_input, RUN_CLI_CMD)
123
- result = subprocess.run(
124
- command,
125
- shell=True,
126
- capture_output=True,
127
- text=True,
142
+ cmd_result, return_code = await run_command(
143
+ [CFG.DEFAULT_SHELL, "-c", command],
144
+ print_method=_create_faint_print(ctx),
145
+ timeout=3600,
128
146
  )
129
147
  ctx.print(
130
148
  render_markdown(
@@ -132,10 +150,14 @@ def run_cli_command(ctx: AnyContext, user_input: str) -> None:
132
150
  f"`{command}`",
133
151
  "\n".join(
134
152
  [
135
- make_markdown_section("📤 Stdout", result.stdout, as_code=True),
136
- make_markdown_section("🚫 Stderr", result.stderr, as_code=True),
137
153
  make_markdown_section(
138
- "🎯 Return code", f"Return Code: {result.returncode}"
154
+ "📤 Stdout", cmd_result.output, as_code=True
155
+ ),
156
+ make_markdown_section(
157
+ "🚫 Stderr", cmd_result.error, as_code=True
158
+ ),
159
+ make_markdown_section(
160
+ "🎯 Return code", f"Return Code: {return_code}"
139
161
  ),
140
162
  ]
141
163
  ),
@@ -146,8 +168,20 @@ def run_cli_command(ctx: AnyContext, user_input: str) -> None:
146
168
  ctx.print("", plain=True)
147
169
 
148
170
 
171
+ def _create_faint_print(ctx: AnyContext) -> Callable[..., None]:
172
+ def print_faint(text: str):
173
+ ctx.print(stylize_faint(f" {text}"), plain=True)
174
+
175
+ return print_faint
176
+
177
+
149
178
  def get_new_yolo_mode(old_yolo_mode: str | bool, user_input: str) -> str | bool:
150
- new_yolo_mode = get_command_param(user_input, YOLO_CMD)
179
+ if not is_command_match(user_input, YOLO_CMD):
180
+ return old_yolo_mode
181
+ if is_command_match(user_input, YOLO_CMD, SET_SUB_CMD):
182
+ new_yolo_mode = get_command_param(user_input, YOLO_CMD, SET_SUB_CMD)
183
+ else:
184
+ new_yolo_mode = get_command_param(user_input, YOLO_CMD)
151
185
  if new_yolo_mode != "":
152
186
  if new_yolo_mode in TRUE_STRS or new_yolo_mode in FALSE_STRS:
153
187
  return to_boolean(new_yolo_mode)
@@ -189,6 +223,59 @@ def get_new_workflows(old_workflow: str, user_input: str) -> str:
189
223
  return _normalize_comma_separated_str(old_workflow)
190
224
 
191
225
 
226
+ def handle_session(
227
+ ctx: AnyContext,
228
+ current_session_name: str | None,
229
+ current_start_new: bool,
230
+ user_input: str,
231
+ ) -> tuple[str | None, bool]:
232
+ if is_command_match(user_input, SAVE_CMD):
233
+ save_point = get_command_param(user_input, SAVE_CMD)
234
+ if not save_point:
235
+ ctx.print(render_markdown("️⚠️ Save point name is required."), plain=True)
236
+ return current_session_name, current_start_new
237
+ if not current_session_name:
238
+ ctx.print(
239
+ render_markdown(
240
+ "⚠️ No active session to save. Please start a conversation first."
241
+ ),
242
+ plain=True,
243
+ )
244
+ return current_session_name, current_start_new
245
+ save_point_path = os.path.join(CFG.LLM_HISTORY_DIR, "save-point", save_point)
246
+ write_file(save_point_path, current_session_name)
247
+ ctx.print(
248
+ render_markdown(
249
+ f"Session saved to save-point: {save_point} ({current_session_name})"
250
+ ),
251
+ plain=True,
252
+ )
253
+ return current_session_name, current_start_new
254
+ if is_command_match(user_input, LOAD_CMD):
255
+ save_point = get_command_param(user_input, LOAD_CMD)
256
+ if not save_point:
257
+ ctx.print(render_markdown("⚠️ Save point name is required."), plain=True)
258
+ return current_session_name, current_start_new
259
+ save_point_path = os.path.join(CFG.LLM_HISTORY_DIR, "save-point", save_point)
260
+ if not os.path.exists(save_point_path):
261
+ ctx.print(
262
+ render_markdown(f"⚠️ Save point '{save_point}' not found."), plain=True
263
+ )
264
+ return current_session_name, current_start_new
265
+ current_session_name = read_file(save_point_path).strip()
266
+ ctx.print(
267
+ render_markdown(f"Loaded session: {current_session_name}"), plain=True
268
+ )
269
+ # When loading a session, we shouldn't start a new one
270
+ return current_session_name, False
271
+ if is_command_match(user_input, SESSION_CMD):
272
+ ctx.print(
273
+ render_markdown(f"Current session: {current_session_name}"), plain=True
274
+ )
275
+ return current_session_name, current_start_new
276
+ return current_session_name, current_start_new
277
+
278
+
192
279
  def _normalize_comma_separated_str(comma_separated_str: str) -> str:
193
280
  return ",".join(
194
281
  [
@@ -252,7 +339,13 @@ def print_commands(ctx: AnyContext):
252
339
  WORKFLOW_SET_SUB_CMD_DESC,
253
340
  ),
254
341
  _show_subcommand(CLEAR_SUB_CMD[0], "", WORKFLOW_CLEAR_SUB_CMD_DESC),
255
- _show_command(f"{SAVE_CMD[0]}", SAVE_CMD_DESC),
342
+ _show_command(SESSION_CMD[0], SESSION_CMD_DESC),
343
+ _show_command(SAVE_CMD[0], SAVE_CMD_DESC),
344
+ _show_command(LOAD_CMD[0], LOAD_CMD_DESC),
345
+ _show_command(RESPONSE_CMD[0], RESPONSE_CMD_DESC),
346
+ _show_subcommand(
347
+ SAVE_SUB_CMD[0], "<file-path>", RESPONSE_SAVE_SUB_CMD_DESC
348
+ ),
256
349
  _show_command(YOLO_CMD[0], YOLO_CMD_DESC),
257
350
  _show_subcommand(SET_SUB_CMD[0], "true", YOLO_SET_TRUE_CMD_DESC),
258
351
  _show_subcommand(SET_SUB_CMD[0], "false", YOLO_SET_FALSE_CMD_DESC),
@@ -4,7 +4,9 @@ from asyncio import StreamReader
4
4
  from typing import TYPE_CHECKING, Any, Callable, Coroutine
5
5
 
6
6
  from zrb.builtin.llm.chat_completion import get_chat_completer
7
+ from zrb.config.llm_config import llm_config
7
8
  from zrb.context.any_context import AnyContext
9
+ from zrb.util.git import get_current_branch
8
10
  from zrb.util.run import run_async
9
11
 
10
12
  if TYPE_CHECKING:
@@ -27,10 +29,20 @@ class LLMChatTrigger:
27
29
  self._triggers.append(single_trigger)
28
30
 
29
31
  async def wait(
30
- self, reader: "PromptSession[Any] | StreamReader", ctx: AnyContext
32
+ self,
33
+ ctx: AnyContext,
34
+ reader: "PromptSession[Any] | StreamReader",
35
+ current_session_name: str | None,
36
+ is_first_time: bool,
31
37
  ) -> str:
32
38
  trigger_tasks = [
33
- asyncio.create_task(run_async(self._read_next_line(reader, ctx)))
39
+ asyncio.create_task(
40
+ run_async(
41
+ self._read_next_line(
42
+ ctx, reader, current_session_name, is_first_time
43
+ )
44
+ )
45
+ )
34
46
  ] + [asyncio.create_task(run_async(trigger(ctx))) for trigger in self._triggers]
35
47
  final_result: str = ""
36
48
  try:
@@ -47,22 +59,33 @@ class LLMChatTrigger:
47
59
  except asyncio.CancelledError:
48
60
  ctx.print("Task cancelled.", plain=True)
49
61
  final_result = "/bye"
62
+ for task in trigger_tasks:
63
+ task.cancel()
50
64
  except KeyboardInterrupt:
51
65
  ctx.print("KeyboardInterrupt detected. Exiting...", plain=True)
52
66
  final_result = "/bye"
67
+ for task in trigger_tasks:
68
+ task.cancel()
53
69
  return final_result
54
70
 
55
71
  async def _read_next_line(
56
- self, reader: "PromptSession[Any] | StreamReader", ctx: AnyContext
72
+ self,
73
+ ctx: AnyContext,
74
+ reader: "PromptSession[Any] | StreamReader",
75
+ current_session_name: str | None,
76
+ is_first_time: bool,
57
77
  ) -> str:
58
78
  """Reads one line of input using the provided reader."""
59
79
  from prompt_toolkit import PromptSession
60
80
 
61
81
  try:
62
82
  if isinstance(reader, PromptSession):
63
- bottom_toolbar = f"📁 Current directory: {os.getcwd()}"
83
+ bottom_toolbar = await self._get_bottom_toolbar(
84
+ ctx, current_session_name, is_first_time
85
+ )
64
86
  return await reader.prompt_async(
65
- completer=get_chat_completer(), bottom_toolbar=bottom_toolbar
87
+ completer=get_chat_completer(),
88
+ bottom_toolbar=bottom_toolbar,
66
89
  )
67
90
  line_bytes = await reader.readline()
68
91
  if not line_bytes:
@@ -74,5 +97,69 @@ class LLMChatTrigger:
74
97
  ctx.print("KeyboardInterrupt detected. Exiting...", plain=True)
75
98
  return "/bye"
76
99
 
100
+ async def _get_bottom_toolbar(
101
+ self, ctx: AnyContext, current_session_name: str | None, is_first_time: bool
102
+ ) -> str:
103
+ import shutil
104
+
105
+ terminal_width = shutil.get_terminal_size().columns
106
+ previous_session_name = self._get_previous_session_name(
107
+ ctx, current_session_name, is_first_time
108
+ )
109
+ current_branch = await self._get_current_branch()
110
+ current_model = self._get_current_model(ctx)
111
+ left_text = f"📌 {os.getcwd()} ({current_branch}) | 🧠 {current_model}"
112
+ right_text = f"📚 Previous Session: {previous_session_name}"
113
+ padding = (
114
+ terminal_width
115
+ - self._get_display_width(left_text)
116
+ - self._get_display_width(right_text)
117
+ - 1
118
+ )
119
+ if padding > 0:
120
+ return f"{left_text}{' ' * padding}{right_text}"
121
+ return f"{left_text} {right_text}"
122
+
123
+ def _get_display_width(self, text: str) -> int:
124
+ import unicodedata
125
+
126
+ width = 0
127
+ for char in text:
128
+ eaw = unicodedata.east_asian_width(char)
129
+ if eaw in ("F", "W"): # Fullwidth or Wide
130
+ width += 2
131
+ elif eaw == "A": # Ambiguous
132
+ width += 1 # Usually 1 in non-East Asian contexts
133
+ else: # Narrow, Halfwidth, Neutral
134
+ width += 1
135
+ return width
136
+
137
+ def _get_current_model(self, ctx: AnyContext) -> str:
138
+ if "model" in ctx.input and ctx.input.model:
139
+ return ctx.input.model
140
+ return str(llm_config.default_model_name)
141
+
142
+ def _get_previous_session_name(
143
+ self, ctx: AnyContext, current_session_name: str | None, is_first_time: bool
144
+ ) -> str:
145
+ if is_first_time:
146
+ start_new: bool = ctx.input.start_new
147
+ if (
148
+ not start_new
149
+ and "previous_session" in ctx.input
150
+ and ctx.input.previous_session is not None
151
+ ):
152
+ return ctx.input.previous_session
153
+ return "<No Session>"
154
+ if not current_session_name:
155
+ return "<No Session>"
156
+ return current_session_name
157
+
158
+ async def _get_current_branch(self) -> str:
159
+ try:
160
+ return await get_current_branch(os.getcwd(), print_method=lambda x: x)
161
+ except Exception:
162
+ return "<Not a git repo>"
163
+
77
164
 
78
165
  llm_chat_trigger = LLMChatTrigger()
@@ -17,13 +17,10 @@ def read_chat_conversation(ctx: AnyContext) -> dict[str, Any] | list | None:
17
17
  return None # Indicate no history to load
18
18
  previous_session_name = ctx.input.previous_session
19
19
  if not previous_session_name: # Check for empty string or None
20
- last_session_file_path = os.path.join(CFG.LLM_HISTORY_DIR, "last-session")
21
- if os.path.isfile(last_session_file_path):
22
- previous_session_name = read_file(last_session_file_path).strip()
23
- if not previous_session_name: # Handle empty last-session file
24
- return None
25
- else:
26
- return None # No previous session specified and no last session found
20
+ last_session_name = get_last_session_name()
21
+ if last_session_name is None:
22
+ return None
23
+ previous_session_name = last_session_name
27
24
  conversation_file_path = os.path.join(
28
25
  CFG.LLM_HISTORY_DIR, f"{previous_session_name}.json"
29
26
  )
@@ -51,6 +48,16 @@ def read_chat_conversation(ctx: AnyContext) -> dict[str, Any] | list | None:
51
48
  return None
52
49
 
53
50
 
51
+ def get_last_session_name() -> str | None:
52
+ last_session_file_path = os.path.join(CFG.LLM_HISTORY_DIR, "last-session")
53
+ if not os.path.isfile(last_session_file_path):
54
+ return None
55
+ last_session_name = read_file(last_session_file_path).strip()
56
+ if not last_session_name: # Handle empty last-session file
57
+ return None
58
+ return last_session_name
59
+
60
+
54
61
  def write_chat_conversation(ctx: AnyContext, history_data: ConversationHistory):
55
62
  """Writes the conversation history data (including context) to a session file."""
56
63
  os.makedirs(CFG.LLM_HISTORY_DIR, exist_ok=True)
@@ -31,6 +31,11 @@ from zrb.builtin.llm.tool.web import (
31
31
  create_search_internet_tool,
32
32
  open_web_page,
33
33
  )
34
+ from zrb.builtin.llm.xcom_names import (
35
+ LLM_ASK_ERROR_XCOM_NAME,
36
+ LLM_ASK_RESULT_XCOM_NAME,
37
+ LLM_ASK_SESSION_XCOM_NAME,
38
+ )
34
39
  from zrb.callback.callback import Callback
35
40
  from zrb.config.config import CFG
36
41
  from zrb.config.llm_config import llm_config
@@ -40,6 +45,7 @@ from zrb.input.bool_input import BoolInput
40
45
  from zrb.input.str_input import StrInput
41
46
  from zrb.input.text_input import TextInput
42
47
  from zrb.task.base_trigger import BaseTrigger
48
+ from zrb.task.llm.workflow import LLM_LOADED_WORKFLOW_XCOM_NAME
43
49
  from zrb.task.llm_task import LLMTask
44
50
  from zrb.util.string.conversion import to_boolean
45
51
 
@@ -99,6 +105,8 @@ def _get_default_yolo_mode(ctx: AnyContext) -> str:
99
105
 
100
106
 
101
107
  def _render_yolo_mode_input(ctx: AnyContext) -> list[str] | bool:
108
+ if isinstance(ctx.input.yolo, bool):
109
+ return ctx.input.yolo
102
110
  if ctx.input.yolo.strip() == "":
103
111
  return []
104
112
  elements = [element.strip() for element in ctx.input.yolo.split(",")]
@@ -172,9 +180,9 @@ def _get_inputs(require_message: bool = True) -> list[AnyInput | None]:
172
180
  always_prompt=False,
173
181
  ),
174
182
  TextInput(
175
- "workflows",
176
- description="Workflows",
177
- prompt="Workflows",
183
+ "workflow",
184
+ description="Workflows (comma separated)",
185
+ prompt="Workflows (comma separated)",
178
186
  default=lambda ctx: ",".join(llm_config.default_workflows),
179
187
  allow_positional_parsing=False,
180
188
  always_prompt=False,
@@ -237,7 +245,7 @@ llm_ask = LLMTask(
237
245
  None if ctx.input.system_prompt.strip() == "" else ctx.input.system_prompt
238
246
  ),
239
247
  workflows=lambda ctx: (
240
- None if ctx.input.workflows.strip() == "" else ctx.input.workflows.split(",")
248
+ None if ctx.input.workflow.strip() == "" else ctx.input.workflow.split(",")
241
249
  ),
242
250
  attachment=_render_attach_input,
243
251
  message="{ctx.input.message}",
@@ -258,9 +266,10 @@ llm_group.add_task(
258
266
  callback=Callback(
259
267
  task=llm_ask,
260
268
  input_mapping=get_llm_ask_input_mapping,
261
- result_queue="ask_result",
262
- error_queue="ask_error",
263
- session_name_queue="ask_session_name",
269
+ xcom_mapping={LLM_LOADED_WORKFLOW_XCOM_NAME: LLM_LOADED_WORKFLOW_XCOM_NAME},
270
+ result_queue=LLM_ASK_RESULT_XCOM_NAME,
271
+ error_queue=LLM_ASK_ERROR_XCOM_NAME,
272
+ session_name_queue=LLM_ASK_SESSION_XCOM_NAME,
264
273
  ),
265
274
  retries=0,
266
275
  cli_only=True,
@@ -1,5 +1,11 @@
1
- import subprocess
1
+ import asyncio
2
2
  import sys
3
+ from typing import Callable
4
+
5
+ from zrb.config.config import CFG
6
+ from zrb.context.any_context import AnyContext
7
+ from zrb.util.cli.style import stylize_faint
8
+ from zrb.util.cmd.command import run_command
3
9
 
4
10
  if sys.version_info >= (3, 12):
5
11
  from typing import TypedDict
@@ -15,17 +21,25 @@ class ShellCommandResult(TypedDict):
15
21
  return_code: The return code, 0 indicating no error
16
22
  stdout: Standard output
17
23
  stderr: Standard error
24
+ display: Combination of standard output and standard error, interlaced
18
25
  """
19
26
 
20
27
  return_code: int
21
28
  stdout: str
22
29
  stderr: str
30
+ display: str
23
31
 
24
32
 
25
- def run_shell_command(command: str, timeout: int = 30) -> ShellCommandResult:
33
+ async def run_shell_command(
34
+ ctx: AnyContext, command: str, timeout: int = 30
35
+ ) -> ShellCommandResult:
26
36
  """
27
37
  Executes a non-interactive shell command on the user's machine.
28
38
 
39
+ **EFFICIENCY TIP:**
40
+ Combine multiple shell commands into a single call using `&&` or `;` to save steps.
41
+ Example: `mkdir new_dir && cd new_dir && touch file.txt`
42
+
29
43
  CRITICAL: This tool runs with user-level permissions. Explain commands that modify
30
44
  the system (e.g., `git`, `pip`) and ask for confirmation.
31
45
  IMPORTANT: Long-running processes should be run in the background (e.g., `command &`).
@@ -42,23 +56,28 @@ def run_shell_command(command: str, timeout: int = 30) -> ShellCommandResult:
42
56
  dict: return_code, stdout, and stderr.
43
57
  """
44
58
  try:
45
- result = subprocess.run(
46
- command,
47
- shell=True,
48
- capture_output=True,
49
- text=True,
59
+ cmd_result, return_code = await run_command(
60
+ [CFG.DEFAULT_SHELL, "-c", command],
61
+ print_method=_create_faint_print(ctx),
50
62
  timeout=timeout,
51
63
  )
52
64
  return {
53
- "return_code": int(result.returncode),
54
- "stdout": str(result.stdout or ""),
55
- "stderr": str(result.stderr or ""),
65
+ "return_code": return_code,
66
+ "stdout": cmd_result.output,
67
+ "stderr": cmd_result.error,
68
+ "display": cmd_result.display,
56
69
  }
57
- except subprocess.TimeoutExpired as e:
58
- stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "")
59
- stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "")
70
+ except asyncio.TimeoutError:
60
71
  return {
61
72
  "return_code": 124,
62
- "stdout": str(stdout),
63
- "stderr": f"{stderr}\nError: Command timed out after {timeout} seconds".strip(),
73
+ "stdout": "",
74
+ "stderr": f"Command timeout after {timeout} seconds",
75
+ "display": f"Command timeout after {timeout} seconds",
64
76
  }
77
+
78
+
79
+ def _create_faint_print(ctx: AnyContext) -> Callable[..., None]:
80
+ def print_faint(text: str):
81
+ ctx.print(stylize_faint(f" {text}"), plain=True)
82
+
83
+ return print_faint
@@ -89,6 +89,10 @@ def list_files(
89
89
  """
90
90
  Lists files recursively up to a specified depth.
91
91
 
92
+ **EFFICIENCY TIP:**
93
+ Do NOT use this tool if you already know the file path (e.g., from the user's prompt).
94
+ Use `read_from_file` directly in that case. Only use this to explore directory structures.
95
+
92
96
  Example:
93
97
  list_files(path='src', include_hidden=False, depth=2)
94
98
 
@@ -179,6 +183,12 @@ def read_from_file(
179
183
  """
180
184
  Reads content from one or more files, optionally specifying line ranges.
181
185
 
186
+ **EFFICIENCY TIP:**
187
+ For source code or configuration files, prefer reading the **entire file** at once
188
+ to ensure you have full context (imports, class definitions, etc.).
189
+ Only use `start_line` and `end_line` for extremely large files (like logs) or
190
+ when you are certain only a specific section is needed.
191
+
182
192
  Examples:
183
193
  ```
184
194
  # Read entire content of a single file
@@ -264,8 +274,9 @@ def write_to_file(
264
274
  - CORRECT: "content": "He said \"Hello\""
265
275
  - WRONG: "content": "He said \\"Hello\\"" <-- This breaks JSON parsing!
266
276
  2. **SIZE LIMIT:** Content MUST NOT exceed 4000 characters.
267
- - Exceeding this causes truncation and EOF errors.
268
- - Split larger content into multiple sequential calls (first 'w', then 'a').
277
+ - **STRICT PROHIBITION:** You are FORBIDDEN from writing more than 4000 characters in a single call.
278
+ - This is due to LLM output token limits, which will cause truncation and failure.
279
+ - To write larger files, you MUST split the content into multiple sequential calls (e.g., first 'w', then 'a').
269
280
 
270
281
  Examples:
271
282
  ```
@@ -553,6 +564,7 @@ async def analyze_file(
553
564
  tools=[read_from_file, search_files],
554
565
  auto_summarize=False,
555
566
  remember_history=False,
567
+ yolo_mode=True,
556
568
  )
557
569
  payload = json.dumps(
558
570
  {
@@ -1,7 +1,5 @@
1
1
  from typing import Any
2
2
 
3
- import requests
4
-
5
3
  from zrb.config.config import CFG
6
4
 
7
5
 
@@ -17,6 +15,12 @@ def search_internet(
17
15
  Use this tool to find up-to-date information, answer questions about current events,
18
16
  or research topics using a search engine.
19
17
 
18
+ **EFFICIENCY TIP:**
19
+ Make your `query` specific and keyword-rich to get the best results in a single call.
20
+ Avoid vague queries that require follow-up searches.
21
+ Bad: "new python features"
22
+ Good: "python 3.12 new features list release date"
23
+
20
24
  Args:
21
25
  query (str): The natural language search query (e.g., 'Soto Madura').
22
26
  Do NOT include instructions, meta-talk, or internal reasoning.
@@ -30,6 +34,8 @@ def search_internet(
30
34
  Returns:
31
35
  dict: Summary of search results (titles, links, snippets).
32
36
  """
37
+ import requests
38
+
33
39
  if safe_search is None:
34
40
  safe_search = CFG.BRAVE_API_SAFE
35
41
  if language is None:
@@ -1,7 +1,5 @@
1
1
  from typing import Any
2
2
 
3
- import requests
4
-
5
3
  from zrb.config.config import CFG
6
4
 
7
5
 
@@ -17,6 +15,12 @@ def search_internet(
17
15
  Use this tool to find up-to-date information, answer questions about current events,
18
16
  or research topics using a search engine.
19
17
 
18
+ **EFFICIENCY TIP:**
19
+ Make your `query` specific and keyword-rich to get the best results in a single call.
20
+ Avoid vague queries that require follow-up searches.
21
+ Bad: "new python features"
22
+ Good: "python 3.12 new features list release date"
23
+
20
24
  Args:
21
25
  query (str): The natural language search query (e.g., 'Soto Madura').
22
26
  Do NOT include instructions, meta-talk, or internal reasoning.
@@ -30,6 +34,8 @@ def search_internet(
30
34
  Returns:
31
35
  dict: Summary of search results (titles, links, snippets).
32
36
  """
37
+ import requests
38
+
33
39
  if safe_search is None:
34
40
  safe_search = CFG.SEARXNG_SAFE
35
41
  if language is None: