460 lines
21 KiB
TypeScript
460 lines
21 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
// axios removed
|
|
import {
|
|
getConfig,
|
|
_getBrainDir,
|
|
findBrainFiles,
|
|
EXCLUDED_DIRS,
|
|
SYSTEM_PROMPT,
|
|
shouldAutoPushBrain,
|
|
getSecondBrainRepo
|
|
} from './utils';
|
|
import { validatePath, sanitizeCommand } from './security';
|
|
|
|
export interface ChatMessage {
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string | any[];
|
|
}
|
|
|
|
export class AgentExecutor {
|
|
private chatHistory: ChatMessage[] = [];
|
|
private abortController: AbortController | null = null;
|
|
private webview: vscode.Webview | undefined;
|
|
|
|
constructor(
|
|
private context: vscode.ExtensionContext
|
|
) {}
|
|
|
|
public setWebview(webview: vscode.Webview) {
|
|
this.webview = webview;
|
|
}
|
|
|
|
public getHistory() {
|
|
return this.chatHistory;
|
|
}
|
|
|
|
public setHistory(history: ChatMessage[]) {
|
|
this.chatHistory = history;
|
|
}
|
|
|
|
public clearHistory() {
|
|
this.chatHistory = [];
|
|
}
|
|
|
|
public stop() {
|
|
if (this.abortController) {
|
|
this.abortController.abort();
|
|
this.abortController = null;
|
|
}
|
|
}
|
|
|
|
public async handlePrompt(
|
|
prompt: string | null,
|
|
modelName: string,
|
|
options: {
|
|
internetEnabled?: boolean,
|
|
brainEnabled?: boolean,
|
|
loopDepth?: number,
|
|
visionContent?: any[],
|
|
temperature?: number,
|
|
systemPrompt?: string
|
|
}
|
|
) {
|
|
const {
|
|
internetEnabled = false,
|
|
brainEnabled = false,
|
|
loopDepth = 0,
|
|
visionContent,
|
|
temperature = 0.7,
|
|
systemPrompt = SYSTEM_PROMPT
|
|
} = options;
|
|
|
|
if (!this.webview) return;
|
|
|
|
try {
|
|
// 1. Prepare Context
|
|
const workspaceFolders = vscode.workspace.workspaceFolders;
|
|
const rootPath = workspaceFolders ? workspaceFolders[0].uri.fsPath : '';
|
|
|
|
let contextBlock = '';
|
|
const config = getConfig();
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (editor && editor.document.uri.scheme === 'file') {
|
|
const text = editor.document.getText();
|
|
const name = path.basename(editor.document.fileName);
|
|
if (text.trim().length > 0 && text.length < config.maxContextSize) {
|
|
contextBlock = `\n\n[Currently open file: ${name}]\n\`\`\`\n${text}\n\`\`\``;
|
|
}
|
|
}
|
|
|
|
// 2. Setup History
|
|
if (prompt !== null) {
|
|
this.chatHistory.push({ role: 'user', content: prompt });
|
|
this.webview.postMessage({ type: 'streamChunk', value: '' }); // Trigger UI update if needed
|
|
}
|
|
|
|
// 3. API Request Setup
|
|
const { ollamaUrl, defaultModel, timeout } = getConfig();
|
|
const reqMessages = [...this.chatHistory];
|
|
|
|
// Handle Vision Content Injection
|
|
if (visionContent && reqMessages.length > 0) {
|
|
const lastUserIdx = reqMessages.map(m => m.role).lastIndexOf('user');
|
|
if (lastUserIdx >= 0) {
|
|
reqMessages[lastUserIdx] = { role: 'user', content: visionContent };
|
|
}
|
|
}
|
|
|
|
// Inject System Directives
|
|
if (reqMessages.length > 0) {
|
|
const internetCtx = internetEnabled
|
|
? `\n\n[CRITICAL: INTERNET ACCESS ENABLED]\nYou can use <read_url> to search. Current time: ${new Date().toLocaleString()}`
|
|
: '';
|
|
const fullSystemPrompt = `${systemPrompt}${internetCtx}\n\n[CONTEXT]\n${contextBlock}\n${internetCtx}`;
|
|
|
|
const firstUserIdx = reqMessages.findIndex(m => m.role === 'user');
|
|
if (firstUserIdx >= 0) {
|
|
let content = reqMessages[firstUserIdx].content;
|
|
if (typeof content === 'string') {
|
|
reqMessages[firstUserIdx].content = `${fullSystemPrompt}\n\n[USER QUERY]\n${content}`;
|
|
if (loopDepth > 0) {
|
|
reqMessages[firstUserIdx].content = `[Autonomous Step ${loopDepth}/${config.maxAutoSteps}]\n${reqMessages[firstUserIdx].content}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Call AI Engine
|
|
const isLMStudio = ollamaUrl.includes('1234') || ollamaUrl.includes('v1') || ollamaUrl.includes('localhost');
|
|
// Note: Many users use LM Studio on localhost, we'll try to be smart or fallback to Ollama format if it fails.
|
|
|
|
const apiUrl = isLMStudio ?
|
|
(ollamaUrl.endsWith('/v1') ? `${ollamaUrl}/chat/completions` : `${ollamaUrl}/v1/chat/completions`) :
|
|
`${ollamaUrl}/api/chat`;
|
|
|
|
this.abortController = new AbortController();
|
|
|
|
const streamBody = {
|
|
model: modelName || defaultModel,
|
|
messages: reqMessages,
|
|
stream: true,
|
|
...(isLMStudio
|
|
? { max_tokens: 4096, temperature }
|
|
: { options: { num_ctx: 32768, num_predict: 4096, temperature } }),
|
|
};
|
|
|
|
const response = await fetch(apiUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive'
|
|
},
|
|
body: JSON.stringify(streamBody),
|
|
signal: this.abortController.signal,
|
|
keepalive: true
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errText = await response.text();
|
|
throw new Error(`AI Engine error: ${response.status} - ${errText}`);
|
|
}
|
|
|
|
let aiResponseText = '';
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error("Response body is not readable.");
|
|
|
|
if (loopDepth === 0) this.webview.postMessage({ type: 'streamStart' });
|
|
|
|
let buffer = '';
|
|
const decoder = new TextDecoder();
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
|
try {
|
|
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
|
|
const json = JSON.parse(raw);
|
|
const token = isLMStudio ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
|
if (token) {
|
|
aiResponseText += token;
|
|
this.webview?.postMessage({ type: 'streamChunk', value: token });
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
if (err.name === 'AbortError') {
|
|
console.log('[Agent] Generation aborted by user.');
|
|
} else {
|
|
console.error('[Agent] Stream reading error:', err);
|
|
this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` });
|
|
}
|
|
}
|
|
|
|
// Final buffer processing
|
|
if (buffer.trim() && buffer.trim() !== 'data: [DONE]') {
|
|
try {
|
|
const trimmed = buffer.trim();
|
|
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
|
|
const json = JSON.parse(raw);
|
|
const token = isLMStudio ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
|
if (token) {
|
|
aiResponseText += token;
|
|
this.webview?.postMessage({ type: 'streamChunk', value: token });
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
if (loopDepth === 0) this.webview.postMessage({ type: 'streamEnd' });
|
|
this.chatHistory.push({ role: 'assistant', content: aiResponseText });
|
|
|
|
// 5. Execute Actions
|
|
const report = await this.executeActions(aiResponseText, rootPath);
|
|
|
|
if (report.length > 0) {
|
|
const reportMsg = `\n\n> ⚙️ **System Action Report** (${loopDepth + 1}/${config.maxAutoSteps})\n> ${report.join("\n> ")}\n\n`;
|
|
this.webview.postMessage({ type: 'streamChunk', value: reportMsg });
|
|
|
|
// Continue loop if needed
|
|
if (loopDepth < config.maxAutoSteps) {
|
|
const currentActionStr = report.join('|');
|
|
const lastActionStr = this.context.workspaceState.get<string>('lastActionStr');
|
|
|
|
if (currentActionStr === lastActionStr) {
|
|
this.webview.postMessage({ type: 'streamChunk', value: "\n⚠️ *Stopping to prevent infinite loop.*" });
|
|
if (loopDepth === 0) this.webview.postMessage({ type: 'streamEnd' });
|
|
return;
|
|
}
|
|
|
|
await this.context.workspaceState.update('lastActionStr', currentActionStr);
|
|
|
|
// Explicitly tell the AI to look at the results and continue
|
|
const continuationPrompt = "I have executed your actions. Above is the result. Please analyze it and provide the next step or the final answer.";
|
|
|
|
this.webview.postMessage({ type: 'autoContinue', value: `Thinking... (${loopDepth + 1}/${config.maxAutoSteps})` });
|
|
await new Promise(r => setTimeout(r, 800));
|
|
await this.handlePrompt(continuationPrompt, modelName, { ...options, loopDepth: loopDepth + 1 });
|
|
}
|
|
}
|
|
|
|
} catch (error: any) {
|
|
this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` });
|
|
}
|
|
}
|
|
|
|
private async executeActions(aiMessage: string, rootPath: string): Promise<string[]> {
|
|
const report: string[] = [];
|
|
let brainModified = false;
|
|
let firstCreatedFile: string | undefined;
|
|
|
|
// Action 1: Create File
|
|
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi;
|
|
let match;
|
|
while ((match = createRegex.exec(aiMessage)) !== null) {
|
|
const relPath = match[1].trim();
|
|
const content = match[2].trim();
|
|
try {
|
|
const absPath = validatePath(rootPath, relPath);
|
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
fs.writeFileSync(absPath, content, 'utf-8');
|
|
report.push(`✅ Created: ${relPath}`);
|
|
if (!firstCreatedFile) firstCreatedFile = absPath;
|
|
if (absPath.startsWith(_getBrainDir())) brainModified = true;
|
|
} catch (err: any) { report.push(`❌ Error Creating ${relPath}: ${err.message}`); }
|
|
}
|
|
|
|
// Action 2: Edit File
|
|
const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi;
|
|
while ((match = editRegex.exec(aiMessage)) !== null) {
|
|
const relPath = match[1].trim();
|
|
const editContent = match[2].trim();
|
|
try {
|
|
const absPath = validatePath(rootPath, relPath);
|
|
if (fs.existsSync(absPath)) {
|
|
let currentContent = fs.readFileSync(absPath, 'utf-8');
|
|
const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
|
|
if (searchMatch) {
|
|
const searchStr = searchMatch[1];
|
|
const replaceStr = searchMatch[2];
|
|
if (currentContent.includes(searchStr)) {
|
|
currentContent = currentContent.replace(searchStr, replaceStr);
|
|
fs.writeFileSync(absPath, currentContent, 'utf-8');
|
|
report.push(`📝 Updated: ${relPath}`);
|
|
} else {
|
|
report.push(`⚠️ Search string not found in ${relPath}`);
|
|
}
|
|
} else {
|
|
fs.writeFileSync(absPath, editContent, 'utf-8');
|
|
report.push(`📝 Updated (Full): ${relPath}`);
|
|
}
|
|
if (absPath.startsWith(_getBrainDir())) brainModified = true;
|
|
} else {
|
|
report.push(`❌ File not found: ${relPath}`);
|
|
}
|
|
} catch (err: any) { report.push(`❌ Error Editing ${relPath}: ${err.message}`); }
|
|
}
|
|
|
|
// Action 3: Delete File
|
|
const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi;
|
|
while ((match = deleteRegex.exec(aiMessage)) !== null) {
|
|
const relPath = match[1].trim();
|
|
try {
|
|
const absPath = validatePath(rootPath, relPath);
|
|
if (fs.existsSync(absPath)) {
|
|
fs.unlinkSync(absPath);
|
|
report.push(`🗑 Deleted: ${relPath}`);
|
|
} else {
|
|
report.push(`⚠️ Delete failed: ${relPath} not found`);
|
|
}
|
|
} catch (err: any) { report.push(`❌ Error Deleting ${relPath}: ${err.message}`); }
|
|
}
|
|
|
|
// Action 4: Read File
|
|
const readRegex = /<read_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/read_file>)?/gi;
|
|
while ((match = readRegex.exec(aiMessage)) !== null) {
|
|
const relPath = match[1].trim();
|
|
try {
|
|
const absPath = validatePath(rootPath, relPath);
|
|
if (fs.existsSync(absPath)) {
|
|
const content = fs.readFileSync(absPath, 'utf-8');
|
|
const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content;
|
|
report.push(`📖 Read: ${relPath}`);
|
|
this.chatHistory.push({ role: 'user', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\`` });
|
|
} else {
|
|
report.push(`❌ Read failed: ${relPath} not found`);
|
|
}
|
|
} catch (err: any) { report.push(`❌ Error Reading ${relPath}: ${err.message}`); }
|
|
}
|
|
|
|
// Action 5: Run Command
|
|
const cmdRegex = /<run_command>([\s\S]*?)<\/run_command>/gi;
|
|
while ((match = cmdRegex.exec(aiMessage)) !== null) {
|
|
const cmd = match[1].trim();
|
|
try {
|
|
const safeCmd = sanitizeCommand(cmd);
|
|
const terminal = vscode.window.terminals.find(t => t.name === 'G1nation Terminal') || vscode.window.createTerminal({ name: 'G1nation Terminal', cwd: rootPath });
|
|
terminal.show();
|
|
terminal.sendText(safeCmd);
|
|
report.push(`🚀 Executed: ${safeCmd}`);
|
|
} catch (err: any) { report.push(`❌ Blocked: ${err.message}`); }
|
|
}
|
|
|
|
// Action 6: List Files
|
|
const listRegex = /<list_files\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/list_files>)?/gi;
|
|
while ((match = listRegex.exec(aiMessage)) !== null) {
|
|
const relPath = match[1].trim() || '.';
|
|
try {
|
|
const absPath = validatePath(rootPath, relPath);
|
|
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
|
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
|
let listing = entries
|
|
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
|
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
|
.join('\n');
|
|
|
|
if (listing.length > 5000) {
|
|
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
|
}
|
|
|
|
report.push(`📂 Listed: ${relPath}`);
|
|
this.chatHistory.push({ role: 'user', content: `[Result of list_files ${relPath}]\n${listing}` });
|
|
}
|
|
} catch (err: any) { report.push(`❌ Listing failed: ${err.message}`); }
|
|
}
|
|
|
|
// Action 7: Second Brain Knowledge (List/Read)
|
|
const listBrainRegex = /<list_brain\s*path=['"]?([^'"]*)['"]?\s*\/?>(?:<\/list_brain>)?/gi;
|
|
while ((match = listBrainRegex.exec(aiMessage)) !== null) {
|
|
const relPath = match[1].trim() || '.';
|
|
try {
|
|
const brainDir = _getBrainDir();
|
|
const absPath = path.join(brainDir, relPath);
|
|
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
|
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
|
let listing = entries
|
|
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
|
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
|
.join('\n');
|
|
|
|
if (listing.length > 5000) {
|
|
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
|
}
|
|
|
|
report.push(`🧠 Brain Listed: ${relPath}`);
|
|
this.chatHistory.push({ role: 'user', content: `[Result of list_brain ${relPath}]\n${listing}` });
|
|
} else {
|
|
report.push(`❌ Brain List failed: ${relPath} not found`);
|
|
}
|
|
} catch (err: any) { report.push(`❌ Error Listing Brain: ${err.message}`); }
|
|
}
|
|
|
|
const brainRegex = /<read_brain>([\s\S]*?)<\/read_brain>/gi;
|
|
while ((match = brainRegex.exec(aiMessage)) !== null) {
|
|
const fileName = match[1].trim();
|
|
try {
|
|
const brainDir = _getBrainDir();
|
|
const files = findBrainFiles(brainDir);
|
|
// Look for direct match or path match
|
|
const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName));
|
|
|
|
if (targetFile && fs.existsSync(targetFile)) {
|
|
const content = fs.readFileSync(targetFile, 'utf-8');
|
|
report.push(`🧠 Brain Read: ${fileName}`);
|
|
this.chatHistory.push({ role: 'user', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\`` });
|
|
} else {
|
|
report.push(`❌ Brain Read failed: ${fileName} not found in Second Brain`);
|
|
}
|
|
} catch (err: any) { report.push(`❌ Error Reading Brain: ${err.message}`); }
|
|
}
|
|
|
|
// Action 8: Read URL (Simple implementation)
|
|
const urlRegex = /<read_url>([\s\S]*?)<\/read_url>/gi;
|
|
while ((match = urlRegex.exec(aiMessage)) !== null) {
|
|
const url = match[1].trim();
|
|
try {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
const text = await res.text();
|
|
// Simple HTML to text-ish conversion
|
|
const content = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
const preview = content.length > 5000 ? content.slice(0, 5000) + "\n... (truncated)" : content;
|
|
report.push(`🌐 Read URL: ${url}`);
|
|
this.chatHistory.push({ role: 'user', content: `[Result of read_url ${url}]\n${preview}` });
|
|
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
|
|
}
|
|
|
|
if (firstCreatedFile) {
|
|
vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { preview: false });
|
|
}
|
|
|
|
// Brain Sync Logic
|
|
if (brainModified && shouldAutoPushBrain() && getSecondBrainRepo()) {
|
|
this.syncBrain();
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
private syncBrain() {
|
|
try {
|
|
const brainDir = _getBrainDir();
|
|
const { execSync } = require('child_process');
|
|
execSync(`git add .`, { cwd: brainDir });
|
|
execSync(`git commit -m "[G1nation] Knowledge Update"`, { cwd: brainDir });
|
|
execSync(`git push`, { cwd: brainDir });
|
|
} catch (err) {
|
|
console.error('[Agent] Sync failed:', err);
|
|
}
|
|
}
|
|
}
|