diff options
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 65 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 106 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 |
4 files changed, 143 insertions, 36 deletions
@@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.45"></script> +<script src="modules/core/core-bundle.js?v=v4.6.46"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.45"></script> +<script src="modules/core/module-loader.js?v=v4.6.46"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.45"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.46"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ 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) { diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index 95a29a7..90edf73 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -11,11 +11,11 @@ this._toolEpoch = 0; this._mutatingTools = new Set(['write_file', 'edit_module', 'run_command', 'delete_file', 'make_directory', 'commit_module', 'ship_module', 'patch_skill', 'delegate_task', - 'reload_module', 'create_skill']); + 'reload_module', 'create_skill', 'create_mindmap']); // 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; @@ -120,6 +120,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() { @@ -152,8 +158,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', @@ -822,10 +832,14 @@ buildSystemPrompt() { } } } catch (e) { console.warn('[AI] Auto-routing error:', e.message); } - // Re-read context from localStorage (auto-restore may have written it after init) - this._loadContext(); - // Compress context if it exceeds the token threshold - this._compressContext(); + // Agent runs must not touch the user's main conversation: _loadContext()/ _compressContext() + // reassign this.context, which would detach the session captured below. + const _isAgentRun = !!opts.agentTag; + if (!_isAgentRun) { + this._loadContext(); + await this._compressContext(); + this._toolCache.clear(); // out-of-band VFS edits may have happened since the last chat + } // Per-agent session: 'main' aliases this.context, agents get their own history. const sess = this._sessionCtx(opts.agentTag); let loopResult; @@ -900,6 +914,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); } @@ -949,8 +969,8 @@ buildSystemPrompt() { } finally { this._chatLock = false; this.terminal.setAiBusy(false); - // Persist conversation context after each exchange - this._saveContext(); + // Persist only the user's conversation — agent sessions are ephemeral. + if (!_isAgentRun) this._saveContext(); // Log the AI interaction this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100)); if (loopResult && loopResult.toolTurns) { @@ -1262,6 +1282,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, '"'); } @@ -1368,6 +1401,7 @@ buildSystemPrompt() { var baseTurns = maxTurns; let _lastTurnSig = null, _progressStreak = 0, _stallStreak = 0; const _logStart = (this._toolCallLog || []).length; + const _loopLog = []; // per-run tool log (the global one self-trims to 50 and breaks slicing) // D3: extend the budget only while turns keep producing NEW output. const _noteProgress = (results) => { try { @@ -1434,7 +1468,7 @@ buildSystemPrompt() { for (let mi = messages.length - 1; mi >= 0; mi--) { if (messages[mi] && messages[mi]._ledger) messages.splice(mi, 1); } - const _led = (this._toolCallLog || []).slice(_logStart).slice(-8).map(e => + const _led = _loopLog.slice(-8).map(e => '- ' + e.name + '(' + String(typeof e.args === 'string' ? e.args : JSON.stringify(e.args || {})).slice(0, 60) + ') → ' + (e.error ? 'ERROR' : this._summarizeObservation(e.result, 80)) ).join('\n'); @@ -1477,8 +1511,9 @@ buildSystemPrompt() { for (const tc of res.tool_calls) { const sig = tc.function.name + ':' + (tc.function.arguments || ''); let entry = _seenCalls.get(sig); - if (!entry || (now - entry.ts) > _loopWindow) { - entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null }; + if (!entry || (now - entry.ts) > _loopWindow || entry.epoch !== this._toolEpoch) { + // A mutation since the last attempt invalidates the block: re-run instead of replaying. + entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch }; } entry.count++; entry.ts = now; @@ -1508,8 +1543,9 @@ buildSystemPrompt() { : JSON.stringify({ blocked: true, reason: 'identical call repeated with unchanged result', tool: tc.function.name }) }); } this.terminal.print(`🛑 Loop guard: ${blocked.length} repeated call(s) skipped — agent continues with cached data`, 'info'); - if (blocked.some(b => b.entry.count >= _hardStopAt)) { - this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hardStopAt} identical calls — ending loop with partial result`, 'error'); + const _hs = Math.min(_hardStopAt, maxTurns); + if (blocked.some(b => b.entry.count >= _hs)) { + this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hs} identical calls — ending loop with partial result`, 'error'); return { finalAnswer: this._loopSummary(toolTurns), toolTurns }; } continue; @@ -1557,6 +1593,7 @@ buildSystemPrompt() { duration: t1, error: this._isToolError(toolResult) ? true : false }); + _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]); // Auto-trim to 50 — prevents unbounded memory growth if (this._toolCallLog.length > 50) { this._toolCallLog = this._toolCallLog.slice(-50); @@ -1565,9 +1602,9 @@ buildSystemPrompt() { const sm = this.terminal.moduleManager.modules['ServerMonitorModule']; if (sm && sm.logToolCall) sm.logToolCall(fn.name, (t1 * 1000) || 1, this._isToolError(toolResult)); if (this._debugPanelEnabled) this._updateDebugPanel(); - safeResults.push({ fn, toolResult, t1 }); + safeResults.push({ tc, fn, toolResult, t1 }); } - for (const { fn, toolResult, t1 } of safeResults) { + for (const { tc, fn, toolResult, t1 } of safeResults) { this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); // Memory graph: record tool call + file access try { @@ -1604,7 +1641,8 @@ buildSystemPrompt() { _eR.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); } } catch (_e) {} - messages.push({ role: 'tool', content: toolResult }); + 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) { @@ -1638,6 +1676,7 @@ buildSystemPrompt() { duration: t1, error: this._isToolError(toolResult) ? true : false }); + _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]); // Auto-trim to 50 — prevents unbounded memory growth if (this._toolCallLog.length > 50) { this._toolCallLog = this._toolCallLog.slice(-50); @@ -1677,7 +1716,8 @@ buildSystemPrompt() { _eD.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); } } catch (_e) {} - messages.push({ role: 'tool', content: toolResult }); + messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); + this._noteObservation(fn.name, fn.arguments, toolResult); } } else { messages.push({ @@ -2294,8 +2334,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 || ''; @@ -3850,18 +3902,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) { diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 3aedc0d..6c3b48e 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.45'; + const LATEST_VER = 'v4.6.46'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { |