@zds-ai/cli 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +387 -34
- package/dist/agent/context-manager.d.ts +70 -0
- package/dist/agent/context-manager.js +138 -0
- package/dist/agent/context-manager.js.map +1 -0
- package/dist/agent/hook-manager.d.ts +194 -0
- package/dist/agent/hook-manager.js +676 -0
- package/dist/agent/hook-manager.js.map +1 -0
- package/dist/agent/llm-agent.d.ts +469 -100
- package/dist/agent/llm-agent.js +781 -1580
- package/dist/agent/llm-agent.js.map +1 -1
- package/dist/agent/message-processor.d.ts +103 -0
- package/dist/agent/message-processor.js +225 -0
- package/dist/agent/message-processor.js.map +1 -0
- package/dist/agent/prompt-variables.d.ts +103 -40
- package/dist/agent/prompt-variables.js +250 -113
- package/dist/agent/prompt-variables.js.map +1 -1
- package/dist/agent/session-manager.d.ts +75 -0
- package/dist/agent/session-manager.js +194 -0
- package/dist/agent/session-manager.js.map +1 -0
- package/dist/agent/tool-executor.d.ts +111 -0
- package/dist/agent/tool-executor.js +397 -0
- package/dist/agent/tool-executor.js.map +1 -0
- package/dist/bin/generate_image_sd.sh +19 -12
- package/dist/bin/joycaption.sh +37 -0
- package/dist/grok/client.d.ts +52 -0
- package/dist/grok/client.js +127 -19
- package/dist/grok/client.js.map +1 -1
- package/dist/grok/tools.js +42 -8
- package/dist/grok/tools.js.map +1 -1
- package/dist/hooks/use-input-handler.d.ts +1 -1
- package/dist/hooks/use-input-handler.js +100 -13
- package/dist/hooks/use-input-handler.js.map +1 -1
- package/dist/index.js +25 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/config.d.ts +1 -0
- package/dist/mcp/config.js +45 -7
- package/dist/mcp/config.js.map +1 -1
- package/dist/tools/character-tool.js +13 -1
- package/dist/tools/character-tool.js.map +1 -1
- package/dist/tools/image-tool.d.ts +11 -1
- package/dist/tools/image-tool.js +109 -2
- package/dist/tools/image-tool.js.map +1 -1
- package/dist/tools/introspect-tool.js +131 -30
- package/dist/tools/introspect-tool.js.map +1 -1
- package/dist/tools/morph-editor.d.ts +21 -9
- package/dist/tools/morph-editor.js +21 -9
- package/dist/tools/morph-editor.js.map +1 -1
- package/dist/ui/components/active-task-status.d.ts +1 -1
- package/dist/ui/components/api-key-input.d.ts +1 -1
- package/dist/ui/components/backend-status.d.ts +1 -1
- package/dist/ui/components/chat-history.d.ts +1 -1
- package/dist/ui/components/chat-interface.d.ts +1 -1
- package/dist/ui/components/chat-interface.js +1 -1
- package/dist/ui/components/chat-interface.js.map +1 -1
- package/dist/ui/components/context-status.d.ts +1 -1
- package/dist/ui/components/mood-status.d.ts +1 -1
- package/dist/ui/components/persona-status.d.ts +1 -1
- package/dist/utils/chat-history-manager.d.ts +12 -4
- package/dist/utils/chat-history-manager.js +26 -11
- package/dist/utils/chat-history-manager.js.map +1 -1
- package/dist/utils/hook-executor.d.ts +53 -2
- package/dist/utils/hook-executor.js +258 -36
- package/dist/utils/hook-executor.js.map +1 -1
- package/dist/utils/rephrase-handler.d.ts +1 -1
- package/dist/utils/settings-manager.d.ts +41 -11
- package/dist/utils/settings-manager.js +172 -40
- package/dist/utils/settings-manager.js.map +1 -1
- package/dist/utils/slash-commands.d.ts +3 -3
- package/dist/utils/slash-commands.js +11 -5
- package/dist/utils/slash-commands.js.map +1 -1
- package/dist/utils/startup-hook.js +9 -2
- package/dist/utils/startup-hook.js.map +1 -1
- package/package.json +10 -8
package/dist/agent/llm-agent.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { LLMClient } from "../grok/client.js";
|
|
2
|
-
import { getAllLLMTools,
|
|
2
|
+
import { getAllLLMTools, initializeMCPServers, } from "../grok/tools.js";
|
|
3
3
|
import { loadMCPConfig } from "../mcp/config.js";
|
|
4
4
|
import { ChatHistoryManager } from "../utils/chat-history-manager.js";
|
|
5
|
-
import { logApiError } from "../utils/error-logger.js";
|
|
6
|
-
import { parseImagesFromMessage, hasImageReferences } from "../utils/image-encoder.js";
|
|
7
5
|
import { getTextContent } from "../utils/content-utils.js";
|
|
8
6
|
import { Variable } from "./prompt-variables.js";
|
|
9
7
|
import fs from "fs";
|
|
@@ -11,13 +9,14 @@ import { TextEditorTool, MorphEditorTool, ZshTool, ConfirmationTool, SearchTool,
|
|
|
11
9
|
import { EventEmitter } from "events";
|
|
12
10
|
import { createTokenCounter } from "../utils/token-counter.js";
|
|
13
11
|
import { getSettingsManager } from "../utils/settings-manager.js";
|
|
14
|
-
import { executeOperationHook,
|
|
12
|
+
import { executeOperationHook, applyHookCommands, applyEnvVariables } from "../utils/hook-executor.js";
|
|
13
|
+
import { ToolExecutor } from "./tool-executor.js";
|
|
14
|
+
import { HookManager } from "./hook-manager.js";
|
|
15
|
+
import { SessionManager } from "./session-manager.js";
|
|
16
|
+
import { MessageProcessor } from "./message-processor.js";
|
|
17
|
+
import { ContextManager } from "./context-manager.js";
|
|
15
18
|
// Interval (ms) between token count updates when streaming
|
|
16
19
|
const TOKEN_UPDATE_INTERVAL_MS = 250;
|
|
17
|
-
// Minimum delay (in ms) applied when stopping a task to ensure smooth UI/UX.
|
|
18
|
-
const MINIMUM_STOP_TASK_DELAY_MS = 3000;
|
|
19
|
-
// Maximum number of attempts to parse nested JSON strings in executeTool
|
|
20
|
-
const MAX_JSON_PARSE_ATTEMPTS = 5;
|
|
21
20
|
/**
|
|
22
21
|
* Threshold used to determine whether an AI response is "substantial" (in characters).
|
|
23
22
|
*/
|
|
@@ -73,6 +72,60 @@ function sanitizeToolArguments(args) {
|
|
|
73
72
|
}
|
|
74
73
|
return argsString;
|
|
75
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Main LLM Agent class that orchestrates AI conversations with tool execution capabilities.
|
|
77
|
+
*
|
|
78
|
+
* ## Architecture Overview
|
|
79
|
+
*
|
|
80
|
+
* The LLMAgent serves as the central coordinator for AI-powered conversations, managing:
|
|
81
|
+
* - **Conversation Flow**: Handles user messages, AI responses, and multi-turn conversations
|
|
82
|
+
* - **Tool Execution**: Coordinates with various tools (file editing, shell commands, web search, etc.)
|
|
83
|
+
* - **Context Management**: Tracks conversation history and manages token limits
|
|
84
|
+
* - **Session State**: Maintains persona, mood, active tasks, and other session data
|
|
85
|
+
* - **Streaming Support**: Provides real-time response streaming for better UX
|
|
86
|
+
*
|
|
87
|
+
* ## Delegation Architecture
|
|
88
|
+
*
|
|
89
|
+
* The agent delegates specialized functionality to focused manager classes:
|
|
90
|
+
* - **ToolExecutor**: Handles all tool execution, validation, and approval workflows
|
|
91
|
+
* - **HookManager**: Manages persona/mood/task hooks and backend testing
|
|
92
|
+
* - **SessionManager**: Handles session persistence and state restoration
|
|
93
|
+
* - **MessageProcessor**: Processes user input, handles rephrasing, and XML parsing
|
|
94
|
+
* - **ContextManager**: Manages context warnings, compaction, and token tracking
|
|
95
|
+
*
|
|
96
|
+
* ## Key Features
|
|
97
|
+
*
|
|
98
|
+
* - **Multi-Model Support**: Works with various LLM backends (Grok, OpenAI, etc.)
|
|
99
|
+
* - **Tool Integration**: Seamlessly integrates with 15+ built-in tools
|
|
100
|
+
* - **MCP Support**: Extends capabilities via Model Context Protocol servers
|
|
101
|
+
* - **Vision Support**: Handles image inputs for vision-capable models
|
|
102
|
+
* - **Streaming Responses**: Real-time response generation with token counting
|
|
103
|
+
* - **Context Awareness**: Intelligent context management and automatic compaction
|
|
104
|
+
* - **Hook System**: Extensible hook system for custom behaviors
|
|
105
|
+
* - **Session Persistence**: Maintains conversation state across restarts
|
|
106
|
+
*
|
|
107
|
+
* ## Usage Patterns
|
|
108
|
+
*
|
|
109
|
+
* ```typescript
|
|
110
|
+
* // Initialize agent
|
|
111
|
+
* const agent = new LLMAgent(apiKey, baseURL, model);
|
|
112
|
+
* await agent.initialize();
|
|
113
|
+
*
|
|
114
|
+
* // Process messages (non-streaming)
|
|
115
|
+
* const entries = await agent.processUserMessage("Hello, world!");
|
|
116
|
+
*
|
|
117
|
+
* // Process messages (streaming)
|
|
118
|
+
* for await (const chunk of agent.processUserMessageStream("Write a file")) {
|
|
119
|
+
* console.log(chunk);
|
|
120
|
+
* }
|
|
121
|
+
*
|
|
122
|
+
* // Manage session state
|
|
123
|
+
* await agent.setPersona("helpful assistant");
|
|
124
|
+
* await agent.startActiveTask("coding", "writing tests");
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* @extends EventEmitter Emits 'contextChange' events for token usage updates
|
|
128
|
+
*/
|
|
76
129
|
export class LLMAgent extends EventEmitter {
|
|
77
130
|
llmClient;
|
|
78
131
|
textEditor;
|
|
@@ -99,8 +152,6 @@ export class LLMAgent extends EventEmitter {
|
|
|
99
152
|
temperature;
|
|
100
153
|
maxTokens;
|
|
101
154
|
firstMessageProcessed = false;
|
|
102
|
-
contextWarningAt80 = false;
|
|
103
|
-
contextWarningAt90 = false;
|
|
104
155
|
persona = "";
|
|
105
156
|
personaColor = "white";
|
|
106
157
|
mood = "";
|
|
@@ -111,7 +162,106 @@ export class LLMAgent extends EventEmitter {
|
|
|
111
162
|
apiKeyEnvVar = "GROK_API_KEY";
|
|
112
163
|
pendingContextEditSession = null;
|
|
113
164
|
rephraseState = null;
|
|
114
|
-
|
|
165
|
+
toolExecutor;
|
|
166
|
+
hookManager;
|
|
167
|
+
sessionManager;
|
|
168
|
+
messageProcessor;
|
|
169
|
+
contextManager;
|
|
170
|
+
maxContextSize = 128000; // Default context size, can be overridden by MAXCONTEXT hook command
|
|
171
|
+
/**
|
|
172
|
+
* Cleans up incomplete tool calls in the message history.
|
|
173
|
+
* Ensures all tool calls have corresponding tool results to prevent API errors.
|
|
174
|
+
*
|
|
175
|
+
* This method scans the last assistant message for tool calls and adds
|
|
176
|
+
* "[Cancelled by user]" results for any tool calls that don't have results.
|
|
177
|
+
*
|
|
178
|
+
* @private
|
|
179
|
+
*/
|
|
180
|
+
async cleanupIncompleteToolCalls() {
|
|
181
|
+
const lastMessage = this.messages[this.messages.length - 1];
|
|
182
|
+
if (lastMessage?.role === "assistant" && lastMessage.tool_calls) {
|
|
183
|
+
const toolCallIds = new Set(lastMessage.tool_calls.map((tc) => tc.id));
|
|
184
|
+
const completedToolCallIds = new Set();
|
|
185
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
186
|
+
const msg = this.messages[i];
|
|
187
|
+
if (msg.role === "tool" && msg.tool_call_id) {
|
|
188
|
+
completedToolCallIds.add(msg.tool_call_id);
|
|
189
|
+
}
|
|
190
|
+
if (this.messages[i] === lastMessage)
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
for (const toolCallId of toolCallIds) {
|
|
194
|
+
if (!completedToolCallIds.has(toolCallId)) {
|
|
195
|
+
console.error(`Adding cancelled result for incomplete tool call: ${toolCallId}`);
|
|
196
|
+
this.messages.push({
|
|
197
|
+
role: "tool",
|
|
198
|
+
content: "[Cancelled by user]",
|
|
199
|
+
tool_call_id: toolCallId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Executes the instance hook if it hasn't been run yet.
|
|
207
|
+
*
|
|
208
|
+
* The instance hook runs once per agent session and can:
|
|
209
|
+
* - Set prompt variables
|
|
210
|
+
* - Add system messages
|
|
211
|
+
* - Provide prefill text for responses
|
|
212
|
+
*
|
|
213
|
+
* @private
|
|
214
|
+
*/
|
|
215
|
+
async executeInstanceHookIfNeeded() {
|
|
216
|
+
if (!this.hasRunInstanceHook) {
|
|
217
|
+
this.hasRunInstanceHook = true;
|
|
218
|
+
const settings = getSettingsManager();
|
|
219
|
+
const instanceHookPath = settings.getInstanceHook();
|
|
220
|
+
if (instanceHookPath) {
|
|
221
|
+
const hookResult = await executeOperationHook(instanceHookPath, "instance", {}, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
222
|
+
if (hookResult.approved && hookResult.commands && hookResult.commands.length > 0) {
|
|
223
|
+
const results = applyHookCommands(hookResult.commands);
|
|
224
|
+
applyEnvVariables(results.env);
|
|
225
|
+
const seenVars = new Set();
|
|
226
|
+
for (const { name, value } of results.promptVars) {
|
|
227
|
+
if (seenVars.has(name)) {
|
|
228
|
+
Variable.append(name, value);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
Variable.set(name, value);
|
|
232
|
+
seenVars.add(name);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Process hook commands through HookManager
|
|
236
|
+
// Note: This is a simplified version - full hook processing is now in HookManager
|
|
237
|
+
if (results.system) {
|
|
238
|
+
this.messages.push({
|
|
239
|
+
role: 'system',
|
|
240
|
+
content: results.system
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (results.prefill) {
|
|
244
|
+
this.messageProcessor.setHookPrefillText(results.prefill);
|
|
245
|
+
}
|
|
246
|
+
if (results.maxContext !== undefined) {
|
|
247
|
+
this.maxContextSize = results.maxContext;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Creates a new LLMAgent instance.
|
|
255
|
+
*
|
|
256
|
+
* @param apiKey - API key for the LLM service
|
|
257
|
+
* @param baseURL - Optional base URL for the API endpoint
|
|
258
|
+
* @param model - Optional model name (defaults to saved model or "grok-code-fast-1")
|
|
259
|
+
* @param maxToolRounds - Maximum number of tool execution rounds (default: 400)
|
|
260
|
+
* @param debugLogFile - Optional path for MCP debug logging
|
|
261
|
+
* @param startupHookOutput - Optional output from startup hook execution
|
|
262
|
+
* @param temperature - Optional temperature for API requests (0.0-2.0)
|
|
263
|
+
* @param maxTokens - Optional maximum tokens for API responses
|
|
264
|
+
*/
|
|
115
265
|
constructor(apiKey, baseURL, model, maxToolRounds, debugLogFile, startupHookOutput, temperature, maxTokens) {
|
|
116
266
|
super();
|
|
117
267
|
const manager = getSettingsManager();
|
|
@@ -149,6 +299,68 @@ export class LLMAgent extends EventEmitter {
|
|
|
149
299
|
this.imageTool.setAgent(this); // Give image tool access to agent
|
|
150
300
|
this.zsh.setAgent(this); // Give zsh tool access to agent for CWD tracking
|
|
151
301
|
this.tokenCounter = createTokenCounter(modelToUse);
|
|
302
|
+
// Initialize tool executor
|
|
303
|
+
this.toolExecutor = new ToolExecutor(this, this.textEditor, this.morphEditor, this.zsh, this.search, this.env, this.introspect, this.clearCacheTool, this.restartTool, this.characterTool, this.taskTool, this.internetTool, this.imageTool, this.fileConversionTool, this.audioTool);
|
|
304
|
+
// Initialize hook manager
|
|
305
|
+
this.hookManager = new HookManager({
|
|
306
|
+
getLLMClient: () => this.llmClient,
|
|
307
|
+
getTokenCounter: () => this.tokenCounter,
|
|
308
|
+
apiKeyEnvVar: this.apiKeyEnvVar,
|
|
309
|
+
messages: this.messages,
|
|
310
|
+
chatHistory: this.chatHistory,
|
|
311
|
+
temperature: this.temperature,
|
|
312
|
+
getCurrentTokenCount: () => this.getCurrentTokenCount(),
|
|
313
|
+
getMaxContextSize: () => this.getMaxContextSize(),
|
|
314
|
+
getCurrentModel: () => this.getCurrentModel(),
|
|
315
|
+
emit: (event, data) => this.emit(event, data),
|
|
316
|
+
setApiKeyEnvVar: (value) => { this.apiKeyEnvVar = value; },
|
|
317
|
+
setTokenCounter: (counter) => { this.tokenCounter = counter; },
|
|
318
|
+
setLLMClient: (client) => { this.llmClient = client; },
|
|
319
|
+
setPersona: (persona, color) => { this.persona = persona; this.personaColor = color; },
|
|
320
|
+
setMood: (mood, color) => { this.mood = mood; this.moodColor = color; },
|
|
321
|
+
setActiveTask: (task, action, color) => { this.activeTask = task; this.activeTaskAction = action; this.activeTaskColor = color; }
|
|
322
|
+
});
|
|
323
|
+
// Initialize session manager
|
|
324
|
+
this.sessionManager = new SessionManager({
|
|
325
|
+
getLLMClient: () => this.llmClient,
|
|
326
|
+
getTokenCounter: () => this.tokenCounter,
|
|
327
|
+
getApiKeyEnvVar: () => this.apiKeyEnvVar,
|
|
328
|
+
hookManager: this.hookManager,
|
|
329
|
+
getPersona: () => this.persona,
|
|
330
|
+
getPersonaColor: () => this.personaColor,
|
|
331
|
+
getMood: () => this.mood,
|
|
332
|
+
getMoodColor: () => this.moodColor,
|
|
333
|
+
getActiveTask: () => this.activeTask,
|
|
334
|
+
getActiveTaskAction: () => this.activeTaskAction,
|
|
335
|
+
getActiveTaskColor: () => this.activeTaskColor,
|
|
336
|
+
getCurrentModel: () => this.getCurrentModel(),
|
|
337
|
+
emit: (event, data) => this.emit(event, data),
|
|
338
|
+
setLLMClient: (client) => { this.llmClient = client; },
|
|
339
|
+
setTokenCounter: (counter) => { this.tokenCounter = counter; },
|
|
340
|
+
setApiKeyEnvVar: (value) => { this.apiKeyEnvVar = value; },
|
|
341
|
+
setPersona: (persona, color) => { this.persona = persona; this.personaColor = color; },
|
|
342
|
+
setMood: (mood, color) => { this.mood = mood; this.moodColor = color; },
|
|
343
|
+
setActiveTask: (task, action, color) => { this.activeTask = task; this.activeTaskAction = action; this.activeTaskColor = color; }
|
|
344
|
+
});
|
|
345
|
+
// Initialize message processor
|
|
346
|
+
this.messageProcessor = new MessageProcessor({
|
|
347
|
+
chatHistory: this.chatHistory,
|
|
348
|
+
getCurrentTokenCount: () => this.getCurrentTokenCount(),
|
|
349
|
+
getMaxContextSize: () => this.getMaxContextSize(),
|
|
350
|
+
setRephraseState: (originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText) => {
|
|
351
|
+
this.setRephraseState(originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
// Initialize context manager
|
|
355
|
+
this.contextManager = new ContextManager({
|
|
356
|
+
chatHistory: this.chatHistory,
|
|
357
|
+
messages: this.messages,
|
|
358
|
+
tokenCounter: this.tokenCounter,
|
|
359
|
+
getCurrentTokenCount: () => this.getCurrentTokenCount(),
|
|
360
|
+
getMaxContextSize: () => this.getMaxContextSize(),
|
|
361
|
+
emit: (event, data) => this.emit(event, data),
|
|
362
|
+
clearCache: () => this.clearCache()
|
|
363
|
+
});
|
|
152
364
|
// Initialize MCP servers if configured
|
|
153
365
|
this.initializeMCP(debugLogFile);
|
|
154
366
|
// System message will be set after async initialization
|
|
@@ -165,16 +377,32 @@ export class LLMAgent extends EventEmitter {
|
|
|
165
377
|
systemPrompt = "Initializing..."; // THE system prompt (always at messages[0])
|
|
166
378
|
hasRunInstanceHook = false;
|
|
167
379
|
/**
|
|
168
|
-
* Initialize the agent with dynamic system prompt
|
|
169
|
-
*
|
|
380
|
+
* Initialize the agent with dynamic system prompt.
|
|
381
|
+
*
|
|
382
|
+
* This method must be called after construction to:
|
|
383
|
+
* - Build the system message with current tool availability
|
|
384
|
+
* - Set up the initial conversation context
|
|
385
|
+
* - Execute the instance hook if configured
|
|
386
|
+
*
|
|
387
|
+
* @throws {Error} If system message generation fails
|
|
170
388
|
*/
|
|
171
389
|
async initialize() {
|
|
172
390
|
// Build system message
|
|
173
391
|
await this.buildSystemMessage();
|
|
392
|
+
// Run instance hook after initialization is complete
|
|
393
|
+
await this.executeInstanceHookIfNeeded();
|
|
174
394
|
}
|
|
175
395
|
/**
|
|
176
|
-
* Build/rebuild the system message with current tool availability
|
|
177
|
-
*
|
|
396
|
+
* Build/rebuild the system message with current tool availability.
|
|
397
|
+
*
|
|
398
|
+
* This method:
|
|
399
|
+
* - Generates a dynamic tool list using the introspect tool
|
|
400
|
+
* - Sets the APP:TOOLS variable for template rendering
|
|
401
|
+
* - Renders the full SYSTEM template with all variables
|
|
402
|
+
* - Updates messages[0] with the new system prompt
|
|
403
|
+
*
|
|
404
|
+
* The system prompt is always at messages[0] and contains the core
|
|
405
|
+
* instructions, tool descriptions, and current context information.
|
|
178
406
|
*/
|
|
179
407
|
async buildSystemMessage() {
|
|
180
408
|
// Generate dynamic tool list using introspect tool
|
|
@@ -182,16 +410,36 @@ export class LLMAgent extends EventEmitter {
|
|
|
182
410
|
const toolsSection = toolsResult.success ? toolsResult.output : "Tools: Unknown";
|
|
183
411
|
// Set APP:TOOLS variable
|
|
184
412
|
Variable.set("APP:TOOLS", toolsSection);
|
|
185
|
-
//
|
|
413
|
+
// Note: System prompt rendering moved to renderSystemMessage()
|
|
414
|
+
// which is called just before each LLM API call to ensure
|
|
415
|
+
// variables reflect current state including hook-set values
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Render system message with current variable state
|
|
419
|
+
* Called before LLM API calls and task processing to ensure fresh content
|
|
420
|
+
*/
|
|
421
|
+
renderSystemMessage() {
|
|
422
|
+
// Render SYSTEM template with current variables
|
|
186
423
|
this.systemPrompt = Variable.renderFull('SYSTEM');
|
|
187
|
-
// Update messages[0] with the system prompt
|
|
424
|
+
// Update messages[0] with the rendered system prompt
|
|
188
425
|
this.messages[0] = {
|
|
189
426
|
role: "system",
|
|
190
427
|
content: this.systemPrompt,
|
|
191
428
|
};
|
|
192
|
-
// Note: chatHistory no longer contains THE system prompt
|
|
193
|
-
// Only conversational system messages (persona, mood, etc.) go in chatHistory
|
|
194
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Load initial conversation history from persistence.
|
|
432
|
+
*
|
|
433
|
+
* This method:
|
|
434
|
+
* - Loads the chat history (excluding system messages)
|
|
435
|
+
* - Sets or generates the system prompt
|
|
436
|
+
* - Converts history to API message format
|
|
437
|
+
* - Handles tool call/result matching
|
|
438
|
+
* - Updates token counts
|
|
439
|
+
*
|
|
440
|
+
* @param history - Array of chat entries to load
|
|
441
|
+
* @param systemPrompt - Optional system prompt (will generate if not provided)
|
|
442
|
+
*/
|
|
195
443
|
async loadInitialHistory(history, systemPrompt) {
|
|
196
444
|
// Load chatHistory (no system messages in new architecture)
|
|
197
445
|
this.chatHistory = history;
|
|
@@ -287,6 +535,16 @@ export class LLMAgent extends EventEmitter {
|
|
|
287
535
|
this.messages[0].content = this.messages[0].content.replace(/Current conversation token usage: .*/, `Current conversation token usage: ${currentTokens}`);
|
|
288
536
|
}
|
|
289
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Initialize Model Context Protocol (MCP) servers in the background.
|
|
540
|
+
*
|
|
541
|
+
* This method loads MCP configuration and initializes any configured
|
|
542
|
+
* servers without blocking agent construction. Errors are logged but
|
|
543
|
+
* don't prevent agent operation.
|
|
544
|
+
*
|
|
545
|
+
* @param debugLogFile - Optional path for MCP debug output
|
|
546
|
+
* @private
|
|
547
|
+
*/
|
|
290
548
|
async initializeMCP(debugLogFile) {
|
|
291
549
|
// Initialize MCP in the background without blocking
|
|
292
550
|
Promise.resolve().then(async () => {
|
|
@@ -304,11 +562,27 @@ export class LLMAgent extends EventEmitter {
|
|
|
304
562
|
}
|
|
305
563
|
});
|
|
306
564
|
}
|
|
565
|
+
/**
|
|
566
|
+
* Checks if the current model is a Grok model.
|
|
567
|
+
* Used to enable Grok-specific features like web search.
|
|
568
|
+
*
|
|
569
|
+
* @returns True if the current model name contains "grok"
|
|
570
|
+
* @private
|
|
571
|
+
*/
|
|
307
572
|
isGrokModel() {
|
|
308
573
|
const currentModel = this.llmClient.getCurrentModel();
|
|
309
574
|
return currentModel.toLowerCase().includes("grok");
|
|
310
575
|
}
|
|
311
|
-
|
|
576
|
+
/**
|
|
577
|
+
* Heuristic to determine if web search should be enabled for a message.
|
|
578
|
+
*
|
|
579
|
+
* Analyzes the message content for keywords that suggest the user is
|
|
580
|
+
* asking for current information, news, or time-sensitive data.
|
|
581
|
+
*
|
|
582
|
+
* @param message - The user message to analyze
|
|
583
|
+
* @returns True if web search should be enabled
|
|
584
|
+
* @private
|
|
585
|
+
*/
|
|
312
586
|
shouldUseSearchFor(message) {
|
|
313
587
|
const q = message.toLowerCase();
|
|
314
588
|
const keywords = [
|
|
@@ -337,197 +611,132 @@ export class LLMAgent extends EventEmitter {
|
|
|
337
611
|
return true;
|
|
338
612
|
return false;
|
|
339
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Process a user message and return all conversation entries generated.
|
|
616
|
+
*
|
|
617
|
+
* This is the main non-streaming message processing method that:
|
|
618
|
+
* - Handles rephrase commands and message preprocessing
|
|
619
|
+
* - Manages the agent loop with tool execution
|
|
620
|
+
* - Processes multiple rounds of AI responses and tool calls
|
|
621
|
+
* - Handles errors and context management
|
|
622
|
+
* - Returns all new conversation entries
|
|
623
|
+
*
|
|
624
|
+
* ## Processing Flow
|
|
625
|
+
*
|
|
626
|
+
* 1. **Setup**: Parse rephrase commands, clean incomplete tool calls
|
|
627
|
+
* 2. **Message Processing**: Parse images, assemble content, add to history
|
|
628
|
+
* 3. **Agent Loop**: Continue until no more tool calls or max rounds reached
|
|
629
|
+
* - Get AI response
|
|
630
|
+
* - Execute any tool calls
|
|
631
|
+
* - Add results to conversation
|
|
632
|
+
* - Get next response if needed
|
|
633
|
+
* 4. **Cleanup**: Handle errors, update context, return entries
|
|
634
|
+
*
|
|
635
|
+
* @param message - The user message to process
|
|
636
|
+
* @returns Promise resolving to array of new conversation entries
|
|
637
|
+
* @throws {Error} If message processing fails critically
|
|
638
|
+
*/
|
|
340
639
|
async processUserMessage(message) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
let isSystemRephrase = false;
|
|
344
|
-
let messageToSend = message;
|
|
345
|
-
let messageType = "user";
|
|
346
|
-
let prefillText;
|
|
347
|
-
if (message.startsWith("/system rephrase")) {
|
|
348
|
-
isRephraseCommand = true;
|
|
349
|
-
isSystemRephrase = true;
|
|
350
|
-
messageToSend = message.substring(8).trim(); // Strip "/system " (8 chars including space)
|
|
351
|
-
messageType = "system";
|
|
352
|
-
// Extract prefill text after "/system rephrase "
|
|
353
|
-
const prefillMatch = message.match(/^\/system rephrase\s+(.+)$/);
|
|
354
|
-
if (prefillMatch) {
|
|
355
|
-
prefillText = prefillMatch[1];
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
else if (message.startsWith("/rephrase")) {
|
|
359
|
-
isRephraseCommand = true;
|
|
360
|
-
messageToSend = message; // Keep full text including "/rephrase"
|
|
361
|
-
messageType = "user";
|
|
362
|
-
// Extract prefill text after "/rephrase "
|
|
363
|
-
const prefillMatch = message.match(/^\/rephrase\s+(.+)$/);
|
|
364
|
-
if (prefillMatch) {
|
|
365
|
-
prefillText = prefillMatch[1];
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
// If this is a rephrase command, find the last assistant message
|
|
369
|
-
if (isRephraseCommand) {
|
|
370
|
-
// Find index of last assistant message in chatHistory
|
|
371
|
-
let lastAssistantIndex = -1;
|
|
372
|
-
for (let i = this.chatHistory.length - 1; i >= 0; i--) {
|
|
373
|
-
if (this.chatHistory[i].type === "assistant") {
|
|
374
|
-
lastAssistantIndex = i;
|
|
375
|
-
break;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
if (lastAssistantIndex === -1) {
|
|
379
|
-
throw new Error("No previous assistant message to rephrase");
|
|
380
|
-
}
|
|
381
|
-
// Store rephrase state (will be updated with newResponseIndex after response)
|
|
382
|
-
// For now, just mark that we're in rephrase mode
|
|
383
|
-
this.setRephraseState(lastAssistantIndex, this.chatHistory.length, -1, messageType, prefillText);
|
|
384
|
-
}
|
|
385
|
-
// Before adding the new user message, check if there are incomplete tool calls
|
|
386
|
-
// from a previous interrupted turn. This prevents malformed message sequences
|
|
387
|
-
// that cause Ollama 500 errors.
|
|
388
|
-
const lastMessage = this.messages[this.messages.length - 1];
|
|
389
|
-
if (lastMessage?.role === "assistant" && lastMessage.tool_calls) {
|
|
390
|
-
// Find tool_call_ids that don't have corresponding tool result messages
|
|
391
|
-
const toolCallIds = new Set(lastMessage.tool_calls.map((tc) => tc.id));
|
|
392
|
-
const completedToolCallIds = new Set();
|
|
393
|
-
// Check which tool calls have results
|
|
394
|
-
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
395
|
-
const msg = this.messages[i];
|
|
396
|
-
if (msg.role === "tool" && msg.tool_call_id) {
|
|
397
|
-
completedToolCallIds.add(msg.tool_call_id);
|
|
398
|
-
}
|
|
399
|
-
// Stop when we hit the assistant message with tool_calls
|
|
400
|
-
if (this.messages[i] === lastMessage)
|
|
401
|
-
break;
|
|
402
|
-
}
|
|
403
|
-
// Add cancelled results for any incomplete tool calls
|
|
404
|
-
for (const toolCallId of toolCallIds) {
|
|
405
|
-
if (!completedToolCallIds.has(toolCallId)) {
|
|
406
|
-
console.error(`Adding cancelled result for incomplete tool call: ${toolCallId}`);
|
|
407
|
-
this.messages.push({
|
|
408
|
-
role: "tool",
|
|
409
|
-
content: "[Cancelled by user]",
|
|
410
|
-
tool_call_id: toolCallId,
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
// Clear one-shot variables
|
|
640
|
+
const { isRephraseCommand, messageType, messageToSend, prefillText } = await this.messageProcessor.setupRephraseCommand(message);
|
|
641
|
+
await this.cleanupIncompleteToolCalls();
|
|
416
642
|
Variable.clearOneShot();
|
|
417
|
-
// Execute
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
const instanceHookPath = settings.getInstanceHook();
|
|
422
|
-
if (instanceHookPath) {
|
|
423
|
-
const hookResult = await executeOperationHook(instanceHookPath, "instance", {}, 30000, false, // Instance hook is not mandatory
|
|
424
|
-
this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
425
|
-
if (hookResult.approved && hookResult.commands && hookResult.commands.length > 0) {
|
|
426
|
-
// Apply hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, SET*)
|
|
427
|
-
const results = applyHookCommands(hookResult.commands);
|
|
428
|
-
// Apply prompt variables from SET* commands
|
|
429
|
-
for (const [varName, value] of results.promptVars.entries()) {
|
|
430
|
-
Variable.set(varName, value);
|
|
431
|
-
}
|
|
432
|
-
// Process other hook commands (MODEL, BACKEND, ENV)
|
|
433
|
-
await this.processHookCommands(results);
|
|
434
|
-
// Add SYSTEM message to messages array if present
|
|
435
|
-
if (results.system) {
|
|
436
|
-
this.messages.push({
|
|
437
|
-
role: 'system',
|
|
438
|
-
content: results.system
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
// Store prefill text from hook if present
|
|
442
|
-
if (results.prefill) {
|
|
443
|
-
this.hookPrefillText = results.prefill;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
// Parse images once if present (for both text extraction and later assembly)
|
|
449
|
-
const parsed = hasImageReferences(messageToSend)
|
|
450
|
-
? parseImagesFromMessage(messageToSend)
|
|
451
|
-
: { text: messageToSend, images: [] };
|
|
452
|
-
// Set USER:PROMPT variable (text only, images stripped)
|
|
453
|
-
Variable.set("USER:PROMPT", parsed.text);
|
|
454
|
-
// Execute prePrompt hook if configured
|
|
455
|
-
const hookPath = getSettingsManager().getPrePromptHook();
|
|
456
|
-
if (hookPath) {
|
|
457
|
-
const hookResult = await executeOperationHook(hookPath, "prePrompt", { USER_MESSAGE: parsed.text }, 30000, false, // prePrompt hook is never mandatory
|
|
458
|
-
this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
643
|
+
// Execute postUserInput hook
|
|
644
|
+
const postUserInputHookPath = getSettingsManager().getPostUserInputHook();
|
|
645
|
+
if (postUserInputHookPath) {
|
|
646
|
+
const hookResult = await executeOperationHook(postUserInputHookPath, "postUserInput", { USER_MESSAGE: message }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
459
647
|
if (hookResult.approved && hookResult.commands) {
|
|
460
648
|
const results = applyHookCommands(hookResult.commands);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
649
|
+
applyEnvVariables(results.env);
|
|
650
|
+
const seenVars = new Set();
|
|
651
|
+
for (const { name, value } of results.promptVars) {
|
|
652
|
+
if (seenVars.has(name)) {
|
|
653
|
+
Variable.append(name, value);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
Variable.set(name, value);
|
|
657
|
+
seenVars.add(name);
|
|
658
|
+
}
|
|
464
659
|
}
|
|
465
|
-
// Process other hook commands (MODEL, BACKEND, SYSTEM, etc.)
|
|
466
|
-
await this.processHookCommands(results);
|
|
467
|
-
// Store prefill text from hook if present
|
|
468
660
|
if (results.prefill) {
|
|
469
|
-
this.
|
|
661
|
+
this.messageProcessor.setHookPrefillText(results.prefill);
|
|
662
|
+
}
|
|
663
|
+
if (results.maxContext !== undefined) {
|
|
664
|
+
this.maxContextSize = results.maxContext;
|
|
470
665
|
}
|
|
471
666
|
}
|
|
472
667
|
}
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
// Add user/system message to conversation
|
|
476
|
-
// Note: System messages can only have string content, so images are only supported for user messages
|
|
477
|
-
const supportsVision = this.llmClient.getSupportsVision();
|
|
478
|
-
let messageContent = assembledMessage;
|
|
479
|
-
if (messageType === "user" && parsed.images.length > 0 && supportsVision) {
|
|
480
|
-
// Construct content array with assembled text and images
|
|
481
|
-
messageContent = [
|
|
482
|
-
{ type: "text", text: assembledMessage },
|
|
483
|
-
...parsed.images
|
|
484
|
-
];
|
|
485
|
-
}
|
|
486
|
-
const userEntry = {
|
|
487
|
-
type: messageType,
|
|
488
|
-
content: messageContent,
|
|
489
|
-
originalContent: messageType === "user" ? (parsed.images.length > 0 && supportsVision
|
|
490
|
-
? [{ type: "text", text: parsed.text }, ...parsed.images]
|
|
491
|
-
: parsed.text) : undefined,
|
|
492
|
-
timestamp: new Date(),
|
|
493
|
-
};
|
|
668
|
+
const { parsed, assembledMessage } = await this.messageProcessor.parseAndAssembleMessage(messageToSend);
|
|
669
|
+
const { userEntry, messageContent } = this.messageProcessor.prepareMessageContent(messageType, assembledMessage, parsed, messageToSend, this.llmClient.getSupportsVision());
|
|
494
670
|
this.chatHistory.push(userEntry);
|
|
495
|
-
// Push to messages array with proper typing based on role
|
|
496
671
|
if (messageType === "user") {
|
|
497
672
|
this.messages.push({ role: "user", content: messageContent });
|
|
498
673
|
}
|
|
499
674
|
else {
|
|
500
|
-
// System messages must have string content only
|
|
501
675
|
this.messages.push({ role: "system", content: typeof messageContent === "string" ? messageContent : messageToSend });
|
|
502
676
|
}
|
|
503
|
-
await this.emitContextChange();
|
|
677
|
+
await this.contextManager.emitContextChange();
|
|
504
678
|
const newEntries = [userEntry];
|
|
505
679
|
const maxToolRounds = this.maxToolRounds; // Prevent infinite loops
|
|
506
680
|
let toolRounds = 0;
|
|
507
681
|
let consecutiveNonToolResponses = 0;
|
|
508
682
|
try {
|
|
509
|
-
//
|
|
510
|
-
|
|
683
|
+
// Execute preLLMResponse hook just before LLM call
|
|
684
|
+
const hookPath = getSettingsManager().getPreLLMResponseHook();
|
|
685
|
+
if (hookPath) {
|
|
686
|
+
const hookResult = await executeOperationHook(hookPath, "preLLMResponse", { USER_MESSAGE: message }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
687
|
+
if (hookResult.approved && hookResult.commands) {
|
|
688
|
+
const results = applyHookCommands(hookResult.commands);
|
|
689
|
+
applyEnvVariables(results.env);
|
|
690
|
+
const seenVars = new Set();
|
|
691
|
+
for (const { name, value } of results.promptVars) {
|
|
692
|
+
if (seenVars.has(name)) {
|
|
693
|
+
Variable.append(name, value);
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
Variable.set(name, value);
|
|
697
|
+
seenVars.add(name);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (results.prefill) {
|
|
701
|
+
this.messageProcessor.setHookPrefillText(results.prefill);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// If rephrase or hook returned prefill text, add the assistant message now
|
|
706
|
+
const rephraseText = this.rephraseState?.prefillText;
|
|
707
|
+
const hookPrefillText = this.messageProcessor.getHookPrefillText();
|
|
708
|
+
if (rephraseText) {
|
|
511
709
|
this.messages.push({
|
|
512
710
|
role: "assistant",
|
|
513
|
-
content:
|
|
711
|
+
content: rephraseText
|
|
514
712
|
});
|
|
515
713
|
}
|
|
516
|
-
|
|
517
|
-
if (this.hookPrefillText) {
|
|
714
|
+
else if (hookPrefillText) {
|
|
518
715
|
this.messages.push({
|
|
519
716
|
role: "assistant",
|
|
520
|
-
content:
|
|
717
|
+
content: hookPrefillText
|
|
521
718
|
});
|
|
522
719
|
}
|
|
523
|
-
//
|
|
720
|
+
// Render system message with current variable state before sending to LLM
|
|
721
|
+
this.renderSystemMessage();
|
|
524
722
|
const supportsTools = this.llmClient.getSupportsTools();
|
|
525
723
|
let currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
|
|
526
724
|
? { search_parameters: { mode: "auto" } }
|
|
527
725
|
: { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
|
|
528
726
|
// Parse XML tool calls from response if present
|
|
529
727
|
if (currentResponse.choices?.[0]?.message) {
|
|
530
|
-
currentResponse.choices[0].message = this.parseXMLToolCalls(currentResponse.choices[0].message);
|
|
728
|
+
currentResponse.choices[0].message = this.messageProcessor.parseXMLToolCalls(currentResponse.choices[0].message);
|
|
729
|
+
}
|
|
730
|
+
// Execute postLLMResponse hook
|
|
731
|
+
const postLLMResponseHookPath = getSettingsManager().getPostLLMResponseHook();
|
|
732
|
+
if (postLLMResponseHookPath) {
|
|
733
|
+
const hookResult = await executeOperationHook(postLLMResponseHookPath, "postLLMResponse", {
|
|
734
|
+
LLM_RESPONSE: getTextContent(currentResponse.choices?.[0]?.message?.content),
|
|
735
|
+
TOOL_CALLS: JSON.stringify(currentResponse.choices?.[0]?.message?.tool_calls || [])
|
|
736
|
+
}, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
737
|
+
if (hookResult.approved && hookResult.commands) {
|
|
738
|
+
await this.processHookResult(hookResult);
|
|
739
|
+
}
|
|
531
740
|
}
|
|
532
741
|
// Agent loop - continue until no more tool calls or max rounds reached
|
|
533
742
|
while (toolRounds < maxToolRounds) {
|
|
@@ -567,7 +776,7 @@ export class LLMAgent extends EventEmitter {
|
|
|
567
776
|
};
|
|
568
777
|
this.chatHistory.push(assistantToolCallEntry);
|
|
569
778
|
newEntries.push(assistantToolCallEntry);
|
|
570
|
-
await this.emitContextChange();
|
|
779
|
+
await this.contextManager.emitContextChange();
|
|
571
780
|
// Create initial tool call entries to show tools are being executed
|
|
572
781
|
// Use cleanedToolCalls to preserve arguments in chatHistory
|
|
573
782
|
cleanedToolCalls.forEach((toolCall) => {
|
|
@@ -600,7 +809,7 @@ export class LLMAgent extends EventEmitter {
|
|
|
600
809
|
}
|
|
601
810
|
throw new Error("Operation cancelled by user");
|
|
602
811
|
}
|
|
603
|
-
const result = await this.executeTool(toolCall);
|
|
812
|
+
const result = await this.toolExecutor.executeTool(toolCall);
|
|
604
813
|
// Update the existing tool_call entry with the result
|
|
605
814
|
const entryIndex = this.chatHistory.findIndex((entry) => entry.type === "tool_call" && entry.toolCall?.id === toolCall.id);
|
|
606
815
|
if (entryIndex !== -1) {
|
|
@@ -629,7 +838,7 @@ export class LLMAgent extends EventEmitter {
|
|
|
629
838
|
tool_call_id: toolCall.id,
|
|
630
839
|
});
|
|
631
840
|
completedToolCallIds.add(toolCall.id);
|
|
632
|
-
await this.emitContextChange();
|
|
841
|
+
await this.contextManager.emitContextChange();
|
|
633
842
|
toolIndex++;
|
|
634
843
|
}
|
|
635
844
|
}
|
|
@@ -687,6 +896,8 @@ export class LLMAgent extends EventEmitter {
|
|
|
687
896
|
msgSummary.tool_call_id = msg.tool_call_id;
|
|
688
897
|
fs.appendFileSync(debugLogPath, ` ${JSON.stringify(msgSummary)}\n`);
|
|
689
898
|
});
|
|
899
|
+
// Render system message with current variable state before sending to LLM
|
|
900
|
+
this.renderSystemMessage();
|
|
690
901
|
currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
|
|
691
902
|
? { search_parameters: { mode: "auto" } }
|
|
692
903
|
: { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
|
|
@@ -699,9 +910,10 @@ export class LLMAgent extends EventEmitter {
|
|
|
699
910
|
trimmedContent = this.rephraseState.prefillText + trimmedContent;
|
|
700
911
|
}
|
|
701
912
|
// If a hook provided prefill, prepend it to the response
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
913
|
+
const hookPrefillText = this.messageProcessor.getHookPrefillText();
|
|
914
|
+
if (trimmedContent && hookPrefillText) {
|
|
915
|
+
trimmedContent = hookPrefillText + trimmedContent;
|
|
916
|
+
this.messageProcessor.clearHookPrefillText();
|
|
705
917
|
}
|
|
706
918
|
if (trimmedContent) {
|
|
707
919
|
const responseEntry = {
|
|
@@ -733,12 +945,14 @@ export class LLMAgent extends EventEmitter {
|
|
|
733
945
|
break; // Task complete - bot gave a full response
|
|
734
946
|
}
|
|
735
947
|
// Short/empty response, give AI another chance
|
|
948
|
+
// Render system message with current variable state before sending to LLM
|
|
949
|
+
this.renderSystemMessage();
|
|
736
950
|
currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
|
|
737
951
|
? { search_parameters: { mode: "auto" } }
|
|
738
952
|
: { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
|
|
739
953
|
// Parse XML tool calls from followup response if present
|
|
740
954
|
if (currentResponse.choices?.[0]?.message) {
|
|
741
|
-
currentResponse.choices[0].message = this.parseXMLToolCalls(currentResponse.choices[0].message);
|
|
955
|
+
currentResponse.choices[0].message = this.messageProcessor.parseXMLToolCalls(currentResponse.choices[0].message);
|
|
742
956
|
}
|
|
743
957
|
const followupMessage = currentResponse.choices?.[0]?.message;
|
|
744
958
|
if (!followupMessage?.tool_calls || followupMessage.tool_calls.length === 0) {
|
|
@@ -795,284 +1009,84 @@ export class LLMAgent extends EventEmitter {
|
|
|
795
1009
|
}
|
|
796
1010
|
}
|
|
797
1011
|
/**
|
|
798
|
-
*
|
|
799
|
-
*
|
|
1012
|
+
* Process a user message with real-time streaming response.
|
|
1013
|
+
*
|
|
1014
|
+
* This is the main streaming message processing method that yields
|
|
1015
|
+
* chunks of data as the conversation progresses. Provides real-time
|
|
1016
|
+
* updates for:
|
|
1017
|
+
* - User message processing
|
|
1018
|
+
* - AI response streaming (content as it's generated)
|
|
1019
|
+
* - Tool execution progress
|
|
1020
|
+
* - Token count updates
|
|
1021
|
+
* - System messages from hooks
|
|
1022
|
+
*
|
|
1023
|
+
* ## Streaming Flow
|
|
1024
|
+
*
|
|
1025
|
+
* 1. **Setup**: Process user message, yield user entry
|
|
1026
|
+
* 2. **Agent Loop**: Stream AI responses and execute tools
|
|
1027
|
+
* - Stream AI response content in real-time
|
|
1028
|
+
* - Yield tool calls when detected
|
|
1029
|
+
* - Execute tools and yield results
|
|
1030
|
+
* - Continue until completion
|
|
1031
|
+
* 3. **Completion**: Yield final token counts and done signal
|
|
1032
|
+
*
|
|
1033
|
+
* ## Chunk Types
|
|
1034
|
+
*
|
|
1035
|
+
* - `user_message`: Initial user message entry
|
|
1036
|
+
* - `content`: Streaming AI response content
|
|
1037
|
+
* - `tool_calls`: Tool calls detected in AI response
|
|
1038
|
+
* - `tool_result`: Results from tool execution
|
|
1039
|
+
* - `token_count`: Updated token usage
|
|
1040
|
+
* - `done`: Processing complete
|
|
1041
|
+
*
|
|
1042
|
+
* @param message - The user message to process
|
|
1043
|
+
* @yields StreamingChunk objects with real-time updates
|
|
1044
|
+
* @throws {Error} If streaming fails critically
|
|
800
1045
|
*/
|
|
801
|
-
parseXMLToolCalls(message) {
|
|
802
|
-
if (!message.content || typeof message.content !== 'string') {
|
|
803
|
-
return message;
|
|
804
|
-
}
|
|
805
|
-
const content = message.content;
|
|
806
|
-
const xmlToolCallRegex = /<xai:function_call\s+name="([^"]+)">([\s\S]*?)<\/xai:function_call>/g;
|
|
807
|
-
const matches = Array.from(content.matchAll(xmlToolCallRegex));
|
|
808
|
-
if (matches.length === 0) {
|
|
809
|
-
return message;
|
|
810
|
-
}
|
|
811
|
-
// Parse each XML tool call
|
|
812
|
-
const toolCalls = [];
|
|
813
|
-
let cleanedContent = content;
|
|
814
|
-
for (const match of matches) {
|
|
815
|
-
const functionName = match[1];
|
|
816
|
-
const paramsXML = match[2];
|
|
817
|
-
// Parse parameters
|
|
818
|
-
const paramRegex = /<parameter\s+name="([^"]+)">([^<]*)<\/parameter>/g;
|
|
819
|
-
const paramMatches = Array.from(paramsXML.matchAll(paramRegex));
|
|
820
|
-
const args = {};
|
|
821
|
-
for (const paramMatch of paramMatches) {
|
|
822
|
-
args[paramMatch[1]] = paramMatch[2];
|
|
823
|
-
}
|
|
824
|
-
// Generate a unique ID for this tool call
|
|
825
|
-
const toolCallId = `call_xml_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
826
|
-
toolCalls.push({
|
|
827
|
-
id: toolCallId,
|
|
828
|
-
type: "function",
|
|
829
|
-
function: {
|
|
830
|
-
name: functionName,
|
|
831
|
-
arguments: JSON.stringify(args)
|
|
832
|
-
}
|
|
833
|
-
});
|
|
834
|
-
// Remove this XML block from content
|
|
835
|
-
cleanedContent = cleanedContent.replace(match[0], '');
|
|
836
|
-
}
|
|
837
|
-
// Trim any extra whitespace
|
|
838
|
-
cleanedContent = cleanedContent.trim();
|
|
839
|
-
// Return modified message with tool_calls and cleaned content
|
|
840
|
-
return {
|
|
841
|
-
...message,
|
|
842
|
-
content: cleanedContent || null,
|
|
843
|
-
tool_calls: [...(message.tool_calls || []), ...toolCalls]
|
|
844
|
-
};
|
|
845
|
-
}
|
|
846
|
-
messageReducer(previous, item) {
|
|
847
|
-
const reduce = (acc, delta) => {
|
|
848
|
-
// Ensure acc is always an object before spreading (handles null/undefined)
|
|
849
|
-
acc = { ...(acc || {}) };
|
|
850
|
-
for (const [key, value] of Object.entries(delta)) {
|
|
851
|
-
// Skip null values in delta (Venice sends tool_calls: null which breaks Object.entries)
|
|
852
|
-
if (value === null)
|
|
853
|
-
continue;
|
|
854
|
-
if (acc[key] === undefined || acc[key] === null) {
|
|
855
|
-
acc[key] = value;
|
|
856
|
-
// Clean up index properties from tool calls
|
|
857
|
-
if (Array.isArray(acc[key])) {
|
|
858
|
-
for (const arr of acc[key]) {
|
|
859
|
-
delete arr.index;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
else if (typeof acc[key] === "string" && typeof value === "string") {
|
|
864
|
-
// Don't concatenate certain properties that should remain separate
|
|
865
|
-
const nonConcatenableProps = ['id', 'type', 'name'];
|
|
866
|
-
if (nonConcatenableProps.includes(key)) {
|
|
867
|
-
// For non-concatenable properties, keep the new value
|
|
868
|
-
acc[key] = value;
|
|
869
|
-
}
|
|
870
|
-
else {
|
|
871
|
-
// For content, arguments, and other text properties, concatenate
|
|
872
|
-
acc[key] += value;
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
else if (Array.isArray(acc[key]) && Array.isArray(value)) {
|
|
876
|
-
const accArray = acc[key];
|
|
877
|
-
for (let i = 0; i < value.length; i++) {
|
|
878
|
-
if (!accArray[i])
|
|
879
|
-
accArray[i] = {};
|
|
880
|
-
accArray[i] = reduce(accArray[i], value[i]);
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
else if (typeof acc[key] === "object" && typeof value === "object") {
|
|
884
|
-
acc[key] = reduce(acc[key], value);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
return acc;
|
|
888
|
-
};
|
|
889
|
-
return reduce(previous, item.choices?.[0]?.delta || {});
|
|
890
|
-
}
|
|
891
1046
|
async *processUserMessageStream(message) {
|
|
892
|
-
// Create new abort controller for this request
|
|
893
1047
|
this.abortController = new AbortController();
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
let isSystemRephrase = false;
|
|
897
|
-
let messageToSend = message;
|
|
898
|
-
let messageType = "user";
|
|
899
|
-
let prefillText;
|
|
900
|
-
if (message.startsWith("/system rephrase")) {
|
|
901
|
-
isRephraseCommand = true;
|
|
902
|
-
isSystemRephrase = true;
|
|
903
|
-
messageToSend = message.substring(8).trim(); // Strip "/system " (8 chars including space)
|
|
904
|
-
messageType = "system";
|
|
905
|
-
// Extract prefill text after "/system rephrase "
|
|
906
|
-
const prefillMatch = message.match(/^\/system rephrase\s+(.+)$/);
|
|
907
|
-
if (prefillMatch) {
|
|
908
|
-
prefillText = prefillMatch[1];
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
else if (message.startsWith("/rephrase")) {
|
|
912
|
-
isRephraseCommand = true;
|
|
913
|
-
messageToSend = message; // Keep full text including "/rephrase"
|
|
914
|
-
messageType = "user";
|
|
915
|
-
// Extract prefill text after "/rephrase "
|
|
916
|
-
const prefillMatch = message.match(/^\/rephrase\s+(.+)$/);
|
|
917
|
-
if (prefillMatch) {
|
|
918
|
-
prefillText = prefillMatch[1];
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
// If this is a rephrase command, find the last assistant message
|
|
922
|
-
if (isRephraseCommand) {
|
|
923
|
-
// Find index of last assistant message in chatHistory
|
|
924
|
-
let lastAssistantIndex = -1;
|
|
925
|
-
for (let i = this.chatHistory.length - 1; i >= 0; i--) {
|
|
926
|
-
if (this.chatHistory[i].type === "assistant") {
|
|
927
|
-
lastAssistantIndex = i;
|
|
928
|
-
break;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
if (lastAssistantIndex === -1) {
|
|
932
|
-
throw new Error("No previous assistant message to rephrase");
|
|
933
|
-
}
|
|
934
|
-
// Store rephrase state (will be updated with newResponseIndex after response)
|
|
935
|
-
// For now, just mark that we're in rephrase mode
|
|
936
|
-
this.setRephraseState(lastAssistantIndex, this.chatHistory.length, -1, messageType, prefillText);
|
|
937
|
-
}
|
|
938
|
-
// Before adding the new user message, check if there are incomplete tool calls
|
|
939
|
-
// from a previous interrupted turn. This prevents malformed message sequences
|
|
940
|
-
// that cause Ollama 500 errors.
|
|
941
|
-
const lastMessage = this.messages[this.messages.length - 1];
|
|
942
|
-
if (lastMessage?.role === "assistant" && lastMessage.tool_calls) {
|
|
943
|
-
// Find tool_call_ids that don't have corresponding tool result messages
|
|
944
|
-
const toolCallIds = new Set(lastMessage.tool_calls.map((tc) => tc.id));
|
|
945
|
-
const completedToolCallIds = new Set();
|
|
946
|
-
// Check which tool calls have results
|
|
947
|
-
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
948
|
-
const msg = this.messages[i];
|
|
949
|
-
if (msg.role === "tool" && msg.tool_call_id) {
|
|
950
|
-
completedToolCallIds.add(msg.tool_call_id);
|
|
951
|
-
}
|
|
952
|
-
// Stop when we hit the assistant message with tool_calls
|
|
953
|
-
if (this.messages[i] === lastMessage)
|
|
954
|
-
break;
|
|
955
|
-
}
|
|
956
|
-
// Add cancelled results for any incomplete tool calls
|
|
957
|
-
for (const toolCallId of toolCallIds) {
|
|
958
|
-
if (!completedToolCallIds.has(toolCallId)) {
|
|
959
|
-
console.error(`Adding cancelled result for incomplete tool call: ${toolCallId}`);
|
|
960
|
-
this.messages.push({
|
|
961
|
-
role: "tool",
|
|
962
|
-
content: "[Cancelled by user]",
|
|
963
|
-
tool_call_id: toolCallId,
|
|
964
|
-
});
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
// Clear one-shot variables
|
|
1048
|
+
const { isRephraseCommand, messageType, messageToSend, prefillText } = await this.messageProcessor.setupRephraseCommand(message);
|
|
1049
|
+
await this.cleanupIncompleteToolCalls();
|
|
969
1050
|
Variable.clearOneShot();
|
|
970
|
-
// Execute
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
const
|
|
974
|
-
const instanceHookPath = settings.getInstanceHook();
|
|
975
|
-
if (instanceHookPath) {
|
|
976
|
-
const hookResult = await executeOperationHook(instanceHookPath, "instance", {}, 30000, false, // Instance hook is not mandatory
|
|
977
|
-
this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
978
|
-
if (hookResult.approved && hookResult.commands && hookResult.commands.length > 0) {
|
|
979
|
-
// Apply hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, SET*)
|
|
980
|
-
const results = applyHookCommands(hookResult.commands);
|
|
981
|
-
// Apply prompt variables from SET* commands
|
|
982
|
-
for (const [varName, value] of results.promptVars.entries()) {
|
|
983
|
-
Variable.set(varName, value);
|
|
984
|
-
}
|
|
985
|
-
// Process other hook commands (MODEL, BACKEND, ENV)
|
|
986
|
-
await this.processHookCommands(results);
|
|
987
|
-
// Add SYSTEM message to messages array if present
|
|
988
|
-
if (results.system) {
|
|
989
|
-
this.messages.push({
|
|
990
|
-
role: 'system',
|
|
991
|
-
content: results.system
|
|
992
|
-
});
|
|
993
|
-
}
|
|
994
|
-
// Store prefill text from hook if present
|
|
995
|
-
if (results.prefill) {
|
|
996
|
-
this.hookPrefillText = results.prefill;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
// Parse images once if present (for both text extraction and later assembly)
|
|
1002
|
-
const parsed = hasImageReferences(messageToSend)
|
|
1003
|
-
? parseImagesFromMessage(messageToSend)
|
|
1004
|
-
: { text: messageToSend, images: [] };
|
|
1005
|
-
// Set USER:PROMPT variable (text only, images stripped)
|
|
1006
|
-
Variable.set("USER:PROMPT", parsed.text);
|
|
1007
|
-
// Execute prePrompt hook if configured
|
|
1008
|
-
const hookPath = getSettingsManager().getPrePromptHook();
|
|
1009
|
-
if (hookPath) {
|
|
1010
|
-
const hookResult = await executeOperationHook(hookPath, "prePrompt", { USER_MESSAGE: parsed.text }, 30000, false, // prePrompt hook is never mandatory
|
|
1011
|
-
this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
1051
|
+
// Execute postUserInput hook
|
|
1052
|
+
const postUserInputHookPath = getSettingsManager().getPostUserInputHook();
|
|
1053
|
+
if (postUserInputHookPath) {
|
|
1054
|
+
const hookResult = await executeOperationHook(postUserInputHookPath, "postUserInput", { USER_MESSAGE: message }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
1012
1055
|
if (hookResult.approved && hookResult.commands) {
|
|
1013
1056
|
const results = applyHookCommands(hookResult.commands);
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1057
|
+
applyEnvVariables(results.env);
|
|
1058
|
+
const seenVars = new Set();
|
|
1059
|
+
for (const { name, value } of results.promptVars) {
|
|
1060
|
+
if (seenVars.has(name)) {
|
|
1061
|
+
Variable.append(name, value);
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
Variable.set(name, value);
|
|
1065
|
+
seenVars.add(name);
|
|
1066
|
+
}
|
|
1017
1067
|
}
|
|
1018
|
-
// Process other hook commands (MODEL, BACKEND, SYSTEM, etc.)
|
|
1019
|
-
await this.processHookCommands(results);
|
|
1020
|
-
// Store prefill text from hook if present
|
|
1021
1068
|
if (results.prefill) {
|
|
1022
|
-
this.
|
|
1069
|
+
this.messageProcessor.setHookPrefillText(results.prefill);
|
|
1070
|
+
}
|
|
1071
|
+
if (results.maxContext !== undefined) {
|
|
1072
|
+
this.maxContextSize = results.maxContext;
|
|
1023
1073
|
}
|
|
1024
1074
|
}
|
|
1025
1075
|
}
|
|
1026
|
-
|
|
1027
|
-
const
|
|
1028
|
-
// Add user/system message to both API conversation and chat history
|
|
1029
|
-
// Note: System messages can only have string content, so images are only supported for user messages
|
|
1030
|
-
const supportsVision = this.llmClient.getSupportsVision();
|
|
1031
|
-
let messageContent = assembledMessage;
|
|
1032
|
-
if (messageType === "user" && parsed.images.length > 0 && supportsVision) {
|
|
1033
|
-
// Construct content array with assembled text and images
|
|
1034
|
-
messageContent = [
|
|
1035
|
-
{ type: "text", text: assembledMessage },
|
|
1036
|
-
...parsed.images
|
|
1037
|
-
];
|
|
1038
|
-
}
|
|
1039
|
-
const userEntry = {
|
|
1040
|
-
type: messageType,
|
|
1041
|
-
content: messageContent,
|
|
1042
|
-
originalContent: messageType === "user" ? (parsed.images.length > 0 && supportsVision
|
|
1043
|
-
? [{ type: "text", text: parsed.text }, ...parsed.images]
|
|
1044
|
-
: parsed.text) : undefined,
|
|
1045
|
-
timestamp: new Date(),
|
|
1046
|
-
};
|
|
1076
|
+
const { parsed, assembledMessage } = await this.messageProcessor.parseAndAssembleMessage(messageToSend);
|
|
1077
|
+
const { userEntry, messageContent } = this.messageProcessor.prepareMessageContent(messageType, assembledMessage, parsed, messageToSend, this.llmClient.getSupportsVision());
|
|
1047
1078
|
this.chatHistory.push(userEntry);
|
|
1048
|
-
// Push to messages array with proper typing based on role
|
|
1049
1079
|
if (messageType === "user") {
|
|
1050
1080
|
this.messages.push({ role: "user", content: messageContent });
|
|
1051
1081
|
}
|
|
1052
1082
|
else {
|
|
1053
|
-
|
|
1054
|
-
this.messages.push({ role: "system", content: typeof messageContent === "string" ? messageContent : assembledMessage });
|
|
1083
|
+
this.messages.push({ role: "system", content: typeof messageContent === "string" ? messageContent : messageToSend });
|
|
1055
1084
|
}
|
|
1056
|
-
await this.emitContextChange();
|
|
1057
|
-
// Yield user message so UI can display it immediately
|
|
1085
|
+
await this.contextManager.emitContextChange();
|
|
1058
1086
|
yield {
|
|
1059
1087
|
type: "user_message",
|
|
1060
1088
|
userEntry: userEntry,
|
|
1061
1089
|
};
|
|
1062
|
-
// If this is a rephrase with prefill text, add the assistant message now
|
|
1063
|
-
if (this.rephraseState?.prefillText) {
|
|
1064
|
-
this.messages.push({
|
|
1065
|
-
role: "assistant",
|
|
1066
|
-
content: this.rephraseState.prefillText
|
|
1067
|
-
});
|
|
1068
|
-
}
|
|
1069
|
-
// If a hook returned prefill text, add the assistant message now
|
|
1070
|
-
if (this.hookPrefillText) {
|
|
1071
|
-
this.messages.push({
|
|
1072
|
-
role: "assistant",
|
|
1073
|
-
content: this.hookPrefillText
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
1090
|
// Calculate input tokens
|
|
1077
1091
|
let inputTokens = this.tokenCounter.countMessageTokens(this.messages);
|
|
1078
1092
|
yield {
|
|
@@ -1103,6 +1117,37 @@ export class LLMAgent extends EventEmitter {
|
|
|
1103
1117
|
this.messages[0].content = this.messages[0].content.replace(/Current conversation token usage: .*/, `Current conversation token usage: ${inputTokens}`);
|
|
1104
1118
|
}
|
|
1105
1119
|
// Stream response and accumulate
|
|
1120
|
+
// Execute preLLMResponse hook just before LLM call
|
|
1121
|
+
const hookPath = getSettingsManager().getPreLLMResponseHook();
|
|
1122
|
+
if (hookPath) {
|
|
1123
|
+
const hookResult = await executeOperationHook(hookPath, "preLLMResponse", { USER_MESSAGE: message }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
1124
|
+
if (hookResult.approved && hookResult.commands) {
|
|
1125
|
+
const results = applyHookCommands(hookResult.commands);
|
|
1126
|
+
for (const { name, value } of results.promptVars) {
|
|
1127
|
+
Variable.set(name, value);
|
|
1128
|
+
}
|
|
1129
|
+
if (results.prefill) {
|
|
1130
|
+
this.messageProcessor.setHookPrefillText(results.prefill);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// If rephrase or hook returned prefill text, add the assistant message now
|
|
1135
|
+
const rephraseText = this.rephraseState?.prefillText;
|
|
1136
|
+
const hookPrefillText = this.messageProcessor.getHookPrefillText();
|
|
1137
|
+
if (rephraseText) {
|
|
1138
|
+
this.messages.push({
|
|
1139
|
+
role: "assistant",
|
|
1140
|
+
content: rephraseText
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
else if (hookPrefillText) {
|
|
1144
|
+
this.messages.push({
|
|
1145
|
+
role: "assistant",
|
|
1146
|
+
content: hookPrefillText
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
// Render system message with current variable state before sending to LLM
|
|
1150
|
+
this.renderSystemMessage();
|
|
1106
1151
|
const stream = this.llmClient.chatStream(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
|
|
1107
1152
|
? { search_parameters: { mode: "auto" } }
|
|
1108
1153
|
: { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
|
|
@@ -1119,15 +1164,6 @@ export class LLMAgent extends EventEmitter {
|
|
|
1119
1164
|
};
|
|
1120
1165
|
accumulatedContent = this.rephraseState.prefillText;
|
|
1121
1166
|
}
|
|
1122
|
-
// If a hook provided prefill, yield it first and add to accumulated content
|
|
1123
|
-
if (this.hookPrefillText) {
|
|
1124
|
-
yield {
|
|
1125
|
-
type: "content",
|
|
1126
|
-
content: this.hookPrefillText,
|
|
1127
|
-
};
|
|
1128
|
-
accumulatedContent = this.hookPrefillText;
|
|
1129
|
-
this.hookPrefillText = null; // Clear after use
|
|
1130
|
-
}
|
|
1131
1167
|
try {
|
|
1132
1168
|
for await (const chunk of stream) {
|
|
1133
1169
|
// Check for cancellation in the streaming loop
|
|
@@ -1146,7 +1182,7 @@ export class LLMAgent extends EventEmitter {
|
|
|
1146
1182
|
streamFinished = true;
|
|
1147
1183
|
}
|
|
1148
1184
|
// Accumulate the message using reducer
|
|
1149
|
-
accumulatedMessage = this.messageReducer(accumulatedMessage, chunk);
|
|
1185
|
+
accumulatedMessage = this.messageProcessor.messageReducer(accumulatedMessage, chunk);
|
|
1150
1186
|
// Check for tool calls - yield when we have complete tool calls with function names
|
|
1151
1187
|
if (!tool_calls_yielded && accumulatedMessage.tool_calls?.length > 0) {
|
|
1152
1188
|
// Check if we have at least one complete tool call with a function name
|
|
@@ -1224,7 +1260,18 @@ export class LLMAgent extends EventEmitter {
|
|
|
1224
1260
|
throw streamError;
|
|
1225
1261
|
}
|
|
1226
1262
|
// Parse XML tool calls from accumulated message if present
|
|
1227
|
-
accumulatedMessage = this.parseXMLToolCalls(accumulatedMessage);
|
|
1263
|
+
accumulatedMessage = this.messageProcessor.parseXMLToolCalls(accumulatedMessage);
|
|
1264
|
+
// Execute postLLMResponse hook
|
|
1265
|
+
const postLLMResponseHookPath = getSettingsManager().getPostLLMResponseHook();
|
|
1266
|
+
if (postLLMResponseHookPath) {
|
|
1267
|
+
const hookResult = await executeOperationHook(postLLMResponseHookPath, "postLLMResponse", {
|
|
1268
|
+
LLM_RESPONSE: getTextContent(accumulatedMessage.content),
|
|
1269
|
+
TOOL_CALLS: JSON.stringify(accumulatedMessage.tool_calls || [])
|
|
1270
|
+
}, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
1271
|
+
if (hookResult.approved && hookResult.commands) {
|
|
1272
|
+
await this.processHookResult(hookResult);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1228
1275
|
// Clean up tool call arguments before adding to conversation history
|
|
1229
1276
|
// This prevents Ollama from rejecting malformed tool calls on subsequent API calls
|
|
1230
1277
|
const cleanedToolCalls = accumulatedMessage.tool_calls?.map(toolCall => {
|
|
@@ -1251,7 +1298,7 @@ export class LLMAgent extends EventEmitter {
|
|
|
1251
1298
|
tool_calls: accumulatedMessage.tool_calls,
|
|
1252
1299
|
};
|
|
1253
1300
|
this.chatHistory.push(assistantEntry);
|
|
1254
|
-
await this.emitContextChange();
|
|
1301
|
+
await this.contextManager.emitContextChange();
|
|
1255
1302
|
// Update rephrase state if this is a final response (no tool calls)
|
|
1256
1303
|
if (this.rephraseState && this.rephraseState.newResponseIndex === -1 && (!accumulatedMessage.tool_calls || accumulatedMessage.tool_calls.length === 0)) {
|
|
1257
1304
|
const newResponseIndex = this.chatHistory.length - 1;
|
|
@@ -1305,7 +1352,7 @@ export class LLMAgent extends EventEmitter {
|
|
|
1305
1352
|
}
|
|
1306
1353
|
// Capture chatHistory length before tool execution to detect new system messages
|
|
1307
1354
|
const chatHistoryLengthBefore = this.chatHistory.length;
|
|
1308
|
-
const result = await this.executeTool(toolCall);
|
|
1355
|
+
const result = await this.toolExecutor.executeTool(toolCall);
|
|
1309
1356
|
// Collect any new system messages added during tool execution (from hooks)
|
|
1310
1357
|
const newSystemMessages = [];
|
|
1311
1358
|
for (let i = chatHistoryLengthBefore; i < this.chatHistory.length; i++) {
|
|
@@ -1467,351 +1514,83 @@ export class LLMAgent extends EventEmitter {
|
|
|
1467
1514
|
* Apply default parameter values for tools
|
|
1468
1515
|
* This ensures the approval hook sees the same parameters that will be used during execution
|
|
1469
1516
|
*/
|
|
1470
|
-
applyToolParameterDefaults(toolName, params) {
|
|
1471
|
-
// Handle null/undefined params (can happen if API sends "null" as arguments string)
|
|
1472
|
-
const result = { ...(params || {}) };
|
|
1473
|
-
switch (toolName) {
|
|
1474
|
-
case "listFiles":
|
|
1475
|
-
// dirname defaults to current directory
|
|
1476
|
-
if (!result.dirname) {
|
|
1477
|
-
result.dirname = ".";
|
|
1478
|
-
}
|
|
1479
|
-
break;
|
|
1480
|
-
// Add other tools with defaults here as needed
|
|
1481
|
-
}
|
|
1482
|
-
return result;
|
|
1483
|
-
}
|
|
1484
1517
|
/**
|
|
1485
1518
|
* Validate tool arguments against the tool's schema
|
|
1486
1519
|
* Returns null if valid, or an error message if invalid
|
|
1487
1520
|
*/
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
const allTools = supportsTools ? await getAllLLMTools() : [];
|
|
1493
|
-
// Find the tool schema
|
|
1494
|
-
const toolSchema = allTools.find(t => t.function.name === toolName);
|
|
1495
|
-
if (!toolSchema) {
|
|
1496
|
-
return `Unknown tool: ${toolName}`;
|
|
1497
|
-
}
|
|
1498
|
-
const schema = toolSchema.function.parameters;
|
|
1499
|
-
const properties = schema.properties || {};
|
|
1500
|
-
const required = schema.required || [];
|
|
1501
|
-
// Check if tool accepts no parameters
|
|
1502
|
-
const acceptsNoParams = Object.keys(properties).length === 0;
|
|
1503
|
-
const hasArgs = args && typeof args === 'object' && Object.keys(args).length > 0;
|
|
1504
|
-
if (acceptsNoParams && hasArgs) {
|
|
1505
|
-
return `Tool ${toolName} accepts no parameters, but received: ${JSON.stringify(args)}`;
|
|
1506
|
-
}
|
|
1507
|
-
// Check for unknown parameters
|
|
1508
|
-
for (const argKey of Object.keys(args || {})) {
|
|
1509
|
-
if (!properties[argKey]) {
|
|
1510
|
-
return `Tool ${toolName} does not accept parameter '${argKey}'. Valid parameters: ${Object.keys(properties).join(', ') || 'none'}`;
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
|
-
// Check for missing required parameters
|
|
1514
|
-
for (const requiredParam of required) {
|
|
1515
|
-
if (!(requiredParam in (args || {}))) {
|
|
1516
|
-
return `Tool ${toolName} missing required parameter '${requiredParam}'`;
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
return null; // Valid
|
|
1520
|
-
}
|
|
1521
|
-
catch (error) {
|
|
1522
|
-
console.error(`Error validating tool arguments for ${toolName}:`, error);
|
|
1523
|
-
return null; // Allow execution if validation itself fails
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
async executeTool(toolCall) {
|
|
1527
|
-
try {
|
|
1528
|
-
// Parse arguments - handle empty string as empty object for parameter-less tools
|
|
1529
|
-
let argsString = toolCall.function.arguments?.trim() || "{}";
|
|
1530
|
-
// Handle duplicate/concatenated JSON objects (LLM bug)
|
|
1531
|
-
// Pattern: {"key":"val"}{"key":"val"}
|
|
1532
|
-
let hadDuplicateJson = false;
|
|
1533
|
-
const extractedArgsString = extractFirstJsonObject(argsString);
|
|
1534
|
-
if (extractedArgsString !== argsString) {
|
|
1535
|
-
hadDuplicateJson = true;
|
|
1536
|
-
argsString = extractedArgsString;
|
|
1537
|
-
}
|
|
1538
|
-
let args = JSON.parse(argsString);
|
|
1539
|
-
// Handle multiple layers of JSON encoding (API bug)
|
|
1540
|
-
// Keep parsing until we get an object, not a string
|
|
1541
|
-
let parseCount = 0;
|
|
1542
|
-
while (typeof args === 'string' && parseCount < MAX_JSON_PARSE_ATTEMPTS) {
|
|
1543
|
-
parseCount++;
|
|
1544
|
-
try {
|
|
1545
|
-
args = JSON.parse(args);
|
|
1546
|
-
}
|
|
1547
|
-
catch (e) {
|
|
1548
|
-
// If parse fails, the string isn't valid JSON - stop trying
|
|
1549
|
-
break;
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
// Log if we had to fix encoding
|
|
1553
|
-
if (parseCount > 0) {
|
|
1554
|
-
const bugMsg = `[BUG] Tool ${toolCall.function.name} had ${parseCount} extra layer(s) of JSON encoding`;
|
|
1555
|
-
console.warn(bugMsg);
|
|
1556
|
-
const systemMsg = `Warning: Tool arguments for ${toolCall.function.name} had ${parseCount} extra encoding layer(s) - this is an API bug`;
|
|
1557
|
-
this.messages.push({
|
|
1558
|
-
role: 'system',
|
|
1559
|
-
content: systemMsg
|
|
1560
|
-
});
|
|
1561
|
-
this.chatHistory.push({
|
|
1562
|
-
type: 'system',
|
|
1563
|
-
content: systemMsg,
|
|
1564
|
-
timestamp: new Date()
|
|
1565
|
-
});
|
|
1566
|
-
}
|
|
1567
|
-
// Log if we had to fix duplicate JSON
|
|
1568
|
-
if (hadDuplicateJson) {
|
|
1569
|
-
const bugMsg = `[BUG] Tool ${toolCall.function.name} had duplicate/concatenated JSON objects`;
|
|
1570
|
-
console.warn(bugMsg);
|
|
1571
|
-
const systemMsg = `Warning: Tool arguments for ${toolCall.function.name} had duplicate JSON objects (used first object only) - this is an LLM bug`;
|
|
1572
|
-
this.messages.push({
|
|
1573
|
-
role: 'system',
|
|
1574
|
-
content: systemMsg
|
|
1575
|
-
});
|
|
1576
|
-
this.chatHistory.push({
|
|
1577
|
-
type: 'system',
|
|
1578
|
-
content: systemMsg,
|
|
1579
|
-
timestamp: new Date()
|
|
1580
|
-
});
|
|
1581
|
-
}
|
|
1582
|
-
// Ensure args is always an object (API might send null)
|
|
1583
|
-
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
1584
|
-
args = {};
|
|
1585
|
-
}
|
|
1586
|
-
// Apply parameter defaults before validation and execution
|
|
1587
|
-
args = this.applyToolParameterDefaults(toolCall.function.name, args);
|
|
1588
|
-
// Validate tool arguments against schema
|
|
1589
|
-
const validationError = await this.validateToolArguments(toolCall.function.name, args);
|
|
1590
|
-
if (validationError) {
|
|
1591
|
-
// Validation failed - return error
|
|
1592
|
-
const errorMsg = `Tool call validation failed: ${validationError}. Please try again with correct parameters.`;
|
|
1593
|
-
console.warn(`[VALIDATION ERROR] ${errorMsg}`);
|
|
1594
|
-
return {
|
|
1595
|
-
success: false,
|
|
1596
|
-
error: validationError
|
|
1597
|
-
};
|
|
1598
|
-
}
|
|
1599
|
-
// Task tools (startActiveTask, transitionActiveTaskStatus, stopActiveTask) have their own
|
|
1600
|
-
// dedicated task approval hook, so skip the general tool approval hook for them
|
|
1601
|
-
const isTaskTool = ['startActiveTask', 'transitionActiveTaskStatus', 'stopActiveTask'].includes(toolCall.function.name);
|
|
1602
|
-
// Check tool approval hook if configured (skip for task tools)
|
|
1603
|
-
const settings = getSettingsManager();
|
|
1604
|
-
const toolApprovalHook = settings.getToolApprovalHook();
|
|
1605
|
-
if (toolApprovalHook && !isTaskTool) {
|
|
1606
|
-
const approvalResult = await executeToolApprovalHook(toolApprovalHook, toolCall.function.name, args, 30000, // 30 second timeout
|
|
1607
|
-
this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
1608
|
-
if (!approvalResult.approved) {
|
|
1609
|
-
const reason = approvalResult.reason || "Tool execution denied by approval hook";
|
|
1610
|
-
// Process rejection commands (MODEL, SYSTEM, BACKEND, etc.)
|
|
1611
|
-
await this.processHookResult(approvalResult);
|
|
1612
|
-
return {
|
|
1613
|
-
success: false,
|
|
1614
|
-
error: `Tool execution blocked: ${reason}`,
|
|
1615
|
-
};
|
|
1616
|
-
}
|
|
1617
|
-
if (approvalResult.timedOut) {
|
|
1618
|
-
// Log timeout for debugging (don't block)
|
|
1619
|
-
console.warn(`Tool approval hook timed out for ${toolCall.function.name} (auto-approved)`);
|
|
1620
|
-
}
|
|
1621
|
-
// Process hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, BACKEND, etc.)
|
|
1622
|
-
// TOOL_RESULT is for tool return values, not used by approval hook
|
|
1623
|
-
// ENV variables can affect tool behavior if tools read from process.env
|
|
1624
|
-
await this.processHookResult(approvalResult);
|
|
1625
|
-
}
|
|
1626
|
-
switch (toolCall.function.name) {
|
|
1627
|
-
case "viewFile":
|
|
1628
|
-
{
|
|
1629
|
-
let range;
|
|
1630
|
-
range = args.start_line && args.end_line
|
|
1631
|
-
? [args.start_line, args.end_line]
|
|
1632
|
-
: undefined;
|
|
1633
|
-
return await this.textEditor.viewFile(args.filename, range);
|
|
1634
|
-
}
|
|
1635
|
-
case "createNewFile":
|
|
1636
|
-
return await this.textEditor.createNewFile(args.filename, args.content);
|
|
1637
|
-
case "strReplace":
|
|
1638
|
-
return await this.textEditor.strReplace(args.filename, args.old_str, args.new_str, args.replace_all);
|
|
1639
|
-
case "editFile":
|
|
1640
|
-
if (!this.morphEditor) {
|
|
1641
|
-
return {
|
|
1642
|
-
success: false,
|
|
1643
|
-
error: "Morph Fast Apply not available. Please set MORPH_API_KEY environment variable to use this feature.",
|
|
1644
|
-
};
|
|
1645
|
-
}
|
|
1646
|
-
return await this.morphEditor.editFile(args.filename, args.instructions, args.code_edit);
|
|
1647
|
-
case "execute":
|
|
1648
|
-
return await this.zsh.execute(args.command);
|
|
1649
|
-
case "listFiles":
|
|
1650
|
-
return await this.zsh.listFiles(args.dirname);
|
|
1651
|
-
case "universalSearch":
|
|
1652
|
-
return await this.search.universalSearch(args.query, {
|
|
1653
|
-
searchType: args.search_type,
|
|
1654
|
-
includePattern: args.include_pattern,
|
|
1655
|
-
excludePattern: args.exclude_pattern,
|
|
1656
|
-
caseSensitive: args.case_sensitive,
|
|
1657
|
-
wholeWord: args.whole_word,
|
|
1658
|
-
regex: args.regex,
|
|
1659
|
-
maxResults: args.max_results,
|
|
1660
|
-
fileTypes: args.file_types,
|
|
1661
|
-
includeHidden: args.include_hidden,
|
|
1662
|
-
});
|
|
1663
|
-
case "getEnv":
|
|
1664
|
-
return await this.env.getEnv(args.variable);
|
|
1665
|
-
case "getAllEnv":
|
|
1666
|
-
return await this.env.getAllEnv();
|
|
1667
|
-
case "searchEnv":
|
|
1668
|
-
return await this.env.searchEnv(args.pattern);
|
|
1669
|
-
case "introspect":
|
|
1670
|
-
return await this.introspect.introspect(args.target);
|
|
1671
|
-
case "clearCache":
|
|
1672
|
-
return await this.clearCacheTool.clearCache(args.confirmationCode);
|
|
1673
|
-
case "restart":
|
|
1674
|
-
return await this.restartTool.restart();
|
|
1675
|
-
case "setPersona":
|
|
1676
|
-
return await this.characterTool.setPersona(args.persona, args.color);
|
|
1677
|
-
case "setMood":
|
|
1678
|
-
return await this.characterTool.setMood(args.mood, args.color);
|
|
1679
|
-
case "getPersona":
|
|
1680
|
-
return await this.characterTool.getPersona();
|
|
1681
|
-
case "getMood":
|
|
1682
|
-
return await this.characterTool.getMood();
|
|
1683
|
-
case "getAvailablePersonas":
|
|
1684
|
-
return await this.characterTool.getAvailablePersonas();
|
|
1685
|
-
case "startActiveTask":
|
|
1686
|
-
return await this.taskTool.startActiveTask(args.activeTask, args.action, args.color);
|
|
1687
|
-
case "transitionActiveTaskStatus":
|
|
1688
|
-
return await this.taskTool.transitionActiveTaskStatus(args.action, args.color);
|
|
1689
|
-
case "stopActiveTask":
|
|
1690
|
-
return await this.taskTool.stopActiveTask(args.reason, args.documentationFile, args.color);
|
|
1691
|
-
case "insertLines":
|
|
1692
|
-
return await this.textEditor.insertLines(args.filename, args.insert_line, args.new_str);
|
|
1693
|
-
case "replaceLines":
|
|
1694
|
-
return await this.textEditor.replaceLines(args.filename, args.start_line, args.end_line, args.new_str);
|
|
1695
|
-
case "undoEdit":
|
|
1696
|
-
return await this.textEditor.undoEdit();
|
|
1697
|
-
case "chdir":
|
|
1698
|
-
return this.zsh.chdir(args.dirname);
|
|
1699
|
-
case "pwdir":
|
|
1700
|
-
return this.zsh.pwdir();
|
|
1701
|
-
case "downloadFile":
|
|
1702
|
-
return await this.internetTool.downloadFile(args.url);
|
|
1703
|
-
case "generateImage":
|
|
1704
|
-
return await this.imageTool.generateImage(args.prompt, args.negativePrompt, args.width, args.height, args.model, args.sampler, args.configScale, args.numSteps, args.nsfw, args.name, args.move, args.seed);
|
|
1705
|
-
case "captionImage":
|
|
1706
|
-
return await this.imageTool.captionImage(args.filename, args.backend);
|
|
1707
|
-
case "pngInfo":
|
|
1708
|
-
return await this.imageTool.pngInfo(args.filename);
|
|
1709
|
-
case "listImageModels":
|
|
1710
|
-
return await this.imageTool.listImageModels();
|
|
1711
|
-
case "listImageLoras":
|
|
1712
|
-
return await this.imageTool.listImageLoras();
|
|
1713
|
-
case "extractTextFromImage":
|
|
1714
|
-
return await this.imageTool.extractTextFromImage(args.filename);
|
|
1715
|
-
case "extractTextFromAudio":
|
|
1716
|
-
return await this.audioTool.extractTextFromAudio(args.filename);
|
|
1717
|
-
case "readXlsx":
|
|
1718
|
-
return await this.fileConversionTool.readXlsx(args.filename, args.sheetName, args.outputFormat, args.output);
|
|
1719
|
-
case "listXlsxSheets":
|
|
1720
|
-
return await this.fileConversionTool.listXlsxSheets(args.filename);
|
|
1721
|
-
default:
|
|
1722
|
-
// Check if this is an MCP tool
|
|
1723
|
-
if (toolCall.function.name.startsWith("mcp__")) {
|
|
1724
|
-
return await this.executeMCPTool(toolCall.function.name, args);
|
|
1725
|
-
}
|
|
1726
|
-
return {
|
|
1727
|
-
success: false,
|
|
1728
|
-
error: `Unknown tool: ${toolCall.function.name}`,
|
|
1729
|
-
};
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
catch (error) {
|
|
1733
|
-
return {
|
|
1734
|
-
success: false,
|
|
1735
|
-
error: `Tool execution error: ${error.message}`,
|
|
1736
|
-
};
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
async executeMCPTool(toolName, args) {
|
|
1740
|
-
try {
|
|
1741
|
-
const mcpManager = getMCPManager();
|
|
1742
|
-
const result = await mcpManager.callTool(toolName, args);
|
|
1743
|
-
if (result.isError) {
|
|
1744
|
-
return {
|
|
1745
|
-
success: false,
|
|
1746
|
-
error: result.content[0]?.text || "MCP tool error",
|
|
1747
|
-
};
|
|
1748
|
-
}
|
|
1749
|
-
// Extract content from result
|
|
1750
|
-
const output = result.content
|
|
1751
|
-
.map((item) => {
|
|
1752
|
-
if (item.type === "text") {
|
|
1753
|
-
return item.text;
|
|
1754
|
-
}
|
|
1755
|
-
else if (item.type === "resource") {
|
|
1756
|
-
return `Resource: ${item.resource?.uri || "Unknown"}`;
|
|
1757
|
-
}
|
|
1758
|
-
return String(item);
|
|
1759
|
-
})
|
|
1760
|
-
.join("\n");
|
|
1761
|
-
// After successful MCP tool execution, invalidate cache for that server
|
|
1762
|
-
// Next call to getAllLLMTools() will lazy-refresh this server
|
|
1763
|
-
const serverNameMatch = toolName.match(/^mcp__(.+?)__/);
|
|
1764
|
-
if (serverNameMatch) {
|
|
1765
|
-
const serverName = serverNameMatch[1];
|
|
1766
|
-
mcpManager.invalidateCache(serverName);
|
|
1767
|
-
}
|
|
1768
|
-
return {
|
|
1769
|
-
success: true,
|
|
1770
|
-
output: output || "Success",
|
|
1771
|
-
};
|
|
1772
|
-
}
|
|
1773
|
-
catch (error) {
|
|
1774
|
-
return {
|
|
1775
|
-
success: false,
|
|
1776
|
-
error: `MCP tool execution error: ${error.message}`,
|
|
1777
|
-
};
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Get a copy of the current chat history.
|
|
1523
|
+
* @returns Array of chat entries (defensive copy)
|
|
1524
|
+
*/
|
|
1780
1525
|
getChatHistory() {
|
|
1781
1526
|
return [...this.chatHistory];
|
|
1782
1527
|
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Set the chat history to a new array of entries.
|
|
1530
|
+
* @param history - New chat history entries
|
|
1531
|
+
*/
|
|
1783
1532
|
setChatHistory(history) {
|
|
1784
1533
|
this.chatHistory = [...history];
|
|
1785
1534
|
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Get the current system prompt.
|
|
1537
|
+
* @returns The system prompt string
|
|
1538
|
+
*/
|
|
1786
1539
|
getSystemPrompt() {
|
|
1540
|
+
// Render system message with current variable state before returning
|
|
1541
|
+
this.renderSystemMessage();
|
|
1787
1542
|
return this.systemPrompt;
|
|
1788
1543
|
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Set a new system prompt and update the first message.
|
|
1546
|
+
* @param prompt - Ignored (deprecated) - system prompt is always rendered from variables
|
|
1547
|
+
*/
|
|
1789
1548
|
setSystemPrompt(prompt) {
|
|
1790
|
-
|
|
1791
|
-
this.
|
|
1792
|
-
role: "system",
|
|
1793
|
-
content: prompt,
|
|
1794
|
-
};
|
|
1549
|
+
// System prompt is always dynamic - render from current variable state
|
|
1550
|
+
this.renderSystemMessage();
|
|
1795
1551
|
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Get a copy of the current API messages array.
|
|
1554
|
+
* @returns Array of LLM messages (defensive copy)
|
|
1555
|
+
*/
|
|
1796
1556
|
getMessages() {
|
|
1797
1557
|
return [...this.messages];
|
|
1798
1558
|
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Get the current token count for the conversation.
|
|
1561
|
+
* @returns Number of tokens in the current message context
|
|
1562
|
+
*/
|
|
1799
1563
|
getCurrentTokenCount() {
|
|
1800
1564
|
return this.tokenCounter.countMessageTokens(this.messages);
|
|
1801
1565
|
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Get the maximum context size for the current model.
|
|
1568
|
+
* @returns Maximum number of tokens supported
|
|
1569
|
+
* @todo Make this model-specific for different context windows
|
|
1570
|
+
*/
|
|
1802
1571
|
getMaxContextSize() {
|
|
1803
|
-
|
|
1804
|
-
// For now, return the standard Grok context window size
|
|
1805
|
-
return 128000;
|
|
1572
|
+
return this.maxContextSize;
|
|
1806
1573
|
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Get the current context usage as a percentage.
|
|
1576
|
+
* @returns Percentage of context window used (0-100)
|
|
1577
|
+
*/
|
|
1807
1578
|
getContextUsagePercent() {
|
|
1808
|
-
|
|
1809
|
-
const max = this.getMaxContextSize();
|
|
1810
|
-
return (current / max) * 100;
|
|
1579
|
+
return this.contextManager.getContextUsagePercent();
|
|
1811
1580
|
}
|
|
1812
1581
|
/**
|
|
1813
|
-
* Convert context
|
|
1582
|
+
* Convert the conversation context to markdown format for viewing.
|
|
1583
|
+
*
|
|
1584
|
+
* Creates a human-readable markdown representation of the conversation
|
|
1585
|
+
* including:
|
|
1586
|
+
* - Header with context file path and token usage
|
|
1587
|
+
* - Numbered messages with timestamps
|
|
1588
|
+
* - Formatted tool calls and results
|
|
1589
|
+
* - Proper attribution (User/Assistant/System)
|
|
1590
|
+
*
|
|
1814
1591
|
* Format: (N) Name (role) - timestamp
|
|
1592
|
+
*
|
|
1593
|
+
* @returns Promise resolving to markdown-formatted conversation
|
|
1815
1594
|
*/
|
|
1816
1595
|
async convertContextToMarkdown() {
|
|
1817
1596
|
const lines = [];
|
|
@@ -1866,389 +1645,234 @@ export class LLMAgent extends EventEmitter {
|
|
|
1866
1645
|
});
|
|
1867
1646
|
return lines.join("\n");
|
|
1868
1647
|
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Get the current persona setting.
|
|
1650
|
+
* @returns Current persona string
|
|
1651
|
+
*/
|
|
1869
1652
|
getPersona() {
|
|
1870
1653
|
return this.persona;
|
|
1871
1654
|
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Get the current persona display color.
|
|
1657
|
+
* @returns Color name for persona display
|
|
1658
|
+
*/
|
|
1872
1659
|
getPersonaColor() {
|
|
1873
1660
|
return this.personaColor;
|
|
1874
1661
|
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Get the current mood setting.
|
|
1664
|
+
* @returns Current mood string
|
|
1665
|
+
*/
|
|
1875
1666
|
getMood() {
|
|
1876
1667
|
return this.mood;
|
|
1877
1668
|
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Get the current mood display color.
|
|
1671
|
+
* @returns Color name for mood display
|
|
1672
|
+
*/
|
|
1878
1673
|
getMoodColor() {
|
|
1879
1674
|
return this.moodColor;
|
|
1880
1675
|
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Get the current active task.
|
|
1678
|
+
* @returns Current active task string
|
|
1679
|
+
*/
|
|
1881
1680
|
getActiveTask() {
|
|
1882
1681
|
return this.activeTask;
|
|
1883
1682
|
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Get the current active task action.
|
|
1685
|
+
* @returns Current task action string
|
|
1686
|
+
*/
|
|
1884
1687
|
getActiveTaskAction() {
|
|
1885
1688
|
return this.activeTaskAction;
|
|
1886
1689
|
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Get the current active task display color.
|
|
1692
|
+
* @returns Color name for task display
|
|
1693
|
+
*/
|
|
1887
1694
|
getActiveTaskColor() {
|
|
1888
1695
|
return this.activeTaskColor;
|
|
1889
1696
|
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Set a pending context edit session for file-based context editing.
|
|
1699
|
+
* @param tmpJsonPath - Path to temporary JSON file
|
|
1700
|
+
* @param contextFilePath - Path to actual context file
|
|
1701
|
+
*/
|
|
1890
1702
|
setPendingContextEditSession(tmpJsonPath, contextFilePath) {
|
|
1891
1703
|
this.pendingContextEditSession = { tmpJsonPath, contextFilePath };
|
|
1892
1704
|
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Get the current pending context edit session.
|
|
1707
|
+
* @returns Edit session info or null if none pending
|
|
1708
|
+
*/
|
|
1893
1709
|
getPendingContextEditSession() {
|
|
1894
1710
|
return this.pendingContextEditSession;
|
|
1895
1711
|
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Clear the pending context edit session.
|
|
1714
|
+
*/
|
|
1896
1715
|
clearPendingContextEditSession() {
|
|
1897
1716
|
this.pendingContextEditSession = null;
|
|
1898
1717
|
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Set the rephrase state for message editing operations.
|
|
1720
|
+
* @param originalAssistantMessageIndex - Index of original assistant message
|
|
1721
|
+
* @param rephraseRequestIndex - Index of rephrase request
|
|
1722
|
+
* @param newResponseIndex - Index of new response (-1 if not yet created)
|
|
1723
|
+
* @param messageType - Type of message being rephrased
|
|
1724
|
+
* @param prefillText - Optional prefill text for the response
|
|
1725
|
+
*/
|
|
1899
1726
|
setRephraseState(originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText) {
|
|
1900
1727
|
this.rephraseState = { originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText };
|
|
1901
1728
|
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Get the current rephrase state.
|
|
1731
|
+
* @returns Rephrase state info or null if none active
|
|
1732
|
+
*/
|
|
1902
1733
|
getRephraseState() {
|
|
1903
1734
|
return this.rephraseState;
|
|
1904
1735
|
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Clear the current rephrase state.
|
|
1738
|
+
*/
|
|
1905
1739
|
clearRephraseState() {
|
|
1906
1740
|
this.rephraseState = null;
|
|
1907
1741
|
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Set the agent's persona with optional color.
|
|
1744
|
+
*
|
|
1745
|
+
* Executes the persona hook if configured and updates the agent's
|
|
1746
|
+
* persona state on success.
|
|
1747
|
+
*
|
|
1748
|
+
* @param persona - The persona description
|
|
1749
|
+
* @param color - Optional display color (defaults to "white")
|
|
1750
|
+
* @returns Promise resolving to success/error result
|
|
1751
|
+
*/
|
|
1908
1752
|
async setPersona(persona, color) {
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
if (!hookPath && hookMandatory) {
|
|
1914
|
-
const reason = "Persona hook is mandatory but not configured";
|
|
1915
|
-
return {
|
|
1916
|
-
success: false,
|
|
1917
|
-
error: reason
|
|
1918
|
-
};
|
|
1753
|
+
const result = await this.hookManager.setPersona(persona, color);
|
|
1754
|
+
if (result.success) {
|
|
1755
|
+
this.persona = persona;
|
|
1756
|
+
this.personaColor = color || "white";
|
|
1919
1757
|
}
|
|
1920
|
-
|
|
1921
|
-
const hookResult = await executeOperationHook(hookPath, "setPersona", {
|
|
1922
|
-
persona_old: this.persona || "",
|
|
1923
|
-
persona_new: persona,
|
|
1924
|
-
persona_color: color || "white"
|
|
1925
|
-
}, 30000, hookMandatory, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
1926
|
-
if (!hookResult.approved) {
|
|
1927
|
-
const reason = hookResult.reason || "Hook rejected persona change";
|
|
1928
|
-
// Process rejection commands (MODEL, SYSTEM)
|
|
1929
|
-
// Even in rejection, we process commands (might have MODEL change)
|
|
1930
|
-
await this.processHookResult(hookResult);
|
|
1931
|
-
// Note: We ignore the return value here since we're already rejecting the persona
|
|
1932
|
-
return {
|
|
1933
|
-
success: false,
|
|
1934
|
-
error: reason
|
|
1935
|
-
};
|
|
1936
|
-
}
|
|
1937
|
-
if (hookResult.timedOut) {
|
|
1938
|
-
// Hook timed out but was auto-approved
|
|
1939
|
-
}
|
|
1940
|
-
// Process hook commands (ENV, MODEL, SYSTEM)
|
|
1941
|
-
const result = await this.processHookResult(hookResult, 'ZDS_AI_AGENT_PERSONA');
|
|
1942
|
-
if (!result.success) {
|
|
1943
|
-
// Model/backend test failed - don't apply persona change
|
|
1944
|
-
return {
|
|
1945
|
-
success: false,
|
|
1946
|
-
error: "Persona change rejected due to failed model/backend test"
|
|
1947
|
-
};
|
|
1948
|
-
}
|
|
1949
|
-
// Apply persona transformation if present
|
|
1950
|
-
if (result.transformedValue) {
|
|
1951
|
-
persona = result.transformedValue;
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
const oldPersona = this.persona;
|
|
1955
|
-
const oldColor = this.personaColor;
|
|
1956
|
-
this.persona = persona;
|
|
1957
|
-
this.personaColor = color || "white";
|
|
1958
|
-
process.env.ZDS_AI_AGENT_PERSONA = persona;
|
|
1959
|
-
// Persona hook generates success message - no need for redundant CLI message
|
|
1960
|
-
this.emit('personaChange', {
|
|
1961
|
-
persona: this.persona,
|
|
1962
|
-
color: this.personaColor
|
|
1963
|
-
});
|
|
1964
|
-
return { success: true };
|
|
1758
|
+
return result;
|
|
1965
1759
|
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Set the agent's mood with optional color.
|
|
1762
|
+
*
|
|
1763
|
+
* Executes the mood hook if configured and updates the agent's
|
|
1764
|
+
* mood state on success.
|
|
1765
|
+
*
|
|
1766
|
+
* @param mood - The mood description
|
|
1767
|
+
* @param color - Optional display color (defaults to "white")
|
|
1768
|
+
* @returns Promise resolving to success/error result
|
|
1769
|
+
*/
|
|
1966
1770
|
async setMood(mood, color) {
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
if (!hookPath && hookMandatory) {
|
|
1972
|
-
const reason = "Mood hook is mandatory but not configured";
|
|
1973
|
-
return {
|
|
1974
|
-
success: false,
|
|
1975
|
-
error: reason
|
|
1976
|
-
};
|
|
1977
|
-
}
|
|
1978
|
-
if (hookPath) {
|
|
1979
|
-
const hookResult = await executeOperationHook(hookPath, "setMood", {
|
|
1980
|
-
mood_old: this.mood || "",
|
|
1981
|
-
mood_new: mood,
|
|
1982
|
-
mood_color: color || "white"
|
|
1983
|
-
}, 30000, hookMandatory, this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
1984
|
-
if (!hookResult.approved) {
|
|
1985
|
-
const reason = hookResult.reason || "Hook rejected mood change";
|
|
1986
|
-
// Process rejection commands (MODEL, SYSTEM)
|
|
1987
|
-
await this.processHookResult(hookResult);
|
|
1988
|
-
return {
|
|
1989
|
-
success: false,
|
|
1990
|
-
error: reason
|
|
1991
|
-
};
|
|
1992
|
-
}
|
|
1993
|
-
if (hookResult.timedOut) {
|
|
1994
|
-
// Hook timed out but was auto-approved
|
|
1995
|
-
}
|
|
1996
|
-
// Process hook commands (ENV, MODEL, SYSTEM)
|
|
1997
|
-
const result = await this.processHookResult(hookResult, 'ZDS_AI_AGENT_MOOD');
|
|
1998
|
-
if (!result.success) {
|
|
1999
|
-
// Model/backend test failed - don't apply mood change
|
|
2000
|
-
return {
|
|
2001
|
-
success: false,
|
|
2002
|
-
error: "Mood change rejected due to failed model/backend test"
|
|
2003
|
-
};
|
|
2004
|
-
}
|
|
2005
|
-
// Apply mood transformation if present
|
|
2006
|
-
if (result.transformedValue) {
|
|
2007
|
-
mood = result.transformedValue;
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
const oldMood = this.mood;
|
|
2011
|
-
const oldColor = this.moodColor;
|
|
2012
|
-
this.mood = mood;
|
|
2013
|
-
this.moodColor = color || "white";
|
|
2014
|
-
process.env.ZDS_AI_AGENT_MOOD = mood;
|
|
2015
|
-
// Add system message for recordkeeping
|
|
2016
|
-
let systemContent;
|
|
2017
|
-
if (oldMood) {
|
|
2018
|
-
const oldColorStr = oldColor && oldColor !== "white" ? ` (${oldColor})` : "";
|
|
2019
|
-
const newColorStr = this.moodColor && this.moodColor !== "white" ? ` (${this.moodColor})` : "";
|
|
2020
|
-
systemContent = `Assistant changed the mood from "${oldMood}"${oldColorStr} to "${this.mood}"${newColorStr}`;
|
|
1771
|
+
const result = await this.hookManager.setMood(mood, color);
|
|
1772
|
+
if (result.success) {
|
|
1773
|
+
this.mood = mood;
|
|
1774
|
+
this.moodColor = color || "white";
|
|
2021
1775
|
}
|
|
2022
|
-
|
|
2023
|
-
const colorStr = this.moodColor && this.moodColor !== "white" ? ` (${this.moodColor})` : "";
|
|
2024
|
-
systemContent = `Assistant set the mood to "${this.mood}"${colorStr}`;
|
|
2025
|
-
}
|
|
2026
|
-
// Note: Don't add to this.messages during tool execution - only chatHistory
|
|
2027
|
-
// System messages added during tool execution create invalid message sequences
|
|
2028
|
-
// because they get inserted between tool_calls and tool_results
|
|
2029
|
-
this.chatHistory.push({
|
|
2030
|
-
type: 'system',
|
|
2031
|
-
content: systemContent,
|
|
2032
|
-
timestamp: new Date()
|
|
2033
|
-
});
|
|
2034
|
-
this.emit('moodChange', {
|
|
2035
|
-
mood: this.mood,
|
|
2036
|
-
color: this.moodColor
|
|
2037
|
-
});
|
|
2038
|
-
return { success: true };
|
|
1776
|
+
return result;
|
|
2039
1777
|
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Start an active task with specified action and color.
|
|
1780
|
+
*
|
|
1781
|
+
* Executes the task start hook if configured and updates the agent's
|
|
1782
|
+
* task state on success.
|
|
1783
|
+
*
|
|
1784
|
+
* @param activeTask - The task description
|
|
1785
|
+
* @param action - The current action within the task
|
|
1786
|
+
* @param color - Optional display color (defaults to "white")
|
|
1787
|
+
* @returns Promise resolving to success/error result
|
|
1788
|
+
*/
|
|
2040
1789
|
async startActiveTask(activeTask, action, color) {
|
|
2041
|
-
|
|
2042
|
-
if (
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
};
|
|
2047
|
-
}
|
|
2048
|
-
// Execute hook if configured
|
|
2049
|
-
const settings = getSettingsManager();
|
|
2050
|
-
const hookPath = settings.getTaskApprovalHook();
|
|
2051
|
-
if (hookPath) {
|
|
2052
|
-
const hookResult = await executeOperationHook(hookPath, "startActiveTask", {
|
|
2053
|
-
activetask: activeTask,
|
|
2054
|
-
action: action,
|
|
2055
|
-
color: color || "white"
|
|
2056
|
-
}, 30000, false, // Task hook is not mandatory
|
|
2057
|
-
this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
2058
|
-
// Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
|
|
2059
|
-
await this.processHookResult(hookResult);
|
|
2060
|
-
if (!hookResult.approved) {
|
|
2061
|
-
const reason = hookResult.reason || "Hook rejected task start";
|
|
2062
|
-
return {
|
|
2063
|
-
success: false,
|
|
2064
|
-
error: reason
|
|
2065
|
-
};
|
|
2066
|
-
}
|
|
2067
|
-
if (hookResult.timedOut) {
|
|
2068
|
-
// Hook timed out but was auto-approved
|
|
2069
|
-
}
|
|
1790
|
+
const result = await this.hookManager.startActiveTask(activeTask, action, color);
|
|
1791
|
+
if (result.success) {
|
|
1792
|
+
this.activeTask = activeTask;
|
|
1793
|
+
this.activeTaskAction = action;
|
|
1794
|
+
this.activeTaskColor = color || "white";
|
|
2070
1795
|
}
|
|
2071
|
-
|
|
2072
|
-
this.activeTask = activeTask;
|
|
2073
|
-
this.activeTaskAction = action;
|
|
2074
|
-
this.activeTaskColor = color || "white";
|
|
2075
|
-
// Add system message
|
|
2076
|
-
const colorStr = this.activeTaskColor && this.activeTaskColor !== "white" ? ` (${this.activeTaskColor})` : "";
|
|
2077
|
-
this.messages.push({
|
|
2078
|
-
role: 'system',
|
|
2079
|
-
content: `Assistant changed task status for "${this.activeTask}" to ${this.activeTaskAction}${colorStr}`
|
|
2080
|
-
});
|
|
2081
|
-
// Emit event
|
|
2082
|
-
this.emit('activeTaskChange', {
|
|
2083
|
-
activeTask: this.activeTask,
|
|
2084
|
-
action: this.activeTaskAction,
|
|
2085
|
-
color: this.activeTaskColor
|
|
2086
|
-
});
|
|
2087
|
-
return { success: true };
|
|
1796
|
+
return result;
|
|
2088
1797
|
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Transition the active task to a new action/status.
|
|
1800
|
+
*
|
|
1801
|
+
* Updates the current task action without changing the task itself.
|
|
1802
|
+
*
|
|
1803
|
+
* @param action - The new action description
|
|
1804
|
+
* @param color - Optional display color (defaults to current color)
|
|
1805
|
+
* @returns Promise resolving to success/error result
|
|
1806
|
+
*/
|
|
2089
1807
|
async transitionActiveTaskStatus(action, color) {
|
|
2090
|
-
|
|
2091
|
-
if (
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
error: "Cannot transition task status. No active task is currently running."
|
|
2095
|
-
};
|
|
1808
|
+
const result = await this.hookManager.transitionActiveTaskStatus(action, color);
|
|
1809
|
+
if (result.success) {
|
|
1810
|
+
this.activeTaskAction = action;
|
|
1811
|
+
this.activeTaskColor = color || this.activeTaskColor;
|
|
2096
1812
|
}
|
|
2097
|
-
|
|
2098
|
-
const settings = getSettingsManager();
|
|
2099
|
-
const hookPath = settings.getTaskApprovalHook();
|
|
2100
|
-
if (hookPath) {
|
|
2101
|
-
const hookResult = await executeOperationHook(hookPath, "transitionActiveTaskStatus", {
|
|
2102
|
-
action: action,
|
|
2103
|
-
color: color || "white"
|
|
2104
|
-
}, 30000, false, // Task hook is not mandatory
|
|
2105
|
-
this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
2106
|
-
// Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
|
|
2107
|
-
await this.processHookResult(hookResult);
|
|
2108
|
-
if (!hookResult.approved) {
|
|
2109
|
-
const reason = hookResult.reason || "Hook rejected task status transition";
|
|
2110
|
-
return {
|
|
2111
|
-
success: false,
|
|
2112
|
-
error: reason
|
|
2113
|
-
};
|
|
2114
|
-
}
|
|
2115
|
-
if (hookResult.timedOut) {
|
|
2116
|
-
// Hook timed out but was auto-approved
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
// Store old action for system message
|
|
2120
|
-
const oldAction = this.activeTaskAction;
|
|
2121
|
-
// Update the action and color
|
|
2122
|
-
this.activeTaskAction = action;
|
|
2123
|
-
this.activeTaskColor = color || this.activeTaskColor;
|
|
2124
|
-
// Add system message
|
|
2125
|
-
const colorStr = this.activeTaskColor && this.activeTaskColor !== "white" ? ` (${this.activeTaskColor})` : "";
|
|
2126
|
-
this.messages.push({
|
|
2127
|
-
role: 'system',
|
|
2128
|
-
content: `Assistant changed task status for "${this.activeTask}" from ${oldAction} to ${this.activeTaskAction}${colorStr}`
|
|
2129
|
-
});
|
|
2130
|
-
// Emit event
|
|
2131
|
-
this.emit('activeTaskChange', {
|
|
2132
|
-
activeTask: this.activeTask,
|
|
2133
|
-
action: this.activeTaskAction,
|
|
2134
|
-
color: this.activeTaskColor
|
|
2135
|
-
});
|
|
2136
|
-
return { success: true };
|
|
1813
|
+
return result;
|
|
2137
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Stop the current active task with reason and documentation.
|
|
1817
|
+
*
|
|
1818
|
+
* Executes the task stop hook if configured and clears the agent's
|
|
1819
|
+
* task state on success.
|
|
1820
|
+
*
|
|
1821
|
+
* @param reason - Reason for stopping the task
|
|
1822
|
+
* @param documentationFile - Path to documentation file
|
|
1823
|
+
* @param color - Optional display color (defaults to "white")
|
|
1824
|
+
* @returns Promise resolving to success/error result
|
|
1825
|
+
*/
|
|
2138
1826
|
async stopActiveTask(reason, documentationFile, color) {
|
|
2139
|
-
|
|
2140
|
-
if (
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
};
|
|
2145
|
-
}
|
|
2146
|
-
// Record the start time for 3-second minimum
|
|
2147
|
-
const startTime = Date.now();
|
|
2148
|
-
// Execute hook if configured
|
|
2149
|
-
const settings = getSettingsManager();
|
|
2150
|
-
const hookPath = settings.getTaskApprovalHook();
|
|
2151
|
-
if (hookPath) {
|
|
2152
|
-
const hookResult = await executeOperationHook(hookPath, "stopActiveTask", {
|
|
2153
|
-
reason: reason,
|
|
2154
|
-
documentation_file: documentationFile,
|
|
2155
|
-
color: color || "white"
|
|
2156
|
-
}, 30000, false, // Task hook is not mandatory
|
|
2157
|
-
this.getCurrentTokenCount(), this.getMaxContextSize());
|
|
2158
|
-
// Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
|
|
2159
|
-
await this.processHookResult(hookResult);
|
|
2160
|
-
if (!hookResult.approved) {
|
|
2161
|
-
const hookReason = hookResult.reason || "Hook rejected task stop";
|
|
2162
|
-
return {
|
|
2163
|
-
success: false,
|
|
2164
|
-
error: hookReason
|
|
2165
|
-
};
|
|
2166
|
-
}
|
|
2167
|
-
if (hookResult.timedOut) {
|
|
2168
|
-
// Hook timed out but was auto-approved
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
// Calculate remaining time to meet 3-second minimum
|
|
2172
|
-
const elapsed = Date.now() - startTime;
|
|
2173
|
-
const minimumDelay = MINIMUM_STOP_TASK_DELAY_MS;
|
|
2174
|
-
const remainingDelay = Math.max(0, minimumDelay - elapsed);
|
|
2175
|
-
// Wait for remaining time if needed
|
|
2176
|
-
if (remainingDelay > 0) {
|
|
2177
|
-
await new Promise(resolve => setTimeout(resolve, remainingDelay));
|
|
1827
|
+
const result = await this.hookManager.stopActiveTask(reason, documentationFile, color);
|
|
1828
|
+
if (result.success) {
|
|
1829
|
+
this.activeTask = "";
|
|
1830
|
+
this.activeTaskAction = "";
|
|
1831
|
+
this.activeTaskColor = "white";
|
|
2178
1832
|
}
|
|
2179
|
-
|
|
2180
|
-
const stoppedTask = this.activeTask;
|
|
2181
|
-
const stoppedAction = this.activeTaskAction;
|
|
2182
|
-
// Clear the task
|
|
2183
|
-
this.activeTask = "";
|
|
2184
|
-
this.activeTaskAction = "";
|
|
2185
|
-
this.activeTaskColor = "white";
|
|
2186
|
-
// Add system message
|
|
2187
|
-
const colorStr = color && color !== "white" ? ` (${color})` : "";
|
|
2188
|
-
this.messages.push({
|
|
2189
|
-
role: 'system',
|
|
2190
|
-
content: `Assistant stopped task "${stoppedTask}" (was ${stoppedAction}) with reason: ${reason}${colorStr}`
|
|
2191
|
-
});
|
|
2192
|
-
// Emit event to clear widget
|
|
2193
|
-
this.emit('activeTaskChange', {
|
|
2194
|
-
activeTask: "",
|
|
2195
|
-
action: "",
|
|
2196
|
-
color: "white"
|
|
2197
|
-
});
|
|
2198
|
-
return { success: true };
|
|
2199
|
-
}
|
|
2200
|
-
async emitContextChange() {
|
|
2201
|
-
const percent = this.getContextUsagePercent();
|
|
2202
|
-
this.emit('contextChange', {
|
|
2203
|
-
current: this.getCurrentTokenCount(),
|
|
2204
|
-
max: this.getMaxContextSize(),
|
|
2205
|
-
percent
|
|
2206
|
-
});
|
|
2207
|
-
// Add system warnings based on context usage (may auto-clear at 100%)
|
|
2208
|
-
await this.addContextWarningIfNeeded(percent);
|
|
1833
|
+
return result;
|
|
2209
1834
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
return;
|
|
2223
|
-
}
|
|
2224
|
-
if (percent >= 95) {
|
|
2225
|
-
// Very stern warning at 95%+ (every time)
|
|
2226
|
-
warning = `CRITICAL CONTEXT WARNING: You are at ${roundedPercent}% context capacity! You MUST immediately save any notes and lessons learned, then run the 'clearCache' tool to reset the conversation context. The conversation will fail if you do not take action now.`;
|
|
2227
|
-
}
|
|
2228
|
-
else if (percent >= 90 && !this.contextWarningAt90) {
|
|
2229
|
-
// Dire warning at 90% (one time only)
|
|
2230
|
-
this.contextWarningAt90 = true;
|
|
2231
|
-
warning = `URGENT CONTEXT WARNING: You are at ${roundedPercent}% context capacity! Perform your final tasks or responses and prepare to be reset.`;
|
|
2232
|
-
}
|
|
2233
|
-
else if (percent >= 80 && !this.contextWarningAt80) {
|
|
2234
|
-
// Initial warning at 80% (one time only)
|
|
2235
|
-
this.contextWarningAt80 = true;
|
|
2236
|
-
warning = `Context Warning: You are at ${roundedPercent}% context capacity! You are approaching the limit. Be concise and avoid lengthy outputs.`;
|
|
2237
|
-
}
|
|
2238
|
-
if (warning) {
|
|
2239
|
-
// Add as a system message
|
|
2240
|
-
this.messages.push({
|
|
2241
|
-
role: 'system',
|
|
2242
|
-
content: warning
|
|
2243
|
-
});
|
|
2244
|
-
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Delegation method for hook processing (used by ToolExecutor).
|
|
1837
|
+
*
|
|
1838
|
+
* Processes hook results through the HookManager to handle commands,
|
|
1839
|
+
* variable transformations, and other hook-specific logic.
|
|
1840
|
+
*
|
|
1841
|
+
* @param hookResult - Result object from hook execution
|
|
1842
|
+
* @param envKey - Optional environment key for variable transformation
|
|
1843
|
+
* @returns Promise resolving to processing result
|
|
1844
|
+
*/
|
|
1845
|
+
async processHookResult(hookResult, envKey) {
|
|
1846
|
+
return await this.hookManager['processHookResult'](hookResult, envKey);
|
|
2245
1847
|
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Execute a shell command through the ZSH tool.
|
|
1850
|
+
*
|
|
1851
|
+
* @param command - Shell command to execute
|
|
1852
|
+
* @param skipConfirmation - Whether to skip confirmation prompts
|
|
1853
|
+
* @returns Promise resolving to tool execution result
|
|
1854
|
+
*/
|
|
2246
1855
|
async executeCommand(command, skipConfirmation = false) {
|
|
2247
1856
|
return await this.zsh.execute(command, 30000, skipConfirmation);
|
|
2248
1857
|
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Get the current LLM model name.
|
|
1860
|
+
* @returns Current model identifier
|
|
1861
|
+
*/
|
|
2249
1862
|
getCurrentModel() {
|
|
2250
1863
|
return this.llmClient.getCurrentModel();
|
|
2251
1864
|
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Set a new LLM model and update related components.
|
|
1867
|
+
*
|
|
1868
|
+
* This method:
|
|
1869
|
+
* - Updates the LLM client model
|
|
1870
|
+
* - Resets vision support flag
|
|
1871
|
+
* - Updates the token counter for the new model
|
|
1872
|
+
* - Handles model name suffixes (e.g., :nothinking)
|
|
1873
|
+
*
|
|
1874
|
+
* @param model - New model identifier
|
|
1875
|
+
*/
|
|
2252
1876
|
setModel(model) {
|
|
2253
1877
|
this.llmClient.setModel(model);
|
|
2254
1878
|
// Reset supportsVision flag for new model
|
|
@@ -2262,308 +1886,36 @@ export class LLMAgent extends EventEmitter {
|
|
|
2262
1886
|
this.tokenCounter = createTokenCounter(cleanModel);
|
|
2263
1887
|
}
|
|
2264
1888
|
/**
|
|
2265
|
-
*
|
|
2266
|
-
*
|
|
2267
|
-
* @returns Cleaned copy of messages array, or original if no stripping needed
|
|
2268
|
-
*/
|
|
2269
|
-
static stripInProgressToolCalls(messages) {
|
|
2270
|
-
// Find the last assistant message
|
|
2271
|
-
let lastAssistantIndex = -1;
|
|
2272
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2273
|
-
if (messages[i].role === 'assistant') {
|
|
2274
|
-
lastAssistantIndex = i;
|
|
2275
|
-
break;
|
|
2276
|
-
}
|
|
2277
|
-
}
|
|
2278
|
-
// If no assistant message or it has no tool_calls, return original
|
|
2279
|
-
if (lastAssistantIndex === -1 || !messages[lastAssistantIndex].tool_calls) {
|
|
2280
|
-
return messages;
|
|
2281
|
-
}
|
|
2282
|
-
// Create deep copy to avoid modifying original
|
|
2283
|
-
const cleanedMessages = JSON.parse(JSON.stringify(messages));
|
|
2284
|
-
// Collect tool_call_ids from the last assistant message
|
|
2285
|
-
const toolCallIds = new Set((cleanedMessages[lastAssistantIndex].tool_calls || []).map((tc) => tc.id));
|
|
2286
|
-
// Remove tool_calls from the last assistant message
|
|
2287
|
-
delete cleanedMessages[lastAssistantIndex].tool_calls;
|
|
2288
|
-
// Remove any tool result messages that correspond to those tool_call_ids
|
|
2289
|
-
// (in case some finished but not all)
|
|
2290
|
-
return cleanedMessages.filter((msg, idx) => {
|
|
2291
|
-
if (idx <= lastAssistantIndex) {
|
|
2292
|
-
return true; // Keep all messages before and including the assistant message
|
|
2293
|
-
}
|
|
2294
|
-
if (msg.role === 'tool' && toolCallIds.has(msg.tool_call_id)) {
|
|
2295
|
-
return false; // Remove tool results for the in-progress tool calls
|
|
2296
|
-
}
|
|
2297
|
-
return true;
|
|
2298
|
-
});
|
|
2299
|
-
}
|
|
2300
|
-
/**
|
|
2301
|
-
* Test a model change by making a test API call with current conversation context
|
|
2302
|
-
* Rolls back to previous model if test fails
|
|
2303
|
-
* @param newModel Model to test
|
|
2304
|
-
* @returns Promise with success status and optional error message
|
|
2305
|
-
*/
|
|
2306
|
-
async testModel(newModel) {
|
|
2307
|
-
const previousModel = this.getCurrentModel();
|
|
2308
|
-
const previousTokenCounter = this.tokenCounter;
|
|
2309
|
-
// Strip in-progress tool calls to avoid sending incomplete assistant messages
|
|
2310
|
-
const testMessages = LLMAgent.stripInProgressToolCalls(this.messages);
|
|
2311
|
-
// Build request payload for logging
|
|
2312
|
-
const supportsTools = this.llmClient.getSupportsTools();
|
|
2313
|
-
const tools = supportsTools ? await getAllLLMTools() : [];
|
|
2314
|
-
const requestPayload = {
|
|
2315
|
-
model: newModel,
|
|
2316
|
-
messages: testMessages,
|
|
2317
|
-
tools: supportsTools && tools.length > 0 ? tools : undefined,
|
|
2318
|
-
temperature: this.temperature,
|
|
2319
|
-
max_tokens: 10
|
|
2320
|
-
};
|
|
2321
|
-
try {
|
|
2322
|
-
// Temporarily set the new model
|
|
2323
|
-
this.llmClient.setModel(newModel);
|
|
2324
|
-
this.tokenCounter = createTokenCounter(newModel);
|
|
2325
|
-
// Test with actual conversation context to verify the model can handle it
|
|
2326
|
-
// This catches issues like ollama models that fail to parse tool calls
|
|
2327
|
-
const response = await this.llmClient.chat(testMessages, tools, newModel, undefined, this.temperature, undefined, 10);
|
|
2328
|
-
// Check if response is valid
|
|
2329
|
-
if (!response || !response.choices || response.choices.length === 0) {
|
|
2330
|
-
throw new Error("Invalid response from API");
|
|
2331
|
-
}
|
|
2332
|
-
// Test succeeded - keep the new model
|
|
2333
|
-
previousTokenCounter.dispose();
|
|
2334
|
-
return { success: true };
|
|
2335
|
-
}
|
|
2336
|
-
catch (error) {
|
|
2337
|
-
// Test failed - roll back to previous model
|
|
2338
|
-
this.llmClient.setModel(previousModel);
|
|
2339
|
-
this.tokenCounter.dispose();
|
|
2340
|
-
this.tokenCounter = previousTokenCounter;
|
|
2341
|
-
// Log test failure with full request/response for debugging
|
|
2342
|
-
const { message: logPaths } = await logApiError(requestPayload, error, { errorType: 'model-switch-test-failure', previousModel, newModel }, 'test-fail');
|
|
2343
|
-
const errorMessage = error.message || "Unknown error during model test";
|
|
2344
|
-
return {
|
|
2345
|
-
success: false,
|
|
2346
|
-
error: `Model test failed: ${errorMessage}\n${logPaths}`
|
|
2347
|
-
};
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
/**
|
|
2351
|
-
* Test backend/baseUrl/model changes by making a test API call with current conversation context
|
|
2352
|
-
* Rolls back all changes if test fails
|
|
2353
|
-
* @param backend Backend display name
|
|
2354
|
-
* @param baseUrl Base URL for API calls
|
|
2355
|
-
* @param apiKeyEnvVar Name of environment variable containing API key
|
|
2356
|
-
* @param model Model to use (optional, uses current model if not specified)
|
|
2357
|
-
* @returns Promise with success status and optional error message
|
|
1889
|
+
* Get the backend name (e.g., "grok", "openai").
|
|
1890
|
+
* @returns Backend identifier string
|
|
2358
1891
|
*/
|
|
2359
|
-
async testBackendModelChange(backend, baseUrl, apiKeyEnvVar, model) {
|
|
2360
|
-
const previousClient = this.llmClient;
|
|
2361
|
-
const previousApiKeyEnvVar = this.apiKeyEnvVar;
|
|
2362
|
-
const previousBackend = this.llmClient.getBackendName();
|
|
2363
|
-
const previousModel = this.getCurrentModel();
|
|
2364
|
-
let requestPayload;
|
|
2365
|
-
let newModel;
|
|
2366
|
-
try {
|
|
2367
|
-
// Get API key from environment
|
|
2368
|
-
const apiKey = process.env[apiKeyEnvVar];
|
|
2369
|
-
if (!apiKey) {
|
|
2370
|
-
throw new Error(`API key not found in environment variable: ${apiKeyEnvVar}`);
|
|
2371
|
-
}
|
|
2372
|
-
// Use current model if not specified
|
|
2373
|
-
newModel = model || this.getCurrentModel();
|
|
2374
|
-
// Create new client with new configuration
|
|
2375
|
-
this.llmClient = new LLMClient(apiKey, newModel, baseUrl, backend);
|
|
2376
|
-
// Store the API key env var name for session persistence
|
|
2377
|
-
this.apiKeyEnvVar = apiKeyEnvVar;
|
|
2378
|
-
// Reinitialize MCP servers since we're switching to a new backend/model
|
|
2379
|
-
try {
|
|
2380
|
-
const config = loadMCPConfig();
|
|
2381
|
-
if (config.servers.length > 0) {
|
|
2382
|
-
await initializeMCPServers();
|
|
2383
|
-
}
|
|
2384
|
-
}
|
|
2385
|
-
catch (mcpError) {
|
|
2386
|
-
console.warn("MCP reinitialization failed:", mcpError);
|
|
2387
|
-
}
|
|
2388
|
-
// Strip in-progress tool calls to avoid sending incomplete assistant messages
|
|
2389
|
-
const testMessages = LLMAgent.stripInProgressToolCalls(this.messages);
|
|
2390
|
-
// Build request payload for logging
|
|
2391
|
-
const supportsTools = this.llmClient.getSupportsTools();
|
|
2392
|
-
const tools = supportsTools ? await getAllLLMTools() : [];
|
|
2393
|
-
requestPayload = {
|
|
2394
|
-
backend,
|
|
2395
|
-
baseUrl,
|
|
2396
|
-
model: newModel,
|
|
2397
|
-
messages: testMessages,
|
|
2398
|
-
tools: supportsTools && tools.length > 0 ? tools : undefined,
|
|
2399
|
-
temperature: this.temperature,
|
|
2400
|
-
max_tokens: 10
|
|
2401
|
-
};
|
|
2402
|
-
// Test with actual conversation context to verify the backend/model can handle it
|
|
2403
|
-
// This catches issues like ollama models that fail to parse tool calls
|
|
2404
|
-
const response = await this.llmClient.chat(testMessages, tools, newModel, undefined, this.temperature, undefined, 10);
|
|
2405
|
-
// Check if response is valid
|
|
2406
|
-
if (!response || !response.choices || response.choices.length === 0) {
|
|
2407
|
-
throw new Error("Invalid response from API");
|
|
2408
|
-
}
|
|
2409
|
-
// Test succeeded - new client is now active
|
|
2410
|
-
return { success: true };
|
|
2411
|
-
}
|
|
2412
|
-
catch (error) {
|
|
2413
|
-
// Test failed - restore previous client and API key env var
|
|
2414
|
-
this.llmClient = previousClient;
|
|
2415
|
-
this.apiKeyEnvVar = previousApiKeyEnvVar;
|
|
2416
|
-
// Log test failure with full request/response for debugging (if we got far enough to build the payload)
|
|
2417
|
-
let logPaths = '';
|
|
2418
|
-
if (requestPayload) {
|
|
2419
|
-
const result = await logApiError(requestPayload, error, {
|
|
2420
|
-
errorType: 'backend-switch-test-failure',
|
|
2421
|
-
previousBackend,
|
|
2422
|
-
previousModel,
|
|
2423
|
-
newBackend: backend,
|
|
2424
|
-
newModel,
|
|
2425
|
-
baseUrl,
|
|
2426
|
-
apiKeyEnvVar
|
|
2427
|
-
}, 'test-fail');
|
|
2428
|
-
logPaths = result.message;
|
|
2429
|
-
}
|
|
2430
|
-
const errorMessage = error.message || "Unknown error during backend/model test";
|
|
2431
|
-
return {
|
|
2432
|
-
success: false,
|
|
2433
|
-
error: logPaths ? `${errorMessage}\n${logPaths}` : errorMessage
|
|
2434
|
-
};
|
|
2435
|
-
}
|
|
2436
|
-
}
|
|
2437
|
-
/**
|
|
2438
|
-
* Process hook result including commands and transformations
|
|
2439
|
-
* Handles ENV transformations, model/backend testing, and error messaging
|
|
2440
|
-
* @param hookResult Hook execution result
|
|
2441
|
-
* @param envKey Optional ENV key to check for transformation (e.g., ZDS_AI_AGENT_PERSONA)
|
|
2442
|
-
* @returns Object with success status and transformed value (if any)
|
|
2443
|
-
*/
|
|
2444
|
-
async processHookResult(hookResult, envKey) {
|
|
2445
|
-
if (!hookResult.commands) {
|
|
2446
|
-
return { success: true };
|
|
2447
|
-
}
|
|
2448
|
-
const results = applyHookCommands(hookResult.commands);
|
|
2449
|
-
// Check for transformation via ENV if key provided
|
|
2450
|
-
let transformedValue;
|
|
2451
|
-
if (envKey && results.env[envKey]) {
|
|
2452
|
-
transformedValue = results.env[envKey];
|
|
2453
|
-
}
|
|
2454
|
-
// Process commands (test model/backend, apply ENV vars, add SYSTEM messages)
|
|
2455
|
-
const success = await this.processHookCommands(results);
|
|
2456
|
-
return { success, transformedValue };
|
|
2457
|
-
}
|
|
2458
|
-
/**
|
|
2459
|
-
* Process hook commands (MODEL, BACKEND, BASE_URL, SYSTEM, ENV)
|
|
2460
|
-
* Handles model/backend testing and error messaging
|
|
2461
|
-
* @param commands Hook commands from applyHookCommands()
|
|
2462
|
-
*/
|
|
2463
|
-
async processHookCommands(commands) {
|
|
2464
|
-
// Import the helper function
|
|
2465
|
-
const { applyEnvVariables } = await import('../utils/hook-executor.js');
|
|
2466
|
-
// Check if backend or model change is requested
|
|
2467
|
-
const hasBackendChange = commands.backend && commands.baseUrl && commands.apiKeyEnvVar;
|
|
2468
|
-
const hasModelChange = commands.model;
|
|
2469
|
-
// Test backend/model changes FIRST before applying anything
|
|
2470
|
-
if (hasBackendChange) {
|
|
2471
|
-
// Backend change - test backend/baseUrl/model together
|
|
2472
|
-
const testResult = await this.testBackendModelChange(commands.backend, commands.baseUrl, commands.apiKeyEnvVar, commands.model);
|
|
2473
|
-
if (!testResult.success) {
|
|
2474
|
-
// Test failed - don't apply ANYTHING
|
|
2475
|
-
const parts = [];
|
|
2476
|
-
if (commands.backend)
|
|
2477
|
-
parts.push(`backend to "${commands.backend}"`);
|
|
2478
|
-
if (commands.model)
|
|
2479
|
-
parts.push(`model to "${commands.model}"`);
|
|
2480
|
-
const errorMsg = `Failed to change ${parts.join(' and ')}: ${testResult.error}`;
|
|
2481
|
-
// Note: Don't add to this.messages during tool execution - only chatHistory
|
|
2482
|
-
this.chatHistory.push({
|
|
2483
|
-
type: "system",
|
|
2484
|
-
content: errorMsg,
|
|
2485
|
-
timestamp: new Date(),
|
|
2486
|
-
});
|
|
2487
|
-
return false; // Signal failure - caller should not apply other changes
|
|
2488
|
-
}
|
|
2489
|
-
// Test succeeded - apply ENV variables and add success message
|
|
2490
|
-
applyEnvVariables(commands.env);
|
|
2491
|
-
const parts = [];
|
|
2492
|
-
if (commands.backend)
|
|
2493
|
-
parts.push(`backend to "${commands.backend}"`);
|
|
2494
|
-
if (commands.model)
|
|
2495
|
-
parts.push(`model to "${commands.model}"`);
|
|
2496
|
-
const successMsg = `Changed ${parts.join(' and ')}`;
|
|
2497
|
-
// Note: Don't add to this.messages during tool execution - only chatHistory
|
|
2498
|
-
this.chatHistory.push({
|
|
2499
|
-
type: "system",
|
|
2500
|
-
content: successMsg,
|
|
2501
|
-
timestamp: new Date(),
|
|
2502
|
-
});
|
|
2503
|
-
// Emit events for UI updates
|
|
2504
|
-
if (commands.backend) {
|
|
2505
|
-
this.emit('backendChange', {
|
|
2506
|
-
backend: commands.backend
|
|
2507
|
-
});
|
|
2508
|
-
}
|
|
2509
|
-
if (commands.model) {
|
|
2510
|
-
this.emit('modelChange', {
|
|
2511
|
-
model: commands.model
|
|
2512
|
-
});
|
|
2513
|
-
}
|
|
2514
|
-
}
|
|
2515
|
-
else if (hasModelChange) {
|
|
2516
|
-
// Model-only change
|
|
2517
|
-
const testResult = await this.testModel(commands.model);
|
|
2518
|
-
if (!testResult.success) {
|
|
2519
|
-
// Test failed - don't apply ANYTHING
|
|
2520
|
-
const errorMsg = `Failed to change model to "${commands.model}": ${testResult.error}`;
|
|
2521
|
-
// Note: Don't add to this.messages during tool execution - only chatHistory
|
|
2522
|
-
this.chatHistory.push({
|
|
2523
|
-
type: "system",
|
|
2524
|
-
content: errorMsg,
|
|
2525
|
-
timestamp: new Date(),
|
|
2526
|
-
});
|
|
2527
|
-
return false; // Signal failure - caller should not apply other changes
|
|
2528
|
-
}
|
|
2529
|
-
// Test succeeded - apply ENV variables and add success message
|
|
2530
|
-
applyEnvVariables(commands.env);
|
|
2531
|
-
const successMsg = `Model changed to "${commands.model}"`;
|
|
2532
|
-
// Note: Don't add to this.messages during tool execution - only chatHistory
|
|
2533
|
-
this.chatHistory.push({
|
|
2534
|
-
type: "system",
|
|
2535
|
-
content: successMsg,
|
|
2536
|
-
timestamp: new Date(),
|
|
2537
|
-
});
|
|
2538
|
-
// Emit modelChange event for UI updates
|
|
2539
|
-
this.emit('modelChange', {
|
|
2540
|
-
model: commands.model
|
|
2541
|
-
});
|
|
2542
|
-
}
|
|
2543
|
-
else {
|
|
2544
|
-
// No model or backend change - just apply ENV variables
|
|
2545
|
-
applyEnvVariables(commands.env);
|
|
2546
|
-
}
|
|
2547
|
-
// Add SYSTEM message if present
|
|
2548
|
-
if (commands.system) {
|
|
2549
|
-
// Note: Don't add to this.messages during tool execution - only chatHistory
|
|
2550
|
-
this.chatHistory.push({
|
|
2551
|
-
type: "system",
|
|
2552
|
-
content: commands.system,
|
|
2553
|
-
timestamp: new Date(),
|
|
2554
|
-
});
|
|
2555
|
-
}
|
|
2556
|
-
return true; // Signal success - caller can apply other changes
|
|
2557
|
-
}
|
|
2558
1892
|
getBackend() {
|
|
2559
1893
|
// Just return the backend name from the client (no detection)
|
|
2560
1894
|
return this.llmClient.getBackendName();
|
|
2561
1895
|
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Abort the current operation if one is in progress.
|
|
1898
|
+
*
|
|
1899
|
+
* This will cancel streaming responses and tool execution.
|
|
1900
|
+
*/
|
|
2562
1901
|
abortCurrentOperation() {
|
|
2563
1902
|
if (this.abortController) {
|
|
2564
1903
|
this.abortController.abort();
|
|
2565
1904
|
}
|
|
2566
1905
|
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Clear the conversation cache and reinitialize the agent.
|
|
1908
|
+
*
|
|
1909
|
+
* This method:
|
|
1910
|
+
* - Backs up current conversation to timestamped files
|
|
1911
|
+
* - Clears chat history and messages
|
|
1912
|
+
* - Resets context warnings and processing flags
|
|
1913
|
+
* - Re-executes startup and instance hooks
|
|
1914
|
+
* - Saves the cleared state
|
|
1915
|
+
* - Emits context change events
|
|
1916
|
+
*
|
|
1917
|
+
* Used when context becomes too large or user requests a fresh start.
|
|
1918
|
+
*/
|
|
2567
1919
|
async clearCache() {
|
|
2568
1920
|
const { ChatHistoryManager } = await import("../utils/chat-history-manager.js");
|
|
2569
1921
|
const { executeStartupHook } = await import("../utils/startup-hook.js");
|
|
@@ -2574,8 +1926,7 @@ export class LLMAgent extends EventEmitter {
|
|
|
2574
1926
|
// Clear the context
|
|
2575
1927
|
this.chatHistory = [];
|
|
2576
1928
|
this.messages = [];
|
|
2577
|
-
this.
|
|
2578
|
-
this.contextWarningAt90 = false;
|
|
1929
|
+
this.contextManager.resetContextWarnings();
|
|
2579
1930
|
this.firstMessageProcessed = false;
|
|
2580
1931
|
// Add temporary system message (will be replaced by initialize())
|
|
2581
1932
|
this.messages.push({
|
|
@@ -2612,211 +1963,55 @@ export class LLMAgent extends EventEmitter {
|
|
|
2612
1963
|
// Note: Intentionally NOT calling addContextWarningIfNeeded here to prevent recursion
|
|
2613
1964
|
}
|
|
2614
1965
|
/**
|
|
2615
|
-
* Get current session state for persistence
|
|
1966
|
+
* Get current session state for persistence.
|
|
1967
|
+
*
|
|
1968
|
+
* Collects all session-related state including:
|
|
1969
|
+
* - Model and backend configuration
|
|
1970
|
+
* - Persona, mood, and task settings
|
|
1971
|
+
* - Context usage statistics
|
|
1972
|
+
* - API key environment variable
|
|
1973
|
+
*
|
|
1974
|
+
* @returns Complete session state object
|
|
2616
1975
|
*/
|
|
2617
1976
|
getSessionState() {
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
moodColor: this.moodColor,
|
|
2624
|
-
activeTask: this.activeTask,
|
|
2625
|
-
activeTaskAction: this.activeTaskAction,
|
|
2626
|
-
activeTaskColor: this.activeTaskColor,
|
|
2627
|
-
cwd: process.cwd(),
|
|
2628
|
-
contextCurrent: this.getCurrentTokenCount(),
|
|
2629
|
-
contextMax: this.getMaxContextSize(),
|
|
2630
|
-
backend: this.llmClient.getBackendName(),
|
|
2631
|
-
baseUrl: this.llmClient.getBaseURL(),
|
|
2632
|
-
apiKeyEnvVar: this.apiKeyEnvVar,
|
|
2633
|
-
model: this.getCurrentModel(),
|
|
2634
|
-
supportsVision: this.llmClient.getSupportsVision(),
|
|
2635
|
-
};
|
|
1977
|
+
const state = this.sessionManager.getSessionState();
|
|
1978
|
+
// Add context values that SessionManager can't access
|
|
1979
|
+
state.contextCurrent = this.getCurrentTokenCount();
|
|
1980
|
+
state.contextMax = this.getMaxContextSize();
|
|
1981
|
+
return state;
|
|
2636
1982
|
}
|
|
2637
1983
|
/**
|
|
2638
|
-
* Restore session state from persistence
|
|
1984
|
+
* Restore session state from persistence.
|
|
1985
|
+
*
|
|
1986
|
+
* Restores all session-related state including:
|
|
1987
|
+
* - Model and backend configuration
|
|
1988
|
+
* - Persona, mood, and task settings
|
|
1989
|
+
* - Token counter and API client setup
|
|
1990
|
+
*
|
|
1991
|
+
* @param state - Session state to restore
|
|
2639
1992
|
*/
|
|
2640
1993
|
async restoreSessionState(state) {
|
|
2641
|
-
|
|
2642
|
-
if (state.session) {
|
|
2643
|
-
process.env.ZDS_AI_AGENT_SESSION = state.session;
|
|
2644
|
-
}
|
|
2645
|
-
// Restore cwd early (hooks may need correct working directory)
|
|
2646
|
-
if (state.cwd) {
|
|
2647
|
-
try {
|
|
2648
|
-
const fs = await import('fs');
|
|
2649
|
-
// Only attempt to change directory if it exists
|
|
2650
|
-
if (fs.existsSync(state.cwd)) {
|
|
2651
|
-
process.chdir(state.cwd);
|
|
2652
|
-
}
|
|
2653
|
-
// Silently skip if directory doesn't exist (common in containerized environments)
|
|
2654
|
-
}
|
|
2655
|
-
catch (error) {
|
|
2656
|
-
// Silently skip on any error - working directory restoration is non-critical
|
|
2657
|
-
}
|
|
2658
|
-
}
|
|
2659
|
-
// Restore backend/baseUrl/apiKeyEnvVar/model if present (creates initial client)
|
|
2660
|
-
if (state.backend && state.baseUrl && state.apiKeyEnvVar) {
|
|
2661
|
-
try {
|
|
2662
|
-
// Get API key from environment
|
|
2663
|
-
const apiKey = process.env[state.apiKeyEnvVar];
|
|
2664
|
-
if (apiKey) {
|
|
2665
|
-
// Create new client with restored configuration
|
|
2666
|
-
const model = state.model || this.getCurrentModel();
|
|
2667
|
-
this.llmClient = new LLMClient(apiKey, model, state.baseUrl, state.backend);
|
|
2668
|
-
this.apiKeyEnvVar = state.apiKeyEnvVar;
|
|
2669
|
-
// Restore supportsVision flag if present
|
|
2670
|
-
if (state.supportsVision !== undefined) {
|
|
2671
|
-
this.llmClient.setSupportsVision(state.supportsVision);
|
|
2672
|
-
}
|
|
2673
|
-
// Reinitialize MCP servers when restoring session
|
|
2674
|
-
try {
|
|
2675
|
-
const config = loadMCPConfig();
|
|
2676
|
-
if (config.servers.length > 0) {
|
|
2677
|
-
await initializeMCPServers();
|
|
2678
|
-
}
|
|
2679
|
-
}
|
|
2680
|
-
catch (mcpError) {
|
|
2681
|
-
console.warn("MCP reinitialization failed:", mcpError);
|
|
2682
|
-
}
|
|
2683
|
-
// Dispose old token counter and create new one for the restored model
|
|
2684
|
-
this.tokenCounter.dispose();
|
|
2685
|
-
this.tokenCounter = createTokenCounter(model);
|
|
2686
|
-
// Emit events for UI updates
|
|
2687
|
-
this.emit('backendChange', { backend: state.backend });
|
|
2688
|
-
this.emit('modelChange', { model });
|
|
2689
|
-
}
|
|
2690
|
-
else {
|
|
2691
|
-
console.warn("Failed to restore backend: API key not found in environment.");
|
|
2692
|
-
}
|
|
2693
|
-
}
|
|
2694
|
-
catch (error) {
|
|
2695
|
-
console.warn(`Failed to restore backend configuration:`, error);
|
|
2696
|
-
}
|
|
2697
|
-
}
|
|
2698
|
-
// Restore persona (hook may change backend/model and sets env vars)
|
|
2699
|
-
if (state.persona) {
|
|
2700
|
-
try {
|
|
2701
|
-
const result = await this.setPersona(state.persona, state.personaColor);
|
|
2702
|
-
if (!result.success) {
|
|
2703
|
-
// If persona hook failed (e.g., backend test failed), still set the persona values
|
|
2704
|
-
// but don't change backend/model. This prevents losing persona state on transitory errors.
|
|
2705
|
-
console.warn(`Persona hook failed, setting persona without backend change: ${result.error}`);
|
|
2706
|
-
this.persona = state.persona;
|
|
2707
|
-
this.personaColor = state.personaColor;
|
|
2708
|
-
process.env.ZDS_AI_AGENT_PERSONA = state.persona;
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
catch (error) {
|
|
2712
|
-
console.warn(`Failed to restore persona "${state.persona}":`, error);
|
|
2713
|
-
// Still set persona values even if hook crashed
|
|
2714
|
-
this.persona = state.persona;
|
|
2715
|
-
this.personaColor = state.personaColor;
|
|
2716
|
-
process.env.ZDS_AI_AGENT_PERSONA = state.persona;
|
|
2717
|
-
}
|
|
2718
|
-
}
|
|
2719
|
-
// Restore mood (hook sets env vars)
|
|
2720
|
-
if (state.mood) {
|
|
2721
|
-
try {
|
|
2722
|
-
const result = await this.setMood(state.mood, state.moodColor);
|
|
2723
|
-
if (!result.success) {
|
|
2724
|
-
// If mood hook failed (e.g., backend test failed), still set the mood values
|
|
2725
|
-
// but don't change backend/model. This prevents losing mood state on transitory errors.
|
|
2726
|
-
console.warn(`Mood hook failed, setting mood without backend change: ${result.error}`);
|
|
2727
|
-
this.mood = state.mood;
|
|
2728
|
-
this.moodColor = state.moodColor;
|
|
2729
|
-
process.env.ZDS_AI_AGENT_MOOD = state.mood;
|
|
2730
|
-
}
|
|
2731
|
-
}
|
|
2732
|
-
catch (error) {
|
|
2733
|
-
console.warn(`Failed to restore mood "${state.mood}":`, error);
|
|
2734
|
-
// Still set mood values even if hook crashed
|
|
2735
|
-
this.mood = state.mood;
|
|
2736
|
-
this.moodColor = state.moodColor;
|
|
2737
|
-
process.env.ZDS_AI_AGENT_MOOD = state.mood;
|
|
2738
|
-
}
|
|
2739
|
-
}
|
|
2740
|
-
// Restore active task (hook sets env vars)
|
|
2741
|
-
if (state.activeTask) {
|
|
2742
|
-
try {
|
|
2743
|
-
const result = await this.startActiveTask(state.activeTask, state.activeTaskAction, state.activeTaskColor);
|
|
2744
|
-
if (!result.success) {
|
|
2745
|
-
// If task hook failed (e.g., backend test failed), still set the task values
|
|
2746
|
-
// but don't change backend/model. This prevents losing task state on transitory errors.
|
|
2747
|
-
console.warn(`Task hook failed, setting active task without backend change: ${result.error}`);
|
|
2748
|
-
this.activeTask = state.activeTask;
|
|
2749
|
-
this.activeTaskAction = state.activeTaskAction;
|
|
2750
|
-
this.activeTaskColor = state.activeTaskColor;
|
|
2751
|
-
process.env.ZDS_AI_AGENT_ACTIVE_TASK = state.activeTask;
|
|
2752
|
-
process.env.ZDS_AI_AGENT_ACTIVE_TASK_ACTION = state.activeTaskAction;
|
|
2753
|
-
}
|
|
2754
|
-
}
|
|
2755
|
-
catch (error) {
|
|
2756
|
-
console.warn(`Failed to restore active task "${state.activeTask}":`, error);
|
|
2757
|
-
// Still set task values even if hook crashed
|
|
2758
|
-
this.activeTask = state.activeTask;
|
|
2759
|
-
this.activeTaskAction = state.activeTaskAction;
|
|
2760
|
-
this.activeTaskColor = state.activeTaskColor;
|
|
2761
|
-
process.env.ZDS_AI_AGENT_ACTIVE_TASK = state.activeTask;
|
|
2762
|
-
process.env.ZDS_AI_AGENT_ACTIVE_TASK_ACTION = state.activeTaskAction;
|
|
2763
|
-
}
|
|
2764
|
-
}
|
|
1994
|
+
return await this.sessionManager.restoreSessionState(state);
|
|
2765
1995
|
}
|
|
2766
1996
|
/**
|
|
2767
|
-
* Compact conversation context by keeping system prompt and last N messages
|
|
2768
|
-
*
|
|
1997
|
+
* Compact conversation context by keeping system prompt and last N messages.
|
|
1998
|
+
*
|
|
1999
|
+
* Reduces context size when it grows too large for the backend to handle.
|
|
2000
|
+
* Removes older messages while preserving the system prompt and recent context.
|
|
2001
|
+
*
|
|
2002
|
+
* @param keepLastMessages - Number of recent messages to keep (default: 20)
|
|
2769
2003
|
* @returns Number of messages removed
|
|
2770
2004
|
*/
|
|
2771
2005
|
compactContext(keepLastMessages = 20) {
|
|
2772
|
-
|
|
2773
|
-
// Nothing to compact
|
|
2774
|
-
return 0;
|
|
2775
|
-
}
|
|
2776
|
-
const removedCount = this.chatHistory.length - keepLastMessages;
|
|
2777
|
-
const keptMessages = this.chatHistory.slice(-keepLastMessages);
|
|
2778
|
-
// Clear both arrays
|
|
2779
|
-
this.chatHistory = keptMessages;
|
|
2780
|
-
this.messages = [];
|
|
2781
|
-
// Add system message noting the compaction
|
|
2782
|
-
const compactionNote = {
|
|
2783
|
-
type: 'system',
|
|
2784
|
-
content: `Context compacted: removed ${removedCount} older messages, keeping last ${keepLastMessages} messages.`,
|
|
2785
|
-
timestamp: new Date()
|
|
2786
|
-
};
|
|
2787
|
-
this.chatHistory.push(compactionNote);
|
|
2788
|
-
// Rebuild this.messages from compacted chatHistory
|
|
2789
|
-
for (const entry of this.chatHistory) {
|
|
2790
|
-
if (entry.type === 'system') {
|
|
2791
|
-
this.messages.push({
|
|
2792
|
-
role: 'system',
|
|
2793
|
-
content: entry.content
|
|
2794
|
-
});
|
|
2795
|
-
}
|
|
2796
|
-
else if (entry.type === 'user') {
|
|
2797
|
-
this.messages.push({
|
|
2798
|
-
role: 'user',
|
|
2799
|
-
content: entry.content
|
|
2800
|
-
});
|
|
2801
|
-
}
|
|
2802
|
-
else if (entry.type === 'assistant') {
|
|
2803
|
-
this.messages.push({
|
|
2804
|
-
role: 'assistant',
|
|
2805
|
-
content: entry.content
|
|
2806
|
-
});
|
|
2807
|
-
}
|
|
2808
|
-
else if (entry.type === 'tool_result') {
|
|
2809
|
-
this.messages.push({
|
|
2810
|
-
role: 'tool',
|
|
2811
|
-
tool_call_id: entry.toolResult.output || '',
|
|
2812
|
-
content: JSON.stringify(entry.toolResult)
|
|
2813
|
-
});
|
|
2814
|
-
}
|
|
2815
|
-
}
|
|
2816
|
-
return removedCount;
|
|
2006
|
+
return this.contextManager.compactContext(keepLastMessages);
|
|
2817
2007
|
}
|
|
2818
2008
|
/**
|
|
2819
|
-
* Get all tool instances and their class names for display purposes
|
|
2009
|
+
* Get all tool instances and their class names for display purposes.
|
|
2010
|
+
*
|
|
2011
|
+
* Uses reflection to find all tool instances and extract their
|
|
2012
|
+
* class names and handled method names for introspection.
|
|
2013
|
+
*
|
|
2014
|
+
* @returns Array of tool info objects with class names and methods
|
|
2820
2015
|
*/
|
|
2821
2016
|
getToolClassInfo() {
|
|
2822
2017
|
const toolInstances = this.getToolInstances();
|
|
@@ -2826,7 +2021,13 @@ export class LLMAgent extends EventEmitter {
|
|
|
2826
2021
|
}));
|
|
2827
2022
|
}
|
|
2828
2023
|
/**
|
|
2829
|
-
* Get all tool instances via reflection
|
|
2024
|
+
* Get all tool instances via reflection.
|
|
2025
|
+
*
|
|
2026
|
+
* Scans all properties of the agent instance to find objects that
|
|
2027
|
+
* implement the tool interface (have getHandledToolNames method).
|
|
2028
|
+
*
|
|
2029
|
+
* @returns Array of tool instances with their class names
|
|
2030
|
+
* @private
|
|
2830
2031
|
*/
|
|
2831
2032
|
getToolInstances() {
|
|
2832
2033
|
const instances = [];
|