bone-agent 1.3.3 → 1.4.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/README.md +17 -0
- package/config.yaml.example +5 -2
- package/package.json +1 -1
- package/prompts/main/communication_style.md +1 -1
- package/prompts/main/dream.md +23 -9
- package/prompts/main/skills.md +3 -0
- package/prompts/micro/communication_style.md +1 -1
- package/prompts/micro/skills.md +1 -0
- package/src/core/agentic.py +138 -38
- package/src/core/chat_manager.py +19 -6
- package/src/core/config_manager.py +8 -1
- package/src/core/cron.py +0 -4
- package/src/core/metadata.py +75 -0
- package/src/core/skills.py +463 -0
- package/src/core/sub_agent.py +93 -43
- package/src/core/tool_feedback.py +87 -76
- package/src/llm/client.py +7 -2
- package/src/llm/codex_provider.py +350 -0
- package/src/llm/config.py +46 -2
- package/src/llm/prompts.py +12 -7
- package/src/llm/providers.py +3 -1
- package/src/llm/token_tracker.py +15 -0
- package/src/tools/__init__.py +24 -85
- package/src/tools/create_file.py +1 -1
- package/src/tools/directory.py +1 -1
- package/src/tools/edit.py +5 -1
- package/src/tools/file_reader.py +1 -1
- package/src/tools/helpers/__init__.py +1 -7
- package/src/tools/helpers/base.py +65 -16
- package/src/tools/helpers/loader.py +2 -88
- package/src/tools/helpers/path_resolver.py +54 -3
- package/src/tools/helpers/plugin_manifest.py +99 -70
- package/src/tools/review_sub_agent.py +2 -1
- package/src/tools/rg_search.py +24 -7
- package/src/tools/search_plugins.py +140 -72
- package/src/tools/shell.py +3 -3
- package/src/ui/commands.py +355 -33
- package/src/ui/displays.py +26 -1
- package/src/ui/main.py +0 -4
- package/src/ui/tool_confirmation.py +16 -5
- package/src/utils/editor.py +88 -39
- package/src/utils/settings.py +6 -2
- package/src/utils/validation.py +10 -0
package/src/tools/__init__.py
CHANGED
|
@@ -5,6 +5,8 @@ capabilities for the bone-agent AI assistant.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
8
10
|
|
|
9
11
|
_logger = logging.getLogger(__name__)
|
|
10
12
|
|
|
@@ -56,7 +58,7 @@ from . import sub_agent
|
|
|
56
58
|
from . import task_list
|
|
57
59
|
from . import select_option
|
|
58
60
|
|
|
59
|
-
# search_plugins — core meta-tool for
|
|
61
|
+
# search_plugins — core meta-tool for capability discovery and loading
|
|
60
62
|
from . import search_plugins
|
|
61
63
|
|
|
62
64
|
# Obsidian tools — conditional registration (register() pattern, NOT @tool at import)
|
|
@@ -99,8 +101,7 @@ __all__ = [
|
|
|
99
101
|
]
|
|
100
102
|
|
|
101
103
|
# =============================================================================
|
|
102
|
-
#
|
|
103
|
-
# This allows imports like: from tools.base import tool
|
|
104
|
+
# Re-export helpers at package level
|
|
104
105
|
# =============================================================================
|
|
105
106
|
from .helpers import (
|
|
106
107
|
ToolDefinition,
|
|
@@ -126,87 +127,25 @@ except Exception as e:
|
|
|
126
127
|
# Plugin modules with @tool(tier="plugin") register into the manifest
|
|
127
128
|
# and are only activated in ToolRegistry on-demand via search_plugins.
|
|
128
129
|
try:
|
|
129
|
-
from .helpers.loader import
|
|
130
|
-
|
|
131
|
-
except Exception as e:
|
|
132
|
-
_logger.debug("Failed to load plugin tools: %s", e)
|
|
130
|
+
from .helpers.loader import discover_tools
|
|
131
|
+
from .helpers.plugin_manifest import plugin_manifest
|
|
133
132
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# Create a synthetic 'base' module that re-exports from helpers
|
|
139
|
-
_base_module = ModuleType('tools.base')
|
|
140
|
-
_base_module.__dict__.update({
|
|
141
|
-
'ToolDefinition': ToolDefinition,
|
|
142
|
-
'ToolRegistry': ToolRegistry,
|
|
143
|
-
'tool': tool,
|
|
144
|
-
'build_context': build_context,
|
|
145
|
-
'get_tool_schemas': get_tool_schemas,
|
|
146
|
-
'TOOLS': TOOLS,
|
|
147
|
-
})
|
|
148
|
-
sys.modules['tools.base'] = _base_module
|
|
149
|
-
|
|
150
|
-
# Create synthetic modules for other helpers
|
|
151
|
-
_formatters_module = ModuleType('tools.formatters')
|
|
152
|
-
_formatters_module.__dict__.update({
|
|
153
|
-
'format_tool_result': format_tool_result,
|
|
154
|
-
'format_file_result': format_file_result,
|
|
155
|
-
'_build_diff': _build_diff,
|
|
156
|
-
'_detect_newline': _detect_newline,
|
|
157
|
-
})
|
|
158
|
-
sys.modules['tools.formatters'] = _formatters_module
|
|
159
|
-
|
|
160
|
-
_file_helpers_module = ModuleType('tools.file_helpers')
|
|
161
|
-
from .helpers.file_helpers import (
|
|
162
|
-
_is_reserved_windows_name,
|
|
163
|
-
GitignoreFilter,
|
|
164
|
-
)
|
|
165
|
-
_file_helpers_module.__dict__.update({
|
|
166
|
-
'_is_reserved_windows_name': _is_reserved_windows_name,
|
|
167
|
-
'GitignoreFilter': GitignoreFilter,
|
|
168
|
-
})
|
|
169
|
-
sys.modules['tools.file_helpers'] = _file_helpers_module
|
|
170
|
-
|
|
171
|
-
# Path resolver module
|
|
172
|
-
_path_resolver_module = ModuleType('tools.path_resolver')
|
|
173
|
-
from .helpers.path_resolver import PathResolver
|
|
174
|
-
_path_resolver_module.__dict__.update({
|
|
175
|
-
'PathResolver': PathResolver,
|
|
176
|
-
})
|
|
177
|
-
sys.modules['tools.path_resolver'] = _path_resolver_module
|
|
178
|
-
|
|
179
|
-
_converters_module = ModuleType('tools.converters')
|
|
180
|
-
from .helpers.converters import coerce_int, coerce_bool
|
|
181
|
-
_converters_module.__dict__.update({
|
|
182
|
-
'coerce_int': coerce_int,
|
|
183
|
-
'coerce_bool': coerce_bool,
|
|
184
|
-
})
|
|
185
|
-
sys.modules['tools.converters'] = _converters_module
|
|
186
|
-
|
|
187
|
-
_loader_module = ModuleType('tools.loader')
|
|
188
|
-
from .helpers.loader import (
|
|
189
|
-
discover_tools,
|
|
190
|
-
load_builtin_tools,
|
|
191
|
-
load_plugin_tools,
|
|
192
|
-
load_all_tools,
|
|
193
|
-
list_registered_tools,
|
|
194
|
-
)
|
|
195
|
-
_loader_module.__dict__.update({
|
|
196
|
-
'discover_tools': discover_tools,
|
|
197
|
-
'load_builtin_tools': load_builtin_tools,
|
|
198
|
-
'load_plugin_tools': load_plugin_tools,
|
|
199
|
-
'load_all_tools': load_all_tools,
|
|
200
|
-
'list_registered_tools': list_registered_tools,
|
|
201
|
-
})
|
|
202
|
-
sys.modules['tools.loader'] = _loader_module
|
|
203
|
-
|
|
204
|
-
_parallel_executor_module = ModuleType('tools.parallel_executor')
|
|
205
|
-
from .helpers.parallel_executor import ToolCall, ToolResult, ParallelToolExecutor
|
|
206
|
-
_parallel_executor_module.__dict__.update({
|
|
207
|
-
'ToolCall': ToolCall,
|
|
208
|
-
'ToolResult': ToolResult,
|
|
209
|
-
'ParallelToolExecutor': ParallelToolExecutor,
|
|
210
|
-
})
|
|
211
|
-
sys.modules['tools.parallel_executor'] = _parallel_executor_module
|
|
133
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
134
|
+
src_dir = str(repo_root / "src")
|
|
135
|
+
if src_dir not in sys.path:
|
|
136
|
+
sys.path.insert(0, src_dir)
|
|
212
137
|
|
|
138
|
+
discover_tools([str(repo_root / "tool_plugins")])
|
|
139
|
+
|
|
140
|
+
_logger.info(
|
|
141
|
+
"Plugin manifest: %s plugins available (categories: %s)",
|
|
142
|
+
plugin_manifest.plugin_count(),
|
|
143
|
+
plugin_manifest.get_categories(),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Re-apply disabled_tools now that plugins are in the manifest
|
|
147
|
+
for tool_name in tool_settings.disabled_tools:
|
|
148
|
+
if plugin_manifest.has_plugin(tool_name):
|
|
149
|
+
ToolRegistry.disable(tool_name)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
_logger.debug("Failed to load plugin tools: %s", e)
|
package/src/tools/create_file.py
CHANGED
package/src/tools/directory.py
CHANGED
|
@@ -139,7 +139,7 @@ def _validate_directory_path(
|
|
|
139
139
|
check_gitignore=False, # Directory listing shows everything
|
|
140
140
|
must_exist=True,
|
|
141
141
|
must_be_dir=True, # Must be a directory
|
|
142
|
-
enforce_boundary=
|
|
142
|
+
enforce_boundary=True
|
|
143
143
|
)
|
|
144
144
|
|
|
145
145
|
if error:
|
package/src/tools/edit.py
CHANGED
|
@@ -184,7 +184,7 @@ def _resolve_repo_path(path_str, repo_root, gitignore_spec=None, vault_root=None
|
|
|
184
184
|
check_gitignore=not skip_gitignore,
|
|
185
185
|
must_exist=True,
|
|
186
186
|
must_be_file=False, # We'll check this separately
|
|
187
|
-
enforce_boundary=
|
|
187
|
+
enforce_boundary=True,
|
|
188
188
|
)
|
|
189
189
|
|
|
190
190
|
if error:
|
|
@@ -455,6 +455,7 @@ def edit_file(
|
|
|
455
455
|
gitignore_spec = None,
|
|
456
456
|
context_lines: int = 3,
|
|
457
457
|
vault_root: str = None,
|
|
458
|
+
reason: str = None,
|
|
458
459
|
) -> str | Text:
|
|
459
460
|
"""Apply search/replace edit to a file.
|
|
460
461
|
|
|
@@ -468,6 +469,7 @@ def edit_file(
|
|
|
468
469
|
gitignore_spec: PathSpec for .gitignore filtering (injected by context)
|
|
469
470
|
context_lines: Number of context lines in diff
|
|
470
471
|
vault_root: Obsidian vault root path (injected by context)
|
|
472
|
+
reason: Brief explanation shown during confirmation
|
|
471
473
|
|
|
472
474
|
Returns:
|
|
473
475
|
Edit result with diff
|
|
@@ -484,6 +486,8 @@ def edit_file(
|
|
|
484
486
|
"replace": replace,
|
|
485
487
|
"context_lines": context_lines,
|
|
486
488
|
}
|
|
489
|
+
if reason:
|
|
490
|
+
arguments["reason"] = reason
|
|
487
491
|
|
|
488
492
|
# Preview edit (confirmation workflow handled by orchestrator)
|
|
489
493
|
try:
|
package/src/tools/file_reader.py
CHANGED
|
@@ -5,7 +5,7 @@ execution, and supporting utilities. It is not intended to be imported
|
|
|
5
5
|
directly by end users - import from tools/ instead for backward compatibility.
|
|
6
6
|
|
|
7
7
|
For creating custom tools, use:
|
|
8
|
-
from tools import tool # or from tools.base import tool
|
|
8
|
+
from tools import tool # or from tools.helpers.base import tool
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
# Core infrastructure
|
|
@@ -48,9 +48,6 @@ from .converters import (
|
|
|
48
48
|
# Tool loading and discovery
|
|
49
49
|
from .loader import (
|
|
50
50
|
discover_tools,
|
|
51
|
-
load_builtin_tools,
|
|
52
|
-
load_plugin_tools,
|
|
53
|
-
load_all_tools,
|
|
54
51
|
list_registered_tools,
|
|
55
52
|
)
|
|
56
53
|
|
|
@@ -91,9 +88,6 @@ __all__ = [
|
|
|
91
88
|
'coerce_bool',
|
|
92
89
|
# Tool loading and discovery
|
|
93
90
|
'discover_tools',
|
|
94
|
-
'load_builtin_tools',
|
|
95
|
-
'load_plugin_tools',
|
|
96
|
-
'load_all_tools',
|
|
97
91
|
'list_registered_tools',
|
|
98
92
|
# Plugin manifest
|
|
99
93
|
'PluginManifest',
|
|
@@ -92,13 +92,13 @@ class ToolDefinition:
|
|
|
92
92
|
|
|
93
93
|
# Named groups of related tools for bulk enable/disable
|
|
94
94
|
TOOL_GROUPS = {
|
|
95
|
-
"
|
|
96
|
-
"label": "
|
|
97
|
-
"tools": [
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
"core": {
|
|
96
|
+
"label": "Core",
|
|
97
|
+
"tools": [
|
|
98
|
+
"rg", "read_file", "create_file", "edit_file", "list_directory",
|
|
99
|
+
"execute_command", "web_search", "sub_agent", "search_plugins",
|
|
100
|
+
"select_option", "create_task_list", "complete_task", "show_task_list",
|
|
101
|
+
],
|
|
102
102
|
},
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -120,30 +120,42 @@ class ToolRegistry:
|
|
|
120
120
|
cls._instance = super().__new__(cls)
|
|
121
121
|
return cls._instance
|
|
122
122
|
|
|
123
|
+
@classmethod
|
|
124
|
+
def _is_known_name(cls, name: str) -> bool:
|
|
125
|
+
"""Check if a name belongs to a registered tool or known plugin."""
|
|
126
|
+
if name in cls._tools:
|
|
127
|
+
return True
|
|
128
|
+
# Lazy check against plugin manifest for names not yet activated
|
|
129
|
+
from tools.helpers.plugin_manifest import plugin_manifest
|
|
130
|
+
return plugin_manifest.has_plugin(name)
|
|
131
|
+
|
|
123
132
|
@classmethod
|
|
124
133
|
def disable(cls, name: str) -> bool:
|
|
125
|
-
"""Disable a tool by name.
|
|
134
|
+
"""Disable a tool or plugin by name.
|
|
135
|
+
|
|
136
|
+
Works for core tools (in _tools) and plugins (known via manifest
|
|
137
|
+
but not yet activated).
|
|
126
138
|
|
|
127
139
|
Args:
|
|
128
|
-
name: Tool name to disable
|
|
140
|
+
name: Tool or plugin name to disable
|
|
129
141
|
|
|
130
142
|
Returns:
|
|
131
|
-
True if
|
|
143
|
+
True if name was recognized and disabled
|
|
132
144
|
"""
|
|
133
|
-
if name in cls._tools:
|
|
145
|
+
if name in cls._tools or cls._is_known_name(name):
|
|
134
146
|
cls._disabled[name] = None
|
|
135
147
|
return True
|
|
136
148
|
return False
|
|
137
149
|
|
|
138
150
|
@classmethod
|
|
139
151
|
def enable(cls, name: str) -> bool:
|
|
140
|
-
"""Enable a previously disabled tool.
|
|
152
|
+
"""Enable a previously disabled tool or plugin.
|
|
141
153
|
|
|
142
154
|
Args:
|
|
143
|
-
name: Tool name to enable
|
|
155
|
+
name: Tool or plugin name to enable
|
|
144
156
|
|
|
145
157
|
Returns:
|
|
146
|
-
True if
|
|
158
|
+
True if it was disabled and is now re-enabled
|
|
147
159
|
"""
|
|
148
160
|
if name in cls._disabled:
|
|
149
161
|
del cls._disabled[name]
|
|
@@ -305,25 +317,32 @@ class ToolRegistry:
|
|
|
305
317
|
Returns:
|
|
306
318
|
Number of enabled tools (excludes disabled tools)
|
|
307
319
|
"""
|
|
308
|
-
return
|
|
320
|
+
return sum(1 for name in cls._tools if name not in cls._disabled)
|
|
309
321
|
|
|
310
322
|
# =========================================================================
|
|
311
323
|
# Plugin activation and TTL management
|
|
312
324
|
# =========================================================================
|
|
313
325
|
|
|
314
326
|
@classmethod
|
|
315
|
-
def activate_plugin(cls, tool_def, ttl=None) ->
|
|
327
|
+
def activate_plugin(cls, tool_def, ttl=None) -> bool:
|
|
316
328
|
"""Activate a plugin-tier tool by registering it with a TTL.
|
|
317
329
|
|
|
318
330
|
Args:
|
|
319
331
|
tool_def: ToolDefinition with tier="plugin"
|
|
320
332
|
ttl: Number of turns before eviction (default: cls._default_plugin_ttl)
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
True if the plugin was activated, False if it is disabled
|
|
321
336
|
"""
|
|
337
|
+
if cls.is_disabled(tool_def.name):
|
|
338
|
+
_logger.debug("Skipping activation for disabled plugin: %s", tool_def.name)
|
|
339
|
+
return False
|
|
322
340
|
if ttl is None:
|
|
323
341
|
ttl = cls._default_plugin_ttl
|
|
324
342
|
cls._tools[tool_def.name] = tool_def
|
|
325
343
|
cls._plugin_ttl[tool_def.name] = ttl
|
|
326
344
|
_logger.debug(f"Plugin activated: {tool_def.name} (TTL={ttl})")
|
|
345
|
+
return True
|
|
327
346
|
|
|
328
347
|
@classmethod
|
|
329
348
|
def deactivate_plugin(cls, name: str) -> bool:
|
|
@@ -405,6 +424,35 @@ def get_terminal_policy(tool_name: str) -> str:
|
|
|
405
424
|
return TERMINAL_NONE # Default to none for unknown tools
|
|
406
425
|
|
|
407
426
|
|
|
427
|
+
def _enrich_plugin_metadata(tool_def: ToolDefinition) -> None:
|
|
428
|
+
"""Auto-generate description and/or tags for a plugin if missing."""
|
|
429
|
+
if tool_def.description and tool_def.tags:
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
source = inspect.getsource(tool_def.handler) if tool_def.handler else ""
|
|
434
|
+
except (OSError, TypeError):
|
|
435
|
+
source = ""
|
|
436
|
+
|
|
437
|
+
content = f"{tool_def.name}\n{tool_def.description}\n{source}"
|
|
438
|
+
try:
|
|
439
|
+
from core.metadata import generate_metadata
|
|
440
|
+
generated = generate_metadata(content, tool_def.name)
|
|
441
|
+
except Exception:
|
|
442
|
+
_logger.debug("Plugin metadata enrichment failed for '%s'", tool_def.name, exc_info=True)
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
if not tool_def.description:
|
|
446
|
+
tool_def.description = str(generated.get("description", "")).strip()
|
|
447
|
+
if not tool_def.tags:
|
|
448
|
+
raw_tags = generated.get("tags")
|
|
449
|
+
if isinstance(raw_tags, str):
|
|
450
|
+
raw_tags = [raw_tags]
|
|
451
|
+
elif not isinstance(raw_tags, list):
|
|
452
|
+
raw_tags = []
|
|
453
|
+
tool_def.tags = [str(tag).strip() for tag in raw_tags if str(tag).strip()]
|
|
454
|
+
|
|
455
|
+
|
|
408
456
|
def tool(
|
|
409
457
|
name: str,
|
|
410
458
|
description: str,
|
|
@@ -475,6 +523,7 @@ def tool(
|
|
|
475
523
|
|
|
476
524
|
# Plugin-tier tools go to the manifest, not the registry
|
|
477
525
|
if tier == "plugin":
|
|
526
|
+
_enrich_plugin_metadata(tool_def)
|
|
478
527
|
from .plugin_manifest import plugin_manifest
|
|
479
528
|
plugin_manifest.register(tool_def)
|
|
480
529
|
else:
|
|
@@ -51,7 +51,7 @@ def _load_module_from_path(module_name: str, file_path: Path) -> Optional[object
|
|
|
51
51
|
|
|
52
52
|
module = importlib.util.module_from_spec(spec)
|
|
53
53
|
|
|
54
|
-
# For
|
|
54
|
+
# For external tool modules (not in src/tools/), set package to None
|
|
55
55
|
# to force absolute imports instead of relative imports
|
|
56
56
|
from tools import __file__ as tools_init_file
|
|
57
57
|
tools_dir = Path(tools_init_file).parent
|
|
@@ -113,7 +113,7 @@ def discover_tools(directories: List[str]) -> int:
|
|
|
113
113
|
# Create unique module name
|
|
114
114
|
module_name = f"tools_{py_file.stem}_{hash(str(py_file)) & 0xFFFFFFFF}"
|
|
115
115
|
|
|
116
|
-
# Skip modules already loaded (e.g. cron re-calling
|
|
116
|
+
# Skip modules already loaded (e.g. cron re-calling discover_tools)
|
|
117
117
|
if module_name in sys.modules:
|
|
118
118
|
logger.debug(f"Module already loaded, skipping: {module_name}")
|
|
119
119
|
continue
|
|
@@ -133,92 +133,6 @@ def discover_tools(directories: List[str]) -> int:
|
|
|
133
133
|
return new_tools
|
|
134
134
|
|
|
135
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
136
|
def list_registered_tools() -> List[str]:
|
|
223
137
|
"""List names of all registered tools.
|
|
224
138
|
|
|
@@ -15,6 +15,57 @@ from .file_helpers import _is_reserved_windows_name
|
|
|
15
15
|
_path_resolution_times = []
|
|
16
16
|
_path_validation_errors = {}
|
|
17
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
|
+
|
|
18
69
|
|
|
19
70
|
class PathResolver:
|
|
20
71
|
"""Centralized path resolution and validation.
|
|
@@ -109,7 +160,7 @@ class PathResolver:
|
|
|
109
160
|
|
|
110
161
|
# Step 2b: Security boundary — path must be within repo_root, vault_path,
|
|
111
162
|
# or the agent's own data directory (~/.bone/).
|
|
112
|
-
if enforce_boundary:
|
|
163
|
+
if enforce_boundary and not _full_filesystem_access:
|
|
113
164
|
try:
|
|
114
165
|
path.relative_to(self.repo_root)
|
|
115
166
|
except ValueError:
|
|
@@ -125,12 +176,12 @@ class PathResolver:
|
|
|
125
176
|
elapsed = time.time() - start_time
|
|
126
177
|
_track_validation_error("outside_allowed_roots")
|
|
127
178
|
_path_resolution_times.append(elapsed)
|
|
128
|
-
return None, f"
|
|
179
|
+
return None, f"{_BOUNDARY_ERROR_PREFIXES[0]} {path_str}"
|
|
129
180
|
else:
|
|
130
181
|
elapsed = time.time() - start_time
|
|
131
182
|
_track_validation_error("outside_repo")
|
|
132
183
|
_path_resolution_times.append(elapsed)
|
|
133
|
-
return None, f"
|
|
184
|
+
return None, f"{_BOUNDARY_ERROR_PREFIXES[1]} {path_str}"
|
|
134
185
|
|
|
135
186
|
# Step 3: Check existence if required
|
|
136
187
|
if must_exist:
|