bone-agent 1.3.1 → 1.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bone-agent",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "A terminal-based AI coding assistant powered by OpenAI-style function calling",
5
5
  "main": "src/ui/main.py",
6
6
  "bin": {
@@ -380,7 +380,7 @@ class AgenticOrchestrator:
380
380
  self.chat_manager.log_message(response)
381
381
 
382
382
  # NEW: Compact tool results after final answer (per-message compaction)
383
- self.chat_manager.compact_tool_results()
383
+ self.chat_manager.compact_tool_results(skip_token_update=True)
384
384
 
385
385
  # Update context tokens with current mode's tools
386
386
  tools_for_mode = TOOLS()
@@ -597,9 +597,8 @@ class AgenticOrchestrator:
597
597
  # Log tool result
598
598
  self.chat_manager.log_message(tool_msg)
599
599
 
600
- # Mid-loop compaction: compact older completed tool blocks
601
- # after each tool result is appended (safe — only compacts completed blocks)
602
- self.chat_manager.compact_tool_results()
600
+ # Compact completed tool blocks once after all tools complete
601
+ self.chat_manager.compact_tool_results(skip_token_update=True)
603
602
 
604
603
  # Update context tokens with current mode's tools
605
604
  tools_for_mode = TOOLS()
@@ -840,7 +839,7 @@ class AgenticOrchestrator:
840
839
 
841
840
  # Mid-loop compaction: compact older completed tool blocks
842
841
  # after all parallel results are appended (safe — only compacts completed blocks)
843
- self.chat_manager.compact_tool_results()
842
+ self.chat_manager.compact_tool_results(skip_token_update=True)
844
843
 
845
844
  # Update context tokens with current mode's tools
846
845
  tools_for_mode = TOOLS()
@@ -1023,6 +1022,10 @@ class AgenticOrchestrator:
1023
1022
 
1024
1023
  return False, str(result)
1025
1024
  except Exception as e:
1025
+ # If thinking_indicator was paused (TERMINAL_YIELD) and tool
1026
+ # raised, resume it so the spinner reappears for the next iteration
1027
+ if policy == TERMINAL_YIELD and thinking_indicator:
1028
+ thinking_indicator.resume()
1026
1029
  return False, f"Error executing tool '{function_name}': {str(e)}"
1027
1030
 
1028
1031
  return False, f"Error: Unknown tool '{function_name}'."
@@ -383,7 +383,7 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
383
383
 
384
384
  # ===== Tool Result Compaction =====
385
385
 
386
- def _find_tool_blocks(self):
386
+ def _find_tool_blocks(self, include_in_flight=False):
387
387
  """Find all tool-result blocks in message history.
388
388
 
389
389
  Handles both single-turn and multi-turn tool chains:
@@ -394,6 +394,12 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
394
394
  a single block spanning from the first assistant(tool_calls) to the
395
395
  final assistant(answer).
396
396
 
397
+ Args:
398
+ include_in_flight: If True, also return blocks that lack a final
399
+ assistant answer (in-flight tool chains). The 'end' field points
400
+ to the index after the last message in the chain (or the breaking
401
+ message index if the chain was interrupted).
402
+
397
403
  Returns:
398
404
  list: List of block dicts with keys: user_idx, start, end, tool_calls, tool_results
399
405
  """
@@ -441,14 +447,25 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
441
447
  # Non-tool, non-assistant message breaks the chain
442
448
  break
443
449
 
444
- if found_end and all_tool_calls:
445
- blocks.append({
446
- 'user_idx': user_idx,
447
- 'start': block_start,
448
- 'end': j,
449
- 'tool_calls': all_tool_calls,
450
- 'tool_results': all_tool_results
451
- })
450
+ if include_in_flight:
451
+ if all_tool_calls:
452
+ blocks.append({
453
+ 'user_idx': user_idx,
454
+ 'start': block_start,
455
+ 'end': j,
456
+ 'tool_calls': all_tool_calls,
457
+ 'tool_results': all_tool_results,
458
+ 'in_flight': not found_end,
459
+ })
460
+ else:
461
+ if found_end and all_tool_calls:
462
+ blocks.append({
463
+ 'user_idx': user_idx,
464
+ 'start': block_start,
465
+ 'end': j,
466
+ 'tool_calls': all_tool_calls,
467
+ 'tool_results': all_tool_results,
468
+ })
452
469
 
453
470
  # Continue scanning from after the final answer (or after the chain)
454
471
  # Guard: always advance at least one position to prevent infinite loops
@@ -635,68 +652,21 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
635
652
  def _find_in_flight_boundary(self):
636
653
  """Find the index where in-flight tool blocks begin.
637
654
 
638
- Scans from the end of messages for any assistant message with tool_calls
639
- that does NOT have a corresponding final assistant answer after it.
655
+ Delegates to _find_tool_blocks(include_in_flight=True) to find all
656
+ blocks, then returns the earliest start of any in-flight block.
640
657
  These messages must never be included in the compactable region.
641
658
 
642
659
  Returns:
643
660
  int: Index of the first in-flight message, or len(messages) if none.
644
661
  """
645
- n = len(self.messages)
646
- i = n - 1
647
-
648
- # Walk backward looking for the pattern: ...assistant(tool_calls) tool_results...
649
- # without a final assistant(answer) after the tool results.
650
- while i >= 0:
651
- msg = self.messages[i]
652
- if msg.get('role') == 'assistant' and msg.get('tool_calls'):
653
- # Found an assistant with tool_calls. Check if there's a final
654
- # answer (assistant without tool_calls) after it.
655
- has_final_answer = False
656
- j = i + 1
657
- while j < n:
658
- if self.messages[j].get('role') == 'assistant' and not self.messages[j].get('tool_calls'):
659
- has_final_answer = True
660
- break
661
- elif self.messages[j].get('role') == 'assistant' and self.messages[j].get('tool_calls'):
662
- # Another tool-calling assistant — skip over its tool results
663
- j += 1
664
- while j < n and self.messages[j].get('role') == 'tool':
665
- j += 1
666
- continue
667
- elif self.messages[j].get('role') == 'tool':
668
- j += 1
669
- continue
670
- else:
671
- break
672
-
673
- if not has_final_answer:
674
- # This is an in-flight tool block. Find its user question.
675
- user_idx = i - 1
676
- while user_idx >= 0 and self.messages[user_idx].get('role') != 'user':
677
- user_idx -= 1
678
- return max(0, user_idx)
679
- else:
680
- # Completed block — continue scanning backward
681
- # Skip past all the tool messages associated with this block
682
- j = i + 1
683
- while j < n:
684
- if self.messages[j].get('role') == 'tool':
685
- j += 1
686
- elif self.messages[j].get('role') == 'assistant' and self.messages[j].get('tool_calls'):
687
- j += 1
688
- while j < n and self.messages[j].get('role') == 'tool':
689
- j += 1
690
- continue
691
- else:
692
- break
693
- i = j - 1
694
- else:
695
- i -= 1
696
-
697
- return n
698
-
699
- def _compute_split_boundary(self, blocks, in_flight_start):
662
+ all_blocks = self._find_tool_blocks(include_in_flight=True)
663
+ in_flight = [b for b in all_blocks if b.get('in_flight')]
664
+ if in_flight:
665
+ return min(b['user_idx'] for b in in_flight)
666
+ return len(self.messages)
667
+
668
+ def _compute_split_boundary(self, blocks, in_flight_start,
669
+ uncompacted_tail_tokens=None, min_tool_blocks=None):
700
670
  """Compute the message index where the uncompacted tail begins.
701
671
 
702
672
  Three constraints determine the boundary (take the most conservative /
@@ -709,19 +679,23 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
709
679
  Args:
710
680
  blocks: List of tool block dicts from _find_tool_blocks()
711
681
  in_flight_start: Index of first in-flight message (from _find_in_flight_boundary)
682
+ uncompacted_tail_tokens: Override for the token budget (None = use settings)
683
+ min_tool_blocks: Override for minimum tool blocks to preserve (None = use settings)
712
684
 
713
685
  Returns:
714
686
  int: Message index where the uncompacted tail starts
715
687
  """
716
688
  tc = context_settings.tool_compaction
717
- token_budget = tc.uncompacted_tail_tokens
718
- min_blocks = tc.min_tool_blocks
689
+ token_budget = uncompacted_tail_tokens if uncompacted_tail_tokens is not None else tc.uncompacted_tail_tokens
690
+ min_blocks = min_tool_blocks if min_tool_blocks is not None else tc.min_tool_blocks
719
691
  n = len(self.messages)
720
692
 
721
693
  # The verbatim region ends at the first in-flight message (exclusive)
722
694
  verbatim_end = min(in_flight_start, n)
723
695
 
724
- # Constraint 1: Token budget — walk from verbatim_end backward
696
+ # Constraint 1: Token budget — walk from verbatim_end backward.
697
+ # Note: range stops at 1 (not 0) so the system prompt is never counted
698
+ # toward the budget — it is always preserved uncompacted.
725
699
  tokens_accumulated = 0
726
700
  token_boundary = 0
727
701
  for i in range(verbatim_end - 1, 0, -1):
@@ -734,25 +708,16 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
734
708
  token_boundary = 1
735
709
 
736
710
  # Constraint 2: Minimum tool blocks — ensure at least min_blocks completed
737
- # blocks are within the verbatim tail
711
+ # blocks are within the uncompacted tail. Take the min_blocks most recent
712
+ # completed blocks and set the boundary so they all fall at or after it.
738
713
  min_block_boundary = 1
739
- completed_blocks_in_tail = []
740
- for block in blocks:
741
- # A block is in the tail if its end is within the verbatim region
742
- if block['end'] < verbatim_end:
743
- completed_blocks_in_tail.append(block)
744
-
745
- if len(completed_blocks_in_tail) < min_blocks and len(completed_blocks_in_tail) < len(blocks):
746
- # Need to extend backward to include more blocks
747
- blocks_needed = min_blocks - len(completed_blocks_in_tail)
748
- # Take the blocks immediately before the current tail
749
- # Find blocks whose end < token_boundary (not already in tail)
750
- earlier_blocks = [b for b in blocks if b['end'] < token_boundary]
751
- # Sort by end index descending (most recent first)
752
- earlier_blocks.sort(key=lambda b: b['end'], reverse=True)
753
- # Extend boundary to include the earliest user_idx of the blocks we need
754
- for b in earlier_blocks[:blocks_needed]:
755
- min_block_boundary = min(min_block_boundary, b['user_idx'])
714
+ if min_blocks > 0 and len(blocks) >= min_blocks:
715
+ # Sort by end index descending (most recent first), take top min_blocks
716
+ sorted_blocks = sorted(blocks, key=lambda b: b['end'], reverse=True)
717
+ recent_blocks = sorted_blocks[:min_blocks]
718
+ # The boundary must be at or before the earliest user_idx of these blocks
719
+ # so that all of them satisfy user_idx >= boundary (i.e. block is fully in the tail)
720
+ min_block_boundary = min(b['user_idx'] for b in recent_blocks)
756
721
 
757
722
  # Constraint 3: Tool-call integrity — if token_boundary lands inside a
758
723
  # tool block, extend backward to include the complete block
@@ -763,13 +728,15 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
763
728
  integrity_boundary = min(integrity_boundary, block['user_idx'])
764
729
 
765
730
  # Take the most conservative (earliest) boundary
766
- boundary = min(token_boundary, integrity_boundary)
731
+ # integrity_boundary <= token_boundary always (starts equal, only decreases)
732
+ boundary = integrity_boundary
767
733
  if min_block_boundary < boundary:
768
734
  boundary = min_block_boundary
769
735
 
770
736
  return boundary
771
737
 
772
- def compact_tool_results(self):
738
+ def compact_tool_results(self, skip_token_update=False,
739
+ uncompacted_tail_tokens=None, min_tool_blocks=None):
773
740
  """Replace completed tool-result blocks with summaries using token-budget tail.
774
741
 
775
742
  Walks messages from the end, accumulating tokens until ~40k tokens are
@@ -779,6 +746,15 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
779
746
 
780
747
  Safe to call mid-loop (during tool execution) because it only compacts
781
748
  completed tool blocks — in-flight blocks are never touched.
749
+
750
+ Args:
751
+ skip_token_update: If True, skip the internal _update_context_tokens()
752
+ call. Use when the caller will update tokens with mode-specific
753
+ tools immediately after.
754
+ uncompacted_tail_tokens: Override for the token budget (None = use settings).
755
+ Use for aggressive compaction with a smaller tail.
756
+ min_tool_blocks: Override for minimum tool blocks to preserve (None = use settings).
757
+ Use for aggressive compaction with fewer preserved blocks.
782
758
  """
783
759
  # Skip if disabled (e.g. sub-agents preserving findings)
784
760
  if self._compaction_disabled:
@@ -801,7 +777,11 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
801
777
  in_flight_start = self._find_in_flight_boundary()
802
778
 
803
779
  # Compute the split boundary using token budget + constraints
804
- split_boundary = self._compute_split_boundary(blocks, in_flight_start)
780
+ split_boundary = self._compute_split_boundary(
781
+ blocks, in_flight_start,
782
+ uncompacted_tail_tokens=uncompacted_tail_tokens,
783
+ min_tool_blocks=min_tool_blocks,
784
+ )
805
785
 
806
786
  # Determine which blocks fall entirely before the split boundary
807
787
  # (those are the ones to compact)
@@ -864,7 +844,8 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
864
844
  new_messages.append(msg)
865
845
 
866
846
  self.messages = new_messages
867
- self._update_context_tokens()
847
+ if not skip_token_update:
848
+ self._update_context_tokens()
868
849
 
869
850
  # ===== AI-Based History Compaction =====
870
851
 
@@ -1101,16 +1082,12 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
1101
1082
  # If compaction is NOT locked, try layers 1 and 2
1102
1083
  if not self._compaction_locked:
1103
1084
  # Layer 1: Aggressive tool result compaction (non-LLM, fast)
1104
- # Temporarily use very small token budget and min blocks for aggressive compaction
1105
- original_tail_tokens = context_settings.tool_compaction.uncompacted_tail_tokens
1106
- original_min_blocks = context_settings.tool_compaction.min_tool_blocks
1107
- try:
1108
- context_settings.tool_compaction.uncompacted_tail_tokens = 10_000
1109
- context_settings.tool_compaction.min_tool_blocks = 1
1110
- self.compact_tool_results()
1111
- finally:
1112
- context_settings.tool_compaction.uncompacted_tail_tokens = original_tail_tokens
1113
- context_settings.tool_compaction.min_tool_blocks = original_min_blocks
1085
+ # Use very small token budget and min blocks for aggressive compaction
1086
+ self.compact_tool_results(
1087
+ skip_token_update=True,
1088
+ uncompacted_tail_tokens=10_000,
1089
+ min_tool_blocks=1,
1090
+ )
1114
1091
 
1115
1092
  self._update_context_tokens()
1116
1093
  current_tokens = self.token_tracker.current_context_tokens
@@ -1,6 +1,5 @@
1
1
  """Interactive selection tool for presenting multiple-choice questions to the user."""
2
2
 
3
- import asyncio
4
3
  from html import escape as _html_escape
5
4
  from threading import Timer
6
5
  from typing import Optional, List, Dict, Any, Union
@@ -49,6 +48,7 @@ class SelectionPanel:
49
48
  # Inline custom input editing state
50
49
  self._editing_custom_input = False
51
50
  self._custom_input_texts: Dict[int, str] = {} # question_idx -> typed text
51
+ self._auto_advance_timer: Optional[Timer] = None # Track for cancellation
52
52
 
53
53
  # Multi-select state: per-question set of checked option indices
54
54
  self._checked_indices: Dict[int, set] = {
@@ -265,7 +265,8 @@ class SelectionPanel:
265
265
  # Single question - show summary then auto-exit
266
266
  self._showing_summary = True
267
267
  event.app.invalidate()
268
- Timer(1.0, lambda: event.app.exit(result=self.selections[0])).start()
268
+ self._auto_advance_timer = Timer(1.0, lambda: event.app.exit(result=self.selections[0]))
269
+ self._auto_advance_timer.start()
269
270
  else:
270
271
  # Multi-question - advance or finish
271
272
  if self.current_question_idx < len(self.questions) - 1:
@@ -275,7 +276,8 @@ class SelectionPanel:
275
276
  else:
276
277
  self._showing_summary = True
277
278
  event.app.invalidate()
278
- Timer(1.0, lambda: event.app.exit(result=self.selections)).start()
279
+ self._auto_advance_timer = Timer(1.0, lambda: event.app.exit(result=self.selections))
280
+ self._auto_advance_timer.start()
279
281
 
280
282
  def run(self) -> Optional[Union[str, List[str]]]:
281
283
  """Display the selection panel and wait for user input.
@@ -401,6 +403,9 @@ class SelectionPanel:
401
403
  event.app.invalidate()
402
404
  else:
403
405
  # Cancel entire selection
406
+ if self._auto_advance_timer:
407
+ self._auto_advance_timer.cancel()
408
+ self._auto_advance_timer = None
404
409
  event.app.exit(result=None)
405
410
 
406
411
  # Printable character input for custom input editing
@@ -465,8 +470,10 @@ class SelectionPanel:
465
470
  style=TOOLBAR_STYLE,
466
471
  )
467
472
 
468
- # Use run_async with asyncio to properly await coroutines
469
- result = asyncio.run(application.run_async())
473
+ # Use prompt_toolkit's synchronous runner avoids creating/destroying
474
+ # an event loop with asyncio.run(), which corrupts the parent
475
+ # PromptSession's event loop state and causes 100% CPU hangs.
476
+ result = application.run()
470
477
 
471
478
  return result
472
479