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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,550 @@
1
+ """Tool registry and decorator for automatic tool registration.
2
+
3
+ This module provides the core infrastructure for defining tools with a
4
+ decorator-based pattern. Tools are automatically registered and can be
5
+ filtered by interaction mode.
6
+ """
7
+
8
+ import logging
9
+ import inspect
10
+ from typing import Dict, List, Optional, Callable, Any
11
+ from dataclasses import dataclass, field
12
+ from functools import wraps
13
+ from pathlib import Path
14
+
15
+
16
+ _logger = logging.getLogger(__name__)
17
+
18
+
19
+ # Terminal policy constants for thinking indicator handoff
20
+ TERMINAL_NONE = "none" # Indicator keeps running (non-interactive tools)
21
+ TERMINAL_YIELD = "yield" # Indicator pauses, clears line, tool takes over terminal (Live/prompt_toolkit)
22
+ TERMINAL_STOP = "stop" # Indicator fully stops (approval prompts need clean terminal)
23
+
24
+
25
+ @dataclass
26
+ class ToolDefinition:
27
+ """Definition of a tool including metadata and execution handler.
28
+
29
+ Attributes:
30
+ name: Tool identifier (e.g., "read_file")
31
+ description: Human-readable description of what the tool does
32
+ parameters: JSON Schema for tool parameters
33
+ requires_approval: Whether this tool requires user confirmation
34
+ terminal_policy: How this tool interacts with the thinking indicator
35
+ handler: Function that executes the tool
36
+ tier: "core" (always in context) or "plugin" (on-demand via search_plugins)
37
+ tags: List of searchable tags for plugin discovery
38
+ category: Category grouping for plugin discovery (e.g., "email", "database")
39
+ """
40
+ name: str
41
+ description: str
42
+ parameters: Dict[str, Any]
43
+ requires_approval: bool = False
44
+ terminal_policy: str = TERMINAL_NONE # Default: indicator keeps running
45
+ handler: Optional[Callable] = None
46
+ tier: str = "core"
47
+ tags: List[str] = field(default_factory=list)
48
+ category: str = ""
49
+
50
+ def to_openai_schema(self) -> Dict[str, Any]:
51
+ """Convert tool definition to OpenAI function-calling schema.
52
+
53
+ Returns:
54
+ Dictionary in OpenAI function format
55
+ """
56
+ return {
57
+ "type": "function",
58
+ "function": {
59
+ "name": self.name,
60
+ "description": self.description,
61
+ "parameters": self.parameters
62
+ }
63
+ }
64
+
65
+ def execute(self, arguments: Dict[str, Any], context: Dict[str, Any]) -> str:
66
+ """Execute the tool with given arguments and context.
67
+
68
+ Args:
69
+ arguments: Tool arguments from LLM
70
+ context: Execution context (repo_root, console, etc.)
71
+
72
+ Returns:
73
+ Tool result string with exit_code=N prefix
74
+
75
+ Raises:
76
+ RuntimeError: If no handler is registered
77
+ """
78
+ if self.handler is None:
79
+ raise RuntimeError(f"Tool '{self.name}' has no registered handler")
80
+
81
+ # Get the handler's parameter names
82
+ sig = inspect.signature(self.handler)
83
+ handler_params = set(sig.parameters.keys())
84
+
85
+ # Inject context parameters only if the handler expects them
86
+ for key, value in context.items():
87
+ if key not in arguments and key in handler_params:
88
+ arguments[key] = value
89
+
90
+ return self.handler(**arguments)
91
+
92
+
93
+ # Named groups of related tools for bulk enable/disable
94
+ TOOL_GROUPS = {
95
+ "file_ops": {
96
+ "label": "File Operations",
97
+ "tools": ["read_file", "create_file", "edit_file", "list_directory"],
98
+ },
99
+ "task_mgmt": {
100
+ "label": "Task Management",
101
+ "tools": ["create_task_list", "complete_task", "show_task_list"],
102
+ },
103
+ }
104
+
105
+
106
+ class ToolRegistry:
107
+ """Global registry for all tools.
108
+
109
+ Singleton pattern ensures all tools are registered in one place.
110
+ """
111
+
112
+ _instance: Optional['ToolRegistry'] = None
113
+ _tools: Dict[str, ToolDefinition] = {}
114
+ _disabled: Dict[str, None] = {} # dict used as ordered set — mirrors _tools pattern
115
+ _plugin_ttl: Dict[str, int] = {} # tracks remaining turns for activated plugins
116
+ _default_plugin_ttl: int = 10
117
+
118
+ def __new__(cls):
119
+ if cls._instance is None:
120
+ cls._instance = super().__new__(cls)
121
+ return cls._instance
122
+
123
+ @classmethod
124
+ def disable(cls, name: str) -> bool:
125
+ """Disable a tool by name.
126
+
127
+ Args:
128
+ name: Tool name to disable
129
+
130
+ Returns:
131
+ True if tool was found and disabled, False if not registered
132
+ """
133
+ if name in cls._tools:
134
+ cls._disabled[name] = None
135
+ return True
136
+ return False
137
+
138
+ @classmethod
139
+ def enable(cls, name: str) -> bool:
140
+ """Enable a previously disabled tool.
141
+
142
+ Args:
143
+ name: Tool name to enable
144
+
145
+ Returns:
146
+ True if tool was re-enabled, False if it wasn't disabled
147
+ """
148
+ if name in cls._disabled:
149
+ del cls._disabled[name]
150
+ return True
151
+ return False
152
+
153
+ @classmethod
154
+ def is_disabled(cls, name: str) -> bool:
155
+ """Check if a tool is currently disabled.
156
+
157
+ Args:
158
+ name: Tool name
159
+
160
+ Returns:
161
+ True if tool is disabled
162
+ """
163
+ return name in cls._disabled
164
+
165
+ @classmethod
166
+ def disable_group(cls, group_key: str) -> list:
167
+ """Disable all tools in a named group.
168
+
169
+ Args:
170
+ group_key: Key from TOOL_GROUPS (e.g. "file_ops")
171
+
172
+ Returns:
173
+ List of tool names that were actually disabled
174
+ """
175
+ group = TOOL_GROUPS.get(group_key)
176
+ if not group:
177
+ return []
178
+ disabled = []
179
+ for name in group["tools"]:
180
+ if name in cls._tools and name not in cls._disabled:
181
+ cls._disabled[name] = None
182
+ disabled.append(name)
183
+ return disabled
184
+
185
+ @classmethod
186
+ def enable_group(cls, group_key: str) -> list:
187
+ """Enable all tools in a named group.
188
+
189
+ Args:
190
+ group_key: Key from TOOL_GROUPS (e.g. "file_ops")
191
+
192
+ Returns:
193
+ List of tool names that were actually re-enabled
194
+ """
195
+ group = TOOL_GROUPS.get(group_key)
196
+ if not group:
197
+ return []
198
+ enabled = []
199
+ for name in group["tools"]:
200
+ if name in cls._disabled:
201
+ del cls._disabled[name]
202
+ enabled.append(name)
203
+ return enabled
204
+
205
+ @classmethod
206
+ def get_group_status(cls, group_key: str) -> dict:
207
+ """Get enabled/disabled status for all tools in a group.
208
+
209
+ Args:
210
+ group_key: Key from TOOL_GROUPS
211
+
212
+ Returns:
213
+ Dict with 'label', 'tools' list of {name, enabled} dicts
214
+ """
215
+ group = TOOL_GROUPS.get(group_key)
216
+ if not group:
217
+ return {"label": group_key, "tools": []}
218
+ return {
219
+ "label": group["label"],
220
+ "tools": [
221
+ {"name": name, "enabled": name not in cls._disabled}
222
+ for name in group["tools"]
223
+ ],
224
+ }
225
+
226
+ @classmethod
227
+ def get_disabled(cls) -> set:
228
+ """Get the set of disabled tool names.
229
+
230
+ Returns:
231
+ Set of disabled tool names
232
+ """
233
+ return set(cls._disabled)
234
+
235
+ @classmethod
236
+ def register(cls, tool_def: ToolDefinition) -> None:
237
+ """Register a tool definition.
238
+
239
+ Args:
240
+ tool_def: ToolDefinition to register
241
+
242
+ Note:
243
+ Overwrites existing tools with same name (logs warning)
244
+ """
245
+ if tool_def.name in cls._tools:
246
+ import warnings
247
+ warnings.warn(
248
+ f"Tool '{tool_def.name}' is being overwritten. "
249
+ f"Previous tool: {cls._tools[tool_def.name].handler}, "
250
+ f"New tool: {tool_def.handler}"
251
+ )
252
+ cls._tools[tool_def.name] = tool_def
253
+
254
+ @classmethod
255
+ def get(cls, name: str) -> Optional[ToolDefinition]:
256
+ """Get a tool definition by name.
257
+
258
+ Args:
259
+ name: Tool name
260
+
261
+ Returns:
262
+ ToolDefinition or None if not found
263
+ """
264
+ return cls._tools.get(name)
265
+
266
+ @classmethod
267
+ def get_all(cls, include_plugins: bool = False) -> List[ToolDefinition]:
268
+ """Get all registered and enabled tools.
269
+
270
+ Args:
271
+ include_plugins: If True, include plugin-tier tools. Default: core only.
272
+
273
+ Returns:
274
+ List of all ToolDefinitions (excluding disabled, excluding plugins unless requested)
275
+ """
276
+ tools = [t for t in cls._tools.values() if t.name not in cls._disabled]
277
+ if not include_plugins:
278
+ tools = [t for t in tools if t.tier != "plugin"]
279
+ return tools
280
+
281
+ @classmethod
282
+ def unregister(cls, name: str) -> bool:
283
+ """Remove a tool from the registry by name.
284
+
285
+ Args:
286
+ name: Tool name to remove
287
+
288
+ Returns:
289
+ True if tool was found and removed, False if not registered
290
+ """
291
+ cls._disabled.pop(name, None)
292
+ return cls._tools.pop(name, None) is not None
293
+
294
+ @classmethod
295
+ def clear(cls) -> None:
296
+ """Clear all registered tools (mainly for testing)."""
297
+ cls._tools.clear()
298
+ cls._disabled.clear()
299
+ cls._plugin_ttl.clear()
300
+
301
+ @classmethod
302
+ def tool_count(cls) -> int:
303
+ """Get the number of active (enabled) tools in the registry.
304
+
305
+ Returns:
306
+ Number of enabled tools (excludes disabled tools)
307
+ """
308
+ return len(cls._tools) - len(cls._disabled)
309
+
310
+ # =========================================================================
311
+ # Plugin activation and TTL management
312
+ # =========================================================================
313
+
314
+ @classmethod
315
+ def activate_plugin(cls, tool_def, ttl=None) -> None:
316
+ """Activate a plugin-tier tool by registering it with a TTL.
317
+
318
+ Args:
319
+ tool_def: ToolDefinition with tier="plugin"
320
+ ttl: Number of turns before eviction (default: cls._default_plugin_ttl)
321
+ """
322
+ if ttl is None:
323
+ ttl = cls._default_plugin_ttl
324
+ cls._tools[tool_def.name] = tool_def
325
+ cls._plugin_ttl[tool_def.name] = ttl
326
+ _logger.debug(f"Plugin activated: {tool_def.name} (TTL={ttl})")
327
+
328
+ @classmethod
329
+ def deactivate_plugin(cls, name: str) -> bool:
330
+ """Deactivate and remove a plugin-tier tool.
331
+
332
+ Args:
333
+ name: Plugin tool name to deactivate
334
+
335
+ Returns:
336
+ True if plugin was found and removed
337
+ """
338
+ if name in cls._plugin_ttl:
339
+ del cls._plugin_ttl[name]
340
+ return cls._tools.pop(name, None) is not None
341
+
342
+ @classmethod
343
+ def decrement_plugin_ttls(cls):
344
+ """Decrement TTL for all activated plugins. Evict those at zero.
345
+
346
+ Returns:
347
+ List of evicted plugin names
348
+ """
349
+ evicted = []
350
+ expired = [name for name, ttl in cls._plugin_ttl.items() if ttl <= 1]
351
+ for name in expired:
352
+ cls.deactivate_plugin(name)
353
+ evicted.append(name)
354
+ _logger.debug(f"Plugin evicted (TTL expired): {name}")
355
+ # Decrement remaining
356
+ for name in cls._plugin_ttl:
357
+ cls._plugin_ttl[name] -= 1
358
+ return evicted
359
+
360
+ @classmethod
361
+ def touch_plugin(cls, name: str) -> None:
362
+ """Reset TTL for an activated plugin (called when plugin is used).
363
+
364
+ Args:
365
+ name: Plugin tool name
366
+ """
367
+ if name in cls._plugin_ttl:
368
+ cls._plugin_ttl[name] = cls._default_plugin_ttl
369
+ _logger.debug(f"Plugin TTL reset: {name}")
370
+
371
+ @classmethod
372
+ def active_plugin_names(cls) -> set:
373
+ """Get the set of currently activated plugin names.
374
+
375
+ Returns:
376
+ Set of active plugin tool names
377
+ """
378
+ return set(cls._plugin_ttl.keys())
379
+
380
+ @classmethod
381
+ def is_plugin_active(cls, name: str) -> bool:
382
+ """Check if a plugin is currently activated in the registry.
383
+
384
+ Args:
385
+ name: Tool name
386
+
387
+ Returns:
388
+ True if tool is an active plugin
389
+ """
390
+ return name in cls._plugin_ttl
391
+
392
+
393
+ def get_terminal_policy(tool_name: str) -> str:
394
+ """Get the terminal policy for a tool.
395
+
396
+ Args:
397
+ tool_name: Name of the tool
398
+
399
+ Returns:
400
+ Terminal policy string (TERMINAL_NONE, TERMINAL_YIELD, or TERMINAL_STOP)
401
+ """
402
+ tool_def = ToolRegistry.get(tool_name)
403
+ if tool_def:
404
+ return tool_def.terminal_policy
405
+ return TERMINAL_NONE # Default to none for unknown tools
406
+
407
+
408
+ def tool(
409
+ name: str,
410
+ description: str,
411
+ parameters: Dict[str, Any],
412
+ requires_approval: bool = False,
413
+ terminal_policy: str = TERMINAL_NONE,
414
+ tier: str = "core",
415
+ tags: Optional[List[str]] = None,
416
+ category: str = ""
417
+ ) -> Callable:
418
+ """Decorator for registering tool functions.
419
+
420
+ Usage:
421
+ @tool(
422
+ name="my_tool",
423
+ description="Does something useful",
424
+ parameters={
425
+ "type": "object",
426
+ "properties": {
427
+ "input": {"type": "string", "description": "Input value"}
428
+ },
429
+ "required": ["input"]
430
+ },
431
+ )
432
+ def my_tool(input: str, repo_root: Path):
433
+ return f"exit_code=0\nProcessed: {input}"
434
+
435
+ Args:
436
+ name: Tool identifier
437
+ description: Human-readable description
438
+ parameters: JSON Schema for parameters
439
+ requires_approval: Whether confirmation is required (default: False)
440
+ terminal_policy: Terminal policy for thinking indicator (default: TERMINAL_NONE)
441
+ tier: "core" or "plugin" (default: "core")
442
+ tags: List of searchable tags for plugin discovery
443
+ category: Category grouping for plugin discovery
444
+
445
+ Returns:
446
+ Decorator function
447
+
448
+ Note:
449
+ The decorated function should return a string with exit_code=N prefix,
450
+ e.g., "exit_code=0\nResult content here"
451
+
452
+ Plugin-tier tools (tier="plugin") are registered in the PluginManifest
453
+ instead of ToolRegistry, so they don't consume context tokens by default.
454
+ They are activated on-demand via the search_plugins core tool.
455
+ """
456
+ def decorator(func: Callable) -> Callable:
457
+ # Validate tier
458
+ if tier not in ("core", "plugin"):
459
+ raise ValueError(
460
+ f"Invalid tier '{tier}' for tool '{name}'. Must be 'core' or 'plugin'."
461
+ )
462
+
463
+ # Create tool definition
464
+ tool_def = ToolDefinition(
465
+ name=name,
466
+ description=description,
467
+ parameters=parameters,
468
+ requires_approval=requires_approval,
469
+ terminal_policy=terminal_policy,
470
+ handler=func,
471
+ tier=tier,
472
+ tags=tags or [],
473
+ category=category,
474
+ )
475
+
476
+ # Plugin-tier tools go to the manifest, not the registry
477
+ if tier == "plugin":
478
+ from .plugin_manifest import plugin_manifest
479
+ plugin_manifest.register(tool_def)
480
+ else:
481
+ # Register core tool normally
482
+ ToolRegistry.register(tool_def)
483
+
484
+ @wraps(func)
485
+ def wrapper(*args, **kwargs):
486
+ return func(*args, **kwargs)
487
+
488
+ return wrapper
489
+
490
+ return decorator
491
+
492
+
493
+ def build_context(
494
+ repo_root: Path,
495
+ console: Any = None,
496
+ gitignore_spec: Any = None,
497
+ debug_mode: bool = False,
498
+ chat_manager: Any = None,
499
+ rg_exe_path: str = None,
500
+ panel_updater: Any = None,
501
+ vault_root: str = None
502
+ ) -> Dict[str, Any]:
503
+ """Build execution context for tool invocation.
504
+
505
+ Args:
506
+ repo_root: Repository root directory
507
+ console: Rich console for output
508
+ gitignore_spec: PathSpec for .gitignore filtering
509
+ debug_mode: Whether debug mode is enabled
510
+ chat_manager: ChatManager instance
511
+ rg_exe_path: Path to rg executable
512
+ panel_updater: Optional SubAgentPanel for live updates
513
+ vault_root: Optional Obsidian vault root path
514
+
515
+ Returns:
516
+ Context dictionary
517
+ """
518
+ context = {
519
+ "repo_root": repo_root,
520
+ "console": console,
521
+ "gitignore_spec": gitignore_spec,
522
+ "debug_mode": debug_mode
523
+ }
524
+ if chat_manager is not None:
525
+ context["chat_manager"] = chat_manager
526
+ if rg_exe_path is not None:
527
+ context["rg_exe_path"] = rg_exe_path
528
+ if panel_updater is not None:
529
+ context["panel_updater"] = panel_updater
530
+ if vault_root is not None:
531
+ context["vault_root"] = vault_root
532
+ return context
533
+
534
+
535
+ # =============================================================================
536
+ # Tool schema exports for OpenAI function calling
537
+ # =============================================================================
538
+
539
+ def get_tool_schemas() -> list:
540
+ """Generate OpenAI tool schemas from registry.
541
+
542
+ Returns:
543
+ List of tool schemas in OpenAI function-calling format
544
+ """
545
+ return [tool.to_openai_schema() for tool in ToolRegistry.get_all(include_plugins=True)]
546
+
547
+
548
+ def TOOLS():
549
+ """Get tool schemas. Callable for backward compatibility."""
550
+ return get_tool_schemas()
@@ -0,0 +1,44 @@
1
+ """Shared type conversion utilities for tool argument validation."""
2
+
3
+
4
+ def coerce_int(value):
5
+ """Best-effort coercion of tool arguments to int.
6
+
7
+ Returns:
8
+ Tuple of (int_value, error_message). error_message is None on success.
9
+ """
10
+ if value is None:
11
+ return None, "Missing required integer value."
12
+ if isinstance(value, bool):
13
+ return None, "Value must be an integer, not a boolean."
14
+ if isinstance(value, int):
15
+ return value, None
16
+ if isinstance(value, str):
17
+ text = value.strip()
18
+ if text == "":
19
+ return None, "Value must be a non-empty integer."
20
+ try:
21
+ return int(text), None
22
+ except ValueError:
23
+ return None, "Value must be an integer."
24
+ return None, "Value must be an integer."
25
+
26
+
27
+ def coerce_bool(value, default=None):
28
+ """Best-effort coercion of tool arguments to boolean.
29
+
30
+ Returns None if value is None and default is None.
31
+ """
32
+ if value is None:
33
+ return default
34
+ if isinstance(value, bool):
35
+ return value
36
+ if isinstance(value, int):
37
+ return bool(value)
38
+ if isinstance(value, str):
39
+ normalized = value.strip().lower()
40
+ if normalized in {"true", "1", "yes", "y", "on"}:
41
+ return True
42
+ if normalized in {"false", "0", "no", "n", "off"}:
43
+ return False
44
+ return default