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.
- janito/agent/setup_agent.py +48 -4
- janito/agent/templates/profiles/system_prompt_template_Developer_with_Python_Tools.txt.j2 +59 -11
- janito/agent/templates/profiles/system_prompt_template_developer.txt.j2 +53 -7
- janito/agent/templates/profiles/system_prompt_template_market_analyst.txt.j2 +110 -0
- janito/agent/templates/profiles/system_prompt_template_model_conversation_without_tools_or_context.txt.j2 +53 -1
- janito/cli/chat_mode/session.py +8 -1
- janito/cli/chat_mode/session_profile_select.py +20 -3
- janito/cli/chat_mode/shell/commands/__init__.py +2 -0
- janito/cli/chat_mode/shell/commands/security/__init__.py +1 -0
- janito/cli/chat_mode/shell/commands/security/allowed_sites.py +94 -0
- janito/cli/chat_mode/shell/commands/security_command.py +51 -0
- janito/cli/cli_commands/list_plugins.py +45 -0
- janito/cli/cli_commands/list_profiles.py +29 -1
- janito/cli/cli_commands/show_system_prompt.py +24 -10
- janito/cli/core/getters.py +4 -0
- janito/cli/core/runner.py +7 -2
- janito/cli/core/setters.py +10 -1
- janito/cli/main_cli.py +25 -3
- janito/cli/single_shot_mode/handler.py +3 -1
- janito/config_manager.py +10 -0
- janito/plugins/__init__.py +17 -0
- janito/plugins/base.py +93 -0
- janito/plugins/discovery.py +160 -0
- janito/plugins/manager.py +185 -0
- janito/providers/ibm/model_info.py +9 -0
- janito/tools/adapters/local/__init__.py +2 -0
- janito/tools/adapters/local/adapter.py +55 -0
- janito/tools/adapters/local/ask_user.py +2 -0
- janito/tools/adapters/local/fetch_url.py +184 -11
- janito/tools/adapters/local/find_files.py +2 -0
- janito/tools/adapters/local/get_file_outline/core.py +2 -0
- janito/tools/adapters/local/get_file_outline/search_outline.py +2 -0
- janito/tools/adapters/local/open_html_in_browser.py +2 -0
- janito/tools/adapters/local/open_url.py +2 -0
- janito/tools/adapters/local/python_code_run.py +15 -10
- janito/tools/adapters/local/python_command_run.py +14 -9
- janito/tools/adapters/local/python_file_run.py +15 -10
- janito/tools/adapters/local/read_chart.py +252 -0
- janito/tools/adapters/local/read_files.py +2 -0
- janito/tools/adapters/local/replace_text_in_file.py +1 -1
- janito/tools/adapters/local/run_bash_command.py +18 -12
- janito/tools/adapters/local/run_powershell_command.py +15 -9
- janito/tools/adapters/local/search_text/core.py +2 -0
- janito/tools/adapters/local/validate_file_syntax/core.py +6 -0
- janito/tools/adapters/local/validate_file_syntax/jinja2_validator.py +47 -0
- janito/tools/adapters/local/view_file.py +2 -0
- janito/tools/loop_protection.py +115 -0
- janito/tools/loop_protection_decorator.py +110 -0
- janito/tools/url_whitelist.py +121 -0
- {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/METADATA +1 -1
- {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/RECORD +55 -41
- {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/WHEEL +0 -0
- {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/entry_points.txt +0 -0
- {janito-2.21.0.dist-info → janito-2.24.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
32
|
-
|
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=
|
172
|
+
response = requests.get(url, timeout=timeout)
|
35
173
|
response.raise_for_status()
|
36
|
-
|
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
|
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
|
-
#
|
134
|
-
|
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."))
|