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.
- agents/__init__.py +2 -0
- agents/coder/__init__.py +0 -0
- agents/coder/agent.json +4 -0
- agents/coder/api-integration.md +2150 -0
- agents/coder/cli-pretty.md +765 -0
- agents/coder/code-review.md +1092 -0
- agents/coder/database-design.md +1525 -0
- agents/coder/debugging.md +1102 -0
- agents/coder/dependency-management.md +1397 -0
- agents/coder/git-workflow.md +1099 -0
- agents/coder/refactoring.md +1454 -0
- agents/coder/security-hardening.md +1732 -0
- agents/coder/system_prompt.md +1448 -0
- agents/coder/tdd.md +1367 -0
- agents/creative-writer/__init__.py +0 -0
- agents/creative-writer/agent.json +4 -0
- agents/creative-writer/character-development.md +1852 -0
- agents/creative-writer/dialogue-craft.md +1122 -0
- agents/creative-writer/plot-structure.md +1073 -0
- agents/creative-writer/revision-editing.md +1484 -0
- agents/creative-writer/system_prompt.md +690 -0
- agents/creative-writer/worldbuilding.md +2049 -0
- agents/data-analyst/__init__.py +30 -0
- agents/data-analyst/agent.json +4 -0
- agents/data-analyst/data-visualization.md +992 -0
- agents/data-analyst/exploratory-data-analysis.md +1110 -0
- agents/data-analyst/pandas-data-manipulation.md +1081 -0
- agents/data-analyst/sql-query-optimization.md +881 -0
- agents/data-analyst/statistical-analysis.md +1118 -0
- agents/data-analyst/system_prompt.md +928 -0
- agents/default/__init__.py +0 -0
- agents/default/agent.json +4 -0
- agents/default/dead-code.md +794 -0
- agents/default/explore-agent-system.md +585 -0
- agents/default/system_prompt.md +1448 -0
- agents/kollabor/__init__.py +0 -0
- agents/kollabor/analyze-plugin-lifecycle.md +175 -0
- agents/kollabor/analyze-terminal-rendering.md +388 -0
- agents/kollabor/code-review.md +1092 -0
- agents/kollabor/debug-mcp-integration.md +521 -0
- agents/kollabor/debug-plugin-hooks.md +547 -0
- agents/kollabor/debugging.md +1102 -0
- agents/kollabor/dependency-management.md +1397 -0
- agents/kollabor/git-workflow.md +1099 -0
- agents/kollabor/inspect-llm-conversation.md +148 -0
- agents/kollabor/monitor-event-bus.md +558 -0
- agents/kollabor/profile-performance.md +576 -0
- agents/kollabor/refactoring.md +1454 -0
- agents/kollabor/system_prompt copy.md +1448 -0
- agents/kollabor/system_prompt.md +757 -0
- agents/kollabor/trace-command-execution.md +178 -0
- agents/kollabor/validate-config.md +879 -0
- agents/research/__init__.py +0 -0
- agents/research/agent.json +4 -0
- agents/research/architecture-mapping.md +1099 -0
- agents/research/codebase-analysis.md +1077 -0
- agents/research/dependency-audit.md +1027 -0
- agents/research/performance-profiling.md +1047 -0
- agents/research/security-review.md +1359 -0
- agents/research/system_prompt.md +492 -0
- agents/technical-writer/__init__.py +0 -0
- agents/technical-writer/agent.json +4 -0
- agents/technical-writer/api-documentation.md +2328 -0
- agents/technical-writer/changelog-management.md +1181 -0
- agents/technical-writer/readme-writing.md +1360 -0
- agents/technical-writer/style-guide.md +1410 -0
- agents/technical-writer/system_prompt.md +653 -0
- agents/technical-writer/tutorial-creation.md +1448 -0
- core/__init__.py +0 -2
- core/application.py +343 -88
- core/cli.py +229 -10
- core/commands/menu_renderer.py +463 -59
- core/commands/registry.py +14 -9
- core/commands/system_commands.py +2461 -14
- core/config/loader.py +151 -37
- core/config/service.py +18 -6
- core/events/bus.py +29 -9
- core/events/executor.py +205 -75
- core/events/models.py +27 -8
- core/fullscreen/command_integration.py +20 -24
- core/fullscreen/components/__init__.py +10 -1
- core/fullscreen/components/matrix_components.py +1 -2
- core/fullscreen/components/space_shooter_components.py +654 -0
- core/fullscreen/plugin.py +5 -0
- core/fullscreen/renderer.py +52 -13
- core/fullscreen/session.py +52 -15
- core/io/__init__.py +29 -5
- core/io/buffer_manager.py +6 -1
- core/io/config_status_view.py +7 -29
- core/io/core_status_views.py +267 -347
- core/io/input/__init__.py +25 -0
- core/io/input/command_mode_handler.py +711 -0
- core/io/input/display_controller.py +128 -0
- core/io/input/hook_registrar.py +286 -0
- core/io/input/input_loop_manager.py +421 -0
- core/io/input/key_press_handler.py +502 -0
- core/io/input/modal_controller.py +1011 -0
- core/io/input/paste_processor.py +339 -0
- core/io/input/status_modal_renderer.py +184 -0
- core/io/input_errors.py +5 -1
- core/io/input_handler.py +211 -2452
- core/io/key_parser.py +7 -0
- core/io/layout.py +15 -3
- core/io/message_coordinator.py +111 -2
- core/io/message_renderer.py +129 -4
- core/io/status_renderer.py +147 -607
- core/io/terminal_renderer.py +97 -51
- core/io/terminal_state.py +21 -4
- core/io/visual_effects.py +816 -165
- core/llm/agent_manager.py +1063 -0
- core/llm/api_adapters/__init__.py +44 -0
- core/llm/api_adapters/anthropic_adapter.py +432 -0
- core/llm/api_adapters/base.py +241 -0
- core/llm/api_adapters/openai_adapter.py +326 -0
- core/llm/api_communication_service.py +167 -113
- core/llm/conversation_logger.py +322 -16
- core/llm/conversation_manager.py +556 -30
- core/llm/file_operations_executor.py +84 -32
- core/llm/llm_service.py +934 -103
- core/llm/mcp_integration.py +541 -57
- core/llm/message_display_service.py +135 -18
- core/llm/plugin_sdk.py +1 -2
- core/llm/profile_manager.py +1183 -0
- core/llm/response_parser.py +274 -56
- core/llm/response_processor.py +16 -3
- core/llm/tool_executor.py +6 -1
- core/logging/__init__.py +2 -0
- core/logging/setup.py +34 -6
- core/models/resume.py +54 -0
- core/plugins/__init__.py +4 -2
- core/plugins/base.py +127 -0
- core/plugins/collector.py +23 -161
- core/plugins/discovery.py +37 -3
- core/plugins/factory.py +6 -12
- core/plugins/registry.py +5 -17
- core/ui/config_widgets.py +128 -28
- core/ui/live_modal_renderer.py +2 -1
- core/ui/modal_actions.py +5 -0
- core/ui/modal_overlay_renderer.py +0 -60
- core/ui/modal_renderer.py +268 -7
- core/ui/modal_state_manager.py +29 -4
- core/ui/widgets/base_widget.py +7 -0
- core/updates/__init__.py +10 -0
- core/updates/version_check_service.py +348 -0
- core/updates/version_comparator.py +103 -0
- core/utils/config_utils.py +685 -526
- core/utils/plugin_utils.py +1 -1
- core/utils/session_naming.py +111 -0
- fonts/LICENSE +21 -0
- fonts/README.md +46 -0
- fonts/SymbolsNerdFont-Regular.ttf +0 -0
- fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
- fonts/__init__.py +44 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
- kollabor-0.4.15.dist-info/RECORD +228 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
- plugins/agent_orchestrator/__init__.py +39 -0
- plugins/agent_orchestrator/activity_monitor.py +181 -0
- plugins/agent_orchestrator/file_attacher.py +77 -0
- plugins/agent_orchestrator/message_injector.py +135 -0
- plugins/agent_orchestrator/models.py +48 -0
- plugins/agent_orchestrator/orchestrator.py +403 -0
- plugins/agent_orchestrator/plugin.py +976 -0
- plugins/agent_orchestrator/xml_parser.py +191 -0
- plugins/agent_orchestrator_plugin.py +9 -0
- plugins/enhanced_input/box_styles.py +1 -0
- plugins/enhanced_input/color_engine.py +19 -4
- plugins/enhanced_input/config.py +2 -2
- plugins/enhanced_input_plugin.py +61 -11
- plugins/fullscreen/__init__.py +6 -2
- plugins/fullscreen/example_plugin.py +1035 -222
- plugins/fullscreen/setup_wizard_plugin.py +592 -0
- plugins/fullscreen/space_shooter_plugin.py +131 -0
- plugins/hook_monitoring_plugin.py +436 -78
- plugins/query_enhancer_plugin.py +66 -30
- plugins/resume_conversation_plugin.py +1494 -0
- plugins/save_conversation_plugin.py +98 -32
- plugins/system_commands_plugin.py +70 -56
- plugins/tmux_plugin.py +154 -78
- plugins/workflow_enforcement_plugin.py +94 -92
- system_prompt/default.md +952 -886
- core/io/input_mode_manager.py +0 -402
- core/io/modal_interaction_handler.py +0 -315
- core/io/raw_input_processor.py +0 -946
- core/storage/__init__.py +0 -5
- core/storage/state_manager.py +0 -84
- core/ui/widget_integration.py +0 -222
- core/utils/key_reader.py +0 -171
- kollabor-0.4.9.dist-info/RECORD +0 -128
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
- {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
|
-
#
|
|
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__(
|
|
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
|
|
122
|
-
|
|
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 =
|
|
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",
|
|
141
|
-
mcp_timeout=config.get("core.llm.mcp_timeout",
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
961
|
-
|
|
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
|
-
|
|
1020
|
-
self.session_stats["
|
|
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
|
|
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
|
-
#
|
|
1705
|
+
|
|
1706
|
+
# Question gate: if enabled and question tag present, suspend tool execution
|
|
1056
1707
|
tool_results = None
|
|
1057
1708
|
if all_tools:
|
|
1058
|
-
|
|
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
|
-
#
|
|
1135
|
-
|
|
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
|
-
|
|
1159
|
-
self.session_stats["
|
|
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
|
-
#
|
|
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
|
-
#
|
|
1944
|
+
|
|
1945
|
+
# Question gate: if enabled and question tag present, suspend tool execution
|
|
1190
1946
|
tool_results = None
|
|
1191
1947
|
if all_tools:
|
|
1192
|
-
|
|
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
|
-
|
|
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")
|