diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-18 16:25:57 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-18 16:25:57 +0200 |
| commit | c2863016f1708e4638640aac38d4cbadd495dbde (patch) | |
| tree | 58a33ee4bd4b0489c9356010210c6d7edbca6695 /modules/core/core-bundle.js | |
| parent | bc199e350ea4ab000df03c9e732bf3478c9c43b6 (diff) | |
newOS4 v4.6.29: progress-aware loop guard + agent attribution
Diffstat (limited to 'modules/core/core-bundle.js')
| -rw-r--r-- | modules/core/core-bundle.js | 114 |
1 files changed, 92 insertions, 22 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 |