janito 2.21.0__py3-none-any.whl → 2.24.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.
Files changed (55) hide show
  1. janito/agent/setup_agent.py +48 -4
  2. janito/agent/templates/profiles/system_prompt_template_Developer_with_Python_Tools.txt.j2 +59 -11
  3. janito/agent/templates/profiles/system_prompt_template_developer.txt.j2 +53 -7
  4. janito/agent/templates/profiles/system_prompt_template_market_analyst.txt.j2 +110 -0
  5. janito/agent/templates/profiles/system_prompt_template_model_conversation_without_tools_or_context.txt.j2 +53 -1
  6. janito/cli/chat_mode/session.py +8 -1
  7. janito/cli/chat_mode/session_profile_select.py +20 -3
  8. janito/cli/chat_mode/shell/commands/__init__.py +2 -0
  9. janito/cli/chat_mode/shell/commands/security/__init__.py +1 -0
  10. janito/cli/chat_mode/shell/commands/security/allowed_sites.py +94 -0
  11. janito/cli/chat_mode/shell/commands/security_command.py +51 -0
  12. janito/cli/cli_commands/list_plugins.py +45 -0
  13. janito/cli/cli_commands/list_profiles.py +29 -1
  14. janito/cli/cli_commands/show_system_prompt.py +24 -10
  15. janito/cli/core/getters.py +4 -0
  16. janito/cli/core/runner.py +7 -2
  17. janito/cli/core/setters.py +10 -1
  18. janito/cli/main_cli.py +25 -3
  19. janito/cli/single_shot_mode/handler.py +3 -1
  20. janito/config_manager.py +10 -0
  21. janito/plugins/__init__.py +17 -0
  22. janito/plugins/base.py +93 -0
  23. janito/plugins/discovery.py +160 -0
  24. janito/plugins/manager.py +185 -0
  25. janito/providers/ibm/model_info.py +9 -0
  26. janito/tools/adapters/local/__init__.py +2 -0
  27. janito/tools/adapters/local/adapter.py +55 -0
  28. janito/tools/adapters/local/ask_user.py +2 -0
  29. janito/tools/adapters/local/fetch_url.py +184 -11
  30. janito/tools/adapters/local/find_files.py +2 -0
  31. janito/tools/adapters/local/get_file_outline/core.py +2 -0
  32. janito/tools/adapters/local/get_file_outline/search_outline.py +2 -0
  33. janito/tools/adapters/local/open_html_in_browser.py +2 -0
  34. janito/tools/adapters/local/open_url.py +2 -0
  35. janito/tools/adapters/local/python_code_run.py +15 -10
  36. janito/tools/adapters/local/python_command_run.py +14 -9
  37. janito/tools/adapters/local/python_file_run.py +15 -10
  38. janito/tools/adapters/local/read_chart.py +252 -0
  39. janito/tools/adapters/local/read_files.py +2 -0
  40. janito/tools/adapters/local/replace_text_in_file.py +1 -1
  41. janito/tools/adapters/local/run_bash_command.py +18 -12
  42. janito/tools/adapters/local/run_powershell_command.py +15 -9
  43. janito/tools/adapters/local/search_text/core.py +2 -0
  44. janito/tools/adapters/local/validate_file_syntax/core.py +6 -0
  45. janito/tools/adapters/local/validate_file_syntax/jinja2_validator.py +47 -0
  46. janito/tools/adapters/local/view_file.py +2 -0
  47. janito/tools/loop_protection.py +115 -0
  48. janito/tools/loop_protection_decorator.py +110 -0
  49. janito/tools/url_whitelist.py +121 -0
  50. {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/METADATA +1 -1
  51. {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/RECORD +55 -41
  52. {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/WHEEL +0 -0
  53. {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/entry_points.txt +0 -0
  54. {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/licenses/LICENSE +0 -0
  55. {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,185 @@
1
+ """
2
+ Plugin manager for loading and managing plugins.
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ import importlib
8
+ import importlib.util
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional, Any
11
+ import logging
12
+
13
+ from .base import Plugin, PluginMetadata
14
+ from .discovery import discover_plugins
15
+ from janito.tools.adapters.local import LocalToolsAdapter
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class PluginManager:
21
+ """
22
+ Manages plugin loading, registration, and lifecycle.
23
+ """
24
+
25
+ def __init__(self, tools_adapter: Optional[LocalToolsAdapter] = None):
26
+ self.tools_adapter = tools_adapter or LocalToolsAdapter()
27
+ self.plugins: Dict[str, Plugin] = {}
28
+ self.plugin_configs: Dict[str, Dict[str, Any]] = {}
29
+ self.plugin_paths: List[Path] = []
30
+
31
+ def add_plugin_path(self, path: str) -> None:
32
+ """Add a directory to search for plugins."""
33
+ plugin_path = Path(path)
34
+ if plugin_path.exists() and plugin_path.is_dir():
35
+ self.plugin_paths.append(plugin_path)
36
+ if str(plugin_path) not in sys.path:
37
+ sys.path.insert(0, str(plugin_path))
38
+
39
+ def load_plugin(self, plugin_name: str, config: Optional[Dict[str, Any]] = None) -> bool:
40
+ """
41
+ Load a plugin by name.
42
+
43
+ Args:
44
+ plugin_name: Name of the plugin to load
45
+ config: Optional configuration for the plugin
46
+
47
+ Returns:
48
+ True if plugin loaded successfully
49
+ """
50
+ try:
51
+ if plugin_name in self.plugins:
52
+ logger.warning(f"Plugin {plugin_name} already loaded")
53
+ return True
54
+
55
+ plugin = discover_plugins(plugin_name, self.plugin_paths)
56
+ if not plugin:
57
+ logger.error(f"Plugin {plugin_name} not found")
58
+ return False
59
+
60
+ # Store config
61
+ if config:
62
+ self.plugin_configs[plugin_name] = config
63
+
64
+ # Validate config if provided
65
+ if config and hasattr(plugin, 'validate_config'):
66
+ if not plugin.validate_config(config):
67
+ logger.error(f"Invalid configuration for plugin {plugin_name}")
68
+ return False
69
+
70
+ # Initialize plugin
71
+ plugin.initialize()
72
+
73
+ # Register tools
74
+ tools = plugin.get_tools()
75
+ for tool_class in tools:
76
+ self.tools_adapter.register_tool(tool_class)
77
+
78
+ # Store plugin
79
+ self.plugins[plugin_name] = plugin
80
+
81
+ logger.info(f"Successfully loaded plugin: {plugin_name}")
82
+ return True
83
+
84
+ except Exception as e:
85
+ logger.error(f"Failed to load plugin {plugin_name}: {e}")
86
+ return False
87
+
88
+ def unload_plugin(self, plugin_name: str) -> bool:
89
+ """
90
+ Unload a plugin.
91
+
92
+ Args:
93
+ plugin_name: Name of the plugin to unload
94
+
95
+ Returns:
96
+ True if plugin unloaded successfully
97
+ """
98
+ try:
99
+ if plugin_name not in self.plugins:
100
+ logger.warning(f"Plugin {plugin_name} not loaded")
101
+ return False
102
+
103
+ plugin = self.plugins[plugin_name]
104
+
105
+ # Unregister tools
106
+ tools = plugin.get_tools()
107
+ for tool_class in tools:
108
+ tool_name = getattr(tool_class(), 'tool_name', None)
109
+ if tool_name:
110
+ self.tools_adapter.unregister_tool(tool_name)
111
+
112
+ # Cleanup plugin
113
+ plugin.cleanup()
114
+
115
+ # Remove from registry
116
+ del self.plugins[plugin_name]
117
+ if plugin_name in self.plugin_configs:
118
+ del self.plugin_configs[plugin_name]
119
+
120
+ logger.info(f"Successfully unloaded plugin: {plugin_name}")
121
+ return True
122
+
123
+ except Exception as e:
124
+ logger.error(f"Failed to unload plugin {plugin_name}: {e}")
125
+ return False
126
+
127
+ def list_plugins(self) -> List[str]:
128
+ """Return list of loaded plugin names."""
129
+ return list(self.plugins.keys())
130
+
131
+ def get_plugin(self, plugin_name: str) -> Optional[Plugin]:
132
+ """Get a loaded plugin by name."""
133
+ return self.plugins.get(plugin_name)
134
+
135
+ def get_plugin_metadata(self, plugin_name: str) -> Optional[PluginMetadata]:
136
+ """Get metadata for a loaded plugin."""
137
+ plugin = self.plugins.get(plugin_name)
138
+ return plugin.metadata if plugin else None
139
+
140
+ def load_plugins_from_config(self, config: Dict[str, Any]) -> None:
141
+ """
142
+ Load plugins from configuration.
143
+
144
+ Args:
145
+ config: Configuration dict with plugin settings
146
+ """
147
+ plugins_config = config.get('plugins', {})
148
+
149
+ # Add plugin paths
150
+ for path in plugins_config.get('paths', []):
151
+ self.add_plugin_path(path)
152
+
153
+ # Load plugins
154
+ for plugin_name, plugin_config in plugins_config.get('load', {}).items():
155
+ if isinstance(plugin_config, bool):
156
+ if plugin_config:
157
+ self.load_plugin(plugin_name)
158
+ else:
159
+ self.load_plugin(plugin_name, plugin_config)
160
+
161
+ def reload_plugin(self, plugin_name: str) -> bool:
162
+ """
163
+ Reload a plugin.
164
+
165
+ Args:
166
+ plugin_name: Name of the plugin to reload
167
+
168
+ Returns:
169
+ True if plugin reloaded successfully
170
+ """
171
+ config = self.plugin_configs.get(plugin_name)
172
+ self.unload_plugin(plugin_name)
173
+ return self.load_plugin(plugin_name, config)
174
+
175
+ def get_loaded_plugins_info(self) -> Dict[str, Dict[str, Any]]:
176
+ """Get information about all loaded plugins."""
177
+ info = {}
178
+ for name, plugin in self.plugins.items():
179
+ info[name] = {
180
+ 'metadata': plugin.metadata,
181
+ 'tools': [tool.__name__ for tool in plugin.get_tools()],
182
+ 'commands': list(plugin.get_commands().keys()),
183
+ 'config': self.plugin_configs.get(name, {})
184
+ }
185
+ return info
@@ -3,6 +3,15 @@
3
3
  from janito.llm.model import LLMModelInfo
4
4
 
5
5
  MODEL_SPECS = {
6
+ "openai/gpt-oss-120b": LLMModelInfo(
7
+ name="openai/gpt-oss-120b",
8
+ context=128000,
9
+ max_input=128000,
10
+ max_response=4096,
11
+ max_cot=4096,
12
+ thinking_supported=True,
13
+ category="IBM WatsonX",
14
+ ),
6
15
  "ibm/granite-3-8b-instruct": LLMModelInfo(
7
16
  name="ibm/granite-3-8b-instruct",
8
17
  context=128000,
@@ -23,6 +23,7 @@ from .get_file_outline.core import GetFileOutlineTool
23
23
  from .get_file_outline.search_outline import SearchOutlineTool
24
24
  from .search_text.core import SearchTextTool
25
25
  from .validate_file_syntax.core import ValidateFileSyntaxTool
26
+ from .read_chart import ReadChartTool
26
27
 
27
28
  from janito.tools.tool_base import ToolPermissions
28
29
  import os
@@ -61,6 +62,7 @@ for tool_class in [
61
62
  SearchOutlineTool,
62
63
  SearchTextTool,
63
64
  ValidateFileSyntaxTool,
65
+ ReadChartTool,
64
66
  ]:
65
67
  local_tools_adapter.register_tool(tool_class)
66
68
 
@@ -1,5 +1,6 @@
1
1
  from typing import Type, Dict, Any
2
2
  from janito.tools.tools_adapter import ToolsAdapterBase as ToolsAdapter
3
+ from janito.tools.tool_use_tracker import ToolUseTracker
3
4
 
4
5
 
5
6
  class LocalToolsAdapter(ToolsAdapter):
@@ -58,6 +59,9 @@ class LocalToolsAdapter(ToolsAdapter):
58
59
  # consistency with many file-system tools.
59
60
  os.chdir(self.workdir)
60
61
 
62
+ # Initialize tool tracker
63
+ self.tool_tracker = ToolUseTracker.instance()
64
+
61
65
  if tools:
62
66
  for tool in tools:
63
67
  self.register_tool(tool)
@@ -130,6 +134,57 @@ class LocalToolsAdapter(ToolsAdapter):
130
134
  and not is_tool_disabled(entry["instance"].tool_name)
131
135
  ]
132
136
 
137
+ # ------------------------------------------------------------------
138
+ # Tool execution with error handling
139
+ # ------------------------------------------------------------------
140
+ def execute_tool(self, name: str, **kwargs):
141
+ """
142
+ Execute a tool with proper error handling.
143
+
144
+ This method extends the base execute_tool functionality by adding
145
+ error handling for RuntimeError exceptions that may be raised by
146
+ tools with loop protection decorators.
147
+
148
+ Args:
149
+ name: The name of the tool to execute
150
+ **kwargs: Arguments to pass to the tool
151
+
152
+ Returns:
153
+ The result of the tool execution
154
+
155
+ Raises:
156
+ ToolCallException: If tool execution fails for any reason
157
+ ValueError: If the tool is not found or not allowed
158
+ """
159
+ # First check if tool exists and is allowed
160
+ tool = self.get_tool(name)
161
+ if not tool:
162
+ raise ValueError(f"Tool '{name}' not found or not allowed.")
163
+
164
+ # Record tool usage
165
+ self.tool_tracker.record(name, kwargs)
166
+
167
+ # Execute the tool and handle any RuntimeError from loop protection
168
+ try:
169
+ return super().execute_tool(name, **kwargs)
170
+ except RuntimeError as e:
171
+ # Check if this is a loop protection error
172
+ if "Loop protection:" in str(e):
173
+ # Re-raise as ToolCallException to maintain consistent error flow
174
+ from janito.exceptions import ToolCallException
175
+ raise ToolCallException(
176
+ name,
177
+ f"Loop protection triggered: {str(e)}",
178
+ arguments=kwargs
179
+ )
180
+ # Re-raise other RuntimeError exceptions as ToolCallException
181
+ from janito.exceptions import ToolCallException
182
+ raise ToolCallException(
183
+ name,
184
+ f"Runtime error during tool execution: {str(e)}",
185
+ arguments=kwargs
186
+ )
187
+
133
188
  # ------------------------------------------------------------------
134
189
  # Convenience methods
135
190
  # ------------------------------------------------------------------
@@ -1,5 +1,6 @@
1
1
  from janito.tools.tool_base import ToolBase, ToolPermissions
2
2
  from janito.tools.adapters.local.adapter import register_local_tool
3
+ from janito.tools.loop_protection_decorator import protect_against_loops
3
4
 
4
5
  from rich import print as rich_print
5
6
  from janito.i18n import tr
@@ -31,6 +32,7 @@ class AskUserTool(ToolBase):
31
32
  permissions = ToolPermissions(read=True)
32
33
  tool_name = "ask_user"
33
34
 
35
+ @protect_against_loops(max_calls=5, time_window=10.0)
34
36
  def run(self, question: str) -> str:
35
37
 
36
38
  print() # Print an empty line before the question panel
@@ -1,10 +1,15 @@
1
1
  import requests
2
+ import time
3
+ import os
4
+ import json
5
+ from pathlib import Path
2
6
  from bs4 import BeautifulSoup
3
7
  from janito.tools.adapters.local.adapter import register_local_tool
4
8
  from janito.tools.tool_base import ToolBase, ToolPermissions
5
9
  from janito.report_events import ReportAction
6
10
  from janito.i18n import tr
7
11
  from janito.tools.tool_utils import pluralize
12
+ from janito.tools.loop_protection_decorator import protect_against_loops
8
13
 
9
14
 
10
15
  @register_local_tool
@@ -12,12 +17,34 @@ class FetchUrlTool(ToolBase):
12
17
  """
13
18
  Fetch the content of a web page and extract its text.
14
19
 
20
+ This tool implements a **session-based caching mechanism** that provides
21
+ **in-memory caching** for the lifetime of the tool instance. URLs are cached
22
+ in RAM during the session, providing instant access to previously fetched
23
+ content without making additional HTTP requests.
24
+
25
+ **Session Cache Behavior:**
26
+ - **Lifetime**: Cache exists for the lifetime of the FetchUrlTool instance
27
+ - **Scope**: In-memory (RAM) cache, not persisted to disk
28
+ - **Storage**: Successful responses are cached as raw HTML content
29
+ - **Key**: Cache key is the exact URL string
30
+ - **Invalidation**: Cache is automatically cleared when the tool instance is destroyed
31
+ - **Performance**: Subsequent requests for the same URL return instantly
32
+
33
+ **Error Cache Behavior:**
34
+ - HTTP 403 errors: Cached for 24 hours (more permanent)
35
+ - HTTP 404 errors: Cached for 1 hour (temporary)
36
+ - Other 4xx errors: Cached for 30 minutes
37
+ - 5xx errors: Not cached (retried on each request)
38
+
15
39
  Args:
16
40
  url (str): The URL of the web page to fetch.
17
41
  search_strings (list[str], optional): Strings to search for in the page content.
18
42
  max_length (int, optional): Maximum number of characters to return. Defaults to 5000.
19
43
  max_lines (int, optional): Maximum number of lines to return. Defaults to 200.
20
44
  context_chars (int, optional): Characters of context around search matches. Defaults to 400.
45
+ timeout (int, optional): Timeout in seconds for the HTTP request. Defaults to 10.
46
+ save_to_file (str, optional): File path to save the full resource content. If provided,
47
+ the complete response will be saved to this file instead of being processed.
21
48
  Returns:
22
49
  str: Extracted text content from the web page, or a warning message. Example:
23
50
  - "<main text content...>"
@@ -28,15 +55,138 @@ class FetchUrlTool(ToolBase):
28
55
  permissions = ToolPermissions(read=True)
29
56
  tool_name = "fetch_url"
30
57
 
31
- def _fetch_url_content(self, url: str) -> str:
32
- """Fetch URL content and handle HTTP errors."""
58
+ def __init__(self):
59
+ super().__init__()
60
+ self.cache_dir = Path.home() / ".janito" / "cache" / "fetch_url"
61
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
62
+ self.cache_file = self.cache_dir / "error_cache.json"
63
+ self.session_cache = {} # In-memory session cache - lifetime matches tool instance
64
+ self._load_cache()
65
+
66
+ def _load_cache(self):
67
+ """Load error cache from disk."""
68
+ if self.cache_file.exists():
69
+ try:
70
+ with open(self.cache_file, 'r', encoding='utf-8') as f:
71
+ self.error_cache = json.load(f)
72
+ except (json.JSONDecodeError, IOError):
73
+ self.error_cache = {}
74
+ else:
75
+ self.error_cache = {}
76
+
77
+ def _save_cache(self):
78
+ """Save error cache to disk."""
79
+ try:
80
+ with open(self.cache_file, 'w', encoding='utf-8') as f:
81
+ json.dump(self.error_cache, f, indent=2)
82
+ except IOError:
83
+ pass # Silently fail if we can't write cache
84
+
85
+ def _get_cached_error(self, url: str) -> tuple[str, bool]:
86
+ """
87
+ Check if we have a cached error for this URL.
88
+ Returns (error_message, is_cached) tuple.
89
+ """
90
+ if url not in self.error_cache:
91
+ return None, False
92
+
93
+ entry = self.error_cache[url]
94
+ current_time = time.time()
95
+
96
+ # Different expiration times for different status codes
97
+ if entry['status_code'] == 403:
98
+ # Cache 403 errors for 24 hours (more permanent)
99
+ expiration_time = 24 * 3600
100
+ elif entry['status_code'] == 404:
101
+ # Cache 404 errors for 1 hour (more temporary)
102
+ expiration_time = 3600
103
+ else:
104
+ # Cache other 4xx errors for 30 minutes
105
+ expiration_time = 1800
106
+
107
+ if current_time - entry['timestamp'] > expiration_time:
108
+ # Cache expired, remove it
109
+ del self.error_cache[url]
110
+ self._save_cache()
111
+ return None, False
112
+
113
+ return entry['message'], True
114
+
115
+ def _cache_error(self, url: str, status_code: int, message: str):
116
+ """Cache an HTTP error response."""
117
+ self.error_cache[url] = {
118
+ 'status_code': status_code,
119
+ 'message': message,
120
+ 'timestamp': time.time()
121
+ }
122
+ self._save_cache()
123
+
124
+ def _fetch_url_content(self, url: str, timeout: int = 10) -> str:
125
+ """Fetch URL content and handle HTTP errors.
126
+
127
+ Implements two-tier caching:
128
+ 1. Session cache: In-memory cache for successful responses (lifetime = tool instance)
129
+ 2. Error cache: Persistent disk cache for HTTP errors with different expiration times
130
+
131
+ Also implements URL whitelist checking.
132
+ """
133
+ # Check URL whitelist
134
+ from janito.tools.url_whitelist import get_url_whitelist_manager
135
+ whitelist_manager = get_url_whitelist_manager()
136
+
137
+ if not whitelist_manager.is_url_allowed(url):
138
+ error_message = tr(
139
+ "Warning: URL blocked by whitelist: {url}",
140
+ url=url,
141
+ )
142
+ self.report_error(
143
+ tr(
144
+ "❗ URL blocked by whitelist: {url}",
145
+ url=url,
146
+ ),
147
+ ReportAction.READ,
148
+ )
149
+ return error_message
150
+
151
+ # Check session cache first
152
+ if url in self.session_cache:
153
+ self.report_warning(
154
+ tr("ℹ️ Using session cache"),
155
+ ReportAction.READ,
156
+ )
157
+ return self.session_cache[url]
158
+
159
+ # Check persistent cache for known errors
160
+ cached_error, is_cached = self._get_cached_error(url)
161
+ if cached_error:
162
+ self.report_warning(
163
+ tr(
164
+ "ℹ️ Using cached HTTP error for URL: {url}",
165
+ url=url,
166
+ ),
167
+ ReportAction.READ,
168
+ )
169
+ return cached_error
170
+
33
171
  try:
34
- response = requests.get(url, timeout=10)
172
+ response = requests.get(url, timeout=timeout)
35
173
  response.raise_for_status()
36
- return response.text
174
+ content = response.text
175
+ # Cache successful responses in session cache
176
+ self.session_cache[url] = content
177
+ return content
37
178
  except requests.exceptions.HTTPError as http_err:
38
179
  status_code = http_err.response.status_code if http_err.response else None
39
180
  if status_code and 400 <= status_code < 500:
181
+ error_message = tr(
182
+ "Warning: HTTP {status_code} error for URL: {url}",
183
+ status_code=status_code,
184
+ url=url,
185
+ )
186
+ # Cache 403 and 404 errors
187
+ if status_code in [403, 404]:
188
+ self._cache_error(url, status_code, error_message)
189
+
40
190
  self.report_error(
41
191
  tr(
42
192
  "❗ HTTP {status_code} error for URL: {url}",
@@ -45,11 +195,7 @@ class FetchUrlTool(ToolBase):
45
195
  ),
46
196
  ReportAction.READ,
47
197
  )
48
- return tr(
49
- "Warning: HTTP {status_code} error for URL: {url}",
50
- status_code=status_code,
51
- url=url,
52
- )
198
+ return error_message
53
199
  else:
54
200
  self.report_error(
55
201
  tr(
@@ -116,6 +262,7 @@ class FetchUrlTool(ToolBase):
116
262
 
117
263
  return text
118
264
 
265
+ @protect_against_loops(max_calls=5, time_window=10.0)
119
266
  def run(
120
267
  self,
121
268
  url: str,
@@ -123,6 +270,8 @@ class FetchUrlTool(ToolBase):
123
270
  max_length: int = 5000,
124
271
  max_lines: int = 200,
125
272
  context_chars: int = 400,
273
+ timeout: int = 10,
274
+ save_to_file: str = None,
126
275
  ) -> str:
127
276
  if not url.strip():
128
277
  self.report_warning(tr("ℹ️ Empty URL provided."), ReportAction.READ)
@@ -130,8 +279,32 @@ class FetchUrlTool(ToolBase):
130
279
 
131
280
  self.report_action(tr("🌐 Fetch URL '{url}' ...", url=url), ReportAction.READ)
132
281
 
133
- # Fetch URL content
134
- html_content = self._fetch_url_content(url)
282
+ # Check if we should save to file
283
+ if save_to_file:
284
+ html_content = self._fetch_url_content(url, timeout=timeout)
285
+ if html_content.startswith("Warning:"):
286
+ return html_content
287
+
288
+ try:
289
+ with open(save_to_file, 'w', encoding='utf-8') as f:
290
+ f.write(html_content)
291
+ file_size = len(html_content)
292
+ self.report_success(
293
+ tr(
294
+ "✅ Saved {size} bytes to {file}",
295
+ size=file_size,
296
+ file=save_to_file,
297
+ ),
298
+ ReportAction.READ,
299
+ )
300
+ return tr("Successfully saved content to: {file}", file=save_to_file)
301
+ except IOError as e:
302
+ error_msg = tr("Error saving to file: {error}", error=str(e))
303
+ self.report_error(error_msg, ReportAction.READ)
304
+ return error_msg
305
+
306
+ # Normal processing path
307
+ html_content = self._fetch_url_content(url, timeout=timeout)
135
308
  if html_content.startswith("Warning:"):
136
309
  return html_content
137
310
 
@@ -6,6 +6,7 @@ from janito.dir_walk_utils import walk_dir_with_gitignore
6
6
  from janito.i18n import tr
7
7
  import fnmatch
8
8
  import os
9
+ from janito.tools.loop_protection_decorator import protect_against_loops
9
10
 
10
11
 
11
12
  @register_local_tool
@@ -107,6 +108,7 @@ class FindFilesTool(ToolBase):
107
108
  }
108
109
  return sorted(dir_output)
109
110
 
111
+ @protect_against_loops(max_calls=5, time_window=10.0)
110
112
  def run(
111
113
  self,
112
114
  paths: str,
@@ -10,6 +10,7 @@ from janito.tools.tool_utils import display_path, pluralize
10
10
  from janito.i18n import tr
11
11
 
12
12
  from janito.tools.adapters.local.adapter import register_local_tool as register_tool
13
+ from janito.tools.loop_protection_decorator import protect_against_loops
13
14
 
14
15
 
15
16
  @register_tool
@@ -24,6 +25,7 @@ class GetFileOutlineTool(ToolBase):
24
25
  permissions = ToolPermissions(read=True)
25
26
  tool_name = "get_file_outline"
26
27
 
28
+ @protect_against_loops(max_calls=5, time_window=10.0)
27
29
  def run(self, path: str) -> str:
28
30
  try:
29
31
  self.report_action(
@@ -1,5 +1,6 @@
1
1
  from janito.tools.tool_base import ToolBase, ToolPermissions
2
2
  from janito.report_events import ReportAction
3
+ from janito.tools.loop_protection_decorator import protect_against_loops
3
4
 
4
5
 
5
6
  class SearchOutlineTool(ToolBase):
@@ -15,6 +16,7 @@ class SearchOutlineTool(ToolBase):
15
16
  permissions = ToolPermissions(read=True)
16
17
  tool_name = "search_outline"
17
18
 
19
+ @protect_against_loops(max_calls=5, time_window=10.0)
18
20
  def run(self, path: str) -> str:
19
21
  from janito.tools.tool_utils import display_path
20
22
  from janito.i18n import tr
@@ -4,6 +4,7 @@ from janito.tools.adapters.local.adapter import register_local_tool
4
4
  from janito.tools.tool_base import ToolBase, ToolPermissions
5
5
  from janito.report_events import ReportAction
6
6
  from janito.i18n import tr
7
+ from janito.tools.loop_protection_decorator import protect_against_loops
7
8
 
8
9
 
9
10
  @register_local_tool
@@ -20,6 +21,7 @@ class OpenHtmlInBrowserTool(ToolBase):
20
21
  permissions = ToolPermissions(read=True)
21
22
  tool_name = "open_html_in_browser"
22
23
 
24
+ @protect_against_loops(max_calls=5, time_window=10.0)
23
25
  def run(self, path: str) -> str:
24
26
  if not path.strip():
25
27
  self.report_warning(tr("ℹ️ Empty file path provided."))
@@ -3,6 +3,7 @@ from janito.tools.adapters.local.adapter import register_local_tool
3
3
  from janito.tools.tool_base import ToolBase, ToolPermissions
4
4
  from janito.report_events import ReportAction
5
5
  from janito.i18n import tr
6
+ from janito.tools.loop_protection_decorator import protect_against_loops
6
7
 
7
8
 
8
9
  @register_local_tool
@@ -19,6 +20,7 @@ class OpenUrlTool(ToolBase):
19
20
  permissions = ToolPermissions(read=True)
20
21
  tool_name = "open_url"
21
22
 
23
+ @protect_against_loops(max_calls=5, time_window=10.0)
22
24
  def run(self, url: str) -> str:
23
25
  if not url.strip():
24
26
  self.report_warning(tr("ℹ️ Empty URL provided."))