aboutsummaryrefslogtreecommitdiff
path: root/modules/core
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core')
-rw-r--r--modules/core/core-bundle.js114
-rw-r--r--modules/core/module-aiassist.js112
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-orchestrator.js2
4 files changed, 185 insertions, 45 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 2714dd3..ddfad31 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -8782,7 +8782,7 @@ buildSystemPrompt() {
this.commands['vaultmind-query'] = (args) => this._vaultmindQueryCmd(args);
this.commands['wallet-lookup'] = (args) => this._walletLookupCmd(args);
}
- async chat(args) {
+ async chat(args, opts = {}) {
const userMessage = args.join(' ');
if (!userMessage) {
this.terminal.print('Usage: ai <your-question>', 'warning'); return;
@@ -8883,7 +8883,7 @@ buildSystemPrompt() {
}
} catch (e) { /* world model not available */ }
- loopResult = await this.agenticLoop(messages);
+ loopResult = await this.agenticLoop(messages, opts);
this.context.push({ role: 'user', content: userMessage });
if (loopResult.finalAnswer) {
this.context.push({ role: 'assistant', content: loopResult.finalAnswer });
@@ -9349,7 +9349,7 @@ buildSystemPrompt() {
this._toolFailuresBuffer = {};
} catch (e) { /* ignore */ }
}
- async agenticLoop(messages) {
+ async agenticLoop(messages, opts = {}) {
var profile = this._activeLoopProfile || {};
var loopCfg = profile.loop || {};
var maxTurns = loopCfg.maxTurns || this.MAX_TOOL_TURNS;
@@ -9379,8 +9379,19 @@ buildSystemPrompt() {
}
} catch (e) { /* VFS not available — skip memory-augmented planning */ }
let toolTurns = 0;
- const _seenCalls = new Map();
+ 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 _agentTag = opts.agentTag || 'main';
+ this._pendingBlockedReplays = null;
+ const _sigHash = (s) => {
+ let h = 5381;
+ const str = (typeof s === 'string') ? s : JSON.stringify(s == null ? '' : s);
+ for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
+ return h >>> 0;
+ };
while (toolTurns < maxTurns) {
if (this._cancelled) { this.terminal.print('AI loop cancelled', 'warning'); return { finalAnswer: null, toolTurns }; }
toolTurns++;
@@ -9410,33 +9421,65 @@ buildSystemPrompt() {
return { finalAnswer: text, toolTurns };
}
if (res.tool_calls && res.tool_calls.length > 0) {
- // v4.5.66: Loop detection — break if AI repeats same tool+args 6+ times in 30s
- // Accumulates across turns (no clear) so real loops are caught,
- // but time-windowed so legitimate retries across separate queries don't false-positive
+ // v4.6.30: progress-aware loop guard — counts STAGNATION, not repetition.
+ // A repeated call whose result changed is progress and resets the counter.
const now = Date.now();
- let looping = false;
+ const blocked = [];
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 };
+ entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null };
}
entry.count++;
entry.ts = now;
_seenCalls.set(sig, entry);
- if (entry.count >= (loopDetect.threshold || 6)) {
- this.terminal.print(`🔄 Loop detected: ${tc.function.name} called ${entry.count}x with same args in 30s — breaking loop`, 'warning');
- looping = true;
- break;
+
+ const argPreview = String(tc.function.arguments || '')
+ .replace(/(token|key|secret|password|bearer)[^,}]*/ig, '$1:[redacted]')
+ .slice(0, 60);
+ if (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) {
+ this.terminal.print(`⚠️ Loop warning: ${_agentTag} called ${tc.function.name}(${argPreview}) ${entry.count}x — reuse the previous result or change the request`, 'warning');
}
}
- if (looping) {
- messages.push(res.message);
- messages.push({ role: 'system', content: 'You are repeating the same tool call with identical arguments. Stop calling tools and provide your final answer based on what you already know.' });
- continue;
+ if (blocked.length) {
+ const _blockedIds = new Set(blocked.map(b => b.tc.id));
+ const _remaining = (res.tool_calls || []).filter(tc => !_blockedIds.has(tc.id));
+ if (_remaining.length === 0) {
+ messages.push(res.message);
+ for (const { tc, entry } of blocked) {
+ messages.push({ role: 'tool', tool_call_id: tc.id,
+ content: (entry.lastResult != null) ? entry.lastResult
+ : 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');
+ return { finalAnswer: this._loopSummary(toolTurns), toolTurns };
+ }
+ continue;
+ }
+ // Mixed batch: keep the non-blocked calls, replay blocked ones after the
+ // assistant message is pushed below.
+ res.tool_calls = _remaining;
+ this._pendingBlockedReplays = blocked;
}
this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${maxTurns} (${thinkTime}s)`, 'info');
messages.push(res.message);
+ if (this._pendingBlockedReplays && this._pendingBlockedReplays.length) {
+ for (const { tc, entry } of this._pendingBlockedReplays) {
+ messages.push({ role: 'tool', tool_call_id: tc.id,
+ content: entry.lastResult != null ? entry.lastResult
+ : JSON.stringify({ blocked: true, tool: tc.function.name }) });
+ }
+ this._pendingBlockedReplays = null;
+ }
let pendingText = res.message?.content || null;
const destructiveTools = res.tool_calls.filter(tc => this.isDestructive(tc.function.name, tc.function.arguments));
const safeTools = res.tool_calls.filter(tc => !this.isDestructive(tc.function.name, tc.function.arguments));
@@ -9494,6 +9537,16 @@ buildSystemPrompt() {
this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`);
this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info');
}
+ try {
+ const _sigR = fn.name + ':' + (fn.arguments || '');
+ const _eR = _seenCalls.get(_sigR);
+ if (_eR) {
+ const _rh = _sigHash(toolResult);
+ if (_eR.resultHash !== null && _eR.resultHash !== _rh) _eR.count = 1; // progress → reset
+ _eR.resultHash = _rh;
+ _eR.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
+ }
+ } catch (_e) {}
messages.push({ role: 'tool', content: toolResult });
}
// Only block on destructive tools
@@ -9556,6 +9609,16 @@ buildSystemPrompt() {
this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`);
this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info');
}
+ try {
+ const _sigD = fn.name + ':' + (fn.arguments || '');
+ const _eD = _seenCalls.get(_sigD);
+ if (_eD) {
+ const _rhD = _sigHash(toolResult);
+ if (_eD.resultHash !== null && _eD.resultHash !== _rhD) _eD.count = 1;
+ _eD.resultHash = _rhD;
+ _eD.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
+ }
+ } catch (_e) {}
messages.push({ role: 'tool', content: toolResult });
}
} else {
@@ -9589,11 +9652,18 @@ buildSystemPrompt() {
}
return { finalAnswer: null, toolTurns };
}
- // Loop exited without final answer — auto-summary
+ // Loop exited without final answer — synthesize from what we have instead of returning nothing
const toolNames = Object.keys(this._commandFreq).slice(-3).join(', ');
this.terminal.print(`🤖 AI reached the ${toolTurns}/${maxTurns} tool limit.`, 'warning');
- this.terminal.print('Tools were executed but no final answer was produced — try a more specific query.', 'info');
- return { finalAnswer: null, toolTurns };
+ this.terminal.print('Tools were executed but no final answer was produced — synthesizing from results.', 'info');
+ return { finalAnswer: this._loopSummary(toolTurns), toolTurns };
+ }
+ _loopSummary(toolTurns) {
+ const recent = (this._toolCallLog || []).slice(-5).map(e =>
+ '- ' + e.name + '(' + String(e.args || '').slice(0, 60) + '): ' +
+ (e.error ? 'error' : this._summarizeObservation(e.result, 100))
+ ).join('\n');
+ return 'Stopped after ' + toolTurns + ' tool turns without a final model answer. Last results:\n' + (recent || '(no tool results)');
}
async afterActionReflection(query, toolTurns) {
// Debounce: only suggest skill creation once per 30 minutes
@@ -11882,7 +11952,7 @@ buildSystemPrompt() {
systemPrompt: { base: null, prepend: '', append: '', useDefault: true },
loop: {
strategy: 'standard', maxTurns: this.MAX_TOOL_TURNS,
- loopDetection: { enabled: true, threshold: 6, windowMs: 30000 },
+ loopDetection: { enabled: true, warnAt: 3, blockAt: 6, hardStopAt: 9, windowMs: 30000 },
confidenceEscalation: { enabled: true, threshold: 90 },
finalTurnPrompt: 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.'
},
@@ -15892,7 +15962,7 @@ class OrchestratorModule {
try {
const aiModule = this.terminal.moduleManager.modules['WebX11AIAssistantModule'];
if (aiModule && typeof aiModule.chat === 'function') {
- const result = await aiModule.chat([`[DELEGATED] ${task}`]);
+ const result = await aiModule.chat([`[DELEGATED] ${task}`], { agentTag: agent.id + '/' + (agent.role || 'worker') });
agent.status = 'completed';
// Write result to outbox
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index 193c58d..96f822d 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -759,7 +759,7 @@ buildSystemPrompt() {
this.commands['vaultmind-query'] = (args) => this._vaultmindQueryCmd(args);
this.commands['wallet-lookup'] = (args) => this._walletLookupCmd(args);
}
- async chat(args) {
+ async chat(args, opts = {}) {
const userMessage = args.join(' ');
if (!userMessage) {
this.terminal.print('Usage: ai <your-question>', 'warning'); return;
@@ -860,7 +860,7 @@ buildSystemPrompt() {
}
} catch (e) { /* world model not available */ }
- loopResult = await this.agenticLoop(messages);
+ loopResult = await this.agenticLoop(messages, opts);
this.context.push({ role: 'user', content: userMessage });
if (loopResult.finalAnswer) {
this.context.push({ role: 'assistant', content: loopResult.finalAnswer });
@@ -1326,7 +1326,7 @@ buildSystemPrompt() {
this._toolFailuresBuffer = {};
} catch (e) { /* ignore */ }
}
- async agenticLoop(messages) {
+ async agenticLoop(messages, opts = {}) {
var profile = this._activeLoopProfile || {};
var loopCfg = profile.loop || {};
var maxTurns = loopCfg.maxTurns || this.MAX_TOOL_TURNS;
@@ -1356,8 +1356,19 @@ buildSystemPrompt() {
}
} catch (e) { /* VFS not available — skip memory-augmented planning */ }
let toolTurns = 0;
- const _seenCalls = new Map();
+ 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 _agentTag = opts.agentTag || 'main';
+ this._pendingBlockedReplays = null;
+ const _sigHash = (s) => {
+ let h = 5381;
+ const str = (typeof s === 'string') ? s : JSON.stringify(s == null ? '' : s);
+ for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
+ return h >>> 0;
+ };
while (toolTurns < maxTurns) {
if (this._cancelled) { this.terminal.print('AI loop cancelled', 'warning'); return { finalAnswer: null, toolTurns }; }
toolTurns++;
@@ -1387,33 +1398,65 @@ buildSystemPrompt() {
return { finalAnswer: text, toolTurns };
}
if (res.tool_calls && res.tool_calls.length > 0) {
- // v4.5.66: Loop detection — break if AI repeats same tool+args 6+ times in 30s
- // Accumulates across turns (no clear) so real loops are caught,
- // but time-windowed so legitimate retries across separate queries don't false-positive
+ // v4.6.30: progress-aware loop guard — counts STAGNATION, not repetition.
+ // A repeated call whose result changed is progress and resets the counter.
const now = Date.now();
- let looping = false;
+ const blocked = [];
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 };
+ entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null };
}
entry.count++;
entry.ts = now;
_seenCalls.set(sig, entry);
- if (entry.count >= (loopDetect.threshold || 6)) {
- this.terminal.print(`🔄 Loop detected: ${tc.function.name} called ${entry.count}x with same args in 30s — breaking loop`, 'warning');
- looping = true;
- break;
+
+ const argPreview = String(tc.function.arguments || '')
+ .replace(/(token|key|secret|password|bearer)[^,}]*/ig, '$1:[redacted]')
+ .slice(0, 60);
+ if (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) {
+ this.terminal.print(`⚠️ Loop warning: ${_agentTag} called ${tc.function.name}(${argPreview}) ${entry.count}x — reuse the previous result or change the request`, 'warning');
}
}
- if (looping) {
- messages.push(res.message);
- messages.push({ role: 'system', content: 'You are repeating the same tool call with identical arguments. Stop calling tools and provide your final answer based on what you already know.' });
- continue;
+ if (blocked.length) {
+ const _blockedIds = new Set(blocked.map(b => b.tc.id));
+ const _remaining = (res.tool_calls || []).filter(tc => !_blockedIds.has(tc.id));
+ if (_remaining.length === 0) {
+ messages.push(res.message);
+ for (const { tc, entry } of blocked) {
+ messages.push({ role: 'tool', tool_call_id: tc.id,
+ content: (entry.lastResult != null) ? entry.lastResult
+ : 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');
+ return { finalAnswer: this._loopSummary(toolTurns), toolTurns };
+ }
+ continue;
+ }
+ // Mixed batch: keep the non-blocked calls, replay blocked ones after the
+ // assistant message is pushed below.
+ res.tool_calls = _remaining;
+ this._pendingBlockedReplays = blocked;
}
this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${maxTurns} (${thinkTime}s)`, 'info');
messages.push(res.message);
+ if (this._pendingBlockedReplays && this._pendingBlockedReplays.length) {
+ for (const { tc, entry } of this._pendingBlockedReplays) {
+ messages.push({ role: 'tool', tool_call_id: tc.id,
+ content: entry.lastResult != null ? entry.lastResult
+ : JSON.stringify({ blocked: true, tool: tc.function.name }) });
+ }
+ this._pendingBlockedReplays = null;
+ }
let pendingText = res.message?.content || null;
const destructiveTools = res.tool_calls.filter(tc => this.isDestructive(tc.function.name, tc.function.arguments));
const safeTools = res.tool_calls.filter(tc => !this.isDestructive(tc.function.name, tc.function.arguments));
@@ -1471,6 +1514,16 @@ buildSystemPrompt() {
this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`);
this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info');
}
+ try {
+ const _sigR = fn.name + ':' + (fn.arguments || '');
+ const _eR = _seenCalls.get(_sigR);
+ if (_eR) {
+ const _rh = _sigHash(toolResult);
+ if (_eR.resultHash !== null && _eR.resultHash !== _rh) _eR.count = 1; // progress → reset
+ _eR.resultHash = _rh;
+ _eR.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
+ }
+ } catch (_e) {}
messages.push({ role: 'tool', content: toolResult });
}
// Only block on destructive tools
@@ -1533,6 +1586,16 @@ buildSystemPrompt() {
this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`);
this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info');
}
+ try {
+ const _sigD = fn.name + ':' + (fn.arguments || '');
+ const _eD = _seenCalls.get(_sigD);
+ if (_eD) {
+ const _rhD = _sigHash(toolResult);
+ if (_eD.resultHash !== null && _eD.resultHash !== _rhD) _eD.count = 1;
+ _eD.resultHash = _rhD;
+ _eD.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
+ }
+ } catch (_e) {}
messages.push({ role: 'tool', content: toolResult });
}
} else {
@@ -1566,11 +1629,18 @@ buildSystemPrompt() {
}
return { finalAnswer: null, toolTurns };
}
- // Loop exited without final answer — auto-summary
+ // Loop exited without final answer — synthesize from what we have instead of returning nothing
const toolNames = Object.keys(this._commandFreq).slice(-3).join(', ');
this.terminal.print(`🤖 AI reached the ${toolTurns}/${maxTurns} tool limit.`, 'warning');
- this.terminal.print('Tools were executed but no final answer was produced — try a more specific query.', 'info');
- return { finalAnswer: null, toolTurns };
+ this.terminal.print('Tools were executed but no final answer was produced — synthesizing from results.', 'info');
+ return { finalAnswer: this._loopSummary(toolTurns), toolTurns };
+ }
+ _loopSummary(toolTurns) {
+ const recent = (this._toolCallLog || []).slice(-5).map(e =>
+ '- ' + e.name + '(' + String(e.args || '').slice(0, 60) + '): ' +
+ (e.error ? 'error' : this._summarizeObservation(e.result, 100))
+ ).join('\n');
+ return 'Stopped after ' + toolTurns + ' tool turns without a final model answer. Last results:\n' + (recent || '(no tool results)');
}
async afterActionReflection(query, toolTurns) {
// Debounce: only suggest skill creation once per 30 minutes
@@ -3859,7 +3929,7 @@ buildSystemPrompt() {
systemPrompt: { base: null, prepend: '', append: '', useDefault: true },
loop: {
strategy: 'standard', maxTurns: this.MAX_TOOL_TURNS,
- loopDetection: { enabled: true, threshold: 6, windowMs: 30000 },
+ loopDetection: { enabled: true, warnAt: 3, blockAt: 6, hardStopAt: 9, windowMs: 30000 },
confidenceEscalation: { enabled: true, threshold: 90 },
finalTurnPrompt: 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.'
},
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index acd8ed0..8a2df64 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.28';
+ const LATEST_VER = 'v4.6.29';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js
index 59d9136..b695974 100644
--- a/modules/core/module-orchestrator.js
+++ b/modules/core/module-orchestrator.js
@@ -1150,7 +1150,7 @@ class OrchestratorModule {
try {
const aiModule = this.terminal.moduleManager.modules['WebX11AIAssistantModule'];
if (aiModule && typeof aiModule.chat === 'function') {
- const result = await aiModule.chat([`[DELEGATED] ${task}`]);
+ const result = await aiModule.chat([`[DELEGATED] ${task}`], { agentTag: agent.id + '/' + (agent.role || 'worker') });
agent.status = 'completed';
// Write result to outbox