aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-27 16:07:35 +0100
committerAgando <roduccomm@gmail.com>2026-10-27 16:07:35 +0100
commit7e4e30a1790ced2b745a73e9c83e41d6e224bac6 (patch)
tree6ead734b73a533e8974ec868d84079f6a076858a
parent240cbf75c7c3cbf23fc95d6b4e0bf807dcd91360 (diff)
fix(newos4): the loop guard reads the config it documents
MAINT-004. The guard read warnAt/blockAt/hardStopAt while one config path wrote `threshold`, and `enabled` was never consulted anywhere — writing {"enabled": false} into webx11_ai_config changed nothing at all. One resolver, _loopPolicy(), now normalises both spellings, guarantees warnAt <= blockAt < hardStopAt, and both the block and the warning are gated on enabled. Live on v4.6.110 (_loopPolicy called in the page): defaults {warn 3, block 6, hardstop 9}; {threshold: 4} -> {warn 2, block 4, hardstop 7}; {enabled: false, threshold: 3} -> enabled false. test-loop-policy.js: 17 asserts, RED on HEAD ("_loopPolicy is not a function"), suite 16/16.
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js30
-rw-r--r--modules/core/module-aiassist.js30
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/test/test-loop-policy.js74
5 files changed, 126 insertions, 16 deletions
diff --git a/main.html b/main.html
index 83cb081..8048efb 100644
--- a/main.html
+++ b/main.html
@@ -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.109"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.110"></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.109"></script>
+<script src="modules/core/module-loader.js?v=v4.6.110"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.109"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.110"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 6e9542e..8d6cbda 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -9841,6 +9841,23 @@ buildSystemPrompt() {
}
return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)';
}
+ // MAINT-004: the loop guard read warnAt/blockAt/hardStopAt while one config path wrote
+ // `threshold`, and `enabled` was never consulted at all — turning the guard off in
+ // config changed nothing. One resolver, one set of defaults, explicit disable.
+ _loopPolicy(cfg) {
+ const c = cfg || {};
+ const threshold = Number(c.threshold);
+ const blockAt = Number(c.blockAt) || (Number.isFinite(threshold) && threshold > 0 ? threshold : 6);
+ const warnAt = Number(c.warnAt) || Math.max(2, blockAt - 3);
+ const hardStopAt = Number(c.hardStopAt) || blockAt + 3;
+ return {
+ enabled: c.enabled !== false,
+ windowMs: Number(c.windowMs) || 30000,
+ warnAt: Math.min(warnAt, blockAt),
+ blockAt,
+ hardStopAt: Math.max(hardStopAt, blockAt + 1)
+ };
+ }
// MAINT-023: one agentic turn used to grow without limit — every role:'tool' body was
// pushed verbatim and only this.context was trimmed BETWEEN turns, so a measured turn
// went 6 → 112 705 tokens across 8 model calls (maxContextTokens is 12 000). Two
@@ -10043,6 +10060,7 @@ buildSystemPrompt() {
} catch (_e) {}
};
var loopDetect = loopCfg.loopDetection || { enabled: true, threshold: 6, windowMs: 30000 };
+ var _lp = this._loopPolicy(loopDetect);
var confEsc = loopCfg.confidenceEscalation || { enabled: true, threshold: 90 };
var finalPrompt = loopCfg.finalTurnPrompt || 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.';
// Memory-augmented planning: inject prior loop lessons as negative constraints
@@ -10077,10 +10095,10 @@ buildSystemPrompt() {
} catch (e) { /* VFS not available — skip memory-augmented planning */ }
let toolTurns = 0;
const _seenCalls = new Map(); // sig -> {count, ts, resultHash, blocked, lastResult}
- const _loopWindow = loopDetect.windowMs || 30000;
- const _warnAt = loopDetect.warnAt || 3;
- const _blockAt = loopDetect.blockAt || 6;
- const _hardStopAt = loopDetect.hardStopAt || 9;
+ const _loopWindow = _lp.windowMs;
+ const _warnAt = _lp.warnAt;
+ const _blockAt = _lp.blockAt;
+ const _hardStopAt = _lp.hardStopAt;
const _agentTag = opts.agentTag || 'main';
this._pendingBlockedReplays = null;
const _sigHash = (s) => {
@@ -10166,13 +10184,13 @@ buildSystemPrompt() {
const argPreview = String(_argKey || '')
.replace(/(token|key|secret|password|bearer)[^,}]*/ig, '$1:[redacted]')
.slice(0, 60);
- if (entry.blocked || entry.count >= _blockAt) {
+ if (_lp.enabled && (entry.blocked || entry.count >= _blockAt)) {
entry.blocked = true;
blocked.push({ tc, entry });
this.terminal.print(`🔄 Loop guard: ${_agentTag} repeated ${tc.function.name}(${argPreview}) ${entry.count}x with an unchanged result — serving cached result, not re-running`, 'warning');
continue;
}
- if (entry.count === _warnAt) {
+ if (_lp.enabled && entry.count === _warnAt) {
this.terminal.print(`⚠️ Loop warning: ${_agentTag} called ${tc.function.name}(${argPreview}) ${entry.count}x — reuse the previous result or change the request`, 'warning');
}
}
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index 2ee95e9..26e47cb 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -1322,6 +1322,23 @@ buildSystemPrompt() {
}
return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)';
}
+ // MAINT-004: the loop guard read warnAt/blockAt/hardStopAt while one config path wrote
+ // `threshold`, and `enabled` was never consulted at all — turning the guard off in
+ // config changed nothing. One resolver, one set of defaults, explicit disable.
+ _loopPolicy(cfg) {
+ const c = cfg || {};
+ const threshold = Number(c.threshold);
+ const blockAt = Number(c.blockAt) || (Number.isFinite(threshold) && threshold > 0 ? threshold : 6);
+ const warnAt = Number(c.warnAt) || Math.max(2, blockAt - 3);
+ const hardStopAt = Number(c.hardStopAt) || blockAt + 3;
+ return {
+ enabled: c.enabled !== false,
+ windowMs: Number(c.windowMs) || 30000,
+ warnAt: Math.min(warnAt, blockAt),
+ blockAt,
+ hardStopAt: Math.max(hardStopAt, blockAt + 1)
+ };
+ }
// MAINT-023: one agentic turn used to grow without limit — every role:'tool' body was
// pushed verbatim and only this.context was trimmed BETWEEN turns, so a measured turn
// went 6 → 112 705 tokens across 8 model calls (maxContextTokens is 12 000). Two
@@ -1524,6 +1541,7 @@ buildSystemPrompt() {
} catch (_e) {}
};
var loopDetect = loopCfg.loopDetection || { enabled: true, threshold: 6, windowMs: 30000 };
+ var _lp = this._loopPolicy(loopDetect);
var confEsc = loopCfg.confidenceEscalation || { enabled: true, threshold: 90 };
var finalPrompt = loopCfg.finalTurnPrompt || 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.';
// Memory-augmented planning: inject prior loop lessons as negative constraints
@@ -1558,10 +1576,10 @@ buildSystemPrompt() {
} catch (e) { /* VFS not available — skip memory-augmented planning */ }
let toolTurns = 0;
const _seenCalls = new Map(); // sig -> {count, ts, resultHash, blocked, lastResult}
- const _loopWindow = loopDetect.windowMs || 30000;
- const _warnAt = loopDetect.warnAt || 3;
- const _blockAt = loopDetect.blockAt || 6;
- const _hardStopAt = loopDetect.hardStopAt || 9;
+ const _loopWindow = _lp.windowMs;
+ const _warnAt = _lp.warnAt;
+ const _blockAt = _lp.blockAt;
+ const _hardStopAt = _lp.hardStopAt;
const _agentTag = opts.agentTag || 'main';
this._pendingBlockedReplays = null;
const _sigHash = (s) => {
@@ -1647,13 +1665,13 @@ buildSystemPrompt() {
const argPreview = String(_argKey || '')
.replace(/(token|key|secret|password|bearer)[^,}]*/ig, '$1:[redacted]')
.slice(0, 60);
- if (entry.blocked || entry.count >= _blockAt) {
+ if (_lp.enabled && (entry.blocked || entry.count >= _blockAt)) {
entry.blocked = true;
blocked.push({ tc, entry });
this.terminal.print(`🔄 Loop guard: ${_agentTag} repeated ${tc.function.name}(${argPreview}) ${entry.count}x with an unchanged result — serving cached result, not re-running`, 'warning');
continue;
}
- if (entry.count === _warnAt) {
+ if (_lp.enabled && entry.count === _warnAt) {
this.terminal.print(`⚠️ Loop warning: ${_agentTag} called ${tc.function.name}(${argPreview}) ${entry.count}x — reuse the previous result or change the request`, 'warning');
}
}
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index da81184..329d0f7 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.109';
+ const LATEST_VER = 'v4.6.110';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/test/test-loop-policy.js b/modules/test/test-loop-policy.js
new file mode 100644
index 0000000..340e308
--- /dev/null
+++ b/modules/test/test-loop-policy.js
@@ -0,0 +1,74 @@
+// modules/test/test-loop-policy.js — run: node
+// MAINT-004: the loop guard must read the config it documents — `threshold` as an alias, and
+// `enabled: false` must actually switch it off (it was never read at all).
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const store = {};
+global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
+global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
+global.window = global;
+
+const AI = new Function(fsMod.readFileSync(path.join(ROOT, 'module-aiassist.js'), 'utf8') + '\nreturn WebX11AIAssistantModule;')();
+const ai = Object.create(AI.prototype);
+ai.terminal = { print: () => {}, log: () => {} };
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+// A. defaults
+{
+ const p = ai._loopPolicy(undefined);
+ ok('A1 enabled by default', p.enabled === true);
+ ok('A2 default warn/block/hardstop', p.warnAt === 3 && p.blockAt === 6 && p.hardStopAt === 9, JSON.stringify(p));
+ ok('A3 default window', p.windowMs === 30000);
+}
+
+// B. `threshold` is honoured (the old code ignored it entirely)
+{
+ const p = ai._loopPolicy({ threshold: 4 });
+ ok('B1 threshold sets blockAt', p.blockAt === 4, JSON.stringify(p));
+ ok('B2 warn comes before block', p.warnAt < p.blockAt, JSON.stringify(p));
+ ok('B3 hard stop comes after block', p.hardStopAt > p.blockAt, JSON.stringify(p));
+}
+
+// C. explicit values win over the alias
+{
+ const p = ai._loopPolicy({ threshold: 4, blockAt: 8, warnAt: 5, hardStopAt: 12 });
+ ok('C1 blockAt wins over threshold', p.blockAt === 8, JSON.stringify(p));
+ ok('C2 warnAt respected', p.warnAt === 5, JSON.stringify(p));
+ ok('C3 hardStopAt respected', p.hardStopAt === 12, JSON.stringify(p));
+}
+
+// D. `enabled: false` is a real kill switch
+{
+ const p = ai._loopPolicy({ enabled: false, threshold: 3 });
+ ok('D1 disable is reported', p.enabled === false, JSON.stringify(p));
+ ok('D2 disable keeps the numbers for display', p.blockAt === 3, JSON.stringify(p));
+}
+
+// E. nonsense input cannot produce a backwards policy
+{
+ const p = ai._loopPolicy({ warnAt: 9, blockAt: 6, hardStopAt: 2 });
+ ok('E1 warn never exceeds block', p.warnAt <= p.blockAt, JSON.stringify(p));
+ ok('E2 hard stop never equals block', p.hardStopAt > p.blockAt, JSON.stringify(p));
+}
+
+// F. the guard actually consults the policy in the loop source
+{
+ const src = fsMod.readFileSync(path.join(ROOT, 'module-aiassist.js'), 'utf8');
+ ok('F1 the loop uses the resolver', /var _lp = this\._loopPolicy\(loopDetect\)/.test(src));
+ ok('F2 blocking is gated on enabled', /_lp\.enabled && \(entry\.blocked \|\| entry\.count >= _blockAt\)/.test(src));
+ ok('F3 warning is gated on enabled', /_lp\.enabled && entry\.count === _warnAt/.test(src));
+ ok('F4 no direct reads of loopDetect.warnAt/blockAt remain', !/loopDetect\.(warnAt|blockAt|hardStopAt)/.test(src));
+}
+
+if (fails === 0) {
+ console.log('test-loop-policy: ' + passes + ' passed, 0 failed');
+} else {
+ console.log('test-loop-policy: ' + passes + ' passed, ' + fails + ' FAILED');
+ process.exit(1);
+}