micro-models-agent 0.4.0 → 0.5.0

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.
@@ -1,466 +1,85 @@
1
- import { generateSubagentToolCallId } from './id-gen.js';
2
- import { parseToolCalls } from './parse-tool-call.js';
3
- import { LMStudioNative } from '../llm/lmstudio-native.js';
4
- import { OpenAICompat } from '../llm/openai-compat.js';
5
- import { ContextManager } from '../agent/context.js';
6
- import { buildSystemPrompt } from '../agent/system-prompt.js';
7
- import { checkFileSyntax } from './syntax-check.js';
8
- import { logInfo, logDebug, logWarn, logLLMRequest, logLLMResponse, logToolCall } from '../logger.js';
9
- import { getLLMConfig } from '../config/config.js';
10
- import { readdirSync, readFileSync, existsSync } from 'node:fs';
11
- import { join } from 'node:path';
1
+ import { generateSubagentToolCallId } from "./id-gen.js";
2
+ import { parseToolCalls } from "./parse-tool-call.js";
3
+ import { LMStudioNative } from "../llm/lmstudio-native.js";
4
+ import { ContextManager } from "../agent/context.js";
5
+ import { buildSystemPrompt } from "../agent/system-prompt.js";
6
+ import { checkFileSyntax } from "./syntax-check.js";
7
+ import { logInfo, logDebug, logWarn, logToolCall, logToolOutput, } from "../logger.js";
8
+ import { getLLMConfig } from "../config/config.js";
9
+ import { validateToolCall } from "../subagent/validation.js";
10
+ export { validateToolCall };
12
11
  // ─── Context builder ──────────────────────────────────────
13
- /**
14
- * Собирает контекст для субагента: список файлов, зависимости, tsconfig.
15
- * Сканирует все директории проекта (не только src/).
16
- *
17
- * @param cwd - корневая директория проекта
18
- * @returns Текстовый контекст для system prompt субагента
19
- */
20
- export function buildSubagentContext(cwd) {
21
- const lines = [];
22
- // Scan ALL directories for existing source files (not just src/)
23
- try {
24
- const entries = readdirSync(cwd, { withFileTypes: true });
25
- for (const entry of entries) {
26
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules')
27
- continue;
28
- const dirPath = join(cwd, entry.name);
29
- try {
30
- const files = readdirSync(dirPath, { recursive: true })
31
- .filter((f) => /\.(ts|tsx|js|jsx)$/.test(f))
32
- .slice(0, 20);
33
- if (files.length > 0) {
34
- lines.push(`Files in ${entry.name}/: ${files.join(', ')}`);
35
- }
36
- }
37
- catch { /* skip unreadable dirs */ }
38
- }
39
- }
40
- catch { /* no entries */ }
41
- // Include root-level .ts/.js files too
42
- try {
43
- const rootFiles = readdirSync(cwd)
44
- .filter((f) => /\.(ts|js)$/.test(f) && !f.startsWith('.'));
45
- if (rootFiles.length > 0) {
46
- lines.push('Root files: ' + rootFiles.join(', '));
47
- }
48
- }
49
- catch { /* skip */ }
50
- try {
51
- const pkgPath = join(cwd, 'package.json');
52
- if (existsSync(pkgPath)) {
53
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
54
- const deps = Object.keys(pkg.dependencies || {});
55
- const devDeps = Object.keys(pkg.devDependencies || {});
56
- if (deps.length > 0)
57
- lines.push('Dependencies: ' + deps.join(', '));
58
- if (devDeps.length > 0)
59
- lines.push('DevDependencies: ' + devDeps.join(', '));
60
- lines.push('Module type: ' + (pkg.type || 'commonjs'));
61
- }
62
- }
63
- catch { /* no package.json */ }
64
- try {
65
- const tsconfigPath = join(cwd, 'tsconfig.json');
66
- if (existsSync(tsconfigPath)) {
67
- lines.push('Has tsconfig.json');
68
- }
69
- }
70
- catch { /* no tsconfig */ }
71
- return lines.join('\n');
72
- }
73
- /**
74
- * Контекст для specialist'а — фокусированное описание ошибки.
75
- *
76
- * @param toolName - имя упавшего инструмента
77
- * @param toolArgs - аргументы которые были переданы
78
- * @param errorOutput - текст ошибки
79
- * @param userTask - исходный запрос пользователя (краткое описание)
80
- * @param attemptCount - сколько раз ошибка повторилась
81
- * @returns Текст задачи для specialist'а
82
- */
83
- export function buildSpecialistTask(toolName, toolArgs, errorOutput, userTask, attemptCount) {
84
- const argsStr = JSON.stringify(toolArgs, null, 2);
85
- // Специфичные подсказки для web_browse evaluate
86
- let webHints = '';
87
- if (toolName === 'web_browse' && String(toolArgs?.action) === 'evaluate') {
88
- const code = String(toolArgs?.value ?? toolArgs?.code ?? '');
89
- webHints = [
90
- '',
91
- 'web_browse evaluate: "value" is JS code executed in browser context.',
92
- '- Expressions work: document.title, document.querySelectorAll(".item").length',
93
- '- return statements work: return document.title',
94
- '- Multi-line with return: return Array.from(document.querySelectorAll("a")).map(a => a.href)',
95
- `- Current code that failed: ${code.slice(0, 300)}`,
96
- ].join('\n');
97
- }
98
- return [
99
- `The tool "${toolName}" has failed ${attemptCount} times with the same error while working on: "${userTask.slice(0, 200)}"`,
100
- '',
101
- `Failed tool call:`,
102
- ` Tool: ${toolName}`,
103
- ` Arguments: ${argsStr}`,
104
- '',
105
- `Error output:`,
106
- ` ${errorOutput.slice(0, 500)}`,
107
- webHints,
108
- '',
109
- `Your task: Diagnose WHY this tool call fails and FIX the underlying problem.`,
110
- ` - Read the relevant files to understand the actual state`,
111
- ` - Fix the root cause (wrong path, missing file, syntax error, etc.)`,
112
- ` - If the tool call arguments are wrong, correct them`,
113
- ` - If the file doesn't exist, create it`,
114
- ` - Return a clear summary of what you found and what you fixed`,
115
- ].join('\n');
116
- }
117
- /** Паттерны ошибок которые НЕ триггерят specialist (trivial) */
118
- const TRIVIAL_ERROR_PATTERNS = [
119
- /no results? found/i,
120
- /pattern .* matched 0 results/i,
121
- /0 files? matched/i,
122
- /command not found.*grep/i,
123
- /not a directory/i,
124
- ];
125
- /** Паттерны ошибок которые ВСЕГДА требуют specialist (не trivial) */
126
- const NON_TRIVIAL_ERROR_PATTERNS = [
127
- /unexpected token/i,
128
- /syntaxerror/i,
129
- /cannot find module/i,
130
- /module not found/i,
131
- /permission denied/i,
132
- /enoent/i,
133
- /eacces/i,
134
- ];
135
- /**
136
- * Проверяет является ли ошибка тривиальной (не требует specialist).
137
- */
138
- export function isTrivialError(toolName, errorOutput) {
139
- // Синтаксические и критические ошибки — ВСЕГДА не trivial
140
- if (NON_TRIVIAL_ERROR_PATTERNS.some(p => p.test(errorOutput))) {
141
- return false;
142
- }
143
- // grep_search/glob_search с 0 результатами — нормальное поведение
144
- if (toolName === 'grep_search' || toolName === 'glob_search') {
145
- if (/0 results|0 files|no match/i.test(errorOutput) && errorOutput.length < 200) {
146
- return true;
147
- }
148
- }
149
- // Любая ошибка < 10 символов — скорее всего trivia
150
- if (errorOutput.length < 10)
151
- return true;
152
- return TRIVIAL_ERROR_PATTERNS.some(p => p.test(errorOutput));
153
- }
12
+ import { buildSubagentContext } from "../subagent/context.js";
13
+ export { buildSubagentContext };
14
+ import { buildSpecialistTask, isTrivialError } from "../subagent/errors.js";
15
+ export { buildSpecialistTask, isTrivialError };
154
16
  // ─── Tool call parsing ───────────────────────────────────
155
- /**
156
- * Валидация tool call: проверка имени инструмента и обязательных аргументов.
157
- *
158
- * @param parsed - распарсенный JSON из tool call
159
- * @returns Результат валидации с именем инструмента и командой (для bash)
160
- */
161
- export function validateToolCall(parsed) {
162
- if (!parsed || typeof parsed !== 'object') {
163
- return { valid: false, error: 'Not an object' };
164
- }
165
- const VALID_TOOLS = new Set(['bash', 'plan', 'todo', 'question', 'approve', 'theme', 'user_profile', 'subagent', 'read_file', 'write_file', 'edit_file', 'list_dir', 'create_dir', 'delete_file', 'copy_file', 'move_file', 'file_info', 'glob_search', 'grep_search', 'tree', 'web_search', 'web_fetch', 'web_browse', 'scaffold', 'project_map']);
166
- if (!parsed.name || !VALID_TOOLS.has(parsed.name)) {
167
- return { valid: false, error: `Unknown tool: ${parsed.name}` };
168
- }
169
- if (!parsed.arguments || typeof parsed.arguments !== 'object') {
170
- return { valid: false, error: 'Missing arguments' };
171
- }
172
- if (parsed.name === 'bash') {
173
- const cmd = String(parsed.arguments.cmd ?? '').trim();
174
- if (!cmd) {
175
- return { valid: false, error: 'Empty command' };
176
- }
177
- return { valid: true, name: parsed.name, cmd };
178
- }
179
- const REQUIRED_ARGS = {
180
- read_file: ['path'],
181
- write_file: ['path', 'content'],
182
- edit_file: ['path', 'old_string', 'new_string'],
183
- list_dir: [],
184
- create_dir: ['path'],
185
- delete_file: ['path'],
186
- copy_file: ['source', 'destination'],
187
- move_file: ['source', 'destination'],
188
- file_info: ['path'],
189
- glob_search: ['pattern'],
190
- grep_search: ['pattern'],
191
- tree: [],
192
- web_search: ['query'],
193
- web_fetch: ['url'],
194
- user_profile: [],
195
- question: ['question'],
17
+ // validateToolCall moved to src/subagent/validation.ts
18
+ import { doChat } from "../subagent/chat.js";
19
+ export { doChat };
20
+ // ─── Tool execution helper ────────────────────────────────
21
+ async function executeToolCall(call, toolExecutor, cwd, createdFiles, errors, step) {
22
+ const { parsed, cmd } = call;
23
+ const isBash = parsed.name === "bash";
24
+ const toolId = generateSubagentToolCallId();
25
+ if (parsed.name === "subagent") {
26
+ logWarn("SUBAGENT", `nested subagent blocked at step ${step}`);
27
+ return {
28
+ output: "Subagents cannot spawn subagents (max depth 1)",
29
+ exitCode: 1,
30
+ };
31
+ }
32
+ const preview = isBash
33
+ ? String(cmd).slice(0, 80)
34
+ : parsed.name === "write_file" || parsed.name === "edit_file"
35
+ ? String(parsed.arguments.path ?? "").slice(-60)
36
+ : JSON.stringify(parsed.arguments).slice(0, 80);
37
+ logToolCall(`sub:${parsed.name}`, preview);
38
+ const hookCmd = isBash
39
+ ? String(cmd)
40
+ : `${parsed.name} ${JSON.stringify(parsed.arguments)}`;
41
+ const execResult = toolExecutor
42
+ ? await toolExecutor.execute({ id: toolId, name: parsed.name, arguments: parsed.arguments }, { cwd, quiet: false }, hookCmd)
43
+ : null;
44
+ const result = execResult?.result ?? {
45
+ stdout: "",
46
+ stderr: "ToolExecutor not available",
47
+ exitCode: 1,
48
+ truncated: false,
196
49
  };
197
- const required = REQUIRED_ARGS[parsed.name] ?? [];
198
- const missing = required.filter(k => {
199
- const v = parsed.arguments[k];
200
- return v === undefined || v === null || v === '';
201
- });
202
- if (missing.length > 0) {
203
- return { valid: false, error: `Missing required argument(s): ${missing.join(', ')}. Tool "${parsed.name}" requires: ${required.join(', ')}` };
204
- }
205
- return { valid: true, name: parsed.name };
206
- }
207
- // ─── LLM call ───────────────────────────────────────────
208
- /**
209
- * Отправка запроса к LLM с streaming-ответом.
210
- * Поддерживает retry при ошибках и отображение "мышления" модели.
211
- *
212
- * @param provider - провайдер LLM
213
- * @param messages - история сообщений
214
- * @param renderer - рендерер для отображения streaming
215
- * @param signal - сигнал прерывания
216
- * @param retries - количество повторных попыток
217
- * @param showThinking - показывать ли "мышление" модели
218
- * @param caller - кто вызвал (main/subagent)
219
- */
220
- export async function doChat(provider, messages, renderer, signal, retries = 1, showThinking = false, caller = 'main', maxTokens) {
221
- let lastReasoningText = '';
222
- // Phase 1: Normal attempts with thinking enabled
223
- for (let attempt = 0; attempt <= retries; attempt++) {
224
- const startTime = performance.now();
225
- const fullText = [];
226
- let started = false;
227
- let reasoningBuf = false;
228
- let currentReasoning = ''; // накопленный reasoning текст для этого attempt
229
- let lastFinishReason = null;
230
- let reasoningChunksCount = 0;
231
- let contentChunksCount = 0;
232
- if (signal?.aborted)
233
- return { response: '', genTimeMs: 0 };
234
- const lastMsg = messages[messages.length - 1];
235
- logLLMRequest(provider.model, messages.length, lastMsg?.content || '', caller);
236
- if (caller === 'main') {
237
- renderer.setStreamRole?.('assistant');
238
- }
239
- renderer.writeSpinner();
240
- try {
241
- let buf = '';
242
- for await (const chunk of provider.chat(messages, { temperature: 0.7, signal, showThinking, enableThinking: true, max_tokens: maxTokens })) {
243
- if (!chunk.content && !chunk.reasoning && !chunk.finish_reason)
244
- continue;
245
- if (chunk.finish_reason) {
246
- lastFinishReason = chunk.finish_reason;
247
- }
248
- if (chunk.content) {
249
- fullText.push(chunk.content);
250
- contentChunksCount++;
251
- }
252
- if (chunk.reasoning) {
253
- reasoningChunksCount++;
254
- currentReasoning += chunk.reasoning;
255
- }
256
- if (!chunk.content && !chunk.reasoning)
257
- continue;
258
- if (!started) {
259
- renderer.clearSpinner();
260
- started = true;
261
- }
262
- if (chunk.reasoning) {
263
- if (showThinking) {
264
- if (reasoningBuf === false) {
265
- renderer.writeNewline();
266
- renderer.writeDim('┈ reasoning ┈');
267
- reasoningBuf = '';
268
- }
269
- reasoningBuf += chunk.reasoning;
270
- }
271
- continue;
272
- }
273
- if (reasoningBuf !== false && typeof reasoningBuf === 'string') {
274
- renderer.writeNewline();
275
- renderer.writeDim(reasoningBuf);
276
- reasoningBuf = false;
277
- }
278
- buf += chunk.content;
279
- while (buf.length > 0) {
280
- const tcStart = buf.indexOf('<tool_call>');
281
- const tcEnd = buf.indexOf('</tool_call>');
282
- if (tcStart === -1 && tcEnd === -1) {
283
- const clean = buf.replace(/@grounding\[файл:\s*[^\]]*\]\n?/g, '');
284
- if (clean)
285
- renderer.writeMarkdownChunk(clean);
286
- buf = '';
287
- break;
288
- }
289
- if (tcStart !== -1 && (tcEnd === -1 || tcStart < tcEnd)) {
290
- if (tcStart > 0) {
291
- const clean = buf.slice(0, tcStart).replace(/@grounding\[файл:\s*[^\]]*\]\n?/g, '');
292
- if (clean)
293
- renderer.writeMarkdownChunk(clean);
294
- }
295
- buf = buf.slice(tcStart);
296
- const closeIdx = buf.indexOf('</tool_call>');
297
- if (closeIdx !== -1) {
298
- buf = buf.slice(closeIdx + '</tool_call>'.length);
299
- }
300
- break;
301
- }
302
- if (tcEnd !== -1 && (tcStart === -1 || tcEnd < tcStart)) {
303
- const clean = buf.slice(0, tcEnd).replace(/@grounding\[файл:\s*[^\]]*\]\n?/g, '');
304
- if (clean)
305
- renderer.writeMarkdownChunk(clean);
306
- buf = buf.slice(tcEnd + '</tool_call>'.length);
307
- }
308
- }
309
- }
310
- if (reasoningBuf !== false && typeof reasoningBuf === 'string') {
311
- renderer.writeNewline();
312
- renderer.writeDim(reasoningBuf);
313
- }
314
- // Сохраняем reasoning для fallback
315
- if (currentReasoning) {
316
- lastReasoningText = currentReasoning;
317
- }
318
- }
319
- catch (e) {
320
- if (e.name === 'AbortError') {
321
- logLLMResponse(provider.model, 0, performance.now() - startTime, 'Aborted', caller);
322
- return { response: '', genTimeMs: 0 };
323
- }
324
- logLLMResponse(provider.model, 0, performance.now() - startTime, e.message, caller);
325
- renderer.writeError(`\n[LLM] ${e.message}`);
326
- // Сетевые ошибки (fetch failed, ECONNREFUSED и т.д.) — retry с задержкой
327
- const isNetworkError = !e.message?.includes('API ') && !e.message?.includes('status');
328
- if (isNetworkError && attempt < retries) {
329
- const delay = Math.min(2000 * (attempt + 1), 10000);
330
- renderer.writeWarning(`[LLM] Network error, retrying in ${delay / 1000}s... (attempt ${attempt + 1}/${retries})`);
331
- await new Promise(r => setTimeout(r, delay));
332
- continue;
333
- }
334
- const providerHint = provider instanceof LMStudioNative
335
- ? 'Check that LM Studio is running on localhost:1234'
336
- : provider instanceof OpenAICompat
337
- ? 'Check that your API server is running and the API key is correct'
338
- : 'Check that your LLM server is running';
339
- renderer.writeWarning(`[LLM] ${providerHint}`);
340
- return { response: '', genTimeMs: 0 };
341
- }
342
- const genTimeMs = performance.now() - startTime;
343
- const response = fullText.join('');
344
- renderer.commitMarkdown();
345
- if (response.trim()) {
346
- logLLMResponse(provider.model, response.length, genTimeMs, undefined, caller);
347
- return { response, genTimeMs };
348
- }
349
- // Диагностика пустого ответа
350
- const reason = lastFinishReason ?? 'unknown';
351
- logWarn('DOCHAT', `empty response #${attempt + 1}: finish_reason=${reason}, reasoning_chunks=${reasoningChunksCount}, content_chunks=${contentChunksCount}`);
352
- if (lastFinishReason === 'length') {
353
- renderer.writeWarning(` [empty response: model hit max_tokens during reasoning (${reasoningChunksCount} reasoning chunks, 0 content chunks)]`);
354
- }
355
- else if (reasoningChunksCount > 0 && contentChunksCount === 0) {
356
- renderer.writeWarning(` [empty response: model produced ${reasoningChunksCount} reasoning chunks but no content]`);
357
- }
358
- if (attempt < retries) {
359
- renderer.writeDim(`[retry ${attempt + 1}/${retries}]`);
360
- }
361
- }
362
- const llmCfg = getLLMConfig();
363
- // Phase 2: Retry WITHOUT thinking — модель генерирует content напрямую
364
- if (llmCfg.retryWithoutThinking && lastReasoningText.trim()) {
365
- logInfo('DOCHAT', `retrying without thinking (had ${lastReasoningText.length} reasoning chars)`);
366
- renderer.writeDim(`[retry without thinking]`);
367
- const retryResult = await doChatOnce(provider, messages, renderer, signal, false, showThinking, caller, maxTokens);
368
- if (retryResult.response.trim()) {
369
- logInfo('DOCHAT', `retry without thinking succeeded: ${retryResult.response.length} chars`);
370
- return retryResult;
371
- }
372
- }
373
- // Phase 2.5: Emergency — принудительно генерируем content с низкой температурой
374
- // Когда reasoning есть, но content пустой — модель "застряла" в thinking loop
375
- if (lastReasoningText.trim()) {
376
- logInfo('DOCHAT', `emergency: forcing content with low temperature`);
377
- renderer.writeDim(`[emergency: forcing content with temperature=0.1]`);
378
- const emergencyMessages = [
379
- ...messages,
380
- { role: 'user', content: '[System] You MUST produce a response now. Do NOT use thinking tags. Reply with a tool call or a direct answer. Any response is better than empty.' },
381
- ];
382
- const emergencyResult = await doChatOnce(provider, emergencyMessages, renderer, signal, false, showThinking, caller, maxTokens, 0.1);
383
- if (emergencyResult.response.trim()) {
384
- logInfo('DOCHAT', `emergency succeeded: ${emergencyResult.response.length} chars`);
385
- return emergencyResult;
386
- }
387
- }
388
- // Phase 3: Fallback — используем reasoning как content
389
- if (llmCfg.fallbackToReasoning && lastReasoningText.trim()) {
390
- logInfo('DOCHAT', `fallback: using reasoning as content (${lastReasoningText.length} chars)`);
391
- renderer.writeWarning(` [fallback: using reasoning as content]`);
392
- return { response: lastReasoningText, genTimeMs: 0 };
393
- }
394
- return { response: '', genTimeMs: 0 };
395
- }
396
- /**
397
- * Одиночный вызов LLM без retry логики.
398
- * Используется для retry с другими параметрами (например, enableThinking: false).
399
- */
400
- async function doChatOnce(provider, messages, renderer, signal, enableThinking = true, showThinking = false, caller = 'main', maxTokens, temperature = 0.7) {
401
- const startTime = performance.now();
402
- const fullText = [];
403
- let started = false;
404
- if (signal?.aborted)
405
- return { response: '', genTimeMs: 0 };
406
- if (caller === 'main') {
407
- renderer.setStreamRole?.('assistant');
408
- }
409
- renderer.writeSpinner();
410
- try {
411
- let buf = '';
412
- for await (const chunk of provider.chat(messages, { temperature, signal, showThinking, enableThinking, max_tokens: maxTokens })) {
413
- if (!chunk.content && !chunk.finish_reason)
414
- continue;
415
- if (chunk.content) {
416
- fullText.push(chunk.content);
50
+ const output = result.stdout || result.stderr || "(no output)";
51
+ const exitCode = result.exitCode;
52
+ logToolOutput(`sub:${parsed.name}`, output, exitCode);
53
+ if (exitCode !== 0 &&
54
+ isBash &&
55
+ /pwsh|powershell|not recognized|not found/i.test(output)) {
56
+ return {
57
+ output: output +
58
+ "\n\n[HINT] Shell unavailable on this system. Use write_file tool to create files manually. Do NOT retry bash commands.",
59
+ exitCode,
60
+ };
61
+ }
62
+ if (exitCode === 0 &&
63
+ (parsed.name === "write_file" || parsed.name === "edit_file")) {
64
+ const filePath = String(parsed.arguments.path ?? "");
65
+ if (filePath && !createdFiles.includes(filePath)) {
66
+ createdFiles.push(filePath);
67
+ logInfo("SUBAGENT", `created: ${filePath}`);
68
+ }
69
+ if (filePath && /\.(ts|tsx|js|jsx|json)$/.test(filePath)) {
70
+ const { isAbsolute, relative } = await import("node:path");
71
+ const relPath = isAbsolute(filePath)
72
+ ? relative(cwd, filePath).replace(/\\/g, "/")
73
+ : filePath;
74
+ const syntaxResult = checkFileSyntax(relPath, cwd);
75
+ if (syntaxResult !== "OK") {
76
+ errors.push(`${filePath}: ${syntaxResult.split("\n")[0]}`);
77
+ logWarn("SUBAGENT", `syntax error in ${filePath}: ${syntaxResult.split("\n")[0]}`);
78
+ return { output: output + `\nSYNTAX ERROR: ${syntaxResult}`, exitCode };
417
79
  }
418
- if (!chunk.content)
419
- continue;
420
- if (!started) {
421
- renderer.clearSpinner();
422
- started = true;
423
- }
424
- buf += chunk.content;
425
- while (buf.length > 0) {
426
- const tcStart = buf.indexOf('<tool_call>');
427
- const tcEnd = buf.indexOf('</tool_call>');
428
- if (tcStart === -1 && tcEnd === -1) {
429
- if (buf)
430
- renderer.writeMarkdownChunk(buf);
431
- buf = '';
432
- break;
433
- }
434
- if (tcStart !== -1 && (tcEnd === -1 || tcStart < tcEnd)) {
435
- if (tcStart > 0) {
436
- renderer.writeMarkdownChunk(buf.slice(0, tcStart));
437
- }
438
- buf = buf.slice(tcStart);
439
- const closeIdx = buf.indexOf('</tool_call>');
440
- if (closeIdx !== -1) {
441
- buf = buf.slice(closeIdx + '</tool_call>'.length);
442
- }
443
- break;
444
- }
445
- if (tcEnd !== -1 && (tcStart === -1 || tcEnd < tcStart)) {
446
- renderer.writeMarkdownChunk(buf.slice(0, tcEnd));
447
- buf = buf.slice(tcEnd + '</tool_call>'.length);
448
- }
449
- }
450
- }
451
- }
452
- catch (e) {
453
- if (e.name === 'AbortError') {
454
- return { response: '', genTimeMs: 0 };
455
80
  }
456
- renderer.writeError(`\n[LLM] ${e.message}`);
457
- return { response: '', genTimeMs: 0 };
458
81
  }
459
- const genTimeMs = performance.now() - startTime;
460
- const response = fullText.join('');
461
- renderer.commitMarkdown();
462
- logLLMResponse(provider.model, response.length, genTimeMs, undefined, caller);
463
- return { response, genTimeMs };
82
+ return { output, exitCode };
464
83
  }
465
84
  // ─── Isolated agent loop ──────────────────────────────────
466
85
  /**
@@ -483,24 +102,24 @@ export async function runIsolatedLoop(provider, registry, task, existingFiles, c
483
102
  let effectiveProvider = provider;
484
103
  if (toolModel && provider instanceof LMStudioNative) {
485
104
  effectiveProvider = new LMStudioNative(provider.baseUrl, toolModel, provider.contextWindow);
486
- logInfo('SUBAGENT', `using tool model: ${toolModel} (main: ${provider.model})`);
105
+ logInfo("SUBAGENT", `using tool model: ${toolModel} (main: ${provider.model})`);
487
106
  }
488
- logInfo('SUBAGENT', `START | task: "${task.slice(0, 200)}" | context window: ${effectiveProvider.contextWindow}`);
107
+ logInfo("SUBAGENT", `START | task: "${task.slice(0, 200)}" | context window: ${effectiveProvider.contextWindow}`);
489
108
  // Always build auto-context from project, even if model didn't pass existingFiles
490
109
  const autoContext = buildSubagentContext(cwd);
491
110
  const mergedContext = existingFiles
492
- ? existingFiles + '\n' + autoContext
111
+ ? existingFiles + "\n" + autoContext
493
112
  : autoContext;
494
113
  if (mergedContext) {
495
- logDebug('SUBAGENT', `context (${mergedContext.split('\n').length} lines):\n${mergedContext}`);
114
+ logDebug("SUBAGENT", `context (${mergedContext.split("\n").length} lines):\n${mergedContext}`);
496
115
  }
497
116
  const systemPrompt = buildSystemPrompt(undefined, (mode || "base"));
498
117
  const contextInfo = mergedContext
499
118
  ? `\n\nКонтекст проекта:\n${mergedContext}\nПосмотри package.json для списка пакетов. Используй read_file для чтения существующих файлов.`
500
- : '';
119
+ : "";
501
120
  const messages = [
502
- { role: 'system', content: systemPrompt },
503
- { role: 'user', content: task + contextInfo },
121
+ { role: "system", content: systemPrompt },
122
+ { role: "user", content: task + contextInfo },
504
123
  ];
505
124
  const ctx = new ContextManager(effectiveProvider.contextWindow);
506
125
  const maxSteps = maxStepsParam ?? 20;
@@ -508,127 +127,61 @@ export async function runIsolatedLoop(provider, registry, task, existingFiles, c
508
127
  for (let step = 0; step < maxSteps; step++) {
509
128
  await ctx.updateTokens(messages);
510
129
  if (step === 0 || step % 5 === 0) {
511
- logDebug('SUBAGENT', `step ${step} | ${messages.length} msgs | ${ctx.totalTokens} tokens (${ctx.usagePercent.toFixed(1)}%)`);
130
+ logDebug("SUBAGENT", `step ${step} | ${messages.length} msgs | ${ctx.totalTokens} tokens (${ctx.usagePercent.toFixed(1)}%)`);
512
131
  }
513
132
  if (ctx.needsCompact && messages.length > 6) {
514
- logInfo('SUBAGENT', `COMPACT at step ${step} | ${ctx.totalTokens} tokens (${ctx.usagePercent.toFixed(1)}%)`);
133
+ logInfo("SUBAGENT", `COMPACT at step ${step} | ${ctx.totalTokens} tokens (${ctx.usagePercent.toFixed(1)}%)`);
515
134
  messages.splice(0, messages.length, ...ctx.compact(messages));
516
135
  await ctx.updateTokens(messages);
517
- logInfo('SUBAGENT', `after compact | ${messages.length} msgs | ${ctx.totalTokens} tokens`);
136
+ logInfo("SUBAGENT", `after compact | ${messages.length} msgs | ${ctx.totalTokens} tokens`);
518
137
  }
519
138
  // После tool call увеличиваем max_tokens чтобы модель успела сгенерировать контент после reasoning
520
- const hasToolResult = messages.some(m => m.role === 'tool');
139
+ const hasToolResult = messages.some((m) => m.role === "tool");
521
140
  const llmCfg = getLLMConfig();
522
141
  const maxTokens = hasToolResult ? llmCfg.maxTokensAfterTool : undefined;
523
- const { response } = await doChat(effectiveProvider, messages, renderer, renderer?.abortSignal, 1, false, 'subagent', maxTokens);
142
+ const { response } = await doChat(effectiveProvider, messages, renderer, renderer?.abortSignal, 1, false, "subagent", maxTokens);
524
143
  if (!response.trim()) {
525
- logDebug('SUBAGENT', `empty response at step ${step}, stopping`);
144
+ logDebug("SUBAGENT", `empty response at step ${step}, stopping`);
526
145
  break;
527
146
  }
528
- messages.push({ role: 'assistant', content: response });
147
+ messages.push({ role: "assistant", content: response });
529
148
  const { validCalls } = parseToolCalls(response);
530
149
  if (validCalls.length === 0) {
531
- logDebug('SUBAGENT', `no valid tool calls at step ${step}, stopping`);
150
+ logDebug("SUBAGENT", `no valid tool calls at step ${step}, stopping`);
532
151
  break;
533
152
  }
534
153
  for (const call of validCalls) {
535
- const { parsed, cmd } = call;
536
- const isBash = parsed.name === 'bash';
537
- const toolId = generateSubagentToolCallId();
538
154
  toolCallCount++;
539
- let toolOutput = '';
540
- let toolExitCode = 0;
541
- if (parsed.name === 'subagent') {
542
- toolOutput = 'Subagents cannot spawn subagents (max depth 1)';
543
- toolExitCode = 1;
544
- logWarn('SUBAGENT', `nested subagent blocked at step ${step}`);
545
- }
546
- else if (!isBash) {
547
- const preview = parsed.name === 'write_file'
548
- ? String(parsed.arguments.path ?? '').slice(-60)
549
- : parsed.name === 'edit_file'
550
- ? String(parsed.arguments.path ?? '').slice(-60)
551
- : JSON.stringify(parsed.arguments).slice(0, 80);
552
- logToolCall(`sub:${parsed.name}`, preview);
553
- const hookCmd = isBash ? String(cmd) : `${parsed.name} ${JSON.stringify(parsed.arguments)}`;
554
- const execResult = toolExecutor ? await toolExecutor.execute({ id: toolId, name: parsed.name, arguments: parsed.arguments }, { cwd, quiet: false }, hookCmd) : null;
555
- const result = execResult?.result ?? { stdout: '', stderr: 'ToolExecutor not available', exitCode: 1, truncated: false };
556
- toolOutput = result.stdout || result.stderr || '(no output)';
557
- toolExitCode = result.exitCode;
558
- if (toolExitCode !== 0) {
559
- logDebug('SUBAGENT', `tool ${parsed.name} failed: exit ${toolExitCode} | ${toolOutput.slice(0, 200)}`);
560
- }
561
- if (toolExitCode === 0 && (parsed.name === 'write_file' || parsed.name === 'edit_file')) {
562
- const filePath = String(parsed.arguments.path ?? '');
563
- if (filePath && !createdFiles.includes(filePath)) {
564
- createdFiles.push(filePath);
565
- logInfo('SUBAGENT', `created: ${filePath}`);
566
- }
567
- }
568
- if (toolExitCode === 0 && (parsed.name === 'write_file' || parsed.name === 'edit_file')) {
569
- const filePath = String(parsed.arguments.path ?? '');
570
- if (filePath && /\.(ts|tsx|js|jsx|json)$/.test(filePath)) {
571
- const { isAbsolute, relative } = await import('node:path');
572
- const relPath = isAbsolute(filePath) ? relative(cwd, filePath).replace(/\\/g, '/') : filePath;
573
- const syntaxResult = checkFileSyntax(relPath, cwd);
574
- if (syntaxResult !== 'OK') {
575
- errors.push(`${filePath}: ${syntaxResult.split('\n')[0]}`);
576
- toolOutput += `\nSYNTAX ERROR: ${syntaxResult}`;
577
- logWarn('SUBAGENT', `syntax error in ${filePath}: ${syntaxResult.split('\n')[0]}`);
578
- }
579
- }
580
- }
581
- }
582
- else {
583
- logToolCall('sub:bash', String(cmd).slice(0, 80));
584
- const bashExecResult = toolExecutor ? await toolExecutor.execute({ id: toolId, name: 'bash', arguments: { cmd, timeout: parsed.arguments?.timeout ?? 30 } }, { cwd, quiet: false }, String(cmd)) : null;
585
- const result = bashExecResult?.result ?? { stdout: '', stderr: 'ToolExecutor not available', exitCode: 1, truncated: false };
586
- toolOutput = result.stdout || result.stderr || '(no output)';
587
- toolExitCode = result.exitCode;
588
- if (toolExitCode !== 0) {
589
- logDebug('SUBAGENT', `bash failed: exit ${toolExitCode} | ${toolOutput.slice(0, 200)}`);
590
- // Если bash падает из-за отсутствия shell —提示 модель использовать write_file
591
- if (/pwsh|powershell|not recognized|not found/i.test(toolOutput)) {
592
- toolOutput += '\n\n[HINT] Shell unavailable on this system. Use write_file tool to create files manually. Do NOT retry bash commands.';
593
- }
594
- }
595
- }
596
- messages.push({ role: 'tool', content: toolOutput.slice(0, 8000), tool_call_id: toolId });
597
- messages.push({ role: 'user', content: 'Continue.' });
155
+ const { output, exitCode } = await executeToolCall(call, toolExecutor, cwd, createdFiles, errors, step);
156
+ messages.push({
157
+ role: "tool",
158
+ content: output.slice(0, 8000),
159
+ tool_call_id: generateSubagentToolCallId(),
160
+ });
161
+ messages.push({ role: "user", content: "Continue." });
598
162
  }
599
163
  }
600
- const turns = messages.filter(m => m.role === 'assistant').length;
164
+ const turns = messages.filter((m) => m.role === "assistant").length;
601
165
  const elapsedMs = performance.now() - startTime;
602
166
  await ctx.updateTokens(messages);
603
167
  const finalTokens = ctx.totalTokens;
604
168
  const finalUsage = ctx.usagePercent;
605
- logInfo('SUBAGENT', `DONE | ${turns} turns | ${toolCallCount} tool calls | ${createdFiles.length} files created | ${errors.length} errors | ${finalTokens} tokens (${finalUsage.toFixed(1)}%) | ${(elapsedMs / 1000).toFixed(1)}s`);
169
+ logInfo("SUBAGENT", `DONE | ${turns} turns | ${toolCallCount} tool calls | ${createdFiles.length} files created | ${errors.length} errors | ${finalTokens} tokens (${finalUsage.toFixed(1)}%) | ${(elapsedMs / 1000).toFixed(1)}s`);
606
170
  // Собираем список использованных инструментов из истории
607
- const toolsUsed = [...new Set(messages
608
- .filter(m => m.role === 'assistant')
609
- .flatMap(m => {
171
+ const toolsUsed = [
172
+ ...new Set(messages
173
+ .filter((m) => m.role === "assistant")
174
+ .flatMap((m) => {
610
175
  const calls = m.content.matchAll(/<tool_call>\{"name":"([^"]+)"/g);
611
- return [...calls].map(c => c[1]);
612
- }))];
613
- const toolsStr = toolsUsed.length > 0 ? `\nTools used: ${toolsUsed.join(', ')}` : '';
176
+ return [...calls].map((c) => c[1]);
177
+ })),
178
+ ];
179
+ const toolsStr = toolsUsed.length > 0 ? `\nTools used: ${toolsUsed.join(", ")}` : "";
614
180
  const summary = createdFiles.length > 0
615
- ? `[Subagent done — ${createdFiles.length} file(s), ${turns} turns]\nFiles: ${createdFiles.join(', ')}${toolsStr}${errors.length > 0 ? `\nErrors: ${errors.join('; ')}` : ''}`
616
- : `[Subagent done — no files, ${turns} turns]${toolsStr}${errors.length > 0 ? `\nErrors: ${errors.join('; ')}` : ''}`;
181
+ ? `[Subagent done — ${createdFiles.length} file(s), ${turns} turns]\nFiles: ${createdFiles.join(", ")}${toolsStr}${errors.length > 0 ? `\nErrors: ${errors.join("; ")}` : ""}`
182
+ : `[Subagent done — no files, ${turns} turns]${toolsStr}${errors.length > 0 ? `\nErrors: ${errors.join("; ")}` : ""}`;
617
183
  return { createdFiles, errors, summary, turns };
618
184
  }
619
- export const subagentDefinition = {
620
- type: 'function',
621
- function: {
622
- name: 'subagent',
623
- description: 'Delegate a task to an isolated agent with a FRESH context (no history of prior errors). Use for: (1) each file when creating 3+ new files, (2) any task where you are stuck or making repeated errors, (3) simple single-step operations (bash, file read/write). The subagent has all tools and starts clean — ideal when accumulated context is causing confusion.',
624
- parameters: {
625
- type: 'object',
626
- properties: {
627
- task: { type: 'string', description: 'What the subagent should do — be specific and include file paths' },
628
- existingFiles: { type: 'string', description: 'Comma-separated files the subagent can read for context (optional)' },
629
- },
630
- required: ['task'],
631
- },
632
- },
633
- };
185
+ import { subagentDefinition } from "../subagent/definition.js";
186
+ export { subagentDefinition };
634
187
  //# sourceMappingURL=subagent.js.map