diff options
Diffstat (limited to 'modules/core/core-bundle.js')
| -rw-r--r-- | modules/core/core-bundle.js | 65 |
1 files changed, 57 insertions, 8 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 3f7e352..4a2416c 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -8112,7 +8112,7 @@ class ServerMonitorModule { // Only deterministic reads are cached — never status/log/polling tools. this._cacheableTools = new Set(['read_file', 'list_files', 'list_modules', 'view_module', 'nvfs_resolve', 'get_help', 'list_skills', 'diff_module', 'get_module_status']); - this.maxContextTokens = 2000; + this.maxContextTokens = 12000; this.MAX_TOOL_TURNS = 8; this._pendingConfirmation = null; this._confirmResolve = null; @@ -8217,6 +8217,12 @@ buildSystemPrompt() { base += '[/Auto-Fix Suggestions]'; } } catch (e) {} + base += '\n\n[File reads, tools, verification]\n' + + '- read_file returns at most 8000 chars. If the reply says truncated=true, call read_file again with offset=<next_offset> before concluding anything about the file.\n' + + '- Prefer targeted reads over whole files: run_command with grep -n "<pattern>" <file>, sed -n \'10,40p\' <file>, head -40 <file>, tail -40 <file>, wc -l <file>.\n' + + '- sed without -i is read-only and allowed; sed -i, rm, mv and writes need user confirmation.\n' + + '- After ANY write or edit: re-read the changed lines, then run a check (test, node scripts/lint-no-undef.py, or a curl) and say in one line what you verified.\n' + + '- If a tool fails twice, change approach (different tool, smaller scope) instead of retrying the same call.'; return base; } getToolDefinitions() { @@ -8249,8 +8255,12 @@ buildSystemPrompt() { type: 'function', function: { name: 'read_file', - description: 'Read VFS file', - parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' } }, required: ['path'] } + description: 'Read a VFS file. Large files come back in pages: if the reply has truncated=true, call again with offset=<next_offset> instead of guessing or giving up. Returns JSON {path, offset, returned, total_chars, truncated, next_offset, content}.', + parameters: { type: 'object', properties: { + path: { type: 'string', description: 'File path' }, + offset: { type: 'integer', description: 'Start character offset (default 0)' }, + limit: { type: 'integer', description: 'Max characters to return (default 8000, max 32000)' } + }, required: ['path'] } } }, { type: 'function', @@ -9001,6 +9011,12 @@ buildSystemPrompt() { if (loopResult.finalAnswer) { sess.context.push({ role: 'assistant', content: loopResult.finalAnswer }); } + // Durable trace of what was read/run this turn (2026-10-21) — without this, + // every later turn started blind and the model re-read the same files. + if (this._turnObservations && this._turnObservations.length) { + sess.context.push({ role: 'assistant', content: '⚙️ tool trace: ' + this._turnObservations.join(' | ') }); + } + this._turnObservations = []; if (sess.context.length > this.maxContext * 2) { sess.context.splice(0, sess.context.length - this.maxContext * 2); } @@ -9363,6 +9379,19 @@ buildSystemPrompt() { } return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)'; } + // 2026-10-21: tool results lived only inside the current turn, so the next turn + // had no idea what the model had already read or run. Keep a compact per-turn + // trace and persist it into the session context (see the turn-end push). + _noteObservation(name, args, result) { + try { + if (!this._turnObservations) this._turnObservations = []; + if (this._turnObservations.length >= 12) return; + const argStr = (typeof args === 'object' && args !== null) ? JSON.stringify(args) : String(args || ''); + const note = (name + '(' + argStr + ') → ' + String(result === undefined || result === null ? '' : result)) + .replace(/\s+/g, ' ').trim(); + this._turnObservations.push(note.slice(0, 240)); + } catch (_e) {} + } _escapeHtml(str) { return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } @@ -9710,6 +9739,7 @@ buildSystemPrompt() { } } catch (_e) {} messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); + this._noteObservation(fn.name, fn.arguments, toolResult); } // Only block on destructive tools if (destructiveTools.length > 0) { @@ -9784,6 +9814,7 @@ buildSystemPrompt() { } } catch (_e) {} messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); + this._noteObservation(fn.name, fn.arguments, toolResult); } } else { messages.push({ @@ -10400,8 +10431,20 @@ buildSystemPrompt() { const path = rp.replace(/^\//, ''); const content = this.terminal.fs.readFile(path); this.terminal.fs.currentPath = savedR; - if (content === null) return JSON.stringify({ error: `File not found: ${path}` }); - return content.length > 8000 ? content.substring(0, 8000) + '\n...(truncated)' : content; + if (content === null || content === undefined) return JSON.stringify({ error: `File not found: ${path}` }); + // Paged reads (2026-10-21): the old 8000-char hard cap made any + // bigger file unreadable — the model could neither finish it nor + // resume. Now the caller pages with offset/limit and gets next_offset. + const total = content.length; + const offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total)); + const limit = Math.max(1000, Math.min(parseInt(a.limit, 10) || 8000, 32000)); + const slice = content.slice(offset, offset + limit); + const nextOffset = offset + slice.length < total ? offset + slice.length : null; + return JSON.stringify({ + path: path, offset: offset, returned: slice.length, + total_chars: total, truncated: nextOffset !== null, + next_offset: nextOffset, content: slice + }); } case 'write_file': { const wp = a.path || ''; @@ -11956,18 +11999,24 @@ buildSystemPrompt() { if (this.context.length <= 6) return; const totalTokens = this._estimateContextTokens(); if (totalTokens < this.maxContextTokens) return; - const keepCount = 3; + const keepCount = 8; const oldMsgs = this.context.slice(0, -keepCount * 2); if (oldMsgs.length < 2) return; const recentMsgs = this.context.slice(-keepCount * 2); + // Tool traces are the only durable record of what was read/run — keep them + // verbatim instead of folding them into the 400-char digest (2026-10-21). const summaryText = oldMsgs.map(m => + /^⚙️ tool trace:/.test(m.content || '') ? (m.content || '').slice(0, 600) : (m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' + - (m.content || '').substring(0, 150) + (m.content || '').substring(0, 400) ).join(' | '); let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`; + // Tool traces must survive compression, so the AI one-sentence summary is only + // used when there are none to lose (2026-10-21). + const _traces = oldMsgs.filter(m => /^⚙️ tool trace:/.test(m.content || '')); // Try AI summarization, fallback to truncation try { - if (this.terminal.ollamaManager && this.terminal.ollamaManager.connected) { + if (!_traces.length && this.terminal.ollamaManager && this.terminal.ollamaManager.connected) { const sumPrompt = 'Summarize these conversation exchanges into a single compact sentence. Focus on what the user asked and what actions were taken. Keep it under 200 characters.\n\n' + JSON.stringify(oldMsgs.map(m => ({ role: m.role, content: (m.content || '').substring(0, 200) }))); const sumRes = await this.terminal.ollamaManager.chat(sumPrompt); if (sumRes && sumRes.length < 300) { |