From 7fdcab345f0905b49e7368a7908a3f507ee48b24 Mon Sep 17 00:00:00 2001 From: Agando Date: Sun, 18 Oct 2026 18:42:00 +0200 Subject: fix(newos4): stop agent accumulation on reload — stable role ids + boot reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spawnAgent() accepts an idHint and is idempotent; boot team uses agent- - _reanimateFromVFS() adopts newest dir per default role, fs.rm()s duplicates/orphans - _bootAgents() awaits async VFS hydration before reconciling, then spawns only gaps - scripts/test-agents-reload.js: 5-boot regression + hydration-race test - v4.6.44 bundle rebuild + cache-bust --- modules/core/core-bundle.js | 197 ++++++++++++++++++++++++++++++++------------ 1 file changed, 143 insertions(+), 54 deletions(-) (limited to 'modules/core/core-bundle.js') diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index f275a1f..ee2e9d0 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -597,6 +597,7 @@ class VirtualFileSystem { const trash = this.root.content['.trash']; const trashName = ts + '-' + name; trash.content[trashName] = JSON.parse(JSON.stringify(node)); + this._pruneTrash(); const resolved = this._resolvePath(path); if (resolved) delete resolved.dir.content[resolved.name]; this.saveToStorage(); @@ -622,6 +623,7 @@ class VirtualFileSystem { let ctr = 0; while (trash.content[unique]) { unique = baseName + '-' + (++ctr); } trash.content[unique] = JSON.parse(JSON.stringify(node)); + this._pruneTrash(); delete resolved.dir.content[resolved.name]; this.saveToStorage(); this._emitChange(path); @@ -630,6 +632,18 @@ class VirtualFileSystem { } return false; } + _pruneTrash(maxEntries = 50) { + // Bounded trash: deletions are moves-to-trash, so without a cap / .trash/ was an + // unbounded second accumulator. Names start with a millis timestamp → sort = oldest first. + const trash = this.root.content['.trash']; + if (!trash) return 0; + const keys = Object.keys(trash.content); + if (keys.length <= maxEntries) return 0; + keys.sort(); + const drop = keys.slice(0, keys.length - maxEntries); + drop.forEach(k => { delete trash.content[k]; }); + return drop.length; + } deleteDirRecursive(path) { return this.trashDir(path); } @@ -2052,7 +2066,13 @@ class OllamaManager { } return { - message: message ? { role: message.role || 'assistant', content: content } : null, + message: message ? { + role: message.role || 'assistant', + content: content, + // OpenAI-compatible providers require tool_calls INSIDE the assistant message; + // a bare { role:'tool' } with no preceding tool_calls is rejected with HTTP 400. + ...(Array.isArray(toolCalls) && toolCalls.length ? { tool_calls: toolCalls } : {}), + } : null, tool_calls: toolCalls ? toolCalls.map(tc => ({ id: tc.id, type: tc.type || 'function', @@ -8080,7 +8100,7 @@ class ServerMonitorModule { this._toolEpoch = 0; this._mutatingTools = new Set(['write_file', 'edit_module', 'run_command', 'delete_file', 'make_directory', 'commit_module', 'ship_module', 'patch_skill', 'delegate_task', - 'reload_module', 'create_skill']); + 'reload_module', 'create_skill', 'create_mindmap']); // Only deterministic reads are cached — never status/log/polling tools. this._cacheableTools = new Set(['read_file', 'list_files', 'list_modules', 'view_module', 'nvfs_resolve', 'get_help', 'list_skills', 'diff_module', 'get_module_status']); @@ -8891,10 +8911,14 @@ buildSystemPrompt() { } } } catch (e) { console.warn('[AI] Auto-routing error:', e.message); } - // Re-read context from localStorage (auto-restore may have written it after init) - this._loadContext(); - // Compress context if it exceeds the token threshold - this._compressContext(); + // Agent runs must not touch the user's main conversation: _loadContext()/ _compressContext() + // reassign this.context, which would detach the session captured below. + const _isAgentRun = !!opts.agentTag; + if (!_isAgentRun) { + this._loadContext(); + await this._compressContext(); + this._toolCache.clear(); // out-of-band VFS edits may have happened since the last chat + } // Per-agent session: 'main' aliases this.context, agents get their own history. const sess = this._sessionCtx(opts.agentTag); let loopResult; @@ -9018,8 +9042,8 @@ buildSystemPrompt() { } finally { this._chatLock = false; this.terminal.setAiBusy(false); - // Persist conversation context after each exchange - this._saveContext(); + // Persist only the user's conversation — agent sessions are ephemeral. + if (!_isAgentRun) this._saveContext(); // Log the AI interaction this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100)); if (loopResult && loopResult.toolTurns) { @@ -9437,6 +9461,7 @@ buildSystemPrompt() { var baseTurns = maxTurns; let _lastTurnSig = null, _progressStreak = 0, _stallStreak = 0; const _logStart = (this._toolCallLog || []).length; + const _loopLog = []; // per-run tool log (the global one self-trims to 50 and breaks slicing) // D3: extend the budget only while turns keep producing NEW output. const _noteProgress = (results) => { try { @@ -9503,7 +9528,7 @@ buildSystemPrompt() { for (let mi = messages.length - 1; mi >= 0; mi--) { if (messages[mi] && messages[mi]._ledger) messages.splice(mi, 1); } - const _led = (this._toolCallLog || []).slice(_logStart).slice(-8).map(e => + const _led = _loopLog.slice(-8).map(e => '- ' + e.name + '(' + String(typeof e.args === 'string' ? e.args : JSON.stringify(e.args || {})).slice(0, 60) + ') → ' + (e.error ? 'ERROR' : this._summarizeObservation(e.result, 80)) ).join('\n'); @@ -9546,8 +9571,9 @@ 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 = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null }; + if (!entry || (now - entry.ts) > _loopWindow || entry.epoch !== this._toolEpoch) { + // A mutation since the last attempt invalidates the block: re-run instead of replaying. + entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch }; } entry.count++; entry.ts = now; @@ -9577,8 +9603,9 @@ buildSystemPrompt() { : 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'); + const _hs = Math.min(_hardStopAt, maxTurns); + if (blocked.some(b => b.entry.count >= _hs)) { + this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hs} identical calls — ending loop with partial result`, 'error'); return { finalAnswer: this._loopSummary(toolTurns), toolTurns }; } continue; @@ -9626,6 +9653,7 @@ buildSystemPrompt() { duration: t1, error: this._isToolError(toolResult) ? true : false }); + _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]); // Auto-trim to 50 — prevents unbounded memory growth if (this._toolCallLog.length > 50) { this._toolCallLog = this._toolCallLog.slice(-50); @@ -9634,9 +9662,9 @@ buildSystemPrompt() { const sm = this.terminal.moduleManager.modules['ServerMonitorModule']; if (sm && sm.logToolCall) sm.logToolCall(fn.name, (t1 * 1000) || 1, this._isToolError(toolResult)); if (this._debugPanelEnabled) this._updateDebugPanel(); - safeResults.push({ fn, toolResult, t1 }); + safeResults.push({ tc, fn, toolResult, t1 }); } - for (const { fn, toolResult, t1 } of safeResults) { + for (const { tc, fn, toolResult, t1 } of safeResults) { this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); // Memory graph: record tool call + file access try { @@ -9673,7 +9701,7 @@ buildSystemPrompt() { _eR.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); } } catch (_e) {} - messages.push({ role: 'tool', content: toolResult }); + messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); } // Only block on destructive tools if (destructiveTools.length > 0) { @@ -9707,6 +9735,7 @@ buildSystemPrompt() { duration: t1, error: this._isToolError(toolResult) ? true : false }); + _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]); // Auto-trim to 50 — prevents unbounded memory growth if (this._toolCallLog.length > 50) { this._toolCallLog = this._toolCallLog.slice(-50); @@ -9746,7 +9775,7 @@ buildSystemPrompt() { _eD.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); } } catch (_e) {} - messages.push({ role: 'tool', content: toolResult }); + messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult }); } } else { messages.push({ @@ -14768,7 +14797,7 @@ class AgentRegistryModule { unregisterAgent(id) { // Unregister from A2A scratchpad this.a2aUnregister(id); - const ok = this.terminal.fs.deleteFile('agents/' + id); + const ok = this.terminal.fs.rm('agents/' + id); if (ok) this.terminal.print(`✅ Agent '${id}' unregistered`, 'success'); else this.terminal.print(`❌ Agent '${id}' not found`, 'error'); } @@ -14948,6 +14977,10 @@ class OrchestratorModule { this.terminal = null; this._agents = {}; this._SPAWN_LIMIT = 5; + // Boot-time agents — stable per-role ids so a reload re-adopts the existing VFS dir + // instead of minting a new timestamp id every boot (that was the /agents/ accumulation bug). + this._DEFAULT_ROLES = ['auto-learner', 'kernel-architect', 'searcher']; + this._BOOT_DELAY = 2000; // let other modules init before the boot team spawns (tests set 0) this._SPAWN_STRATEGY = 'eager'; this._HEARTBEAT_TIMEOUT = 15000; this._IDLE_TIMEOUT = 120000; @@ -15114,8 +15147,10 @@ class OrchestratorModule { if (registry) { registry.registerAgent(['register', 'orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']); } - // Reanimate sleeping agents from VFS - this._reanimateFromVFS(); + // Reconcile + pre-spawn the default team. VirtualFileSystem.loadFromStorage() is ASYNC, + // so reconciliation MUST wait for it — running it synchronously saw an empty root and + // silently did nothing, which let duplicate agents accumulate on every reload. + this._bootAgents(); // A2A mailbox polling loop (500ms) if (this._A2A_POLLING) { this._a2aPollTimer = this.terminal.moduleManager._setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL); @@ -15127,8 +15162,6 @@ class OrchestratorModule { this.terminal.print('🧠 Orchestrator loaded — A2A mailbox transport active', 'success'); // Load recurring schedules from VFS this._loadSchedules(); - // Pre-spawn default agents after a short delay (let other modules init first) - setTimeout(() => this._preSpawnDefaultAgents(), 2000); // Subscribe to event bus for in-memory A2A delivery (instant, no VFS poll) this._on('a2a:message', (msg) => { if (msg.type === 'heartbeat' && msg.from && this._agents[msg.from]) { @@ -15274,7 +15307,7 @@ class OrchestratorModule { _sanitizeId(name) { return (name || 'worker').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 32) || 'worker'; } - spawnAgent(role, capabilities, modelName = null) { + spawnAgent(role, capabilities, modelName = null, idHint = null) { if (this._spawnLock) { this.terminal.print('⚠️ Spawn in progress — queued', 'warning'); return null; @@ -15282,7 +15315,12 @@ class OrchestratorModule { this._spawnLock = true; try { const safeRole = this._sanitizeId(role); - const id = `agent-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}-${safeRole}`; + const id = idHint || `agent-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}-${safeRole}`; + // Idempotent: a stable-id agent that is already live is reused, never duplicated + if (this._agents[id]) { + this.terminal.print(`♻️ Agent '${id}' already live — reusing`, 'info'); + return this._agents[id]; + } if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { this.terminal.print(`❌ Agent spawn limit (${this._SPAWN_LIMIT}) reached`, 'error'); return null; @@ -15340,8 +15378,10 @@ class OrchestratorModule { registry.a2aUnregister(id); } } - // Clean up VFS - this.terminal.fs.deleteFile('agents/' + id); + // Clean up VFS — rm() dispatches file|dir → trash (deleteFile() is file-only + // and silently returns false for directories, which leaked /agents//) + const removed = this.terminal.fs.rm('agents/' + id); + if (!removed) this.terminal.print(`⚠️ VFS cleanup failed for '${id}' — /agents/${id}/ still present`, 'warning'); delete this._agents[id]; delete this._restartBackoffs[id]; this.terminal.print(`💀 Agent '${id}' killed`, 'warning'); @@ -15379,31 +15419,79 @@ class OrchestratorModule { this.terminal.print(`🔄 Agent '${id}' reanimated from VFS sleep`, 'success'); } + async _bootAgents() { + // Boot sequence for /agents/ — order is load-bearing: + // 1. await VFS hydration (loadFromStorage is async; a sync reconcile sees an empty root) + // 2. reconcile /agents/ (adopt what is already there, purge duplicates/orphans) + // 3. only then spawn any missing default agent + try { + await Promise.resolve(this.terminal.fs._ready); + } catch (e) { + this.terminal.print('⚠️ VFS hydration failed — agent reconciliation skipped', 'warning'); + return this._agents; + } + this._reanimateFromVFS(); + if (this._BOOT_DELAY > 0) await new Promise(r => setTimeout(r, this._BOOT_DELAY)); + this._preSpawnDefaultAgents(); + return this._agents; + } + _reanimateFromVFS() { + // Boot reconciliation for /agents/: + // 1. sleep.json → reanimate the agent (previous behaviour) + // 2. status.json + default role → ADOPT it (a reload left it behind, still wanted) + // 3. no usable state / duplicate → orphan → fs.rm(), so a reload cannot grow /agents/ + // Only `agent-*` dirs are touched; /agents/register/ belongs to AgentRegistry. const fs = this.terminal.fs; const agentsDir = fs.readDir('/agents/'); if (!agentsDir) return; - let reanimated = 0; + + // Stable ids (agent-) beat timestamped ones; among timestamped ids, newest wins. + const tsOf = (id) => { + const m = /^agent-([0-9a-z]+)-[0-9a-z]{4}-/.exec(id); + return m ? (parseInt(m[1], 36) || 0) : Number.MAX_SAFE_INTEGER; + }; + const keep = new Map(); // role: for default agents (singleton) | id: for workers + const orphans = []; + for (const entry of agentsDir) { - if (entry.type !== 'dir') continue; - const sleepData = fs.fsReadJSON(`/agents/${entry.name}/sleep.json`); - if (sleepData) { - const agent = { - ...sleepData, - status: 'idle', - currentTask: null, - leaseId: null, - lastHeartbeat: Date.now() - }; - delete agent._sleepTime; - this._agents[entry.name] = agent; - fs.writeFile(`agents/${entry.name}/status.json`, JSON.stringify({ ...agent, status: 'idle' })); - reanimated++; + if (entry.type !== 'dir' || !entry.name.startsWith('agent-')) continue; + const id = entry.name; + if (this._agents[id]) continue; + const sleepData = fs.fsReadJSON(`/agents/${id}/sleep.json`); + const statusData = fs.fsReadJSON(`/agents/${id}/status.json`); + const state = sleepData || null; + const role = (state || statusData || {}).role; + const wanted = !!state || (statusData && this._DEFAULT_ROLES.includes(role)); + if (!wanted) { orphans.push(id); continue; } + // Default roles are singletons (one dir per role); worker agents stay unique per id. + const key = this._DEFAULT_ROLES.includes(role) ? 'role:' + role : 'id:' + id; + const prev = keep.get(key); + if (!prev || tsOf(id) > tsOf(prev.id)) { + if (prev) orphans.push(prev.id); + keep.set(key, { id, state: state || statusData }); + } else { + orphans.push(id); } } - if (reanimated > 0) { - this.terminal.print(`🔄 Reanimated ${reanimated} agent(s) from VFS sleep`, 'info'); + + for (const { id, state } of keep.values()) { + const agent = { + ...state, + status: 'idle', + currentTask: null, + leaseId: null, + lastHeartbeat: Date.now() + }; + delete agent._sleepTime; + this._agents[id] = agent; + fs.writeFile(`agents/${id}/status.json`, JSON.stringify({ ...agent, status: 'idle' })); + if (fs.readFile(`/agents/${id}/sleep.json`)) fs.rm(`agents/${id}/sleep.json`); } + for (const id of orphans) fs.rm('agents/' + id); // rm() dispatches dir → trashDir + + if (keep.size > 0) this.terminal.print(`🔄 Restored ${keep.size} agent(s) from VFS`, 'info'); + if (orphans.length > 0) this.terminal.print(`🧹 Purged ${orphans.length} orphaned agent dir(s)`, 'warning'); } // ─── Restart Policies ─── @@ -16224,22 +16312,23 @@ class OrchestratorModule { } _preSpawnDefaultAgents() { - // Spawn 3 role-specific agents at boot + // Spawn the default team ONCE per browser profile. Stable ids (`agent-`) mean a + // reload re-adopts the existing dir in _reanimateFromVFS() instead of writing a new one. var roles = [ - { role: 'auto-learner', caps: ['list_files', 'read_file', 'write_file', 'view_module'], task: 'audit skills' }, - { role: 'kernel-architect', caps: ['list_files', 'read_file', 'write_file', 'run_command'], task: 'index VFS' }, - { role: 'searcher', caps: ['list_files', 'read_file', 'web_search'], task: 'check stale data' } + { role: 'auto-learner', caps: ['list_files', 'read_file', 'write_file', 'view_module'] }, + { role: 'kernel-architect', caps: ['list_files', 'read_file', 'write_file', 'run_command'] }, + { role: 'searcher', caps: ['list_files', 'read_file', 'web_search'] } ]; for (var i = 0; i < roles.length; i++) { var r = roles[i]; - // Check if an agent with this role already exists + // Already live (fresh spawn this boot, or adopted from VFS) → just keep it alive var existing = Object.values(this._agents).find(function(a) { return a.role === r.role; }); - if (!existing) { - var agent = this.spawnAgent(r.role, r.caps); - if (agent) { - agent.restartPolicy = 'always'; - this.terminal.print('🤖 Pre-spawned agent: ' + r.role + ' (' + r.caps.length + ' caps)', 'info'); - } + if (existing) { existing.restartPolicy = 'always'; continue; } + var id = 'agent-' + this._sanitizeId(r.role); + var agent = this.spawnAgent(r.role, r.caps, null, id); + if (agent) { + agent.restartPolicy = 'always'; + this.terminal.print('🤖 Pre-spawned agent: ' + r.role + ' (' + r.caps.length + ' caps)', 'info'); } } } -- cgit v1.2.3