kollabor 0.4.9__py3-none-any.whl → 0.4.15__py3-none-any.whl

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 (192) hide show
  1. agents/__init__.py +2 -0
  2. agents/coder/__init__.py +0 -0
  3. agents/coder/agent.json +4 -0
  4. agents/coder/api-integration.md +2150 -0
  5. agents/coder/cli-pretty.md +765 -0
  6. agents/coder/code-review.md +1092 -0
  7. agents/coder/database-design.md +1525 -0
  8. agents/coder/debugging.md +1102 -0
  9. agents/coder/dependency-management.md +1397 -0
  10. agents/coder/git-workflow.md +1099 -0
  11. agents/coder/refactoring.md +1454 -0
  12. agents/coder/security-hardening.md +1732 -0
  13. agents/coder/system_prompt.md +1448 -0
  14. agents/coder/tdd.md +1367 -0
  15. agents/creative-writer/__init__.py +0 -0
  16. agents/creative-writer/agent.json +4 -0
  17. agents/creative-writer/character-development.md +1852 -0
  18. agents/creative-writer/dialogue-craft.md +1122 -0
  19. agents/creative-writer/plot-structure.md +1073 -0
  20. agents/creative-writer/revision-editing.md +1484 -0
  21. agents/creative-writer/system_prompt.md +690 -0
  22. agents/creative-writer/worldbuilding.md +2049 -0
  23. agents/data-analyst/__init__.py +30 -0
  24. agents/data-analyst/agent.json +4 -0
  25. agents/data-analyst/data-visualization.md +992 -0
  26. agents/data-analyst/exploratory-data-analysis.md +1110 -0
  27. agents/data-analyst/pandas-data-manipulation.md +1081 -0
  28. agents/data-analyst/sql-query-optimization.md +881 -0
  29. agents/data-analyst/statistical-analysis.md +1118 -0
  30. agents/data-analyst/system_prompt.md +928 -0
  31. agents/default/__init__.py +0 -0
  32. agents/default/agent.json +4 -0
  33. agents/default/dead-code.md +794 -0
  34. agents/default/explore-agent-system.md +585 -0
  35. agents/default/system_prompt.md +1448 -0
  36. agents/kollabor/__init__.py +0 -0
  37. agents/kollabor/analyze-plugin-lifecycle.md +175 -0
  38. agents/kollabor/analyze-terminal-rendering.md +388 -0
  39. agents/kollabor/code-review.md +1092 -0
  40. agents/kollabor/debug-mcp-integration.md +521 -0
  41. agents/kollabor/debug-plugin-hooks.md +547 -0
  42. agents/kollabor/debugging.md +1102 -0
  43. agents/kollabor/dependency-management.md +1397 -0
  44. agents/kollabor/git-workflow.md +1099 -0
  45. agents/kollabor/inspect-llm-conversation.md +148 -0
  46. agents/kollabor/monitor-event-bus.md +558 -0
  47. agents/kollabor/profile-performance.md +576 -0
  48. agents/kollabor/refactoring.md +1454 -0
  49. agents/kollabor/system_prompt copy.md +1448 -0
  50. agents/kollabor/system_prompt.md +757 -0
  51. agents/kollabor/trace-command-execution.md +178 -0
  52. agents/kollabor/validate-config.md +879 -0
  53. agents/research/__init__.py +0 -0
  54. agents/research/agent.json +4 -0
  55. agents/research/architecture-mapping.md +1099 -0
  56. agents/research/codebase-analysis.md +1077 -0
  57. agents/research/dependency-audit.md +1027 -0
  58. agents/research/performance-profiling.md +1047 -0
  59. agents/research/security-review.md +1359 -0
  60. agents/research/system_prompt.md +492 -0
  61. agents/technical-writer/__init__.py +0 -0
  62. agents/technical-writer/agent.json +4 -0
  63. agents/technical-writer/api-documentation.md +2328 -0
  64. agents/technical-writer/changelog-management.md +1181 -0
  65. agents/technical-writer/readme-writing.md +1360 -0
  66. agents/technical-writer/style-guide.md +1410 -0
  67. agents/technical-writer/system_prompt.md +653 -0
  68. agents/technical-writer/tutorial-creation.md +1448 -0
  69. core/__init__.py +0 -2
  70. core/application.py +343 -88
  71. core/cli.py +229 -10
  72. core/commands/menu_renderer.py +463 -59
  73. core/commands/registry.py +14 -9
  74. core/commands/system_commands.py +2461 -14
  75. core/config/loader.py +151 -37
  76. core/config/service.py +18 -6
  77. core/events/bus.py +29 -9
  78. core/events/executor.py +205 -75
  79. core/events/models.py +27 -8
  80. core/fullscreen/command_integration.py +20 -24
  81. core/fullscreen/components/__init__.py +10 -1
  82. core/fullscreen/components/matrix_components.py +1 -2
  83. core/fullscreen/components/space_shooter_components.py +654 -0
  84. core/fullscreen/plugin.py +5 -0
  85. core/fullscreen/renderer.py +52 -13
  86. core/fullscreen/session.py +52 -15
  87. core/io/__init__.py +29 -5
  88. core/io/buffer_manager.py +6 -1
  89. core/io/config_status_view.py +7 -29
  90. core/io/core_status_views.py +267 -347
  91. core/io/input/__init__.py +25 -0
  92. core/io/input/command_mode_handler.py +711 -0
  93. core/io/input/display_controller.py +128 -0
  94. core/io/input/hook_registrar.py +286 -0
  95. core/io/input/input_loop_manager.py +421 -0
  96. core/io/input/key_press_handler.py +502 -0
  97. core/io/input/modal_controller.py +1011 -0
  98. core/io/input/paste_processor.py +339 -0
  99. core/io/input/status_modal_renderer.py +184 -0
  100. core/io/input_errors.py +5 -1
  101. core/io/input_handler.py +211 -2452
  102. core/io/key_parser.py +7 -0
  103. core/io/layout.py +15 -3
  104. core/io/message_coordinator.py +111 -2
  105. core/io/message_renderer.py +129 -4
  106. core/io/status_renderer.py +147 -607
  107. core/io/terminal_renderer.py +97 -51
  108. core/io/terminal_state.py +21 -4
  109. core/io/visual_effects.py +816 -165
  110. core/llm/agent_manager.py +1063 -0
  111. core/llm/api_adapters/__init__.py +44 -0
  112. core/llm/api_adapters/anthropic_adapter.py +432 -0
  113. core/llm/api_adapters/base.py +241 -0
  114. core/llm/api_adapters/openai_adapter.py +326 -0
  115. core/llm/api_communication_service.py +167 -113
  116. core/llm/conversation_logger.py +322 -16
  117. core/llm/conversation_manager.py +556 -30
  118. core/llm/file_operations_executor.py +84 -32
  119. core/llm/llm_service.py +934 -103
  120. core/llm/mcp_integration.py +541 -57
  121. core/llm/message_display_service.py +135 -18
  122. core/llm/plugin_sdk.py +1 -2
  123. core/llm/profile_manager.py +1183 -0
  124. core/llm/response_parser.py +274 -56
  125. core/llm/response_processor.py +16 -3
  126. core/llm/tool_executor.py +6 -1
  127. core/logging/__init__.py +2 -0
  128. core/logging/setup.py +34 -6
  129. core/models/resume.py +54 -0
  130. core/plugins/__init__.py +4 -2
  131. core/plugins/base.py +127 -0
  132. core/plugins/collector.py +23 -161
  133. core/plugins/discovery.py +37 -3
  134. core/plugins/factory.py +6 -12
  135. core/plugins/registry.py +5 -17
  136. core/ui/config_widgets.py +128 -28
  137. core/ui/live_modal_renderer.py +2 -1
  138. core/ui/modal_actions.py +5 -0
  139. core/ui/modal_overlay_renderer.py +0 -60
  140. core/ui/modal_renderer.py +268 -7
  141. core/ui/modal_state_manager.py +29 -4
  142. core/ui/widgets/base_widget.py +7 -0
  143. core/updates/__init__.py +10 -0
  144. core/updates/version_check_service.py +348 -0
  145. core/updates/version_comparator.py +103 -0
  146. core/utils/config_utils.py +685 -526
  147. core/utils/plugin_utils.py +1 -1
  148. core/utils/session_naming.py +111 -0
  149. fonts/LICENSE +21 -0
  150. fonts/README.md +46 -0
  151. fonts/SymbolsNerdFont-Regular.ttf +0 -0
  152. fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
  153. fonts/__init__.py +44 -0
  154. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
  155. kollabor-0.4.15.dist-info/RECORD +228 -0
  156. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
  157. plugins/agent_orchestrator/__init__.py +39 -0
  158. plugins/agent_orchestrator/activity_monitor.py +181 -0
  159. plugins/agent_orchestrator/file_attacher.py +77 -0
  160. plugins/agent_orchestrator/message_injector.py +135 -0
  161. plugins/agent_orchestrator/models.py +48 -0
  162. plugins/agent_orchestrator/orchestrator.py +403 -0
  163. plugins/agent_orchestrator/plugin.py +976 -0
  164. plugins/agent_orchestrator/xml_parser.py +191 -0
  165. plugins/agent_orchestrator_plugin.py +9 -0
  166. plugins/enhanced_input/box_styles.py +1 -0
  167. plugins/enhanced_input/color_engine.py +19 -4
  168. plugins/enhanced_input/config.py +2 -2
  169. plugins/enhanced_input_plugin.py +61 -11
  170. plugins/fullscreen/__init__.py +6 -2
  171. plugins/fullscreen/example_plugin.py +1035 -222
  172. plugins/fullscreen/setup_wizard_plugin.py +592 -0
  173. plugins/fullscreen/space_shooter_plugin.py +131 -0
  174. plugins/hook_monitoring_plugin.py +436 -78
  175. plugins/query_enhancer_plugin.py +66 -30
  176. plugins/resume_conversation_plugin.py +1494 -0
  177. plugins/save_conversation_plugin.py +98 -32
  178. plugins/system_commands_plugin.py +70 -56
  179. plugins/tmux_plugin.py +154 -78
  180. plugins/workflow_enforcement_plugin.py +94 -92
  181. system_prompt/default.md +952 -886
  182. core/io/input_mode_manager.py +0 -402
  183. core/io/modal_interaction_handler.py +0 -315
  184. core/io/raw_input_processor.py +0 -946
  185. core/storage/__init__.py +0 -5
  186. core/storage/state_manager.py +0 -84
  187. core/ui/widget_integration.py +0 -222
  188. core/utils/key_reader.py +0 -171
  189. kollabor-0.4.9.dist-info/RECORD +0 -128
  190. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
  191. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
  192. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
core/llm/llm_service.py CHANGED
@@ -17,6 +17,7 @@ from ..events import EventType, Hook, HookPriority
17
17
  from ..config.llm_task_config import LLMTaskConfig
18
18
  from .api_communication_service import APICommunicationService
19
19
  from .conversation_logger import KollaborConversationLogger
20
+ from .conversation_manager import ConversationManager
20
21
  from .hook_system import LLMHookSystem
21
22
  from .mcp_integration import MCPIntegration
22
23
  from .message_display_service import MessageDisplayService
@@ -73,27 +74,40 @@ class LLMService:
73
74
  import uuid
74
75
  message_uuid = str(uuid.uuid4())
75
76
 
76
- # Add to legacy history for compatibility
77
+ # conversation_history: primary list used by API calls
78
+ # conversation_manager: adds persistence, UUID tracking, metadata
79
+ # both systems stay synchronized
77
80
  self.conversation_history.append(message)
78
81
 
79
82
  return message_uuid
80
83
 
81
84
 
82
- def __init__(self, config, state_manager, event_bus, renderer, default_timeout: Optional[float] = None, enable_metrics: bool = False):
85
+ def __init__(
86
+ self,
87
+ config,
88
+ event_bus,
89
+ renderer,
90
+ profile_manager=None,
91
+ agent_manager=None,
92
+ default_timeout: Optional[float] = None,
93
+ enable_metrics: bool = False,
94
+ ):
83
95
  """Initialize the core LLM service.
84
96
 
85
97
  Args:
86
98
  config: Configuration manager instance
87
- state_manager: State management system
88
99
  event_bus: Event bus for hook registration
89
100
  renderer: Terminal renderer for output
101
+ profile_manager: Profile manager for LLM endpoint profiles
102
+ agent_manager: Agent manager for agent/skill system
90
103
  default_timeout: Default timeout for background tasks in seconds
91
104
  enable_metrics: Whether to enable detailed task metrics tracking
92
105
  """
93
106
  self.config = config
94
- self.state_manager = state_manager
95
107
  self.event_bus = event_bus
96
108
  self.renderer = renderer
109
+ self.profile_manager = profile_manager
110
+ self.agent_manager = agent_manager
97
111
 
98
112
  # Timeout and metrics configuration
99
113
  self.default_timeout = default_timeout
@@ -118,16 +132,21 @@ class LLMService:
118
132
  self.cancellation_message_shown = False
119
133
 
120
134
  # Initialize conversation logger with intelligence features
121
- from ..utils.config_utils import get_config_directory
122
- config_dir = get_config_directory()
123
- conversations_dir = config_dir / "conversations"
135
+ from ..utils.config_utils import get_conversations_dir
136
+ conversations_dir = get_conversations_dir()
124
137
  conversations_dir.mkdir(parents=True, exist_ok=True)
125
138
 
126
- # Initialize raw conversation logging directory
127
- self.raw_conversations_dir = config_dir / "conversations_raw"
139
+ # Initialize raw conversation logging directory (inside conversations/)
140
+ self.raw_conversations_dir = conversations_dir / "raw"
128
141
  self.raw_conversations_dir.mkdir(parents=True, exist_ok=True)
129
142
  self.conversation_logger = KollaborConversationLogger(conversations_dir)
130
-
143
+
144
+ # Initialize conversation manager for advanced features
145
+ self.conversation_manager = ConversationManager(
146
+ config=self.config,
147
+ conversation_logger=self.conversation_logger
148
+ )
149
+
131
150
  # Initialize hook system
132
151
  self.hook_system = LLMHookSystem(event_bus)
133
152
 
@@ -137,18 +156,49 @@ class LLMService:
137
156
  self.tool_executor = ToolExecutor(
138
157
  mcp_integration=self.mcp_integration,
139
158
  event_bus=event_bus,
140
- terminal_timeout=config.get("core.llm.terminal_timeout", 30),
141
- mcp_timeout=config.get("core.llm.mcp_timeout", 60)
159
+ terminal_timeout=config.get("core.llm.terminal_timeout", 120),
160
+ mcp_timeout=config.get("core.llm.mcp_timeout", 120)
142
161
  )
162
+
163
+ # Native tool calling support (tools passed to API for native function calling)
164
+ # Both native API tool calls AND XML <tool_call> tags are supported
165
+ self.native_tools: Optional[List[Dict[str, Any]]] = None
166
+ self.native_tool_calling_enabled = config.get("core.llm.native_tool_calling", True)
143
167
 
144
168
  # Initialize message display service (KISS/DRY: eliminates duplicated display code)
145
169
  self.message_display = MessageDisplayService(renderer)
146
-
170
+
171
+ # Get active profile for API service (fallback to minimal default if no profile manager)
172
+ if self.profile_manager:
173
+ api_profile = self.profile_manager.get_active_profile()
174
+ else:
175
+ # Fallback: create minimal default profile (profile_manager should always exist)
176
+ from .profile_manager import LLMProfile
177
+ api_profile = LLMProfile(
178
+ name="default",
179
+ api_url="http://localhost:1234",
180
+ model="default",
181
+ temperature=0.7,
182
+ )
183
+
147
184
  # Initialize API communication service (KISS: pure API communication separation)
148
- self.api_service = APICommunicationService(config, self.raw_conversations_dir)
149
-
185
+ self.api_service = APICommunicationService(config, self.raw_conversations_dir, api_profile)
186
+
187
+ # Link session ID for raw log correlation
188
+ self.api_service.set_session_id(self.conversation_logger.session_id)
189
+
150
190
  # Track current message threading
151
191
  self.current_parent_uuid = None
192
+
193
+ # Plugin instances reference (set after plugins are loaded)
194
+ self._plugin_instances: Optional[Dict[str, Any]] = None
195
+
196
+ # Question gate: pending tools queue
197
+ # When agent uses <question> tag, tool calls are suspended here
198
+ # and injected when user responds
199
+ self.pending_tools: List[Dict[str, Any]] = []
200
+ self.question_gate_active = False
201
+ self.question_gate_enabled = config.get("core.llm.question_gate_enabled", True)
152
202
 
153
203
  # Create hooks for LLM service
154
204
  self.hooks = [
@@ -165,6 +215,13 @@ class LLMService:
165
215
  event_type=EventType.CANCEL_REQUEST,
166
216
  priority=HookPriority.SYSTEM.value,
167
217
  callback=self._handle_cancel_request
218
+ ),
219
+ Hook(
220
+ name="add_message_handler",
221
+ plugin_name="llm_core",
222
+ event_type=EventType.ADD_MESSAGE,
223
+ priority=HookPriority.LLM.value,
224
+ callback=self._handle_add_message
168
225
  )
169
226
  ]
170
227
 
@@ -179,8 +236,10 @@ class LLMService:
179
236
  }
180
237
 
181
238
  self.session_stats = {
182
- "input_tokens": 0,
183
- "output_tokens": 0,
239
+ "input_tokens": 0, # Last request input tokens (context size)
240
+ "output_tokens": 0, # Last request output tokens
241
+ "total_input_tokens": 0, # Cumulative session input
242
+ "total_output_tokens": 0, # Cumulative session output
184
243
  "messages": 0
185
244
  }
186
245
 
@@ -217,7 +276,7 @@ class LLMService:
217
276
 
218
277
  logger.info("Core LLM Service initialized")
219
278
 
220
- async def initialize(self):
279
+ async def initialize(self) -> bool:
221
280
  """Initialize the LLM service components."""
222
281
  # Initialize API communication service (KISS refactoring)
223
282
  await self.api_service.initialize()
@@ -225,16 +284,16 @@ class LLMService:
225
284
  # Register hooks
226
285
  await self.hook_system.register_hooks()
227
286
 
228
- # Discover and register MCP servers and tools
229
- try:
230
- discovered_servers = await self.mcp_integration.discover_mcp_servers()
231
- logger.info(f"Discovered {len(discovered_servers)} MCP servers")
232
- except Exception as e:
233
- logger.warning(f"MCP discovery failed: {e}")
287
+ # Discover MCP servers in background (non-blocking startup)
288
+ # This allows the UI to start immediately while MCP servers connect
289
+ self.create_background_task(self._background_mcp_discovery(), name="mcp_discovery")
234
290
 
235
291
  # Initialize conversation with context
236
292
  await self._initialize_conversation()
237
293
 
294
+ # Set conversation context before logging start
295
+ self._set_conversation_context()
296
+
238
297
  # Log conversation start
239
298
  await self.conversation_logger.log_conversation_start()
240
299
 
@@ -243,14 +302,14 @@ class LLMService:
243
302
  await self.start_task_monitor()
244
303
 
245
304
  logger.info("Core LLM Service initialized and ready")
305
+ return True
246
306
 
247
307
  async def _initialize_conversation(self):
248
308
  """Initialize conversation with project context."""
249
309
  try:
250
310
  # Clear any existing history
251
311
  self.conversation_history = []
252
- self.state_manager.set("llm.conversation_history", [])
253
-
312
+
254
313
  # Build system prompt from configuration
255
314
  initial_message = self._build_system_prompt()
256
315
 
@@ -273,6 +332,45 @@ class LLMService:
273
332
  except Exception as e:
274
333
  logger.error(f"Failed to initialize conversation: {e}")
275
334
 
335
+ def _set_conversation_context(self):
336
+ """Set dynamic context on conversation logger before logging start."""
337
+ # Get version
338
+ try:
339
+ from importlib.metadata import version
340
+ app_version = version("kollabor")
341
+ except Exception:
342
+ try:
343
+ from pathlib import Path
344
+ pyproject = Path(__file__).parent.parent.parent / "pyproject.toml"
345
+ if pyproject.exists():
346
+ for line in pyproject.read_text().split("\n"):
347
+ if line.startswith("version ="):
348
+ app_version = line.split('"')[1]
349
+ break
350
+ else:
351
+ app_version = "unknown"
352
+ else:
353
+ app_version = "unknown"
354
+ except Exception:
355
+ app_version = "unknown"
356
+
357
+ # Get active plugins from event bus
358
+ active_plugins = []
359
+ if self.event_bus and hasattr(self.event_bus, 'registry'):
360
+ try:
361
+ hooks = self.event_bus.registry.get_all_hooks()
362
+ plugin_names = set()
363
+ for hook_list in hooks.values():
364
+ for hook in hook_list:
365
+ if hasattr(hook, '__self__'):
366
+ plugin_names.add(type(hook.__self__).__name__)
367
+ active_plugins = sorted(plugin_names)
368
+ except Exception:
369
+ pass
370
+
371
+ self.conversation_logger.app_version = app_version
372
+ self.conversation_logger.active_plugins = active_plugins
373
+
276
374
  async def _enqueue_with_overflow_strategy(self, message: str) -> None:
277
375
  """Enqueue message with configurable overflow strategy.
278
376
 
@@ -778,40 +876,22 @@ class LLMService:
778
876
  except Exception as e:
779
877
  logger.warning(f"Failed to get tree output: {e}")
780
878
  return "Could not get directory listing"
781
-
782
- def _build_system_prompt(self) -> str:
783
- """Build system prompt from file (not config.json).
784
879
 
785
- Priority:
786
- 1. KOLLABOR_SYSTEM_PROMPT environment variable (direct string)
787
- 2. KOLLABOR_SYSTEM_PROMPT_FILE environment variable (custom file path)
788
- 3. Local .kollabor-cli/system_prompt/default.md (project override)
789
- 4. Global ~/.kollabor-cli/system_prompt/default.md
790
- 5. Fallback to minimal default
880
+ def _finalize_system_prompt(self, prompt_parts: List[str]) -> str:
881
+ """Finalize system prompt by adding common sections.
882
+
883
+ Args:
884
+ prompt_parts: List of prompt parts (base prompt should be first)
791
885
 
792
886
  Returns:
793
- Fully rendered system prompt with all <trender> tags executed.
887
+ Complete system prompt string
794
888
  """
795
- from ..utils.config_utils import get_system_prompt_content, initialize_system_prompt
796
- from ..utils.prompt_renderer import render_system_prompt
797
-
798
- # Ensure system prompts are initialized (copies global to local if needed)
799
- initialize_system_prompt()
800
-
801
- # Load base prompt (checks env vars and files in priority order)
802
- base_prompt = get_system_prompt_content()
803
-
804
- # Render <trender> tags BEFORE building the full prompt
805
- base_prompt = render_system_prompt(base_prompt, timeout=5)
806
-
807
- prompt_parts = [base_prompt]
808
-
809
889
  # Add project structure if enabled
810
890
  include_structure = self.config.get("core.llm.system_prompt.include_project_structure", True)
811
891
  if include_structure:
812
892
  tree_output = self._get_tree_output()
813
893
  prompt_parts.append(f"## Project Structure\n```\n{tree_output}\n```")
814
-
894
+
815
895
  # Add attachment files
816
896
  attachment_files = self.config.get("core.llm.system_prompt.attachment_files", [])
817
897
  for filename in attachment_files:
@@ -823,7 +903,7 @@ class LLMService:
823
903
  logger.debug(f"Attached file: {filename}")
824
904
  except Exception as e:
825
905
  logger.warning(f"Failed to read {filename}: {e}")
826
-
906
+
827
907
  # Add custom prompt files
828
908
  custom_files = self.config.get("core.llm.system_prompt.custom_prompt_files", [])
829
909
  for filename in custom_files:
@@ -835,47 +915,435 @@ class LLMService:
835
915
  logger.debug(f"Added custom prompt: {filename}")
836
916
  except Exception as e:
837
917
  logger.warning(f"Failed to read custom prompt {filename}: {e}")
838
-
918
+
919
+ # Add plugin system prompt additions
920
+ plugin_additions = self._get_plugin_system_prompt_additions()
921
+ for addition in plugin_additions:
922
+ prompt_parts.append(addition)
923
+
839
924
  # Add closing statement
840
925
  prompt_parts.append("This is the codebase and context for our session. You now have full project awareness.")
841
-
926
+
842
927
  return "\n\n".join(prompt_parts)
843
-
928
+
929
+ def set_plugin_instances(self, plugin_instances: Dict[str, Any]) -> None:
930
+ """Set plugin instances reference for system prompt additions.
931
+
932
+ Called by the application after plugins are loaded.
933
+
934
+ Args:
935
+ plugin_instances: Dictionary of plugin name to plugin instance
936
+ """
937
+ self._plugin_instances = plugin_instances
938
+ logger.debug(f"Plugin instances set: {len(plugin_instances)} plugins")
939
+
940
+ def _get_plugin_system_prompt_additions(self) -> List[str]:
941
+ """Get system prompt additions from all plugins.
942
+
943
+ Queries each plugin that implements get_system_prompt_addition()
944
+ and collects their additions.
945
+
946
+ Returns:
947
+ List of system prompt addition strings
948
+ """
949
+ additions = []
950
+
951
+ if not self._plugin_instances:
952
+ return additions
953
+
954
+ for plugin_name, plugin_instance in self._plugin_instances.items():
955
+ if hasattr(plugin_instance, 'get_system_prompt_addition'):
956
+ try:
957
+ addition = plugin_instance.get_system_prompt_addition()
958
+ if addition:
959
+ additions.append(addition)
960
+ logger.debug(f"Plugin '{plugin_name}' added system prompt content")
961
+ except Exception as e:
962
+ logger.warning(f"Failed to get system prompt addition from '{plugin_name}': {e}")
963
+
964
+ return additions
965
+
966
+ def _build_system_prompt(self) -> str:
967
+ """Build system prompt from file or agent.
968
+
969
+ Priority:
970
+ 0. Active agent's system prompt (if agent is active)
971
+ 1. KOLLABOR_SYSTEM_PROMPT environment variable (direct string)
972
+ 2. KOLLABOR_SYSTEM_PROMPT_FILE environment variable (custom file path)
973
+ 3. Local .kollabor-cli/system_prompt/default.md (project override)
974
+ 4. Global ~/.kollabor-cli/system_prompt/default.md
975
+ 5. Fallback to minimal default
976
+
977
+ Returns:
978
+ Fully rendered system prompt with all <trender> tags executed.
979
+ """
980
+ from ..utils.config_utils import get_system_prompt_content, initialize_system_prompt
981
+ from ..utils.prompt_renderer import render_system_prompt
982
+
983
+ # Check if we have an active agent with a system prompt
984
+ if self.agent_manager:
985
+ agent_prompt = self.agent_manager.get_system_prompt()
986
+ if agent_prompt:
987
+ # Render <trender> tags in agent prompt
988
+ base_prompt = render_system_prompt(agent_prompt, timeout=5)
989
+ logger.info(f"Using agent system prompt from: {self.agent_manager.active_agent_name}")
990
+ # Continue with the rest of the build process using agent prompt
991
+ prompt_parts = [base_prompt]
992
+ return self._finalize_system_prompt(prompt_parts)
993
+
994
+ # Ensure system prompts are initialized (copies global to local if needed)
995
+ initialize_system_prompt()
996
+
997
+ # Load base prompt (checks env vars and files in priority order)
998
+ base_prompt = get_system_prompt_content()
999
+
1000
+ # Render <trender> tags BEFORE building the full prompt
1001
+ base_prompt = render_system_prompt(base_prompt, timeout=5)
1002
+
1003
+ prompt_parts = [base_prompt]
1004
+ return self._finalize_system_prompt(prompt_parts)
1005
+
1006
+ def rebuild_system_prompt(self) -> bool:
1007
+ """Rebuild the system prompt and update conversation history.
1008
+
1009
+ Call this after skills are loaded/unloaded to update the system message
1010
+ with the new prompt content including active skills.
1011
+
1012
+ Returns:
1013
+ True if system prompt was rebuilt successfully.
1014
+ """
1015
+ try:
1016
+ new_prompt = self._build_system_prompt()
1017
+
1018
+ # Update the first message in conversation history (system message)
1019
+ if self.conversation_history:
1020
+ first_msg = self.conversation_history[0]
1021
+ if first_msg.role == "system":
1022
+ # Create new message with updated content
1023
+ self.conversation_history[0] = ConversationMessage(
1024
+ role="system",
1025
+ content=new_prompt
1026
+ )
1027
+ logger.info("System prompt rebuilt with updated skills")
1028
+ return True
1029
+
1030
+ logger.warning("No system message found to update")
1031
+ return False
1032
+
1033
+ except Exception as e:
1034
+ logger.error(f"Failed to rebuild system prompt: {e}")
1035
+ return False
1036
+
1037
+ async def _background_mcp_discovery(self) -> None:
1038
+ """Discover MCP servers in background (non-blocking).
1039
+
1040
+ Runs MCP server discovery asynchronously so the UI can start
1041
+ immediately. Updates native_tools when discovery completes.
1042
+ """
1043
+ try:
1044
+ discovered_servers = await self.mcp_integration.discover_mcp_servers()
1045
+ logger.info(f"Background MCP discovery: found {len(discovered_servers)} servers")
1046
+
1047
+ # Load native tools now that MCP is ready
1048
+ await self._load_native_tools()
1049
+ except Exception as e:
1050
+ logger.warning(f"Background MCP discovery failed: {e}")
1051
+
1052
+ async def _load_native_tools(self) -> None:
1053
+ """Load MCP tools for native API function calling.
1054
+
1055
+ Populates self.native_tools with tool definitions from MCP integration
1056
+ for passing to API calls. This enables native tool calling where the
1057
+ LLM returns structured tool_calls instead of XML tags.
1058
+
1059
+ Respects both:
1060
+ - Global config: core.llm.native_tool_calling (default: True)
1061
+ - Profile setting: profile.native_tool_calling (default: True)
1062
+
1063
+ Both must be True for native tools to be loaded. When disabled,
1064
+ the LLM uses XML tags (<terminal>, <tool>, etc.) instead.
1065
+ """
1066
+ # Check global config setting
1067
+ if not self.native_tool_calling_enabled:
1068
+ logger.info("Native tool calling disabled in global config")
1069
+ self.native_tools = None
1070
+ return
1071
+
1072
+ # Check profile-specific setting
1073
+ profile = self.profile_manager.get_active_profile()
1074
+ profile_native = profile.get_native_tool_calling()
1075
+ if not profile_native:
1076
+ logger.info(f"Native tool calling disabled for profile '{profile.name}' (using XML mode)")
1077
+ self.native_tools = None
1078
+ return
1079
+
1080
+ try:
1081
+ tools = self.mcp_integration.get_tool_definitions_for_api()
1082
+ if tools:
1083
+ self.native_tools = tools
1084
+ logger.info(f"Loaded {len(tools)} tools for native API calling")
1085
+ else:
1086
+ self.native_tools = None
1087
+ logger.debug("No MCP tools available for native calling")
1088
+ except Exception as e:
1089
+ logger.warning(f"Failed to load native tools: {e}")
1090
+ self.native_tools = None
1091
+
1092
+ async def _execute_native_tool_calls(self) -> List[Any]:
1093
+ """Execute tool calls from native API response.
1094
+
1095
+ Processes tool calls returned by the API's native function calling
1096
+ and executes them through the tool executor.
1097
+
1098
+ Handles edge case where LLM outputs malformed tool names containing XML.
1099
+
1100
+ Returns:
1101
+ List of ToolExecutionResult objects
1102
+ """
1103
+ import re
1104
+ from .tool_executor import ToolExecutionResult
1105
+
1106
+ results = []
1107
+ tool_calls = self.api_service.get_last_tool_calls()
1108
+
1109
+ if not tool_calls:
1110
+ return results
1111
+
1112
+ logger.info(f"Executing {len(tool_calls)} native tool calls")
1113
+
1114
+ for tc in tool_calls:
1115
+ tool_name = tc.tool_name
1116
+
1117
+ # Handle malformed tool names that contain XML (LLM confusion)
1118
+ # Example: "read><file>path</file></read><tool_call>search_nodes"
1119
+ if '<' in tool_name or '>' in tool_name:
1120
+ logger.warning(f"Malformed tool name detected: {tool_name[:100]}")
1121
+ # Try to extract actual tool name from <tool_call>...</tool_call>
1122
+ match = re.search(r'<tool_call>([^<]+)', tool_name)
1123
+ if match:
1124
+ tool_name = match.group(1).strip()
1125
+ logger.info(f"Extracted tool name from malformed call: {tool_name}")
1126
+ else:
1127
+ # Try to find any known MCP tool name in the string
1128
+ available_tools = list(self.mcp_integration.tool_registry.keys())
1129
+ for known_tool in available_tools:
1130
+ if known_tool in tool_name:
1131
+ tool_name = known_tool
1132
+ logger.info(f"Found known tool in malformed name: {tool_name}")
1133
+ break
1134
+ else:
1135
+ logger.error(f"Could not parse malformed tool name: {tool_name[:100]}")
1136
+ continue
1137
+
1138
+ # Convert ToolCallResult to tool_executor format
1139
+ # File operations use their name as type (file_create, file_edit, etc.)
1140
+ # Terminal commands use "terminal" as type
1141
+ # MCP tools use "mcp_tool" as type
1142
+ if tool_name.startswith("file_"):
1143
+ tool_type = tool_name
1144
+ # Map file operation arguments to expected format
1145
+ tool_data = {
1146
+ "type": tool_type,
1147
+ "id": tc.tool_id,
1148
+ **tc.arguments # Spread arguments directly (file, content, etc.)
1149
+ }
1150
+ elif tool_name == "terminal":
1151
+ tool_type = "terminal"
1152
+ tool_data = {
1153
+ "type": tool_type,
1154
+ "id": tc.tool_id,
1155
+ "command": tc.arguments.get("command", "")
1156
+ }
1157
+ else:
1158
+ tool_type = "mcp_tool"
1159
+ tool_data = {
1160
+ "type": tool_type,
1161
+ "id": tc.tool_id,
1162
+ "name": tool_name,
1163
+ "arguments": tc.arguments
1164
+ }
1165
+
1166
+ result = await self.tool_executor.execute_tool(tool_data)
1167
+ results.append(result)
1168
+
1169
+ logger.debug(f"Native tool {tool_name}: {'success' if result.success else 'failed'}")
1170
+
1171
+ return results
1172
+
844
1173
  async def process_user_input(self, message: str) -> Dict[str, Any]:
845
1174
  """Process user input through the LLM.
846
-
1175
+
847
1176
  This is the main entry point for user messages.
848
-
1177
+
849
1178
  Args:
850
1179
  message: User's input message
851
-
1180
+
852
1181
  Returns:
853
1182
  Status information about processing
854
1183
  """
855
1184
  # Display user message using MessageDisplayService (DRY refactoring)
856
1185
  logger.debug(f"DISPLAY DEBUG: About to display user message: '{message[:100]}...' ({len(message)} chars)")
857
1186
  self.message_display.display_user_message(message)
858
-
1187
+
1188
+ # Question gate: if enabled and there are pending tools, execute them now
1189
+ # and inject results into conversation before processing user message
1190
+ tool_injection_results = None
1191
+ if self.question_gate_enabled and self.question_gate_active and self.pending_tools:
1192
+ logger.info(f"Question gate: executing {len(self.pending_tools)} suspended tool(s)")
1193
+
1194
+ # Show tool execution indicator (prevents UI freeze appearance)
1195
+ tool_count = len(self.pending_tools)
1196
+ tool_desc = self.pending_tools[0].get("type", "tool") if tool_count == 1 else f"{tool_count} tools"
1197
+ self.renderer.update_thinking(True, f"Executing {tool_desc}...")
1198
+
1199
+ tool_injection_results = await self.tool_executor.execute_all_tools(self.pending_tools)
1200
+
1201
+ # Stop tool execution indicator
1202
+ self.renderer.update_thinking(False)
1203
+
1204
+ # Display and log tool results
1205
+ if tool_injection_results:
1206
+ # Display tool results
1207
+ self.message_display.display_complete_response(
1208
+ thinking_duration=0,
1209
+ response="",
1210
+ tool_results=tool_injection_results,
1211
+ original_tools=self.pending_tools
1212
+ )
1213
+
1214
+ # Add tool results to conversation history
1215
+ batched_tool_results = []
1216
+ for result in tool_injection_results:
1217
+ await self.conversation_logger.log_system_message(
1218
+ f"Executed {result.tool_type} ({result.tool_id}): {result.output if result.success else result.error}",
1219
+ parent_uuid=self.current_parent_uuid,
1220
+ subtype="tool_call"
1221
+ )
1222
+ tool_context = self.tool_executor.format_result_for_conversation(result)
1223
+ batched_tool_results.append(f"Tool result: {tool_context}")
1224
+
1225
+ if batched_tool_results:
1226
+ self._add_conversation_message(ConversationMessage(
1227
+ role="user",
1228
+ content="\n".join(batched_tool_results)
1229
+ ))
1230
+
1231
+ # Clear question gate state
1232
+ self.pending_tools = []
1233
+ self.question_gate_active = False
1234
+ logger.info("Question gate: cleared after tool execution")
1235
+
859
1236
  # Reset turn_completed flag
860
1237
  self.turn_completed = False
861
1238
  self.cancel_processing = False
862
1239
  self.cancellation_message_shown = False
863
-
1240
+
864
1241
  # Log user message
865
1242
  self.current_parent_uuid = await self.conversation_logger.log_user_message(
866
1243
  message,
867
1244
  parent_uuid=self.current_parent_uuid
868
1245
  )
869
-
1246
+
870
1247
  # Add to processing queue with overflow handling
871
1248
  await self._enqueue_with_overflow_strategy(message)
872
-
1249
+
873
1250
  # Start processing if not already running
874
1251
  if not self.is_processing:
875
1252
  self.create_background_task(self._process_queue(), name="process_queue")
876
1253
 
877
- return {"status": "queued"}
878
-
1254
+ return {"status": "queued", "tools_injected": len(tool_injection_results) if tool_injection_results else 0}
1255
+
1256
+ def process_user_input_background(
1257
+ self,
1258
+ message: str,
1259
+ task_name: str = None,
1260
+ custom_system_prompt: str = None,
1261
+ silent: bool = True
1262
+ ) -> asyncio.Task:
1263
+ """Process user input in background without blocking.
1264
+
1265
+ This creates a background task that processes the message independently.
1266
+ The task will show up in the status line with elapsed time.
1267
+
1268
+ Args:
1269
+ message: User's input message
1270
+ task_name: Optional custom name for the task (appears in status line)
1271
+ custom_system_prompt: Optional custom system prompt to use instead of default
1272
+ silent: If True, don't display user message or thinking animation (default: True)
1273
+
1274
+ Returns:
1275
+ The background task object (can be used to check status or cancel)
1276
+ """
1277
+ task_name = task_name or f"background_input_{len(self._background_tasks)}"
1278
+
1279
+ async def _background_process():
1280
+ """Inner coroutine that processes the message."""
1281
+ try:
1282
+ # Temporarily override system prompt if provided
1283
+ original_system_message = None
1284
+ if custom_system_prompt and self.conversation_history:
1285
+ # Save original system message (first message in history)
1286
+ original_system_message = self.conversation_history[0]
1287
+
1288
+ # Replace with custom system prompt
1289
+ self.conversation_history[0] = ConversationMessage(
1290
+ role="system",
1291
+ content=custom_system_prompt
1292
+ )
1293
+ logger.info(f"Background task '{task_name}': using custom system prompt ({len(custom_system_prompt)} chars)")
1294
+
1295
+ # Process silently - skip user message display
1296
+ if silent:
1297
+ # Log the message but don't display it
1298
+ logger.info(f"Background task '{task_name}': silently queuing message ({len(message)} chars)")
1299
+
1300
+ # Reset flags
1301
+ self.turn_completed = False
1302
+ self.cancel_processing = False
1303
+ self.cancellation_message_shown = False
1304
+
1305
+ # Log user message (but don't display)
1306
+ self.current_parent_uuid = await self.conversation_logger.log_user_message(
1307
+ message,
1308
+ parent_uuid=self.current_parent_uuid
1309
+ )
1310
+
1311
+ # Add to processing queue
1312
+ await self._enqueue_with_overflow_strategy(message)
1313
+
1314
+ # Start processing if not already running
1315
+ if not self.is_processing:
1316
+ self.create_background_task(self._process_queue(), name=f"process_queue")
1317
+
1318
+ logger.info(f"Background task '{task_name}': message queued, processing in background")
1319
+ # Don't wait - return immediately and let it process in background
1320
+
1321
+ else:
1322
+ # Use normal processing (shows user message and thinking)
1323
+ await self.process_user_input(message)
1324
+
1325
+ # Restore original system prompt
1326
+ if original_system_message is not None:
1327
+ self.conversation_history[0] = original_system_message
1328
+ logger.debug(f"Background task '{task_name}': restored original system prompt")
1329
+
1330
+ logger.info(f"Background task '{task_name}' completed successfully")
1331
+ return {"status": "completed"}
1332
+
1333
+ except Exception as e:
1334
+ logger.error(f"Background task '{task_name}' failed: {e}")
1335
+
1336
+ # Restore system prompt on error
1337
+ if original_system_message is not None:
1338
+ self.conversation_history[0] = original_system_message
1339
+
1340
+ raise
1341
+
1342
+ # Create and track the background task
1343
+ task = self.create_background_task(_background_process(), name=task_name)
1344
+ logger.info(f"Started background task: {task_name}")
1345
+ return task
1346
+
879
1347
  async def _handle_user_input(self, data: Dict[str, Any], event) -> Dict[str, Any]:
880
1348
  """Handle user input hook callback.
881
1349
 
@@ -922,7 +1390,120 @@ class LLMService:
922
1390
 
923
1391
  logger.info(f"LLM SERVICE: Cancellation flag set: {self.cancel_processing}")
924
1392
  return {"status": "cancelled", "reason": reason}
925
-
1393
+
1394
+ async def _handle_add_message(self, data: Dict[str, Any], event) -> Dict[str, Any]:
1395
+ """Handle ADD_MESSAGE event - inject messages into conversation.
1396
+
1397
+ This allows plugins to inject messages into the conversation that:
1398
+ - Get added to AI-visible history
1399
+ - Get logged to conversation logger
1400
+ - Get displayed to user with loading indicator
1401
+ - Optionally trigger LLM response
1402
+
1403
+ Args:
1404
+ data: Event data with messages array and options
1405
+ event: The event object
1406
+
1407
+ Returns:
1408
+ Result dict with status and message count
1409
+ """
1410
+ messages = data.get("messages", [])
1411
+ options = data.get("options", {})
1412
+
1413
+ if not messages:
1414
+ return {"success": False, "error": "No messages provided"}
1415
+
1416
+ show_loading = options.get("show_loading", True)
1417
+ loading_message = options.get("loading_message", "Loading...")
1418
+ log_messages = options.get("log_messages", True)
1419
+ add_to_history = options.get("add_to_history", True)
1420
+ display_messages = options.get("display_messages", True)
1421
+ trigger_llm = options.get("trigger_llm", False)
1422
+ parent_uuid = options.get("parent_uuid", self.current_parent_uuid)
1423
+
1424
+ try:
1425
+ # Show loading indicator
1426
+ if show_loading:
1427
+ self.message_display.show_loading(loading_message)
1428
+
1429
+ display_sequence = []
1430
+
1431
+ for msg in messages:
1432
+ role = msg.get("role", "user")
1433
+ content = msg.get("content", "")
1434
+
1435
+ # Add to conversation history
1436
+ if add_to_history:
1437
+ from ..models import ConversationMessage
1438
+ self._add_conversation_message(
1439
+ ConversationMessage(role=role, content=content),
1440
+ parent_uuid=parent_uuid
1441
+ )
1442
+
1443
+ # Log message
1444
+ if log_messages:
1445
+ if role == "user":
1446
+ parent_uuid = await self.conversation_logger.log_user_message(
1447
+ content, parent_uuid=parent_uuid
1448
+ )
1449
+ elif role == "assistant":
1450
+ parent_uuid = await self.conversation_logger.log_assistant_message(
1451
+ content, parent_uuid=parent_uuid
1452
+ )
1453
+ elif role == "system":
1454
+ await self.conversation_logger.log_system_message(
1455
+ content, parent_uuid=parent_uuid
1456
+ )
1457
+
1458
+ # Build display sequence
1459
+ if display_messages and role in ("user", "assistant", "system"):
1460
+ display_sequence.append((role, content, {}))
1461
+
1462
+
1463
+ # Display messages atomically
1464
+ if display_messages and display_sequence:
1465
+ self.message_display.message_coordinator.display_message_sequence(
1466
+ display_sequence
1467
+ )
1468
+
1469
+ # Hide loading before display
1470
+ if show_loading:
1471
+ await asyncio.sleep(0.5)
1472
+ self.message_display.hide_loading()
1473
+
1474
+ # Update session stats
1475
+ if hasattr(self, 'session_stats'):
1476
+ self.session_stats["messages"] += len(messages)
1477
+
1478
+ # Optionally trigger LLM response
1479
+ if trigger_llm:
1480
+ # Find the last user message to process
1481
+ last_user_msg = None
1482
+ for msg in reversed(messages):
1483
+ if msg.get("role") == "user":
1484
+ last_user_msg = msg.get("content", "")
1485
+ break
1486
+
1487
+ if last_user_msg:
1488
+ await self._enqueue_with_overflow_strategy(last_user_msg)
1489
+ if not self.is_processing:
1490
+ self.create_background_task(self._process_queue(), name="process_queue")
1491
+
1492
+ logger.info(f"ADD_MESSAGE: Processed {len(messages)} messages, trigger_llm={trigger_llm}")
1493
+ return {
1494
+ "success": True,
1495
+ "message_count": len(messages),
1496
+ "parent_uuid": parent_uuid,
1497
+ "llm_triggered": trigger_llm
1498
+ }
1499
+
1500
+ except Exception as e:
1501
+ # Ensure loading is hidden on error
1502
+ if show_loading:
1503
+ self.message_display.hide_loading()
1504
+ logger.error(f"Error in ADD_MESSAGE handler: {e}")
1505
+ return {"success": False, "error": str(e)}
1506
+
926
1507
  async def register_hooks(self) -> None:
927
1508
  """Register LLM service hooks with the event bus."""
928
1509
  for hook in self.hooks:
@@ -957,8 +1538,12 @@ class LLMService:
957
1538
 
958
1539
  except Exception as e:
959
1540
  logger.error(f"Queue processing error: {e}")
960
- # Display error using MessageDisplayService (DRY refactoring)
961
- self.message_display.display_error_message(str(e))
1541
+ # Provide user-friendly error messages
1542
+ error_msg = str(e)
1543
+ if "'str' object has no attribute 'get'" in error_msg:
1544
+ error_msg = ("API format mismatch. Your profile's tool_format setting may be wrong.\n"
1545
+ "Run /profile, press 'e' to edit, and check Tool Format matches your API.")
1546
+ self.message_display.display_error_message(error_msg)
962
1547
  break
963
1548
 
964
1549
  # Continue conversation until completed (unlimited agentic turns)
@@ -1016,18 +1601,84 @@ class LLMService:
1016
1601
  if token_usage:
1017
1602
  prompt_tokens = token_usage.get("prompt_tokens", 0)
1018
1603
  completion_tokens = token_usage.get("completion_tokens", 0)
1019
- self.session_stats["input_tokens"] += prompt_tokens
1020
- self.session_stats["output_tokens"] += completion_tokens
1604
+ # Store last request tokens (for context window display)
1605
+ self.session_stats["input_tokens"] = prompt_tokens
1606
+ self.session_stats["output_tokens"] = completion_tokens
1607
+ # Accumulate totals
1608
+ self.session_stats["total_input_tokens"] += prompt_tokens
1609
+ self.session_stats["total_output_tokens"] += completion_tokens
1021
1610
  logger.debug(f"Token usage: {prompt_tokens} input, {completion_tokens} output")
1022
-
1611
+
1612
+ # Check for native tool calls (API function calling)
1613
+ # Native calls are optional; XML-based tools are the Kollabor standard
1614
+ if self.native_tool_calling_enabled and self.api_service.has_pending_tool_calls():
1615
+ thinking_duration = time.time() - thinking_start
1616
+ self.renderer.update_thinking(False)
1617
+
1618
+ logger.info("Processing native tool calls from API response")
1619
+
1620
+ # Build original_tools list for display (before execution may modify names)
1621
+ raw_tool_calls = self.api_service.get_last_tool_calls()
1622
+ original_tools = [
1623
+ {"name": tc.tool_name, "arguments": tc.arguments}
1624
+ for tc in raw_tool_calls
1625
+ ]
1626
+
1627
+ # Show tool execution indicator (prevents UI freeze appearance)
1628
+ tool_count = len(raw_tool_calls)
1629
+ tool_desc = raw_tool_calls[0].tool_name if tool_count == 1 else f"{tool_count} tools"
1630
+ self.renderer.update_thinking(True, f"Executing {tool_desc}...")
1631
+
1632
+ native_results = await self._execute_native_tool_calls()
1633
+
1634
+ # Stop tool execution indicator
1635
+ self.renderer.update_thinking(False)
1636
+
1637
+ # Display response and native tool results
1638
+ self.message_display.display_complete_response(
1639
+ thinking_duration=thinking_duration,
1640
+ response=response,
1641
+ tool_results=native_results,
1642
+ original_tools=original_tools
1643
+ )
1644
+
1645
+ # Add assistant response to history
1646
+ self._add_conversation_message(ConversationMessage(
1647
+ role="assistant",
1648
+ content=response
1649
+ ))
1650
+
1651
+ # Add tool results to conversation using native format
1652
+ for result in native_results:
1653
+ tool_calls = self.api_service.get_last_tool_calls()
1654
+ for tc in tool_calls:
1655
+ if tc.tool_id == result.tool_id:
1656
+ msg = self.api_service.format_tool_result(
1657
+ tc.tool_id,
1658
+ result.output if result.success else result.error,
1659
+ is_error=not result.success
1660
+ )
1661
+ # Add formatted tool result to conversation
1662
+ self._add_conversation_message(ConversationMessage(
1663
+ role=msg.get("role", "tool"),
1664
+ content=str(msg.get("content", result.output))
1665
+ ))
1666
+ break
1667
+
1668
+ # Continue conversation to get LLM response with tool results
1669
+ self.turn_completed = False
1670
+ self.stats["total_thinking_time"] += thinking_duration
1671
+ self.session_stats["messages"] += 1
1672
+ return # Native tools handled, continue conversation loop
1673
+
1023
1674
  # Stop thinking animation and show completion message
1024
1675
  thinking_duration = time.time() - thinking_start
1025
1676
  self.renderer.update_thinking(False)
1026
-
1677
+
1027
1678
  # Brief pause to ensure clean transition from thinking to completion message
1028
1679
  await asyncio.sleep(self.config.get("core.llm.processing_delay", 0.1))
1029
-
1030
- # Parse response using new ResponseParser
1680
+
1681
+ # Parse response using ResponseParser for XML-based tools (Kollabor standard)
1031
1682
  parsed_response = self.response_parser.parse_response(response)
1032
1683
  clean_response = parsed_response["content"]
1033
1684
  all_tools = self.response_parser.get_all_tools(parsed_response)
@@ -1051,18 +1702,60 @@ class LLMService:
1051
1702
 
1052
1703
  # Stop generating animation before message display
1053
1704
  self.renderer.update_thinking(False)
1054
-
1055
- # Execute all tools (terminal commands and MCP tools) if any
1705
+
1706
+ # Question gate: if enabled and question tag present, suspend tool execution
1056
1707
  tool_results = None
1057
1708
  if all_tools:
1058
- tool_results = await self.tool_executor.execute_all_tools(all_tools)
1709
+ if self.question_gate_enabled and parsed_response.get("question_gate_active"):
1710
+ # Store tools for later execution when user responds
1711
+ self.pending_tools = all_tools
1712
+ self.question_gate_active = True
1713
+ logger.info(f"Question gate: suspended {len(all_tools)} tool(s) pending user response")
1714
+ else:
1715
+ # Show tool execution indicator (prevents UI freeze appearance)
1716
+ tool_count = len(all_tools)
1717
+ tool_desc = all_tools[0].get("type", "tool") if tool_count == 1 else f"{tool_count} tools"
1718
+ self.renderer.update_thinking(True, f"Executing {tool_desc}...")
1719
+
1720
+ # Execute tools normally
1721
+ tool_results = await self.tool_executor.execute_all_tools(all_tools)
1722
+
1723
+ # Stop tool execution indicator
1724
+ self.renderer.update_thinking(False)
1725
+
1726
+ # Emit LLM_RESPONSE event BEFORE display so plugins can show tool indicators first
1727
+ response_context = await self.event_bus.emit_with_hooks(
1728
+ EventType.LLM_RESPONSE,
1729
+ {
1730
+ "response_text": response,
1731
+ "clean_response": clean_response,
1732
+ "thinking_duration": thinking_duration,
1733
+ "tool_results": tool_results,
1734
+ },
1735
+ "llm_service"
1736
+ )
1737
+
1738
+ # Check if any plugin wants to force continuation (e.g., agent orchestrator)
1739
+ # Plugins can set force_continue in any phase (pre, main, post)
1740
+ force_continue = False
1741
+ if response_context:
1742
+ for phase in ["pre", "main", "post"]:
1743
+ phase_data = response_context.get(phase, {})
1744
+ final_data = phase_data.get("final_data", {})
1745
+ if final_data.get("force_continue"):
1746
+ force_continue = True
1747
+ break
1748
+ if force_continue:
1749
+ self.turn_completed = False
1750
+ logger.info("Plugin requested turn continuation")
1059
1751
 
1060
1752
  # Display thinking duration, response, and tool results atomically using unified method
1753
+ # Note: when question gate is active, tool_results is None (tools not executed yet)
1061
1754
  self.message_display.display_complete_response(
1062
1755
  thinking_duration=thinking_duration,
1063
1756
  response=clean_response,
1064
1757
  tool_results=tool_results,
1065
- original_tools=all_tools
1758
+ original_tools=all_tools if not self.question_gate_active else None
1066
1759
  )
1067
1760
 
1068
1761
  # Log assistant response
@@ -1111,11 +1804,6 @@ class LLMService:
1111
1804
  # Clear any display artifacts
1112
1805
  self.renderer.clear_active_area()
1113
1806
 
1114
- # Remove the user message that was just added since processing was cancelled
1115
- if self.conversation_history and self.conversation_history[-1].role == "user":
1116
- self.conversation_history.pop()
1117
- logger.info("Removed cancelled user message from conversation history")
1118
-
1119
1807
  # Show cancellation message (only once)
1120
1808
  if not self.cancellation_message_shown:
1121
1809
  self.cancellation_message_shown = True
@@ -1131,8 +1819,12 @@ class LLMService:
1131
1819
  except Exception as e:
1132
1820
  logger.error(f"Error processing message batch: {e}")
1133
1821
  self.renderer.update_thinking(False)
1134
- # Display error using MessageDisplayService (DRY refactoring)
1135
- self.message_display.display_error_message(str(e))
1822
+ # Provide user-friendly error messages
1823
+ error_msg = str(e)
1824
+ if "'str' object has no attribute 'get'" in error_msg:
1825
+ error_msg = ("API format mismatch. Your profile's tool_format setting may be wrong.\n"
1826
+ "Run /profile, press 'e' to edit, and check Tool Format matches your API.")
1827
+ self.message_display.display_error_message(error_msg)
1136
1828
  # Complete turn on error to prevent infinite loops
1137
1829
  self.turn_completed = True
1138
1830
 
@@ -1155,18 +1847,82 @@ class LLMService:
1155
1847
  if token_usage:
1156
1848
  prompt_tokens = token_usage.get("prompt_tokens", 0)
1157
1849
  completion_tokens = token_usage.get("completion_tokens", 0)
1158
- self.session_stats["input_tokens"] += prompt_tokens
1159
- self.session_stats["output_tokens"] += completion_tokens
1850
+ # Store last request tokens (for context window display)
1851
+ self.session_stats["input_tokens"] = prompt_tokens
1852
+ self.session_stats["output_tokens"] = completion_tokens
1853
+ # Accumulate totals
1854
+ self.session_stats["total_input_tokens"] += prompt_tokens
1855
+ self.session_stats["total_output_tokens"] += completion_tokens
1160
1856
  logger.debug(f"Token usage: {prompt_tokens} input, {completion_tokens} output")
1161
-
1162
- # Parse response using new ResponseParser
1857
+
1858
+ # Check for native tool calls (API function calling)
1859
+ # Native calls are optional; XML-based tools are the Kollabor standard
1860
+ if self.native_tool_calling_enabled and self.api_service.has_pending_tool_calls():
1861
+ thinking_duration = time.time() - thinking_start
1862
+ self.renderer.update_thinking(False)
1863
+
1864
+ logger.info("Processing native tool calls from API response (continue)")
1865
+
1866
+ # Build original_tools list for display
1867
+ raw_tool_calls = self.api_service.get_last_tool_calls()
1868
+ original_tools = [
1869
+ {"name": tc.tool_name, "arguments": tc.arguments}
1870
+ for tc in raw_tool_calls
1871
+ ]
1872
+
1873
+ # Show tool execution indicator (prevents UI freeze appearance)
1874
+ tool_count = len(raw_tool_calls)
1875
+ tool_desc = raw_tool_calls[0].tool_name if tool_count == 1 else f"{tool_count} tools"
1876
+ self.renderer.update_thinking(True, f"Executing {tool_desc}...")
1877
+
1878
+ native_results = await self._execute_native_tool_calls()
1879
+
1880
+ # Stop tool execution indicator
1881
+ self.renderer.update_thinking(False)
1882
+
1883
+ # Display response and native tool results
1884
+ self.message_display.display_complete_response(
1885
+ thinking_duration=thinking_duration,
1886
+ response=response,
1887
+ tool_results=native_results,
1888
+ original_tools=original_tools
1889
+ )
1890
+
1891
+ # Add assistant response to history
1892
+ self._add_conversation_message(ConversationMessage(
1893
+ role="assistant",
1894
+ content=response
1895
+ ))
1896
+
1897
+ # Add tool results to conversation using native format
1898
+ for result in native_results:
1899
+ tool_calls = self.api_service.get_last_tool_calls()
1900
+ for tc in tool_calls:
1901
+ if tc.tool_id == result.tool_id:
1902
+ msg = self.api_service.format_tool_result(
1903
+ tc.tool_id,
1904
+ result.output if result.success else result.error,
1905
+ is_error=not result.success
1906
+ )
1907
+ self._add_conversation_message(ConversationMessage(
1908
+ role=msg.get("role", "tool"),
1909
+ content=str(msg.get("content", result.output))
1910
+ ))
1911
+ break
1912
+
1913
+ # Continue conversation to get LLM response with tool results
1914
+ self.turn_completed = False
1915
+ self.stats["total_thinking_time"] += thinking_duration
1916
+ return # Native tools handled, continue conversation loop
1917
+
1918
+ # Parse response using ResponseParser for XML-based tools (Kollabor standard)
1163
1919
  parsed_response = self.response_parser.parse_response(response)
1164
1920
  clean_response = parsed_response["content"]
1165
1921
  all_tools = self.response_parser.get_all_tools(parsed_response)
1166
-
1922
+
1167
1923
  # Update turn completion state
1168
1924
  self.turn_completed = parsed_response["turn_completed"]
1169
-
1925
+
1170
1926
  thinking_duration = time.time() - thinking_start
1171
1927
  self.renderer.update_thinking(False)
1172
1928
 
@@ -1185,18 +1941,60 @@ class LLMService:
1185
1941
 
1186
1942
  # Stop generating animation before message display
1187
1943
  self.renderer.update_thinking(False)
1188
-
1189
- # Execute all tools (terminal commands and MCP tools) if any
1944
+
1945
+ # Question gate: if enabled and question tag present, suspend tool execution
1190
1946
  tool_results = None
1191
1947
  if all_tools:
1192
- tool_results = await self.tool_executor.execute_all_tools(all_tools)
1948
+ if self.question_gate_enabled and parsed_response.get("question_gate_active"):
1949
+ # Store tools for later execution when user responds
1950
+ self.pending_tools = all_tools
1951
+ self.question_gate_active = True
1952
+ logger.info(f"Question gate (continue): suspended {len(all_tools)} tool(s) pending user response")
1953
+ else:
1954
+ # Show tool execution indicator (prevents UI freeze appearance)
1955
+ tool_count = len(all_tools)
1956
+ tool_desc = all_tools[0].get("type", "tool") if tool_count == 1 else f"{tool_count} tools"
1957
+ self.renderer.update_thinking(True, f"Executing {tool_desc}...")
1958
+
1959
+ # Execute tools normally
1960
+ tool_results = await self.tool_executor.execute_all_tools(all_tools)
1961
+
1962
+ # Stop tool execution indicator
1963
+ self.renderer.update_thinking(False)
1964
+
1965
+ # Emit LLM_RESPONSE event BEFORE display so plugins can show tool indicators first
1966
+ response_context = await self.event_bus.emit_with_hooks(
1967
+ EventType.LLM_RESPONSE,
1968
+ {
1969
+ "response_text": response,
1970
+ "clean_response": clean_response,
1971
+ "thinking_duration": thinking_duration,
1972
+ "tool_results": tool_results,
1973
+ },
1974
+ "llm_service"
1975
+ )
1976
+
1977
+ # Check if any plugin wants to force continuation (e.g., agent orchestrator)
1978
+ # Plugins can set force_continue in any phase (pre, main, post)
1979
+ force_continue = False
1980
+ if response_context:
1981
+ for phase in ["pre", "main", "post"]:
1982
+ phase_data = response_context.get(phase, {})
1983
+ final_data = phase_data.get("final_data", {})
1984
+ if final_data.get("force_continue"):
1985
+ force_continue = True
1986
+ break
1987
+ if force_continue:
1988
+ self.turn_completed = False
1989
+ logger.info("Plugin requested turn continuation (continue path)")
1193
1990
 
1194
1991
  # Display thinking duration, response, and tool results atomically using unified method
1992
+ # Note: when question gate is active, tool_results is None (tools not executed yet)
1195
1993
  self.message_display.display_complete_response(
1196
1994
  thinking_duration=thinking_duration,
1197
1995
  response=clean_response,
1198
1996
  tool_results=tool_results,
1199
- original_tools=all_tools
1997
+ original_tools=all_tools if not self.question_gate_active else None
1200
1998
  )
1201
1999
 
1202
2000
  # Log continuation
@@ -1444,13 +2242,14 @@ class LLMService:
1444
2242
  if self.cancel_processing:
1445
2243
  logger.info("API call cancelled before starting")
1446
2244
  raise asyncio.CancelledError("Request cancelled by user")
1447
-
2245
+
1448
2246
  # Delegate to API communication service (eliminates ~160 lines of duplicated API code)
1449
2247
  try:
1450
2248
  return await self.api_service.call_llm(
1451
2249
  conversation_history=self.conversation_history,
1452
2250
  max_history=self.max_history,
1453
- streaming_callback=self._handle_streaming_chunk
2251
+ streaming_callback=self._handle_streaming_chunk,
2252
+ tools=self.native_tools # Native function calling
1454
2253
  )
1455
2254
  except asyncio.CancelledError:
1456
2255
  logger.info("LLM API call was cancelled")
@@ -1480,6 +2279,30 @@ class LLMService:
1480
2279
 
1481
2280
  logger.debug("Cleaned up streaming state")
1482
2281
 
2282
+ def reload_config(self) -> None:
2283
+ """Reload configuration values from config service (hot reload support).
2284
+
2285
+ Called when configuration changes via /config modal or file watcher.
2286
+ Re-reads all cached config values to apply changes without restart.
2287
+ """
2288
+ logger.info("Hot reloading LLM configuration...")
2289
+
2290
+ # Reload LLM settings
2291
+ self.max_history = self.config.get("core.llm.max_history", 90)
2292
+
2293
+ # Reload tool executor timeouts
2294
+ self.tool_executor.terminal_timeout = self.config.get("core.llm.terminal_timeout", 120)
2295
+ self.tool_executor.mcp_timeout = self.config.get("core.llm.mcp_timeout", 120)
2296
+
2297
+ # Reload streaming setting
2298
+ self.api_service.enable_streaming = self.config.get("core.llm.enable_streaming", False)
2299
+
2300
+ # Note: processing_delay and thinking_delay are already read dynamically each call
2301
+
2302
+ logger.info(f"Config reloaded: max_history={self.max_history}, "
2303
+ f"terminal_timeout={self.tool_executor.terminal_timeout}, "
2304
+ f"mcp_timeout={self.tool_executor.mcp_timeout}, "
2305
+ f"streaming={self.api_service.enable_streaming}")
1483
2306
 
1484
2307
  def get_status_line(self) -> Dict[str, List[str]]:
1485
2308
  """Get status information for display."""
@@ -1510,12 +2333,25 @@ class LLMService:
1510
2333
  dropped_indicator = f" ({self.dropped_messages} dropped)" if self.dropped_messages > 0 else ""
1511
2334
 
1512
2335
  status["C"].append(f"Queue: {queue_size}/{self.max_queue_size} ({queue_utilization:.0f}%){dropped_indicator}")
1513
-
2336
+
1514
2337
  # Add warning if queue utilization is high
1515
2338
  if queue_utilization > 80:
1516
2339
  status["C"].append(f"⚠️ Queue usage high!")
1517
2340
  status["C"].append(f"History: {len(self.conversation_history)}")
1518
- status["C"].append(f"Tasks: {len(self._background_tasks)}")
2341
+
2342
+ # Show active background tasks with elapsed time
2343
+ if self._background_tasks:
2344
+ # Show up to 3 most recent tasks
2345
+ for task in list(self._background_tasks)[:3]:
2346
+ task_name = task.get_name()
2347
+ if task_name and task_name in self._task_metadata:
2348
+ elapsed = time.time() - self._task_metadata[task_name]['start_time']
2349
+ # Format: "⏳ task_name (45s)"
2350
+ status["C"].append(f"⏳ {task_name} ({elapsed:.0f}s)")
2351
+ elif task_name:
2352
+ # Task without metadata - just show name
2353
+ status["C"].append(f"⏳ {task_name}")
2354
+
1519
2355
  if self._task_error_count > 0:
1520
2356
  status["C"].append(f"Task Errors: {self._task_error_count}")
1521
2357
 
@@ -1621,9 +2457,4 @@ class LLMService:
1621
2457
  except Exception as e:
1622
2458
  logger.warning(f"MCP shutdown error: {e}")
1623
2459
 
1624
- # Save statistics
1625
- self.state_manager.set("llm.stats", self.stats)
1626
-
1627
-
1628
-
1629
2460
  logger.info("Core LLM Service shutdown complete")