bone-agent 1.3.0
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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/npm-wrapper.js +235 -0
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +133 -0
- package/package.json +53 -0
- package/requirements.txt +9 -0
- package/src/__init__.py +11 -0
- package/src/core/__init__.py +1 -0
- package/src/core/agentic.py +1054 -0
- package/src/core/chat_manager.py +1552 -0
- package/src/core/config_manager.py +247 -0
- package/src/core/cron.py +527 -0
- package/src/core/cron_allowlist.py +118 -0
- package/src/core/memory.py +232 -0
- package/src/core/retry.py +71 -0
- package/src/core/sub_agent.py +326 -0
- package/src/core/tool_approval.py +220 -0
- package/src/core/tool_feedback.py +778 -0
- package/src/exceptions.py +79 -0
- package/src/llm/__init__.py +1 -0
- package/src/llm/client.py +171 -0
- package/src/llm/config.py +466 -0
- package/src/llm/prompts.py +735 -0
- package/src/llm/providers.py +417 -0
- package/src/llm/streaming.py +163 -0
- package/src/llm/token_tracker.py +368 -0
- package/src/tools/__init__.py +212 -0
- package/src/tools/constants.py +59 -0
- package/src/tools/create_file.py +136 -0
- package/src/tools/directory.py +389 -0
- package/src/tools/edit.py +543 -0
- package/src/tools/file_reader.py +322 -0
- package/src/tools/helpers/__init__.py +105 -0
- package/src/tools/helpers/base.py +550 -0
- package/src/tools/helpers/converters.py +44 -0
- package/src/tools/helpers/file_helpers.py +189 -0
- package/src/tools/helpers/formatters.py +411 -0
- package/src/tools/helpers/loader.py +231 -0
- package/src/tools/helpers/parallel_executor.py +231 -0
- package/src/tools/helpers/path_resolver.py +226 -0
- package/src/tools/helpers/plugin_manifest.py +156 -0
- package/src/tools/obsidian.py +96 -0
- package/src/tools/review_sub_agent.py +189 -0
- package/src/tools/rg_search.py +393 -0
- package/src/tools/search_plugins.py +109 -0
- package/src/tools/select_option.py +593 -0
- package/src/tools/shell.py +302 -0
- package/src/tools/sub_agent.py +139 -0
- package/src/tools/task_list.py +269 -0
- package/src/tools/web_search.py +61 -0
- package/src/ui/__init__.py +1 -0
- package/src/ui/banner.py +87 -0
- package/src/ui/commands.py +2694 -0
- package/src/ui/displays.py +213 -0
- package/src/ui/loader.py +284 -0
- package/src/ui/main.py +646 -0
- package/src/ui/prompt_utils.py +113 -0
- package/src/ui/setting_selector.py +590 -0
- package/src/ui/setup_wizard.py +294 -0
- package/src/ui/sub_agent_panel.py +234 -0
- package/src/ui/tool_confirmation.py +215 -0
- package/src/utils/__init__.py +1 -0
- package/src/utils/citation_parser.py +199 -0
- package/src/utils/editor.py +158 -0
- package/src/utils/gitignore_filter.py +149 -0
- package/src/utils/logger.py +254 -0
- package/src/utils/paths.py +30 -0
- package/src/utils/result_parsers.py +108 -0
- package/src/utils/safe_commands.py +243 -0
- package/src/utils/settings.py +174 -0
- package/src/utils/validation.py +191 -0
- package/src/utils/web_search.py +173 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Tool auto-discovery and loading mechanism.
|
|
2
|
+
|
|
3
|
+
This module provides automatic discovery and loading of tools from
|
|
4
|
+
multiple directories. Tools are imported to trigger @tool decorator
|
|
5
|
+
registration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from .base import ToolRegistry
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_python_file(path: Path) -> bool:
|
|
20
|
+
"""Check if a file is a Python module.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
path: File path to check
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if file is a .py file (not __pycache__ or test file)
|
|
27
|
+
"""
|
|
28
|
+
return (
|
|
29
|
+
path.suffix == ".py"
|
|
30
|
+
and path.name != "__init__.py"
|
|
31
|
+
and not path.name.startswith("test_")
|
|
32
|
+
and not path.name.startswith("_")
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_module_from_path(module_name: str, file_path: Path) -> Optional[object]:
|
|
37
|
+
"""Load a Python module from a file path.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
module_name: Name to give the module
|
|
41
|
+
file_path: Path to the Python file
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Loaded module or None if loading failed
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
48
|
+
if spec is None or spec.loader is None:
|
|
49
|
+
logger.warning(f"Could not load spec for {file_path}")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
module = importlib.util.module_from_spec(spec)
|
|
53
|
+
|
|
54
|
+
# For user tools (not in src/utils/tools/), set package to None
|
|
55
|
+
# to force absolute imports instead of relative imports
|
|
56
|
+
from tools import __file__ as tools_init_file
|
|
57
|
+
tools_dir = Path(tools_init_file).parent
|
|
58
|
+
|
|
59
|
+
# User tools are those not in the main tools directory
|
|
60
|
+
# (helper modules are in src/tools/helpers/)
|
|
61
|
+
if file_path.parent != tools_dir and file_path.parent != tools_dir / "helpers":
|
|
62
|
+
# User tool - set to None to force absolute imports
|
|
63
|
+
module.__package__ = None
|
|
64
|
+
|
|
65
|
+
sys.modules[module_name] = module
|
|
66
|
+
spec.loader.exec_module(module)
|
|
67
|
+
|
|
68
|
+
logger.debug(f"Successfully loaded module: {module_name}")
|
|
69
|
+
return module
|
|
70
|
+
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.warning(f"Failed to load module {module_name} from {file_path}: {e}")
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def discover_tools(directories: List[str]) -> int:
|
|
77
|
+
"""Discover and load tools from specified directories.
|
|
78
|
+
|
|
79
|
+
This scans directories for Python files and imports them,
|
|
80
|
+
which triggers @tool decorator registration.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
directories: List of directory paths to scan
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Number of tools successfully loaded
|
|
87
|
+
|
|
88
|
+
Note:
|
|
89
|
+
- Only .py files are considered (excluding __pycache__, tests)
|
|
90
|
+
- Import errors are logged but don't stop discovery
|
|
91
|
+
- User tools can override built-in tools (with warning)
|
|
92
|
+
"""
|
|
93
|
+
initial_count = ToolRegistry.tool_count()
|
|
94
|
+
loaded_count = 0
|
|
95
|
+
|
|
96
|
+
for directory in directories:
|
|
97
|
+
dir_path = Path(directory)
|
|
98
|
+
|
|
99
|
+
if not dir_path.exists():
|
|
100
|
+
logger.debug(f"Tool directory does not exist: {directory}")
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if not dir_path.is_dir():
|
|
104
|
+
logger.warning(f"Tool path is not a directory: {directory}")
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
logger.info(f"Discovering tools in: {directory}")
|
|
108
|
+
|
|
109
|
+
# Find all Python files
|
|
110
|
+
python_files = [f for f in dir_path.iterdir() if _is_python_file(f)]
|
|
111
|
+
|
|
112
|
+
for py_file in python_files:
|
|
113
|
+
# Create unique module name
|
|
114
|
+
module_name = f"tools_{py_file.stem}_{hash(str(py_file)) & 0xFFFFFFFF}"
|
|
115
|
+
|
|
116
|
+
# Skip modules already loaded (e.g. cron re-calling load_all_tools)
|
|
117
|
+
if module_name in sys.modules:
|
|
118
|
+
logger.debug(f"Module already loaded, skipping: {module_name}")
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
module = _load_module_from_path(module_name, py_file)
|
|
122
|
+
if module:
|
|
123
|
+
loaded_count += 1
|
|
124
|
+
|
|
125
|
+
final_count = ToolRegistry.tool_count()
|
|
126
|
+
new_tools = final_count - initial_count
|
|
127
|
+
|
|
128
|
+
logger.info(
|
|
129
|
+
f"Tool discovery complete: Loaded {loaded_count} modules, "
|
|
130
|
+
f"registered {new_tools} new tools (total: {final_count})"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return new_tools
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_builtin_tools() -> int:
|
|
137
|
+
"""Load built-in tools from src/utils/tools/.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Number of tools loaded
|
|
141
|
+
|
|
142
|
+
Note:
|
|
143
|
+
Built-in tools are already imported in __init__.py, which triggers
|
|
144
|
+
@tool decorator registration. This function returns the count of
|
|
145
|
+
currently registered built-in tools.
|
|
146
|
+
"""
|
|
147
|
+
# Built-in tools are already imported in utils/tools/__init__.py
|
|
148
|
+
# which triggers @tool decorator registration.
|
|
149
|
+
# Just return the count of tools currently in registry.
|
|
150
|
+
return ToolRegistry.tool_count()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_plugin_tools() -> int:
|
|
154
|
+
"""Load plugin tools from tool_plugins/ directory.
|
|
155
|
+
|
|
156
|
+
Plugin modules are imported, which triggers @tool(tier="plugin") decorator
|
|
157
|
+
registration into the PluginManifest (NOT ToolRegistry). This keeps plugin
|
|
158
|
+
schemas out of the LLM context window until activated via search_plugins.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Number of plugin modules loaded
|
|
162
|
+
|
|
163
|
+
Note:
|
|
164
|
+
- tool_plugins/ directory at repository root
|
|
165
|
+
- Plugin tools registered in PluginManifest, not ToolRegistry
|
|
166
|
+
"""
|
|
167
|
+
from .plugin_manifest import plugin_manifest
|
|
168
|
+
|
|
169
|
+
# Get repository root (assumes we're in src/tools/helpers/)
|
|
170
|
+
current_dir = Path(__file__).parent
|
|
171
|
+
repo_root = current_dir.parent.parent.parent
|
|
172
|
+
|
|
173
|
+
# Define plugin tool directories
|
|
174
|
+
plugin_directories = [
|
|
175
|
+
str(repo_root / "tool_plugins"),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
# Discover tools in plugin directories
|
|
179
|
+
modules_loaded = discover_tools(plugin_directories)
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
f"Plugin manifest: {plugin_manifest.plugin_count()} plugins available "
|
|
183
|
+
f"(categories: {plugin_manifest.get_categories()})"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return modules_loaded
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def load_all_tools() -> int:
|
|
190
|
+
"""Load all tools (built-in and plugin tools).
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Total number of tools loaded
|
|
194
|
+
|
|
195
|
+
Discovery order:
|
|
196
|
+
1. Built-in tools (src/tools/*.py)
|
|
197
|
+
2. Plugin tools (tool_plugins/*.py)
|
|
198
|
+
|
|
199
|
+
Note:
|
|
200
|
+
Plugin tools loaded later can override built-in tools.
|
|
201
|
+
"""
|
|
202
|
+
logger.info("Starting tool loading...")
|
|
203
|
+
|
|
204
|
+
# Load built-in tools first
|
|
205
|
+
builtin_count = load_builtin_tools()
|
|
206
|
+
|
|
207
|
+
# Then load plugin tools (can override built-ins)
|
|
208
|
+
plugin_count = load_plugin_tools()
|
|
209
|
+
|
|
210
|
+
total_count = builtin_count + plugin_count
|
|
211
|
+
total_registered = ToolRegistry.tool_count()
|
|
212
|
+
|
|
213
|
+
logger.info(
|
|
214
|
+
f"Tool loading complete: {builtin_count} built-in + "
|
|
215
|
+
f"{plugin_count} plugin modules = {total_count} modules, "
|
|
216
|
+
f"{total_registered} tools registered"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return total_count
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def list_registered_tools() -> List[str]:
|
|
223
|
+
"""List names of all registered tools.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List of tool names
|
|
227
|
+
"""
|
|
228
|
+
return [tool.name for tool in ToolRegistry.get_all()]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Concurrent tool execution engine.
|
|
2
|
+
|
|
3
|
+
This module provides parallel execution of multiple tool calls using
|
|
4
|
+
ThreadPoolExecutor for I/O-bound operations like file reads and web searches.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import concurrent.futures
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .base import ToolRegistry, build_context
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ToolCall:
|
|
16
|
+
"""Represents a single tool call.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
tool_id: Unique identifier for this tool call
|
|
20
|
+
function_name: Name of the tool function to execute
|
|
21
|
+
arguments: Dictionary of arguments to pass to the tool handler
|
|
22
|
+
call_index: Index in original tool_calls array (for order preservation)
|
|
23
|
+
"""
|
|
24
|
+
tool_id: str
|
|
25
|
+
function_name: str
|
|
26
|
+
arguments: dict
|
|
27
|
+
call_index: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ToolResult:
|
|
32
|
+
"""Result of a tool execution.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
tool_id: Unique identifier for the tool call
|
|
36
|
+
call_index: Index in original tool_calls array (for order preservation)
|
|
37
|
+
success: Whether the tool executed successfully
|
|
38
|
+
result: String result from tool execution (if successful)
|
|
39
|
+
error: Error message (if failed)
|
|
40
|
+
should_exit: Whether the tool requested the orchestration loop to exit
|
|
41
|
+
requires_approval: Whether this tool requires user approval (for orchestrator)
|
|
42
|
+
"""
|
|
43
|
+
tool_id: str
|
|
44
|
+
call_index: int
|
|
45
|
+
success: bool
|
|
46
|
+
result: str
|
|
47
|
+
error: str = None
|
|
48
|
+
should_exit: bool = False
|
|
49
|
+
requires_approval: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ParallelToolExecutor:
|
|
53
|
+
"""Executes multiple tool calls concurrently with proper error handling.
|
|
54
|
+
|
|
55
|
+
This class provides thread-safe concurrent execution of tool calls using
|
|
56
|
+
ThreadPoolExecutor. Key features:
|
|
57
|
+
- Executes independent tools concurrently for performance
|
|
58
|
+
- Preserves result order using call_index tracking
|
|
59
|
+
- Isolates errors (one failure doesn't stop others)
|
|
60
|
+
- Fast-path optimization for single tool calls (no threading overhead)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, max_workers: int = 5):
|
|
64
|
+
"""Initialize executor.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
max_workers: Maximum number of concurrent tool executions
|
|
68
|
+
"""
|
|
69
|
+
self.max_workers = max_workers
|
|
70
|
+
|
|
71
|
+
def execute_tools(
|
|
72
|
+
self,
|
|
73
|
+
tool_calls: List[ToolCall],
|
|
74
|
+
context: dict
|
|
75
|
+
) -> Tuple[List[ToolResult], bool]:
|
|
76
|
+
"""Execute multiple tools concurrently.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
tool_calls: List of ToolCall objects
|
|
80
|
+
context: Dictionary containing repo_root, console, chat_manager, etc.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Tuple of (results in call_index order, had_any_errors)
|
|
84
|
+
"""
|
|
85
|
+
if len(tool_calls) == 1:
|
|
86
|
+
# Fast path for single tool (no threading overhead)
|
|
87
|
+
return self._execute_single(tool_calls[0], context)
|
|
88
|
+
|
|
89
|
+
# Parallel execution for multiple tools
|
|
90
|
+
results = []
|
|
91
|
+
|
|
92
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
93
|
+
max_workers=min(self.max_workers, len(tool_calls))
|
|
94
|
+
) as executor:
|
|
95
|
+
# Submit all tool executions
|
|
96
|
+
future_to_call = {
|
|
97
|
+
executor.submit(
|
|
98
|
+
self._execute_single_tool,
|
|
99
|
+
tool_call,
|
|
100
|
+
context
|
|
101
|
+
): tool_call
|
|
102
|
+
for tool_call in tool_calls
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Collect results as they complete
|
|
106
|
+
for future in concurrent.futures.as_completed(future_to_call):
|
|
107
|
+
tool_call = future_to_call[future]
|
|
108
|
+
try:
|
|
109
|
+
result = future.result()
|
|
110
|
+
results.append(result)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
results.append(ToolResult(
|
|
113
|
+
tool_id=tool_call.tool_id,
|
|
114
|
+
call_index=tool_call.call_index,
|
|
115
|
+
success=False,
|
|
116
|
+
result="",
|
|
117
|
+
error=str(e)
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
# Sort by call_index to maintain order
|
|
121
|
+
results.sort(key=lambda r: r.call_index)
|
|
122
|
+
|
|
123
|
+
# Check for errors
|
|
124
|
+
had_errors = any(not r.success for r in results)
|
|
125
|
+
|
|
126
|
+
return results, had_errors
|
|
127
|
+
|
|
128
|
+
def _execute_single(
|
|
129
|
+
self,
|
|
130
|
+
tool_call: ToolCall,
|
|
131
|
+
context: dict
|
|
132
|
+
) -> Tuple[List[ToolResult], bool]:
|
|
133
|
+
"""Execute single tool (fast path, no threading overhead).
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
tool_call: Single ToolCall to execute
|
|
137
|
+
context: Execution context dict
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Tuple of (single-element result list, had_errors)
|
|
141
|
+
"""
|
|
142
|
+
result = self._execute_single_tool(tool_call, context)
|
|
143
|
+
return [result], not result.success
|
|
144
|
+
|
|
145
|
+
def _execute_single_tool(
|
|
146
|
+
self,
|
|
147
|
+
tool_call: ToolCall,
|
|
148
|
+
context: dict
|
|
149
|
+
) -> ToolResult:
|
|
150
|
+
"""Execute a single tool call with error handling.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
tool_call: ToolCall to execute
|
|
154
|
+
context: Execution context dict
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
ToolResult with execution outcome
|
|
158
|
+
"""
|
|
159
|
+
tool = ToolRegistry.get(tool_call.function_name)
|
|
160
|
+
if tool:
|
|
161
|
+
try:
|
|
162
|
+
# For tools requiring approval, return preview without executing
|
|
163
|
+
# The orchestrator will handle the approval workflow
|
|
164
|
+
if tool.requires_approval:
|
|
165
|
+
# Build context from context dict
|
|
166
|
+
cm = context.get('chat_manager')
|
|
167
|
+
|
|
168
|
+
tool_context = build_context(
|
|
169
|
+
repo_root=context.get('repo_root'),
|
|
170
|
+
console=context.get('console'),
|
|
171
|
+
gitignore_spec=context.get('gitignore_spec'),
|
|
172
|
+
debug_mode=context.get('debug_mode', False),
|
|
173
|
+
chat_manager=cm,
|
|
174
|
+
rg_exe_path=context.get('rg_exe_path'),
|
|
175
|
+
panel_updater=context.get('panel_updater'),
|
|
176
|
+
vault_root=context.get('vault_root')
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# For edit_file: return preview (orchestrator handles approval)
|
|
180
|
+
if tool_call.function_name == "edit_file":
|
|
181
|
+
tool_result = tool.execute(tool_call.arguments, tool_context)
|
|
182
|
+
return ToolResult(
|
|
183
|
+
tool_id=tool_call.tool_id,
|
|
184
|
+
call_index=tool_call.call_index,
|
|
185
|
+
success=True,
|
|
186
|
+
result=tool_result,
|
|
187
|
+
should_exit=False,
|
|
188
|
+
requires_approval=True # Flag for orchestrator to handle
|
|
189
|
+
)
|
|
190
|
+
# Other approval-required tools would go here in the future
|
|
191
|
+
|
|
192
|
+
# Normal execution for tools without approval
|
|
193
|
+
# Build context from context dict
|
|
194
|
+
cm = context.get('chat_manager')
|
|
195
|
+
|
|
196
|
+
tool_context = build_context(
|
|
197
|
+
repo_root=context.get('repo_root'),
|
|
198
|
+
console=context.get('console'),
|
|
199
|
+
gitignore_spec=context.get('gitignore_spec'),
|
|
200
|
+
debug_mode=context.get('debug_mode', False),
|
|
201
|
+
chat_manager=cm,
|
|
202
|
+
rg_exe_path=context.get('rg_exe_path'),
|
|
203
|
+
panel_updater=context.get('panel_updater'),
|
|
204
|
+
vault_root=context.get('vault_root')
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
tool_result = tool.execute(tool_call.arguments, tool_context)
|
|
208
|
+
return ToolResult(
|
|
209
|
+
tool_id=tool_call.tool_id,
|
|
210
|
+
call_index=tool_call.call_index,
|
|
211
|
+
success=True,
|
|
212
|
+
result=tool_result,
|
|
213
|
+
should_exit=False
|
|
214
|
+
)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
return ToolResult(
|
|
217
|
+
tool_id=tool_call.tool_id,
|
|
218
|
+
call_index=tool_call.call_index,
|
|
219
|
+
success=False,
|
|
220
|
+
result="",
|
|
221
|
+
error=f"Error executing tool '{tool_call.function_name}': {str(e)}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Tool not found in registry
|
|
225
|
+
return ToolResult(
|
|
226
|
+
tool_id=tool_call.tool_id,
|
|
227
|
+
call_index=tool_call.call_index,
|
|
228
|
+
success=False,
|
|
229
|
+
result="",
|
|
230
|
+
error=f"Unknown tool '{tool_call.function_name}'"
|
|
231
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Path resolution and validation utilities.
|
|
2
|
+
|
|
3
|
+
This module provides centralized path validation and resolution logic,
|
|
4
|
+
ensuring consistent behavior across all tools that work with file paths.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from .file_helpers import _is_reserved_windows_name
|
|
13
|
+
|
|
14
|
+
# Performance metrics tracking
|
|
15
|
+
_path_resolution_times = []
|
|
16
|
+
_path_validation_errors = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PathResolver:
|
|
20
|
+
"""Centralized path resolution and validation.
|
|
21
|
+
|
|
22
|
+
This class provides a single source of truth for path validation logic,
|
|
23
|
+
including Windows-specific checks, path resolution, and gitignore filtering.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
resolver = PathResolver(repo_root=Path("/project"), gitignore_spec=spec)
|
|
27
|
+
resolved_path, error = resolver.resolve_and_validate("src/file.py")
|
|
28
|
+
if error:
|
|
29
|
+
return f"Error: {error}"
|
|
30
|
+
# Use resolved_path...
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
repo_root: Path,
|
|
36
|
+
gitignore_spec = None,
|
|
37
|
+
vault_path: Path = None
|
|
38
|
+
):
|
|
39
|
+
"""Initialize the path resolver.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
repo_root: Repository root directory for resolving relative paths
|
|
43
|
+
gitignore_spec: Optional PathSpec for .gitignore filtering
|
|
44
|
+
vault_path: Optional Obsidian vault root (allowed as second base path)
|
|
45
|
+
"""
|
|
46
|
+
self.repo_root = repo_root
|
|
47
|
+
self.vault_path = vault_path
|
|
48
|
+
self.gitignore_spec = gitignore_spec
|
|
49
|
+
|
|
50
|
+
def resolve_and_validate(
|
|
51
|
+
self,
|
|
52
|
+
path_str: str,
|
|
53
|
+
check_gitignore: bool = True,
|
|
54
|
+
must_exist: bool = True,
|
|
55
|
+
must_be_file: bool = False,
|
|
56
|
+
must_be_dir: bool = False,
|
|
57
|
+
enforce_boundary: bool = False,
|
|
58
|
+
) -> Tuple[Optional[Path], Optional[str]]:
|
|
59
|
+
"""Validate and resolve a path string.
|
|
60
|
+
|
|
61
|
+
Performs comprehensive validation including:
|
|
62
|
+
- Windows filename validation (invalid chars, reserved names)
|
|
63
|
+
- Path resolution (absolute vs relative)
|
|
64
|
+
- Gitignore filtering (optional, within repo only)
|
|
65
|
+
- Path existence check (optional)
|
|
66
|
+
- Type validation (file/directory, optional)
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
path_str: Path string to validate
|
|
70
|
+
check_gitignore: Whether to apply gitignore filtering
|
|
71
|
+
must_exist: Whether the path must exist on disk
|
|
72
|
+
must_be_file: Whether the path must be a file (requires must_exist=True)
|
|
73
|
+
must_be_dir: Whether the path must be a directory (requires must_exist=True)
|
|
74
|
+
enforce_boundary: Whether to restrict paths to repo_root or vault_path (default: False)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Tuple of (resolved_path, error_message)
|
|
78
|
+
- resolved_path: Path object if valid, None if invalid
|
|
79
|
+
- error_message: None if valid, error description if invalid
|
|
80
|
+
"""
|
|
81
|
+
start_time = time.time()
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
# Step 1: Validate filename for Windows-specific issues
|
|
85
|
+
if os.name == 'nt': # Windows-specific validation
|
|
86
|
+
# Check for invalid characters
|
|
87
|
+
invalid_chars = '<>:"|?*[]{}"\n\r\t'
|
|
88
|
+
if any(char in path_str for char in invalid_chars):
|
|
89
|
+
elapsed = time.time() - start_time
|
|
90
|
+
_track_validation_error("invalid_chars")
|
|
91
|
+
_path_resolution_times.append(elapsed)
|
|
92
|
+
return None, f"Filename contains invalid characters: {invalid_chars}"
|
|
93
|
+
|
|
94
|
+
# Check for reserved device names
|
|
95
|
+
filename = Path(path_str).name
|
|
96
|
+
if _is_reserved_windows_name(filename):
|
|
97
|
+
elapsed = time.time() - start_time
|
|
98
|
+
_track_validation_error("reserved_name")
|
|
99
|
+
_path_resolution_times.append(elapsed)
|
|
100
|
+
return None, f"Filename is a reserved Windows device name: {filename}"
|
|
101
|
+
|
|
102
|
+
# Step 2: Resolve the path
|
|
103
|
+
path = Path(path_str)
|
|
104
|
+
if not path.is_absolute():
|
|
105
|
+
path = self.repo_root / path
|
|
106
|
+
|
|
107
|
+
# Resolve to absolute path (handles .. and symlinks)
|
|
108
|
+
path = path.resolve()
|
|
109
|
+
|
|
110
|
+
# Step 2b: Security boundary — path must be within repo_root or vault_path
|
|
111
|
+
if enforce_boundary:
|
|
112
|
+
try:
|
|
113
|
+
path.relative_to(self.repo_root)
|
|
114
|
+
except ValueError:
|
|
115
|
+
if self.vault_path is not None:
|
|
116
|
+
try:
|
|
117
|
+
path.relative_to(self.vault_path)
|
|
118
|
+
except ValueError:
|
|
119
|
+
elapsed = time.time() - start_time
|
|
120
|
+
_track_validation_error("outside_allowed_roots")
|
|
121
|
+
_path_resolution_times.append(elapsed)
|
|
122
|
+
return None, f"Path is outside allowed directories: {path_str}"
|
|
123
|
+
else:
|
|
124
|
+
elapsed = time.time() - start_time
|
|
125
|
+
_track_validation_error("outside_repo")
|
|
126
|
+
_path_resolution_times.append(elapsed)
|
|
127
|
+
return None, f"Path is outside repository: {path_str}"
|
|
128
|
+
|
|
129
|
+
# Step 3: Check existence if required
|
|
130
|
+
if must_exist:
|
|
131
|
+
if not path.exists():
|
|
132
|
+
elapsed = time.time() - start_time
|
|
133
|
+
_track_validation_error("not_found")
|
|
134
|
+
_path_resolution_times.append(elapsed)
|
|
135
|
+
return None, f"Path not found: {path_str}"
|
|
136
|
+
|
|
137
|
+
# Step 4: Validate type if required
|
|
138
|
+
if must_be_file and not path.is_file():
|
|
139
|
+
elapsed = time.time() - start_time
|
|
140
|
+
_track_validation_error("not_a_file")
|
|
141
|
+
_path_resolution_times.append(elapsed)
|
|
142
|
+
return None, f"Path is not a file: {path_str}"
|
|
143
|
+
|
|
144
|
+
if must_be_dir and not path.is_dir():
|
|
145
|
+
elapsed = time.time() - start_time
|
|
146
|
+
_track_validation_error("not_a_dir")
|
|
147
|
+
_path_resolution_times.append(elapsed)
|
|
148
|
+
return None, f"Path is not a directory: {path_str}"
|
|
149
|
+
|
|
150
|
+
# Step 5: Check gitignore if requested and within repo
|
|
151
|
+
if check_gitignore and self.gitignore_spec is not None:
|
|
152
|
+
# Only check gitignore for paths within the repo
|
|
153
|
+
try:
|
|
154
|
+
path.relative_to(self.repo_root)
|
|
155
|
+
from .file_helpers import _is_ignored_cached, _register_gitignore_spec
|
|
156
|
+
|
|
157
|
+
# Full gitignore check
|
|
158
|
+
spec_key = _register_gitignore_spec(self.gitignore_spec)
|
|
159
|
+
if _is_ignored_cached(str(path), str(self.repo_root), spec_key):
|
|
160
|
+
elapsed = time.time() - start_time
|
|
161
|
+
_track_validation_error("gitignore_filtered")
|
|
162
|
+
_path_resolution_times.append(elapsed)
|
|
163
|
+
return None, f"Path is excluded by .gitignore: {path_str}"
|
|
164
|
+
except ValueError:
|
|
165
|
+
# Path is outside repo, skip gitignore check
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
# Success - track timing
|
|
169
|
+
elapsed = time.time() - start_time
|
|
170
|
+
_path_resolution_times.append(elapsed)
|
|
171
|
+
return path, None
|
|
172
|
+
|
|
173
|
+
except OSError as e:
|
|
174
|
+
elapsed = time.time() - start_time
|
|
175
|
+
_track_validation_error("os_error")
|
|
176
|
+
_path_resolution_times.append(elapsed)
|
|
177
|
+
return None, f"Error accessing path '{path_str}': {e}"
|
|
178
|
+
except Exception as e:
|
|
179
|
+
elapsed = time.time() - start_time
|
|
180
|
+
_track_validation_error("unexpected_error")
|
|
181
|
+
_path_resolution_times.append(elapsed)
|
|
182
|
+
return None, f"Unexpected error resolving path '{path_str}': {e}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _track_validation_error(error_type: str):
|
|
186
|
+
"""Track validation errors for metrics.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
error_type: Type of validation error
|
|
190
|
+
"""
|
|
191
|
+
_path_validation_errors[error_type] = _path_validation_errors.get(error_type, 0) + 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_path_resolver_metrics() -> dict:
|
|
195
|
+
"""Get performance metrics for path resolution operations.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Dictionary with metrics:
|
|
199
|
+
- total_resolutions: Total number of path resolutions
|
|
200
|
+
- avg_resolution_time: Average resolution time in seconds
|
|
201
|
+
- max_resolution_time: Maximum resolution time
|
|
202
|
+
- min_resolution_time: Minimum resolution time
|
|
203
|
+
- validation_errors: Dict of error types and counts
|
|
204
|
+
"""
|
|
205
|
+
if not _path_resolution_times:
|
|
206
|
+
return {
|
|
207
|
+
"total_resolutions": 0,
|
|
208
|
+
"avg_resolution_time": 0,
|
|
209
|
+
"max_resolution_time": 0,
|
|
210
|
+
"min_resolution_time": 0,
|
|
211
|
+
"validation_errors": _path_validation_errors.copy()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
"total_resolutions": len(_path_resolution_times),
|
|
216
|
+
"avg_resolution_time": sum(_path_resolution_times) / len(_path_resolution_times),
|
|
217
|
+
"max_resolution_time": max(_path_resolution_times),
|
|
218
|
+
"min_resolution_time": min(_path_resolution_times),
|
|
219
|
+
"validation_errors": _path_validation_errors.copy()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def clear_path_resolver_metrics():
|
|
224
|
+
"""Clear all accumulated metrics for testing or monitoring reset."""
|
|
225
|
+
_path_resolution_times.clear()
|
|
226
|
+
_path_validation_errors.clear()
|