zrb 1.10.1__py3-none-any.whl → 1.11.0__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.
@@ -0,0 +1,238 @@
1
+ import os
2
+ import re
3
+ from typing import Callable, Generator, NamedTuple
4
+
5
+
6
+ class Section(NamedTuple):
7
+ name: str
8
+ key: str
9
+ content: str
10
+ config_file: str
11
+
12
+
13
+ def _parse_config_file(
14
+ config_file: str, lines: list[str]
15
+ ) -> Generator[Section, None, None]:
16
+ """
17
+ Parses a config file's lines, yielding sections.
18
+ It correctly handles markdown code fences.
19
+ """
20
+ any_header_pattern = re.compile(r"^# (\w+):\s*(.*)")
21
+ fence_pattern = re.compile(r"^([`~]{3,})")
22
+ fence_stack = []
23
+ active_section_name = None
24
+ active_section_key = None
25
+ active_section_content = []
26
+
27
+ for line in lines:
28
+ stripped_line = line.strip()
29
+ fence_match = fence_pattern.match(stripped_line)
30
+
31
+ if fence_match:
32
+ current_fence = fence_match.group(1)
33
+ if (
34
+ fence_stack
35
+ and fence_stack[-1][0] == current_fence[0]
36
+ and len(current_fence) >= len(fence_stack[-1])
37
+ ):
38
+ fence_stack.pop()
39
+ else:
40
+ fence_stack.append(current_fence)
41
+
42
+ if fence_stack:
43
+ if active_section_key is not None:
44
+ active_section_content.append(line)
45
+ continue
46
+
47
+ match = any_header_pattern.match(line)
48
+ if match:
49
+ if active_section_key is not None:
50
+ content = "".join(active_section_content).strip()
51
+ if content:
52
+ yield Section(
53
+ name=active_section_name,
54
+ key=active_section_key,
55
+ content=content,
56
+ config_file=config_file,
57
+ )
58
+
59
+ active_section_name = match.group(1)
60
+ active_section_key = match.group(2).strip()
61
+ active_section_content = []
62
+ elif active_section_key is not None:
63
+ active_section_content.append(line)
64
+
65
+ if active_section_key is not None:
66
+ content = "".join(active_section_content).strip()
67
+ if content:
68
+ yield Section(
69
+ name=active_section_name,
70
+ key=active_section_key,
71
+ content=content,
72
+ config_file=config_file,
73
+ )
74
+
75
+
76
+ def _get_config_file_hierarchy(path: str, config_file_name: str) -> list[str]:
77
+ """Finds all config files from a given path up to the home directory."""
78
+ config_files = []
79
+ home_dir = os.path.expanduser("~")
80
+ current_path = os.path.abspath(path)
81
+ while True:
82
+ config_path = os.path.join(current_path, config_file_name)
83
+ if os.path.exists(config_path):
84
+ config_files.append(config_path)
85
+ if current_path == home_dir:
86
+ break
87
+ parent = os.path.dirname(current_path)
88
+ if parent == current_path: # Reached root
89
+ break
90
+ current_path = parent
91
+ return config_files
92
+
93
+
94
+ class LLMContextConfigHandler:
95
+ """Handles the logic for a specific section of the config."""
96
+
97
+ def __init__(
98
+ self,
99
+ section_name: str,
100
+ config_file_name: str = "ZRB.md",
101
+ filter_section_func: Callable[[str, str], bool] | None = None,
102
+ resolve_section_path: bool = True,
103
+ ):
104
+ self._section_name = section_name
105
+ self._config_file_name = config_file_name
106
+ self._filter_func = filter_section_func
107
+ self._resolve_section_path = resolve_section_path
108
+
109
+ def _include_section(self, section_path: str, base_path: str) -> bool:
110
+ if self._filter_func:
111
+ return self._filter_func(section_path, base_path)
112
+ return True
113
+
114
+ def get_section(self, cwd: str) -> dict[str, str]:
115
+ """Gathers all relevant sections for a given path."""
116
+ abs_path = os.path.abspath(cwd)
117
+ all_sections = {}
118
+ config_files = _get_config_file_hierarchy(abs_path, self._config_file_name)
119
+
120
+ for config_file in reversed(config_files):
121
+ if not os.path.exists(config_file):
122
+ continue
123
+ with open(config_file, "r") as f:
124
+ lines = f.readlines()
125
+
126
+ for section in _parse_config_file(config_file, lines):
127
+ if section.name != self._section_name:
128
+ continue
129
+
130
+ config_dir = os.path.dirname(section.config_file)
131
+ key = (
132
+ os.path.abspath(os.path.join(config_dir, section.key))
133
+ if self._resolve_section_path
134
+ else section.key
135
+ )
136
+
137
+ if self._include_section(key, abs_path):
138
+ if key in all_sections:
139
+ all_sections[key] = f"{all_sections[key]}\n{section.content}"
140
+ else:
141
+ all_sections[key] = section.content
142
+
143
+ return all_sections
144
+
145
+ def add_to_section(self, content: str, key: str, cwd: str):
146
+ """Adds content to a section block in the nearest configuration file."""
147
+ abs_search_path = os.path.abspath(cwd)
148
+ config_files = _get_config_file_hierarchy(
149
+ abs_search_path, self._config_file_name
150
+ )
151
+ closest_config_file = (
152
+ config_files[0]
153
+ if config_files
154
+ else os.path.join(os.path.expanduser("~"), self._config_file_name)
155
+ )
156
+
157
+ config_dir = os.path.dirname(closest_config_file)
158
+ header_key = key
159
+ if self._resolve_section_path and os.path.isabs(key):
160
+ if key == config_dir:
161
+ header_key = "."
162
+ elif key.startswith(config_dir):
163
+ header_key = f"./{os.path.relpath(key, config_dir)}"
164
+ header = f"# {self._section_name}: {header_key}"
165
+ new_content = content.strip()
166
+ lines = []
167
+ if os.path.exists(closest_config_file):
168
+ with open(closest_config_file, "r") as f:
169
+ lines = f.readlines()
170
+ header_index = next(
171
+ (i for i, line in enumerate(lines) if line.strip() == header), -1
172
+ )
173
+ if header_index != -1:
174
+ insert_index = len(lines)
175
+ for i in range(header_index + 1, len(lines)):
176
+ if re.match(r"^# \w+:", lines[i].strip()):
177
+ insert_index = i
178
+ break
179
+ if insert_index > 0 and lines[insert_index - 1].strip():
180
+ lines.insert(insert_index, f"\n{new_content}\n")
181
+ else:
182
+ lines.insert(insert_index, f"{new_content}\n")
183
+ else:
184
+ if lines and lines[-1].strip():
185
+ lines.append("\n\n")
186
+ lines.append(f"{header}\n")
187
+ lines.append(f"{new_content}\n")
188
+ with open(closest_config_file, "w") as f:
189
+ f.writelines(lines)
190
+
191
+ def remove_from_section(self, content: str, key: str, cwd: str) -> bool:
192
+ """Removes content from a section block in all relevant config files."""
193
+ abs_search_path = os.path.abspath(cwd)
194
+ config_files = _get_config_file_hierarchy(
195
+ abs_search_path, self._config_file_name
196
+ )
197
+ content_to_remove = content.strip()
198
+ was_removed = False
199
+ for config_file_path in config_files:
200
+ if not os.path.exists(config_file_path):
201
+ continue
202
+ with open(config_file_path, "r") as f:
203
+ file_content = f.read()
204
+ config_dir = os.path.dirname(config_file_path)
205
+ header_key = key
206
+ if self._resolve_section_path and os.path.isabs(key):
207
+ if key == config_dir:
208
+ header_key = "."
209
+ elif key.startswith(config_dir):
210
+ header_key = f"./{os.path.relpath(key, config_dir)}"
211
+ header = f"# {self._section_name}: {header_key}"
212
+ # Use regex to find the section content
213
+ section_pattern = re.compile(
214
+ rf"^{re.escape(header)}\n(.*?)(?=\n# \w+:|\Z)",
215
+ re.DOTALL | re.MULTILINE,
216
+ )
217
+ match = section_pattern.search(file_content)
218
+ if not match:
219
+ continue
220
+
221
+ section_content = match.group(1)
222
+ # Remove the target content and handle surrounding newlines
223
+ new_section_content = section_content.replace(content_to_remove, "")
224
+ new_section_content = "\n".join(
225
+ line for line in new_section_content.splitlines() if line.strip()
226
+ )
227
+
228
+ if new_section_content != section_content.strip():
229
+ was_removed = True
230
+ # Reconstruct the file content
231
+ start = match.start(1)
232
+ end = match.end(1)
233
+ new_file_content = (
234
+ file_content[:start] + new_section_content + file_content[end:]
235
+ )
236
+ with open(config_file_path, "w") as f:
237
+ f.write(new_file_content)
238
+ return was_removed
@@ -18,6 +18,16 @@ class AnySharedContext(ABC):
18
18
  rendering templates with additional data.
19
19
  """
20
20
 
21
+ @property
22
+ @abstractmethod
23
+ def is_web_mode(self) -> bool:
24
+ pass
25
+
26
+ @property
27
+ @abstractmethod
28
+ def is_tty(self) -> bool:
29
+ pass
30
+
21
31
  @property
22
32
  def input(self) -> DotDict:
23
33
  pass
zrb/context/context.py CHANGED
@@ -33,6 +33,14 @@ class Context(AnyContext):
33
33
  class_name = self.__class__.__name__
34
34
  return f"<{class_name} shared_ctx={self._shared_ctx}>"
35
35
 
36
+ @property
37
+ def is_web_mode(self) -> bool:
38
+ return self._shared_ctx.is_web_mode
39
+
40
+ @property
41
+ def is_tty(self) -> bool:
42
+ return self._shared_ctx.is_tty
43
+
36
44
  @property
37
45
  def input(self) -> DotDict:
38
46
  return self._shared_ctx.input
@@ -1,4 +1,5 @@
1
1
  import datetime
2
+ import sys
2
3
  from typing import Any
3
4
 
4
5
  from zrb.config.config import CFG
@@ -43,6 +44,14 @@ class SharedContext(AnySharedContext):
43
44
  xcom = self._xcom
44
45
  return f"<{class_name} input={input} args={args} xcom={xcom} env={env}>"
45
46
 
47
+ @property
48
+ def is_web_mode(self) -> bool:
49
+ return self.env.get("_ZRB_IS_WEB_MODE", "0") == "1"
50
+
51
+ @property
52
+ def is_tty(self) -> bool:
53
+ return sys.stdin.isatty()
54
+
46
55
  @property
47
56
  def input(self) -> DotDict:
48
57
  return self._input
@@ -58,7 +58,7 @@ def serve_task_session_api(
58
58
  session_name = residual_args[0] if residual_args else None
59
59
  if not session_name:
60
60
  shared_ctx = SharedContext(
61
- env={**dict(os.environ), "_ZRB_WEB_ENV": "1"}
61
+ env={**dict(os.environ), "_ZRB_IS_WEB_MODE": "1"}
62
62
  )
63
63
  session = Session(shared_ctx=shared_ctx, root_group=root_group)
64
64
  coro = asyncio.create_task(task.async_run(session, str_kwargs=inputs))
zrb/task/llm/agent.py CHANGED
@@ -47,7 +47,7 @@ def create_agent_instance(
47
47
  model=model,
48
48
  system_prompt=system_prompt,
49
49
  tools=tool_list,
50
- mcp_servers=mcp_servers,
50
+ toolsets=mcp_servers,
51
51
  model_settings=model_settings,
52
52
  retries=retries,
53
53
  )
@@ -160,7 +160,7 @@ async def _run_single_agent_iteration(
160
160
  else:
161
161
  await llm_rate_limitter.throttle(agent_payload)
162
162
 
163
- async with agent.run_mcp_servers():
163
+ async with agent:
164
164
  async with agent.iter(
165
165
  user_prompt=user_prompt,
166
166
  message_history=ModelMessagesTypeAdapter.validate_python(history_list),