aboutsummaryrefslogtreecommitdiff
path: root/modules/core
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-21 22:50:03 +0200
committerAgando <roduccomm@gmail.com>2026-10-21 22:50:03 +0200
commit0c45056c7c51daae9b99c9395b94196537c7d32d (patch)
treebfb65eaf901265743fd0db93a85448e532ea5e54 /modules/core
parent94618145bbcbf467e5e7f28fb81aac77dd1c263c (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
Diffstat (limited to 'modules/core')
-rw-r--r--modules/core/core-bundle.js93
-rw-r--r--modules/core/module-aiassist.js93
-rw-r--r--modules/core/module-loader.js2
3 files changed, 141 insertions, 47 deletions
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) {