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.
Files changed (43) hide show
  1. package/README.md +17 -0
  2. package/config.yaml.example +5 -2
  3. package/package.json +1 -1
  4. package/prompts/main/communication_style.md +1 -1
  5. package/prompts/main/dream.md +23 -9
  6. package/prompts/main/skills.md +3 -0
  7. package/prompts/micro/communication_style.md +1 -1
  8. package/prompts/micro/skills.md +1 -0
  9. package/src/core/agentic.py +138 -38
  10. package/src/core/chat_manager.py +19 -6
  11. package/src/core/config_manager.py +8 -1
  12. package/src/core/cron.py +0 -4
  13. package/src/core/metadata.py +75 -0
  14. package/src/core/skills.py +463 -0
  15. package/src/core/sub_agent.py +93 -43
  16. package/src/core/tool_feedback.py +87 -76
  17. package/src/llm/client.py +7 -2
  18. package/src/llm/codex_provider.py +350 -0
  19. package/src/llm/config.py +46 -2
  20. package/src/llm/prompts.py +12 -7
  21. package/src/llm/providers.py +3 -1
  22. package/src/llm/token_tracker.py +15 -0
  23. package/src/tools/__init__.py +24 -85
  24. package/src/tools/create_file.py +1 -1
  25. package/src/tools/directory.py +1 -1
  26. package/src/tools/edit.py +5 -1
  27. package/src/tools/file_reader.py +1 -1
  28. package/src/tools/helpers/__init__.py +1 -7
  29. package/src/tools/helpers/base.py +65 -16
  30. package/src/tools/helpers/loader.py +2 -88
  31. package/src/tools/helpers/path_resolver.py +54 -3
  32. package/src/tools/helpers/plugin_manifest.py +99 -70
  33. package/src/tools/review_sub_agent.py +2 -1
  34. package/src/tools/rg_search.py +24 -7
  35. package/src/tools/search_plugins.py +140 -72
  36. package/src/tools/shell.py +3 -3
  37. package/src/ui/commands.py +355 -33
  38. package/src/ui/displays.py +26 -1
  39. package/src/ui/main.py +0 -4
  40. package/src/ui/tool_confirmation.py +16 -5
  41. package/src/utils/editor.py +88 -39
  42. package/src/utils/settings.py +6 -2
  43. package/src/utils/validation.py +10 -0
@@ -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 plugin discovery
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
- # Backward compatibility: Re-export helpers at package level
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 load_plugin_tools
130
- load_plugin_tools()
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
- # Make base module available for backward compatibility
135
- import sys
136
- from types import ModuleType
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)
@@ -33,7 +33,7 @@ def _validate_create_path(
33
33
  path_str,
34
34
  check_gitignore=True,
35
35
  must_exist=False, # File doesn't need to exist yet
36
- enforce_boundary=vault_path is not None,
36
+ enforce_boundary=True,
37
37
  )
38
38
 
39
39
 
@@ -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=vault_path is not None
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=vault_path is not None,
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:
@@ -34,7 +34,7 @@ def _validate_read_path(
34
34
  check_gitignore=True,
35
35
  must_exist=True,
36
36
  must_be_file=True,
37
- enforce_boundary=vault_path is not None,
37
+ enforce_boundary=True,
38
38
  )
39
39
 
40
40
 
@@ -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
- "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"],
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 tool was found and disabled, False if not registered
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 tool was re-enabled, False if it wasn't disabled
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 len(cls._tools) - len(cls._disabled)
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) -> 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 user tools (not in src/utils/tools/), set package to None
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 load_all_tools)
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"Path is outside allowed directories: {path_str}"
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"Path is outside repository: {path_str}"
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: