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
zrb/task/base_task.py CHANGED
@@ -3,7 +3,7 @@ import inspect
3
3
  from collections.abc import Callable
4
4
  from typing import Any
5
5
 
6
- from zrb.attr.type import BoolAttr, fstring
6
+ from zrb.attr.type import fstring
7
7
  from zrb.context.any_context import AnyContext
8
8
  from zrb.env.any_env import AnyEnv
9
9
  from zrb.input.any_input import AnyInput
@@ -55,7 +55,7 @@ class BaseTask(AnyTask):
55
55
  input: list[AnyInput | None] | AnyInput | None = None,
56
56
  env: list[AnyEnv | None] | AnyEnv | None = None,
57
57
  action: fstring | Callable[[AnyContext], Any] | None = None,
58
- execute_condition: BoolAttr = True,
58
+ execute_condition: bool | str | Callable[[AnyContext], bool] = True,
59
59
  retries: int = 2,
60
60
  retry_period: float = 0,
61
61
  readiness_check: list[AnyTask] | AnyTask | None = None,
@@ -68,9 +68,18 @@ class BaseTask(AnyTask):
68
68
  fallback: list[AnyTask] | AnyTask | None = None,
69
69
  successor: list[AnyTask] | AnyTask | None = None,
70
70
  ):
71
- caller_frame = inspect.stack()[1]
72
- self.__decl_file = caller_frame.filename
73
- self.__decl_line = caller_frame.lineno
71
+ # Optimized stack retrieval
72
+ frame = inspect.currentframe()
73
+ if frame is not None:
74
+ caller_frame = frame.f_back
75
+ self.__decl_file = (
76
+ caller_frame.f_code.co_filename if caller_frame else "unknown"
77
+ )
78
+ self.__decl_line = caller_frame.f_lineno if caller_frame else 0
79
+ else:
80
+ self.__decl_file = "unknown"
81
+ self.__decl_line = 0
82
+
74
83
  self._name = name
75
84
  self._color = color
76
85
  self._icon = icon
@@ -80,10 +89,10 @@ class BaseTask(AnyTask):
80
89
  self._envs = env
81
90
  self._retries = retries
82
91
  self._retry_period = retry_period
83
- self._upstreams = upstream
84
- self._fallbacks = fallback
85
- self._successors = successor
86
- self._readiness_checks = readiness_check
92
+ self._upstreams = self._ensure_task_list(upstream)
93
+ self._fallbacks = self._ensure_task_list(fallback)
94
+ self._successors = self._ensure_task_list(successor)
95
+ self._readiness_checks = self._ensure_task_list(readiness_check)
87
96
  self._readiness_check_delay = readiness_check_delay
88
97
  self._readiness_check_period = readiness_check_period
89
98
  self._readiness_failure_threshold = readiness_failure_threshold
@@ -92,6 +101,13 @@ class BaseTask(AnyTask):
92
101
  self._execute_condition = execute_condition
93
102
  self._action = action
94
103
 
104
+ def _ensure_task_list(self, tasks: AnyTask | list[AnyTask] | None) -> list[AnyTask]:
105
+ if tasks is None:
106
+ return []
107
+ if isinstance(tasks, list):
108
+ return tasks
109
+ return [tasks]
110
+
95
111
  def __repr__(self):
96
112
  return f"<{self.__class__.__name__} name={self.name}>"
97
113
 
@@ -132,18 +148,10 @@ class BaseTask(AnyTask):
132
148
  @property
133
149
  def fallbacks(self) -> list[AnyTask]:
134
150
  """Returns the list of fallback tasks."""
135
- if self._fallbacks is None:
136
- return []
137
- elif isinstance(self._fallbacks, list):
138
- return self._fallbacks
139
- return [self._fallbacks] # Assume single task
151
+ return self._fallbacks
140
152
 
141
153
  def append_fallback(self, fallbacks: AnyTask | list[AnyTask]):
142
154
  """Appends fallback tasks, ensuring no duplicates."""
143
- if self._fallbacks is None:
144
- self._fallbacks = []
145
- elif not isinstance(self._fallbacks, list):
146
- self._fallbacks = [self._fallbacks]
147
155
  to_add = fallbacks if isinstance(fallbacks, list) else [fallbacks]
148
156
  for fb in to_add:
149
157
  if fb not in self._fallbacks:
@@ -152,18 +160,10 @@ class BaseTask(AnyTask):
152
160
  @property
153
161
  def successors(self) -> list[AnyTask]:
154
162
  """Returns the list of successor tasks."""
155
- if self._successors is None:
156
- return []
157
- elif isinstance(self._successors, list):
158
- return self._successors
159
- return [self._successors] # Assume single task
163
+ return self._successors
160
164
 
161
165
  def append_successor(self, successors: AnyTask | list[AnyTask]):
162
166
  """Appends successor tasks, ensuring no duplicates."""
163
- if self._successors is None:
164
- self._successors = []
165
- elif not isinstance(self._successors, list):
166
- self._successors = [self._successors]
167
167
  to_add = successors if isinstance(successors, list) else [successors]
168
168
  for succ in to_add:
169
169
  if succ not in self._successors:
@@ -172,18 +172,10 @@ class BaseTask(AnyTask):
172
172
  @property
173
173
  def readiness_checks(self) -> list[AnyTask]:
174
174
  """Returns the list of readiness check tasks."""
175
- if self._readiness_checks is None:
176
- return []
177
- elif isinstance(self._readiness_checks, list):
178
- return self._readiness_checks
179
- return [self._readiness_checks] # Assume single task
175
+ return self._readiness_checks
180
176
 
181
177
  def append_readiness_check(self, readiness_checks: AnyTask | list[AnyTask]):
182
178
  """Appends readiness check tasks, ensuring no duplicates."""
183
- if self._readiness_checks is None:
184
- self._readiness_checks = []
185
- elif not isinstance(self._readiness_checks, list):
186
- self._readiness_checks = [self._readiness_checks]
187
179
  to_add = (
188
180
  readiness_checks
189
181
  if isinstance(readiness_checks, list)
@@ -196,18 +188,10 @@ class BaseTask(AnyTask):
196
188
  @property
197
189
  def upstreams(self) -> list[AnyTask]:
198
190
  """Returns the list of upstream tasks."""
199
- if self._upstreams is None:
200
- return []
201
- elif isinstance(self._upstreams, list):
202
- return self._upstreams
203
- return [self._upstreams] # Assume single task
191
+ return self._upstreams
204
192
 
205
193
  def append_upstream(self, upstreams: AnyTask | list[AnyTask]):
206
194
  """Appends upstream tasks, ensuring no duplicates."""
207
- if self._upstreams is None:
208
- self._upstreams = []
209
- elif not isinstance(self._upstreams, list):
210
- self._upstreams = [self._upstreams]
211
195
  to_add = upstreams if isinstance(upstreams, list) else [upstreams]
212
196
  for up in to_add:
213
197
  if up not in self._upstreams:
@@ -277,6 +261,8 @@ class BaseTask(AnyTask):
277
261
  try:
278
262
  # Delegate to the helper function for the default behavior
279
263
  return await run_default_action(self, ctx)
264
+ except (KeyboardInterrupt, GeneratorExit):
265
+ raise
280
266
  except BaseException as e:
281
267
  additional_error_note = (
282
268
  f"Task: {self.name} ({self.__decl_file}:{self.__decl_line})"
zrb/task/base_trigger.py CHANGED
@@ -5,7 +5,6 @@ from typing import Any
5
5
  from zrb.attr.type import fstring
6
6
  from zrb.callback.any_callback import AnyCallback
7
7
  from zrb.context.any_context import AnyContext
8
- from zrb.context.any_shared_context import AnySharedContext
9
8
  from zrb.context.shared_context import SharedContext
10
9
  from zrb.dot_dict.dot_dict import DotDict
11
10
  from zrb.env.any_env import AnyEnv
zrb/task/cmd_task.py CHANGED
@@ -48,6 +48,7 @@ class CmdTask(BaseTask):
48
48
  warn_unrecommended_command: bool | None = None,
49
49
  max_output_line: int = 1000,
50
50
  max_error_line: int = 1000,
51
+ execution_timeout: int = 3600,
51
52
  is_interactive: bool = False,
52
53
  execute_condition: BoolAttr = True,
53
54
  retries: int = 2,
@@ -103,6 +104,7 @@ class CmdTask(BaseTask):
103
104
  self._render_cwd = render_cwd
104
105
  self._max_output_line = max_output_line
105
106
  self._max_error_line = max_error_line
107
+ self._execution_timeout = execution_timeout
106
108
  self._should_plain_print = plain_print
107
109
  self._should_warn_unrecommended_command = warn_unrecommended_command
108
110
  self._is_interactive = is_interactive
@@ -142,6 +144,7 @@ class CmdTask(BaseTask):
142
144
  register_pid_method=lambda pid: ctx.xcom.get(xcom_pid_key).push(pid),
143
145
  max_output_line=self._max_output_line,
144
146
  max_error_line=self._max_error_line,
147
+ timeout=self._execution_timeout,
145
148
  is_interactive=self._is_interactive,
146
149
  )
147
150
  # Check for errors
zrb/task/llm/agent.py CHANGED
@@ -39,39 +39,10 @@ def create_agent_instance(
39
39
  auto_summarize: bool = True,
40
40
  ) -> "Agent[None, Any]":
41
41
  """Creates a new Agent instance with configured tools and servers."""
42
- from pydantic_ai import Agent, RunContext, Tool
42
+ from pydantic_ai import Agent, Tool
43
43
  from pydantic_ai.tools import GenerateToolJsonSchema
44
- from pydantic_ai.toolsets import ToolsetTool, WrapperToolset
45
44
 
46
- @dataclass
47
- class ConfirmationWrapperToolset(WrapperToolset):
48
- ctx: AnyContext
49
- yolo_mode: bool | list[str]
50
-
51
- async def call_tool(
52
- self, name: str, tool_args: dict, ctx: RunContext, tool: ToolsetTool[None]
53
- ) -> Any:
54
- # The `tool` object is passed in. Use it for inspection.
55
- # Define a temporary function that performs the actual tool call.
56
- async def execute_delegated_tool_call(**params):
57
- # Pass all arguments down the chain.
58
- return await self.wrapped.call_tool(name, tool_args, ctx, tool)
59
-
60
- # For the confirmation UI, make our temporary function look like the real one.
61
- try:
62
- execute_delegated_tool_call.__name__ = name
63
- execute_delegated_tool_call.__doc__ = tool.function.__doc__
64
- execute_delegated_tool_call.__signature__ = inspect.signature(
65
- tool.function
66
- )
67
- except (AttributeError, TypeError):
68
- pass # Ignore if we can't inspect the original function
69
- # Use the existing wrap_func to get the confirmation logic
70
- wrapped_executor = wrap_func(
71
- execute_delegated_tool_call, self.ctx, self.yolo_mode
72
- )
73
- # Call the wrapped executor. This will trigger the confirmation prompt.
74
- return await wrapped_executor(**tool_args)
45
+ ConfirmationWrapperToolset = _get_confirmation_wrapper_toolset_class()
75
46
 
76
47
  if yolo_mode is None:
77
48
  yolo_mode = False
@@ -132,6 +103,43 @@ def create_agent_instance(
132
103
  )
133
104
 
134
105
 
106
+ def _get_confirmation_wrapper_toolset_class():
107
+ from pydantic_ai import RunContext
108
+ from pydantic_ai.toolsets import ToolsetTool, WrapperToolset
109
+
110
+ @dataclass
111
+ class ConfirmationWrapperToolset(WrapperToolset):
112
+ ctx: AnyContext
113
+ yolo_mode: bool | list[str]
114
+
115
+ async def call_tool(
116
+ self, name: str, tool_args: dict, ctx: RunContext, tool: ToolsetTool[None]
117
+ ) -> Any:
118
+ # The `tool` object is passed in. Use it for inspection.
119
+ # Define a temporary function that performs the actual tool call.
120
+ async def execute_delegated_tool_call(**params):
121
+ # Pass all arguments down the chain.
122
+ return await self.wrapped.call_tool(name, tool_args, ctx, tool)
123
+
124
+ # For the confirmation UI, make our temporary function look like the real one.
125
+ try:
126
+ execute_delegated_tool_call.__name__ = name
127
+ execute_delegated_tool_call.__doc__ = tool.function.__doc__
128
+ execute_delegated_tool_call.__signature__ = inspect.signature(
129
+ tool.function
130
+ )
131
+ except (AttributeError, TypeError):
132
+ pass # Ignore if we can't inspect the original function
133
+ # Use the existing wrap_func to get the confirmation logic
134
+ wrapped_executor = wrap_func(
135
+ execute_delegated_tool_call, self.ctx, self.yolo_mode
136
+ )
137
+ # Call the wrapped executor. This will trigger the confirmation prompt.
138
+ return await wrapped_executor(**tool_args)
139
+
140
+ return ConfirmationWrapperToolset
141
+
142
+
135
143
  def get_agent(
136
144
  ctx: AnyContext,
137
145
  model: "str | Model",
@@ -1,4 +1,9 @@
1
+ import asyncio
1
2
  import json
3
+ import os
4
+ import sys
5
+ import termios
6
+ import tty
2
7
  from collections.abc import Callable
3
8
  from typing import TYPE_CHECKING, Any
4
9
 
@@ -93,11 +98,17 @@ async def _run_single_agent_iteration(
93
98
  message_history=ModelMessagesTypeAdapter.validate_python(history_list),
94
99
  usage_limits=UsageLimits(request_limit=None), # We don't want limit
95
100
  ) as agent_run:
101
+ escape_task = asyncio.create_task(_wait_for_escape(ctx))
96
102
  async for node in agent_run:
97
103
  # Each node represents a step in the agent's execution
98
104
  try:
99
105
  await print_node(
100
- _get_plain_printer(ctx), agent_run, node, log_indent_level
106
+ _get_plain_printer(ctx),
107
+ agent_run,
108
+ node,
109
+ ctx.is_tty,
110
+ log_indent_level,
111
+ lambda: escape_task.done(),
101
112
  )
102
113
  except APIError as e:
103
114
  # Extract detailed error information from the response
@@ -108,12 +119,63 @@ async def _run_single_agent_iteration(
108
119
  ctx.log_error(f"Error processing node: {str(e)}")
109
120
  ctx.log_error(f"Error type: {type(e).__name__}")
110
121
  raise
122
+ if escape_task.done():
123
+ break
124
+ # Clean escape_task
125
+ if not escape_task.done():
126
+ try:
127
+ escape_task.cancel()
128
+ await escape_task
129
+ except asyncio.CancelledError:
130
+ pass
111
131
  return agent_run
112
132
 
113
133
 
134
+ async def _wait_for_escape(ctx: AnyContext) -> None:
135
+ if not ctx.is_tty:
136
+ # Wait forever
137
+ await asyncio.Future()
138
+ return
139
+ loop = asyncio.get_event_loop()
140
+ future = loop.create_future()
141
+ fd = sys.stdin.fileno()
142
+ old_settings = termios.tcgetattr(fd)
143
+ try:
144
+ tty.setcbreak(fd)
145
+ loop.add_reader(fd, _create_escape_detector(ctx, future, fd))
146
+ await future
147
+ except asyncio.CancelledError:
148
+ raise
149
+ finally:
150
+ loop.remove_reader(fd)
151
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
152
+
153
+
154
+ def _create_escape_detector(
155
+ ctx: AnyContext, future: asyncio.Future[Any], fd: int | Any
156
+ ) -> Callable[[], None]:
157
+ def on_stdin():
158
+ try:
159
+ # Read just one byte
160
+ ch = os.read(fd, 1)
161
+ if ch == b"\x1b":
162
+ ctx.print("\n🚫 Interrupted by user.", plain=True)
163
+ if not future.done():
164
+ future.set_result(None)
165
+ except Exception as e:
166
+ if not future.done():
167
+ future.set_exception(e)
168
+
169
+ return on_stdin
170
+
171
+
114
172
  def _create_print_throttle_notif(ctx: AnyContext) -> Callable[[str], None]:
115
- def _print_throttle_notif(reason: str):
116
- ctx.print(stylize_faint(f" ⌛>> Request Throttled: {reason}"), plain=True)
173
+ def _print_throttle_notif(text: str, *args: Any, **kwargs: Any):
174
+ new_line = kwargs.get("new_line", True)
175
+ prefix = "\r" if not new_line else "\n"
176
+ if text != "":
177
+ prefix = f"{prefix} 🐢 Request Throttled: "
178
+ ctx.print(stylize_faint(f"{prefix}{text}"), plain=True, end="")
117
179
 
118
180
  return _print_throttle_notif
119
181
 
@@ -9,6 +9,7 @@ Follow this workflow to deliver accurate, well-sourced, and synthesized research
9
9
  - **Source Hierarchy:** Prioritize authoritative sources over secondary ones
10
10
  - **Synthesis Excellence:** Connect information into coherent narratives
11
11
  - **Comprehensive Attribution:** Cite all significant claims and data points
12
+ - **Mandatory Citations:** ALWAYS include a "Sources" section at the end of the response with a list of all URLs used.
12
13
 
13
14
  # Tool Usage Guideline
14
15
  - Use `search_internet` for web research and information gathering
@@ -114,6 +115,7 @@ Follow this workflow to deliver accurate, well-sourced, and synthesized research
114
115
  - **Detailed Analysis:** Provide comprehensive information for deep dives
115
116
  - **Actionable Insights:** Highlight implications and recommended actions
116
117
  - **Further Reading:** Suggest additional resources for interested readers
118
+ - **Sources:** ALWAYS end the response with a "Sources" section listing all URLs used.
117
119
 
118
120
  # Risk Assessment Guidelines
119
121
 
@@ -0,0 +1,13 @@
1
+ from typing import Any
2
+
3
+
4
+ def remove_system_prompt_and_instruction(
5
+ history_list: list[dict[str, Any]],
6
+ ) -> list[dict[str, Any]]:
7
+ for msg in history_list:
8
+ if msg.get("instructions"):
9
+ msg["instructions"] = ""
10
+ msg["parts"] = [
11
+ p for p in msg.get("parts", []) if p.get("part_kind") != "system-prompt"
12
+ ]
13
+ return history_list
@@ -88,30 +88,21 @@ def create_summarize_history_processor(
88
88
  messages: list[ModelMessage],
89
89
  ) -> list[ModelMessage]:
90
90
  history_list = json.loads(ModelMessagesTypeAdapter.dump_json(messages))
91
- history_json_str = json.dumps(history_list)
91
+ history_list_str = json.dumps(history_list)
92
92
  # Estimate token usage
93
93
  # Note: Pydantic ai has run context parameter
94
94
  # (https://ai.pydantic.dev/message-history/#runcontext-parameter)
95
95
  # But we cannot use run_ctx.usage.total_tokens because total token keep increasing
96
96
  # even after summariztion.
97
- estimated_token_usage = rate_limitter.count_token(history_json_str)
97
+ estimated_token_usage = rate_limitter.count_token(history_list_str)
98
98
  _print_request_info(
99
99
  ctx, estimated_token_usage, summarization_token_threshold, messages
100
100
  )
101
101
  if estimated_token_usage < summarization_token_threshold or len(messages) == 1:
102
102
  return messages
103
- history_list_without_instruction = [
104
- {
105
- key: obj[key]
106
- for key in obj
107
- if index == len(history_list) - 1 or key != "instructions"
108
- }
109
- for index, obj in enumerate(history_list)
110
- ]
111
- history_json_str_without_instruction = json.dumps(
112
- history_list_without_instruction
103
+ summarization_message = (
104
+ f"Summarize the following conversation: {history_list_str}"
113
105
  )
114
- summarization_message = f"Summarize the following conversation: {history_json_str_without_instruction}"
115
106
  summarization_agent = Agent[None, ConversationSummary](
116
107
  model=summarization_model,
117
108
  output_type=save_conversation_summary,
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import json
2
3
  from collections.abc import Callable
3
4
  from typing import Any
@@ -7,7 +8,12 @@ from zrb.util.cli.style import stylize_faint
7
8
 
8
9
 
9
10
  async def print_node(
10
- print_func: Callable, agent_run: Any, node: Any, log_indent_level: int = 0
11
+ print_func: Callable,
12
+ agent_run: Any,
13
+ node: Any,
14
+ is_tty: bool,
15
+ log_indent_level: int = 0,
16
+ stop_check: Callable[[], bool] | None = None,
11
17
  ):
12
18
  """Prints the details of an agent execution node using a provided print function."""
13
19
  from pydantic_ai import Agent
@@ -24,45 +30,83 @@ async def print_node(
24
30
  )
25
31
 
26
32
  meta = getattr(node, "id", None) or getattr(node, "request_id", None)
33
+ progress_char_list = ["|", "/", "-", "\\"]
34
+ progress_index = 0
27
35
  if Agent.is_user_prompt_node(node):
28
36
  print_func(_format_header("🔠 Receiving input...", log_indent_level))
29
- elif Agent.is_model_request_node(node):
37
+ return
38
+ if Agent.is_model_request_node(node):
30
39
  # A model request node => We can stream tokens from the model's request
31
- print_func(_format_header("🧠 Processing...", log_indent_level))
40
+ esc_notif = " (Press esc to cancel)" if is_tty else ""
41
+ print_func(_format_header(f"🧠 Processing{esc_notif}...", log_indent_level))
32
42
  # Reference: https://ai.pydantic.dev/agents/#streaming-all-events-and-output
33
43
  try:
34
44
  async with node.stream(agent_run.ctx) as request_stream:
35
45
  is_streaming = False
46
+ is_tool_processing = False
36
47
  async for event in request_stream:
48
+ if stop_check and stop_check():
49
+ return
50
+ await asyncio.sleep(0)
37
51
  if isinstance(event, PartStartEvent) and event.part:
38
52
  if is_streaming:
39
53
  print_func("")
40
54
  content = _get_event_part_content(event)
41
55
  print_func(_format_content(content, log_indent_level), end="")
42
56
  is_streaming = True
43
- elif isinstance(event, PartDeltaEvent):
57
+ is_tool_processing = False
58
+ continue
59
+ if isinstance(event, PartDeltaEvent):
44
60
  if isinstance(event.delta, TextPartDelta):
45
61
  content_delta = event.delta.content_delta
46
62
  print_func(
47
63
  _format_stream_content(content_delta, log_indent_level),
48
64
  end="",
49
65
  )
50
- elif isinstance(event.delta, ThinkingPartDelta):
66
+ is_tool_processing = False
67
+ is_streaming = True
68
+ continue
69
+ if isinstance(event.delta, ThinkingPartDelta):
51
70
  content_delta = event.delta.content_delta
52
71
  print_func(
53
72
  _format_stream_content(content_delta, log_indent_level),
54
73
  end="",
55
74
  )
56
- elif isinstance(event.delta, ToolCallPartDelta):
57
- args_delta = event.delta.args_delta
58
- if isinstance(args_delta, dict):
59
- args_delta = json.dumps(args_delta)
75
+ is_tool_processing = False
76
+ is_streaming = True
77
+ continue
78
+ if isinstance(event.delta, ToolCallPartDelta):
79
+ if CFG.LLM_SHOW_TOOL_CALL_PREPARATION:
80
+ args_delta = event.delta.args_delta
81
+ if isinstance(args_delta, dict):
82
+ args_delta = json.dumps(args_delta)
83
+ print_func(
84
+ _format_stream_content(
85
+ args_delta, log_indent_level
86
+ ),
87
+ end="",
88
+ )
89
+ is_streaming = True
90
+ is_tool_processing = True
91
+ continue
92
+ prefix = "\n" if not is_tool_processing else ""
93
+ progress_char = progress_char_list[progress_index]
60
94
  print_func(
61
- _format_stream_content(args_delta, log_indent_level),
95
+ _format_content(
96
+ f"Preparing Tool Parameters... {progress_char}",
97
+ log_indent_level,
98
+ prefix=f"\r{prefix}",
99
+ ),
62
100
  end="",
63
101
  )
102
+ progress_index += 1
103
+ if progress_index >= len(progress_char_list):
104
+ progress_index = 0
105
+ is_tool_processing = True
106
+ is_streaming = True
107
+ continue
64
108
  is_streaming = True
65
- elif isinstance(event, FinalResultEvent) and event.tool_name:
109
+ if isinstance(event, FinalResultEvent) and event.tool_name:
66
110
  if is_streaming:
67
111
  print_func("")
68
112
  tool_name = event.tool_name
@@ -72,6 +116,7 @@ async def print_node(
72
116
  )
73
117
  )
74
118
  is_streaming = False
119
+ is_tool_processing = False
75
120
  if is_streaming:
76
121
  print_func("")
77
122
  except UnexpectedModelBehavior as e:
@@ -85,12 +130,16 @@ async def print_node(
85
130
  log_indent_level,
86
131
  )
87
132
  )
88
- elif Agent.is_call_tools_node(node):
133
+ return
134
+ if Agent.is_call_tools_node(node):
89
135
  # A handle-response node => The model returned some data, potentially calls a tool
90
136
  print_func(_format_header("🧰 Calling Tool...", log_indent_level))
91
137
  try:
92
138
  async with node.stream(agent_run.ctx) as handle_stream:
93
139
  async for event in handle_stream:
140
+ if stop_check and stop_check():
141
+ return
142
+ await asyncio.sleep(0)
94
143
  if isinstance(event, FunctionToolCallEvent):
95
144
  args = _get_event_part_args(event)
96
145
  call_id = event.part.tool_call_id
@@ -100,7 +149,8 @@ async def print_node(
100
149
  f"{call_id} | Call {tool_name} {args}", log_indent_level
101
150
  )
102
151
  )
103
- elif (
152
+ continue
153
+ if (
104
154
  isinstance(event, FunctionToolResultEvent)
105
155
  and event.tool_call_id
106
156
  ):
@@ -113,12 +163,10 @@ async def print_node(
113
163
  log_indent_level,
114
164
  )
115
165
  )
116
- else:
117
- print_func(
118
- _format_content(
119
- f"{call_id} | Executed", log_indent_level
120
- )
121
- )
166
+ continue
167
+ print_func(
168
+ _format_content(f"{call_id} | Executed", log_indent_level)
169
+ )
122
170
  except UnexpectedModelBehavior as e:
123
171
  print_func("") # ensure newline consistency
124
172
  print_func(
@@ -130,9 +178,11 @@ async def print_node(
130
178
  log_indent_level,
131
179
  )
132
180
  )
133
- elif Agent.is_end_node(node):
181
+ return
182
+ if Agent.is_end_node(node):
134
183
  # Once an End node is reached, the agent run is complete
135
184
  print_func(_format_header("✅ Completed...", log_indent_level))
185
+ return
136
186
 
137
187
 
138
188
  def _format_header(text: str | None, log_indent_level: int = 0) -> str:
@@ -145,8 +195,10 @@ def _format_header(text: str | None, log_indent_level: int = 0) -> str:
145
195
  )
146
196
 
147
197
 
148
- def _format_content(text: str | None, log_indent_level: int = 0) -> str:
149
- return _format(
198
+ def _format_content(
199
+ text: str | None, log_indent_level: int = 0, prefix: str = ""
200
+ ) -> str:
201
+ return prefix + _format(
150
202
  text,
151
203
  base_indent=2,
152
204
  first_indent=3,
@@ -155,8 +207,10 @@ def _format_content(text: str | None, log_indent_level: int = 0) -> str:
155
207
  )
156
208
 
157
209
 
158
- def _format_stream_content(text: str | None, log_indent_level: int = 0) -> str:
159
- return _format(
210
+ def _format_stream_content(
211
+ text: str | None, log_indent_level: int = 0, prefix: str = ""
212
+ ) -> str:
213
+ return prefix + _format(
160
214
  text,
161
215
  base_indent=2,
162
216
  indent=3,
@@ -207,7 +261,7 @@ def _truncate_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
207
261
  return {key: _truncate_arg(val) for key, val in kwargs.items()}
208
262
 
209
263
 
210
- def _truncate_arg(arg: str, length: int = 19) -> str:
264
+ def _truncate_arg(arg: str, length: int = 30) -> str:
211
265
  if isinstance(arg, str) and len(arg) > length:
212
266
  return f"{arg[:length-4]} ..."
213
267
  return arg