bone-agent 1.4.0 → 2.0.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/bin/bone.js +39 -0
- package/package.json +25 -39
- package/LICENSE +0 -21
- package/README.md +0 -201
- package/bin/npm-wrapper.js +0 -235
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +0 -144
- package/prompts/main/ask_questions.md +0 -31
- package/prompts/main/batch_independent_calls.md +0 -5
- package/prompts/main/casual_interactions.md +0 -11
- package/prompts/main/code_references.md +0 -8
- package/prompts/main/communication_style.md +0 -12
- package/prompts/main/context_reliability.md +0 -12
- package/prompts/main/conversational_tool_calling.md +0 -15
- package/prompts/main/dream.md +0 -50
- package/prompts/main/editing_pattern.md +0 -13
- package/prompts/main/error_handling.md +0 -6
- package/prompts/main/exploration_pattern.md +0 -21
- package/prompts/main/intro.md +0 -1
- package/prompts/main/obsidian.md +0 -16
- package/prompts/main/obsidian_project.md +0 -79
- package/prompts/main/professional_objectivity.md +0 -3
- package/prompts/main/skills.md +0 -3
- package/prompts/main/targeted_searching.md +0 -10
- package/prompts/main/task_lists_pattern.md +0 -8
- package/prompts/main/temp_folder.md +0 -9
- package/prompts/main/think_before_acting.md +0 -10
- package/prompts/main/tone_and_style.md +0 -4
- package/prompts/main/tool_preferences.md +0 -24
- package/prompts/main/trust_subagent_context.md +0 -21
- package/prompts/main/when_to_use_sub_agent.md +0 -7
- package/prompts/micro/ask_questions.md +0 -1
- package/prompts/micro/batch_independent_calls.md +0 -1
- package/prompts/micro/casual_interactions.md +0 -1
- package/prompts/micro/code_references.md +0 -1
- package/prompts/micro/communication_style.md +0 -1
- package/prompts/micro/context_reliability.md +0 -1
- package/prompts/micro/conversational_tool_calling.md +0 -1
- package/prompts/micro/editing_pattern.md +0 -1
- package/prompts/micro/error_handling.md +0 -1
- package/prompts/micro/exploration_pattern.md +0 -1
- package/prompts/micro/intro.md +0 -1
- package/prompts/micro/obsidian.md +0 -4
- package/prompts/micro/obsidian_project.md +0 -5
- package/prompts/micro/professional_objectivity.md +0 -1
- package/prompts/micro/skills.md +0 -1
- package/prompts/micro/targeted_searching.md +0 -1
- package/prompts/micro/task_lists_pattern.md +0 -1
- package/prompts/micro/temp_folder.md +0 -1
- package/prompts/micro/think_before_acting.md +0 -5
- package/prompts/micro/tone_and_style.md +0 -1
- package/prompts/micro/tool_preferences.md +0 -1
- package/prompts/micro/trust_subagent_context.md +0 -1
- package/prompts/micro/when_to_use_sub_agent.md +0 -1
- package/requirements.txt +0 -9
- package/src/__init__.py +0 -11
- package/src/core/__init__.py +0 -1
- package/src/core/agentic.py +0 -1085
- package/src/core/chat_manager.py +0 -1577
- package/src/core/config_manager.py +0 -260
- package/src/core/cron.py +0 -578
- package/src/core/cron_allowlist.py +0 -118
- package/src/core/memory.py +0 -145
- package/src/core/metadata.py +0 -75
- package/src/core/retry.py +0 -71
- package/src/core/skills.py +0 -463
- package/src/core/sub_agent.py +0 -376
- package/src/core/tool_approval.py +0 -220
- package/src/core/tool_feedback.py +0 -789
- package/src/exceptions.py +0 -79
- package/src/llm/__init__.py +0 -1
- package/src/llm/client.py +0 -176
- package/src/llm/codex_provider.py +0 -350
- package/src/llm/config.py +0 -536
- package/src/llm/prompts.py +0 -494
- package/src/llm/providers.py +0 -438
- package/src/llm/streaming.py +0 -163
- package/src/llm/token_tracker.py +0 -399
- package/src/tools/__init__.py +0 -151
- package/src/tools/constants.py +0 -59
- package/src/tools/create_file.py +0 -136
- package/src/tools/directory.py +0 -389
- package/src/tools/edit.py +0 -549
- package/src/tools/file_reader.py +0 -322
- package/src/tools/helpers/__init__.py +0 -99
- package/src/tools/helpers/base.py +0 -599
- package/src/tools/helpers/converters.py +0 -44
- package/src/tools/helpers/file_helpers.py +0 -189
- package/src/tools/helpers/formatters.py +0 -411
- package/src/tools/helpers/loader.py +0 -145
- package/src/tools/helpers/parallel_executor.py +0 -231
- package/src/tools/helpers/path_resolver.py +0 -283
- package/src/tools/helpers/plugin_manifest.py +0 -185
- package/src/tools/obsidian.py +0 -96
- package/src/tools/review_sub_agent.py +0 -190
- package/src/tools/rg_search.py +0 -477
- package/src/tools/search_plugins.py +0 -177
- package/src/tools/select_option.py +0 -600
- package/src/tools/shell.py +0 -302
- package/src/tools/sub_agent.py +0 -139
- package/src/tools/task_list.py +0 -269
- package/src/tools/web_search.py +0 -61
- package/src/ui/__init__.py +0 -1
- package/src/ui/banner.py +0 -87
- package/src/ui/commands.py +0 -3131
- package/src/ui/displays.py +0 -239
- package/src/ui/loader.py +0 -284
- package/src/ui/main.py +0 -643
- package/src/ui/prompt_utils.py +0 -113
- package/src/ui/setting_selector.py +0 -590
- package/src/ui/setup_wizard.py +0 -294
- package/src/ui/sub_agent_panel.py +0 -234
- package/src/ui/tool_confirmation.py +0 -226
- package/src/utils/__init__.py +0 -1
- package/src/utils/citation_parser.py +0 -199
- package/src/utils/editor.py +0 -207
- package/src/utils/gitignore_filter.py +0 -149
- package/src/utils/logger.py +0 -254
- package/src/utils/paths.py +0 -30
- package/src/utils/result_parsers.py +0 -108
- package/src/utils/safe_commands.py +0 -243
- package/src/utils/settings.py +0 -195
- package/src/utils/user_message_logger.py +0 -120
- package/src/utils/validation.py +0 -201
- package/src/utils/web_search.py +0 -173
|
@@ -1,145 +0,0 @@
|
|
|
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 external tool modules (not in src/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 discover_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 list_registered_tools() -> List[str]:
|
|
137
|
-
"""List names of all registered tools.
|
|
138
|
-
|
|
139
|
-
Returns:
|
|
140
|
-
List of tool names
|
|
141
|
-
"""
|
|
142
|
-
return [tool.name for tool in ToolRegistry.get_all()]
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
@@ -1,231 +0,0 @@
|
|
|
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
|
-
)
|
|
@@ -1,283 +0,0 @@
|
|
|
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
|
-
# Session-scoped filesystem access flag
|
|
19
|
-
# When True, boundary enforcement is skipped — agent can access any path.
|
|
20
|
-
_full_filesystem_access = False
|
|
21
|
-
|
|
22
|
-
# Boundary error prefixes — used by both resolve_and_validate() and is_boundary_error().
|
|
23
|
-
_BOUNDARY_ERROR_PREFIXES = (
|
|
24
|
-
"Path is outside allowed directories:",
|
|
25
|
-
"Path is outside repository:",
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def has_full_filesystem_access() -> bool:
|
|
30
|
-
"""Check if full filesystem access has been granted this session."""
|
|
31
|
-
return _full_filesystem_access
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def set_full_filesystem_access(enabled: bool):
|
|
35
|
-
"""Grant or revoke full filesystem access for this session."""
|
|
36
|
-
global _full_filesystem_access
|
|
37
|
-
_full_filesystem_access = enabled
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _boundary_error_line(result: str) -> Optional[tuple[str, str]]:
|
|
41
|
-
"""Return the boundary prefix and message line from a tool result."""
|
|
42
|
-
if not result:
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
for line in result.splitlines():
|
|
46
|
-
stripped = line.strip()
|
|
47
|
-
if stripped.lower().startswith("error:"):
|
|
48
|
-
stripped = stripped[len("error:"):].strip()
|
|
49
|
-
for prefix in _BOUNDARY_ERROR_PREFIXES:
|
|
50
|
-
if stripped.startswith(prefix):
|
|
51
|
-
return prefix, stripped
|
|
52
|
-
return None
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def is_boundary_error(result: str) -> bool:
|
|
56
|
-
"""Check if a tool result is a path boundary violation."""
|
|
57
|
-
return _boundary_error_line(result) is not None
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def extract_boundary_path(result: str) -> str:
|
|
61
|
-
"""Extract the offending path from a boundary error message."""
|
|
62
|
-
boundary_line = _boundary_error_line(result)
|
|
63
|
-
if not boundary_line:
|
|
64
|
-
return ""
|
|
65
|
-
|
|
66
|
-
prefix, line = boundary_line
|
|
67
|
-
return line[len(prefix):].strip()
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class PathResolver:
|
|
71
|
-
"""Centralized path resolution and validation.
|
|
72
|
-
|
|
73
|
-
This class provides a single source of truth for path validation logic,
|
|
74
|
-
including Windows-specific checks, path resolution, and gitignore filtering.
|
|
75
|
-
|
|
76
|
-
Example:
|
|
77
|
-
resolver = PathResolver(repo_root=Path("/project"), gitignore_spec=spec)
|
|
78
|
-
resolved_path, error = resolver.resolve_and_validate("src/file.py")
|
|
79
|
-
if error:
|
|
80
|
-
return f"Error: {error}"
|
|
81
|
-
# Use resolved_path...
|
|
82
|
-
"""
|
|
83
|
-
|
|
84
|
-
def __init__(
|
|
85
|
-
self,
|
|
86
|
-
repo_root: Path,
|
|
87
|
-
gitignore_spec = None,
|
|
88
|
-
vault_path: Path = None
|
|
89
|
-
):
|
|
90
|
-
"""Initialize the path resolver.
|
|
91
|
-
|
|
92
|
-
Args:
|
|
93
|
-
repo_root: Repository root directory for resolving relative paths
|
|
94
|
-
gitignore_spec: Optional PathSpec for .gitignore filtering
|
|
95
|
-
vault_path: Optional Obsidian vault root (allowed as second base path)
|
|
96
|
-
"""
|
|
97
|
-
self.repo_root = repo_root
|
|
98
|
-
self.vault_path = vault_path
|
|
99
|
-
self.gitignore_spec = gitignore_spec
|
|
100
|
-
|
|
101
|
-
def resolve_and_validate(
|
|
102
|
-
self,
|
|
103
|
-
path_str: str,
|
|
104
|
-
check_gitignore: bool = True,
|
|
105
|
-
must_exist: bool = True,
|
|
106
|
-
must_be_file: bool = False,
|
|
107
|
-
must_be_dir: bool = False,
|
|
108
|
-
enforce_boundary: bool = False,
|
|
109
|
-
) -> Tuple[Optional[Path], Optional[str]]:
|
|
110
|
-
"""Validate and resolve a path string.
|
|
111
|
-
|
|
112
|
-
Performs comprehensive validation including:
|
|
113
|
-
- Windows filename validation (invalid chars, reserved names)
|
|
114
|
-
- Path resolution (absolute vs relative)
|
|
115
|
-
- Gitignore filtering (optional, within repo only)
|
|
116
|
-
- Path existence check (optional)
|
|
117
|
-
- Type validation (file/directory, optional)
|
|
118
|
-
|
|
119
|
-
Args:
|
|
120
|
-
path_str: Path string to validate
|
|
121
|
-
check_gitignore: Whether to apply gitignore filtering
|
|
122
|
-
must_exist: Whether the path must exist on disk
|
|
123
|
-
must_be_file: Whether the path must be a file (requires must_exist=True)
|
|
124
|
-
must_be_dir: Whether the path must be a directory (requires must_exist=True)
|
|
125
|
-
enforce_boundary: Whether to restrict paths to repo_root or vault_path (default: False)
|
|
126
|
-
|
|
127
|
-
Returns:
|
|
128
|
-
Tuple of (resolved_path, error_message)
|
|
129
|
-
- resolved_path: Path object if valid, None if invalid
|
|
130
|
-
- error_message: None if valid, error description if invalid
|
|
131
|
-
"""
|
|
132
|
-
start_time = time.time()
|
|
133
|
-
|
|
134
|
-
try:
|
|
135
|
-
# Step 1: Validate filename for Windows-specific issues
|
|
136
|
-
if os.name == 'nt': # Windows-specific validation
|
|
137
|
-
# Check for invalid characters
|
|
138
|
-
invalid_chars = '<>:"|?*[]{}"\n\r\t'
|
|
139
|
-
if any(char in path_str for char in invalid_chars):
|
|
140
|
-
elapsed = time.time() - start_time
|
|
141
|
-
_track_validation_error("invalid_chars")
|
|
142
|
-
_path_resolution_times.append(elapsed)
|
|
143
|
-
return None, f"Filename contains invalid characters: {invalid_chars}"
|
|
144
|
-
|
|
145
|
-
# Check for reserved device names
|
|
146
|
-
filename = Path(path_str).name
|
|
147
|
-
if _is_reserved_windows_name(filename):
|
|
148
|
-
elapsed = time.time() - start_time
|
|
149
|
-
_track_validation_error("reserved_name")
|
|
150
|
-
_path_resolution_times.append(elapsed)
|
|
151
|
-
return None, f"Filename is a reserved Windows device name: {filename}"
|
|
152
|
-
|
|
153
|
-
# Step 2: Resolve the path
|
|
154
|
-
path = Path(path_str)
|
|
155
|
-
if not path.is_absolute():
|
|
156
|
-
path = self.repo_root / path
|
|
157
|
-
|
|
158
|
-
# Resolve to absolute path (handles .. and symlinks)
|
|
159
|
-
path = path.resolve()
|
|
160
|
-
|
|
161
|
-
# Step 2b: Security boundary — path must be within repo_root, vault_path,
|
|
162
|
-
# or the agent's own data directory (~/.bone/).
|
|
163
|
-
if enforce_boundary and not _full_filesystem_access:
|
|
164
|
-
try:
|
|
165
|
-
path.relative_to(self.repo_root)
|
|
166
|
-
except ValueError:
|
|
167
|
-
# Check ~/.bone/ — agent data dir is always accessible
|
|
168
|
-
bone_root = Path.home() / ".bone"
|
|
169
|
-
try:
|
|
170
|
-
path.relative_to(bone_root)
|
|
171
|
-
except ValueError:
|
|
172
|
-
if self.vault_path is not None:
|
|
173
|
-
try:
|
|
174
|
-
path.relative_to(self.vault_path)
|
|
175
|
-
except ValueError:
|
|
176
|
-
elapsed = time.time() - start_time
|
|
177
|
-
_track_validation_error("outside_allowed_roots")
|
|
178
|
-
_path_resolution_times.append(elapsed)
|
|
179
|
-
return None, f"{_BOUNDARY_ERROR_PREFIXES[0]} {path_str}"
|
|
180
|
-
else:
|
|
181
|
-
elapsed = time.time() - start_time
|
|
182
|
-
_track_validation_error("outside_repo")
|
|
183
|
-
_path_resolution_times.append(elapsed)
|
|
184
|
-
return None, f"{_BOUNDARY_ERROR_PREFIXES[1]} {path_str}"
|
|
185
|
-
|
|
186
|
-
# Step 3: Check existence if required
|
|
187
|
-
if must_exist:
|
|
188
|
-
if not path.exists():
|
|
189
|
-
elapsed = time.time() - start_time
|
|
190
|
-
_track_validation_error("not_found")
|
|
191
|
-
_path_resolution_times.append(elapsed)
|
|
192
|
-
return None, f"Path not found: {path_str}"
|
|
193
|
-
|
|
194
|
-
# Step 4: Validate type if required
|
|
195
|
-
if must_be_file and not path.is_file():
|
|
196
|
-
elapsed = time.time() - start_time
|
|
197
|
-
_track_validation_error("not_a_file")
|
|
198
|
-
_path_resolution_times.append(elapsed)
|
|
199
|
-
return None, f"Path is not a file: {path_str}"
|
|
200
|
-
|
|
201
|
-
if must_be_dir and not path.is_dir():
|
|
202
|
-
elapsed = time.time() - start_time
|
|
203
|
-
_track_validation_error("not_a_dir")
|
|
204
|
-
_path_resolution_times.append(elapsed)
|
|
205
|
-
return None, f"Path is not a directory: {path_str}"
|
|
206
|
-
|
|
207
|
-
# Step 5: Check gitignore if requested and within repo
|
|
208
|
-
if check_gitignore and self.gitignore_spec is not None:
|
|
209
|
-
# Only check gitignore for paths within the repo
|
|
210
|
-
try:
|
|
211
|
-
path.relative_to(self.repo_root)
|
|
212
|
-
from .file_helpers import _is_ignored_cached, _register_gitignore_spec
|
|
213
|
-
|
|
214
|
-
# Full gitignore check
|
|
215
|
-
spec_key = _register_gitignore_spec(self.gitignore_spec)
|
|
216
|
-
if _is_ignored_cached(str(path), str(self.repo_root), spec_key):
|
|
217
|
-
elapsed = time.time() - start_time
|
|
218
|
-
_track_validation_error("gitignore_filtered")
|
|
219
|
-
_path_resolution_times.append(elapsed)
|
|
220
|
-
return None, f"Path is excluded by .gitignore: {path_str}"
|
|
221
|
-
except ValueError:
|
|
222
|
-
# Path is outside repo, skip gitignore check
|
|
223
|
-
pass
|
|
224
|
-
|
|
225
|
-
# Success - track timing
|
|
226
|
-
elapsed = time.time() - start_time
|
|
227
|
-
_path_resolution_times.append(elapsed)
|
|
228
|
-
return path, None
|
|
229
|
-
|
|
230
|
-
except OSError as e:
|
|
231
|
-
elapsed = time.time() - start_time
|
|
232
|
-
_track_validation_error("os_error")
|
|
233
|
-
_path_resolution_times.append(elapsed)
|
|
234
|
-
return None, f"Error accessing path '{path_str}': {e}"
|
|
235
|
-
except Exception as e:
|
|
236
|
-
elapsed = time.time() - start_time
|
|
237
|
-
_track_validation_error("unexpected_error")
|
|
238
|
-
_path_resolution_times.append(elapsed)
|
|
239
|
-
return None, f"Unexpected error resolving path '{path_str}': {e}"
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def _track_validation_error(error_type: str):
|
|
243
|
-
"""Track validation errors for metrics.
|
|
244
|
-
|
|
245
|
-
Args:
|
|
246
|
-
error_type: Type of validation error
|
|
247
|
-
"""
|
|
248
|
-
_path_validation_errors[error_type] = _path_validation_errors.get(error_type, 0) + 1
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def get_path_resolver_metrics() -> dict:
|
|
252
|
-
"""Get performance metrics for path resolution operations.
|
|
253
|
-
|
|
254
|
-
Returns:
|
|
255
|
-
Dictionary with metrics:
|
|
256
|
-
- total_resolutions: Total number of path resolutions
|
|
257
|
-
- avg_resolution_time: Average resolution time in seconds
|
|
258
|
-
- max_resolution_time: Maximum resolution time
|
|
259
|
-
- min_resolution_time: Minimum resolution time
|
|
260
|
-
- validation_errors: Dict of error types and counts
|
|
261
|
-
"""
|
|
262
|
-
if not _path_resolution_times:
|
|
263
|
-
return {
|
|
264
|
-
"total_resolutions": 0,
|
|
265
|
-
"avg_resolution_time": 0,
|
|
266
|
-
"max_resolution_time": 0,
|
|
267
|
-
"min_resolution_time": 0,
|
|
268
|
-
"validation_errors": _path_validation_errors.copy()
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
"total_resolutions": len(_path_resolution_times),
|
|
273
|
-
"avg_resolution_time": sum(_path_resolution_times) / len(_path_resolution_times),
|
|
274
|
-
"max_resolution_time": max(_path_resolution_times),
|
|
275
|
-
"min_resolution_time": min(_path_resolution_times),
|
|
276
|
-
"validation_errors": _path_validation_errors.copy()
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def clear_path_resolver_metrics():
|
|
281
|
-
"""Clear all accumulated metrics for testing or monitoring reset."""
|
|
282
|
-
_path_resolution_times.clear()
|
|
283
|
-
_path_validation_errors.clear()
|