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
@@ -4,11 +4,12 @@ Uses existing AgenticOrchestrator with isolated message context
4
4
  and read-only tools to execute generic delegated tasks.
5
5
  """
6
6
 
7
- from pathlib import Path
8
-
9
- from core.chat_manager import ChatManager
10
- from llm.prompts import build_sub_agent_prompt
11
- from utils.settings import sub_agent_settings
7
+ from pathlib import Path
8
+
9
+ from core.chat_manager import ChatManager
10
+ from exceptions import LLMError
11
+ from llm.prompts import build_sub_agent_prompt
12
+ from utils.settings import sub_agent_settings
12
13
 
13
14
 
14
15
  class HardLimitExceeded(Exception):
@@ -16,6 +17,11 @@ class HardLimitExceeded(Exception):
16
17
  pass
17
18
 
18
19
 
20
+ class BilledLimitExceeded(Exception):
21
+ """Raised when the sub-agent hits its cumulative billed token limit."""
22
+ pass
23
+
24
+
19
25
  def _format_messages_dump(messages) -> str:
20
26
  """Format sub-agent message history as a markdown dump.
21
27
 
@@ -89,7 +95,7 @@ def _inject_system_prompt(chat_manager, sub_agent_type: str = "research"):
89
95
  chat_manager.messages = [{"role": "system", "content": base_prompt}]
90
96
 
91
97
 
92
- def _load_codebase_map(chat_manager):
98
+ def _load_codebase_map(chat_manager):
93
99
  """Load agents.md codebase map into sub-agent context if available.
94
100
 
95
101
  Args:
@@ -98,19 +104,13 @@ def _load_codebase_map(chat_manager):
98
104
  agents_path = Path.cwd() / "agents.md"
99
105
  if agents_path.exists():
100
106
  map_content = agents_path.read_text(encoding="utf-8").strip()
101
- user_msg = (
102
- "Here is the codebase map for this project. "
103
- "This provides an overview of the repository structure and file purposes. "
104
- "Use this as a reference when exploring the codebase.\n\n"
105
- f"## Codebase Map (auto-generated from agents.md)\n\n{map_content}"
106
- )
107
- assistant_msg = (
108
- "I've received the codebase map. I'll use this as a reference when "
109
- "exploring the repository, but I'll always verify current state by "
110
- "reading files and searching the codebase before making changes."
111
- )
112
- chat_manager.messages.append({"role": "user", "content": user_msg})
113
- chat_manager.messages.append({"role": "assistant", "content": assistant_msg})
107
+ user_msg = (
108
+ "Here is the codebase map for this project. "
109
+ "This provides an overview of the repository structure and file purposes. "
110
+ "Use this as a reference when exploring the codebase.\n\n"
111
+ f"## Codebase Map (auto-generated from agents.md)\n\n{map_content}"
112
+ )
113
+ chat_manager.messages.append({"role": "user", "content": user_msg})
114
114
 
115
115
 
116
116
  def _configure_isolation(chat_manager):
@@ -183,14 +183,11 @@ def run_sub_agent(
183
183
  # Create fresh ChatManager for sub-agent
184
184
  temp_chat_manager = _create_chat_manager(sub_agent_type=sub_agent_type)
185
185
 
186
- # Inject initial context as a user/assistant exchange if provided
187
- if initial_context:
188
- temp_chat_manager.messages.append(
189
- {"role": "user", "content": initial_context}
190
- )
191
- temp_chat_manager.messages.append(
192
- {"role": "assistant", "content": "I've received the context. I'll analyze it and use the available tools to gather additional information as needed."}
193
- )
186
+ # Inject initial context as a user/assistant exchange if provided
187
+ if initial_context:
188
+ temp_chat_manager.messages.append(
189
+ {"role": "user", "content": initial_context}
190
+ )
194
191
 
195
192
  # Import here to avoid circular import with core.agentic
196
193
  from core.agentic import AgenticOrchestrator
@@ -216,27 +213,40 @@ def run_sub_agent(
216
213
  original_chat_completion = temp_chat_manager.client.chat_completion
217
214
 
218
215
  _soft_limit_warned = False
216
+ _billed_warning_sent = False
219
217
 
220
218
  def _chat_completion_with_token_hint(messages, **kwargs):
221
- """Prepend a system-level token budget hint (and soft-limit warning once) to every LLM call."""
222
- nonlocal _soft_limit_warned
219
+ """Prepend a system-level token budget hint and one-time warnings to every LLM call."""
220
+ nonlocal _soft_limit_warned, _billed_warning_sent
223
221
  tt = temp_chat_manager.token_tracker
224
- hint = f"[Token budget: {tt.current_context_tokens:,} curr / {tt.conv_total_tokens:,} total]"
222
+ hint = f"[Token budget: {tt.current_context_tokens:,} curr / {tt.conv_total_tokens:,} total billed]"
223
+ warnings = []
225
224
 
226
225
  if not _soft_limit_warned and tt.current_context_tokens >= sub_agent_settings.soft_limit_tokens:
227
226
  _soft_limit_warned = True
228
- hint = (
229
- f"WARNING: You have exceeded the soft token limit "
227
+ warnings.append(
228
+ f"WARNING: You have exceeded the current-context soft token limit "
230
229
  f"({tt.current_context_tokens:,} / {sub_agent_settings.soft_limit_tokens:,}). "
231
- "STOP exploring and return your findings immediately. Do NOT call any more tools. "
232
- + hint
230
+ "STOP exploring and return your findings immediately. Do NOT call any more tools."
233
231
  )
234
232
 
233
+ if not _billed_warning_sent and tt.conv_total_tokens >= sub_agent_settings.billed_warning_tokens:
234
+ _billed_warning_sent = True
235
+ warnings.append(
236
+ f"WARNING: You have exceeded the cumulative billed token warning limit "
237
+ f"({tt.conv_total_tokens:,} / {sub_agent_settings.billed_warning_tokens:,}). "
238
+ "This sub-agent may be running away. STOP exploring and return your findings immediately. "
239
+ "Do NOT call any more tools."
240
+ )
241
+
242
+ if warnings:
243
+ hint = "\n".join([*warnings, hint])
244
+
235
245
  token_msg = {"role": "system", "content": hint}
236
246
  return original_chat_completion([token_msg, *messages], **kwargs)
237
247
 
238
- def _get_llm_response_with_hard_limit(allowed_tools=None):
239
- """Wrapper to check hard token limit and update panel with live token counts."""
248
+ def _get_llm_response_with_hard_limit(allowed_tools=None, allow_active_plugins=False):
249
+ """Wrapper to check context and billed token limits and update panel state."""
240
250
  tt = temp_chat_manager.token_tracker
241
251
 
242
252
  # Check hard token limit before making LLM call
@@ -248,6 +258,19 @@ def run_sub_agent(
248
258
  f"{tt.current_context_tokens:,} / {sub_agent_settings.hard_limit_tokens:,} tokens."
249
259
  )
250
260
 
261
+ # Check cumulative billed tokens to stop runaway sub-agents even when
262
+ # current context remains below the prompt-size hard limit.
263
+ #
264
+ # Note: the billed warning is injected by _chat_completion_with_token_hint
265
+ # on the next chat_completion call. This hard stop runs before each LLM
266
+ # response, so once we hit the billed hard limit the warning may never be
267
+ # delivered if no further chat_completion call is made.
268
+ if tt.conv_total_tokens >= sub_agent_settings.billed_hard_limit_tokens:
269
+ raise BilledLimitExceeded(
270
+ f"Sub-agent billed token limit exceeded: "
271
+ f"{tt.conv_total_tokens:,} / {sub_agent_settings.billed_hard_limit_tokens:,} tokens."
272
+ )
273
+
251
274
  # Update panel with live token counts
252
275
  # Order: conversation length (current context) first, total tokens billed second
253
276
  conv_length = tt.current_context_tokens
@@ -256,27 +279,45 @@ def run_sub_agent(
256
279
  panel_updater.token_info = f"{conv_length:,} curr | {total_billed:,} total"
257
280
  panel_updater.append("") # Refresh panel title
258
281
 
259
- return original_get_llm_response(allowed_tools=allowed_tools)
282
+ return original_get_llm_response(
283
+ allowed_tools=allowed_tools,
284
+ allow_active_plugins=allow_active_plugins,
285
+ )
260
286
 
261
287
  # Apply both patches once, before the orchestrator loop starts
262
288
  orchestrator._get_llm_response = _get_llm_response_with_hard_limit
263
289
  temp_chat_manager.client.chat_completion = _chat_completion_with_token_hint
264
290
 
265
291
  hard_limit_exceeded = False
292
+ billed_limit_exceeded = False
266
293
 
267
294
  try:
268
295
  # Run sub-agent task
269
296
  orchestrator.run(
270
297
  task_query,
271
298
  thinking_indicator=None,
272
- allowed_tools=sub_agent_settings.allowed_tools
299
+ allowed_tools=sub_agent_settings.allowed_tools,
300
+ allow_active_plugins=sub_agent_settings.allow_active_plugins,
273
301
  )
274
302
  except HardLimitExceeded:
275
303
  hard_limit_exceeded = True
276
- except Exception as e:
277
- import traceback
278
- error_details = f"{e}\n\nTraceback:\n{traceback.format_exc()}"
279
- return {
304
+ except BilledLimitExceeded:
305
+ billed_limit_exceeded = True
306
+ except LLMError as e:
307
+ return {
308
+ "result": "",
309
+ "usage": {
310
+ "prompt_tokens": 0,
311
+ "completion_tokens": 0,
312
+ "total_tokens": 0
313
+ },
314
+ "model": temp_chat_manager.client.model,
315
+ "error": str(e)
316
+ }
317
+ except Exception as e:
318
+ import traceback
319
+ error_details = f"{e}\n\nTraceback:\n{traceback.format_exc()}"
320
+ return {
280
321
  "result": "",
281
322
  "usage": {
282
323
  "prompt_tokens": 0,
@@ -306,7 +347,15 @@ def run_sub_agent(
306
347
  if msg.get("role") == "assistant" and msg.get("content"):
307
348
  final_content = msg["content"].strip()
308
349
  break
309
- result = final_content
350
+
351
+ if billed_limit_exceeded:
352
+ prefix = (
353
+ "WARNING: Sub-agent billed token limit reached. "
354
+ "Returning current findings early to prevent runaway execution."
355
+ )
356
+ result = f"{prefix}\n\n{final_content}" if final_content else prefix
357
+ else:
358
+ result = final_content
310
359
 
311
360
  usage = {
312
361
  "prompt_tokens": delta_prompt,
@@ -323,4 +372,5 @@ def run_sub_agent(
323
372
  "model": temp_chat_manager.client.model,
324
373
  "error": None,
325
374
  "hard_limit_exceeded": hard_limit_exceeded,
375
+ "billed_limit_exceeded": billed_limit_exceeded,
326
376
  }
@@ -1,6 +1,7 @@
1
1
  """Tool result display functions for the agentic loop."""
2
2
 
3
3
  import re
4
+ import textwrap
4
5
  from pathlib import Path
5
6
  from typing import Optional
6
7
 
@@ -8,6 +9,7 @@ from rich.syntax import Syntax
8
9
 
9
10
  from utils.settings import MAX_COMMAND_OUTPUT_LINES, MonokaiDarkBGStyle
10
11
  from utils.result_parsers import extract_exit_code, extract_all_metadata, extract_multiple_metadata
12
+ from tools.search_plugins import HEADER_MATCHES, HEADER_ALL
11
13
  from tools.task_list import _format_task_list, _strip_rich_markup
12
14
 
13
15
 
@@ -303,110 +305,119 @@ def handle_list_directory_feedback(tool_result, console, panel_updater):
303
305
  def handle_search_plugins_feedback(tool_result, console, panel_updater):
304
306
  """Handle feedback for search_plugins tool.
305
307
 
306
- Display a tree of found plugins with query and category info,
307
- similar to list_directory feedback style.
308
+ Display compact capability results in the same style as other tool output.
308
309
  """
309
310
  lines = tool_result.split('\n')
311
+ prefix = "╰─ " if not panel_updater else ""
310
312
 
311
- # Extract query from "Found N plugin(s) matching 'query':" line
312
313
  query = ""
313
- query_match = re.search(r"matching '([^']+)':", tool_result)
314
- if query_match:
315
- query = query_match.group(1)
314
+ capabilities = []
315
+ in_capability_section = False
316
316
 
317
- # Parse plugin entries: - **name** [category] (status): description
318
- # Tags: tag1, tag2
319
- plugins = []
320
317
  i = 0
321
318
  while i < len(lines):
322
- plugin_match = re.match(r'^- \*\*(.+?)\*\*(?:\s+\[(.+?)\])?\s+\((.+?)\):\s+(.+)$', lines[i])
323
- if plugin_match:
324
- name = plugin_match.group(1)
325
- category = plugin_match.group(2) or ""
326
- status = plugin_match.group(3)
327
- description = plugin_match.group(4)
328
-
329
- # Check next line for tags
330
- tags = ""
331
- if i + 1 < len(lines) and lines[i + 1].strip().startswith("Tags:"):
332
- tags = lines[i + 1].strip().replace("Tags: ", "")
319
+ raw_line = lines[i]
320
+ line = raw_line.strip()
321
+
322
+ if line.startswith(HEADER_MATCHES):
323
+ query = line.replace(HEADER_MATCHES, "", 1)
324
+ in_capability_section = True
325
+ i += 1
326
+ continue
327
+
328
+ if line == HEADER_ALL:
329
+ query = line
330
+ in_capability_section = True
331
+ i += 1
332
+ continue
333
+
334
+ if not in_capability_section:
335
+ i += 1
336
+ continue
337
+
338
+ if line.startswith("Results: ") or line.startswith("Total: ") or not line:
339
+ i += 1
340
+ continue
341
+
342
+ if line.startswith("Activated plugins:") or line.startswith("Loaded skills:") or line.startswith("Load issues:"):
343
+ break
344
+
345
+ item_match = re.match(r'^- (.+)$', line)
346
+ if item_match:
347
+ item = {
348
+ "name": item_match.group(1),
349
+ "type": "",
350
+ "status": "",
351
+ "summary": "",
352
+ "tags": "",
353
+ }
354
+ i += 1
355
+ while i < len(lines):
356
+ raw_detail_line = lines[i]
357
+ stripped = raw_detail_line.strip()
358
+ if not stripped:
359
+ i += 1
360
+ continue
361
+ if re.match(r'^- (.+)$', stripped):
362
+ break
363
+ if stripped.startswith("Activated plugins:") or stripped.startswith("Loaded skills:") or stripped.startswith("Load issues:"):
364
+ break
365
+ if stripped.startswith("type: "):
366
+ item["type"] = stripped.replace("type: ", "", 1)
367
+ elif stripped.startswith("status: "):
368
+ item["status"] = stripped.replace("status: ", "", 1)
369
+ elif stripped.startswith("summary: "):
370
+ item["summary"] = stripped.replace("summary: ", "", 1)
371
+ elif stripped.startswith("tags: "):
372
+ item["tags"] = stripped.replace("tags: ", "", 1)
333
373
  i += 1
374
+ capabilities.append(item)
375
+ continue
334
376
 
335
- plugins.append({
336
- "name": name,
337
- "category": category,
338
- "status": status,
339
- "description": description,
340
- "tags": tags,
341
- })
342
377
  i += 1
343
378
 
344
- if not plugins:
345
- # No plugins found — just show the message
346
- prefix = "╰─ " if not panel_updater else ""
347
- # Extract the informational message (skip exit_code line)
379
+ if not capabilities:
348
380
  msg_lines = [l for l in lines if l.strip() and not l.startswith("exit_code=")]
349
- output = prefix + "\n".join(msg_lines) if msg_lines else f"{prefix}No plugins found"
381
+ output = prefix + "\n".join(msg_lines) if msg_lines else f"{prefix}No capability matches"
350
382
  _print_or_append(output, console, panel_updater)
351
383
  if not panel_updater:
352
384
  console.print()
353
385
  return
354
386
 
355
- # Build tree display
356
- max_display = 10
357
- display_plugins = plugins[:max_display]
358
- remaining = max(0, len(plugins) - max_display)
387
+ def append_wrapped_detail(line_parts, text, indent, width=72):
388
+ wrapped_lines = textwrap.wrap(text, width=width) or [text]
389
+ line_parts.extend(f"{indent}[dim]{wrapped}[/dim]" for wrapped in wrapped_lines)
359
390
 
360
- # Count activated vs already active
361
- activated_count = sum(1 for p in plugins if p["status"] == "activated")
362
- already_active_count = sum(1 for p in plugins if p["status"] == "already active")
391
+ max_display = 10
392
+ display_capabilities = capabilities[:max_display]
393
+ remaining = max(0, len(capabilities) - max_display)
363
394
 
395
+ header = f"{query} ({len(capabilities)} match{'es' if len(capabilities) != 1 else ''})" if query else "capabilities"
364
396
  tree_lines = []
365
- for i, plugin in enumerate(display_plugins):
366
- is_last = (i == len(display_plugins) - 1) and (remaining == 0)
367
- connector = "└─" if is_last else "├─"
368
397
 
369
- # Build the plugin line: name [category] (status)
370
- cat_part = f" [{plugin['category']}]" if plugin['category'] else ""
371
- status_part = plugin['status']
398
+ for idx, capability in enumerate(display_capabilities):
399
+ is_last = (idx == len(display_capabilities) - 1) and (remaining == 0)
400
+ connector = "└─" if is_last else "├─"
372
401
 
373
- if status_part == "activated":
374
- status_display = f"[green]{status_part}[/green]"
402
+ if capability["type"] == "plugin":
403
+ meta = "[dim](plugin)[/dim]"
404
+ if capability["status"]:
405
+ meta += f" [dim][{capability['status']}][/dim]"
375
406
  else:
376
- status_display = f"[dim]{status_part}[/dim]"
377
-
378
- line = f" {connector} **{plugin['name']}**{cat_part} ({status_display}): {plugin['description']}"
379
- tree_lines.append(line)
407
+ meta = "[dim](skill)[/dim]"
380
408
 
381
- # Tags on sub-line
382
- if plugin["tags"]:
383
- tag_connector = "│ " if not is_last else " "
384
- tree_lines.append(f"{tag_connector} Tags: [dim]{plugin['tags']}[/dim]")
409
+ detail_indent = " │ " if not is_last else " "
410
+ line_parts = [f" {connector} {capability['name']} {meta}"]
411
+ if capability["summary"]:
412
+ append_wrapped_detail(line_parts, capability["summary"], detail_indent)
413
+ if capability["tags"]:
414
+ append_wrapped_detail(line_parts, f"tags: {capability['tags']}", detail_indent)
415
+ tree_lines.append("\n".join(line_parts))
385
416
 
386
- # Add overflow indicator
387
417
  if remaining > 0:
388
418
  tree_lines.append(f" └─ ... and {remaining} more")
389
419
 
390
- # Build header
391
- if query:
392
- header = f"plugins matching '{query}' ({len(plugins)} found)"
393
- else:
394
- header = f"plugins ({len(plugins)} found)"
395
-
396
- prefix = "╰─ " if not panel_updater else ""
397
- output = f"{prefix}{header}\n"
398
- output += "\n".join(tree_lines)
399
-
400
- # Activation summary
401
- if activated_count > 0 or already_active_count > 0:
402
- summary_parts = []
403
- if activated_count > 0:
404
- summary_parts.append(f"{activated_count} activated")
405
- if already_active_count > 0:
406
- summary_parts.append(f"{already_active_count} already active")
407
- summary = ", ".join(summary_parts)
408
- output += f"\n{prefix}[dim]{summary}. Schemas available next turn. Auto-evict after 10 turns of non-use.[/dim]"
409
-
420
+ output = f"{prefix}{header}\n" + "\n".join(tree_lines)
410
421
  _print_or_append(output, console, panel_updater)
411
422
 
412
423
  if not panel_updater:
package/src/llm/client.py CHANGED
@@ -127,8 +127,13 @@ class LLMClient:
127
127
  self.handler.parse_stream(response)
128
128
  )
129
129
  else:
130
- response_json = response.json()
131
- return self.handler.parse_response(response_json)
130
+ try:
131
+ response_json = response.json()
132
+ return self.handler.parse_response(response_json)
133
+ except ValueError:
134
+ if getattr(self.handler, "supports_sse_response_fallback", False):
135
+ return self.handler.parse_sse_response(response.text)
136
+ raise
132
137
 
133
138
  except requests.exceptions.RequestException as e:
134
139
  raise LLMConnectionError(