diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-21 22:50:03 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-21 22:50:03 +0200 |
| commit | 128f0f3694ae4ddeb5833c94a7c40ac9e22b4c31 (patch) | |
| tree | e72eeb4c2659dabb323b5b866cf6e2f70487a80c | |
| parent | 6e7418288a09fe2cd811efc53ee32b59768b5dcc (diff) | |
fix(newos4): GLM review follow-ups for AI read/context (v4.6.47)
- loop guard: a mutation unblocks the replay but no longer erases the repeat counter
(identical mutating calls could loop forever)
- tool traces are per agent tag, flushed in a catch as well as on success, ring-buffered
(first 2 + last 10) so verification reads survive
- read_file notes are metadata only (path/range/next_offset); no file bodies, no secrets,
and web_search/web_fetch output is excluded from durable context (injection surface)
- trace is role:'system' (was 'assistant' — the model re-read it as its own answer)
- surrogate-safe pagination, 500 floor / 16000 cap, prompt limits aligned
- prompt now names checks that exist in the in-page terminal (test, grep -n, wc -l)
- compressed summary capped at 4000 chars; denied-destructive tool result carries tool_call_id
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 93 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 93 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 |
4 files changed, 144 insertions, 50 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.46"></script> +<script src="modules/core/core-bundle.js?v=v4.6.47"></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.46"></script> +<script src="modules/core/module-loader.js?v=v4.6.47"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.46"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.47"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 4a2416c..c4bf64e 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -8218,10 +8218,10 @@ buildSystemPrompt() { } } 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' + + '- read_file returns 8000 chars by default (limit up to 16000). 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' + + '- After ANY write or edit: re-read the changed lines (cat or sed -n) and run a check that exists in THIS terminal — `test` for the suite, `grep -n` to confirm the new line, `wc -l` for size. There is no shell, no node and no curl here.\n' + '- If a tool fails twice, change approach (different tool, smaller scope) instead of retrying the same call.'; return base; } @@ -9006,17 +9006,20 @@ buildSystemPrompt() { } } catch (e) { /* world model not available */ } - loopResult = await this.agenticLoop(messages, opts); + const _traceTag = opts && opts.agentTag ? opts.agentTag : 'main'; + try { + loopResult = await this.agenticLoop(messages, opts); + } catch (_loopErr) { + this._flushToolTrace(sess, _traceTag); // never leave stale notes behind + throw _loopErr; + } sess.context.push({ role: 'user', content: userMessage }); 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 = []; + this._flushToolTrace(sess, _traceTag); if (sess.context.length > this.maxContext * 2) { sess.context.splice(0, sess.context.length - this.maxContext * 2); } @@ -9381,15 +9384,43 @@ buildSystemPrompt() { } // 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) { + // trace (per agent tag) and persist it into the session context. + // Review-hardened: no file bodies, no untrusted web output, no secrets. + _noteObservation(name, args, result, tag) { 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)); + if (/^(web_search|web_fetch|scrape|fetch)$/.test(name)) return; // injection surface + const key = tag || 'main'; + if (!this._turnObservations) this._turnObservations = {}; + const buf = this._turnObservations[key] || (this._turnObservations[key] = []); + const clean = s => String(s === undefined || s === null ? '' : s) + .replace(/\s+/g, ' ') + .replace(/(token|key|secret|password|bearer)[^,}\s]*/ig, '$1:[redacted]'); + let argStr = (typeof args === 'object' && args !== null) ? JSON.stringify(args) : String(args || ''); + argStr = clean(argStr).slice(0, 80); + let note; + if (name === 'read_file') { + let p = {}; + try { p = JSON.parse(String(result)) || {}; } catch (e) {} + note = 'read_file ' + (p.path || argStr) + ' [' + (p.offset || 0) + '+' + (p.returned || 0) + + '/' + (p.total_chars === undefined ? '?' : p.total_chars) + + (p.truncated ? ', next=' + p.next_offset : '') + ']'; + } else { + note = name + '(' + argStr + ') → ' + clean(String(result)).slice(0, 120); + } + buf.push(note.slice(0, 240)); + // Ring buffer: keep the first 2 (the goal) and the last 10 (verification). + if (buf.length > 12) buf.splice(2, 1); + } catch (_e) {} + } + // Push this turn's trace into the session context and clear the buffer. + _flushToolTrace(sess, tag) { + try { + const key = tag || 'main'; + const buf = (this._turnObservations && this._turnObservations[key]) || []; + if (buf.length && sess && sess.context) { + sess.context.push({ role: 'system', content: '⚙️ tool trace: ' + buf.join(' | ') }); + } + if (this._turnObservations) delete this._turnObservations[key]; } catch (_e) {} } _escapeHtml(str) { @@ -9608,9 +9639,14 @@ 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.epoch !== this._toolEpoch) { - // A mutation since the last attempt invalidates the block: re-run instead of replaying. + if (!entry || (now - entry.ts) > _loopWindow) { entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch }; + } else if (entry.epoch !== this._toolEpoch) { + // A mutation since the last attempt unblocks the replay, but the + // repeat counter must survive: resetting it let an identical + // mutating call loop forever (review 2026-10-21). + entry.epoch = this._toolEpoch; + entry.blocked = false; } entry.count++; entry.ts = now; @@ -9739,7 +9775,7 @@ buildSystemPrompt() { } } catch (_e) {} messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); - this._noteObservation(fn.name, fn.arguments, toolResult); + this._noteObservation(fn.name, fn.arguments, toolResult, _agentTag); } // Only block on destructive tools if (destructiveTools.length > 0) { @@ -9814,11 +9850,12 @@ buildSystemPrompt() { } } catch (_e) {} messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); - this._noteObservation(fn.name, fn.arguments, toolResult); + this._noteObservation(fn.name, fn.arguments, toolResult, _agentTag); } } else { messages.push({ role: 'tool', + tool_call_id: (destructiveTools[0] && destructiveTools[0].id) || undefined, content: `Destructive actions (${destructiveTools.map(tc => tc.function.name).join(', ')}) denied by user. Non-destructive actions already executed.` }); } @@ -10436,9 +10473,19 @@ buildSystemPrompt() { // 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); + let offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total)); + const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || 8000, 16000)); + // Never split a surrogate pair (emoji/CJK-ext) across pages: + // a lone surrogate makes JSON.stringify emit an invalid body. + if (offset > 0) { + const prev = content.charCodeAt(offset - 1); + if (prev >= 0xD800 && prev <= 0xDBFF) offset += 1; + } + let slice = content.slice(offset, offset + limit); + if (slice.length && slice.charCodeAt(slice.length - 1) >= 0xD800 && + slice.charCodeAt(slice.length - 1) <= 0xDBFF) { + slice = slice.slice(0, -1); + } const nextOffset = offset + slice.length < total ? offset + slice.length : null; return JSON.stringify({ path: path, offset: offset, returned: slice.length, @@ -12010,7 +12057,7 @@ buildSystemPrompt() { (m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' + (m.content || '').substring(0, 400) ).join(' | '); - let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`; + let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText.slice(0, 4000)}]`; // 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 || '')); diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index 90edf73..8d1fc9d 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -121,10 +121,10 @@ buildSystemPrompt() { } } 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' + + '- read_file returns 8000 chars by default (limit up to 16000). 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' + + '- After ANY write or edit: re-read the changed lines (cat or sed -n) and run a check that exists in THIS terminal — `test` for the suite, `grep -n` to confirm the new line, `wc -l` for size. There is no shell, no node and no curl here.\n' + '- If a tool fails twice, change approach (different tool, smaller scope) instead of retrying the same call.'; return base; } @@ -909,17 +909,20 @@ buildSystemPrompt() { } } catch (e) { /* world model not available */ } - loopResult = await this.agenticLoop(messages, opts); + const _traceTag = opts && opts.agentTag ? opts.agentTag : 'main'; + try { + loopResult = await this.agenticLoop(messages, opts); + } catch (_loopErr) { + this._flushToolTrace(sess, _traceTag); // never leave stale notes behind + throw _loopErr; + } sess.context.push({ role: 'user', content: userMessage }); 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 = []; + this._flushToolTrace(sess, _traceTag); if (sess.context.length > this.maxContext * 2) { sess.context.splice(0, sess.context.length - this.maxContext * 2); } @@ -1284,15 +1287,43 @@ buildSystemPrompt() { } // 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) { + // trace (per agent tag) and persist it into the session context. + // Review-hardened: no file bodies, no untrusted web output, no secrets. + _noteObservation(name, args, result, tag) { 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)); + if (/^(web_search|web_fetch|scrape|fetch)$/.test(name)) return; // injection surface + const key = tag || 'main'; + if (!this._turnObservations) this._turnObservations = {}; + const buf = this._turnObservations[key] || (this._turnObservations[key] = []); + const clean = s => String(s === undefined || s === null ? '' : s) + .replace(/\s+/g, ' ') + .replace(/(token|key|secret|password|bearer)[^,}\s]*/ig, '$1:[redacted]'); + let argStr = (typeof args === 'object' && args !== null) ? JSON.stringify(args) : String(args || ''); + argStr = clean(argStr).slice(0, 80); + let note; + if (name === 'read_file') { + let p = {}; + try { p = JSON.parse(String(result)) || {}; } catch (e) {} + note = 'read_file ' + (p.path || argStr) + ' [' + (p.offset || 0) + '+' + (p.returned || 0) + + '/' + (p.total_chars === undefined ? '?' : p.total_chars) + + (p.truncated ? ', next=' + p.next_offset : '') + ']'; + } else { + note = name + '(' + argStr + ') → ' + clean(String(result)).slice(0, 120); + } + buf.push(note.slice(0, 240)); + // Ring buffer: keep the first 2 (the goal) and the last 10 (verification). + if (buf.length > 12) buf.splice(2, 1); + } catch (_e) {} + } + // Push this turn's trace into the session context and clear the buffer. + _flushToolTrace(sess, tag) { + try { + const key = tag || 'main'; + const buf = (this._turnObservations && this._turnObservations[key]) || []; + if (buf.length && sess && sess.context) { + sess.context.push({ role: 'system', content: '⚙️ tool trace: ' + buf.join(' | ') }); + } + if (this._turnObservations) delete this._turnObservations[key]; } catch (_e) {} } _escapeHtml(str) { @@ -1511,9 +1542,14 @@ 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.epoch !== this._toolEpoch) { - // A mutation since the last attempt invalidates the block: re-run instead of replaying. + if (!entry || (now - entry.ts) > _loopWindow) { entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch }; + } else if (entry.epoch !== this._toolEpoch) { + // A mutation since the last attempt unblocks the replay, but the + // repeat counter must survive: resetting it let an identical + // mutating call loop forever (review 2026-10-21). + entry.epoch = this._toolEpoch; + entry.blocked = false; } entry.count++; entry.ts = now; @@ -1642,7 +1678,7 @@ buildSystemPrompt() { } } catch (_e) {} messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); - this._noteObservation(fn.name, fn.arguments, toolResult); + this._noteObservation(fn.name, fn.arguments, toolResult, _agentTag); } // Only block on destructive tools if (destructiveTools.length > 0) { @@ -1717,11 +1753,12 @@ buildSystemPrompt() { } } catch (_e) {} messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); - this._noteObservation(fn.name, fn.arguments, toolResult); + this._noteObservation(fn.name, fn.arguments, toolResult, _agentTag); } } else { messages.push({ role: 'tool', + tool_call_id: (destructiveTools[0] && destructiveTools[0].id) || undefined, content: `Destructive actions (${destructiveTools.map(tc => tc.function.name).join(', ')}) denied by user. Non-destructive actions already executed.` }); } @@ -2339,9 +2376,19 @@ buildSystemPrompt() { // 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); + let offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total)); + const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || 8000, 16000)); + // Never split a surrogate pair (emoji/CJK-ext) across pages: + // a lone surrogate makes JSON.stringify emit an invalid body. + if (offset > 0) { + const prev = content.charCodeAt(offset - 1); + if (prev >= 0xD800 && prev <= 0xDBFF) offset += 1; + } + let slice = content.slice(offset, offset + limit); + if (slice.length && slice.charCodeAt(slice.length - 1) >= 0xD800 && + slice.charCodeAt(slice.length - 1) <= 0xDBFF) { + slice = slice.slice(0, -1); + } const nextOffset = offset + slice.length < total ? offset + slice.length : null; return JSON.stringify({ path: path, offset: offset, returned: slice.length, @@ -3913,7 +3960,7 @@ buildSystemPrompt() { (m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' + (m.content || '').substring(0, 400) ).join(' | '); - let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`; + let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText.slice(0, 4000)}]`; // 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 || '')); diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 6c3b48e..ccd92d8 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.46'; + const LATEST_VER = 'v4.6.47'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { |